pax_global_header00006660000000000000000000000064150160170500014505gustar00rootroot0000000000000052 comment=7aa20c0e9ffad068033812a14ee7004da7baf418 yolink-api-0.5.4/000077500000000000000000000000001501601705000135675ustar00rootroot00000000000000yolink-api-0.5.4/.github/000077500000000000000000000000001501601705000151275ustar00rootroot00000000000000yolink-api-0.5.4/.github/workflows/000077500000000000000000000000001501601705000171645ustar00rootroot00000000000000yolink-api-0.5.4/.github/workflows/publish-to-pypi.yml000066400000000000000000000012361501601705000227560ustar00rootroot00000000000000name: 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@v3.0.2 - name: Set up Python 3.9 uses: actions/setup-python@v3.1.2 with: python-version: 3.9 - name: Install wheel run: >- pip install wheel==0.45.1 - name: Build run: >- python3 setup.py sdist bdist_wheel - 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.4/.gitignore000066400000000000000000000015141501601705000155600ustar00rootroot00000000000000# 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.4/LICENSE000066400000000000000000000020611501601705000145730ustar00rootroot00000000000000Copyright (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.4/README.md000066400000000000000000000036531501601705000150550ustar00rootroot00000000000000# 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) yolink-api-0.5.4/pyproject.toml000066400000000000000000000001471501601705000165050ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=42", "wheel" ] build-backend = "setuptools.build_meta"yolink-api-0.5.4/requirements.txt000066400000000000000000000001211501601705000170450ustar00rootroot00000000000000setuptools==58.1.0 aiohttp>=3.8.1 pydantic>=1.9.0 aiomqtt>=2.0.0 tenacity>=8.1.0 yolink-api-0.5.4/setup.py000066400000000000000000000015521501601705000153040ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup setup( name="yolink-api", version="0.5.4", author="YoSmart", description="A library to authenticate with yolink device", long_description=open("README.md").read(), long_description_content_type="text/markdown", url="https://github.com/YoSmart-Inc/yolink-api", project_urls={ "Bug Tracker": "https://github.com/YoSmart-Inc/yolink-api/issues", }, license="MIT", keywords="yolink api", packages=["yolink"], zip_safe=False, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], python_requires=">=3.6", install_requires=[ "aiohttp>=3.8.1", "aiomqtt>=2.0.0,<3.0.0", "pydantic>=1.9.0", "tenacity>=8.1.0", ], ) yolink-api-0.5.4/yolink/000077500000000000000000000000001501601705000150745ustar00rootroot00000000000000yolink-api-0.5.4/yolink/__init__.py000066400000000000000000000000001501601705000171730ustar00rootroot00000000000000yolink-api-0.5.4/yolink/auth_mgr.py000066400000000000000000000013311501601705000172520ustar00rootroot00000000000000"""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.4/yolink/client.py000066400000000000000000000054311501601705000167270ustar00rootroot00000000000000"""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.parse_raw(_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.4/yolink/client_request.py000066400000000000000000000007031501601705000204740ustar00rootroot00000000000000"""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.4/yolink/const.py000066400000000000000000000031651501601705000166010ustar00rootroot00000000000000"""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_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" UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit." yolink-api-0.5.4/yolink/device.py000066400000000000000000000112501501601705000167040ustar00rootroot00000000000000"""YoLink Device.""" from __future__ import annotations import abc from typing import Optional from tenacity import RetryError try: from pydantic.v1 import BaseModel, Field, validator except ImportError: from pydantic import BaseModel, Field, validator from .client import YoLinkClient from .endpoint import Endpoint, Endpoints from .exception import YoLinkClientError 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, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ) from .client_request import ClientRequest from .message_resolver import ( water_depth_sensor_message_resolve, water_meter_controller_message_resolve, multi_water_meter_controller_message_resolve, ) 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) @validator("device_parent_id") def check_parent_id(cls, val): """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 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 YoLinkClientError("-1003", "yolink client request failed!") from err 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.""" state_brdp: BRDP = await self.__invoke("fetchState", None) if self.device_type == ATTR_DEVICE_WATER_DEPTH_SENSOR: water_depth_sensor_message_resolve( state_brdp.data.get("state"), self.device_attrs ) if self.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER: water_meter_controller_message_resolve( state_brdp.data.get("state"), self.device_model_name ) if self.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER: multi_water_meter_controller_message_resolve( state_brdp.data.get("state"), self.device_model_name ) 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 yolink-api-0.5.4/yolink/endpoint.py000066400000000000000000000012611501601705000172660ustar00rootroot00000000000000"""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): """Init SVR Endpoint.""" self.name = name self.host = host self.url = f"https://{host}/open/yolink/v2/api" self.mqtt_broker_host = host self.mqtt_broker_port = 8003 class Endpoints(Enum): """All YoLink SVR Endpoints.""" US: Endpoint = Endpoint(name="US", host="api.yosmart.com") EU: Endpoint = Endpoint(name="EU", host="api-eu.yosmart.com") yolink-api-0.5.4/yolink/exception.py000066400000000000000000000012331501601705000174430ustar00rootroot00000000000000"""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.4/yolink/home_manager.py000066400000000000000000000116041501601705000200720ustar00rootroot00000000000000"""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.4/yolink/message_listener.py000066400000000000000000000005431501601705000210010ustar00rootroot00000000000000"""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.4/yolink/message_resolver.py000066400000000000000000000140231501601705000210130ustar00rootroot00000000000000"""YoLink cloud message resolver.""" from typing import Any from math import log2 from decimal import Decimal, ROUND_DOWN from .unit_helper import UnitOfVolume, VolumeConverter def smart_remoter_message_resolve( event_type: str, msg_data: dict[str, Any] ) -> dict[str, Any]: """SmartRemoter message resolve.""" if msg_data is None: return msg_data 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 return msg_data def water_depth_sensor_message_resolve( msg_data: dict[str, Any], dev_attrs: dict[str, Any] ) -> dict[str, Any]: """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, 2 ) return msg_data def water_meter_controller_message_resolve( msg_data: dict[str, Any], device_model: str ) -> dict[str, Any]: """WaterMeterController message resolve.""" if msg_data is None: return msg_data if (meter_state := msg_data.get("state")) is None: return msg_data meter_step_factor: int = 10 # for some reason meter value can't be read meter_value: int = 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"] return msg_data def multi_water_meter_controller_message_resolve( msg_data: dict[str, Any], device_model: str, ) -> dict[str, Any]: if msg_data is None: return msg_data """MultiWaterMeterController message resolve.""" if (meter_state := msg_data.get("state")) is None: return msg_data 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"] return msg_data yolink-api-0.5.4/yolink/model.py000066400000000000000000000030661501601705000165530ustar00rootroot00000000000000"""YoLink Basic Model.""" from typing import Any, Dict, Optional try: from pydantic.v1 import BaseModel except ImportError: 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.4/yolink/mqtt_client.py000066400000000000000000000141501501601705000177720ustar00rootroot00000000000000"""YoLink mqtt client.""" import asyncio import logging from typing import Any import aiomqtt try: from pydantic.v1 import ValidationError except ImportError: from pydantic import ValidationError from .auth_mgr import YoLinkAuthMgr from .const import ( ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ) from .device import YoLinkDevice from .message_listener import MessageListener from .model import BRDP from .message_resolver import ( smart_remoter_message_resolve, water_depth_sensor_message_resolve, water_meter_controller_message_resolve, multi_water_meter_controller_message_resolve, ) _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=50, ) 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", ]: 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(msg_type, paired_device, paired_device_state) self.__resolve_message(msg_type, device, msg_data.data) except ValidationError: # ignore invalidate message _LOGGER.debug("Message invalidate.") def __resolve_message( self, event_type: str, device: YoLinkDevice, msg_data: dict[str, Any] ) -> None: """Resolve device message.""" if device.device_type == ATTR_DEVICE_SMART_REMOTER: msg_data = smart_remoter_message_resolve(event_type, msg_data) if device.device_type == ATTR_DEVICE_WATER_DEPTH_SENSOR: msg_data = water_depth_sensor_message_resolve(msg_data, device.device_attrs) if device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER: msg_data = water_meter_controller_message_resolve( msg_data, device.device_model_name ) if device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER: msg_data = multi_water_meter_controller_message_resolve( msg_data, device.device_model_name ) self._message_listener.on_message(device, msg_data) yolink-api-0.5.4/yolink/outlet_request_builder.py000066400000000000000000000010201501601705000222310ustar00rootroot00000000000000"""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.4/yolink/thermostat_request_builder.py000066400000000000000000000016651501601705000231260ustar00rootroot00000000000000"""Thermostat request builder""" from __future__ import annotations from typing import Optional try: from pydantic.v1 import BaseModel except ImportError: 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.4/yolink/unit_helper.py000066400000000000000000000066551501601705000200000ustar00rootroot00000000000000"""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, }