mirror of
https://github.com/home-assistant/core.git
synced 2026-03-12 05:51:59 +01:00
Compare commits
1 Commits
state_attr
...
add_window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba8a844e0 |
10
.github/workflows/builder.yml
vendored
10
.github/workflows/builder.yml
vendored
@@ -196,7 +196,7 @@ jobs:
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -406,13 +406,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -609,7 +609,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
@@ -342,7 +342,6 @@ homeassistant.components.lookin.*
|
||||
homeassistant.components.lovelace.*
|
||||
homeassistant.components.luftdaten.*
|
||||
homeassistant.components.lunatone.*
|
||||
homeassistant.components.lutron.*
|
||||
homeassistant.components.madvr.*
|
||||
homeassistant.components.manual.*
|
||||
homeassistant.components.mastodon.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -577,8 +577,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/garages_amsterdam/ @klaasnicolaas
|
||||
/homeassistant/components/gardena_bluetooth/ @elupus
|
||||
/tests/components/gardena_bluetooth/ @elupus
|
||||
/homeassistant/components/gate/ @home-assistant/core
|
||||
/tests/components/gate/ @home-assistant/core
|
||||
/homeassistant/components/gdacs/ @exxamalte
|
||||
/tests/components/gdacs/ @exxamalte
|
||||
/homeassistant/components/generic/ @davet2001
|
||||
@@ -1905,6 +1903,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
/tests/components/window/ @home-assistant/core
|
||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||
/homeassistant/components/withings/ @joostlek
|
||||
/tests/components/withings/ @joostlek
|
||||
|
||||
@@ -243,8 +243,8 @@ DEFAULT_INTEGRATIONS = {
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pyanglianwater.meter import SmartMeter
|
||||
@@ -33,14 +32,13 @@ class AnglianWaterSensor(StrEnum):
|
||||
YESTERDAY_WATER_COST = "yesterday_water_cost"
|
||||
YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost"
|
||||
LATEST_READING = "latest_reading"
|
||||
LAST_UPDATED = "last_updated"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AnglianWaterSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes AnglianWater sensor entity."""
|
||||
|
||||
value_fn: Callable[[SmartMeter], float | datetime | None]
|
||||
value_fn: Callable[[SmartMeter], float]
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
|
||||
@@ -78,13 +76,6 @@ ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
|
||||
translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
AnglianWaterSensorEntityDescription(
|
||||
key=AnglianWaterSensor.LAST_UPDATED,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda entity: entity.last_updated,
|
||||
translation_key=AnglianWaterSensor.LAST_UPDATED,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -121,6 +112,6 @@ class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | datetime | None:
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.smart_meter)
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_updated": {
|
||||
"name": "Last meter reading processed"
|
||||
},
|
||||
"latest_reading": {
|
||||
"name": "Latest reading"
|
||||
},
|
||||
|
||||
@@ -8,11 +8,19 @@ from typing import Any
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
SIGNAL_CLIENT_DATA,
|
||||
SIGNAL_CLIENT_STARTED,
|
||||
SIGNAL_CLIENT_STOPPED,
|
||||
)
|
||||
|
||||
type ArcamFmjConfigEntry = ConfigEntry[Client]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,41 +30,24 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
"""Set up config entry."""
|
||||
client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||
|
||||
coordinators: dict[int, ArcamFmjCoordinator] = {}
|
||||
for zone in (1, 2):
|
||||
coordinator = ArcamFmjCoordinator(hass, entry, client, zone)
|
||||
coordinators[zone] = coordinator
|
||||
|
||||
entry.runtime_data = ArcamFmjRuntimeData(client, coordinators)
|
||||
entry.runtime_data = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
_run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL),
|
||||
"arcam_fmj",
|
||||
hass, _run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL), "arcam_fmj"
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Cleanup before removing config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def _run_client(
|
||||
hass: HomeAssistant,
|
||||
runtime_data: ArcamFmjRuntimeData,
|
||||
interval: float,
|
||||
) -> None:
|
||||
client = runtime_data.client
|
||||
coordinators = runtime_data.coordinators
|
||||
|
||||
async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> None:
|
||||
def _listen(_: Any) -> None:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_data_updated()
|
||||
async_dispatcher_send(hass, SIGNAL_CLIENT_DATA, client.host)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -64,21 +55,16 @@ async def _run_client(
|
||||
await client.start()
|
||||
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
async_dispatcher_send(hass, SIGNAL_CLIENT_STARTED, client.host)
|
||||
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.state.start()
|
||||
|
||||
with client.listen(_listen):
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
await client.process()
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_disconnected()
|
||||
async_dispatcher_send(hass, SIGNAL_CLIENT_STOPPED, client.host)
|
||||
|
||||
except ConnectionFailed:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
DOMAIN = "arcam_fmj"
|
||||
|
||||
SIGNAL_CLIENT_STARTED = "arcam.client_started"
|
||||
SIGNAL_CLIENT_STOPPED = "arcam.client_stopped"
|
||||
SIGNAL_CLIENT_DATA = "arcam.client_data"
|
||||
|
||||
EVENT_TURN_ON = "arcam_fmj.turn_on"
|
||||
|
||||
DEFAULT_PORT = 50000
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Coordinator for Arcam FMJ integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArcamFmjRuntimeData:
|
||||
"""Runtime data for Arcam FMJ integration."""
|
||||
|
||||
client: Client
|
||||
coordinators: dict[int, ArcamFmjCoordinator]
|
||||
|
||||
|
||||
type ArcamFmjConfigEntry = ConfigEntry[ArcamFmjRuntimeData]
|
||||
|
||||
|
||||
class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single Arcam FMJ zone."""
|
||||
|
||||
config_entry: ArcamFmjConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
client: Client,
|
||||
zone: int,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"Arcam FMJ zone {zone}",
|
||||
)
|
||||
self.client = client
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
unique_id_device = unique_id
|
||||
if zone != 1:
|
||||
unique_id_device += f"-{zone}"
|
||||
name += f" Zone {zone}"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id_device)},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=name,
|
||||
)
|
||||
|
||||
if zone != 1:
|
||||
self.device_info["via_device"] = (DOMAIN, unique_id)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
await self.state.update()
|
||||
except ConnectionFailed as err:
|
||||
raise UpdateFailed(
|
||||
f"Connection failed during update for zone {self.state.zn}"
|
||||
) from err
|
||||
|
||||
@callback
|
||||
def async_notify_data_updated(self) -> None:
|
||||
"""Notify that new data has been received from the device."""
|
||||
self.async_set_updated_data(None)
|
||||
|
||||
@callback
|
||||
def async_notify_connected(self) -> None:
|
||||
"""Handle client connected."""
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@callback
|
||||
def async_notify_disconnected(self) -> None:
|
||||
"""Handle client disconnected."""
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from arcam.fmj import ConnectionFailed, SourceCodes
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
@@ -19,13 +20,20 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
from . import ArcamFmjConfigEntry
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_TURN_ON,
|
||||
SIGNAL_CLIENT_DATA,
|
||||
SIGNAL_CLIENT_STARTED,
|
||||
SIGNAL_CLIENT_STOPPED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,17 +44,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the configuration entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
ArcamFmj(
|
||||
config_entry.title,
|
||||
coordinators[zone],
|
||||
State(client, zone),
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
)
|
||||
for zone in (1, 2)
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,21 +77,21 @@ def convert_exception[**_P, _R](
|
||||
return _convert_exception
|
||||
|
||||
|
||||
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
class ArcamFmj(MediaPlayerEntity):
|
||||
"""Representation of a media device."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_name: str,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
state: State,
|
||||
uuid: str,
|
||||
) -> None:
|
||||
"""Initialize device."""
|
||||
super().__init__(coordinator)
|
||||
self._state = coordinator.state
|
||||
self._attr_name = f"Zone {self._state.zn}"
|
||||
self._state = state
|
||||
self._attr_name = f"Zone {state.zn}"
|
||||
self._attr_supported_features = (
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -92,11 +102,18 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.TURN_ON
|
||||
)
|
||||
if self._state.zn == 1:
|
||||
if state.zn == 1:
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
self._attr_unique_id = f"{uuid}-{self._state.zn}"
|
||||
self._attr_entity_registry_enabled_default = self._state.zn == 1
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_unique_id = f"{uuid}-{state.zn}"
|
||||
self._attr_entity_registry_enabled_default = state.zn == 1
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, uuid),
|
||||
},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
@@ -105,6 +122,49 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Once registered, add listener for events."""
|
||||
await self._state.start()
|
||||
try:
|
||||
await self._state.update()
|
||||
except ConnectionFailed as connection:
|
||||
_LOGGER.debug("Connection lost during addition: %s", connection)
|
||||
|
||||
@callback
|
||||
def _data(host: str) -> None:
|
||||
if host == self._state.client.host:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _started(host: str) -> None:
|
||||
if host == self._state.client.host:
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
@callback
|
||||
def _stopped(host: str) -> None:
|
||||
if host == self._state.client.host:
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_DATA, _data)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STARTED, _started)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STOPPED, _stopped)
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Force update of state."""
|
||||
_LOGGER.debug("Update state %s", self.name)
|
||||
try:
|
||||
await self._state.update()
|
||||
except ConnectionFailed as connection:
|
||||
_LOGGER.debug("Connection lost during update: %s", connection)
|
||||
|
||||
@convert_exception
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
|
||||
@@ -137,6 +137,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
@@ -144,10 +145,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"input_boolean",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
@@ -161,6 +160,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"text",
|
||||
"update",
|
||||
"vacuum",
|
||||
"window",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ from homeassistant.helpers import (
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader
|
||||
@@ -79,8 +78,6 @@ from .util import (
|
||||
validate_password_stream,
|
||||
)
|
||||
|
||||
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class NewBackup:
|
||||
@@ -593,49 +590,23 @@ class BackupManager:
|
||||
)
|
||||
agent = self.backup_agents[agent_id]
|
||||
|
||||
latest_uploaded_bytes = 0
|
||||
|
||||
@callback
|
||||
def _emit_upload_progress() -> None:
|
||||
"""Emit the latest upload progress event."""
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=latest_uploaded_bytes,
|
||||
uploaded_bytes=bytes_uploaded,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
|
||||
upload_progress_debouncer: Debouncer[None] = Debouncer(
|
||||
self.hass,
|
||||
LOGGER,
|
||||
cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS,
|
||||
immediate=True,
|
||||
function=_emit_upload_progress,
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
nonlocal latest_uploaded_bytes
|
||||
latest_uploaded_bytes = bytes_uploaded
|
||||
upload_progress_debouncer.async_schedule_call()
|
||||
|
||||
await agent.async_upload_backup(
|
||||
open_stream=open_stream_func,
|
||||
backup=_backup,
|
||||
on_progress=on_upload_progress,
|
||||
)
|
||||
upload_progress_debouncer.async_cancel()
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=_backup.size,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
if streamer:
|
||||
await streamer.wait()
|
||||
|
||||
|
||||
@@ -174,5 +174,13 @@
|
||||
"on": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_bat_low": "{entity_name} battery is low",
|
||||
@@ -317,5 +321,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor"
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor",
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
homeassistant/components/binary_sensor/trigger.py
Normal file
67
homeassistant/components/binary_sensor/trigger.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Provides triggers for binary sensors."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import DOMAIN, BinarySensorDeviceClass
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domains = {DOMAIN}
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
def make_binary_sensor_trigger(
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
to_state: str,
|
||||
) -> type[BinarySensorOnOffTrigger]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(BinarySensorOnOffTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
_to_states = {to_state}
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"occupancy_detected": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
|
||||
),
|
||||
"occupancy_cleared": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for binary sensors."""
|
||||
return TRIGGERS
|
||||
25
homeassistant/components/binary_sensor/triggers.yaml
Normal file
25
homeassistant/components/binary_sensor/triggers.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
occupancy_cleared:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
|
||||
occupancy_detected:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
@@ -37,8 +37,8 @@
|
||||
"name": "Entity"
|
||||
},
|
||||
"speed": {
|
||||
"description": "The fan speed as a percentage.",
|
||||
"name": "Fan speed"
|
||||
"description": "Fan Speed as %.",
|
||||
"name": "Fan Speed"
|
||||
}
|
||||
},
|
||||
"name": "Set fan speed tracked state"
|
||||
@@ -47,7 +47,7 @@
|
||||
"description": "Sets the tracked brightness state of a Bond light.",
|
||||
"fields": {
|
||||
"brightness": {
|
||||
"description": "The tracked brightness of the light.",
|
||||
"description": "Brightness.",
|
||||
"name": "Brightness"
|
||||
},
|
||||
"entity_id": {
|
||||
@@ -79,22 +79,22 @@
|
||||
"name": "Entity"
|
||||
},
|
||||
"power_state": {
|
||||
"description": "The tracked power state.",
|
||||
"description": "Power state.",
|
||||
"name": "Power state"
|
||||
}
|
||||
},
|
||||
"name": "Set switch power tracked state"
|
||||
},
|
||||
"start_decreasing_brightness": {
|
||||
"description": "Starts decreasing the brightness of a light (deprecated).",
|
||||
"description": "Starts decreasing the brightness of the light (deprecated).",
|
||||
"name": "Start decreasing brightness"
|
||||
},
|
||||
"start_increasing_brightness": {
|
||||
"description": "Starts increasing the brightness of a light (deprecated).",
|
||||
"description": "Starts increasing the brightness of the light (deprecated).",
|
||||
"name": "Start increasing brightness"
|
||||
},
|
||||
"stop": {
|
||||
"description": "Stops any in-progress action and empties the queue (deprecated).",
|
||||
"description": "Stops any in-progress action and empty the queue (deprecated).",
|
||||
"name": "[%key:common::action::stop%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ from .const import DOMAIN
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"""EHEIM Digital binary sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
|
||||
BinarySensorEntityDescription
|
||||
):
|
||||
"""Class describing EHEIM Digital binary sensor entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], bool | None]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_lighting",
|
||||
translation_key="is_lighting",
|
||||
value_fn=lambda device: device.is_lighting,
|
||||
device_class=BinarySensorDeviceClass.LIGHT,
|
||||
),
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_uvc_connected",
|
||||
translation_key="is_uvc_connected",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.is_uvc_connected,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the binary sensor entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalBinarySensor[Any]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities += [
|
||||
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], BinarySensorEntity
|
||||
):
|
||||
"""Represent an EHEIM Digital binary sensor entity."""
|
||||
|
||||
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalBinarySensorDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital binary sensor entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_is_on = self.entity_description.value_fn(self._device)
|
||||
@@ -1,19 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"default": "mdi:lightbulb-outline",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"default": "mdi:lightbulb-off",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"day_speed": {
|
||||
"default": "mdi:weather-sunny"
|
||||
|
||||
@@ -33,17 +33,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"name": "UVC lamp connected"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"heater": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -363,12 +363,10 @@ class EvoController(EvoClimateEntity):
|
||||
|
||||
Data validation is not required, it will have been done upstream.
|
||||
"""
|
||||
|
||||
if service == EvoService.RESET_SYSTEM:
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
return
|
||||
|
||||
mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
|
||||
if service == EvoService.SET_SYSTEM_MODE:
|
||||
mode = data[ATTR_MODE]
|
||||
else: # otherwise it is EvoService.RESET_SYSTEM
|
||||
mode = EvoSystemMode.AUTO_WITH_RESET
|
||||
|
||||
if ATTR_PERIOD in data:
|
||||
until = dt_util.start_of_local_day()
|
||||
|
||||
@@ -27,6 +27,7 @@ from .coordinator import EvoDataUpdateCoordinator
|
||||
# because supported modes can vary for edge-case systems
|
||||
|
||||
# Zone service schemas (registered as entity services)
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
@@ -46,7 +47,7 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema=None,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
func="async_clear_zone_override",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
@@ -78,6 +79,7 @@ def setup_service_functions(
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def set_system_mode(call: ServiceCall) -> None:
|
||||
"""Set the system mode."""
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
payload = {
|
||||
"unique_id": coordinator.tcs.id,
|
||||
@@ -89,11 +91,18 @@ def setup_service_functions(
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
|
||||
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
|
||||
|
||||
# Enumerate which operating modes are supported by this system
|
||||
modes = list(coordinator.tcs.allowed_system_modes)
|
||||
|
||||
# Not all systems support "AutoWithReset": register this handler only if required
|
||||
if any(
|
||||
m[SZ_SYSTEM_MODE]
|
||||
for m in modes
|
||||
if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET
|
||||
):
|
||||
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
|
||||
|
||||
system_mode_schemas = []
|
||||
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ def get_model_selection_schema(
|
||||
),
|
||||
vol.Required(
|
||||
CONF_BACKEND,
|
||||
default=options.get(CONF_BACKEND, "s2-pro"),
|
||||
default=options.get(CONF_BACKEND, "s1"),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
|
||||
@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
|
||||
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
|
||||
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
|
||||
LATENCY_OPTIONS = ["normal", "balanced"]
|
||||
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime
|
||||
from gardena_bluetooth.exceptions import CommunicationFailure
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
@@ -29,7 +23,6 @@ from .coordinator import (
|
||||
GardenaBluetoothConfigEntry,
|
||||
GardenaBluetoothCoordinator,
|
||||
)
|
||||
from .util import async_get_product_type
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -58,41 +51,22 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
|
||||
|
||||
|
||||
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
|
||||
try:
|
||||
await client.update_timestamp(characteristics, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
client = Client(get_connection(hass, address))
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
product_type = await async_get_product_type(hass, address)
|
||||
except TimeoutError as exception:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exception
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
try:
|
||||
chars = await client.get_all_characteristics()
|
||||
|
||||
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
|
||||
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
|
||||
model = await client.read_char(DeviceInformation.model_number, None)
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
|
||||
name = await client.read_char(
|
||||
DeviceConfiguration.custom_device_name, entry.title
|
||||
)
|
||||
uuids = await client.get_all_characteristics_uuid()
|
||||
await client.update_timestamp(dt_util.now())
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -109,7 +83,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
hass, entry, LOGGER, client, uuids, device, address
|
||||
)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Valve.connected_state.unique_id,
|
||||
key=Valve.connected_state.uuid,
|
||||
translation_key="valve_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Valve.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Sensor.connected_state.unique_id,
|
||||
key=Sensor.connected_state.uuid,
|
||||
translation_key="sensor_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -60,7 +60,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothBinarySensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
if description.key in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothButtonEntityDescription(
|
||||
key=Reset.factory_reset.unique_id,
|
||||
key=Reset.factory_reset.uuid,
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -49,7 +49,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothButton(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
if description.key in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
|
||||
"requirements": ["gardena-bluetooth==2.1.0"]
|
||||
"requirements": ["gardena-bluetooth==1.6.0"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.manual_watering_time.unique_id,
|
||||
key=Valve.manual_watering_time.uuid,
|
||||
translation_key="manual_watering_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.remaining_open_time.unique_id,
|
||||
key=Valve.remaining_open_time.uuid,
|
||||
translation_key="remaining_open_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=0.0,
|
||||
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.rain_pause.unique_id,
|
||||
key=DeviceConfiguration.rain_pause.uuid,
|
||||
translation_key="rain_pause",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.seasonal_adjust.unique_id,
|
||||
key=DeviceConfiguration.seasonal_adjust.uuid,
|
||||
translation_key="seasonal_adjust",
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Sensor.threshold.unique_id,
|
||||
key=Sensor.threshold.uuid,
|
||||
translation_key="sensor_threshold",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -117,9 +117,9 @@ async def async_setup_entry(
|
||||
entities: list[NumberEntity] = [
|
||||
GardenaBluetoothNumber(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
if description.key in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Valve.activation_reason.unique_id,
|
||||
key=Valve.activation_reason.uuid,
|
||||
translation_key="activation_reason",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
|
||||
char=Valve.activation_reason,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Battery.battery_level.unique_id,
|
||||
key=Battery.battery_level.uuid,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.unique_id,
|
||||
key=Sensor.battery_level.uuid,
|
||||
translation_key="sensor_battery_level",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.value.unique_id,
|
||||
key=Sensor.value.uuid,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.type.unique_id,
|
||||
key=Sensor.type.uuid,
|
||||
translation_key="sensor_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.type,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.measurement_timestamp.unique_id,
|
||||
key=Sensor.measurement_timestamp.uuid,
|
||||
translation_key="sensor_measurement_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -102,9 +102,9 @@ async def async_setup_entry(
|
||||
entities: list[GardenaBluetoothEntity] = [
|
||||
GardenaBluetoothSensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
if description.key in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainSensor(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
"""Representation of a valve switch."""
|
||||
|
||||
characteristics = {
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_translation_key = "state"
|
||||
self._attr_is_on = None
|
||||
self._attr_entity_registry_enabled_default = False
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Utility functions for Gardena Bluetooth integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
|
||||
|
||||
async def _async_service_info(
|
||||
hass, address
|
||||
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
|
||||
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
|
||||
|
||||
def _callback(
|
||||
service_info: bluetooth.BluetoothServiceInfoBleak,
|
||||
change: bluetooth.BluetoothChange,
|
||||
) -> None:
|
||||
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
|
||||
return
|
||||
|
||||
queue.put_nowait(service_info)
|
||||
|
||||
service_info = bluetooth.async_last_service_info(hass, address, True)
|
||||
if service_info:
|
||||
yield service_info
|
||||
|
||||
cancel = bluetooth.async_register_callback(
|
||||
hass,
|
||||
_callback,
|
||||
{bluetooth.match.ADDRESS: address},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
cancel()
|
||||
|
||||
|
||||
async def async_get_product_type(hass, address: str) -> ProductType:
|
||||
"""Wait for enough packets of manufacturer data to get the product type."""
|
||||
data = ManufacturerData()
|
||||
|
||||
async for service_info in _async_service_info(hass, address):
|
||||
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
product_type = ProductType.from_manufacturer_data(data)
|
||||
if product_type is not ProductType.UNKNOWN:
|
||||
return product_type
|
||||
raise AssertionError("Iterator should have been infinite")
|
||||
@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
_attr_device_class = ValveDeviceClass.WATER
|
||||
|
||||
characteristics = {
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:gate"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:gate-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"domain": "gate",
|
||||
"name": "Gate",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gate",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Gate",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more gates close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more gates open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Provides triggers for gates."""
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_closed_trigger,
|
||||
make_cover_opened_trigger,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger
|
||||
|
||||
DEVICE_CLASSES_GATE: dict[str, str] = {
|
||||
COVER_DOMAIN: CoverDeviceClass.GATE,
|
||||
}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for gates."""
|
||||
return TRIGGERS
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioghost"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioghost==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -7,13 +7,7 @@ from collections.abc import Callable, Collection, Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_GROUP_ENTITIES,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -41,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class GroupEntity(Entity):
|
||||
"""Representation of a Group of entities."""
|
||||
|
||||
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES})
|
||||
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID})
|
||||
|
||||
_attr_should_poll = False
|
||||
_entity_ids: list[str]
|
||||
|
||||
@@ -20,6 +20,9 @@ from homeassistant.const import (
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
SERVICE_LOCK,
|
||||
SERVICE_OPEN,
|
||||
SERVICE_UNLOCK,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
@@ -29,7 +32,6 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.group import GenericGroup
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .entity import GroupEntity
|
||||
@@ -115,13 +117,47 @@ class LockGroup(GroupEntity, LockEntity):
|
||||
) -> None:
|
||||
"""Initialize a lock group."""
|
||||
self._entity_ids = entity_ids
|
||||
self.group = GenericGroup(self, entity_ids)
|
||||
self._attr_supported_features = LockEntityFeature.OPEN
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Forward the lock command to all locks in the group."""
|
||||
data = {ATTR_ENTITY_ID: self._entity_ids}
|
||||
_LOGGER.debug("Forwarded lock command: %s", data)
|
||||
|
||||
await self.hass.services.async_call(
|
||||
LOCK_DOMAIN,
|
||||
SERVICE_LOCK,
|
||||
data,
|
||||
blocking=True,
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Forward the unlock command to all locks in the group."""
|
||||
data = {ATTR_ENTITY_ID: self._entity_ids}
|
||||
await self.hass.services.async_call(
|
||||
LOCK_DOMAIN,
|
||||
SERVICE_UNLOCK,
|
||||
data,
|
||||
blocking=True,
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Forward the open command to all locks in the group."""
|
||||
data = {ATTR_ENTITY_ID: self._entity_ids}
|
||||
await self.hass.services.async_call(
|
||||
LOCK_DOMAIN,
|
||||
SERVICE_OPEN,
|
||||
data,
|
||||
blocking=True,
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the lock group state."""
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
"""The Growatt server PV inverter sensor integration.
|
||||
|
||||
This integration supports two distinct Growatt APIs with different auth models:
|
||||
|
||||
Classic API (username/password):
|
||||
- Authenticates via api.login(), which returns a dict with a "success" key.
|
||||
- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE).
|
||||
- A failed login does NOT raise an exception — the return value must be checked.
|
||||
- The coordinator calls api.login() on every update cycle to maintain the session.
|
||||
|
||||
Open API V1 (API token):
|
||||
- Stateless — no login call, token is sent as a Bearer header on every request.
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
|
||||
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
|
||||
any non-zero error_code raises an exception via _process_response().
|
||||
- Because the library always raises on error, return-value validation after a
|
||||
successful V1 API call is unnecessary — if it returned, the token was valid.
|
||||
|
||||
Error handling pattern for reauth:
|
||||
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
|
||||
"""
|
||||
"""The Growatt server PV inverter sensor integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from json import JSONDecodeError
|
||||
@@ -49,7 +25,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .models import GrowattRuntimeData
|
||||
@@ -252,12 +227,8 @@ def get_device_list_v1(
|
||||
try:
|
||||
devices_dict = api.device_list(plant_id)
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
@@ -301,7 +272,6 @@ async def async_setup_entry(
|
||||
# V1 API (token-based, no login needed)
|
||||
token = config[CONF_TOKEN]
|
||||
api = growattServer.OpenApiV1(token=token)
|
||||
api.server_url = url
|
||||
devices, plant_id = await hass.async_add_executor_job(
|
||||
get_device_list_v1, api, config
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Config flow for growatt server integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -32,11 +31,8 @@ from .const import (
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
|
||||
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -64,137 +60,6 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
menu_options=["password_auth", "token_auth"],
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
|
||||
|
||||
if auth_type == AUTH_PASSWORD:
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
api = growattServer.GrowattApi(
|
||||
add_random_user_id=True,
|
||||
agent_identifier=user_input[CONF_USERNAME],
|
||||
)
|
||||
api.server_url = server_url
|
||||
|
||||
try:
|
||||
login_response = await self.hass.async_add_executor_job(
|
||||
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
_LOGGER.debug("Network error during reauth login: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
else:
|
||||
if not isinstance(login_response, dict):
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
elif login_response.get("success"):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_URL: server_url,
|
||||
},
|
||||
)
|
||||
elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
|
||||
elif auth_type == AUTH_API_TOKEN:
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
|
||||
api.server_url = server_url
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(api.plant_list)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
_LOGGER.debug(
|
||||
"Network error during reauth token validation: %s", ex
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Growatt V1 API error during reauth: %s (Code: %s)",
|
||||
err.error_msg or str(err),
|
||||
err.error_code,
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.debug(
|
||||
"Invalid response format during reauth token validation: %s", ex
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_TOKEN: user_input[CONF_TOKEN],
|
||||
CONF_URL: server_url,
|
||||
},
|
||||
)
|
||||
|
||||
# Determine the current region key from the stored config value.
|
||||
# Legacy entries may store the region key directly; newer entries store the URL.
|
||||
stored_url = reauth_entry.data.get(CONF_URL, "")
|
||||
if stored_url in SERVER_URLS_NAMES:
|
||||
current_region = stored_url
|
||||
else:
|
||||
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
|
||||
|
||||
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
|
||||
if auth_type == AUTH_PASSWORD:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USERNAME,
|
||||
default=reauth_entry.data.get(CONF_USERNAME),
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_REGION, default=current_region): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
elif auth_type == AUTH_API_TOKEN:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): str,
|
||||
vol.Required(CONF_REGION, default=current_region): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_password_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -264,11 +129,9 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.error(
|
||||
"Growatt V1 API error: %s (Code: %s)",
|
||||
e.error_msg or str(e),
|
||||
e.error_code,
|
||||
getattr(e, "error_code", None),
|
||||
)
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.error(
|
||||
"Invalid response format during Growatt V1 API plant list: %s", ex
|
||||
|
||||
@@ -40,17 +40,8 @@ DOMAIN = "growatt_server"
|
||||
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
# Growatt Classic API error codes
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
# Growatt Open API V1 error codes
|
||||
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
|
||||
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
|
||||
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
|
||||
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
|
||||
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
|
||||
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
|
||||
|
||||
# Config flow error types (also used as abort reasons)
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
|
||||
ERROR_INVALID_AUTH = "invalid_auth"
|
||||
|
||||
@@ -13,11 +13,7 @@ from homeassistant.components.sensor import SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -27,8 +23,6 @@ from .const import (
|
||||
BATT_MODE_LOAD_FIRST,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
|
||||
@@ -69,7 +63,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
|
||||
self.token = config_entry.data["token"]
|
||||
self.api = growattServer.OpenApiV1(token=self.token)
|
||||
self.api.server_url = self.url
|
||||
elif self.api_version == "classic":
|
||||
self.username = config_entry.data.get(CONF_USERNAME)
|
||||
self.password = config_entry.data[CONF_PASSWORD]
|
||||
@@ -95,14 +88,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# login only required for classic API
|
||||
if self.api_version == "classic":
|
||||
login_response = self.api.login(self.username, self.password)
|
||||
if not login_response.get("success"):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Username, password, or URL may be incorrect"
|
||||
)
|
||||
raise UpdateFailed(f"Growatt login failed: {msg}")
|
||||
self.api.login(self.username, self.password)
|
||||
|
||||
if self.device_type == "total":
|
||||
if self.api_version == "v1":
|
||||
@@ -114,16 +100,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# todayEnergy -> today_energy
|
||||
# totalEnergy -> total_energy
|
||||
# invTodayPpv -> current_power
|
||||
try:
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
f"Error fetching plant energy overview: {err}"
|
||||
) from err
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
total_info["invTodayPpv"] = total_info["current_power"]
|
||||
@@ -145,10 +122,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
|
||||
@@ -30,7 +30,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_plants": "No plants have been found on this account",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"no_plants": "No plants have been found on this account"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
|
||||
@@ -14,7 +13,7 @@
|
||||
"password_auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "Server region",
|
||||
"url": "Server region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"title": "Enter your Growatt login credentials"
|
||||
@@ -25,20 +24,10 @@
|
||||
},
|
||||
"title": "Select your plant"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Re-enter your credentials to continue using this integration.",
|
||||
"title": "Re-authenticate with Growatt"
|
||||
},
|
||||
"token_auth": {
|
||||
"data": {
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "API Token"
|
||||
"token": "API Token",
|
||||
"url": "Server region"
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
|
||||
@@ -38,7 +38,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
|
||||
@@ -19,7 +19,7 @@ from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
|
||||
|
||||
def should_add_option_entity(
|
||||
@@ -48,7 +48,7 @@ def _create_option_entities(
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectEntity],
|
||||
list[HomeConnectOptionEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
@@ -78,7 +78,7 @@ def _handle_paired_or_connected_appliance(
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectEntity],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None,
|
||||
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
|
||||
@@ -161,7 +161,7 @@ def setup_home_connect_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectEntity],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
FanEntityDescription,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FAN_SPEED_MODE_OPTIONS = {
|
||||
"auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningFanEntity(appliance_coordinator)]
|
||||
if appliance_coordinator.data.options
|
||||
and any(
|
||||
option in appliance_coordinator.data.options
|
||||
for option in (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
)
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect fan entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
lambda appliance_coordinator, _: _get_entities_for_appliance(
|
||||
appliance_coordinator
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
"""Representation of a Home Connect fan entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys())
|
||||
self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=(
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
),
|
||||
)
|
||||
self.update_preset_mode()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_preset_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_preset_mode()
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.preset_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_preset_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the speed percentage and speed mode values."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
option_value = event.value
|
||||
self._attr_percentage = (
|
||||
cast(int, option_value) if option_value is not None else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Return the supported features for this fan entity."""
|
||||
features = FanEntityFeature(0)
|
||||
if (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
in self.appliance.options
|
||||
):
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
if (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
in self.appliance.options
|
||||
):
|
||||
features |= FanEntityFeature.PRESET_MODE
|
||||
return features
|
||||
|
||||
def update_preset_mode(self) -> None:
|
||||
"""Set the preset mode value."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
option_value = event.value
|
||||
self._attr_preset_mode = (
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
and (
|
||||
allowed_values_without_none := {
|
||||
value
|
||||
for value in option_constraints.allowed_values
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
and self._original_speed_modes_keys != allowed_values_without_none
|
||||
):
|
||||
self._original_speed_modes_keys = allowed_values_without_none
|
||||
self._attr_preset_modes = [
|
||||
key
|
||||
for key, value in FAN_SPEED_MODE_OPTIONS.items()
|
||||
if value in self._original_speed_modes_keys
|
||||
]
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed percentage option, new state: %s",
|
||||
self.entity_id,
|
||||
percentage,
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and any(
|
||||
option in self.appliance.options
|
||||
for option in (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
)
|
||||
)
|
||||
@@ -136,7 +136,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectEntity]:
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(appliance_coordinator, description)
|
||||
|
||||
@@ -355,7 +355,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectEntity]:
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(appliance_coordinator, desc)
|
||||
|
||||
@@ -119,18 +119,6 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"ambient_light": {
|
||||
"name": "Ambient light"
|
||||
|
||||
@@ -189,7 +189,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectEntity]:
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(appliance_coordinator, description)
|
||||
|
||||
@@ -29,21 +29,21 @@
|
||||
"title": "Humidity",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when the relative humidity changes.",
|
||||
"description": "Triggers when the humidity changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when relative humidity is above this value.",
|
||||
"description": "Only trigger when humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when relative humidity is below this value.",
|
||||
"description": "Only trigger when humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Relative humidity changed"
|
||||
"name": "Humidity changed"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"description": "Triggers when the relative humidity crosses a threshold.",
|
||||
"description": "Triggers when the humidity crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
|
||||
@@ -62,7 +62,7 @@
|
||||
"name": "Upper limit"
|
||||
}
|
||||
},
|
||||
"name": "Relative humidity crossed threshold"
|
||||
"name": "Humidity crossed threshold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
|
||||
@@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
|
||||
# Some mowers only expose the serial number in the manufacturer data
|
||||
# and not the product type, so we allow None here as well.
|
||||
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
|
||||
if product_type not in (ProductType.MOWER, None):
|
||||
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
|
||||
}
|
||||
|
||||
@@ -51,38 +51,6 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the Indevolt device host."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
# Attempt to setup from user input
|
||||
if user_input is not None:
|
||||
errors, device_data = await self._async_validate_input(user_input)
|
||||
|
||||
if not errors and device_data:
|
||||
await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER])
|
||||
self._abort_if_unique_id_mismatch(reason="different_device")
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
**device_data,
|
||||
},
|
||||
)
|
||||
|
||||
# Retrieve user input (prefilled form)
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_validate_input(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||
|
||||
@@ -77,7 +77,8 @@ rules:
|
||||
status: todo
|
||||
icon-translations:
|
||||
status: todo
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues needed for current functionality
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "Failed to connect (aborted)",
|
||||
"different_device": "The device at the new host has a different serial number. Please ensure the new host is the same device.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"cannot_connect": "Failed to connect (aborted)"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -12,16 +10,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::indevolt::config::step::user::data_description::host%]"
|
||||
},
|
||||
"description": "Update the connection details for your Indevolt device.",
|
||||
"title": "Reconfigure Indevolt device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
|
||||
@@ -241,104 +241,6 @@ class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
entry = self._get_reconfigure_entry()
|
||||
if entry.data[CONF_API_VERSION] == API_VERSION_2:
|
||||
return await self.async_step_reconfigure_v2(user_input)
|
||||
return await self.async_step_reconfigure_v1(user_input)
|
||||
|
||||
async def async_step_reconfigure_v1(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of InfluxDB v1."""
|
||||
errors: dict[str, str] = {}
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
url = URL(user_input[CONF_URL])
|
||||
data = {
|
||||
CONF_API_VERSION: DEFAULT_API_VERSION,
|
||||
CONF_HOST: url.host,
|
||||
CONF_PORT: url.port,
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
|
||||
CONF_DB_NAME: user_input[CONF_DB_NAME],
|
||||
CONF_SSL: url.scheme == "https",
|
||||
CONF_PATH: url.path,
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
}
|
||||
if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None:
|
||||
path = await _save_uploaded_cert_file(self.hass, cert)
|
||||
data[CONF_SSL_CA_CERT] = str(path)
|
||||
elif CONF_SSL_CA_CERT in entry.data:
|
||||
data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT]
|
||||
errors = await _validate_influxdb_connection(self.hass, data)
|
||||
|
||||
if not errors:
|
||||
title = f"{data[CONF_DB_NAME]} ({data[CONF_HOST]})"
|
||||
return self.async_update_reload_and_abort(
|
||||
entry, title=title, data_updates=data
|
||||
)
|
||||
|
||||
suggested_values = dict(entry.data) | (user_input or {})
|
||||
if user_input is None:
|
||||
suggested_values[CONF_URL] = str(
|
||||
URL.build(
|
||||
scheme="https" if entry.data.get(CONF_SSL) else "http",
|
||||
host=entry.data.get(CONF_HOST, ""),
|
||||
port=entry.data.get(CONF_PORT),
|
||||
path=entry.data.get(CONF_PATH, ""),
|
||||
)
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_v1",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
INFLUXDB_V1_SCHEMA, suggested_values
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_v2(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of InfluxDB v2."""
|
||||
errors: dict[str, str] = {}
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
data = {
|
||||
CONF_API_VERSION: API_VERSION_2,
|
||||
CONF_URL: user_input[CONF_URL],
|
||||
CONF_TOKEN: user_input[CONF_TOKEN],
|
||||
CONF_ORG: user_input[CONF_ORG],
|
||||
CONF_BUCKET: user_input[CONF_BUCKET],
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
}
|
||||
if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None:
|
||||
path = await _save_uploaded_cert_file(self.hass, cert)
|
||||
data[CONF_SSL_CA_CERT] = str(path)
|
||||
elif CONF_SSL_CA_CERT in entry.data:
|
||||
data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT]
|
||||
errors = await _validate_influxdb_connection(self.hass, data)
|
||||
|
||||
if not errors:
|
||||
title = f"{data[CONF_BUCKET]} ({data[CONF_URL]})"
|
||||
return self.async_update_reload_and_abort(
|
||||
entry, title=title, data_updates=data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_v2",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
INFLUXDB_V2_SCHEMA, entry.data | (user_input or {})
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
import_data = {**import_data}
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
"ssl_ca_cert": "SSL CA certificate (Optional)"
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
@@ -49,39 +46,6 @@
|
||||
"import": {
|
||||
"title": "Import configuration"
|
||||
},
|
||||
"reconfigure_v1": {
|
||||
"data": {
|
||||
"database": "[%key:component::influxdb::config::step::configure_v1::data::database%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"database": "[%key:component::influxdb::config::step::configure_v1::data_description::database%]",
|
||||
"ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v1::data_description::ssl_ca_cert%]"
|
||||
},
|
||||
"description": "Update the connection settings for your InfluxDB v1.x server.",
|
||||
"title": "[%key:component::influxdb::config::step::configure_v1::title%]"
|
||||
},
|
||||
"reconfigure_v2": {
|
||||
"data": {
|
||||
"bucket": "[%key:component::influxdb::config::step::configure_v2::data::bucket%]",
|
||||
"organization": "[%key:component::influxdb::config::step::configure_v2::data::organization%]",
|
||||
"ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]",
|
||||
"token": "[%key:common::config_flow::data::api_token%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"bucket": "[%key:component::influxdb::config::step::configure_v2::data_description::bucket%]",
|
||||
"organization": "[%key:component::influxdb::config::step::configure_v2::data_description::organization%]",
|
||||
"ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v2::data_description::ssl_ca_cert%]"
|
||||
},
|
||||
"description": "Update the connection settings for your InfluxDB v2.x / v3 server.",
|
||||
"title": "[%key:component::influxdb::config::step::configure_v2::title%]"
|
||||
},
|
||||
"user": {
|
||||
"menu_options": {
|
||||
"configure_v1": "InfluxDB v1.x",
|
||||
|
||||
@@ -20,13 +20,5 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:toggle-switch"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"trigger": "mdi:toggle-switch-off"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:toggle-switch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted toggles to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::input_boolean::title%]",
|
||||
@@ -21,15 +17,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads helpers from the YAML-configuration.",
|
||||
@@ -48,27 +35,5 @@
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
"title": "Input boolean",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more toggles turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Toggle turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers after one or more toggles turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Toggle turned on"
|
||||
}
|
||||
}
|
||||
"title": "Input boolean"
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Provides triggers for input booleans."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for input booleans."""
|
||||
return TRIGGERS
|
||||
@@ -1,18 +0,0 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: input_boolean
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
turned_off: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
@@ -6,7 +6,6 @@ import asyncio
|
||||
|
||||
from intellifire4py import UnifiedFireplace
|
||||
from intellifire4py.cloud_interface import IntelliFireCloudInterface
|
||||
from intellifire4py.const import IntelliFireApiMode
|
||||
from intellifire4py.model import IntelliFireCommonFireplaceData
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -21,7 +20,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
API_MODE_LOCAL,
|
||||
CONF_AUTH_COOKIE,
|
||||
CONF_CONTROL_MODE,
|
||||
CONF_READ_MODE,
|
||||
@@ -57,10 +55,8 @@ def _construct_common_data(
|
||||
serial=entry.data[CONF_SERIAL],
|
||||
api_key=entry.data[CONF_API_KEY],
|
||||
ip_address=entry.data[CONF_IP_ADDRESS],
|
||||
read_mode=IntelliFireApiMode(entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)),
|
||||
control_mode=IntelliFireApiMode(
|
||||
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
|
||||
),
|
||||
read_mode=entry.options[CONF_READ_MODE],
|
||||
control_mode=entry.options[CONF_CONTROL_MODE],
|
||||
)
|
||||
|
||||
|
||||
@@ -101,34 +97,12 @@ async def async_migrate_entry(
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data=new,
|
||||
options={
|
||||
CONF_READ_MODE: API_MODE_LOCAL,
|
||||
CONF_CONTROL_MODE: API_MODE_LOCAL,
|
||||
},
|
||||
options={CONF_READ_MODE: "local", CONF_CONTROL_MODE: "local"},
|
||||
unique_id=new[CONF_SERIAL],
|
||||
version=1,
|
||||
minor_version=3,
|
||||
minor_version=2,
|
||||
)
|
||||
LOGGER.debug("Migration to 1.3 successful")
|
||||
|
||||
if config_entry.minor_version < 3:
|
||||
# Migrate old option keys (cloud_read, cloud_control) to new keys
|
||||
old_options = config_entry.options
|
||||
new_options = {
|
||||
CONF_READ_MODE: old_options.get(
|
||||
"cloud_read", old_options.get(CONF_READ_MODE, API_MODE_LOCAL)
|
||||
),
|
||||
CONF_CONTROL_MODE: old_options.get(
|
||||
"cloud_control", old_options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
|
||||
),
|
||||
}
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
options=new_options,
|
||||
version=1,
|
||||
minor_version=3,
|
||||
)
|
||||
LOGGER.debug("Migration to 1.3 successful (options keys renamed)")
|
||||
LOGGER.debug("Pseudo Migration %s successful", config_entry.version)
|
||||
|
||||
return True
|
||||
|
||||
@@ -165,43 +139,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: IntellifireConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
coordinator: IntellifireDataUpdateCoordinator = entry.runtime_data
|
||||
|
||||
new_read_mode = IntelliFireApiMode(
|
||||
entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
|
||||
)
|
||||
new_control_mode = IntelliFireApiMode(
|
||||
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
|
||||
)
|
||||
|
||||
fireplace = coordinator.fireplace
|
||||
current_read_mode = fireplace.read_mode
|
||||
current_control_mode = fireplace.control_mode
|
||||
|
||||
# Only update modes that actually changed
|
||||
if new_read_mode != current_read_mode:
|
||||
LOGGER.debug("Updating read mode: %s -> %s", current_read_mode, new_read_mode)
|
||||
await fireplace.set_read_mode(new_read_mode)
|
||||
|
||||
if new_control_mode != current_control_mode:
|
||||
LOGGER.debug(
|
||||
"Updating control mode: %s -> %s", current_control_mode, new_control_mode
|
||||
)
|
||||
await fireplace.set_control_mode(new_control_mode)
|
||||
|
||||
# Refresh data with new mode settings
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def _async_wait_for_initialization(
|
||||
fireplace: UnifiedFireplace, timeout=STARTUP_TIMEOUT
|
||||
):
|
||||
|
||||
@@ -13,12 +13,7 @@ from intellifire4py.local_api import IntelliFireAPILocal
|
||||
from intellifire4py.model import IntelliFireCommonFireplaceData
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
@@ -26,12 +21,9 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import (
|
||||
API_MODE_CLOUD,
|
||||
API_MODE_LOCAL,
|
||||
CONF_AUTH_COOKIE,
|
||||
CONF_CONTROL_MODE,
|
||||
@@ -42,7 +34,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import IntellifireConfigEntry
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
@@ -79,7 +70,7 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for IntelliFire."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Config Flow Handler."""
|
||||
@@ -269,85 +260,3 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="not_intellifire_device")
|
||||
|
||||
return await self.async_step_cloud_api()
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: IntellifireConfigEntry) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return IntelliFireOptionsFlowHandler()
|
||||
|
||||
|
||||
class IntelliFireOptionsFlowHandler(OptionsFlow):
|
||||
"""Options flow for IntelliFire component."""
|
||||
|
||||
config_entry: IntellifireConfigEntry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Validate connectivity for requested modes if runtime data is available
|
||||
coordinator = self.config_entry.runtime_data
|
||||
if coordinator is not None:
|
||||
fireplace = coordinator.fireplace
|
||||
|
||||
# Refresh connectivity status before validating
|
||||
await fireplace.async_validate_connectivity()
|
||||
|
||||
if (
|
||||
user_input[CONF_READ_MODE] == API_MODE_LOCAL
|
||||
and not fireplace.local_connectivity
|
||||
):
|
||||
errors[CONF_READ_MODE] = "local_unavailable"
|
||||
if (
|
||||
user_input[CONF_READ_MODE] == API_MODE_CLOUD
|
||||
and not fireplace.cloud_connectivity
|
||||
):
|
||||
errors[CONF_READ_MODE] = "cloud_unavailable"
|
||||
if (
|
||||
user_input[CONF_CONTROL_MODE] == API_MODE_LOCAL
|
||||
and not fireplace.local_connectivity
|
||||
):
|
||||
errors[CONF_CONTROL_MODE] = "local_unavailable"
|
||||
if (
|
||||
user_input[CONF_CONTROL_MODE] == API_MODE_CLOUD
|
||||
and not fireplace.cloud_connectivity
|
||||
):
|
||||
errors[CONF_CONTROL_MODE] = "cloud_unavailable"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
existing_read = self.config_entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
|
||||
existing_control = self.config_entry.options.get(
|
||||
CONF_CONTROL_MODE, API_MODE_LOCAL
|
||||
)
|
||||
|
||||
cloud_local_options = selector.SelectSelectorConfig(
|
||||
options=[API_MODE_LOCAL, API_MODE_CLOUD],
|
||||
translation_key="api_mode",
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_READ_MODE,
|
||||
default=user_input.get(CONF_READ_MODE, existing_read)
|
||||
if user_input
|
||||
else existing_read,
|
||||
): selector.SelectSelector(cloud_local_options),
|
||||
vol.Required(
|
||||
CONF_CONTROL_MODE,
|
||||
default=user_input.get(CONF_CONTROL_MODE, existing_control)
|
||||
if user_input
|
||||
else existing_control,
|
||||
): selector.SelectSelector(cloud_local_options),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ CONF_WEB_CLIENT_ID = "web_client_id" # part of the cloud cookie
|
||||
CONF_AUTH_COOKIE = "auth_cookie" # part of the cloud cookie
|
||||
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_READ_MODE = "read_mode"
|
||||
CONF_CONTROL_MODE = "control_mode"
|
||||
CONF_READ_MODE = "cloud_read"
|
||||
CONF_CONTROL_MODE = "cloud_control"
|
||||
|
||||
|
||||
API_MODE_LOCAL = "local"
|
||||
|
||||
@@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import API_MODE_CLOUD, API_MODE_LOCAL
|
||||
from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator
|
||||
from .entity import IntellifireEntity
|
||||
|
||||
@@ -67,22 +66,6 @@ def _uptime_to_timestamp(
|
||||
|
||||
|
||||
INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
|
||||
IntellifireSensorEntityDescription(
|
||||
key="read_mode",
|
||||
translation_key="read_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[API_MODE_LOCAL, API_MODE_CLOUD],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda coordinator: coordinator.fireplace.read_mode.value,
|
||||
),
|
||||
IntellifireSensorEntityDescription(
|
||||
key="control_mode",
|
||||
translation_key="control_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[API_MODE_LOCAL, API_MODE_CLOUD],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda coordinator: coordinator.fireplace.control_mode.value,
|
||||
),
|
||||
IntellifireSensorEntityDescription(
|
||||
key="flame_height",
|
||||
translation_key="flame_height",
|
||||
|
||||
@@ -100,13 +100,6 @@
|
||||
"connection_quality": {
|
||||
"name": "Connection quality"
|
||||
},
|
||||
"control_mode": {
|
||||
"name": "Control mode",
|
||||
"state": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local"
|
||||
}
|
||||
},
|
||||
"downtime": {
|
||||
"name": "Downtime"
|
||||
},
|
||||
@@ -122,13 +115,6 @@
|
||||
"ipv4_address": {
|
||||
"name": "IP address"
|
||||
},
|
||||
"read_mode": {
|
||||
"name": "Read mode",
|
||||
"state": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local"
|
||||
}
|
||||
},
|
||||
"target_temp": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
@@ -147,29 +133,5 @@
|
||||
"name": "Pilot light"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"cloud_unavailable": "Cloud connectivity is not available",
|
||||
"local_unavailable": "Local connectivity is not available"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"control_mode": "Send commands to",
|
||||
"read_mode": "Read data from"
|
||||
},
|
||||
"description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.",
|
||||
"title": "Endpoint selection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"api_mode": {
|
||||
"options": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==2.0.3"]
|
||||
"requirements": ["pyjvcprojector==2.0.2"]
|
||||
}
|
||||
|
||||
@@ -120,19 +120,6 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
|
||||
value_type=config[CONF_TYPE],
|
||||
),
|
||||
)
|
||||
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
|
||||
dpt_info = get_supported_dpts()[dpt_string]
|
||||
|
||||
self._attr_device_class = config.get(
|
||||
CONF_DEVICE_CLASS,
|
||||
try_parse_enum(
|
||||
# sensor device classes should, with some exceptions ("enum" etc.), align with number device classes
|
||||
NumberDeviceClass,
|
||||
dpt_info["sensor_device_class"],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_mode = config[CONF_MODE]
|
||||
self._attr_native_max_value = config.get(
|
||||
NumberConf.MAX,
|
||||
self._device.sensor_value.dpt_class.value_max,
|
||||
@@ -141,16 +128,14 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
|
||||
NumberConf.MIN,
|
||||
self._device.sensor_value.dpt_class.value_min,
|
||||
)
|
||||
self._attr_mode = config[CONF_MODE]
|
||||
self._attr_native_step = config.get(
|
||||
NumberConf.STEP,
|
||||
self._device.sensor_value.dpt_class.resolution,
|
||||
)
|
||||
self._attr_native_unit_of_measurement = config.get(
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
dpt_info["unit"],
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.sensor_value.group_address)
|
||||
|
||||
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
|
||||
self._device.sensor_value.value = max(0, self._attr_native_min_value)
|
||||
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ from homeassistant.components.climate import FAN_OFF, HVACMode
|
||||
from homeassistant.components.cover import (
|
||||
DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA,
|
||||
)
|
||||
from homeassistant.components.number import (
|
||||
DEVICE_CLASSES_SCHEMA as NUMBER_DEVICE_CLASSES_SCHEMA,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.components.number import NumberMode
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
|
||||
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
|
||||
@@ -42,7 +39,6 @@ from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PAYLOAD,
|
||||
CONF_TYPE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
Platform,
|
||||
)
|
||||
@@ -791,8 +787,6 @@ class NumberSchema(KNXPlatformSchema):
|
||||
vol.Optional(NumberConf.MAX): vol.Coerce(float),
|
||||
vol.Optional(NumberConf.MIN): vol.Coerce(float),
|
||||
vol.Optional(NumberConf.STEP): cv.positive_float,
|
||||
vol.Optional(CONF_DEVICE_CLASS): NUMBER_DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
}
|
||||
),
|
||||
@@ -873,7 +867,6 @@ class SensorSchema(KNXPlatformSchema):
|
||||
vol.Required(CONF_TYPE): sensor_type_validator,
|
||||
vol.Required(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
}
|
||||
),
|
||||
|
||||
@@ -216,22 +216,20 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
|
||||
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
|
||||
dpt_info = get_supported_dpts()[dpt_string]
|
||||
|
||||
self._attr_device_class = config.get(
|
||||
CONF_DEVICE_CLASS,
|
||||
dpt_info["sensor_device_class"],
|
||||
if device_class := config.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = device_class
|
||||
else:
|
||||
self._attr_device_class = dpt_info["sensor_device_class"]
|
||||
|
||||
self._attr_state_class = (
|
||||
config.get(CONF_STATE_CLASS) or dpt_info["sensor_state_class"]
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
self._attr_native_unit_of_measurement = dpt_info["unit"]
|
||||
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
|
||||
self._attr_native_unit_of_measurement = config.get(
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
dpt_info["unit"],
|
||||
)
|
||||
self._attr_state_class = config.get(
|
||||
CONF_STATE_CLASS,
|
||||
dpt_info["sensor_state_class"],
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
|
||||
class KnxUiSensor(_KnxSensor, KnxUiEntity):
|
||||
|
||||
@@ -62,9 +62,7 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
registry=dr.async_get(self.hass), config_entry_id=self._entry_id
|
||||
)
|
||||
self._previous_devices: dict[DeviceId, DeviceName] = {
|
||||
DeviceId(
|
||||
next(iter(device.identifiers))[1].removeprefix(f"{self._entry_id}_")
|
||||
): DeviceName(device.name)
|
||||
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
|
||||
for device in device_entries
|
||||
if device.identifiers and device.name
|
||||
}
|
||||
@@ -111,6 +109,11 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
self, detected_devices: dict[DeviceId, DeviceName]
|
||||
) -> None:
|
||||
"""Handle device changes by deleting devices from / adding devices to Home Assistant."""
|
||||
detected_devices = {
|
||||
DeviceId(f"{self.config_entry.entry_id}_{detected_id}"): device_name
|
||||
for detected_id, device_name in detected_devices.items()
|
||||
}
|
||||
|
||||
previous_device_ids = set(self._previous_devices.keys())
|
||||
detected_device_ids = set(detected_devices.keys())
|
||||
|
||||
@@ -128,14 +131,25 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in orphaned_devices:
|
||||
if device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{self._entry_id}_{device_id}")}
|
||||
identifiers={(DOMAIN, device_id)}
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Removing device: %s", self._previous_devices[device_id]
|
||||
)
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self._entry_id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
if self.data is None:
|
||||
# initial update during integration startup
|
||||
self._previous_devices = detected_devices # type: ignore[unreachable]
|
||||
return
|
||||
|
||||
if new_devices := detected_device_ids - previous_device_ids:
|
||||
_LOGGER.warning(
|
||||
"New Device(s) detected, reload integration to add them to Home Assistant: %s",
|
||||
[detected_devices[DeviceId(device_id)] for device_id in new_devices],
|
||||
)
|
||||
|
||||
self._previous_devices = detected_devices
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from librehardwaremonitor_api.model import DeviceId, LibreHardwareMonitorSensorData
|
||||
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
|
||||
from librehardwaremonitor_api.sensor_type import SensorType
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||
@@ -17,8 +16,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from . import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
STATE_MIN_VALUE = "min_value"
|
||||
@@ -33,28 +30,10 @@ async def async_setup_entry(
|
||||
"""Set up the LibreHardwareMonitor platform."""
|
||||
lhm_coordinator = config_entry.runtime_data
|
||||
|
||||
known_devices: set[DeviceId] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(lhm_coordinator.data.main_device_ids_and_names)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
_LOGGER.debug("New Device(s) detected, adding: %s", new_devices)
|
||||
known_devices.update(new_devices)
|
||||
new_devices_sensor_data = [
|
||||
sensor_data
|
||||
for sensor_data in lhm_coordinator.data.sensor_data.values()
|
||||
if sensor_data.device_id in new_devices
|
||||
]
|
||||
async_add_entities(
|
||||
LibreHardwareMonitorSensor(
|
||||
lhm_coordinator, config_entry.entry_id, sensor_data
|
||||
)
|
||||
for sensor_data in new_devices_sensor_data
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(lhm_coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
LibreHardwareMonitorSensor(lhm_coordinator, config_entry.entry_id, sensor_data)
|
||||
for sensor_data in lhm_coordinator.data.sensor_data.values()
|
||||
)
|
||||
|
||||
|
||||
class LibreHardwareMonitorSensor(
|
||||
|
||||
@@ -7,7 +7,7 @@ import csv
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final, override
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -272,18 +272,6 @@ def filter_turn_off_params(
|
||||
return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)}
|
||||
|
||||
|
||||
def process_turn_off_params(
|
||||
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Process light turn off params."""
|
||||
params = dict(params)
|
||||
|
||||
if ATTR_TRANSITION not in params:
|
||||
hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params)
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter out params not supported by the light."""
|
||||
supported_features = light.supported_features
|
||||
@@ -318,171 +306,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
|
||||
return params
|
||||
|
||||
|
||||
def process_turn_on_params( # noqa: C901
|
||||
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Process light turn on params."""
|
||||
params = dict(params)
|
||||
|
||||
# Only process params once we processed brightness step
|
||||
if params and (
|
||||
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
|
||||
):
|
||||
brightness = light.brightness if light.is_on and light.brightness else 0
|
||||
|
||||
if ATTR_BRIGHTNESS_STEP in params:
|
||||
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
|
||||
|
||||
else:
|
||||
brightness_pct = round(brightness / 255 * 100)
|
||||
brightness = round(
|
||||
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
|
||||
)
|
||||
|
||||
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
|
||||
|
||||
preprocess_turn_on_alternatives(hass, params)
|
||||
|
||||
if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params):
|
||||
hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params)
|
||||
|
||||
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
|
||||
|
||||
# If a color temperature is specified, emulate it if not supported by the light
|
||||
if ATTR_COLOR_TEMP_KELVIN in params:
|
||||
if (
|
||||
ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp)
|
||||
|
||||
# If a color is specified, convert to the color space supported by the light
|
||||
rgb_color: tuple[int, int, int] | None
|
||||
rgbww_color: tuple[int, int, int, int, int] | None
|
||||
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
|
||||
hs_color = params.pop(ATTR_HS_COLOR)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgb_color = cast(tuple[int, int, int], rgb_color)
|
||||
if ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
|
||||
elif ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes:
|
||||
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
|
||||
assert rgbww_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
|
||||
rgb_color = color_util.color_rgbww_to_rgb(
|
||||
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
# integer.
|
||||
if params.get(ATTR_WHITE) is True:
|
||||
params[ATTR_WHITE] = light.brightness
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
return params
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
|
||||
"""Expose light control via state machine and services."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
@@ -506,15 +330,177 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
base["params"] = data
|
||||
return base
|
||||
|
||||
async def async_handle_light_on_service(
|
||||
async def async_handle_light_on_service( # noqa: C901
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle turning a light on.
|
||||
|
||||
If brightness is set to 0, this service will turn the light off.
|
||||
"""
|
||||
params = process_turn_on_params(hass, light, call.data["params"])
|
||||
params: dict[str, Any] = dict(call.data["params"])
|
||||
|
||||
# Only process params once we processed brightness step
|
||||
if params and (
|
||||
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
|
||||
):
|
||||
brightness = light.brightness if light.is_on and light.brightness else 0
|
||||
|
||||
if ATTR_BRIGHTNESS_STEP in params:
|
||||
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
|
||||
|
||||
else:
|
||||
brightness_pct = round(brightness / 255 * 100)
|
||||
brightness = round(
|
||||
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
|
||||
)
|
||||
|
||||
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
|
||||
|
||||
preprocess_turn_on_alternatives(hass, params)
|
||||
|
||||
if (not params or not light.is_on) or (
|
||||
params and ATTR_TRANSITION not in params
|
||||
):
|
||||
profiles.apply_default(light.entity_id, light.is_on, params)
|
||||
|
||||
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
|
||||
|
||||
# If a color temperature is specified, emulate it if not supported by the light
|
||||
if ATTR_COLOR_TEMP_KELVIN in params:
|
||||
if (
|
||||
ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
|
||||
color_temp
|
||||
)
|
||||
|
||||
# If a color is specified, convert to the color space supported by the light
|
||||
rgb_color: tuple[int, int, int] | None
|
||||
rgbww_color: tuple[int, int, int, int, int] | None
|
||||
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
|
||||
hs_color = params.pop(ATTR_HS_COLOR)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgb_color = cast(tuple[int, int, int], rgb_color)
|
||||
if ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
|
||||
elif ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif (
|
||||
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
|
||||
):
|
||||
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
|
||||
assert rgbww_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
|
||||
rgb_color = color_util.color_rgbww_to_rgb(
|
||||
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
# integer.
|
||||
if params.get(ATTR_WHITE) is True:
|
||||
params[ATTR_WHITE] = light.brightness
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
# Remove deprecated white value if the light supports color mode
|
||||
if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0:
|
||||
await async_handle_light_off_service(light, call)
|
||||
else:
|
||||
@@ -524,7 +510,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle turning off a light."""
|
||||
params = process_turn_off_params(hass, light, call.data["params"])
|
||||
params = dict(call.data["params"])
|
||||
|
||||
if ATTR_TRANSITION not in params:
|
||||
profiles.apply_default(light.entity_id, True, params)
|
||||
|
||||
await light.async_turn_off(**filter_turn_off_params(light, params))
|
||||
|
||||
@@ -532,7 +521,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle toggling a light."""
|
||||
await light.async_toggle(**call.data["params"])
|
||||
if light.is_on:
|
||||
await async_handle_light_off_service(light, call)
|
||||
else:
|
||||
await async_handle_light_on_service(light, call)
|
||||
|
||||
# Listen for light on and light off service calls.
|
||||
|
||||
@@ -1054,15 +1046,3 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def supported_features(self) -> LightEntityFeature:
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@override
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Toggle the entity."""
|
||||
if not self.is_on:
|
||||
params = process_turn_on_params(self.hass, self, kwargs)
|
||||
if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0:
|
||||
await self.async_turn_on(**filter_turn_on_params(self, params))
|
||||
return
|
||||
|
||||
params = process_turn_off_params(self.hass, self, kwargs)
|
||||
await self.async_turn_off(**filter_turn_off_params(self, params))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output
|
||||
|
||||
@@ -43,7 +42,7 @@ class LutronData:
|
||||
covers: list[tuple[str, Output]]
|
||||
fans: list[tuple[str, Output]]
|
||||
lights: list[tuple[str, Output]]
|
||||
scenes: list[tuple[str, Keypad, Button, Led | None]]
|
||||
scenes: list[tuple[str, Keypad, Button, Led]]
|
||||
switches: list[tuple[str, Output]]
|
||||
|
||||
|
||||
@@ -111,14 +110,6 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for keypad in area.keypads:
|
||||
_async_check_keypad_identifiers(
|
||||
hass,
|
||||
device_registry,
|
||||
keypad.id,
|
||||
keypad.uuid,
|
||||
keypad.legacy_uuid,
|
||||
entry_data.client.guid,
|
||||
)
|
||||
for button in keypad.buttons:
|
||||
# If the button has a function assigned to it, add it as a scene
|
||||
if button.name != "Unknown Button" and button.button_type in (
|
||||
@@ -235,36 +226,6 @@ def _async_check_device_identifiers(
|
||||
)
|
||||
|
||||
|
||||
def _async_check_keypad_identifiers(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
keypad_id: int,
|
||||
uuid: str,
|
||||
legacy_uuid: str,
|
||||
controller_guid: str,
|
||||
) -> None:
|
||||
"""Migrate from integer based keypad.ids to proper uuids."""
|
||||
|
||||
# First check for the very old integer-based ID
|
||||
# We use cast(Any, ...) here because legacy devices may have integer identifiers
|
||||
# in the registry, but modern Home Assistant expects strings.
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, cast(Any, keypad_id))}
|
||||
)
|
||||
if device:
|
||||
new_unique_id = f"{controller_guid}_{uuid or legacy_uuid}"
|
||||
_LOGGER.debug("Updating keypad id from %d to %s", keypad_id, new_unique_id)
|
||||
device_registry.async_update_device(
|
||||
device.id, new_identifiers={(DOMAIN, new_unique_id)}
|
||||
)
|
||||
return
|
||||
|
||||
# Now handle legacy_uuid to uuid migration if needed
|
||||
_async_check_device_identifiers(
|
||||
hass, device_registry, uuid, legacy_uuid, controller_guid
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool:
|
||||
"""Clean up resources and entities associated with the integration."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -37,12 +37,11 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
ip_address = user_input[CONF_HOST]
|
||||
guid: str | None = None
|
||||
|
||||
main_repeater = Lutron(
|
||||
ip_address,
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
user_input.get(CONF_USERNAME),
|
||||
user_input.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -56,11 +55,10 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
guid = main_repeater.guid
|
||||
|
||||
if guid is None or len(guid) <= 10:
|
||||
if len(guid) <= 10:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
assert guid is not None
|
||||
await self.async_set_unique_id(guid)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class LutronCover(LutronDevice, CoverEntity):
|
||||
"""Update the state attributes."""
|
||||
level = self._lutron_device.last_level()
|
||||
self._attr_is_closed = level < 1
|
||||
self._attr_current_cover_position = int(level)
|
||||
self._attr_current_cover_position = level
|
||||
_LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level)
|
||||
|
||||
@property
|
||||
|
||||
@@ -43,8 +43,10 @@ class LutronBaseEntity(Entity):
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
device_uuid = self._lutron_device.uuid or self._lutron_device.legacy_uuid
|
||||
return f"{self._controller.guid}_{device_uuid}"
|
||||
|
||||
if self._lutron_device.uuid is None:
|
||||
return f"{self._controller.guid}_{self._lutron_device.legacy_uuid}"
|
||||
return f"{self._controller.guid}_{self._lutron_device.uuid}"
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update the entity's state."""
|
||||
@@ -81,9 +83,8 @@ class LutronKeypad(LutronBaseEntity):
|
||||
) -> None:
|
||||
"""Initialize the device."""
|
||||
super().__init__(area_name, lutron_device, controller)
|
||||
device_uuid = keypad.uuid or keypad.legacy_uuid
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{controller.guid}_{device_uuid}")},
|
||||
identifiers={(DOMAIN, keypad.id)},
|
||||
manufacturer="Lutron",
|
||||
name=keypad.name,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Support for Lutron events."""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import cast
|
||||
|
||||
from pylutron import Button, Keypad, Lutron, LutronEntity, LutronEvent
|
||||
from pylutron import Button, Keypad, Lutron, LutronEvent
|
||||
|
||||
from homeassistant.components.event import EventEntity
|
||||
from homeassistant.const import ATTR_ID
|
||||
@@ -79,10 +78,9 @@ class LutronEventEntity(LutronKeypad, EventEntity):
|
||||
|
||||
@callback
|
||||
def handle_event(
|
||||
self, button: LutronEntity, _context: None, event: LutronEvent, _params: dict
|
||||
self, button: Button, _context: None, event: LutronEvent, _params: dict
|
||||
) -> None:
|
||||
"""Handle received event."""
|
||||
button = cast(Button, button)
|
||||
action: LutronEventType | None = None
|
||||
if self._has_release_event:
|
||||
if event == Button.Event.PRESSED:
|
||||
|
||||
@@ -83,7 +83,7 @@ class LutronFan(LutronDevice, FanEntity):
|
||||
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update the state attributes."""
|
||||
level = int(self._lutron_device.last_level())
|
||||
level = self._lutron_device.last_level()
|
||||
self._attr_is_on = level > 0
|
||||
self._attr_percentage = level
|
||||
if self._prev_percentage is None or level != 0:
|
||||
|
||||
@@ -45,12 +45,12 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def to_lutron_level(level: int) -> float:
|
||||
def to_lutron_level(level):
|
||||
"""Convert the given Home Assistant light level (0-255) to Lutron (0.0-100.0)."""
|
||||
return float((level * 100) / 255)
|
||||
|
||||
|
||||
def to_hass_level(level: float) -> int:
|
||||
def to_hass_level(level):
|
||||
"""Convert the given Lutron (0.0-100.0) light level to Home Assistant (0-255)."""
|
||||
return int((level * 255) / 100)
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.3.0"],
|
||||
"requirements": ["pylutron==0.2.18"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@ class LutronLed(LutronKeypad, SwitchEntity):
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the LED on."""
|
||||
self._lutron_device.state = True
|
||||
self._lutron_device.state = 1
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the LED off."""
|
||||
self._lutron_device.state = False
|
||||
self._lutron_device.state = 0
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
|
||||
@@ -124,7 +124,6 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
# support fan-only mode.
|
||||
(0x0001, 0x0108),
|
||||
(0x0001, 0x010A),
|
||||
(0x118C, 0x2022),
|
||||
(0x1209, 0x8000),
|
||||
(0x1209, 0x8001),
|
||||
(0x1209, 0x8002),
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aionut import AIONUTClient, NUTError
|
||||
from aionut import AIONUTClient, NUTError, NUTLoginError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ALIAS,
|
||||
CONF_HOST,
|
||||
@@ -19,17 +21,29 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS
|
||||
from .coordinator import NutConfigEntry, NutCoordinator, NutRuntimeData
|
||||
|
||||
NUT_FAKE_SERIAL = ["unknown", "blank"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type NutConfigEntry = ConfigEntry[NutRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutRuntimeData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
coordinator: DataUpdateCoordinator
|
||||
data: PyNUTData
|
||||
unique_id: str
|
||||
user_available_commands: set[str]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
"""Set up Network UPS Tools (NUT) from a config entry."""
|
||||
@@ -59,7 +73,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
|
||||
entry.async_on_unload(data.async_shutdown)
|
||||
|
||||
coordinator = NutCoordinator(hass, data, entry)
|
||||
async def async_update_data() -> dict[str, str]:
|
||||
"""Fetch data from NUT."""
|
||||
try:
|
||||
return await data.async_update()
|
||||
except NUTLoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_authentication",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
except NUTError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="data_fetch_error",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="NUT resource status",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=60),
|
||||
always_update=False,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.button import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NutConfigEntry
|
||||
from . import NutConfigEntry
|
||||
from .entity import NUTBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""The NUT coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aionut import NUTError, NUTLoginError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import PyNUTData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutRuntimeData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
coordinator: NutCoordinator
|
||||
data: PyNUTData
|
||||
unique_id: str
|
||||
user_available_commands: set[str]
|
||||
|
||||
|
||||
type NutConfigEntry = ConfigEntry[NutRuntimeData]
|
||||
|
||||
|
||||
class NutCoordinator(DataUpdateCoordinator[dict[str, str]]):
|
||||
"""Coordinator for NUT data."""
|
||||
|
||||
config_entry: NutConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
data: PyNUTData,
|
||||
config_entry: NutConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize NUT coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="NUT resource status",
|
||||
update_interval=timedelta(seconds=60),
|
||||
always_update=False,
|
||||
)
|
||||
self._data = data
|
||||
|
||||
async def _async_update_data(self) -> dict[str, str]:
|
||||
"""Fetch data from NUT."""
|
||||
try:
|
||||
return await self._data.async_update()
|
||||
except NUTLoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_authentication",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
except NUTError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="data_fetch_error",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
@@ -13,8 +13,8 @@ from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from . import NutConfigEntry, NutRuntimeData
|
||||
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS
|
||||
from .coordinator import NutConfigEntry, NutRuntimeData
|
||||
|
||||
ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import NutConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NutConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import PyNUTData
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NutCoordinator
|
||||
|
||||
NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
|
||||
"manufacturer": ATTR_MANUFACTURER,
|
||||
@@ -27,14 +29,14 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
class NUTBaseEntity(CoordinatorEntity[NutCoordinator]):
|
||||
class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
|
||||
"""NUT base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NutCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
data: PyNUTData,
|
||||
unique_id: str,
|
||||
|
||||
@@ -25,8 +25,8 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import NutConfigEntry
|
||||
from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES
|
||||
from .coordinator import NutConfigEntry
|
||||
from .entity import NUTBaseEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NutConfigEntry
|
||||
from . import NutConfigEntry
|
||||
from .entity import NUTBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,5 +19,6 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"device_info": client.device_info,
|
||||
"vehicles": client.vehicles,
|
||||
"ct_connected": client.ct_connected,
|
||||
"cap_available": client.cap_available,
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.7.0"]
|
||||
"requirements": ["ohme==1.6.0"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user