pax_global_header00006660000000000000000000000064151251454750014523gustar00rootroot0000000000000052 comment=0f12955449ba3fd3ca9920befef56b36e20caf6c YoSmart-Inc-yolink-api-0f12955/000077500000000000000000000000001512514547500161575ustar00rootroot00000000000000YoSmart-Inc-yolink-api-0f12955/.github/000077500000000000000000000000001512514547500175175ustar00rootroot00000000000000YoSmart-Inc-yolink-api-0f12955/.github/workflows/000077500000000000000000000000001512514547500215545ustar00rootroot00000000000000YoSmart-Inc-yolink-api-0f12955/.github/workflows/publish-to-pypi.yml000066400000000000000000000011761512514547500253510ustar00rootroot00000000000000name: 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 }} YoSmart-Inc-yolink-api-0f12955/.gitignore000066400000000000000000000015141512514547500201500ustar00rootroot00000000000000# 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/ YoSmart-Inc-yolink-api-0f12955/LICENSE000066400000000000000000000020611512514547500171630ustar00rootroot00000000000000Copyright (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.YoSmart-Inc-yolink-api-0f12955/README.md000066400000000000000000000042611512514547500174410ustar00rootroot00000000000000# 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) - YS7914-UC (Leak Sensor) YoSmart-Inc-yolink-api-0f12955/pyproject.toml000066400000000000000000000013561512514547500211000ustar00rootroot00000000000000[build-system] requires = ["setuptools>=77.0"] build-backend = "setuptools.build_meta" [project] name = "yolink-api" version = "0.6.1" 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*"] YoSmart-Inc-yolink-api-0f12955/yolink/000077500000000000000000000000001512514547500174645ustar00rootroot00000000000000YoSmart-Inc-yolink-api-0f12955/yolink/__init__.py000066400000000000000000000000001512514547500215630ustar00rootroot00000000000000YoSmart-Inc-yolink-api-0f12955/yolink/auth_mgr.py000066400000000000000000000013311512514547500216420ustar00rootroot00000000000000"""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.""" YoSmart-Inc-yolink-api-0f12955/yolink/client.py000066400000000000000000000056761512514547500213320ustar00rootroot00000000000000"""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) timeout = kwargs.pop("timeout", 8) # 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=timeout, ) 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 YoSmart-Inc-yolink-api-0f12955/yolink/client_request.py000066400000000000000000000007031512514547500230640ustar00rootroot00000000000000"""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 YoSmart-Inc-yolink-api-0f12955/yolink/const.py000066400000000000000000000044231512514547500211670ustar00rootroot00000000000000"""Const for YoLink Client.""" from typing import Final MANUFACTURER = "YoLink" CLASS_AD_KEEPALIVE_TIME = 9 * 60 * 60 # 9 hours in seconds CLASS_C_KEEPALIVE_TIME = 70 * 60 # 70 minutes in seconds HUB_KEEPALIVE_TIME = 10 * 60 # 10 minutes in seconds ATTR_FIELD_LORA = "loraInfo" ATTR_FIELD_STATE = "state" 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_MULTI_CAPS_LEAK_SENSOR = "MultiCapsLeakSensor" 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" # device models ATTR_DEVICE_MODEL_MULTIOUTLET_YS6801 = "YS6801" # water meter controller ATTR_DEVICE_MODEL_WATER_METER_YS5018 = "YS5018" 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 YoSmart-Inc-yolink-api-0f12955/yolink/device.py000066400000000000000000000140711512514547500213000ustar00rootroot00000000000000"""YoLink Device.""" from __future__ import annotations import abc from typing import Optional, Any from datetime import datetime, timezone 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_net_type, get_keepalive_time from time import time 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, default=None) device_parent_id: Optional[str] = Field(alias=ATTR_DEVICE_PARENT_ID, default=None) device_service_zone: Optional[str] = Field( alias=ATTR_DEVICE_SERVICE_ZONE, default=None ) @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._state: dict | None = {} self.device_model: str = ( device.device_model_name.split("-")[0] if device.device_model_name is not None else "" ) 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: if device.device_model_name is not None: self.device_endpoint: Endpoint = ( Endpoints.EU.value if device.device_model_name.endswith("-EC") else Endpoints.US.value ) else: self.device_endpoint: Endpoint = Endpoints.US.value self.class_mode: str = get_net_type(self.device_type, self.device_model) async def __invoke(self, method: str, params: dict | None, **kwargs: Any) -> 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(), **kwargs ) 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.""" # call_method: str = "getState" if self.is_hub else "fetchState" # options = {"timeout": 4} if call_method == "fetchState" else {} if self.is_hub: 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) @property def is_hub(self) -> bool: """Check if the device is a Hub device.""" return self.device_type in ["Hub", "SpeakerHub"] @property def 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 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 def is_online(self, data: dict[str, Any]) -> bool: """Check if the device is online. Not for Hub devices. """ if data is None: return False if self.is_hub and data.get("online") is not None: return data.get("online") last_report_at: Optional[int] = data.get("reportAt") if last_report_at is None: return False keepalive_time = get_keepalive_time(self) if keepalive_time <= 0: return False last_report_at_ts = datetime.strptime( last_report_at, "%Y-%m-%dT%H:%M:%S.%fZ" ).replace(tzinfo=timezone.utc) return (int(time.time()) - last_report_at_ts) <= keepalive_time YoSmart-Inc-yolink-api-0f12955/yolink/device_helper.py000066400000000000000000000061761512514547500226460ustar00rootroot00000000000000"""Helper functions for YoLink devices.""" from __future__ import annotations from .const import CLASS_AD_KEEPALIVE_TIME, CLASS_C_KEEPALIVE_TIME, HUB_KEEPALIVE_TIME 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_net_type(device_type: str, device_model: str) -> str | None: """Get device network mode.""" 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_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_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_model in ["YS5007"]: return "A" return "D" if device_type in [ATTR_DEVICE_HUB, ATTR_DEVICE_SPEAKER_HUB]: return "Hub" return None def get_keepalive_time(device_type: str, device_model: str) -> int: """Get device keepalive time in seconds.""" device_class_mode = get_net_type(device_type, device_model) if device_class_mode in ["A", "D"]: return CLASS_AD_KEEPALIVE_TIME if device_class_mode == "C": return CLASS_C_KEEPALIVE_TIME if device_class_mode == "Hub": return HUB_KEEPALIVE_TIME YoSmart-Inc-yolink-api-0f12955/yolink/endpoint.py000066400000000000000000000017471512514547500216670ustar00rootroot00000000000000"""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, url: str, mqtt_host: str, mqtt_port: int): """Init SVR Endpoint.""" self.name = name self.host = host self.url = url self.mqtt_broker_host = mqtt_host self.mqtt_broker_port = mqtt_port class Endpoints(Enum): """YoLink SVR Endpoints.""" US: Endpoint = Endpoint( name="US", host="api.yosmart.com", url="https://api.yosmart.com/open/yolink/v2/api", mqtt_host="mqtt.api.yosmart.com", mqtt_port=8003, ) EU: Endpoint = Endpoint( name="EU", host="api-eu.yosmart.com", url="https://api-eu.yosmart.com/open/yolink/v2/api", mqtt_host="api-eu.yosmart.com", mqtt_port=8003, ) YoSmart-Inc-yolink-api-0f12955/yolink/exception.py000066400000000000000000000012331512514547500220330ustar00rootroot00000000000000"""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.""" YoSmart-Inc-yolink-api-0f12955/yolink/home_manager.py000066400000000000000000000121031512514547500224550ustar00rootroot00000000000000"""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 self._endpoints: dict[str, Endpoint] = {} self._mqtt_clients: dict[str, YoLinkMqttClient] = {} self._message_listener: MessageListener 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, broker_host=endpoint.mqtt_broker_host, broker_port=endpoint.mqtt_broker_port, devices=self._home_devices, ) await endpoint_mqtt_client.connect( f"yl-home/{home_info.data['id']}/+/report", 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 # type: ignore 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 # type: ignore 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, # type: ignore 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, # type: ignore bsdp={"method": "Home.getDeviceList"}, **kwargs, ) response: BRDP = await self._http_client.execute( url=Endpoints.US.value.url, # type: ignore 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: # type: ignore # sync eu device token _yl_device.device_token = eu_dev_tokens.get(_yl_device.device_id) # type: ignore 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 list(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) YoSmart-Inc-yolink-api-0f12955/yolink/local_auth_mgr.py000066400000000000000000000051601512514547500230200ustar00rootroot00000000000000"""Local Hub Authentication Manager.""" import logging import time from asyncio import Lock from .auth_mgr import YoLinkAuthMgr from aiohttp import ClientSession, ClientError from json import JSONDecodeError from typing import cast _LOGGER = logging.getLogger(__name__) CLOCK_OUT_OF_SYNC_MAX_SEC = 20 class YoLinkLocalAuthMgr(YoLinkAuthMgr): """YoLink Local API Authentication Manager.""" def __init__( self, session: ClientSession, token_url: str, client_id: str, client_secret: str, ) -> None: """Init YoLink Local Auth Manager.""" super().__init__(session) self._token_url = token_url self._client_id: str = client_id self._client_secret: str = client_secret self._token: dict | None = None self._token_lock = Lock() def access_token(self) -> str | None: """Get auth token.""" return self._token["access_token"] if self._token is not None else None @property def valid_token(self) -> bool: if self._token is None: return False return ( cast(float, self._token["expires_at"]) > time.time() + CLOCK_OUT_OF_SYNC_MAX_SEC ) async def check_and_refresh_token(self) -> str | None: """Check and fresh token.""" async with self._token_lock: if self.valid_token: return self.access_token() new_token = await self._token_request() new_token["expires_at"] = time.time() + new_token["expires_in"] self._token = new_token return self.access_token() async def _token_request(self) -> dict: """Make a token request.""" resp = await self._session.post( url=self._token_url, headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "grant_type": "client_credentials", "scope": "create", "client_id": self._client_id, "client_secret": self._client_secret, }, ) if resp.status >= 400: try: error_response = await resp.json() except (ClientError, JSONDecodeError): error_response = {} error_code = error_response.get("error", "unknown") error_description = error_response.get("error_description", "unknown error") _LOGGER.error( "Token request failed (%s): %s", error_code, error_description, ) resp.raise_for_status() return cast(dict, await resp.json()) YoSmart-Inc-yolink-api-0f12955/yolink/local_hub_client.py000066400000000000000000000106741512514547500233340ustar00rootroot00000000000000"""YoLink Local Hub Client.""" from __future__ import annotations import logging from typing import Any from .client import YoLinkClient from .mqtt_client import YoLinkMqttClient from .local_auth_mgr import YoLinkLocalAuthMgr 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 .endpoint import Endpoint from aiohttp import ClientSession _LOGGER = logging.getLogger(__name__) has_external_data_devices = [ATTR_DEVICE_WATER_DEPTH_SENSOR] class YoLinkLocalHubClient: """YoLink Local Hub client.""" def __init__( self, session: ClientSession, host: str, net_id: str, client_id: str, client_secret: str, ) -> None: """Init YoLink Local Hub Client.""" self._session = session self._net_id: str = net_id self._client_id = client_id self._client_secret = client_secret self._endpoint = Endpoint( name="Local", host=host, url=f"http://{host}:1080/open/yolink/v2/api", mqtt_host=host, mqtt_port=18080, ) self._auth_mgr = YoLinkLocalAuthMgr( session=session, token_url=f"http://{host}:1080/open/yolink/token", client_id=client_id, client_secret=client_secret, ) self._devices: dict[str, YoLinkDevice] = {} self._http_client: YoLinkClient | None = None self._mqtt_client = None self._message_listener: MessageListener | None = None async def authenticate(self) -> bool: """Authenticate to Local Hub.""" return await self._auth_mgr.check_and_refresh_token() is not None async def async_setup(self, listener: MessageListener) -> None: """Init YoLink Local Hub Client.""" if not listener: raise YoLinkClientError( "-1002", "setup failed, message listener is required!" ) self._http_client = YoLinkClient(self._auth_mgr) await self.async_load_devices() self._message_listener = listener self._mqtt_client = YoLinkMqttClient( auth_manager=self._auth_mgr, endpoint=self._endpoint, broker_host=self._endpoint.mqtt_broker_host, broker_port=self._endpoint.mqtt_broker_port, devices=self._devices, ) await self._mqtt_client.connect( f"ylsubnet/{self._net_id}/+/report", self._message_listener ) async def async_unload(self) -> None: """Unload YoLink home.""" self._devices = {} self._http_client = None if self._mqtt_client is not None: await self._mqtt_client.disconnect() _LOGGER.info( "Local Hub mqtt client disconnected.", ) self._mqtt_client = None self._message_listener = None async def async_load_devices(self, **kwargs: Any) -> dict[str, YoLinkDevice]: """Get sub-net devices.""" if self._http_client is None: raise YoLinkClientError( "-1004", "load devices failed, http client is not initialized!" ) response: BRDP = await self._http_client.execute( self._endpoint.url, bsdp={"method": "Home.getDeviceList"}, **kwargs ) for device_data in response.data["devices"]: device = YoLinkDevice(YoLinkDeviceMode(**device_data), self._http_client) device.device_endpoint = self._endpoint if device.device_type in has_external_data_devices: try: dev_external_data_resp = await device.get_external_data() device.device_attrs = dev_external_data_resp.data.get("extData") except YoLinkUnSupportedMethodError: _LOGGER.debug( "getExternalData is not supported for: %s", device.device_type, ) self._devices[device.device_id] = device return self._devices def get_devices(self) -> list[YoLinkDevice]: """Get Local Hub sub-net devices.""" return list(self._devices.values()) def get_device(self, device_id: str) -> YoLinkDevice | None: """Get Local Hub sub-net device via device id.""" return self._devices.get(device_id) YoSmart-Inc-yolink-api-0f12955/yolink/message_listener.py000066400000000000000000000005431512514547500233710ustar00rootroot00000000000000"""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.""" YoSmart-Inc-yolink-api-0f12955/yolink/message_resolver.py000066400000000000000000000222421512514547500234050ustar00rootroot00000000000000"""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 ) -> 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 is not None and 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, ) -> 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 is not None and 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) YoSmart-Inc-yolink-api-0f12955/yolink/model.py000066400000000000000000000027631512514547500211460ustar00rootroot00000000000000"""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 YoSmart-Inc-yolink-api-0f12955/yolink/mqtt_client.py000066400000000000000000000127171512514547500223710ustar00rootroot00000000000000"""YoLink mqtt client.""" import asyncio import logging from typing import Any from aiomqtt import Client, MqttError, ProtocolVersion 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 from .endpoint import Endpoint from .local_auth_mgr import YoLinkLocalAuthMgr _LOGGER = logging.getLogger(__name__) class YoLinkMqttClient: """YoLink mqtt client.""" def __init__( self, auth_manager: YoLinkAuthMgr, endpoint: Endpoint, broker_host: str, broker_port: int, devices: dict[str, YoLinkDevice], ) -> None: self._auth_mgr = auth_manager self._endpoint = endpoint self._broker_host = broker_host self._broker_port = broker_port self._topic = None self._devices = devices self._message_listener = None self._running = False self._listener_task = None async def connect(self, topic: str, listener: MessageListener) -> None: """Connect to yolink mqtt broker.""" self._topic = topic self._message_listener = listener self._listener_task = asyncio.create_task(self._listen()) async def _check_and_refresh_token(self) -> tuple[str, str]: """Check and refresh token.""" new_token = await self._auth_mgr.check_and_refresh_token() if isinstance(self._auth_mgr, YoLinkLocalAuthMgr): return self._auth_mgr._client_id, new_token else: return new_token, "" async def _listen(self): """Listen to yolink mqtt broker.""" reconnect_interval = 30 self._running = True while self._running: try: username, password = await self._check_and_refresh_token() async with Client( hostname=self._broker_host, port=self._broker_port, username=username, password=password, keepalive=60, protocol=ProtocolVersion.V311, ) as client: _LOGGER.info( "[%s] connecting to yolink mqtt broker.", self._endpoint.name ) if self._topic is not None: await client.subscribe(self._topic) _LOGGER.info( "[%s] yolink mqtt client connected.", self._endpoint.name ) async for message in client.messages: self._process_message(message) except MqttError: _LOGGER.error( "[%s] yolink mqtt client disconnected!", self._endpoint.name, exc_info=True, ) await asyncio.sleep(reconnect_interval) except Exception: _LOGGER.error( "[%s] unexcept exception:", self._endpoint.name, exc_info=True ) await asyncio.sleep(reconnect_interval) 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._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._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) if self._message_listener is not None: self._message_listener.on_message(device, msg_data) YoSmart-Inc-yolink-api-0f12955/yolink/outlet_request_builder.py000066400000000000000000000010201512514547500246210ustar00rootroot00000000000000"""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) YoSmart-Inc-yolink-api-0f12955/yolink/thermostat_request_builder.py000066400000000000000000000015621512514547500255120ustar00rootroot00000000000000"""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}) YoSmart-Inc-yolink-api-0f12955/yolink/unit_helper.py000066400000000000000000000066551512514547500223700ustar00rootroot00000000000000"""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, }