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.LIGHT,
|
||||||
Platform.LOCK,
|
Platform.LOCK,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
|
Platform.SELECT,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SIREN,
|
Platform.SIREN,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
|
@@ -5,13 +5,16 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from yolink.client_request import ClientRequest
|
||||||
from yolink.device import YoLinkDevice
|
from yolink.device import YoLinkDevice
|
||||||
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
|
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
|
||||||
|
from yolink.model import BRDP
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME
|
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")
|
self.dev_net_type = dev_lora_info.get("devNetType")
|
||||||
return device_state
|
return device_state
|
||||||
return {}
|
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 __future__ import annotations
|
||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from yolink.client_request import ClientRequest
|
from yolink.client_request import ClientRequest
|
||||||
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
@@ -64,13 +63,6 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]):
|
|||||||
def update_entity_state(self, state: dict) -> None:
|
def update_entity_state(self, state: dict) -> None:
|
||||||
"""Parse and update entity state, should be overridden."""
|
"""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."""
|
"""Call device api."""
|
||||||
try:
|
return await self.coordinator.call_device(request)
|
||||||
# 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
|
|
||||||
|
@@ -27,10 +27,20 @@
|
|||||||
"default": "mdi:gauge"
|
"default": "mdi:gauge"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"select": {
|
||||||
|
"sprinkler_mode": {
|
||||||
|
"default": "mdi:auto-mode"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"manipulator_state": {
|
"manipulator_state": {
|
||||||
"default": "mdi:pipe"
|
"default": "mdi:pipe"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"valve": {
|
||||||
|
"sprinkler_valve": {
|
||||||
|
"default": "mdi:sprinkler-variant"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"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_SMART_REMOTER,
|
||||||
ATTR_DEVICE_SMOKE_ALARM,
|
ATTR_DEVICE_SMOKE_ALARM,
|
||||||
ATTR_DEVICE_SOIL_TH_SENSOR,
|
ATTR_DEVICE_SOIL_TH_SENSOR,
|
||||||
|
ATTR_DEVICE_SPRINKLER,
|
||||||
|
ATTR_DEVICE_SPRINKLER_V2,
|
||||||
ATTR_DEVICE_SWITCH,
|
ATTR_DEVICE_SWITCH,
|
||||||
ATTR_DEVICE_TH_SENSOR,
|
ATTR_DEVICE_TH_SENSOR,
|
||||||
ATTR_DEVICE_THERMOSTAT,
|
ATTR_DEVICE_THERMOSTAT,
|
||||||
@@ -110,6 +112,8 @@ SENSOR_DEVICE_TYPE = [
|
|||||||
ATTR_GARAGE_DOOR_CONTROLLER,
|
ATTR_GARAGE_DOOR_CONTROLLER,
|
||||||
ATTR_DEVICE_SOIL_TH_SENSOR,
|
ATTR_DEVICE_SOIL_TH_SENSOR,
|
||||||
ATTR_DEVICE_SMOKE_ALARM,
|
ATTR_DEVICE_SMOKE_ALARM,
|
||||||
|
ATTR_DEVICE_SPRINKLER,
|
||||||
|
ATTR_DEVICE_SPRINKLER_V2,
|
||||||
]
|
]
|
||||||
|
|
||||||
BATTERY_POWER_SENSOR = [
|
BATTERY_POWER_SENSOR = [
|
||||||
@@ -131,6 +135,7 @@ BATTERY_POWER_SENSOR = [
|
|||||||
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
||||||
ATTR_DEVICE_SOIL_TH_SENSOR,
|
ATTR_DEVICE_SOIL_TH_SENSOR,
|
||||||
ATTR_DEVICE_SMOKE_ALARM,
|
ATTR_DEVICE_SMOKE_ALARM,
|
||||||
|
ATTR_DEVICE_SPRINKLER_V2,
|
||||||
]
|
]
|
||||||
|
|
||||||
MCU_DEV_TEMPERATURE_SENSOR = [
|
MCU_DEV_TEMPERATURE_SENSOR = [
|
||||||
|
@@ -121,6 +121,19 @@
|
|||||||
},
|
},
|
||||||
"meter_valve_2_state": {
|
"meter_valve_2_state": {
|
||||||
"name": "Valve 2"
|
"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 collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from yolink.client_request import ClientRequest
|
from yolink.client_request import ClientRequest
|
||||||
from yolink.const import (
|
from yolink.const import (
|
||||||
ATTR_DEVICE_MODEL_A,
|
ATTR_DEVICE_MODEL_A,
|
||||||
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER,
|
||||||
|
ATTR_DEVICE_SPRINKLER,
|
||||||
|
ATTR_DEVICE_SPRINKLER_V2,
|
||||||
ATTR_DEVICE_WATER_METER_CONTROLLER,
|
ATTR_DEVICE_WATER_METER_CONTROLLER,
|
||||||
)
|
)
|
||||||
from yolink.device import YoLinkDevice
|
from yolink.device import YoLinkDevice
|
||||||
@@ -36,6 +39,20 @@ class YoLinkValveEntityDescription(ValveEntityDescription):
|
|||||||
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
|
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
|
||||||
value: Callable = lambda state: state
|
value: Callable = lambda state: state
|
||||||
channel_index: int | None = None
|
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, ...] = (
|
DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
|
||||||
@@ -68,11 +85,24 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
channel_index=1,
|
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 = [
|
DEVICE_TYPE = [
|
||||||
ATTR_DEVICE_WATER_METER_CONTROLLER,
|
ATTR_DEVICE_WATER_METER_CONTROLLER,
|
||||||
ATTR_DEVICE_MULTI_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(
|
attr_val := self.entity_description.value(
|
||||||
state.get(self.entity_description.key)
|
state.get(self.entity_description.key)
|
||||||
)
|
)
|
||||||
) is None:
|
) is None and self.entity_description.should_update_entity(attr_val) is False:
|
||||||
return
|
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()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def _async_invoke_device(self, state: str) -> None:
|
async def _async_invoke_device(self, state: str) -> None:
|
||||||
@@ -147,6 +181,16 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity):
|
|||||||
await self.call_device(
|
await self.call_device(
|
||||||
ClientRequest("setState", {"valves": {str(channel_index): state}})
|
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:
|
else:
|
||||||
await self.call_device(ClientRequest("setState", {"valve": state}))
|
await self.call_device(ClientRequest("setState", {"valve": state}))
|
||||||
self._attr_is_closed = state == "close"
|
self._attr_is_closed = state == "close"
|
||||||
@@ -163,4 +207,4 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity):
|
|||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return true is device is available."""
|
"""Return true is device is available."""
|
||||||
return super().available
|
return self._attr_available and super().available
|
||||||
|
Reference in New Issue
Block a user