From 4e9276ed027c3c647c98a584b7c2d8709d56653d Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Tue, 19 Aug 2025 22:25:42 -0400 Subject: [PATCH 1/6] Init yolink branch for yolink integration --- pylabrobot/yolink/__init__.py | 2 ++ pylabrobot/yolink/yolink_backend.py | 16 ++++++++++++++++ pylabrobot/yolink/yolink_device.py | 9 +++++++++ 3 files changed, 27 insertions(+) create mode 100644 pylabrobot/yolink/__init__.py create mode 100644 pylabrobot/yolink/yolink_backend.py create mode 100644 pylabrobot/yolink/yolink_device.py diff --git a/pylabrobot/yolink/__init__.py b/pylabrobot/yolink/__init__.py new file mode 100644 index 00000000000..a56584f6e35 --- /dev/null +++ b/pylabrobot/yolink/__init__.py @@ -0,0 +1,2 @@ +from .yolink_backend import YoLinkBackend +from .yolink_device import YoLinkDevice diff --git a/pylabrobot/yolink/yolink_backend.py b/pylabrobot/yolink/yolink_backend.py new file mode 100644 index 00000000000..561bfa78d39 --- /dev/null +++ b/pylabrobot/yolink/yolink_backend.py @@ -0,0 +1,16 @@ +from abc import abstractmethod + +from pylabrobot.machine import MachineBackend + + +class YoLinkBackend(MachineBackend): + def __init__(self, api_key: str, device_id: str): + super().__init__() + self.api_key = api_key + self.device_id = device_id + + @abstractmethod + async def get_status(self): + pass + + # Add your YoLink API methods here diff --git a/pylabrobot/yolink/yolink_device.py b/pylabrobot/yolink/yolink_device.py new file mode 100644 index 00000000000..bb3fd4dbaa4 --- /dev/null +++ b/pylabrobot/yolink/yolink_device.py @@ -0,0 +1,9 @@ +from pylabrobot.machine import Machine + + +class YoLinkDevice(Machine): + def __init__(self, backend: YoLinkBackend): + super().__init__(backend=backend) + + async def get_device_status(self): + return await self.backend.get_status() From 2c49bd5834fa83e847fc4ff49d205265c06667be Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Wed, 20 Aug 2025 00:05:38 -0400 Subject: [PATCH 2/6] Create YoLink backend and Sensor class --- pylabrobot/yolink/LICENSE | 19 + pylabrobot/yolink/README.md | 64 +++ pylabrobot/yolink/__init__.py | 15 +- pylabrobot/yolink/auth_mgr.py | 29 ++ pylabrobot/yolink/client.py | 76 ++++ pylabrobot/yolink/client_request.py | 21 + pylabrobot/yolink/const.py | 55 +++ pylabrobot/yolink/device.py | 119 +++++ pylabrobot/yolink/device_helper.py | 121 +++++ pylabrobot/yolink/endpoint.py | 40 ++ pylabrobot/yolink/exception.py | 35 ++ pylabrobot/yolink/home_manager.py | 118 +++++ pylabrobot/yolink/message_listener.py | 13 + pylabrobot/yolink/message_resolver.py | 207 +++++++++ pylabrobot/yolink/model.py | 55 +++ pylabrobot/yolink/mqtt_client.py | 138 ++++++ pylabrobot/yolink/outlet_request_builder.py | 16 + pylabrobot/yolink/pyproject.toml | 31 ++ .../yolink/thermostat_request_builder.py | 32 ++ pylabrobot/yolink/unit_helper.py | 100 +++++ pylabrobot/yolink/yolink.py | 421 ++++++++++++++++++ pylabrobot/yolink/yolink_backend.py | 16 - pylabrobot/yolink/yolink_device.py | 9 - 23 files changed, 1723 insertions(+), 27 deletions(-) create mode 100644 pylabrobot/yolink/LICENSE create mode 100644 pylabrobot/yolink/README.md create mode 100644 pylabrobot/yolink/auth_mgr.py create mode 100644 pylabrobot/yolink/client.py create mode 100644 pylabrobot/yolink/client_request.py create mode 100644 pylabrobot/yolink/const.py create mode 100644 pylabrobot/yolink/device.py create mode 100644 pylabrobot/yolink/device_helper.py create mode 100644 pylabrobot/yolink/endpoint.py create mode 100644 pylabrobot/yolink/exception.py create mode 100644 pylabrobot/yolink/home_manager.py create mode 100644 pylabrobot/yolink/message_listener.py create mode 100644 pylabrobot/yolink/message_resolver.py create mode 100644 pylabrobot/yolink/model.py create mode 100644 pylabrobot/yolink/mqtt_client.py create mode 100644 pylabrobot/yolink/outlet_request_builder.py create mode 100644 pylabrobot/yolink/pyproject.toml create mode 100644 pylabrobot/yolink/thermostat_request_builder.py create mode 100644 pylabrobot/yolink/unit_helper.py create mode 100644 pylabrobot/yolink/yolink.py delete mode 100644 pylabrobot/yolink/yolink_backend.py delete mode 100644 pylabrobot/yolink/yolink_device.py diff --git a/pylabrobot/yolink/LICENSE b/pylabrobot/yolink/LICENSE new file mode 100644 index 00000000000..335ea9d070a --- /dev/null +++ b/pylabrobot/yolink/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 The Python Packaging Authority + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/pylabrobot/yolink/README.md b/pylabrobot/yolink/README.md new file mode 100644 index 00000000000..d474c4ec019 --- /dev/null +++ b/pylabrobot/yolink/README.md @@ -0,0 +1,64 @@ +# YoLink Python library for HA Integration + +## Supported devices + +- YS1603-UC (Hub) +- YS1604-UC (SpeakerHub) +- YS3604-UC (YoLink KeyFob) +- YS3605-UC (YoLink On/OffFob) +- YS3606-UC (YoLink DimmerFob) +- YS3607-UC (YoLink SirenFob) +- YS3614-UC (YoLink Mini FlexFob) +- YS4002-UC (YoLink Thermostat) +- YS4003-UC (YoLink Thermostat Heatpump) +- YS4906-UC + YS7706-UC (Garage Door Kit 1) +- YS4908-UC + YS7706-UC (Garage Door Kit 2 (Finger)) +- YS4909-UC (Water Valve Controller) +- YS5001-UC (X3 Water Valve Controller) +- YS5002-UC (YoLink Motorized Ball Valve) +- YS5003-UC (Water Valve Controller 2) +- YS5705-UC (In-Wall Switch) +- YS5706-UC (YoLink Relay) +- YS5707-UC (Dimmer Switch) +- YS5708-UC (In-Wall Switch 2) +- YS6602-UC (YoLink Energy Plug) +- YS6604-UC (YoLink Plug Mini) +- YS6704-UC (In-wall Outlet) +- YS6801-UC (Smart Power Strip) +- YS6802-UC (Smart Outdoor Power Strip) +- YS6803-UC (Outdoor Energy Plug) +- YS7103-UC (Siren Alarm) +- YS7104-UC (Outdoor Alarm Controller) +- YS7105-UC (X3 Outdoor Alarm Controller) +- YS7106-UC (Power Fail Alarm) +- YS7107-UC (Outdoor Alarm Controller 2) +- YS7201-UC (Vibration Sensor) +- YS7606-UC (YoLink Smart Lock M1) +- YS7607-UC (YoLink Smart Lock M2) +- YS7704-UC (Door Sensor) +- YS7706-UC (Garage Door Sensor) +- YS7707-UC (Contact Sensor) +- YS7804-UC (Motion Sensor) +- YS7805-UC (Outdoor Motion Sensor) +- YS7903-UC (Water Leak Sensor) +- YS7904-UC (Water Leak Sensor 2) +- YS7906-UC (Water Leak Sensor 4) +- YS7916-UC (Water Leak Sensor 4 MoveAlert) +- YS7905-UC (WaterDepthSensor) +- YS7A01-UC (Smart Smoke/CO Alarm) +- YS8003-UC (Temperature Humidity Sensor) +- YS8004-UC (Weatherproof Temperature Sensor) +- YS8005-UC (Weatherproof Temperature & Humidity Sensor) +- YS8006-UC (X3 Temperature & Humidity Sensor) +- YS8014-UC (X3 Outdoor Temperature Sensor) +- YS8015-UC (X3 Outdoor Temperature & Humidity Sensor) +- YS5006-UC (FlowSmart Control) +- YS5007-UC (FlowSmart Meter) +- YS5008-UC (FlowSmart All-in-One) +- YS8017-UC (Thermometer) +- YS5009-UC (LeakStop Controller) +- YS5029-UC (LeakStop Controller 2 Channel) +- YS8009-UC (Soil Temperature & Humidity Sensor) +- YS4102-UC (Smart Sprinkler Controller) +- YS4103-UC (Smart Sprinkler Controller V2) +- YS7A12-UC (Smoke Alarm) diff --git a/pylabrobot/yolink/__init__.py b/pylabrobot/yolink/__init__.py index a56584f6e35..642a184b32d 100644 --- a/pylabrobot/yolink/__init__.py +++ b/pylabrobot/yolink/__init__.py @@ -1,2 +1,13 @@ -from .yolink_backend import YoLinkBackend -from .yolink_device import YoLinkDevice +import aiohttp # this needs to be added to pylabrobot dependencies if yolink is to be used + +from .yolink import ( + Sensor, + YoLink, +) + +# dependencies = [ +# "aiohttp>=3.8.1", +# "aiomqtt>=2.0.0,<3.0.0", +# "pydantic>=2.0.0", +# "tenacity>=8.1.0", +# ] diff --git a/pylabrobot/yolink/auth_mgr.py b/pylabrobot/yolink/auth_mgr.py new file mode 100644 index 00000000000..f6261bafcf3 --- /dev/null +++ b/pylabrobot/yolink/auth_mgr.py @@ -0,0 +1,29 @@ +"""YoLink authorization manager.""" + +import abc + +from aiohttp import ClientSession + + +class YoLinkAuthMgr(metaclass=abc.ABCMeta): + """YoLink API Authentication Manager.""" + + def __init__(self, session: ClientSession) -> None: + """YoLink Auth Manager""" + self._session = session + + def client_session(self) -> ClientSession: + """Get client session.""" + return self._session + + @abc.abstractmethod + def access_token(self) -> str: + """Get auth token.""" + + def http_auth_header(self) -> str: + """Get auth header.""" + return f"Bearer {self.access_token()}" + + @abc.abstractmethod + async def check_and_refresh_token(self) -> str: + """Check and fresh token.""" diff --git a/pylabrobot/yolink/client.py b/pylabrobot/yolink/client.py new file mode 100644 index 00000000000..3533f097e5e --- /dev/null +++ b/pylabrobot/yolink/client.py @@ -0,0 +1,76 @@ +"""YoLink client.""" + +from typing import Any, Dict + +from aiohttp import ClientError, ClientResponse +from tenacity import retry, retry_if_exception_type, stop_after_attempt + +from .auth_mgr import YoLinkAuthMgr +from .exception import YoLinkClientError, YoLinkDeviceConnectionFailed +from .model import BRDP + + +class YoLinkClient: + """YoLink client.""" + + def __init__(self, auth_mgr: YoLinkAuthMgr) -> None: + """Init YoLink client""" + self._auth_mgr = auth_mgr + + async def request( + self, method: str, url: str, auth_required: bool = True, **kwargs: Any + ) -> ClientResponse: + """Proxy Request and add Auth/CV headers.""" + headers = kwargs.pop("headers", {}) + params = kwargs.pop("params", None) + data = kwargs.pop("data", None) + + # Extra, user supplied values + extra_headers = kwargs.pop("extra_headers", None) + extra_params = kwargs.pop("extra_params", None) + extra_data = kwargs.pop("extra_data", None) + if auth_required: + # Ensure token valid + await self._auth_mgr.check_and_refresh_token() + # Set auth header + headers["Authorization"] = self._auth_mgr.http_auth_header() + # Extend with optionally supplied values + if extra_headers: + headers.update(extra_headers) + if extra_params: + # Query parameters + params = params or {} + params.update(extra_params) + if extra_data: + # form encoded post data + data = data or {} + data.update(extra_data) + return await self._auth_mgr.client_session().request( + method, url, **kwargs, headers=headers, params=params, data=data, timeout=8 + ) + + async def get(self, url: str, **kwargs: Any) -> ClientResponse: + """Call http request with Get Method.""" + return await self.request("GET", url, True, **kwargs) + + async def post(self, url: str, **kwargs: Any) -> ClientResponse: + """Call Http Request with POST Method""" + return await self.request("POST", url, True, **kwargs) + + @retry( + retry=retry_if_exception_type(YoLinkDeviceConnectionFailed), + stop=stop_after_attempt(2), + ) + async def execute(self, url: str, bsdp: Dict, **kwargs: Any) -> BRDP: + """Call YoLink Api""" + try: + yl_resp = await self.post(url, json=bsdp, **kwargs) + yl_resp.raise_for_status() + _yl_body = await yl_resp.text() + brdp = BRDP.model_validate_json(_yl_body) + brdp.check_response() + except ClientError as client_err: + raise YoLinkClientError("-1003", "yolink client request failed!") from client_err + except YoLinkClientError as yl_client_err: + raise yl_client_err + return brdp diff --git a/pylabrobot/yolink/client_request.py b/pylabrobot/yolink/client_request.py new file mode 100644 index 00000000000..109d6123bcc --- /dev/null +++ b/pylabrobot/yolink/client_request.py @@ -0,0 +1,21 @@ +"""Client request""" + +from typing import Any + + +class ClientRequest: + """Client request""" + + def __init__(self, method: str, params: dict[str, Any]) -> None: + self._method = method + self._params = params + + @property + def method(self) -> str: + """Return call device method""" + return self._method + + @property + def params(self) -> dict[str, Any]: + """Return call params""" + return self._params diff --git a/pylabrobot/yolink/const.py b/pylabrobot/yolink/const.py new file mode 100644 index 00000000000..2ba77d43280 --- /dev/null +++ b/pylabrobot/yolink/const.py @@ -0,0 +1,55 @@ +"""Const for YoLink Client.""" + +from typing import Final + +OAUTH2_AUTHORIZE = "https://api.yosmart.com/oauth/v2/authorization.htm" +OAUTH2_TOKEN = "https://api.yosmart.com/open/yolink/token" + +ATTR_DEVICE_ID = "deviceId" +ATTR_DEVICE_NAME = "name" +ATTR_DEVICE_TYPE = "type" +ATTR_DEVICE_TOKEN = "token" +ATTR_DEVICE_MODEL_NAME = "modelName" +ATTR_DEVICE_PARENT_ID = "parentDeviceId" +ATTR_DEVICE_SERVICE_ZONE = "serviceZone" + +ATTR_DEVICE_MODEL_A = "A" +ATTR_DEVICE_MODEL_C = "C" +ATTR_DEVICE_MODEL_D = "D" +ATTR_DEVICE_MODEL_HUB = "Hub" + +ATTR_DEVICE_DOOR_SENSOR = "DoorSensor" +ATTR_DEVICE_TH_SENSOR = "THSensor" +ATTR_DEVICE_MOTION_SENSOR = "MotionSensor" +ATTR_DEVICE_MULTI_OUTLET = "MultiOutlet" +ATTR_DEVICE_LEAK_SENSOR = "LeakSensor" +ATTR_DEVICE_VIBRATION_SENSOR = "VibrationSensor" +ATTR_DEVICE_OUTLET = "Outlet" +ATTR_DEVICE_SIREN = "Siren" +ATTR_DEVICE_LOCK = "Lock" +ATTR_DEVICE_MANIPULATOR = "Manipulator" +ATTR_DEVICE_CO_SMOKE_SENSOR = "COSmokeSensor" +ATTR_DEVICE_SWITCH = "Switch" +ATTR_DEVICE_THERMOSTAT = "Thermostat" +ATTR_DEVICE_DIMMER = "Dimmer" +ATTR_GARAGE_DOOR_CONTROLLER = "GarageDoor" +ATTR_DEVICE_SMART_REMOTER = "SmartRemoter" +ATTR_DEVICE_POWER_FAILURE_ALARM = "PowerFailureAlarm" +ATTR_DEVICE_HUB = "Hub" +ATTR_DEVICE_SPEAKER_HUB = "SpeakerHub" +ATTR_DEVICE_FINGER = "Finger" +ATTR_DEVICE_WATER_DEPTH_SENSOR = "WaterDepthSensor" +ATTR_DEVICE_WATER_METER_CONTROLLER = "WaterMeterController" +ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER = "WaterMeterMultiController" +ATTR_DEVICE_LOCK_V2 = "LockV2" +ATTR_DEVICE_SOIL_TH_SENSOR = "SoilThcSensor" +ATTR_DEVICE_SPRINKLER = "Sprinkler" +ATTR_DEVICE_SPRINKLER_V2 = "SprinklerV2" +ATTR_DEVICE_SMOKE_ALARM = "SmokeAlarm" + +UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit." + + +DEVICE_LEAK_STOP_MODELS = ["YS5009-UC", "YS5009-EC", "YS5029-UC", "YS5029-EC"] + +DEVICE_MODELS_SUPPORT_MODE_SWITCHING = DEVICE_LEAK_STOP_MODELS diff --git a/pylabrobot/yolink/device.py b/pylabrobot/yolink/device.py new file mode 100644 index 00000000000..6c75542dbe8 --- /dev/null +++ b/pylabrobot/yolink/device.py @@ -0,0 +1,119 @@ +"""YoLink Device.""" + +from __future__ import annotations + +import abc +from typing import Optional + +from pydantic import BaseModel, Field, field_validator +from tenacity import RetryError + +from .client import YoLinkClient +from .client_request import ClientRequest +from .const import ( + ATTR_DEVICE_ID, + ATTR_DEVICE_MODEL_NAME, + ATTR_DEVICE_NAME, + ATTR_DEVICE_PARENT_ID, + ATTR_DEVICE_SERVICE_ZONE, + ATTR_DEVICE_TOKEN, + ATTR_DEVICE_TYPE, + DEVICE_MODELS_SUPPORT_MODE_SWITCHING, +) +from .device_helper import get_device_net_mode +from .endpoint import Endpoint, Endpoints +from .message_resolver import resolve_message +from .model import BRDP, BSDPHelper + + +class YoLinkDeviceMode(BaseModel): + """YoLink Device Mode.""" + + device_id: str = Field(alias=ATTR_DEVICE_ID) + device_name: str = Field(alias=ATTR_DEVICE_NAME) + device_token: str = Field(alias=ATTR_DEVICE_TOKEN) + device_type: str = Field(alias=ATTR_DEVICE_TYPE) + device_model_name: str = Field(alias=ATTR_DEVICE_MODEL_NAME) + device_parent_id: Optional[str] = Field(alias=ATTR_DEVICE_PARENT_ID) + device_service_zone: Optional[str] = Field(alias=ATTR_DEVICE_SERVICE_ZONE) + + @field_validator("device_parent_id") + @classmethod + def check_parent_id(cls, val: Optional[str]) -> Optional[str]: + """Checking and replace parent id.""" + if val == "null": + val = None + return val + + +class YoLinkDevice(metaclass=abc.ABCMeta): + """YoLink device.""" + + def __init__(self, device: YoLinkDeviceMode, client: YoLinkClient) -> None: + self.device_id: str = device.device_id + self.device_name: str = device.device_name + self.device_token: str = device.device_token + self.device_type: str = device.device_type + self.device_model_name: str = device.device_model_name + self.device_attrs: dict | None = None + self.parent_id: str = device.device_parent_id + self._client: YoLinkClient = client + self.class_mode: str = get_device_net_mode(device) + self._state: dict | None = {} + if device.device_service_zone is not None: + self.device_endpoint: Endpoint = ( + Endpoints.EU.value if device.device_service_zone.startswith("eu_") else Endpoints.US.value + ) + else: + self.device_endpoint: Endpoint = ( + Endpoints.EU.value if device.device_model_name.endswith("-EC") else Endpoints.US.value + ) + + async def __invoke(self, method: str, params: dict | None) -> BRDP: + """Invoke device.""" + try: + bsdp_helper = BSDPHelper( + self.device_id, + self.device_token, + f"{self.device_type}.{method}", + ) + if params is not None: + bsdp_helper.add_params(params) + return await self._client.execute(url=self.device_endpoint.url, bsdp=bsdp_helper.build()) + except RetryError as err: + raise err.last_attempt.result() + + async def get_state(self) -> BRDP: + """Call *.getState with device to request realtime state data.""" + return await self.__invoke("getState", None) + + async def fetch_state(self) -> BRDP: + """Call *.fetchState with device to fetch state data.""" + if self.device_type in ["Hub", "SpeakerHub"]: + return BRDP( + code="000000", + desc="success", + method="fetchState", + data={}, + ) + state_brdp: BRDP = await self.__invoke("fetchState", None) + resolve_message(self, state_brdp.data.get("state"), None) + return state_brdp + + async def get_external_data(self) -> BRDP: + """Call *.getExternalData to get device settings.""" + return await self.__invoke("getExternalData", None) + + async def call_device(self, request: ClientRequest) -> BRDP: + """Device invoke.""" + return await self.__invoke(request.method, request.params) + + def get_paired_device_id(self) -> str | None: + """Get device paired device id.""" + if self.parent_id is None or self.parent_id == "null": + return None + return self.parent_id + + def is_support_mode_switching(self) -> bool: + """Check if the device supports mode switching.""" + return self.device_model_name in DEVICE_MODELS_SUPPORT_MODE_SWITCHING diff --git a/pylabrobot/yolink/device_helper.py b/pylabrobot/yolink/device_helper.py new file mode 100644 index 00000000000..c6c4bbf2188 --- /dev/null +++ b/pylabrobot/yolink/device_helper.py @@ -0,0 +1,121 @@ +"""Helper functions for YoLink devices.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .device import YoLinkDevice + +from .const import ( + ATTR_DEVICE_CO_SMOKE_SENSOR, + ATTR_DEVICE_DIMMER, + ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_FINGER, + ATTR_DEVICE_HUB, + ATTR_DEVICE_LEAK_SENSOR, + ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, + ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_OUTLET, + ATTR_DEVICE_POWER_FAILURE_ALARM, + ATTR_DEVICE_SIREN, + ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SMOKE_ALARM, + ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SPEAKER_HUB, + ATTR_DEVICE_SPRINKLER, + ATTR_DEVICE_SPRINKLER_V2, + ATTR_DEVICE_SWITCH, + ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_THERMOSTAT, + ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_WATER_DEPTH_SENSOR, + ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_GARAGE_DOOR_CONTROLLER, +) + + +def get_device_net_mode(device: YoLinkDevice) -> str | None: + """Get device network mode.""" + # Assuming all devices are WiFi for this example + device_type = device.device_type + device_model = device.device_model_name + device_short_model = None + if device_model is not None: + device_short_model = device_model.split("-")[0] + if device_type in [ + ATTR_DEVICE_LEAK_SENSOR, + ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, + ATTR_DEVICE_POWER_FAILURE_ALARM, + ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_WATER_DEPTH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, + ]: + if device_short_model in [ + "YS7A02", + "YS8006", + ]: + return "D" + return "A" + if device_type in [ + ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_OUTLET, + ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_THERMOSTAT, + ATTR_DEVICE_SIREN, + ATTR_DEVICE_SWITCH, + ATTR_GARAGE_DOOR_CONTROLLER, + ATTR_DEVICE_DIMMER, + ATTR_DEVICE_SPRINKLER, + ]: + if device_short_model in [ + # + "YS4909", + # Mainpulator(Class D) + "YS5001", + "YS5002", + "YS5003", + "YS5012", + # Switch(Class D) + "YS5709", + # Siren(Class D) + "YS7104", + "YS7105", + "YS7107", + ]: + return "D" + return "C" + if device_type in [ + ATTR_DEVICE_FINGER, + ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, + ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SPRINKLER_V2, + ]: + if device_short_model in ["YS5007"]: + return "A" + return "D" + if device_type in [ATTR_DEVICE_HUB, ATTR_DEVICE_SPEAKER_HUB]: + return "Hub" + return None + + +def get_device_keepalive_time(device: YoLinkDevice) -> int: + """Get device keepalive time in seconds.""" + device_class_mode = get_device_net_mode(device) + if device_class_mode in ["A", "D"]: + return 32400 + if device_class_mode == "C": + return 3600 + if device_class_mode == "Hub": + return 600 diff --git a/pylabrobot/yolink/endpoint.py b/pylabrobot/yolink/endpoint.py new file mode 100644 index 00000000000..a56e9f36f48 --- /dev/null +++ b/pylabrobot/yolink/endpoint.py @@ -0,0 +1,40 @@ +"""SVR info.""" + +from dataclasses import dataclass +from enum import Enum + + +@dataclass(repr=True) +class Endpoint: + """SVR endpoint.""" + + name: str + host: str + url: str + mqtt_broker_host: str + mqtt_broker_port: int = 8003 + + def __init__(self, name: str, host: str, mqtt_host: str, mqtt_port: int): + """Init SVR Endpoint.""" + self.name = name + self.host = host + self.url = f"https://{host}/open/yolink/v2/api" + self.mqtt_broker_host = mqtt_host + self.mqtt_broker_port = mqtt_port + + +class Endpoints(Enum): + """All YoLink SVR Endpoints.""" + + US: Endpoint = Endpoint( + name="US", + host="api.yosmart.com", + mqtt_host="mqtt.api.yosmart.com", + mqtt_port=8003, + ) + EU: Endpoint = Endpoint( + name="EU", + host="api-eu.yosmart.com", + mqtt_host="api-eu.yosmart.com", + mqtt_port=8003, + ) diff --git a/pylabrobot/yolink/exception.py b/pylabrobot/yolink/exception.py new file mode 100644 index 00000000000..106da10f7a8 --- /dev/null +++ b/pylabrobot/yolink/exception.py @@ -0,0 +1,35 @@ +"""YoLink Client Error.""" + + +class YoLinkError(Exception): + """YoLink Error.""" + + +class YoLinkClientError(YoLinkError): + """YoLink Client Error. + + code: Error Code + desc: Desc or Error + """ + + def __init__( + self, + code: str, + desc: str, + ) -> None: + """Initialize the yolink api error.""" + + self.code = code + self.message = desc + + +class YoLinkAuthFailError(YoLinkClientError): + """YoLink Auth Fail""" + + +class YoLinkDeviceConnectionFailed(YoLinkClientError): + """YoLink device connection failed.""" + + +class YoLinkUnSupportedMethodError(YoLinkClientError): + """YoLink Unsupported method error.""" diff --git a/pylabrobot/yolink/home_manager.py b/pylabrobot/yolink/home_manager.py new file mode 100644 index 00000000000..2fc123eda93 --- /dev/null +++ b/pylabrobot/yolink/home_manager.py @@ -0,0 +1,118 @@ +"""YoLink home manager.""" + +from __future__ import annotations + +import logging +from typing import Any + +from .auth_mgr import YoLinkAuthMgr +from .client import YoLinkClient +from .const import ATTR_DEVICE_WATER_DEPTH_SENSOR +from .device import YoLinkDevice, YoLinkDeviceMode +from .endpoint import Endpoint, Endpoints +from .exception import YoLinkClientError, YoLinkUnSupportedMethodError +from .message_listener import MessageListener +from .model import BRDP +from .mqtt_client import YoLinkMqttClient + +_LOGGER = logging.getLogger(__name__) + +has_external_data_devices = [ATTR_DEVICE_WATER_DEPTH_SENSOR] + + +class YoLinkHome: + """YoLink home manager.""" + + def __init__(self) -> None: + """Init YoLink Home Manager.""" + self._home_devices: dict[str, YoLinkDevice] = {} + self._http_client: YoLinkClient = None + self._endpoints: dict[str, Endpoint] = {} + self._mqtt_clients: dict[str, YoLinkMqttClient] = {} + self._message_listener: MessageListener = None + + async def async_setup(self, auth_mgr: YoLinkAuthMgr, listener: MessageListener) -> None: + """Init YoLink home.""" + if not auth_mgr: + raise YoLinkClientError("-1001", "setup failed, auth_mgr is required!") + if not listener: + raise YoLinkClientError("-1002", "setup failed, message listener is required!") + self._http_client = YoLinkClient(auth_mgr) + home_info: BRDP = await self.async_get_home_info() + # load home devices + await self.async_load_home_devices() + # setup yolink mqtt connection + self._message_listener = listener + # setup yolink mqtt clients + for endpoint in self._endpoints.values(): + endpoint_mqtt_client = YoLinkMqttClient( + auth_manager=auth_mgr, + endpoint=endpoint.name, + broker_host=endpoint.mqtt_broker_host, + broker_port=endpoint.mqtt_broker_port, + home_devices=self._home_devices, + ) + await endpoint_mqtt_client.connect(home_info.data["id"], self._message_listener) + self._mqtt_clients[endpoint.name] = endpoint_mqtt_client + + async def async_unload(self) -> None: + """Unload YoLink home.""" + self._home_devices = {} + self._http_client = None + for endpoint, client in self._mqtt_clients.items(): + _LOGGER.info( + "[%s] shutting down yolink mqtt client.", + endpoint, + ) + await client.disconnect() + _LOGGER.info( + "[%s] yolink mqtt client disconnected.", + endpoint, + ) + self._message_listener = None + self._mqtt_clients = {} + + async def async_get_home_info(self, **kwargs: Any) -> BRDP: + """Get home general information.""" + return await self._http_client.execute( + url=Endpoints.US.value.url, bsdp={"method": "Home.getGeneralInfo"}, **kwargs + ) + + async def async_load_home_devices(self, **kwargs: Any) -> dict[str, YoLinkDevice]: + """Get home devices.""" + # sync eu devices, will remove in future + eu_response: BRDP = await self._http_client.execute( + url=Endpoints.EU.value.url, bsdp={"method": "Home.getDeviceList"}, **kwargs + ) + response: BRDP = await self._http_client.execute( + url=Endpoints.US.value.url, bsdp={"method": "Home.getDeviceList"}, **kwargs + ) + eu_dev_tokens = {} + for eu_device in eu_response.data["devices"]: + eu_dev_tokens[eu_device["deviceId"]] = eu_device["token"] + for _device in response.data["devices"]: + _yl_device = YoLinkDevice(YoLinkDeviceMode(**_device), self._http_client) + if _yl_device.device_endpoint == Endpoints.EU.value: + # sync eu device token + _yl_device.device_token = eu_dev_tokens.get(_yl_device.device_id) + self._endpoints[_yl_device.device_endpoint.name] = _yl_device.device_endpoint + if _yl_device.device_type in has_external_data_devices: + try: + dev_external_data_resp = await _yl_device.get_external_data() + _yl_device.device_attrs = dev_external_data_resp.data.get("extData") + except YoLinkUnSupportedMethodError: + _LOGGER.debug( + "getExternalData is not supported for: %s", + _yl_device.device_type, + ) + self._home_devices[_device["deviceId"]] = _yl_device + + return self._home_devices + + def get_devices(self) -> list[YoLinkDevice]: + """Get home devices.""" + return self._home_devices.values() + + def get_device(self, device_id: str) -> YoLinkDevice | None: + """Get home device via device id.""" + return self._home_devices.get(device_id) diff --git a/pylabrobot/yolink/message_listener.py b/pylabrobot/yolink/message_listener.py new file mode 100644 index 00000000000..4cdc1e54ad2 --- /dev/null +++ b/pylabrobot/yolink/message_listener.py @@ -0,0 +1,13 @@ +"""YoLink cloud message listener.""" +from abc import ABCMeta, abstractmethod +from typing import Any + +from .device import YoLinkDevice + + +class MessageListener(metaclass=ABCMeta): + """Home message listener.""" + + @abstractmethod + def on_message(self, device: YoLinkDevice, msg_data: dict[str, Any]) -> None: + """On device message receive.""" diff --git a/pylabrobot/yolink/message_resolver.py b/pylabrobot/yolink/message_resolver.py new file mode 100644 index 00000000000..dbd4b1f1d8d --- /dev/null +++ b/pylabrobot/yolink/message_resolver.py @@ -0,0 +1,207 @@ +"""YoLink cloud message resolver.""" + +from __future__ import annotations + +from decimal import ROUND_DOWN, Decimal +from math import log2 +from typing import TYPE_CHECKING, Any + +from .const import ( + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SPRINKLER, + ATTR_DEVICE_SPRINKLER_V2, + ATTR_DEVICE_WATER_DEPTH_SENSOR, + ATTR_DEVICE_WATER_METER_CONTROLLER, +) +from .unit_helper import UnitOfVolume, VolumeConverter + +if TYPE_CHECKING: + from .device import YoLinkDevice + + +def smart_remoter_message_resolve(msg_data: dict[str, Any], event_type: str) -> None: + """SmartRemoter message resolve.""" + if msg_data is not None: + btn_press_event = msg_data.get("event") + if btn_press_event is not None: + if event_type == "Report": + msg_data["event"] = None + else: + key_mask = btn_press_event["keyMask"] + button_sequence = 0 if key_mask == 0 else (int(log2(key_mask)) + 1) + # replace with button sequence + msg_data["event"]["keyMask"] = button_sequence + + +def water_depth_sensor_message_resolve(msg_data: dict[str, Any], dev_attrs: dict[str, Any]) -> None: + """WaterDepthSensor message resolve.""" + if msg_data is not None: + depth_value = msg_data.get("waterDepth") + if depth_value is not None: + # default range settings if range and desity was not set. + dev_range = 5 + dev_density = 1 + if dev_attrs is not None and (range_attrs := dev_attrs.get("range")) is not None: + dev_range = range_attrs["range"] + dev_density = range_attrs["density"] + msg_data["waterDepth"] = round((dev_range * (depth_value / 1000)) / dev_density, 3) + + +def water_meter_controller_message_resolve(msg_data: dict[str, Any], device_model: str) -> None: + """WaterMeterController message resolve.""" + if msg_data is not None and ((meter_state := msg_data.get("state")) is not None): + meter_step_factor: int = 10 + # for some reason meter value can't be read + meter_value = meter_state.get("meter") + if meter_value is not None: + meter_unit = UnitOfVolume.GALLONS + if (meter_attrs := msg_data.get("attributes")) is not None: + if device_model.startswith("YS5009"): + meter_step_factor = ( + 1 / (_meter_step_factor / (1000 * 100)) + if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None + else 10 + ) + else: + meter_step_factor = ( + _meter_step_factor + if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None + else 10 + ) + meter_unit = ( + UnitOfVolume(_meter_unit) + if (_meter_unit := meter_attrs.get("meterUnit")) is not None + else UnitOfVolume.GALLONS + ) + _meter_reading = None + if meter_step_factor < 0: + _meter_reading = meter_value * abs(meter_step_factor) + else: + _meter_reading = meter_value / meter_step_factor + meter_value = VolumeConverter.convert(_meter_reading, meter_unit, UnitOfVolume.CUBIC_METERS) + msg_data["meter_reading"] = float( + Decimal(meter_value).quantize(Decimal(".00000"), rounding=ROUND_DOWN) + ) + msg_data["valve_state"] = meter_state["valve"] + + +def multi_water_meter_controller_message_resolve( + msg_data: dict[str, Any], + device_model: str, +) -> None: + """MultiWaterMeterController message resolve.""" + if msg_data is not None and ((meter_state := msg_data.get("state")) is not None): + meter_step_factor: int = 10 + meter_reading_values: dict = meter_state.get("meters") + if meter_reading_values is not None: + meter_unit = UnitOfVolume.GALLONS + if (meter_attrs := msg_data.get("attributes")) is not None: + if device_model.startswith("YS5029"): + meter_step_factor = ( + 1 / (_meter_step_factor / (1000 * 100)) + if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None + else 10 + ) + else: + meter_step_factor = ( + _meter_step_factor + if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None + else 10 + ) + meter_unit = ( + UnitOfVolume(_meter_unit) + if (_meter_unit := meter_attrs.get("meterUnit")) is not None + else UnitOfVolume.GALLONS + ) + _meter_1_reading = None + if meter_step_factor < 0: + _meter_1_reading = meter_reading_values["0"] * abs(meter_step_factor) + else: + _meter_1_reading = meter_reading_values["0"] / meter_step_factor + meter_reading_values["0"] = VolumeConverter.convert( + _meter_1_reading, + meter_unit, + UnitOfVolume.CUBIC_METERS, + ) + _meter_2_reading = None + if meter_step_factor < 0: + _meter_2_reading = meter_reading_values["1"] * abs(meter_step_factor) + else: + _meter_2_reading = meter_reading_values["1"] / meter_step_factor + meter_reading_values["1"] = VolumeConverter.convert( + _meter_2_reading, + meter_unit, + UnitOfVolume.CUBIC_METERS, + ) + msg_data["meter_1_reading"] = float( + Decimal(meter_reading_values["0"]).quantize(Decimal(".00000"), rounding=ROUND_DOWN) + ) + msg_data["meter_2_reading"] = float( + Decimal(meter_reading_values["1"]).quantize(Decimal(".00000"), rounding=ROUND_DOWN) + ) + # for some reason meter value can't be read + if (meter_valves := meter_state.get("valves")) is not None: + msg_data["valve_1_state"] = meter_valves["0"] + msg_data["valve_2_state"] = meter_valves["1"] + + +def soil_thc_sensor_message_resolve( + msg_data: dict[str, Any], +) -> None: + """SoilThcSensor message resolve.""" + if msg_data is not None and ((state := msg_data.get("state")) is not None): + msg_data["temperature"] = state.get("temperature") + msg_data["humidity"] = state.get("humidity") + msg_data["conductivity"] = state.get("conductivity") + + +def sprinkler_message_resolve( + device: YoLinkDevice, + msg_data: dict[str, Any], + msg_type: str | None = None, +) -> None: + """Sprinkler message resolve.""" + if msg_data is not None: + if (state := msg_data.get("state")) is not None: + device._state = {"mode": state.get("mode")} + if (watering_data := state.get("watering")) is not None: + msg_data["valve"] = watering_data["left"] != watering_data["total"] + if msg_type == "waterReport": + if device._state is not None: + msg_data["state"] = {"mode": device._state.get("mode")} + if (event := msg_data.get("event")) is not None: + msg_data["valve"] = event == "start" + + +def sprinkler_v2_message_resolve( + msg_data: dict[str, Any], +) -> None: + """Sprinkler V2 message resolve.""" + if msg_data is not None and ((state := msg_data.get("state")) is not None): + msg_data["valve"] = state.get("running") + + +def resolve_message(device: YoLinkDevice, msg_data: dict[str, Any], msg_type: str | None) -> None: + """Resolve device message.""" + if device.device_type == ATTR_DEVICE_WATER_DEPTH_SENSOR: + water_depth_sensor_message_resolve(msg_data, device.device_attrs) + elif device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER: + water_meter_controller_message_resolve(msg_data, device.device_model_name) + elif device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER: + multi_water_meter_controller_message_resolve(msg_data, device.device_model_name) + elif device.device_type == ATTR_DEVICE_SOIL_TH_SENSOR: + soil_thc_sensor_message_resolve(msg_data) + elif device.device_type == ATTR_DEVICE_SPRINKLER: + sprinkler_message_resolve(device, msg_data, msg_type) + elif device.device_type == ATTR_DEVICE_SPRINKLER_V2: + sprinkler_v2_message_resolve(msg_data) + + +def resolve_sub_message(device: YoLinkDevice, msg_data: dict[str, Any], msg_type: str) -> None: + """Resolve device pushing message.""" + if device.device_type == ATTR_DEVICE_SMART_REMOTER: + smart_remoter_message_resolve(msg_data, msg_type) + else: + resolve_message(device, msg_data, msg_type) diff --git a/pylabrobot/yolink/model.py b/pylabrobot/yolink/model.py new file mode 100644 index 00000000000..3f54812721f --- /dev/null +++ b/pylabrobot/yolink/model.py @@ -0,0 +1,55 @@ +"""YoLink Basic Model.""" + +from typing import Any, Dict, Optional + +from pydantic import BaseModel + +from .exception import ( + YoLinkAuthFailError, + YoLinkClientError, + YoLinkDeviceConnectionFailed, + YoLinkUnSupportedMethodError, +) + + +class BRDP(BaseModel): + """BRDP of YoLink API.""" + + code: Optional[str] = None + desc: Optional[str] = None + method: Optional[str] = None + data: Dict[str, Any] = None + event: Optional[str] = None + + def check_response(self): + """Check API Response.""" + if self.code != "000000": + if self.code == "000103": + raise YoLinkAuthFailError(self.code, self.desc) + if self.code == "000201": + raise YoLinkDeviceConnectionFailed(self.code, self.desc) + if self.code == "010203": + raise YoLinkUnSupportedMethodError(self.code, self.desc) + raise YoLinkClientError(self.code, self.desc) + + +class BSDPHelper: + """YoLink API -> BSDP Builder.""" + + _bsdp: Dict + + def __init__(self, device_id: str, device_token: str, method: str): + """Constanst.""" + self._bsdp = {"method": method, "params": {}} + if device_id is not None: + self._bsdp["targetDevice"] = device_id + self._bsdp["token"] = device_token + + def add_params(self, params: Dict): + """Build params of BSDP.""" + self._bsdp["params"].update(params) + return self + + def build(self) -> Dict: + """Generate BSDP.""" + return self._bsdp diff --git a/pylabrobot/yolink/mqtt_client.py b/pylabrobot/yolink/mqtt_client.py new file mode 100644 index 00000000000..23045a01d79 --- /dev/null +++ b/pylabrobot/yolink/mqtt_client.py @@ -0,0 +1,138 @@ +"""YoLink mqtt client.""" + +import asyncio +import logging +from typing import Any + +import aiomqtt +from pydantic import ValidationError + +from .auth_mgr import YoLinkAuthMgr +from .device import YoLinkDevice +from .message_listener import MessageListener +from .message_resolver import resolve_sub_message +from .model import BRDP + +_LOGGER = logging.getLogger(__name__) + + +class YoLinkMqttClient: + """YoLink mqtt client.""" + + def __init__( + self, + auth_manager: YoLinkAuthMgr, + endpoint: str, + broker_host: str, + broker_port: int, + home_devices: dict[str, YoLinkDevice], + ) -> None: + self._auth_mgr = auth_manager + self._endpoint = endpoint + self._broker_host = broker_host + self._broker_port = broker_port + self._home_topic = None + self._message_listener = None + self._home_devices = home_devices + self._running = False + self._listener_task = None + + async def connect(self, home_id: str, listener: MessageListener) -> None: + """Connect to yolink mqtt broker.""" + self._home_topic = f"yl-home/{home_id}/+/report" + self._message_listener = listener + self._listener_task = asyncio.create_task(self._listen()) + + async def _listen(self): + # check and fresh access token + await self._auth_mgr.check_and_refresh_token() + reconnect_interval = 30 + self._running = True + while self._running: + try: + async with aiomqtt.Client( + hostname=self._broker_host, + port=self._broker_port, + username=self._auth_mgr.access_token(), + password="", + keepalive=60, + ) as client: + _LOGGER.info("[%s] connecting to yolink mqtt broker.", self._endpoint) + await client.subscribe(self._home_topic) + _LOGGER.info("[%s] yolink mqtt client connected.", self._endpoint) + async for message in client.messages: + self._process_message(message) + except aiomqtt.MqttError as mqtt_err: + _LOGGER.error( + "[%s] yolink mqtt client disconnected!", + self._endpoint, + exc_info=True, + ) + await asyncio.sleep(reconnect_interval) + if isinstance(mqtt_err, aiomqtt.MqttCodeError): + if mqtt_err.rc in [4, 5]: + _LOGGER.error( + "[%s] token expired or invalid, acquire new one.", + self._endpoint, + ) + await self._auth_mgr.check_and_refresh_token() + except Exception: + _LOGGER.error("[%s] unexcept exception:", self._endpoint, exc_info=True) + + async def disconnect(self) -> None: + """UnRegister listener""" + if self._listener_task is None: + return + self._listener_task.cancel() + self._listener_task = None + self._running = False + + def _process_message(self, msg) -> None: + """Mqtt on message.""" + _LOGGER.debug( + "Received message on %s%s: %s", + msg.topic, + " (retained)" if msg.retain else "", + msg.payload[0:8192], + ) + keys = str(msg.topic).split("/") + if len(keys) == 4 and keys[3] == "report": + try: + device_id = keys[2] + msg_data = BRDP.parse_raw(msg.payload.decode("UTF-8")) + if msg_data.event is None: + return + msg_event = msg_data.event.split(".") + msg_type = msg_event[len(msg_event) - 1] + if msg_type not in [ + "Report", + "Alert", + "StatusChange", + "getState", + "setState", + "DevEvent", + "waterReport", # Sprinkler + ]: + return + device = self._home_devices.get(device_id) + if device is None: + return + paired_device_id = device.get_paired_device_id() + if paired_device_id is not None: + paired_device = self._home_devices.get(paired_device_id) + if paired_device is None: + return + # post current device state to paired device + paired_device_state = {"state": msg_data.data.get("state")} + self.__resolve_message(paired_device, paired_device_state, msg_type) + self.__resolve_message(device, msg_data.data, msg_type) + except ValidationError: + # ignore invalidate message + _LOGGER.debug("Message invalidate.") + + def __resolve_message( + self, device: YoLinkDevice, msg_data: dict[str, Any], msg_type: str + ) -> None: + """Resolve device message.""" + resolve_sub_message(device, msg_data, msg_type) + self._message_listener.on_message(device, msg_data) diff --git a/pylabrobot/yolink/outlet_request_builder.py b/pylabrobot/yolink/outlet_request_builder.py new file mode 100644 index 00000000000..14d50826304 --- /dev/null +++ b/pylabrobot/yolink/outlet_request_builder.py @@ -0,0 +1,16 @@ +"""Outlet request builder""" +from __future__ import annotations + +from .client_request import ClientRequest + + +class OutletRequestBuilder: # pylint: disable=too-few-public-methods + """Outlet request builder""" + + @classmethod + def set_state_request(cls, state: str, plug_indx: int | None) -> ClientRequest: + """Set device state.""" + params: dict[str, str | int] = {"state": state} + if plug_indx is not None: + params["chs"] = 1 << plug_indx + return ClientRequest("setState", params) diff --git a/pylabrobot/yolink/pyproject.toml b/pylabrobot/yolink/pyproject.toml new file mode 100644 index 00000000000..56091fae03e --- /dev/null +++ b/pylabrobot/yolink/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=77.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "yolink-api" +version = "0.5.8" +license = "MIT" +license-files = ["LICENSE"] +description = "A library to authenticate with yolink device" +readme = "README.md" +authors = [{ name = "YoSmart" }] +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +keywords = ["yolink", "api"] +dependencies = [ + "aiohttp>=3.8.1", + "aiomqtt>=2.0.0,<3.0.0", + "pydantic>=2.0.0", + "tenacity>=8.1.0", +] + +[project.urls] +"Source" = "https://github.com/YoSmart-Inc/yolink-api" +"Bug Tracker" = "https://github.com/YoSmart-Inc/yolink-api/issues" + +[tool.setuptools.packages.find] +include = ["yolink*"] diff --git a/pylabrobot/yolink/thermostat_request_builder.py b/pylabrobot/yolink/thermostat_request_builder.py new file mode 100644 index 00000000000..746069d7c16 --- /dev/null +++ b/pylabrobot/yolink/thermostat_request_builder.py @@ -0,0 +1,32 @@ +"""Thermostat request builder""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + +from .client_request import ClientRequest + + +class ThermostatState(BaseModel): + """Thermostat State.""" + + lowTemp: Optional[float] = None + highTemp: Optional[float] = None + mode: Optional[str] = None + fan: Optional[str] = None + sche: Optional[str] = None + + +class ThermostatRequestBuilder: # pylint: disable=too-few-public-methods + """Thermostat request builder""" + + @classmethod + def set_state_request(cls, state: ThermostatState) -> ClientRequest: + """Set device state.""" + return ClientRequest("setState", state.dict(exclude_none=True)) + + @classmethod + def set_eco_request(cls, state: str) -> ClientRequest: + """Enable/Disable eco mode.""" + return ClientRequest("setECO", {"mode": state}) diff --git a/pylabrobot/yolink/unit_helper.py b/pylabrobot/yolink/unit_helper.py new file mode 100644 index 00000000000..e43a8e3c865 --- /dev/null +++ b/pylabrobot/yolink/unit_helper.py @@ -0,0 +1,100 @@ +"""YoLink Unit convert helper.""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import IntEnum +from functools import lru_cache + +from .const import UNIT_NOT_RECOGNIZED_TEMPLATE +from .exception import YoLinkError + + +class UnitOfVolume(IntEnum): + """Unit of meter.""" + + GALLONS = 0 + CENTUM_CUBIC_FEET = 1 + CUBIC_METERS = 2 + LITERS = 3 + + +_IN_TO_M = 0.0254 # 1 inch = 0.0254 m +_FOOT_TO_M = _IN_TO_M * 12 # 12 inches = 1 foot (0.3048 m) +_L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ +_GALLON_TO_CUBIC_METER = 231 * pow(_IN_TO_M, 3) # US gallon is 231 cubic inches +_CUBIC_FOOT_TO_CUBIC_METER = pow(_FOOT_TO_M, 3) + + +# source code from homeassistant.util.unit_conversion.py +class BaseUnitConverter: + """Define the format of a conversion utility.""" + + UNIT_CLASS: str + NORMALIZED_UNIT: str | None + VALID_UNITS: set[str | None] + + _UNIT_CONVERSION: dict[str | None, float] + + @classmethod + def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: + """Convert one unit of measurement to another.""" + return cls.converter_factory(from_unit, to_unit)(value) + + @classmethod + @lru_cache + def converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Return a function to convert one unit of measurement to another.""" + if from_unit == to_unit: + return lambda value: value + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: (val / from_ratio) * to_ratio + + @classmethod + def _get_from_to_ratio(cls, from_unit: str | None, to_unit: str | None) -> tuple[float, float]: + """Get unit ratio between units of measurement.""" + unit_conversion = cls._UNIT_CONVERSION + try: + return unit_conversion[from_unit], unit_conversion[to_unit] + except KeyError as err: + raise YoLinkError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(err.args[0], cls.UNIT_CLASS)) from err + + @classmethod + @lru_cache + def converter_factory_allow_none( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float | None], float | None]: + """Return a function to convert one unit of measurement to another which allows None.""" + if from_unit == to_unit: + return lambda value: value + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: None if val is None else (val / from_ratio) * to_ratio + + @classmethod + @lru_cache + def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float: + """Get unit ratio between units of measurement.""" + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return from_ratio / to_ratio + + +class VolumeConverter(BaseUnitConverter): + """Utility to convert volume values.""" + + UNIT_CLASS = "volume" + NORMALIZED_UNIT = UnitOfVolume.CUBIC_METERS + # Units in terms of m³ + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfVolume.LITERS: 1 / _L_TO_CUBIC_METER, + UnitOfVolume.GALLONS: 1 / _GALLON_TO_CUBIC_METER, + UnitOfVolume.CUBIC_METERS: 1, + UnitOfVolume.CENTUM_CUBIC_FEET: 1 / (100 * _CUBIC_FOOT_TO_CUBIC_METER), + } + VALID_UNITS = { + UnitOfVolume.LITERS, + UnitOfVolume.GALLONS, + UnitOfVolume.CUBIC_METERS, + UnitOfVolume.CENTUM_CUBIC_FEET, + } diff --git a/pylabrobot/yolink/yolink.py b/pylabrobot/yolink/yolink.py new file mode 100644 index 00000000000..046ad88bd93 --- /dev/null +++ b/pylabrobot/yolink/yolink.py @@ -0,0 +1,421 @@ +"""YoLink backend implementation for PyLabRobot devices.""" + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +import aiohttp + +from .auth_mgr import YoLinkAuthMgr +from .device import YoLinkDevice +from .home_manager import YoLinkHome +from .message_listener import MessageListener +from .outlet_request_builder import OutletRequestBuilder + +logger = logging.getLogger(__name__) + + +class YoLinkAuthMgr(YoLinkAuthMgr): + """Authentication manager for YoLink API.""" + + def __init__(self, session: aiohttp.ClientSession, access_token: str): + super().__init__(session) + self._access_token = access_token + + def access_token(self) -> str: + return self._access_token + + async def check_and_refresh_token(self) -> str: + # TODO: Implement token refresh logic here + return self._access_token + + +class YoLinkMessageListener(MessageListener): + """Message listener for YoLink device updates.""" + + def __init__(self): + self._callbacks: Dict[str, callable] = {} + + def register_callback(self, device_id: str, callback: callable): + """Register a callback for device updates.""" + self._callbacks[device_id] = callback + + def on_message(self, device: YoLinkDevice, msg_data: Dict[str, Any]) -> None: + logger.debug(f"Device {device.device_name} ({device.device_id}): {msg_data}") + if device.device_id in self._callbacks: + self._callbacks[device.device_id](msg_data) + + +class YoLink: + """YoLink backend for PyLabRobot devices.""" + + def __init__(self, api_key: str): + """Initialize YoLink backend. + + Args: + api_key: YoLink API access token + """ + self.api_key = api_key + self._session: Optional[aiohttp.ClientSession] = None + self._auth_mgr: Optional[YoLinkAuthMgr] = None + self._home: Optional[YoLinkHome] = None + self._listener: Optional[YoLinkMessageListener] = None + self._is_setup = False + + async def setup(self) -> None: + """Set up the YoLink backend connection.""" + if self._is_setup: + logger.warning("YoLink backend already set up") + return + + try: + # Create HTTP session + self._session = aiohttp.ClientSession() + + # Initialize authentication manager + self._auth_mgr = YoLinkAuthMgr(self._session, self.api_key) + + # Initialize message listener + self._listener = YoLinkMessageListener() + + # Initialize home manager + self._home = YoLinkHome() + await self._home.async_setup(self._auth_mgr, self._listener) + + self._is_setup = True + logger.info(f"YoLink backend set up") + + except Exception as e: + logger.error(f"Failed to set up YoLink backend: {e}") + await self.stop() + raise + + async def stop(self) -> None: + """Stop the YoLink backend and clean up resources.""" + if not self._is_setup: + return + + try: + if self._home: + await self._home.async_unload() + self._home = None + + if self._session: + await self._session.close() + self._session = None + + self._auth_mgr = None + self._listener = None + self._is_setup = False + + logger.info("YoLink backend stopped") + + except Exception as e: + logger.error(f"Error stopping YoLink backend: {e}") + + def _get_all_devices(self) -> List[YoLinkDevice]: + """Get all devices in the home to call later.""" + devices = list(self._home.get_devices()) + return devices + + def _ensure_setup(self) -> None: + """Ensure the backend is set up before operations.""" + if not self._is_setup or self._device is None: + raise RuntimeError("YoLink backend not set up. Call setup() first.") + + @property + def is_setup(self) -> bool: + """Check if the backend is set up.""" + return self._is_setup + + +class Sensor: + """YoLink sensor device wrapper for PyLabRobot.""" + + def __init__(self, backend: YoLink, sensor_name: str): + """Initialize YoLink sensor. + + Args: + backend: YoLink backend instance + sensor_name: Name of the specific sensor device + """ + self.backend = backend + self.sensor_name = sensor_name + self._device: Optional[YoLinkDevice] = None + + async def setup(self) -> None: + """Set up the sensor device.""" + # Ensure backend is set up + if not self.backend.is_setup: + await self.backend.setup() + + # Find the specific sensor device + devices = self.backend._get_all_devices() + self._device = None + + for device in devices: + if device.device_name == self.sensor_name: + self._device = device + break + + if self._device is None: + available_devices = [d.device_name for d in devices] + raise ValueError( + f"Sensor '{self.sensor_name}' not found. " f"Available devices: {available_devices}" + ) + + async def get_temperature(self) -> float: + """Get temperature reading from sensor device. + + Returns: + Temperature in degrees Celsius + """ + self._ensure_device_ready() + + try: + state = await self._device.get_state() + temperature = state.data["state"]["temperature"] + + if temperature is None: + raise ValueError("Temperature data not available from device") + + logger.debug(f"Temperature reading: {temperature}°C") + return float(temperature) + + except Exception as e: + logger.error(f"Failed to get temperature: {e}") + raise + + async def get_humidity(self) -> float: + """Get humidity reading from sensor device. + + Returns: + Relative humidity percentage (0-100) + """ + self._ensure_device_ready() + + try: + state = await self._device.get_state() + humidity = state.data["state"]["humidity"] + + if humidity is None: + raise ValueError("Humidity data not available from device") + + logger.debug(f"Humidity reading: {humidity}%") + return float(humidity) + + except Exception as e: + logger.error(f"Failed to get humidity: {e}") + raise + + # async def get_battery_level(self) -> Optional[int]: + # """Get battery level if available. + + # Returns: + # Battery level percentage (0-100) or None if not available + # """ + # self._ensure_device_ready() + + # try: + # state = await self._device.get_state() + # battery_data = state.get('data', {}).get('state', {}) + # battery = battery_data.get('battery') + + # if battery is not None: + # logger.debug(f"Battery level: {battery}%") + # return int(battery) + # return None + + # except Exception as e: + # logger.error(f"Failed to get battery level: {e}") + # return None + + # async def get_all_readings(self) -> Dict[str, Any]: + # """Get all available sensor readings. + + # Returns: + # Dictionary with all available sensor data + # """ + # self._ensure_device_ready() + + # try: + # state = await self._device.get_state() + # sensor_data = state.get('data', {}).get('state', {}) + # logger.debug(f"All sensor readings: {sensor_data}") + # return sensor_data + + # except Exception as e: + # logger.error(f"Failed to get sensor readings: {e}") + # raise + + async def stop(self) -> None: + """Stop the sensor and clean up resources.""" + logger.info(f"Stopping sensor '{self.sensor_name}'") + await self.backend.stop() + self._device = None + + def _ensure_device_ready(self) -> None: + """Ensure the device is ready for operations.""" + if self._device is None: + raise RuntimeError("Sensor not set up. Call setup() first.") + if not self.backend.is_setup: + raise RuntimeError("Backend not set up. Call setup() first.") + + @property + def device_name(self) -> str: + """Get the device name.""" + return self.sensor_name + + @property + def device_id(self) -> Optional[str]: + """Get the device ID if available.""" + return self._device.device_id if self._device else None + + @property + def is_online(self) -> bool: + """Check if the device is online.""" + if self._device is None: + return False + # Implement based on YoLinkDevice API + return getattr(self._device, "is_online", True) + + +# class Outlet: +# """YoLink outlet device wrapper for PyLabRobot.""" + +# def __init__(self, backend: YoLink, outlet_name: str): +# """Initialize YoLink outlet. + +# Args: +# backend: YoLink backend instance +# outlet_name: Name of the specific outlet device +# """ +# self.backend = backend +# self.outlet_name = outlet_name +# self._device: Optional[YoLinkDevice] = None + +# async def setup(self) -> None: +# """Set up the outlet device.""" +# # Ensure backend is set up +# if not self.backend.is_setup: +# await self.backend.setup() + +# # Find the specific outlet device +# devices = self.backend._get_all_devices() +# self._device = None + +# for device in devices: +# if device.device_name == self.outlet_name: +# self._device = device +# break + +# if self._device is None: +# available_devices = [d.device_name for d in devices] +# raise ValueError( +# f"Outlet '{self.outlet_name}' not found. " +# f"Available devices: {available_devices}" +# ) + +# # Set the device in backend for compatibility +# self.backend._device = self._device +# logger.info(f"Outlet '{self.outlet_name}' set up successfully") + +# async def turn_on(self, outlet_index: int = 0) -> None: +# """Turn on a specific outlet. + +# Args: +# outlet_index: Index of the outlet to turn on (0-based) +# """ +# self._ensure_device_ready() + +# try: +# if hasattr(self._device, 'turn_on'): +# await self._device.turn_on(outlet_index) +# else: +# # Use OutletRequestBuilder for custom commands +# request_builder = OutletRequestBuilder() +# command = request_builder.build_turn_on_request(outlet_index) +# await self._device.send_command(command) + +# logger.info(f"Turned on outlet {outlet_index}") + +# except Exception as e: +# logger.error(f"Failed to turn on outlet {outlet_index}: {e}") +# raise + +# async def turn_off(self, outlet_index: int = 0) -> None: +# """Turn off a specific outlet. + +# Args: +# outlet_index: Index of the outlet to turn off (0-based) +# """ +# self._ensure_device_ready() + +# try: +# if hasattr(self._device, 'turn_off'): +# await self._device.turn_off(outlet_index) +# else: +# # Use OutletRequestBuilder for custom commands +# request_builder = OutletRequestBuilder() +# command = request_builder.build_turn_off_request(outlet_index) +# await self._device.send_command(command) + +# logger.info(f"Turned off outlet {outlet_index}") + +# except Exception as e: +# logger.error(f"Failed to turn off outlet {outlet_index}: {e}") +# raise + +# async def get_status(self, outlet_index: int = 0) -> bool: +# """Get the status of a specific outlet. + +# Args: +# outlet_index: Index of the outlet to check + +# Returns: +# True if outlet is on, False if off +# """ +# self._ensure_device_ready() + +# try: +# state = await self._device.get_state() +# outlet_data = state.get('data', {}).get('state', {}) + +# # Handle different outlet state formats +# if 'outlets' in outlet_data: +# outlets = outlet_data['outlets'] +# if outlet_index < len(outlets): +# return outlets[outlet_index].get('on', False) +# elif f'outlet_{outlet_index}' in outlet_data: +# return outlet_data[f'outlet_{outlet_index}'].get('on', False) +# elif 'power' in outlet_data and outlet_index == 0: +# return outlet_data['power'] + +# return False + +# except Exception as e: +# logger.error(f"Failed to get outlet {outlet_index} status: {e}") +# raise + +# async def stop(self) -> None: +# """Stop the outlet and clean up resources.""" +# logger.info(f"Stopping outlet '{self.outlet_name}'") +# await self.backend.stop() +# self._device = None + +# def _ensure_device_ready(self) -> None: +# """Ensure the device is ready for operations.""" +# if self._device is None: +# raise RuntimeError("Outlet not set up. Call setup() first.") +# if not self.backend.is_setup: +# raise RuntimeError("Backend not set up. Call setup() first.") + +# @property +# def device_name(self) -> str: +# """Get the device name.""" +# return self.outlet_name + +# @property +# def device_id(self) -> Optional[str]: +# """Get the device ID if available.""" +# return self._device.device_id if self._device else None diff --git a/pylabrobot/yolink/yolink_backend.py b/pylabrobot/yolink/yolink_backend.py deleted file mode 100644 index 561bfa78d39..00000000000 --- a/pylabrobot/yolink/yolink_backend.py +++ /dev/null @@ -1,16 +0,0 @@ -from abc import abstractmethod - -from pylabrobot.machine import MachineBackend - - -class YoLinkBackend(MachineBackend): - def __init__(self, api_key: str, device_id: str): - super().__init__() - self.api_key = api_key - self.device_id = device_id - - @abstractmethod - async def get_status(self): - pass - - # Add your YoLink API methods here diff --git a/pylabrobot/yolink/yolink_device.py b/pylabrobot/yolink/yolink_device.py deleted file mode 100644 index bb3fd4dbaa4..00000000000 --- a/pylabrobot/yolink/yolink_device.py +++ /dev/null @@ -1,9 +0,0 @@ -from pylabrobot.machine import Machine - - -class YoLinkDevice(Machine): - def __init__(self, backend: YoLinkBackend): - super().__init__(backend=backend) - - async def get_device_status(self): - return await self.backend.get_status() From a03ec98c8c0205ee4ecd3db2d72e78ee1698db54 Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:20:46 -0400 Subject: [PATCH 3/6] Refine backend and sensor setup, add more methods to get data --- pylabrobot/yolink/__init__.py | 2 - pylabrobot/yolink/yolink.py | 306 ++++++++++++++++++++++++++++------ 2 files changed, 253 insertions(+), 55 deletions(-) diff --git a/pylabrobot/yolink/__init__.py b/pylabrobot/yolink/__init__.py index 642a184b32d..525a0864cdc 100644 --- a/pylabrobot/yolink/__init__.py +++ b/pylabrobot/yolink/__init__.py @@ -1,5 +1,3 @@ -import aiohttp # this needs to be added to pylabrobot dependencies if yolink is to be used - from .yolink import ( Sensor, YoLink, diff --git a/pylabrobot/yolink/yolink.py b/pylabrobot/yolink/yolink.py index 046ad88bd93..0e2043516a8 100644 --- a/pylabrobot/yolink/yolink.py +++ b/pylabrobot/yolink/yolink.py @@ -1,8 +1,10 @@ """YoLink backend implementation for PyLabRobot devices.""" import asyncio +import json import logging -from typing import Any, Dict, List, Optional +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Union import aiohttp @@ -15,19 +17,118 @@ logger = logging.getLogger(__name__) +class YoLinkTokenManager: + """Manages YoLink API token lifecycle.""" + + def __init__(self, api_host: str = "https://api.yosmart.com"): + self.api_host = api_host + self._access_token: Optional[str] = None + self._refresh_token: Optional[str] = None + self._token_expires_at: Optional[datetime] = None + + async def get_access_token_from_credentials( + self, session: aiohttp.ClientSession, client_id: str, client_secret: str + ) -> Dict[str, Any]: + """Get access token using UAC credentials.""" + token_url = f"{self.api_host}/open/yolink/token" + + payload = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + + try: + async with session.post(token_url, data=payload) as response: + response.raise_for_status() + token_data = await response.json() + + self._access_token = token_data.get("access_token") + self._refresh_token = token_data.get("refresh_token") + + # Calculate expiration time + expires_in = token_data.get("expires_in", 3600) # Default 1 hour + self._token_expires_at = datetime.now() + timedelta( + seconds=expires_in - 300 + ) # 5 min buffer + + logger.info("Successfully obtained access token from credentials") + return token_data + + except aiohttp.ClientError as e: + logger.error(f"Failed to get access token from credentials: {e}") + raise + + async def refresh_access_token( + self, session: aiohttp.ClientSession, client_id: str + ) -> Dict[str, Any]: + """Refresh access token using refresh token.""" + if not self._refresh_token: + raise ValueError("No refresh token available") + + token_url = f"{self.api_host}/open/yolink/token" + + payload = { + "grant_type": "refresh_token", + "client_id": client_id, + "refresh_token": self._refresh_token, + } + + try: + async with session.post(token_url, data=payload) as response: + response.raise_for_status() + token_data = await response.json() + + self._access_token = token_data.get("access_token") + # Refresh token might be updated + if "refresh_token" in token_data: + self._refresh_token = token_data["refresh_token"] + + # Calculate expiration time + expires_in = token_data.get("expires_in", 3600) + self._token_expires_at = datetime.now() + timedelta(seconds=expires_in - 300) + + logger.info("Successfully refreshed access token") + return token_data + + except aiohttp.ClientError as e: + logger.error(f"Failed to refresh access token: {e}") + raise + + def is_token_expired(self) -> bool: + """Check if the current token is expired or about to expire.""" + if not self._token_expires_at: + return True + return datetime.now() >= self._token_expires_at + + @property + def access_token(self) -> Optional[str]: + """Get the current access token.""" + return self._access_token + + @property + def refresh_token(self) -> Optional[str]: + """Get the current refresh token.""" + return self._refresh_token + + class YoLinkAuthMgr(YoLinkAuthMgr): """Authentication manager for YoLink API.""" - def __init__(self, session: aiohttp.ClientSession, access_token: str): + def __init__(self, session: aiohttp.ClientSession, token_manager: YoLinkTokenManager): super().__init__(session) - self._access_token = access_token + self._token_manager = token_manager def access_token(self) -> str: - return self._access_token + return self._token_manager.access_token or "" async def check_and_refresh_token(self) -> str: - # TODO: Implement token refresh logic here - return self._access_token + """Check token validity and refresh if needed.""" + if self._token_manager.is_token_expired(): + logger.info("Token expired, attempting refresh") + # This would need additional client_id parameter - handle in YoLink class + pass + return self._token_manager.access_token or "" class YoLinkMessageListener(MessageListener): @@ -49,19 +150,78 @@ def on_message(self, device: YoLinkDevice, msg_data: Dict[str, Any]) -> None: class YoLink: """YoLink backend for PyLabRobot devices.""" - def __init__(self, api_key: str): + def __init__( + self, + api_key: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + refresh_token: Optional[str] = None, + api_host: str = "https://api.yosmart.com", + ): """Initialize YoLink backend. Args: - api_key: YoLink API access token + api_key: Direct API access token (if already obtained) + client_id: Your UAID (required if using credentials or refresh token) + client_secret: Your UAC Secret Key (for getting initial token) + refresh_token: Your refresh token (for token refresh) + api_host: API host URL (default: https://api.yosmart.com) + + Note: + You must provide either: + - api_key (for direct token usage) + - client_id + client_secret (for credential-based auth) """ - self.api_key = api_key + self.client_id = client_id + self.client_secret = client_secret + self.api_host = api_host + + # Initialize token manager + self._token_manager = YoLinkTokenManager(api_host) + + # If direct API key provided, use it + if api_key: + self._token_manager._access_token = api_key + + # If refresh token provided, store it + if refresh_token: + self._token_manager._refresh_token = refresh_token + self._session: Optional[aiohttp.ClientSession] = None self._auth_mgr: Optional[YoLinkAuthMgr] = None self._home: Optional[YoLinkHome] = None self._listener: Optional[YoLinkMessageListener] = None self._is_setup = False + @classmethod + def from_credentials( + cls, client_id: str, client_secret: str, api_host: str = "https://api.yosmart.com" + ): + """Create YoLink instance using UAC credentials. + + Args: + client_id: Your UAID + client_secret: Your UAC Secret Key + api_host: API host URL + + Returns: + YoLink instance configured for credential-based authentication + """ + return cls(client_id=client_id, client_secret=client_secret, api_host=api_host) + + @classmethod + def from_access_token(cls, access_token: str, api_host: str = "https://api.yosmart.com"): + """Create YoLink instance using direct access token. + + Args: + access_token: Direct API access token + api_host: API host URL + + Returns: + YoLink instance configured for direct token usage + """ + return cls(api_key=access_token, api_host=api_host) + async def setup(self) -> None: """Set up the YoLink backend connection.""" if self._is_setup: @@ -72,8 +232,11 @@ async def setup(self) -> None: # Create HTTP session self._session = aiohttp.ClientSession() + # Ensure we have a valid access token + await self._ensure_access_token() + # Initialize authentication manager - self._auth_mgr = YoLinkAuthMgr(self._session, self.api_key) + self._auth_mgr = YoLinkAuthMgr(self._session, self._token_manager) # Initialize message listener self._listener = YoLinkMessageListener() @@ -83,13 +246,38 @@ async def setup(self) -> None: await self._home.async_setup(self._auth_mgr, self._listener) self._is_setup = True - logger.info(f"YoLink backend set up") + logger.info("YoLink backend set up successfully") except Exception as e: logger.error(f"Failed to set up YoLink backend: {e}") await self.stop() raise + async def _ensure_access_token(self) -> None: + """Ensure we have a valid access token.""" + if not self.client_id: + if not self._token_manager.access_token: + raise ValueError("No access token or client credentials provided") + return + + # If token is expired or missing, get/refresh it + if self._token_manager.is_token_expired() or not self._token_manager.access_token: + if self.client_secret: + # Get token using credentials + await self._token_manager.get_access_token_from_credentials( + self._session, self.client_id, self.client_secret + ) + elif self._token_manager.refresh_token: + # Refresh using refresh token + await self._token_manager.refresh_access_token(self._session, self.client_id) + else: + raise ValueError("No valid authentication method available") + + async def refresh_token_if_needed(self) -> None: + """Check and refresh token if needed (can be called externally).""" + if self._token_manager.is_token_expired() and self._session: + await self._ensure_access_token() + async def stop(self) -> None: """Stop the YoLink backend and clean up resources.""" if not self._is_setup: @@ -115,12 +303,14 @@ async def stop(self) -> None: def _get_all_devices(self) -> List[YoLinkDevice]: """Get all devices in the home to call later.""" + if not self._home: + return [] devices = list(self._home.get_devices()) return devices def _ensure_setup(self) -> None: """Ensure the backend is set up before operations.""" - if not self._is_setup or self._device is None: + if not self._is_setup: raise RuntimeError("YoLink backend not set up. Call setup() first.") @property @@ -128,6 +318,16 @@ def is_setup(self) -> bool: """Check if the backend is set up.""" return self._is_setup + @property + def current_access_token(self) -> Optional[str]: + """Get the current access token.""" + return self._token_manager.access_token + + @property + def current_refresh_token(self) -> Optional[str]: + """Get the current refresh token.""" + return self._token_manager.refresh_token + class Sensor: """YoLink sensor device wrapper for PyLabRobot.""" @@ -174,7 +374,8 @@ async def get_temperature(self) -> float: try: state = await self._device.get_state() - temperature = state.data["state"]["temperature"] + + temperature = getattr(state, "data", {}).get("state", {}).get("temperature") if temperature is None: raise ValueError("Temperature data not available from device") @@ -196,7 +397,7 @@ async def get_humidity(self) -> float: try: state = await self._device.get_state() - humidity = state.data["state"]["humidity"] + humidity = getattr(state, "data", {}).get("state", {}).get("humidity") if humidity is None: raise ValueError("Humidity data not available from device") @@ -208,45 +409,44 @@ async def get_humidity(self) -> float: logger.error(f"Failed to get humidity: {e}") raise - # async def get_battery_level(self) -> Optional[int]: - # """Get battery level if available. - - # Returns: - # Battery level percentage (0-100) or None if not available - # """ - # self._ensure_device_ready() - - # try: - # state = await self._device.get_state() - # battery_data = state.get('data', {}).get('state', {}) - # battery = battery_data.get('battery') - - # if battery is not None: - # logger.debug(f"Battery level: {battery}%") - # return int(battery) - # return None - - # except Exception as e: - # logger.error(f"Failed to get battery level: {e}") - # return None - - # async def get_all_readings(self) -> Dict[str, Any]: - # """Get all available sensor readings. - - # Returns: - # Dictionary with all available sensor data - # """ - # self._ensure_device_ready() - - # try: - # state = await self._device.get_state() - # sensor_data = state.get('data', {}).get('state', {}) - # logger.debug(f"All sensor readings: {sensor_data}") - # return sensor_data - - # except Exception as e: - # logger.error(f"Failed to get sensor readings: {e}") - # raise + async def get_battery_level(self) -> Optional[int]: + """Get battery level if available. + + Returns: + Battery level percentage (0-100) or None if not available + """ + self._ensure_device_ready() + + try: + state = await self._device.get_state() + battery = getattr(state, "data", {}).get("state", {}).get("battery") + + if battery is not None: + logger.debug(f"Battery level: {battery}%") + return int(battery) + return None + + except Exception as e: + logger.error(f"Failed to get battery level: {e}") + return None + + async def get_all_readings(self) -> Dict[str, Any]: + """Get all available sensor readings. + + Returns: + Dictionary with all available sensor data + """ + self._ensure_device_ready() + + try: + state = await self._device.get_state() + sensor_data = getattr(state, "data", {}) + logger.debug(f"All sensor readings: {sensor_data}") + return sensor_data + + except Exception as e: + logger.error(f"Failed to get sensor readings: {e}") + raise async def stop(self) -> None: """Stop the sensor and clean up resources.""" From d224dc89315384ce467751beb83b82b62e9c17a2 Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:28:47 -0400 Subject: [PATCH 4/6] Add outlet class to interface with outlet api --- pylabrobot/yolink/__init__.py | 1 + pylabrobot/yolink/yolink.py | 259 ++++++++++++++++------------------ 2 files changed, 120 insertions(+), 140 deletions(-) diff --git a/pylabrobot/yolink/__init__.py b/pylabrobot/yolink/__init__.py index 525a0864cdc..0579b5519fd 100644 --- a/pylabrobot/yolink/__init__.py +++ b/pylabrobot/yolink/__init__.py @@ -1,4 +1,5 @@ from .yolink import ( + Outlet, Sensor, YoLink, ) diff --git a/pylabrobot/yolink/yolink.py b/pylabrobot/yolink/yolink.py index 0e2043516a8..0b2f9a87b2c 100644 --- a/pylabrobot/yolink/yolink.py +++ b/pylabrobot/yolink/yolink.py @@ -374,7 +374,6 @@ async def get_temperature(self) -> float: try: state = await self._device.get_state() - temperature = getattr(state, "data", {}).get("state", {}).get("temperature") if temperature is None: @@ -480,142 +479,122 @@ def is_online(self) -> bool: return getattr(self._device, "is_online", True) -# class Outlet: -# """YoLink outlet device wrapper for PyLabRobot.""" - -# def __init__(self, backend: YoLink, outlet_name: str): -# """Initialize YoLink outlet. - -# Args: -# backend: YoLink backend instance -# outlet_name: Name of the specific outlet device -# """ -# self.backend = backend -# self.outlet_name = outlet_name -# self._device: Optional[YoLinkDevice] = None - -# async def setup(self) -> None: -# """Set up the outlet device.""" -# # Ensure backend is set up -# if not self.backend.is_setup: -# await self.backend.setup() - -# # Find the specific outlet device -# devices = self.backend._get_all_devices() -# self._device = None - -# for device in devices: -# if device.device_name == self.outlet_name: -# self._device = device -# break - -# if self._device is None: -# available_devices = [d.device_name for d in devices] -# raise ValueError( -# f"Outlet '{self.outlet_name}' not found. " -# f"Available devices: {available_devices}" -# ) - -# # Set the device in backend for compatibility -# self.backend._device = self._device -# logger.info(f"Outlet '{self.outlet_name}' set up successfully") - -# async def turn_on(self, outlet_index: int = 0) -> None: -# """Turn on a specific outlet. - -# Args: -# outlet_index: Index of the outlet to turn on (0-based) -# """ -# self._ensure_device_ready() - -# try: -# if hasattr(self._device, 'turn_on'): -# await self._device.turn_on(outlet_index) -# else: -# # Use OutletRequestBuilder for custom commands -# request_builder = OutletRequestBuilder() -# command = request_builder.build_turn_on_request(outlet_index) -# await self._device.send_command(command) - -# logger.info(f"Turned on outlet {outlet_index}") - -# except Exception as e: -# logger.error(f"Failed to turn on outlet {outlet_index}: {e}") -# raise - -# async def turn_off(self, outlet_index: int = 0) -> None: -# """Turn off a specific outlet. - -# Args: -# outlet_index: Index of the outlet to turn off (0-based) -# """ -# self._ensure_device_ready() - -# try: -# if hasattr(self._device, 'turn_off'): -# await self._device.turn_off(outlet_index) -# else: -# # Use OutletRequestBuilder for custom commands -# request_builder = OutletRequestBuilder() -# command = request_builder.build_turn_off_request(outlet_index) -# await self._device.send_command(command) - -# logger.info(f"Turned off outlet {outlet_index}") - -# except Exception as e: -# logger.error(f"Failed to turn off outlet {outlet_index}: {e}") -# raise - -# async def get_status(self, outlet_index: int = 0) -> bool: -# """Get the status of a specific outlet. - -# Args: -# outlet_index: Index of the outlet to check - -# Returns: -# True if outlet is on, False if off -# """ -# self._ensure_device_ready() - -# try: -# state = await self._device.get_state() -# outlet_data = state.get('data', {}).get('state', {}) - -# # Handle different outlet state formats -# if 'outlets' in outlet_data: -# outlets = outlet_data['outlets'] -# if outlet_index < len(outlets): -# return outlets[outlet_index].get('on', False) -# elif f'outlet_{outlet_index}' in outlet_data: -# return outlet_data[f'outlet_{outlet_index}'].get('on', False) -# elif 'power' in outlet_data and outlet_index == 0: -# return outlet_data['power'] - -# return False - -# except Exception as e: -# logger.error(f"Failed to get outlet {outlet_index} status: {e}") -# raise - -# async def stop(self) -> None: -# """Stop the outlet and clean up resources.""" -# logger.info(f"Stopping outlet '{self.outlet_name}'") -# await self.backend.stop() -# self._device = None - -# def _ensure_device_ready(self) -> None: -# """Ensure the device is ready for operations.""" -# if self._device is None: -# raise RuntimeError("Outlet not set up. Call setup() first.") -# if not self.backend.is_setup: -# raise RuntimeError("Backend not set up. Call setup() first.") - -# @property -# def device_name(self) -> str: -# """Get the device name.""" -# return self.outlet_name - -# @property -# def device_id(self) -> Optional[str]: -# """Get the device ID if available.""" -# return self._device.device_id if self._device else None +class Outlet: + """YoLink outlet device wrapper for PyLabRobot.""" + + def __init__(self, backend: YoLink, outlet_name: str): + """Initialize YoLink outlet. + + Args: + backend: YoLink backend instance + outlet_name: Name of the specific outlet device + """ + self.backend = backend + self.outlet_name = outlet_name + self._device: Optional[YoLinkDevice] = None + + async def setup(self) -> None: + """Set up the outlet device.""" + # Ensure backend is set up + if not self.backend.is_setup: + await self.backend.setup() + + # Find the specific outlet device + devices = self.backend._get_all_devices() + self._device = None + + for device in devices: + if device.device_name == self.outlet_name: + self._device = device + break + + if self._device is None: + available_devices = [d.device_name for d in devices] + raise ValueError( + f"Outlet '{self.outlet_name}' not found. " f"Available devices: {available_devices}" + ) + + async def turn_on(self, outlet_index: int = 0) -> None: + """Turn on a specific outlet. + + Args: + outlet_index: Index of the outlet to turn on (0-based) + """ + self._ensure_device_ready() + + try: + request = OutletRequestBuilder.set_state_request("open", outlet_index) + response = await self._device.call_device(request) + + logger.info(f"Turned on outlet {outlet_index}") + + except Exception as e: + logger.error(f"Failed to turn on outlet {outlet_index}: {e}") + raise + + async def turn_off(self, outlet_index: int = 0) -> None: + """Turn off a specific outlet. + + Args: + outlet_index: Index of the outlet to turn off (0-based) + """ + self._ensure_device_ready() + + try: + request = OutletRequestBuilder.set_state_request("close", outlet_index) + response = await self._device.call_device(request) + + logger.info(f"Turned off outlet {outlet_index}") + + except Exception as e: + logger.error(f"Failed to turn off outlet {outlet_index}: {e}") + raise + + async def get_status(self, outlet_index: int = -1) -> bool: + """Get the status of a specific outlet. + + Args: + outlet_index: Index of the outlet to check. -1 returns all outlet states + + Returns: + True if outlet is on, False if off + """ + self._ensure_device_ready() + + try: + state = await self._device.get_state() + outlet_data = getattr(state, "data", {}).get("state", {}) + + if outlet_index > 7 or outlet_index < -1: + raise ValueError("Invalid outlet index") + + if outlet_index == -1: + return outlet_data + return outlet_data[outlet_index] + + except Exception as e: + logger.error(f"Failed to get outlet {outlet_index} status: {e}") + raise + + async def stop(self) -> None: + """Stop the outlet and clean up resources.""" + logger.info(f"Stopping outlet '{self.outlet_name}'") + await self.backend.stop() + self._device = None + + def _ensure_device_ready(self) -> None: + """Ensure the device is ready for operations.""" + if self._device is None: + raise RuntimeError("Outlet not set up. Call setup() first.") + if not self.backend.is_setup: + raise RuntimeError("Backend not set up. Call setup() first.") + + @property + def device_name(self) -> str: + """Get the device name.""" + return self.outlet_name + + @property + def device_id(self) -> Optional[str]: + """Get the device ID if available.""" + return self._device.device_id if self._device else None From 95ee64a94b2ece5330d9f6c098fd3c0543f3d977 Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:12:11 -0400 Subject: [PATCH 5/6] Modify implementation to use yolink-api via pypi --- pylabrobot/yolink/LICENSE | 19 -- pylabrobot/yolink/README.md | 64 ------ pylabrobot/yolink/__init__.py | 3 + pylabrobot/yolink/auth_mgr.py | 29 --- pylabrobot/yolink/client.py | 76 ------- pylabrobot/yolink/client_request.py | 21 -- pylabrobot/yolink/const.py | 55 ----- pylabrobot/yolink/device.py | 119 ---------- pylabrobot/yolink/device_helper.py | 121 ---------- pylabrobot/yolink/endpoint.py | 40 ---- pylabrobot/yolink/exception.py | 35 --- pylabrobot/yolink/home_manager.py | 118 ---------- pylabrobot/yolink/message_listener.py | 13 -- pylabrobot/yolink/message_resolver.py | 207 ------------------ pylabrobot/yolink/model.py | 55 ----- pylabrobot/yolink/mqtt_client.py | 138 ------------ pylabrobot/yolink/outlet_request_builder.py | 16 -- pylabrobot/yolink/pyproject.toml | 31 --- .../yolink/thermostat_request_builder.py | 32 --- pylabrobot/yolink/unit_helper.py | 100 --------- pylabrobot/yolink/yolink.py | 25 +-- 21 files changed, 14 insertions(+), 1303 deletions(-) delete mode 100644 pylabrobot/yolink/LICENSE delete mode 100644 pylabrobot/yolink/README.md delete mode 100644 pylabrobot/yolink/auth_mgr.py delete mode 100644 pylabrobot/yolink/client.py delete mode 100644 pylabrobot/yolink/client_request.py delete mode 100644 pylabrobot/yolink/const.py delete mode 100644 pylabrobot/yolink/device.py delete mode 100644 pylabrobot/yolink/device_helper.py delete mode 100644 pylabrobot/yolink/endpoint.py delete mode 100644 pylabrobot/yolink/exception.py delete mode 100644 pylabrobot/yolink/home_manager.py delete mode 100644 pylabrobot/yolink/message_listener.py delete mode 100644 pylabrobot/yolink/message_resolver.py delete mode 100644 pylabrobot/yolink/model.py delete mode 100644 pylabrobot/yolink/mqtt_client.py delete mode 100644 pylabrobot/yolink/outlet_request_builder.py delete mode 100644 pylabrobot/yolink/pyproject.toml delete mode 100644 pylabrobot/yolink/thermostat_request_builder.py delete mode 100644 pylabrobot/yolink/unit_helper.py diff --git a/pylabrobot/yolink/LICENSE b/pylabrobot/yolink/LICENSE deleted file mode 100644 index 335ea9d070a..00000000000 --- a/pylabrobot/yolink/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2018 The Python Packaging Authority - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/pylabrobot/yolink/README.md b/pylabrobot/yolink/README.md deleted file mode 100644 index d474c4ec019..00000000000 --- a/pylabrobot/yolink/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# YoLink Python library for HA Integration - -## Supported devices - -- YS1603-UC (Hub) -- YS1604-UC (SpeakerHub) -- YS3604-UC (YoLink KeyFob) -- YS3605-UC (YoLink On/OffFob) -- YS3606-UC (YoLink DimmerFob) -- YS3607-UC (YoLink SirenFob) -- YS3614-UC (YoLink Mini FlexFob) -- YS4002-UC (YoLink Thermostat) -- YS4003-UC (YoLink Thermostat Heatpump) -- YS4906-UC + YS7706-UC (Garage Door Kit 1) -- YS4908-UC + YS7706-UC (Garage Door Kit 2 (Finger)) -- YS4909-UC (Water Valve Controller) -- YS5001-UC (X3 Water Valve Controller) -- YS5002-UC (YoLink Motorized Ball Valve) -- YS5003-UC (Water Valve Controller 2) -- YS5705-UC (In-Wall Switch) -- YS5706-UC (YoLink Relay) -- YS5707-UC (Dimmer Switch) -- YS5708-UC (In-Wall Switch 2) -- YS6602-UC (YoLink Energy Plug) -- YS6604-UC (YoLink Plug Mini) -- YS6704-UC (In-wall Outlet) -- YS6801-UC (Smart Power Strip) -- YS6802-UC (Smart Outdoor Power Strip) -- YS6803-UC (Outdoor Energy Plug) -- YS7103-UC (Siren Alarm) -- YS7104-UC (Outdoor Alarm Controller) -- YS7105-UC (X3 Outdoor Alarm Controller) -- YS7106-UC (Power Fail Alarm) -- YS7107-UC (Outdoor Alarm Controller 2) -- YS7201-UC (Vibration Sensor) -- YS7606-UC (YoLink Smart Lock M1) -- YS7607-UC (YoLink Smart Lock M2) -- YS7704-UC (Door Sensor) -- YS7706-UC (Garage Door Sensor) -- YS7707-UC (Contact Sensor) -- YS7804-UC (Motion Sensor) -- YS7805-UC (Outdoor Motion Sensor) -- YS7903-UC (Water Leak Sensor) -- YS7904-UC (Water Leak Sensor 2) -- YS7906-UC (Water Leak Sensor 4) -- YS7916-UC (Water Leak Sensor 4 MoveAlert) -- YS7905-UC (WaterDepthSensor) -- YS7A01-UC (Smart Smoke/CO Alarm) -- YS8003-UC (Temperature Humidity Sensor) -- YS8004-UC (Weatherproof Temperature Sensor) -- YS8005-UC (Weatherproof Temperature & Humidity Sensor) -- YS8006-UC (X3 Temperature & Humidity Sensor) -- YS8014-UC (X3 Outdoor Temperature Sensor) -- YS8015-UC (X3 Outdoor Temperature & Humidity Sensor) -- YS5006-UC (FlowSmart Control) -- YS5007-UC (FlowSmart Meter) -- YS5008-UC (FlowSmart All-in-One) -- YS8017-UC (Thermometer) -- YS5009-UC (LeakStop Controller) -- YS5029-UC (LeakStop Controller 2 Channel) -- YS8009-UC (Soil Temperature & Humidity Sensor) -- YS4102-UC (Smart Sprinkler Controller) -- YS4103-UC (Smart Sprinkler Controller V2) -- YS7A12-UC (Smoke Alarm) diff --git a/pylabrobot/yolink/__init__.py b/pylabrobot/yolink/__init__.py index 0579b5519fd..468da742752 100644 --- a/pylabrobot/yolink/__init__.py +++ b/pylabrobot/yolink/__init__.py @@ -9,4 +9,7 @@ # "aiomqtt>=2.0.0,<3.0.0", # "pydantic>=2.0.0", # "tenacity>=8.1.0", +# "yolink-api>=0.5.8" # ] + +# pip install yolink-api to get all dependencies diff --git a/pylabrobot/yolink/auth_mgr.py b/pylabrobot/yolink/auth_mgr.py deleted file mode 100644 index f6261bafcf3..00000000000 --- a/pylabrobot/yolink/auth_mgr.py +++ /dev/null @@ -1,29 +0,0 @@ -"""YoLink authorization manager.""" - -import abc - -from aiohttp import ClientSession - - -class YoLinkAuthMgr(metaclass=abc.ABCMeta): - """YoLink API Authentication Manager.""" - - def __init__(self, session: ClientSession) -> None: - """YoLink Auth Manager""" - self._session = session - - def client_session(self) -> ClientSession: - """Get client session.""" - return self._session - - @abc.abstractmethod - def access_token(self) -> str: - """Get auth token.""" - - def http_auth_header(self) -> str: - """Get auth header.""" - return f"Bearer {self.access_token()}" - - @abc.abstractmethod - async def check_and_refresh_token(self) -> str: - """Check and fresh token.""" diff --git a/pylabrobot/yolink/client.py b/pylabrobot/yolink/client.py deleted file mode 100644 index 3533f097e5e..00000000000 --- a/pylabrobot/yolink/client.py +++ /dev/null @@ -1,76 +0,0 @@ -"""YoLink client.""" - -from typing import Any, Dict - -from aiohttp import ClientError, ClientResponse -from tenacity import retry, retry_if_exception_type, stop_after_attempt - -from .auth_mgr import YoLinkAuthMgr -from .exception import YoLinkClientError, YoLinkDeviceConnectionFailed -from .model import BRDP - - -class YoLinkClient: - """YoLink client.""" - - def __init__(self, auth_mgr: YoLinkAuthMgr) -> None: - """Init YoLink client""" - self._auth_mgr = auth_mgr - - async def request( - self, method: str, url: str, auth_required: bool = True, **kwargs: Any - ) -> ClientResponse: - """Proxy Request and add Auth/CV headers.""" - headers = kwargs.pop("headers", {}) - params = kwargs.pop("params", None) - data = kwargs.pop("data", None) - - # Extra, user supplied values - extra_headers = kwargs.pop("extra_headers", None) - extra_params = kwargs.pop("extra_params", None) - extra_data = kwargs.pop("extra_data", None) - if auth_required: - # Ensure token valid - await self._auth_mgr.check_and_refresh_token() - # Set auth header - headers["Authorization"] = self._auth_mgr.http_auth_header() - # Extend with optionally supplied values - if extra_headers: - headers.update(extra_headers) - if extra_params: - # Query parameters - params = params or {} - params.update(extra_params) - if extra_data: - # form encoded post data - data = data or {} - data.update(extra_data) - return await self._auth_mgr.client_session().request( - method, url, **kwargs, headers=headers, params=params, data=data, timeout=8 - ) - - async def get(self, url: str, **kwargs: Any) -> ClientResponse: - """Call http request with Get Method.""" - return await self.request("GET", url, True, **kwargs) - - async def post(self, url: str, **kwargs: Any) -> ClientResponse: - """Call Http Request with POST Method""" - return await self.request("POST", url, True, **kwargs) - - @retry( - retry=retry_if_exception_type(YoLinkDeviceConnectionFailed), - stop=stop_after_attempt(2), - ) - async def execute(self, url: str, bsdp: Dict, **kwargs: Any) -> BRDP: - """Call YoLink Api""" - try: - yl_resp = await self.post(url, json=bsdp, **kwargs) - yl_resp.raise_for_status() - _yl_body = await yl_resp.text() - brdp = BRDP.model_validate_json(_yl_body) - brdp.check_response() - except ClientError as client_err: - raise YoLinkClientError("-1003", "yolink client request failed!") from client_err - except YoLinkClientError as yl_client_err: - raise yl_client_err - return brdp diff --git a/pylabrobot/yolink/client_request.py b/pylabrobot/yolink/client_request.py deleted file mode 100644 index 109d6123bcc..00000000000 --- a/pylabrobot/yolink/client_request.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Client request""" - -from typing import Any - - -class ClientRequest: - """Client request""" - - def __init__(self, method: str, params: dict[str, Any]) -> None: - self._method = method - self._params = params - - @property - def method(self) -> str: - """Return call device method""" - return self._method - - @property - def params(self) -> dict[str, Any]: - """Return call params""" - return self._params diff --git a/pylabrobot/yolink/const.py b/pylabrobot/yolink/const.py deleted file mode 100644 index 2ba77d43280..00000000000 --- a/pylabrobot/yolink/const.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Const for YoLink Client.""" - -from typing import Final - -OAUTH2_AUTHORIZE = "https://api.yosmart.com/oauth/v2/authorization.htm" -OAUTH2_TOKEN = "https://api.yosmart.com/open/yolink/token" - -ATTR_DEVICE_ID = "deviceId" -ATTR_DEVICE_NAME = "name" -ATTR_DEVICE_TYPE = "type" -ATTR_DEVICE_TOKEN = "token" -ATTR_DEVICE_MODEL_NAME = "modelName" -ATTR_DEVICE_PARENT_ID = "parentDeviceId" -ATTR_DEVICE_SERVICE_ZONE = "serviceZone" - -ATTR_DEVICE_MODEL_A = "A" -ATTR_DEVICE_MODEL_C = "C" -ATTR_DEVICE_MODEL_D = "D" -ATTR_DEVICE_MODEL_HUB = "Hub" - -ATTR_DEVICE_DOOR_SENSOR = "DoorSensor" -ATTR_DEVICE_TH_SENSOR = "THSensor" -ATTR_DEVICE_MOTION_SENSOR = "MotionSensor" -ATTR_DEVICE_MULTI_OUTLET = "MultiOutlet" -ATTR_DEVICE_LEAK_SENSOR = "LeakSensor" -ATTR_DEVICE_VIBRATION_SENSOR = "VibrationSensor" -ATTR_DEVICE_OUTLET = "Outlet" -ATTR_DEVICE_SIREN = "Siren" -ATTR_DEVICE_LOCK = "Lock" -ATTR_DEVICE_MANIPULATOR = "Manipulator" -ATTR_DEVICE_CO_SMOKE_SENSOR = "COSmokeSensor" -ATTR_DEVICE_SWITCH = "Switch" -ATTR_DEVICE_THERMOSTAT = "Thermostat" -ATTR_DEVICE_DIMMER = "Dimmer" -ATTR_GARAGE_DOOR_CONTROLLER = "GarageDoor" -ATTR_DEVICE_SMART_REMOTER = "SmartRemoter" -ATTR_DEVICE_POWER_FAILURE_ALARM = "PowerFailureAlarm" -ATTR_DEVICE_HUB = "Hub" -ATTR_DEVICE_SPEAKER_HUB = "SpeakerHub" -ATTR_DEVICE_FINGER = "Finger" -ATTR_DEVICE_WATER_DEPTH_SENSOR = "WaterDepthSensor" -ATTR_DEVICE_WATER_METER_CONTROLLER = "WaterMeterController" -ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER = "WaterMeterMultiController" -ATTR_DEVICE_LOCK_V2 = "LockV2" -ATTR_DEVICE_SOIL_TH_SENSOR = "SoilThcSensor" -ATTR_DEVICE_SPRINKLER = "Sprinkler" -ATTR_DEVICE_SPRINKLER_V2 = "SprinklerV2" -ATTR_DEVICE_SMOKE_ALARM = "SmokeAlarm" - -UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit." - - -DEVICE_LEAK_STOP_MODELS = ["YS5009-UC", "YS5009-EC", "YS5029-UC", "YS5029-EC"] - -DEVICE_MODELS_SUPPORT_MODE_SWITCHING = DEVICE_LEAK_STOP_MODELS diff --git a/pylabrobot/yolink/device.py b/pylabrobot/yolink/device.py deleted file mode 100644 index 6c75542dbe8..00000000000 --- a/pylabrobot/yolink/device.py +++ /dev/null @@ -1,119 +0,0 @@ -"""YoLink Device.""" - -from __future__ import annotations - -import abc -from typing import Optional - -from pydantic import BaseModel, Field, field_validator -from tenacity import RetryError - -from .client import YoLinkClient -from .client_request import ClientRequest -from .const import ( - ATTR_DEVICE_ID, - ATTR_DEVICE_MODEL_NAME, - ATTR_DEVICE_NAME, - ATTR_DEVICE_PARENT_ID, - ATTR_DEVICE_SERVICE_ZONE, - ATTR_DEVICE_TOKEN, - ATTR_DEVICE_TYPE, - DEVICE_MODELS_SUPPORT_MODE_SWITCHING, -) -from .device_helper import get_device_net_mode -from .endpoint import Endpoint, Endpoints -from .message_resolver import resolve_message -from .model import BRDP, BSDPHelper - - -class YoLinkDeviceMode(BaseModel): - """YoLink Device Mode.""" - - device_id: str = Field(alias=ATTR_DEVICE_ID) - device_name: str = Field(alias=ATTR_DEVICE_NAME) - device_token: str = Field(alias=ATTR_DEVICE_TOKEN) - device_type: str = Field(alias=ATTR_DEVICE_TYPE) - device_model_name: str = Field(alias=ATTR_DEVICE_MODEL_NAME) - device_parent_id: Optional[str] = Field(alias=ATTR_DEVICE_PARENT_ID) - device_service_zone: Optional[str] = Field(alias=ATTR_DEVICE_SERVICE_ZONE) - - @field_validator("device_parent_id") - @classmethod - def check_parent_id(cls, val: Optional[str]) -> Optional[str]: - """Checking and replace parent id.""" - if val == "null": - val = None - return val - - -class YoLinkDevice(metaclass=abc.ABCMeta): - """YoLink device.""" - - def __init__(self, device: YoLinkDeviceMode, client: YoLinkClient) -> None: - self.device_id: str = device.device_id - self.device_name: str = device.device_name - self.device_token: str = device.device_token - self.device_type: str = device.device_type - self.device_model_name: str = device.device_model_name - self.device_attrs: dict | None = None - self.parent_id: str = device.device_parent_id - self._client: YoLinkClient = client - self.class_mode: str = get_device_net_mode(device) - self._state: dict | None = {} - if device.device_service_zone is not None: - self.device_endpoint: Endpoint = ( - Endpoints.EU.value if device.device_service_zone.startswith("eu_") else Endpoints.US.value - ) - else: - self.device_endpoint: Endpoint = ( - Endpoints.EU.value if device.device_model_name.endswith("-EC") else Endpoints.US.value - ) - - async def __invoke(self, method: str, params: dict | None) -> BRDP: - """Invoke device.""" - try: - bsdp_helper = BSDPHelper( - self.device_id, - self.device_token, - f"{self.device_type}.{method}", - ) - if params is not None: - bsdp_helper.add_params(params) - return await self._client.execute(url=self.device_endpoint.url, bsdp=bsdp_helper.build()) - except RetryError as err: - raise err.last_attempt.result() - - async def get_state(self) -> BRDP: - """Call *.getState with device to request realtime state data.""" - return await self.__invoke("getState", None) - - async def fetch_state(self) -> BRDP: - """Call *.fetchState with device to fetch state data.""" - if self.device_type in ["Hub", "SpeakerHub"]: - return BRDP( - code="000000", - desc="success", - method="fetchState", - data={}, - ) - state_brdp: BRDP = await self.__invoke("fetchState", None) - resolve_message(self, state_brdp.data.get("state"), None) - return state_brdp - - async def get_external_data(self) -> BRDP: - """Call *.getExternalData to get device settings.""" - return await self.__invoke("getExternalData", None) - - async def call_device(self, request: ClientRequest) -> BRDP: - """Device invoke.""" - return await self.__invoke(request.method, request.params) - - def get_paired_device_id(self) -> str | None: - """Get device paired device id.""" - if self.parent_id is None or self.parent_id == "null": - return None - return self.parent_id - - def is_support_mode_switching(self) -> bool: - """Check if the device supports mode switching.""" - return self.device_model_name in DEVICE_MODELS_SUPPORT_MODE_SWITCHING diff --git a/pylabrobot/yolink/device_helper.py b/pylabrobot/yolink/device_helper.py deleted file mode 100644 index c6c4bbf2188..00000000000 --- a/pylabrobot/yolink/device_helper.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Helper functions for YoLink devices.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .device import YoLinkDevice - -from .const import ( - ATTR_DEVICE_CO_SMOKE_SENSOR, - ATTR_DEVICE_DIMMER, - ATTR_DEVICE_DOOR_SENSOR, - ATTR_DEVICE_FINGER, - ATTR_DEVICE_HUB, - ATTR_DEVICE_LEAK_SENSOR, - ATTR_DEVICE_LOCK, - ATTR_DEVICE_LOCK_V2, - ATTR_DEVICE_MANIPULATOR, - ATTR_DEVICE_MOTION_SENSOR, - ATTR_DEVICE_MULTI_OUTLET, - ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, - ATTR_DEVICE_OUTLET, - ATTR_DEVICE_POWER_FAILURE_ALARM, - ATTR_DEVICE_SIREN, - ATTR_DEVICE_SMART_REMOTER, - ATTR_DEVICE_SMOKE_ALARM, - ATTR_DEVICE_SOIL_TH_SENSOR, - ATTR_DEVICE_SPEAKER_HUB, - ATTR_DEVICE_SPRINKLER, - ATTR_DEVICE_SPRINKLER_V2, - ATTR_DEVICE_SWITCH, - ATTR_DEVICE_TH_SENSOR, - ATTR_DEVICE_THERMOSTAT, - ATTR_DEVICE_VIBRATION_SENSOR, - ATTR_DEVICE_WATER_DEPTH_SENSOR, - ATTR_DEVICE_WATER_METER_CONTROLLER, - ATTR_GARAGE_DOOR_CONTROLLER, -) - - -def get_device_net_mode(device: YoLinkDevice) -> str | None: - """Get device network mode.""" - # Assuming all devices are WiFi for this example - device_type = device.device_type - device_model = device.device_model_name - device_short_model = None - if device_model is not None: - device_short_model = device_model.split("-")[0] - if device_type in [ - ATTR_DEVICE_LEAK_SENSOR, - ATTR_DEVICE_DOOR_SENSOR, - ATTR_DEVICE_TH_SENSOR, - ATTR_DEVICE_MOTION_SENSOR, - ATTR_DEVICE_CO_SMOKE_SENSOR, - ATTR_DEVICE_POWER_FAILURE_ALARM, - ATTR_DEVICE_SOIL_TH_SENSOR, - ATTR_DEVICE_VIBRATION_SENSOR, - ATTR_DEVICE_SMART_REMOTER, - ATTR_DEVICE_WATER_DEPTH_SENSOR, - ATTR_DEVICE_SMOKE_ALARM, - ]: - if device_short_model in [ - "YS7A02", - "YS8006", - ]: - return "D" - return "A" - if device_type in [ - ATTR_DEVICE_MANIPULATOR, - ATTR_DEVICE_OUTLET, - ATTR_DEVICE_MULTI_OUTLET, - ATTR_DEVICE_THERMOSTAT, - ATTR_DEVICE_SIREN, - ATTR_DEVICE_SWITCH, - ATTR_GARAGE_DOOR_CONTROLLER, - ATTR_DEVICE_DIMMER, - ATTR_DEVICE_SPRINKLER, - ]: - if device_short_model in [ - # - "YS4909", - # Mainpulator(Class D) - "YS5001", - "YS5002", - "YS5003", - "YS5012", - # Switch(Class D) - "YS5709", - # Siren(Class D) - "YS7104", - "YS7105", - "YS7107", - ]: - return "D" - return "C" - if device_type in [ - ATTR_DEVICE_FINGER, - ATTR_DEVICE_LOCK, - ATTR_DEVICE_LOCK_V2, - ATTR_DEVICE_WATER_METER_CONTROLLER, - ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, - ATTR_DEVICE_SPRINKLER_V2, - ]: - if device_short_model in ["YS5007"]: - return "A" - return "D" - if device_type in [ATTR_DEVICE_HUB, ATTR_DEVICE_SPEAKER_HUB]: - return "Hub" - return None - - -def get_device_keepalive_time(device: YoLinkDevice) -> int: - """Get device keepalive time in seconds.""" - device_class_mode = get_device_net_mode(device) - if device_class_mode in ["A", "D"]: - return 32400 - if device_class_mode == "C": - return 3600 - if device_class_mode == "Hub": - return 600 diff --git a/pylabrobot/yolink/endpoint.py b/pylabrobot/yolink/endpoint.py deleted file mode 100644 index a56e9f36f48..00000000000 --- a/pylabrobot/yolink/endpoint.py +++ /dev/null @@ -1,40 +0,0 @@ -"""SVR info.""" - -from dataclasses import dataclass -from enum import Enum - - -@dataclass(repr=True) -class Endpoint: - """SVR endpoint.""" - - name: str - host: str - url: str - mqtt_broker_host: str - mqtt_broker_port: int = 8003 - - def __init__(self, name: str, host: str, mqtt_host: str, mqtt_port: int): - """Init SVR Endpoint.""" - self.name = name - self.host = host - self.url = f"https://{host}/open/yolink/v2/api" - self.mqtt_broker_host = mqtt_host - self.mqtt_broker_port = mqtt_port - - -class Endpoints(Enum): - """All YoLink SVR Endpoints.""" - - US: Endpoint = Endpoint( - name="US", - host="api.yosmart.com", - mqtt_host="mqtt.api.yosmart.com", - mqtt_port=8003, - ) - EU: Endpoint = Endpoint( - name="EU", - host="api-eu.yosmart.com", - mqtt_host="api-eu.yosmart.com", - mqtt_port=8003, - ) diff --git a/pylabrobot/yolink/exception.py b/pylabrobot/yolink/exception.py deleted file mode 100644 index 106da10f7a8..00000000000 --- a/pylabrobot/yolink/exception.py +++ /dev/null @@ -1,35 +0,0 @@ -"""YoLink Client Error.""" - - -class YoLinkError(Exception): - """YoLink Error.""" - - -class YoLinkClientError(YoLinkError): - """YoLink Client Error. - - code: Error Code - desc: Desc or Error - """ - - def __init__( - self, - code: str, - desc: str, - ) -> None: - """Initialize the yolink api error.""" - - self.code = code - self.message = desc - - -class YoLinkAuthFailError(YoLinkClientError): - """YoLink Auth Fail""" - - -class YoLinkDeviceConnectionFailed(YoLinkClientError): - """YoLink device connection failed.""" - - -class YoLinkUnSupportedMethodError(YoLinkClientError): - """YoLink Unsupported method error.""" diff --git a/pylabrobot/yolink/home_manager.py b/pylabrobot/yolink/home_manager.py deleted file mode 100644 index 2fc123eda93..00000000000 --- a/pylabrobot/yolink/home_manager.py +++ /dev/null @@ -1,118 +0,0 @@ -"""YoLink home manager.""" - -from __future__ import annotations - -import logging -from typing import Any - -from .auth_mgr import YoLinkAuthMgr -from .client import YoLinkClient -from .const import ATTR_DEVICE_WATER_DEPTH_SENSOR -from .device import YoLinkDevice, YoLinkDeviceMode -from .endpoint import Endpoint, Endpoints -from .exception import YoLinkClientError, YoLinkUnSupportedMethodError -from .message_listener import MessageListener -from .model import BRDP -from .mqtt_client import YoLinkMqttClient - -_LOGGER = logging.getLogger(__name__) - -has_external_data_devices = [ATTR_DEVICE_WATER_DEPTH_SENSOR] - - -class YoLinkHome: - """YoLink home manager.""" - - def __init__(self) -> None: - """Init YoLink Home Manager.""" - self._home_devices: dict[str, YoLinkDevice] = {} - self._http_client: YoLinkClient = None - self._endpoints: dict[str, Endpoint] = {} - self._mqtt_clients: dict[str, YoLinkMqttClient] = {} - self._message_listener: MessageListener = None - - async def async_setup(self, auth_mgr: YoLinkAuthMgr, listener: MessageListener) -> None: - """Init YoLink home.""" - if not auth_mgr: - raise YoLinkClientError("-1001", "setup failed, auth_mgr is required!") - if not listener: - raise YoLinkClientError("-1002", "setup failed, message listener is required!") - self._http_client = YoLinkClient(auth_mgr) - home_info: BRDP = await self.async_get_home_info() - # load home devices - await self.async_load_home_devices() - # setup yolink mqtt connection - self._message_listener = listener - # setup yolink mqtt clients - for endpoint in self._endpoints.values(): - endpoint_mqtt_client = YoLinkMqttClient( - auth_manager=auth_mgr, - endpoint=endpoint.name, - broker_host=endpoint.mqtt_broker_host, - broker_port=endpoint.mqtt_broker_port, - home_devices=self._home_devices, - ) - await endpoint_mqtt_client.connect(home_info.data["id"], self._message_listener) - self._mqtt_clients[endpoint.name] = endpoint_mqtt_client - - async def async_unload(self) -> None: - """Unload YoLink home.""" - self._home_devices = {} - self._http_client = None - for endpoint, client in self._mqtt_clients.items(): - _LOGGER.info( - "[%s] shutting down yolink mqtt client.", - endpoint, - ) - await client.disconnect() - _LOGGER.info( - "[%s] yolink mqtt client disconnected.", - endpoint, - ) - self._message_listener = None - self._mqtt_clients = {} - - async def async_get_home_info(self, **kwargs: Any) -> BRDP: - """Get home general information.""" - return await self._http_client.execute( - url=Endpoints.US.value.url, bsdp={"method": "Home.getGeneralInfo"}, **kwargs - ) - - async def async_load_home_devices(self, **kwargs: Any) -> dict[str, YoLinkDevice]: - """Get home devices.""" - # sync eu devices, will remove in future - eu_response: BRDP = await self._http_client.execute( - url=Endpoints.EU.value.url, bsdp={"method": "Home.getDeviceList"}, **kwargs - ) - response: BRDP = await self._http_client.execute( - url=Endpoints.US.value.url, bsdp={"method": "Home.getDeviceList"}, **kwargs - ) - eu_dev_tokens = {} - for eu_device in eu_response.data["devices"]: - eu_dev_tokens[eu_device["deviceId"]] = eu_device["token"] - for _device in response.data["devices"]: - _yl_device = YoLinkDevice(YoLinkDeviceMode(**_device), self._http_client) - if _yl_device.device_endpoint == Endpoints.EU.value: - # sync eu device token - _yl_device.device_token = eu_dev_tokens.get(_yl_device.device_id) - self._endpoints[_yl_device.device_endpoint.name] = _yl_device.device_endpoint - if _yl_device.device_type in has_external_data_devices: - try: - dev_external_data_resp = await _yl_device.get_external_data() - _yl_device.device_attrs = dev_external_data_resp.data.get("extData") - except YoLinkUnSupportedMethodError: - _LOGGER.debug( - "getExternalData is not supported for: %s", - _yl_device.device_type, - ) - self._home_devices[_device["deviceId"]] = _yl_device - - return self._home_devices - - def get_devices(self) -> list[YoLinkDevice]: - """Get home devices.""" - return self._home_devices.values() - - def get_device(self, device_id: str) -> YoLinkDevice | None: - """Get home device via device id.""" - return self._home_devices.get(device_id) diff --git a/pylabrobot/yolink/message_listener.py b/pylabrobot/yolink/message_listener.py deleted file mode 100644 index 4cdc1e54ad2..00000000000 --- a/pylabrobot/yolink/message_listener.py +++ /dev/null @@ -1,13 +0,0 @@ -"""YoLink cloud message listener.""" -from abc import ABCMeta, abstractmethod -from typing import Any - -from .device import YoLinkDevice - - -class MessageListener(metaclass=ABCMeta): - """Home message listener.""" - - @abstractmethod - def on_message(self, device: YoLinkDevice, msg_data: dict[str, Any]) -> None: - """On device message receive.""" diff --git a/pylabrobot/yolink/message_resolver.py b/pylabrobot/yolink/message_resolver.py deleted file mode 100644 index dbd4b1f1d8d..00000000000 --- a/pylabrobot/yolink/message_resolver.py +++ /dev/null @@ -1,207 +0,0 @@ -"""YoLink cloud message resolver.""" - -from __future__ import annotations - -from decimal import ROUND_DOWN, Decimal -from math import log2 -from typing import TYPE_CHECKING, Any - -from .const import ( - ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, - ATTR_DEVICE_SMART_REMOTER, - ATTR_DEVICE_SOIL_TH_SENSOR, - ATTR_DEVICE_SPRINKLER, - ATTR_DEVICE_SPRINKLER_V2, - ATTR_DEVICE_WATER_DEPTH_SENSOR, - ATTR_DEVICE_WATER_METER_CONTROLLER, -) -from .unit_helper import UnitOfVolume, VolumeConverter - -if TYPE_CHECKING: - from .device import YoLinkDevice - - -def smart_remoter_message_resolve(msg_data: dict[str, Any], event_type: str) -> None: - """SmartRemoter message resolve.""" - if msg_data is not None: - btn_press_event = msg_data.get("event") - if btn_press_event is not None: - if event_type == "Report": - msg_data["event"] = None - else: - key_mask = btn_press_event["keyMask"] - button_sequence = 0 if key_mask == 0 else (int(log2(key_mask)) + 1) - # replace with button sequence - msg_data["event"]["keyMask"] = button_sequence - - -def water_depth_sensor_message_resolve(msg_data: dict[str, Any], dev_attrs: dict[str, Any]) -> None: - """WaterDepthSensor message resolve.""" - if msg_data is not None: - depth_value = msg_data.get("waterDepth") - if depth_value is not None: - # default range settings if range and desity was not set. - dev_range = 5 - dev_density = 1 - if dev_attrs is not None and (range_attrs := dev_attrs.get("range")) is not None: - dev_range = range_attrs["range"] - dev_density = range_attrs["density"] - msg_data["waterDepth"] = round((dev_range * (depth_value / 1000)) / dev_density, 3) - - -def water_meter_controller_message_resolve(msg_data: dict[str, Any], device_model: str) -> None: - """WaterMeterController message resolve.""" - if msg_data is not None and ((meter_state := msg_data.get("state")) is not None): - meter_step_factor: int = 10 - # for some reason meter value can't be read - meter_value = meter_state.get("meter") - if meter_value is not None: - meter_unit = UnitOfVolume.GALLONS - if (meter_attrs := msg_data.get("attributes")) is not None: - if device_model.startswith("YS5009"): - meter_step_factor = ( - 1 / (_meter_step_factor / (1000 * 100)) - if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None - else 10 - ) - else: - meter_step_factor = ( - _meter_step_factor - if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None - else 10 - ) - meter_unit = ( - UnitOfVolume(_meter_unit) - if (_meter_unit := meter_attrs.get("meterUnit")) is not None - else UnitOfVolume.GALLONS - ) - _meter_reading = None - if meter_step_factor < 0: - _meter_reading = meter_value * abs(meter_step_factor) - else: - _meter_reading = meter_value / meter_step_factor - meter_value = VolumeConverter.convert(_meter_reading, meter_unit, UnitOfVolume.CUBIC_METERS) - msg_data["meter_reading"] = float( - Decimal(meter_value).quantize(Decimal(".00000"), rounding=ROUND_DOWN) - ) - msg_data["valve_state"] = meter_state["valve"] - - -def multi_water_meter_controller_message_resolve( - msg_data: dict[str, Any], - device_model: str, -) -> None: - """MultiWaterMeterController message resolve.""" - if msg_data is not None and ((meter_state := msg_data.get("state")) is not None): - meter_step_factor: int = 10 - meter_reading_values: dict = meter_state.get("meters") - if meter_reading_values is not None: - meter_unit = UnitOfVolume.GALLONS - if (meter_attrs := msg_data.get("attributes")) is not None: - if device_model.startswith("YS5029"): - meter_step_factor = ( - 1 / (_meter_step_factor / (1000 * 100)) - if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None - else 10 - ) - else: - meter_step_factor = ( - _meter_step_factor - if (_meter_step_factor := meter_attrs.get("meterStepFactor")) is not None - else 10 - ) - meter_unit = ( - UnitOfVolume(_meter_unit) - if (_meter_unit := meter_attrs.get("meterUnit")) is not None - else UnitOfVolume.GALLONS - ) - _meter_1_reading = None - if meter_step_factor < 0: - _meter_1_reading = meter_reading_values["0"] * abs(meter_step_factor) - else: - _meter_1_reading = meter_reading_values["0"] / meter_step_factor - meter_reading_values["0"] = VolumeConverter.convert( - _meter_1_reading, - meter_unit, - UnitOfVolume.CUBIC_METERS, - ) - _meter_2_reading = None - if meter_step_factor < 0: - _meter_2_reading = meter_reading_values["1"] * abs(meter_step_factor) - else: - _meter_2_reading = meter_reading_values["1"] / meter_step_factor - meter_reading_values["1"] = VolumeConverter.convert( - _meter_2_reading, - meter_unit, - UnitOfVolume.CUBIC_METERS, - ) - msg_data["meter_1_reading"] = float( - Decimal(meter_reading_values["0"]).quantize(Decimal(".00000"), rounding=ROUND_DOWN) - ) - msg_data["meter_2_reading"] = float( - Decimal(meter_reading_values["1"]).quantize(Decimal(".00000"), rounding=ROUND_DOWN) - ) - # for some reason meter value can't be read - if (meter_valves := meter_state.get("valves")) is not None: - msg_data["valve_1_state"] = meter_valves["0"] - msg_data["valve_2_state"] = meter_valves["1"] - - -def soil_thc_sensor_message_resolve( - msg_data: dict[str, Any], -) -> None: - """SoilThcSensor message resolve.""" - if msg_data is not None and ((state := msg_data.get("state")) is not None): - msg_data["temperature"] = state.get("temperature") - msg_data["humidity"] = state.get("humidity") - msg_data["conductivity"] = state.get("conductivity") - - -def sprinkler_message_resolve( - device: YoLinkDevice, - msg_data: dict[str, Any], - msg_type: str | None = None, -) -> None: - """Sprinkler message resolve.""" - if msg_data is not None: - if (state := msg_data.get("state")) is not None: - device._state = {"mode": state.get("mode")} - if (watering_data := state.get("watering")) is not None: - msg_data["valve"] = watering_data["left"] != watering_data["total"] - if msg_type == "waterReport": - if device._state is not None: - msg_data["state"] = {"mode": device._state.get("mode")} - if (event := msg_data.get("event")) is not None: - msg_data["valve"] = event == "start" - - -def sprinkler_v2_message_resolve( - msg_data: dict[str, Any], -) -> None: - """Sprinkler V2 message resolve.""" - if msg_data is not None and ((state := msg_data.get("state")) is not None): - msg_data["valve"] = state.get("running") - - -def resolve_message(device: YoLinkDevice, msg_data: dict[str, Any], msg_type: str | None) -> None: - """Resolve device message.""" - if device.device_type == ATTR_DEVICE_WATER_DEPTH_SENSOR: - water_depth_sensor_message_resolve(msg_data, device.device_attrs) - elif device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER: - water_meter_controller_message_resolve(msg_data, device.device_model_name) - elif device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER: - multi_water_meter_controller_message_resolve(msg_data, device.device_model_name) - elif device.device_type == ATTR_DEVICE_SOIL_TH_SENSOR: - soil_thc_sensor_message_resolve(msg_data) - elif device.device_type == ATTR_DEVICE_SPRINKLER: - sprinkler_message_resolve(device, msg_data, msg_type) - elif device.device_type == ATTR_DEVICE_SPRINKLER_V2: - sprinkler_v2_message_resolve(msg_data) - - -def resolve_sub_message(device: YoLinkDevice, msg_data: dict[str, Any], msg_type: str) -> None: - """Resolve device pushing message.""" - if device.device_type == ATTR_DEVICE_SMART_REMOTER: - smart_remoter_message_resolve(msg_data, msg_type) - else: - resolve_message(device, msg_data, msg_type) diff --git a/pylabrobot/yolink/model.py b/pylabrobot/yolink/model.py deleted file mode 100644 index 3f54812721f..00000000000 --- a/pylabrobot/yolink/model.py +++ /dev/null @@ -1,55 +0,0 @@ -"""YoLink Basic Model.""" - -from typing import Any, Dict, Optional - -from pydantic import BaseModel - -from .exception import ( - YoLinkAuthFailError, - YoLinkClientError, - YoLinkDeviceConnectionFailed, - YoLinkUnSupportedMethodError, -) - - -class BRDP(BaseModel): - """BRDP of YoLink API.""" - - code: Optional[str] = None - desc: Optional[str] = None - method: Optional[str] = None - data: Dict[str, Any] = None - event: Optional[str] = None - - def check_response(self): - """Check API Response.""" - if self.code != "000000": - if self.code == "000103": - raise YoLinkAuthFailError(self.code, self.desc) - if self.code == "000201": - raise YoLinkDeviceConnectionFailed(self.code, self.desc) - if self.code == "010203": - raise YoLinkUnSupportedMethodError(self.code, self.desc) - raise YoLinkClientError(self.code, self.desc) - - -class BSDPHelper: - """YoLink API -> BSDP Builder.""" - - _bsdp: Dict - - def __init__(self, device_id: str, device_token: str, method: str): - """Constanst.""" - self._bsdp = {"method": method, "params": {}} - if device_id is not None: - self._bsdp["targetDevice"] = device_id - self._bsdp["token"] = device_token - - def add_params(self, params: Dict): - """Build params of BSDP.""" - self._bsdp["params"].update(params) - return self - - def build(self) -> Dict: - """Generate BSDP.""" - return self._bsdp diff --git a/pylabrobot/yolink/mqtt_client.py b/pylabrobot/yolink/mqtt_client.py deleted file mode 100644 index 23045a01d79..00000000000 --- a/pylabrobot/yolink/mqtt_client.py +++ /dev/null @@ -1,138 +0,0 @@ -"""YoLink mqtt client.""" - -import asyncio -import logging -from typing import Any - -import aiomqtt -from pydantic import ValidationError - -from .auth_mgr import YoLinkAuthMgr -from .device import YoLinkDevice -from .message_listener import MessageListener -from .message_resolver import resolve_sub_message -from .model import BRDP - -_LOGGER = logging.getLogger(__name__) - - -class YoLinkMqttClient: - """YoLink mqtt client.""" - - def __init__( - self, - auth_manager: YoLinkAuthMgr, - endpoint: str, - broker_host: str, - broker_port: int, - home_devices: dict[str, YoLinkDevice], - ) -> None: - self._auth_mgr = auth_manager - self._endpoint = endpoint - self._broker_host = broker_host - self._broker_port = broker_port - self._home_topic = None - self._message_listener = None - self._home_devices = home_devices - self._running = False - self._listener_task = None - - async def connect(self, home_id: str, listener: MessageListener) -> None: - """Connect to yolink mqtt broker.""" - self._home_topic = f"yl-home/{home_id}/+/report" - self._message_listener = listener - self._listener_task = asyncio.create_task(self._listen()) - - async def _listen(self): - # check and fresh access token - await self._auth_mgr.check_and_refresh_token() - reconnect_interval = 30 - self._running = True - while self._running: - try: - async with aiomqtt.Client( - hostname=self._broker_host, - port=self._broker_port, - username=self._auth_mgr.access_token(), - password="", - keepalive=60, - ) as client: - _LOGGER.info("[%s] connecting to yolink mqtt broker.", self._endpoint) - await client.subscribe(self._home_topic) - _LOGGER.info("[%s] yolink mqtt client connected.", self._endpoint) - async for message in client.messages: - self._process_message(message) - except aiomqtt.MqttError as mqtt_err: - _LOGGER.error( - "[%s] yolink mqtt client disconnected!", - self._endpoint, - exc_info=True, - ) - await asyncio.sleep(reconnect_interval) - if isinstance(mqtt_err, aiomqtt.MqttCodeError): - if mqtt_err.rc in [4, 5]: - _LOGGER.error( - "[%s] token expired or invalid, acquire new one.", - self._endpoint, - ) - await self._auth_mgr.check_and_refresh_token() - except Exception: - _LOGGER.error("[%s] unexcept exception:", self._endpoint, exc_info=True) - - async def disconnect(self) -> None: - """UnRegister listener""" - if self._listener_task is None: - return - self._listener_task.cancel() - self._listener_task = None - self._running = False - - def _process_message(self, msg) -> None: - """Mqtt on message.""" - _LOGGER.debug( - "Received message on %s%s: %s", - msg.topic, - " (retained)" if msg.retain else "", - msg.payload[0:8192], - ) - keys = str(msg.topic).split("/") - if len(keys) == 4 and keys[3] == "report": - try: - device_id = keys[2] - msg_data = BRDP.parse_raw(msg.payload.decode("UTF-8")) - if msg_data.event is None: - return - msg_event = msg_data.event.split(".") - msg_type = msg_event[len(msg_event) - 1] - if msg_type not in [ - "Report", - "Alert", - "StatusChange", - "getState", - "setState", - "DevEvent", - "waterReport", # Sprinkler - ]: - return - device = self._home_devices.get(device_id) - if device is None: - return - paired_device_id = device.get_paired_device_id() - if paired_device_id is not None: - paired_device = self._home_devices.get(paired_device_id) - if paired_device is None: - return - # post current device state to paired device - paired_device_state = {"state": msg_data.data.get("state")} - self.__resolve_message(paired_device, paired_device_state, msg_type) - self.__resolve_message(device, msg_data.data, msg_type) - except ValidationError: - # ignore invalidate message - _LOGGER.debug("Message invalidate.") - - def __resolve_message( - self, device: YoLinkDevice, msg_data: dict[str, Any], msg_type: str - ) -> None: - """Resolve device message.""" - resolve_sub_message(device, msg_data, msg_type) - self._message_listener.on_message(device, msg_data) diff --git a/pylabrobot/yolink/outlet_request_builder.py b/pylabrobot/yolink/outlet_request_builder.py deleted file mode 100644 index 14d50826304..00000000000 --- a/pylabrobot/yolink/outlet_request_builder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Outlet request builder""" -from __future__ import annotations - -from .client_request import ClientRequest - - -class OutletRequestBuilder: # pylint: disable=too-few-public-methods - """Outlet request builder""" - - @classmethod - def set_state_request(cls, state: str, plug_indx: int | None) -> ClientRequest: - """Set device state.""" - params: dict[str, str | int] = {"state": state} - if plug_indx is not None: - params["chs"] = 1 << plug_indx - return ClientRequest("setState", params) diff --git a/pylabrobot/yolink/pyproject.toml b/pylabrobot/yolink/pyproject.toml deleted file mode 100644 index 56091fae03e..00000000000 --- a/pylabrobot/yolink/pyproject.toml +++ /dev/null @@ -1,31 +0,0 @@ -[build-system] -requires = ["setuptools>=77.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "yolink-api" -version = "0.5.8" -license = "MIT" -license-files = ["LICENSE"] -description = "A library to authenticate with yolink device" -readme = "README.md" -authors = [{ name = "YoSmart" }] -requires-python = ">=3.9" -classifiers = [ - "Programming Language :: Python :: 3", - "Operating System :: OS Independent", -] -keywords = ["yolink", "api"] -dependencies = [ - "aiohttp>=3.8.1", - "aiomqtt>=2.0.0,<3.0.0", - "pydantic>=2.0.0", - "tenacity>=8.1.0", -] - -[project.urls] -"Source" = "https://github.com/YoSmart-Inc/yolink-api" -"Bug Tracker" = "https://github.com/YoSmart-Inc/yolink-api/issues" - -[tool.setuptools.packages.find] -include = ["yolink*"] diff --git a/pylabrobot/yolink/thermostat_request_builder.py b/pylabrobot/yolink/thermostat_request_builder.py deleted file mode 100644 index 746069d7c16..00000000000 --- a/pylabrobot/yolink/thermostat_request_builder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Thermostat request builder""" -from __future__ import annotations - -from typing import Optional - -from pydantic import BaseModel - -from .client_request import ClientRequest - - -class ThermostatState(BaseModel): - """Thermostat State.""" - - lowTemp: Optional[float] = None - highTemp: Optional[float] = None - mode: Optional[str] = None - fan: Optional[str] = None - sche: Optional[str] = None - - -class ThermostatRequestBuilder: # pylint: disable=too-few-public-methods - """Thermostat request builder""" - - @classmethod - def set_state_request(cls, state: ThermostatState) -> ClientRequest: - """Set device state.""" - return ClientRequest("setState", state.dict(exclude_none=True)) - - @classmethod - def set_eco_request(cls, state: str) -> ClientRequest: - """Enable/Disable eco mode.""" - return ClientRequest("setECO", {"mode": state}) diff --git a/pylabrobot/yolink/unit_helper.py b/pylabrobot/yolink/unit_helper.py deleted file mode 100644 index e43a8e3c865..00000000000 --- a/pylabrobot/yolink/unit_helper.py +++ /dev/null @@ -1,100 +0,0 @@ -"""YoLink Unit convert helper.""" - -from __future__ import annotations - -from collections.abc import Callable -from enum import IntEnum -from functools import lru_cache - -from .const import UNIT_NOT_RECOGNIZED_TEMPLATE -from .exception import YoLinkError - - -class UnitOfVolume(IntEnum): - """Unit of meter.""" - - GALLONS = 0 - CENTUM_CUBIC_FEET = 1 - CUBIC_METERS = 2 - LITERS = 3 - - -_IN_TO_M = 0.0254 # 1 inch = 0.0254 m -_FOOT_TO_M = _IN_TO_M * 12 # 12 inches = 1 foot (0.3048 m) -_L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ -_GALLON_TO_CUBIC_METER = 231 * pow(_IN_TO_M, 3) # US gallon is 231 cubic inches -_CUBIC_FOOT_TO_CUBIC_METER = pow(_FOOT_TO_M, 3) - - -# source code from homeassistant.util.unit_conversion.py -class BaseUnitConverter: - """Define the format of a conversion utility.""" - - UNIT_CLASS: str - NORMALIZED_UNIT: str | None - VALID_UNITS: set[str | None] - - _UNIT_CONVERSION: dict[str | None, float] - - @classmethod - def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: - """Convert one unit of measurement to another.""" - return cls.converter_factory(from_unit, to_unit)(value) - - @classmethod - @lru_cache - def converter_factory( - cls, from_unit: str | None, to_unit: str | None - ) -> Callable[[float], float]: - """Return a function to convert one unit of measurement to another.""" - if from_unit == to_unit: - return lambda value: value - from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) - return lambda val: (val / from_ratio) * to_ratio - - @classmethod - def _get_from_to_ratio(cls, from_unit: str | None, to_unit: str | None) -> tuple[float, float]: - """Get unit ratio between units of measurement.""" - unit_conversion = cls._UNIT_CONVERSION - try: - return unit_conversion[from_unit], unit_conversion[to_unit] - except KeyError as err: - raise YoLinkError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(err.args[0], cls.UNIT_CLASS)) from err - - @classmethod - @lru_cache - def converter_factory_allow_none( - cls, from_unit: str | None, to_unit: str | None - ) -> Callable[[float | None], float | None]: - """Return a function to convert one unit of measurement to another which allows None.""" - if from_unit == to_unit: - return lambda value: value - from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) - return lambda val: None if val is None else (val / from_ratio) * to_ratio - - @classmethod - @lru_cache - def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float: - """Get unit ratio between units of measurement.""" - from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) - return from_ratio / to_ratio - - -class VolumeConverter(BaseUnitConverter): - """Utility to convert volume values.""" - - UNIT_CLASS = "volume" - NORMALIZED_UNIT = UnitOfVolume.CUBIC_METERS - # Units in terms of m³ - _UNIT_CONVERSION: dict[str | None, float] = { - UnitOfVolume.LITERS: 1 / _L_TO_CUBIC_METER, - UnitOfVolume.GALLONS: 1 / _GALLON_TO_CUBIC_METER, - UnitOfVolume.CUBIC_METERS: 1, - UnitOfVolume.CENTUM_CUBIC_FEET: 1 / (100 * _CUBIC_FOOT_TO_CUBIC_METER), - } - VALID_UNITS = { - UnitOfVolume.LITERS, - UnitOfVolume.GALLONS, - UnitOfVolume.CUBIC_METERS, - UnitOfVolume.CENTUM_CUBIC_FEET, - } diff --git a/pylabrobot/yolink/yolink.py b/pylabrobot/yolink/yolink.py index 0b2f9a87b2c..021b8082564 100644 --- a/pylabrobot/yolink/yolink.py +++ b/pylabrobot/yolink/yolink.py @@ -1,18 +1,15 @@ """YoLink backend implementation for PyLabRobot devices.""" -import asyncio -import json import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional import aiohttp - -from .auth_mgr import YoLinkAuthMgr -from .device import YoLinkDevice -from .home_manager import YoLinkHome -from .message_listener import MessageListener -from .outlet_request_builder import OutletRequestBuilder +from yolink.auth_mgr import YoLinkAuthMgr +from yolink.device import YoLinkDevice +from yolink.home_manager import YoLinkHome +from yolink.message_listener import MessageListener +from yolink.outlet_request_builder import OutletRequestBuilder logger = logging.getLogger(__name__) @@ -112,7 +109,7 @@ def refresh_token(self) -> Optional[str]: return self._refresh_token -class YoLinkAuthMgr(YoLinkAuthMgr): +class YoLinkAuthManager(YoLinkAuthMgr): """Authentication manager for YoLink API.""" def __init__(self, session: aiohttp.ClientSession, token_manager: YoLinkTokenManager): @@ -188,7 +185,7 @@ def __init__( self._token_manager._refresh_token = refresh_token self._session: Optional[aiohttp.ClientSession] = None - self._auth_mgr: Optional[YoLinkAuthMgr] = None + self._auth_mgr: Optional[YoLinkAuthManager] = None self._home: Optional[YoLinkHome] = None self._listener: Optional[YoLinkMessageListener] = None self._is_setup = False @@ -236,7 +233,7 @@ async def setup(self) -> None: await self._ensure_access_token() # Initialize authentication manager - self._auth_mgr = YoLinkAuthMgr(self._session, self._token_manager) + self._auth_mgr = YoLinkAuthManager(self._session, self._token_manager) # Initialize message listener self._listener = YoLinkMessageListener() @@ -524,7 +521,7 @@ async def turn_on(self, outlet_index: int = 0) -> None: try: request = OutletRequestBuilder.set_state_request("open", outlet_index) - response = await self._device.call_device(request) + await self._device.call_device(request) logger.info(f"Turned on outlet {outlet_index}") @@ -542,7 +539,7 @@ async def turn_off(self, outlet_index: int = 0) -> None: try: request = OutletRequestBuilder.set_state_request("close", outlet_index) - response = await self._device.call_device(request) + await self._device.call_device(request) logger.info(f"Turned off outlet {outlet_index}") From 189d7ae42adce466570d2173ca87fda94adc4181 Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:24:35 -0400 Subject: [PATCH 6/6] Add yolink-api as extra dependency to plr --- pylabrobot/yolink/__init__.py | 10 ---------- setup.py | 4 ++++ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/pylabrobot/yolink/__init__.py b/pylabrobot/yolink/__init__.py index 468da742752..de203652ba7 100644 --- a/pylabrobot/yolink/__init__.py +++ b/pylabrobot/yolink/__init__.py @@ -3,13 +3,3 @@ Sensor, YoLink, ) - -# dependencies = [ -# "aiohttp>=3.8.1", -# "aiomqtt>=2.0.0,<3.0.0", -# "pydantic>=2.0.0", -# "tenacity>=8.1.0", -# "yolink-api>=0.5.8" -# ] - -# pip install yolink-api to get all dependencies diff --git a/setup.py b/setup.py index 6d83dbc3161..877acc7dc0e 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,8 @@ extras_agrow = ["pymodbus==3.6.8"] +extras_yolink = ["yolink-api>=0.5.8"] + extras_dev = ( extras_fw + extras_http @@ -39,6 +41,7 @@ + extras_server + extras_inheco + extras_agrow + + extras_yolink + [ "pydata-sphinx-theme", "myst_nb", @@ -77,6 +80,7 @@ "opentrons": extras_opentrons, "server": extras_server, "agrow": extras_agrow, + "yolink": extras_yolink, "dev": extras_dev, "all": extras_all, },