Support for YoLink YS4102 YS4103 (#150464)

This commit is contained in:
Matrix
2025-08-19 17:35:36 +08:00
committed by GitHub
parent 899f0e03c1
commit 69757bed52
8 changed files with 215 additions and 15 deletions

View File

@@ -43,6 +43,7 @@ PLATFORMS = [
Platform.LIGHT,
Platform.LOCK,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,

View File

@@ -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

View File

@@ -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)

View File

@@ -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": {

View 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()

View File

@@ -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 = [

View File

@@ -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%]"
}
}
}
},

View File

@@ -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
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