From 69757bed522974c2e58072eb24e623683aeabac4 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 19 Aug 2025 17:35:36 +0800 Subject: [PATCH] Support for YoLink YS4102 YS4103 (#150464) --- homeassistant/components/yolink/__init__.py | 1 + .../components/yolink/coordinator.py | 18 ++- homeassistant/components/yolink/entity.py | 14 +-- homeassistant/components/yolink/icons.json | 10 ++ homeassistant/components/yolink/select.py | 119 ++++++++++++++++++ homeassistant/components/yolink/sensor.py | 5 + homeassistant/components/yolink/strings.json | 13 ++ homeassistant/components/yolink/valve.py | 50 +++++++- 8 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/yolink/select.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 96db2ab555a..f33da34c1fc 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -43,6 +43,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 7d5323663de..2c914e84a08 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -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 diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 7828bf91541..ecc42ad1a0e 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -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) diff --git a/homeassistant/components/yolink/icons.json b/homeassistant/components/yolink/icons.json index 6d9062a92b8..59366b804f5 100644 --- a/homeassistant/components/yolink/icons.json +++ b/homeassistant/components/yolink/icons.json @@ -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": { diff --git a/homeassistant/components/yolink/select.py b/homeassistant/components/yolink/select.py new file mode 100644 index 00000000000..e98b0440b92 --- /dev/null +++ b/homeassistant/components/yolink/select.py @@ -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() diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 5425c242821..3bb0e965eae 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -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 = [ diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 4215031d904..9e60b77f43a 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -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%]" + } } } }, diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index e63488194d0..8361724a3cf 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -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