mirror of
https://github.com/home-assistant/core.git
synced 2025-09-10 07:11:37 +02:00
Support for YoLink YS4102 YS4103 (#150464)
This commit is contained in:
@@ -43,6 +43,7 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
|
@@ -5,13 +5,16 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from yolink.client_request import ClientRequest
|
||||
from yolink.device import YoLinkDevice
|
||||
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
|
||||
from yolink.model import BRDP
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME
|
||||
@@ -89,3 +92,16 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
|
||||
self.dev_net_type = dev_lora_info.get("devNetType")
|
||||
return device_state
|
||||
return {}
|
||||
|
||||
async def call_device(self, request: ClientRequest) -> dict[str, Any]:
|
||||
"""Call device api."""
|
||||
try:
|
||||
# call_device will check result, fail by raise YoLinkClientError
|
||||
resp: BRDP = await self.device.call_device(request)
|
||||
except YoLinkAuthFailError as yl_auth_err:
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(yl_auth_err) from yl_auth_err
|
||||
except YoLinkClientError as yl_client_err:
|
||||
raise HomeAssistantError(yl_client_err) from yl_client_err
|
||||
else:
|
||||
return resp.data
|
||||
|
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from yolink.client_request import ClientRequest
|
||||
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -64,13 +63,6 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]):
|
||||
def update_entity_state(self, state: dict) -> None:
|
||||
"""Parse and update entity state, should be overridden."""
|
||||
|
||||
async def call_device(self, request: ClientRequest) -> None:
|
||||
async def call_device(self, request: ClientRequest) -> dict[str, Any]:
|
||||
"""Call device api."""
|
||||
try:
|
||||
# call_device will check result, fail by raise YoLinkClientError
|
||||
await self.coordinator.device.call_device(request)
|
||||
except YoLinkAuthFailError as yl_auth_err:
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(yl_auth_err) from yl_auth_err
|
||||
except YoLinkClientError as yl_client_err:
|
||||
raise HomeAssistantError(yl_client_err) from yl_client_err
|
||||
return await self.coordinator.call_device(request)
|
||||
|
@@ -27,10 +27,20 @@
|
||||
"default": "mdi:gauge"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"sprinkler_mode": {
|
||||
"default": "mdi:auto-mode"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"manipulator_state": {
|
||||
"default": "mdi:pipe"
|
||||
}
|
||||
},
|
||||
"valve": {
|
||||
"sprinkler_valve": {
|
||||
"default": "mdi:sprinkler-variant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
119
homeassistant/components/yolink/select.py
Normal file
119
homeassistant/components/yolink/select.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""YoLink select platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from yolink.client_request import ClientRequest
|
||||
from yolink.const import ATTR_DEVICE_SPRINKLER
|
||||
from yolink.device import YoLinkDevice
|
||||
from yolink.message_resolver import sprinkler_message_resolve
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import YoLinkCoordinator
|
||||
from .entity import YoLinkEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class YoLinkSelectEntityDescription(SelectEntityDescription):
|
||||
"""YoLink SelectEntityDescription."""
|
||||
|
||||
state_key: str = "state"
|
||||
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
|
||||
should_update_entity: Callable = lambda state: True
|
||||
value: Callable = lambda data: data
|
||||
on_option_selected: Callable[[YoLinkCoordinator, str], Awaitable[bool]]
|
||||
|
||||
|
||||
async def set_sprinker_mode_fn(coordinator: YoLinkCoordinator, option: str) -> bool:
|
||||
"""Set sprinkler mode."""
|
||||
data: dict[str, Any] = await coordinator.call_device(
|
||||
ClientRequest(
|
||||
"setState",
|
||||
{
|
||||
"state": {
|
||||
"mode": option,
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
sprinkler_message_resolve(coordinator.device, data, None)
|
||||
coordinator.async_set_updated_data(data)
|
||||
return True
|
||||
|
||||
|
||||
SELECTOR_MAPPINGS: tuple[YoLinkSelectEntityDescription, ...] = (
|
||||
YoLinkSelectEntityDescription(
|
||||
key="model",
|
||||
options=["auto", "manual", "off"],
|
||||
translation_key="sprinkler_mode",
|
||||
value=lambda data: (
|
||||
data.get("mode") if data is not None else None
|
||||
), # watering state report will missing state field
|
||||
exists_fn=lambda device: device.device_type == ATTR_DEVICE_SPRINKLER,
|
||||
should_update_entity=lambda value: value is not None,
|
||||
on_option_selected=set_sprinker_mode_fn,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up YoLink select from a config entry."""
|
||||
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
|
||||
async_add_entities(
|
||||
YoLinkSelectEntity(config_entry, selector_device_coordinator, description)
|
||||
for selector_device_coordinator in device_coordinators.values()
|
||||
if selector_device_coordinator.device.device_type in [ATTR_DEVICE_SPRINKLER]
|
||||
for description in SELECTOR_MAPPINGS
|
||||
if description.exists_fn(selector_device_coordinator.device)
|
||||
)
|
||||
|
||||
|
||||
class YoLinkSelectEntity(YoLinkEntity, SelectEntity):
|
||||
"""YoLink Select Entity."""
|
||||
|
||||
entity_description: YoLinkSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
coordinator: YoLinkCoordinator,
|
||||
description: YoLinkSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Init YoLink Select."""
|
||||
super().__init__(config_entry, coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.device.device_id} {self.entity_description.key}"
|
||||
)
|
||||
|
||||
@callback
|
||||
def update_entity_state(self, state: dict[str, Any]) -> None:
|
||||
"""Update HA Entity State."""
|
||||
if (
|
||||
current_value := self.entity_description.value(
|
||||
state.get(self.entity_description.state_key)
|
||||
)
|
||||
) is None and self.entity_description.should_update_entity(
|
||||
current_value
|
||||
) is False:
|
||||
return
|
||||
self._attr_current_option = current_value
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
if await self.entity_description.on_option_selected(self.coordinator, option):
|
||||
self._attr_current_option = option
|
||||
self.async_write_ha_state()
|
@@ -23,6 +23,8 @@ from yolink.const import (
|
||||
ATTR_DEVICE_SMART_REMOTER,
|
||||
ATTR_DEVICE_SMOKE_ALARM,
|
||||
ATTR_DEVICE_SOIL_TH_SENSOR,
|
||||
ATTR_DEVICE_SPRINKLER,
|
||||
ATTR_DEVICE_SPRINKLER_V2,
|
||||
ATTR_DEVICE_SWITCH,
|
||||
ATTR_DEVICE_TH_SENSOR,
|
||||
ATTR_DEVICE_THERMOSTAT,
|
||||
@@ -110,6 +112,8 @@ SENSOR_DEVICE_TYPE = [
|
||||
ATTR_GARAGE_DOOR_CONTROLLER,
|
||||
ATTR_DEVICE_SOIL_TH_SENSOR,
|
||||
ATTR_DEVICE_SMOKE_ALARM,
|
||||
ATTR_DEVICE_SPRINKLER,
|
||||
ATTR_DEVICE_SPRINKLER_V2,
|
||||
]
|
||||
|
||||
BATTERY_POWER_SENSOR = [
|
||||
@@ -131,6 +135,7 @@ BATTERY_POWER_SENSOR = [
|
||||
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
||||
ATTR_DEVICE_SOIL_TH_SENSOR,
|
||||
ATTR_DEVICE_SMOKE_ALARM,
|
||||
ATTR_DEVICE_SPRINKLER_V2,
|
||||
]
|
||||
|
||||
MCU_DEV_TEMPERATURE_SENSOR = [
|
||||
|
@@ -121,6 +121,19 @@
|
||||
},
|
||||
"meter_valve_2_state": {
|
||||
"name": "Valve 2"
|
||||
},
|
||||
"sprinkler_valve": {
|
||||
"name": "[%key:component::valve::title%]"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"sprinkler_mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -4,11 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from yolink.client_request import ClientRequest
|
||||
from yolink.const import (
|
||||
ATTR_DEVICE_MODEL_A,
|
||||
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
||||
ATTR_DEVICE_SPRINKLER,
|
||||
ATTR_DEVICE_SPRINKLER_V2,
|
||||
ATTR_DEVICE_WATER_METER_CONTROLLER,
|
||||
)
|
||||
from yolink.device import YoLinkDevice
|
||||
@@ -36,6 +39,20 @@ class YoLinkValveEntityDescription(ValveEntityDescription):
|
||||
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
|
||||
value: Callable = lambda state: state
|
||||
channel_index: int | None = None
|
||||
should_update_entity: Callable = lambda state: True
|
||||
is_available: Callable[[YoLinkDevice, dict[str, Any]], bool] = (
|
||||
lambda device, state: True
|
||||
)
|
||||
|
||||
|
||||
def sprinkler_valve_available(device: YoLinkDevice, data: dict[str, Any]) -> bool:
|
||||
"""Check if sprinkler valve is available."""
|
||||
if device.device_type == ATTR_DEVICE_SPRINKLER_V2:
|
||||
return True
|
||||
if (state := data.get("state")) is not None:
|
||||
if (mode := state.get("mode")) is not None:
|
||||
return mode == "manual"
|
||||
return False
|
||||
|
||||
|
||||
DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
|
||||
@@ -68,11 +85,24 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
|
||||
),
|
||||
channel_index=1,
|
||||
),
|
||||
YoLinkValveEntityDescription(
|
||||
key="valve",
|
||||
translation_key="sprinkler_valve",
|
||||
device_class=ValveDeviceClass.WATER,
|
||||
value=lambda value: value is False if value is not None else None,
|
||||
exists_fn=lambda device: (
|
||||
device.device_type in [ATTR_DEVICE_SPRINKLER, ATTR_DEVICE_SPRINKLER_V2]
|
||||
),
|
||||
should_update_entity=lambda value: value is not None,
|
||||
is_available=sprinkler_valve_available,
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_TYPE = [
|
||||
ATTR_DEVICE_WATER_METER_CONTROLLER,
|
||||
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
||||
ATTR_DEVICE_SPRINKLER,
|
||||
ATTR_DEVICE_SPRINKLER_V2,
|
||||
]
|
||||
|
||||
|
||||
@@ -124,9 +154,13 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity):
|
||||
attr_val := self.entity_description.value(
|
||||
state.get(self.entity_description.key)
|
||||
)
|
||||
) is None:
|
||||
) is None and self.entity_description.should_update_entity(attr_val) is False:
|
||||
return
|
||||
self._attr_is_closed = attr_val
|
||||
if self.entity_description.is_available(self.coordinator.device, state) is True:
|
||||
self._attr_is_closed = attr_val
|
||||
self._attr_available = True
|
||||
else:
|
||||
self._attr_available = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_invoke_device(self, state: str) -> None:
|
||||
@@ -147,6 +181,16 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity):
|
||||
await self.call_device(
|
||||
ClientRequest("setState", {"valves": {str(channel_index): state}})
|
||||
)
|
||||
if self.coordinator.device.device_type == ATTR_DEVICE_SPRINKLER:
|
||||
await self.call_device(
|
||||
ClientRequest(
|
||||
"setManualWater", {"state": "start" if state == "open" else "stop"}
|
||||
)
|
||||
)
|
||||
if self.coordinator.device.device_type == ATTR_DEVICE_SPRINKLER_V2:
|
||||
await self.call_device(
|
||||
ClientRequest("setState", {"running": state == "open"})
|
||||
)
|
||||
else:
|
||||
await self.call_device(ClientRequest("setState", {"valve": state}))
|
||||
self._attr_is_closed = state == "close"
|
||||
@@ -163,4 +207,4 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true is device is available."""
|
||||
return super().available
|
||||
return self._attr_available and super().available
|
||||
|
Reference in New Issue
Block a user