pax_global_header00006660000000000000000000000064150465172170014522gustar00rootroot0000000000000052 comment=aa5e377ab2ea152715106582743fb7210b542af3 yolink-api-0.5.8/000077500000000000000000000000001504651721700136105ustar00rootroot00000000000000yolink-api-0.5.8/.github/000077500000000000000000000000001504651721700151505ustar00rootroot00000000000000yolink-api-0.5.8/.github/workflows/000077500000000000000000000000001504651721700172055ustar00rootroot00000000000000yolink-api-0.5.8/.github/workflows/publish-to-pypi.yml000066400000000000000000000011761504651721700230020ustar00rootroot00000000000000name: yolink_ on: release: types: [published, prereleased] jobs: build-and-publish: name: Builds and publishes releases to PyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.13 uses: actions/setup-python@v5 with: python-version: 3.13 - name: Install build run: >- pip install build - name: Build run: >- python3 -m build - name: Publish release to PyPI uses: pypa/gh-action-pypi-publish@v1.5.0 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} yolink-api-0.5.8/.gitignore000066400000000000000000000015141504651721700156010ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python .env .venv/ venv/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg #Virtualenv folders and files Scripts pyvenv.cfg Lib # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ # Intellij .idea/ # VS Code .vscode/ .history/ yolink-api-0.5.8/LICENSE000066400000000000000000000020611504651721700146140ustar00rootroot00000000000000Copyright (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.yolink-api-0.5.8/README.md000066400000000000000000000042271504651721700150740ustar00rootroot00000000000000# 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) yolink-api-0.5.8/pyproject.toml000066400000000000000000000013561504651721700165310ustar00rootroot00000000000000[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*"] yolink-api-0.5.8/yolink/000077500000000000000000000000001504651721700151155ustar00rootroot00000000000000yolink-api-0.5.8/yolink/__init__.py000066400000000000000000000000001504651721700172140ustar00rootroot00000000000000yolink-api-0.5.8/yolink/auth_mgr.py000066400000000000000000000013311504651721700172730ustar00rootroot00000000000000"""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.""" yolink-api-0.5.8/yolink/client.py000066400000000000000000000054431504651721700167530ustar00rootroot00000000000000"""YoLink client.""" from typing import Any, Dict from aiohttp import ClientError, ClientResponse from tenacity import retry, stop_after_attempt, retry_if_exception_type 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 yolink-api-0.5.8/yolink/client_request.py000066400000000000000000000007031504651721700205150ustar00rootroot00000000000000"""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 yolink-api-0.5.8/yolink/const.py000066400000000000000000000035131504651721700166170ustar00rootroot00000000000000"""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 yolink-api-0.5.8/yolink/device.py000066400000000000000000000105731504651721700167340ustar00rootroot00000000000000"""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 .endpoint import Endpoint, Endpoints from .model import BRDP, BSDPHelper from .const import ( ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_DEVICE_TOKEN, ATTR_DEVICE_TYPE, ATTR_DEVICE_MODEL_NAME, ATTR_DEVICE_PARENT_ID, ATTR_DEVICE_SERVICE_ZONE, DEVICE_MODELS_SUPPORT_MODE_SWITCHING, ) from .client_request import ClientRequest from .message_resolver import resolve_message from .device_helper import get_device_net_mode 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 yolink-api-0.5.8/yolink/device_helper.py000066400000000000000000000064771504651721700203030ustar00rootroot00000000000000"""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_LEAK_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_DOOR_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, ATTR_DEVICE_DIMMER, ATTR_DEVICE_FINGER, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_OUTLET, ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_SIREN, ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_SWITCH, ATTR_DEVICE_THERMOSTAT, ATTR_DEVICE_SOIL_TH_SENSOR, ATTR_DEVICE_LOCK, ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_HUB, ATTR_DEVICE_SPEAKER_HUB, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_SMOKE_ALARM, ATTR_DEVICE_SPRINKLER, ATTR_DEVICE_SPRINKLER_V2, ) 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 yolink-api-0.5.8/yolink/endpoint.py000066400000000000000000000016131504651721700173100ustar00rootroot00000000000000"""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, ) yolink-api-0.5.8/yolink/exception.py000066400000000000000000000012331504651721700174640ustar00rootroot00000000000000"""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.""" yolink-api-0.5.8/yolink/home_manager.py000066400000000000000000000116041504651721700201130ustar00rootroot00000000000000"""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 .exception import YoLinkClientError, YoLinkUnSupportedMethodError from .message_listener import MessageListener from .model import BRDP from .mqtt_client import YoLinkMqttClient from .endpoint import Endpoint, Endpoints _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) yolink-api-0.5.8/yolink/message_listener.py000066400000000000000000000005431504651721700210220ustar00rootroot00000000000000"""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.""" yolink-api-0.5.8/yolink/message_resolver.py000066400000000000000000000221321504651721700210340ustar00rootroot00000000000000"""YoLink cloud message resolver.""" from __future__ import annotations from typing import TYPE_CHECKING, Any from math import log2 from decimal import Decimal, ROUND_DOWN from .unit_helper import UnitOfVolume, VolumeConverter from .const import ( ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_SOIL_TH_SENSOR, ATTR_DEVICE_SPRINKLER, ATTR_DEVICE_SPRINKLER_V2, ) 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) yolink-api-0.5.8/yolink/model.py000066400000000000000000000027631504651721700165770ustar00rootroot00000000000000"""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 yolink-api-0.5.8/yolink/mqtt_client.py000066400000000000000000000122051504651721700200120ustar00rootroot00000000000000"""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 .model import BRDP from .message_resolver import resolve_sub_message _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) yolink-api-0.5.8/yolink/outlet_request_builder.py000066400000000000000000000010201504651721700222520ustar00rootroot00000000000000"""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) yolink-api-0.5.8/yolink/thermostat_request_builder.py000066400000000000000000000015621504651721700231430ustar00rootroot00000000000000"""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}) yolink-api-0.5.8/yolink/unit_helper.py000066400000000000000000000066551504651721700200210ustar00rootroot00000000000000"""YoLink Unit convert helper.""" from __future__ import annotations from collections.abc import Callable from functools import lru_cache from enum import IntEnum from .exception import YoLinkError from .const import UNIT_NOT_RECOGNIZED_TEMPLATE 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, }