From 8fa01343bcb27b4512c93e26918ea16a44d714a5 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 9 Apr 2023 18:13:50 +0200 Subject: [PATCH 01/35] Use connection handler --- pyhon/__init__.py | 2 +- pyhon/__main__.py | 6 +- pyhon/{device.py => appliance.py} | 2 +- pyhon/connection/__init__.py | 0 pyhon/{ => connection}/api.py | 168 ++++++++++++------------------ pyhon/{ => connection}/auth.py | 73 ++++++------- pyhon/connection/connection.py | 100 ++++++++++++++++++ pyhon/connection/device.py | 36 +++++++ 8 files changed, 247 insertions(+), 140 deletions(-) mode change 100755 => 100644 pyhon/__main__.py rename pyhon/{device.py => appliance.py} (99%) create mode 100644 pyhon/connection/__init__.py rename pyhon/{ => connection}/api.py (50%) rename pyhon/{ => connection}/auth.py (68%) create mode 100644 pyhon/connection/connection.py create mode 100644 pyhon/connection/device.py diff --git a/pyhon/__init__.py b/pyhon/__init__.py index b13705c..c761612 100644 --- a/pyhon/__init__.py +++ b/pyhon/__init__.py @@ -1 +1 @@ -from .api import HonConnection +from .connection.api import HonAPI diff --git a/pyhon/__main__.py b/pyhon/__main__.py old mode 100755 new mode 100644 index e5843d4..cc0c1be --- a/pyhon/__main__.py +++ b/pyhon/__main__.py @@ -11,7 +11,7 @@ from pprint import pprint if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyhon import HonConnection +from pyhon import HonAPI _LOGGER = logging.getLogger(__name__) @@ -85,7 +85,7 @@ def create_command(commands, concat=False): async def translate(language, json_output=False): - async with HonConnection() as hon: + async with HonAPI() as hon: keys = await hon.translation_keys(language) if json_output: print(json.dumps(keys, indent=4)) @@ -104,7 +104,7 @@ async def main(): user = input("User for hOn account: ") if not (password := args["password"]): password = getpass("Password for hOn account: ") - async with HonConnection(user, password) as hon: + async with HonAPI(user, password) as hon: for device in hon.devices: print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10) if args.get("keys"): diff --git a/pyhon/device.py b/pyhon/appliance.py similarity index 99% rename from pyhon/device.py rename to pyhon/appliance.py index 711b7e1..4137466 100644 --- a/pyhon/device.py +++ b/pyhon/appliance.py @@ -5,7 +5,7 @@ from pyhon.commands import HonCommand from pyhon.parameter import HonParameterFixed -class HonDevice: +class HonAppliance: def __init__(self, connector, appliance): if attributes := appliance.get("attributes"): appliance["attributes"] = {v["parName"]: v["parValue"] for v in attributes} diff --git a/pyhon/connection/__init__.py b/pyhon/connection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyhon/api.py b/pyhon/connection/api.py similarity index 50% rename from pyhon/api.py rename to pyhon/connection/api.py index 4a72274..62196a3 100644 --- a/pyhon/api.py +++ b/pyhon/connection/api.py @@ -1,74 +1,58 @@ import asyncio import json import logging -import secrets from datetime import datetime from typing import List -import aiohttp as aiohttp - from pyhon import const -from pyhon.auth import HonAuth -from pyhon.device import HonDevice +from pyhon.appliance import HonAppliance +from pyhon.connection.connection import HonConnectionHandler, HonAnonymousConnectionHandler _LOGGER = logging.getLogger() -class HonConnection: - def __init__(self, email="", password="", session=None) -> None: +class HonAPI: + def __init__(self, email="", password="") -> None: super().__init__() self._email = email self._password = password - self._request_headers = {"Content-Type": "application/json"} - self._session = session self._devices = [] - self._mobile_id = secrets.token_hex(8) + self._hon = None + self._hon_anonymous = HonAnonymousConnectionHandler() async def __aenter__(self): - self._session = aiohttp.ClientSession() - if self._email and self._password: - await self.setup() + self._hon = HonConnectionHandler(self._email, self._password) + await self._hon.create() + await self.setup() + return self async def __aexit__(self, exc_type, exc_val, exc_tb): - await self._session.close() + await self._hon.close() @property - def devices(self) -> List[HonDevice]: + def devices(self) -> List[HonAppliance]: return self._devices - @property - async def _headers(self): - if "cognito-token" not in self._request_headers or "id-token" not in self._request_headers: - auth = HonAuth() - if await auth.authorize(self._email, self._password, self._mobile_id): - self._request_headers["cognito-token"] = auth.cognito_token - self._request_headers["id-token"] = auth.id_token - else: - raise PermissionError("Can't Login") - return self._request_headers - async def setup(self): - async with aiohttp.ClientSession() as session: - async with session.get(f"{const.API_URL}/commands/v1/appliance", - headers=await self._headers) as resp: - try: - appliances = (await resp.json())["payload"]["appliances"] - for appliance in appliances: - device = HonDevice(self, appliance) - if device.mac_address is None: - continue - await asyncio.gather(*[ - device.load_attributes(), - device.load_commands(), - device.load_statistics()]) - self._devices.append(device) - except json.JSONDecodeError: - _LOGGER.error("No JSON Data after GET: %s", await resp.text()) - return False + async with self._hon.get(f"{const.API_URL}/commands/v1/appliance") as resp: + try: + appliances = (await resp.json())["payload"]["appliances"] + for appliance in appliances: + device = HonAppliance(self, appliance) + if device.mac_address is None: + continue + await asyncio.gather(*[ + device.load_attributes(), + device.load_commands(), + device.load_statistics()]) + self._devices.append(device) + except json.JSONDecodeError: + _LOGGER.error("No JSON Data after GET: %s", await resp.text()) + return False return True - async def load_commands(self, device: HonDevice): + async def load_commands(self, device: HonAppliance): params = { "applianceType": device.appliance_type, "code": device.appliance["code"], @@ -81,84 +65,46 @@ class HonConnection: "series": device.appliance["series"], } url = f"{const.API_URL}/commands/v1/retrieve" - async with self._session.get(url, params=params, headers=await self._headers) as response: + async with self._hon.get(url, params=params) as response: result = (await response.json()).get("payload", {}) if not result or result.pop("resultCode") != "0": return {} return result - async def command_history(self, device: HonDevice): + async def command_history(self, device: HonAppliance): url = f"{const.API_URL}/commands/v1/appliance/{device.mac_address}/history" - async with self._session.get(url, headers=await self._headers) as response: + async with self._hon.get(url) as response: result = await response.json() if not result or not result.get("payload"): return {} return result["payload"]["history"] - async def last_activity(self, device: HonDevice): + async def last_activity(self, device: HonAppliance): url = f"{const.API_URL}/commands/v1/retrieve-last-activity" params = {"macAddress": device.mac_address} - async with self._session.get(url, params=params, headers=await self._headers) as response: + async with self._hon.get(url, params=params) as response: result = await response.json() if result and (activity := result.get("attributes")): return activity return {} - async def appliance_configuration(self): - url = f"{const.API_URL}/config/v1/appliance-configuration" - headers = {"x-api-key": const.API_KEY, "content-type": "application/json"} - async with self._session.get(url, headers=headers) as response: - result = await response.json() - if result and (data := result.get("payload")): - return data - return {} - - async def app_config(self, language="en", beta=True): - headers = {"x-api-key": const.API_KEY, "content-type": "application/json"} - url = f"{const.API_URL}/app-config" - payload = { - "languageCode": language, - "beta": beta, - "appVersion": const.APP_VERSION, - "os": const.OS - } - payload = json.dumps(payload, separators=(',', ':')) - async with self._session.post(url, headers=headers, data=payload) as response: - if (result := await response.json()) and (data := result.get("payload")): - return data - return {} - - async def translation_keys(self, language="en"): - headers = {"x-api-key": const.API_KEY, "content-type": "application/json"} - config = await self.app_config(language=language) - if url := config.get("language", {}).get("jsonPath"): - async with self._session.get(url, headers=headers) as response: - if result := await response.json(): - return result - return {} - - async def load_attributes(self, device: HonDevice, loop=False): + async def load_attributes(self, device: HonAppliance): params = { "macAddress": device.mac_address, "applianceType": device.appliance_type, "category": "CYCLE" } url = f"{const.API_URL}/commands/v1/context" - async with self._session.get(url, params=params, headers=await self._headers) as response: - if response.status == 403 and not loop: - _LOGGER.error("%s - Error %s - %s", url, response.status, await response.text()) - self._request_headers.pop("cognito-token", None) - self._request_headers.pop("id-token", None) - return await self.load_attributes(device, loop=True) + async with self._hon.get(url, params=params) as response: return (await response.json()).get("payload", {}) - async def load_statistics(self, device: HonDevice): + async def load_statistics(self, device: HonAppliance): params = { "macAddress": device.mac_address, "applianceType": device.appliance_type } url = f"{const.API_URL}/commands/v1/statistics" - async with self._session.get(url, params=params, headers=await self._headers) as response: + async with self._hon.get(url, params=params) as response: return (await response.json()).get("payload", {}) async def send_command(self, device, command, parameters, ancillary_parameters): @@ -169,13 +115,7 @@ class HonConnection: "commandName": command, "transactionId": f"{device.mac_address}_{now[:-3]}Z", "applianceOptions": device.commands_options, - "device": { - "mobileId": self._mobile_id, - "mobileOs": const.OS, - "osVersion": const.OS_VERSION, - "appVersion": const.APP_VERSION, - "deviceModel": const.DEVICE_MODEL - }, + "device": self._hon.device.get(), "attributes": { "channel": "mobileApp", "origin": "standardProgram", @@ -186,7 +126,7 @@ class HonConnection: "applianceType": device.appliance_type } url = f"{const.API_URL}/commands/v1/send" - async with self._session.post(url, headers=await self._headers, json=data) as resp: + async with self._hon.post(url, json=data) as resp: try: json_data = await resp.json() except json.JSONDecodeError: @@ -194,3 +134,33 @@ class HonConnection: if json_data["payload"]["resultCode"] == "0": return True return False + + async def appliance_configuration(self): + url = f"{const.API_URL}/config/v1/appliance-configuration" + async with self._hon_anonymous.get(url) as response: + result = await response.json() + if result and (data := result.get("payload")): + return data + return {} + + async def app_config(self, language="en", beta=True): + url = f"{const.API_URL}/app-config" + payload = { + "languageCode": language, + "beta": beta, + "appVersion": const.APP_VERSION, + "os": const.OS + } + payload = json.dumps(payload, separators=(',', ':')) + async with self._hon_anonymous.post(url, data=payload) as response: + if (result := await response.json()) and (data := result.get("payload")): + return data + return {} + + async def translation_keys(self, language="en"): + config = await self.app_config(language=language) + if url := config.get("language", {}).get("jsonPath"): + async with self._hon_anonymous.get(url) as response: + if result := await response.json(): + return result + return {} diff --git a/pyhon/auth.py b/pyhon/connection/auth.py similarity index 68% rename from pyhon/auth.py rename to pyhon/connection/auth.py index 1baca58..72a7426 100644 --- a/pyhon/auth.py +++ b/pyhon/connection/auth.py @@ -3,9 +3,9 @@ import logging import re import secrets import urllib +from pprint import pprint from urllib import parse -import aiohttp as aiohttp from yarl import URL from pyhon import const @@ -14,11 +14,15 @@ _LOGGER = logging.getLogger() class HonAuth: - def __init__(self) -> None: + def __init__(self, session, email, password, device) -> None: + self._session = session + self._email = email + self._password = password self._access_token = "" self._refresh_token = "" self._cognito_token = "" self._id_token = "" + self._device = device @property def cognito_token(self): @@ -36,7 +40,7 @@ class HonAuth: def refresh_token(self): return self._refresh_token - async def _load_login(self, session): + async def _load_login(self): nonce = secrets.token_hex(16) nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" params = { @@ -48,16 +52,16 @@ class HonAuth: "nonce": nonce } params = "&".join([f"{k}={v}" for k, v in params.items()]) - async with session.get(f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}") as resp: + async with self._session.get(f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}") as resp: if not (login_url := re.findall("url = '(.+?)'", await resp.text())): return False - async with session.get(login_url[0], allow_redirects=False) as redirect1: + async with self._session.get(login_url[0], allow_redirects=False) as redirect1: if not (url := redirect1.headers.get("Location")): return False - async with session.get(url, allow_redirects=False) as redirect2: + async with self._session.get(url, allow_redirects=False) as redirect2: if not (url := redirect2.headers.get("Location") + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn"): return False - async with session.get(URL(url, encoded=True)) as login_screen: + async with self._session.get(URL(url, encoded=True)) as login_screen: if context := re.findall('"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text()): fw_uid, loaded_str = context[0] loaded = json.loads(loaded_str) @@ -65,7 +69,7 @@ class HonAuth: return fw_uid, loaded, login_url return False - async def _login(self, session, email, password, fw_uid, loaded, login_url): + async def _login(self, fw_uid, loaded, login_url): data = { "message": { "actions": [ @@ -74,8 +78,8 @@ class HonAuth: "descriptor": "apex://LightningLoginCustomController/ACTION$login", "callingDescriptor": "markup://c:loginForm", "params": { - "username": email, - "password": password, + "username": self._email, + "password": self._password, "startUrl": parse.unquote(login_url.split("startURL=")[-1]).split("%3D")[0] } } @@ -93,7 +97,7 @@ class HonAuth: "aura.token": None} params = {"r": 3, "other.LightningLoginCustom.login": 1} - async with session.post( + async with self._session.post( const.AUTH_API + "/s/sfsites/aura", headers={"Content-Type": "application/x-www-form-urlencoded"}, data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), @@ -107,19 +111,19 @@ class HonAuth: _LOGGER.error("Unable to login: %s\n%s", response.status, await response.text()) return "" - async def _get_token(self, session, url): - async with session.get(url) as resp: + async def _get_token(self, url): + async with self._session.get(url) as resp: if resp.status != 200: _LOGGER.error("Unable to get token: %s", resp.status) return False url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text()) - async with session.get(url[0]) as resp: + async with self._session.get(url[0]) as resp: if resp.status != 200: _LOGGER.error("Unable to get token: %s", resp.status) return False url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text()) url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] - async with session.get(url) as resp: + async with self._session.get(url) as resp: if resp.status != 200: _LOGGER.error("Unable to connect to the login service: %s", resp.status) return False @@ -132,26 +136,23 @@ class HonAuth: self._id_token = id_token[0] return True - async def authorize(self, email, password, mobile_id): - headers = {"user-agent": const.USER_AGENT} - async with aiohttp.ClientSession(headers=headers) as session: - if login_site := await self._load_login(session): - fw_uid, loaded, login_url = login_site - else: - return False - if not (url := await self._login(session, email, password, fw_uid, loaded, login_url)): - return False - if not await self._get_token(session, url): - return False + async def authorize(self): + if login_site := await self._load_login(): + fw_uid, loaded, login_url = login_site + else: + return False + if not (url := await self._login(fw_uid, loaded, login_url)): + return False + if not await self._get_token(url): + return False - post_headers = {"Content-Type": "application/json", "id-token": self._id_token} - data = {"appVersion": const.APP_VERSION, "mobileId": mobile_id, "osVersion": const.OS_VERSION, - "os": const.OS, "deviceModel": const.DEVICE_MODEL} - async with session.post(f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data) as resp: - try: - json_data = await resp.json() - except json.JSONDecodeError: - _LOGGER.error("No JSON Data after POST: %s", await resp.text()) - return False - self._cognito_token = json_data["cognitoUser"]["Token"] + post_headers = {"Content-Type": "application/json", "id-token": self._id_token} + data = self._device.get() + async with self._session.post(f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data) as resp: + try: + json_data = await resp.json() + except json.JSONDecodeError: + _LOGGER.error("No JSON Data after POST: %s", await resp.text()) + return False + self._cognito_token = json_data["cognitoUser"]["Token"] return True diff --git a/pyhon/connection/connection.py b/pyhon/connection/connection.py new file mode 100644 index 0000000..6c9bf56 --- /dev/null +++ b/pyhon/connection/connection.py @@ -0,0 +1,100 @@ +from contextlib import asynccontextmanager + +import aiohttp + +from pyhon import const +from pyhon.connection.auth import HonAuth, _LOGGER +from pyhon.connection.device import HonDevice + + +class HonBaseConnectionHandler: + _HEADERS = {"user-agent": const.USER_AGENT, "Content-Type": "application/json"} + + def __init__(self): + self._session = None + self._auth = None + + async def __aenter__(self): + await self.create() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def create(self): + self._session = aiohttp.ClientSession(headers=self._HEADERS) + + @asynccontextmanager + async def get(self, *args, **kwargs): + raise NotImplemented + + @asynccontextmanager + async def post(self, *args, **kwargs): + raise NotImplemented + + async def close(self): + await self._session.close() + + +class HonConnectionHandler(HonBaseConnectionHandler): + def __init__(self, email, password): + super().__init__() + self._device = HonDevice() + self._email = email + self._password = password + if not self._email: + raise PermissionError("Login-Error - An email address must be specified") + if not self._password: + raise PermissionError("Login-Error - A password address must be specified") + self._request_headers = {} + + @property + def device(self): + return self._device + + async def create(self): + await super().create() + self._auth = HonAuth(self._session, self._email, self._password, self._device) + + async def _check_headers(self, headers): + if "cognito-token" not in self._request_headers or "id-token" not in self._request_headers: + if await self._auth.authorize(): + self._request_headers["cognito-token"] = self._auth.cognito_token + self._request_headers["id-token"] = self._auth.id_token + else: + raise PermissionError("Can't Login") + return {h: v for h, v in self._request_headers.items() if h not in headers} + + @asynccontextmanager + async def get(self, *args, loop=0, **kwargs): + kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) + async with self._session.get(*args, **kwargs) as response: + if response.status == 403 and not loop: + _LOGGER.warning("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) + await self.create() + yield await self.get(*args, loop=loop + 1, **kwargs) + elif loop >= 2: + _LOGGER.error("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) + raise PermissionError() + else: + yield response + + @asynccontextmanager + async def post(self, *args, **kwargs): + kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) + async with self._session.post(*args, **kwargs) as response: + yield response + + +class HonAnonymousConnectionHandler(HonBaseConnectionHandler): + _HEADERS = HonBaseConnectionHandler._HEADERS | {"x-api-key": const.API_KEY} + + @asynccontextmanager + async def get(self, *args, **kwargs): + async with self._session.post(*args, **kwargs) as response: + yield response + + @asynccontextmanager + async def post(self, *args, **kwargs): + async with self._session.post(*args, **kwargs) as response: + yield response diff --git a/pyhon/connection/device.py b/pyhon/connection/device.py new file mode 100644 index 0000000..f817885 --- /dev/null +++ b/pyhon/connection/device.py @@ -0,0 +1,36 @@ +import secrets + +from pyhon import const + + +class HonDevice: + def __init__(self): + self._app_version = const.APP_VERSION + self._os_version = const.OS_VERSION + self._os = const.OS + self._device_model = const.DEVICE_MODEL + self._mobile_id = secrets.token_hex(8) + + @property + def app_version(self): + return self._app_version + + @property + def os_version(self): + return self._os_version + + @property + def os(self): + return self._os + + @property + def device_model(self): + return self._device_model + + @property + def mobile_id(self): + return self._mobile_id + + def get(self): + return {"appVersion": self.app_version, "mobileId": self.mobile_id, "osVersion": self.os_version, + "os": self.os, "deviceModel": self.device_model} From eb6741145a7d00febe29bf7a3366445110c1d720 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 9 Apr 2023 18:43:57 +0200 Subject: [PATCH 02/35] Get new token via refresh-token #10 --- pyhon/connection/auth.py | 18 +++++++++++++++++- pyhon/connection/connection.py | 20 ++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 72a7426..3066555 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -1,3 +1,4 @@ +import datetime import json import logging import re @@ -146,7 +147,7 @@ class HonAuth: if not await self._get_token(url): return False - post_headers = {"Content-Type": "application/json", "id-token": self._id_token} + post_headers = {"id-token": self._id_token} data = self._device.get() async with self._session.post(f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data) as resp: try: @@ -156,3 +157,18 @@ class HonAuth: return False self._cognito_token = json_data["cognitoUser"]["Token"] return True + + async def refresh(self): + params = { + "client_id": const.CLIENT_ID, + "refresh_token": self._refresh_token, + "grant_type": "refresh_token" + } + async with self._session.post(f"{const.AUTH_API}/services/oauth2/token", params=params) as resp: + if resp.status >= 400: + return False + data = await resp.json() + self._id_token = data["id_token"] + self._access_token = data["access_token"] + + diff --git a/pyhon/connection/connection.py b/pyhon/connection/connection.py index 6c9bf56..66deb5b 100644 --- a/pyhon/connection/connection.py +++ b/pyhon/connection/connection.py @@ -66,23 +66,31 @@ class HonConnectionHandler(HonBaseConnectionHandler): return {h: v for h, v in self._request_headers.items() if h not in headers} @asynccontextmanager - async def get(self, *args, loop=0, **kwargs): + async def _intercept(self, method, *args, loop=0, **kwargs): kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) - async with self._session.get(*args, **kwargs) as response: + async with method(*args, **kwargs) as response: if response.status == 403 and not loop: + _LOGGER.info("Try refreshing token...") + await self._auth.refresh() + yield await self._intercept(method, *args, loop=loop + 1, **kwargs) + elif response.status == 403 and loop < 2: _LOGGER.warning("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) await self.create() - yield await self.get(*args, loop=loop + 1, **kwargs) + yield await self._intercept(method, *args, loop=loop + 1, **kwargs) elif loop >= 2: _LOGGER.error("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) - raise PermissionError() + raise PermissionError("Login failure") else: yield response + @asynccontextmanager + async def get(self, *args, **kwargs): + async with self._intercept(self._session.get, *args, **kwargs) as response: + yield response + @asynccontextmanager async def post(self, *args, **kwargs): - kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) - async with self._session.post(*args, **kwargs) as response: + async with self._intercept(self._session.post, *args, **kwargs) as response: yield response From 36fad84ee206ff7ed12daff7e05a684efa8295a7 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 9 Apr 2023 20:50:28 +0200 Subject: [PATCH 03/35] Refactor api access --- pyhon/__init__.py | 1 + pyhon/__main__.py | 6 +- pyhon/appliance.py | 40 ++++---- pyhon/connection/api.py | 93 +++++++------------ pyhon/connection/auth.py | 2 - .../connection/{connection.py => handler.py} | 14 ++- pyhon/hon.py | 36 +++++++ 7 files changed, 107 insertions(+), 85 deletions(-) rename pyhon/connection/{connection.py => handler.py} (90%) create mode 100644 pyhon/hon.py diff --git a/pyhon/__init__.py b/pyhon/__init__.py index c761612..e2b5e87 100644 --- a/pyhon/__init__.py +++ b/pyhon/__init__.py @@ -1 +1,2 @@ from .connection.api import HonAPI +from hon import Hon diff --git a/pyhon/__main__.py b/pyhon/__main__.py index cc0c1be..aed3a5d 100644 --- a/pyhon/__main__.py +++ b/pyhon/__main__.py @@ -11,7 +11,7 @@ from pprint import pprint if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyhon import HonAPI +from pyhon import Hon, HonAPI _LOGGER = logging.getLogger(__name__) @@ -104,8 +104,8 @@ async def main(): user = input("User for hOn account: ") if not (password := args["password"]): password = getpass("Password for hOn account: ") - async with HonAPI(user, password) as hon: - for device in hon.devices: + async with Hon(user, password) as hon: + for device in hon.appliances: print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10) if args.get("keys"): data = device.data.copy() diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 4137466..1670980 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -6,11 +6,11 @@ from pyhon.parameter import HonParameterFixed class HonAppliance: - def __init__(self, connector, appliance): - if attributes := appliance.get("attributes"): - appliance["attributes"] = {v["parName"]: v["parValue"] for v in attributes} - self._appliance = appliance - self._connector = connector + def __init__(self, api, info): + if attributes := info.get("attributes"): + info["attributes"] = {v["parName"]: v["parValue"] for v in attributes} + self._info = info + self._api = api self._appliance_model = {} self._commands = {} @@ -36,7 +36,7 @@ class HonAppliance: return self.data[item] if item in self.attributes["parameters"]: return self.attributes["parameters"].get(item) - return self.appliance[item] + return self.info[item] def get(self, item, default=None): try: @@ -46,23 +46,23 @@ class HonAppliance: @property def appliance_model_id(self): - return self._appliance.get("applianceModelId") + return self._info.get("applianceModelId") @property def appliance_type(self): - return self._appliance.get("applianceTypeName") + return self._info.get("applianceTypeName") @property def mac_address(self): - return self._appliance.get("macAddress") + return self._info.get("macAddress") @property def model_name(self): - return self._appliance.get("modelName") + return self._info.get("modelName") @property def nick_name(self): - return self._appliance.get("nickName") + return self._info.get("nickName") @property def commands_options(self): @@ -81,11 +81,11 @@ class HonAppliance: return self._statistics @property - def appliance(self): - return self._appliance + def info(self): + return self._info async def _recover_last_command_states(self, commands): - command_history = await self._connector.command_history(self) + command_history = await self._api.command_history(self) for name, command in commands.items(): last = next((index for (index, d) in enumerate(command_history) if d.get("command", {}).get("commandName") == name), None) if last is None: @@ -100,19 +100,19 @@ class HonAppliance: data.value = parameters.get(key) async def load_commands(self): - raw = await self._connector.load_commands(self) + raw = await self._api.load_commands(self) self._appliance_model = raw.pop("applianceModel") for item in ["settings", "options", "dictionaryId"]: raw.pop(item) commands = {} for command, attr in raw.items(): if "parameters" in attr: - commands[command] = HonCommand(command, attr, self._connector, self) + commands[command] = HonCommand(command, attr, self._api, self) elif "parameters" in attr[list(attr)[0]]: multi = {} for program, attr2 in attr.items(): program = program.split(".")[-1].lower() - cmd = HonCommand(command, attr2, self._connector, self, multi=multi, program=program) + cmd = HonCommand(command, attr2, self._api, self, multi=multi, program=program) multi[program] = cmd commands[command] = cmd self._commands = commands @@ -137,19 +137,19 @@ class HonAppliance: return result async def load_attributes(self): - self._attributes = await self._connector.load_attributes(self) + self._attributes = await self._api.load_attributes(self) for name, values in self._attributes.pop("shadow").get("parameters").items(): self._attributes.setdefault("parameters", {})[name] = values["parNewVal"] async def load_statistics(self): - self._statistics = await self._connector.load_statistics(self) + self._statistics = await self._api.load_statistics(self) async def update(self): await self.load_attributes() @property def data(self): - result = {"attributes": self.attributes, "appliance": self.appliance, "statistics": self.statistics, + result = {"attributes": self.attributes, "appliance": self.info, "statistics": self.statistics, **self.parameters} if self._extra: return self._extra.data(result) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index 62196a3..34566cc 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -1,12 +1,10 @@ -import asyncio import json import logging from datetime import datetime -from typing import List from pyhon import const from pyhon.appliance import HonAppliance -from pyhon.connection.connection import HonConnectionHandler, HonAnonymousConnectionHandler +from pyhon.connection.handler import HonConnectionHandler, HonAnonymousConnectionHandler _LOGGER = logging.getLogger() @@ -16,53 +14,34 @@ class HonAPI: super().__init__() self._email = email self._password = password - self._devices = [] self._hon = None self._hon_anonymous = HonAnonymousConnectionHandler() async def __aenter__(self): - self._hon = HonConnectionHandler(self._email, self._password) - await self._hon.create() - await self.setup() - - return self + return await self.create() async def __aexit__(self, exc_type, exc_val, exc_tb): await self._hon.close() - @property - def devices(self) -> List[HonAppliance]: - return self._devices + async def create(self): + self._hon = await HonConnectionHandler(self._email, self._password).create() + return self - async def setup(self): + async def load_appliances(self): async with self._hon.get(f"{const.API_URL}/commands/v1/appliance") as resp: - try: - appliances = (await resp.json())["payload"]["appliances"] - for appliance in appliances: - device = HonAppliance(self, appliance) - if device.mac_address is None: - continue - await asyncio.gather(*[ - device.load_attributes(), - device.load_commands(), - device.load_statistics()]) - self._devices.append(device) - except json.JSONDecodeError: - _LOGGER.error("No JSON Data after GET: %s", await resp.text()) - return False - return True + return await resp.json() - async def load_commands(self, device: HonAppliance): + async def load_commands(self, appliance: HonAppliance): params = { - "applianceType": device.appliance_type, - "code": device.appliance["code"], - "applianceModelId": device.appliance_model_id, - "firmwareId": device.appliance["eepromId"], - "macAddress": device.mac_address, - "fwVersion": device.appliance["fwVersion"], + "applianceType": appliance.appliance_type, + "code": appliance.info["code"], + "applianceModelId": appliance.appliance_model_id, + "firmwareId": appliance.info["eepromId"], + "macAddress": appliance.mac_address, + "fwVersion": appliance.info["fwVersion"], "os": const.OS, "appVersion": const.APP_VERSION, - "series": device.appliance["series"], + "series": appliance.info["series"], } url = f"{const.API_URL}/commands/v1/retrieve" async with self._hon.get(url, params=params) as response: @@ -71,51 +50,51 @@ class HonAPI: return {} return result - async def command_history(self, device: HonAppliance): - url = f"{const.API_URL}/commands/v1/appliance/{device.mac_address}/history" + async def command_history(self, appliance: HonAppliance): + url = f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/history" async with self._hon.get(url) as response: result = await response.json() if not result or not result.get("payload"): return {} return result["payload"]["history"] - async def last_activity(self, device: HonAppliance): + async def last_activity(self, appliance: HonAppliance): url = f"{const.API_URL}/commands/v1/retrieve-last-activity" - params = {"macAddress": device.mac_address} + params = {"macAddress": appliance.mac_address} async with self._hon.get(url, params=params) as response: result = await response.json() if result and (activity := result.get("attributes")): return activity return {} - async def load_attributes(self, device: HonAppliance): + async def load_attributes(self, appliance: HonAppliance): params = { - "macAddress": device.mac_address, - "applianceType": device.appliance_type, + "macAddress": appliance.mac_address, + "applianceType": appliance.appliance_type, "category": "CYCLE" } url = f"{const.API_URL}/commands/v1/context" async with self._hon.get(url, params=params) as response: return (await response.json()).get("payload", {}) - async def load_statistics(self, device: HonAppliance): + async def load_statistics(self, appliance: HonAppliance): params = { - "macAddress": device.mac_address, - "applianceType": device.appliance_type + "macAddress": appliance.mac_address, + "applianceType": appliance.appliance_type } url = f"{const.API_URL}/commands/v1/statistics" async with self._hon.get(url, params=params) as response: return (await response.json()).get("payload", {}) - async def send_command(self, device, command, parameters, ancillary_parameters): + async def send_command(self, appliance, command, parameters, ancillary_parameters): now = datetime.utcnow().isoformat() data = { - "macAddress": device.mac_address, + "macAddress": appliance.mac_address, "timestamp": f"{now[:-3]}Z", "commandName": command, - "transactionId": f"{device.mac_address}_{now[:-3]}Z", - "applianceOptions": device.commands_options, - "device": self._hon.device.get(), + "transactionId": f"{appliance.mac_address}_{now[:-3]}Z", + "applianceOptions": appliance.commands_options, + "appliance": self._hon.device.get(), "attributes": { "channel": "mobileApp", "origin": "standardProgram", @@ -123,15 +102,12 @@ class HonAPI: }, "ancillaryParameters": ancillary_parameters, "parameters": parameters, - "applianceType": device.appliance_type + "applianceType": appliance.appliance_type } url = f"{const.API_URL}/commands/v1/send" async with self._hon.post(url, json=data) as resp: - try: - json_data = await resp.json() - except json.JSONDecodeError: - return False - if json_data["payload"]["resultCode"] == "0": + json_data = await resp.json() + if json_data.get("payload", {}).get("resultCode") == "0": return True return False @@ -164,3 +140,6 @@ class HonAPI: if result := await response.json(): return result return {} + + async def close(self): + await self._hon.close() diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 3066555..86960bc 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -1,10 +1,8 @@ -import datetime import json import logging import re import secrets import urllib -from pprint import pprint from urllib import parse from yarl import URL diff --git a/pyhon/connection/connection.py b/pyhon/connection/handler.py similarity index 90% rename from pyhon/connection/connection.py rename to pyhon/connection/handler.py index 66deb5b..2a3cf55 100644 --- a/pyhon/connection/connection.py +++ b/pyhon/connection/handler.py @@ -1,3 +1,4 @@ +import json from contextlib import asynccontextmanager import aiohttp @@ -15,14 +16,14 @@ class HonBaseConnectionHandler: self._auth = None async def __aenter__(self): - await self.create() - return self + return await self.create() async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() async def create(self): self._session = aiohttp.ClientSession(headers=self._HEADERS) + return self @asynccontextmanager async def get(self, *args, **kwargs): @@ -55,6 +56,7 @@ class HonConnectionHandler(HonBaseConnectionHandler): async def create(self): await super().create() self._auth = HonAuth(self._session, self._email, self._password, self._device) + return self async def _check_headers(self, headers): if "cognito-token" not in self._request_headers or "id-token" not in self._request_headers: @@ -81,7 +83,13 @@ class HonConnectionHandler(HonBaseConnectionHandler): _LOGGER.error("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) raise PermissionError("Login failure") else: - yield response + try: + await response.json() + yield response + except json.JSONDecodeError: + _LOGGER.warning("%s - JsonDecodeError %s - %s", response.request_info.url, response.status, + await response.text()) + yield {} @asynccontextmanager async def get(self, *args, **kwargs): diff --git a/pyhon/hon.py b/pyhon/hon.py new file mode 100644 index 0000000..f30783c --- /dev/null +++ b/pyhon/hon.py @@ -0,0 +1,36 @@ +import asyncio +from typing import List + +from pyhon import HonAPI +from pyhon.appliance import HonAppliance + + +class Hon: + def __init__(self, email, password): + self._email = email + self._password = password + self._appliances = [] + self._api = None + + async def __aenter__(self): + self._api = await HonAPI(self._email, self._password).create() + await self.setup() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._api.close() + + @property + def appliances(self) -> List[HonAppliance]: + return self._appliances + + async def setup(self): + for appliance in (await self._api.load_appliances())["payload"]["appliances"]: + appliance = HonAppliance(self._api, appliance) + if appliance.mac_address is None: + continue + await asyncio.gather(*[ + appliance.load_attributes(), + appliance.load_commands(), + appliance.load_statistics()]) + self._appliances.append(appliance) From 8dc6cd71cda0c9401d63591f05b347c1a4914062 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 9 Apr 2023 20:55:36 +0200 Subject: [PATCH 04/35] Format with black --- pyhon/__main__.py | 21 +++++++++--- pyhon/appliance.py | 30 +++++++++++++---- pyhon/appliances/ov.py | 2 +- pyhon/commands.py | 35 +++++++++++++++----- pyhon/connection/api.py | 12 +++---- pyhon/connection/auth.py | 64 ++++++++++++++++++++++++------------- pyhon/connection/device.py | 9 ++++-- pyhon/connection/handler.py | 27 +++++++++++++--- pyhon/hon.py | 11 ++++--- pyhon/parameter.py | 12 +++++-- setup.py | 8 ++--- 11 files changed, 166 insertions(+), 65 deletions(-) diff --git a/pyhon/__main__.py b/pyhon/__main__.py index aed3a5d..7eac5b6 100644 --- a/pyhon/__main__.py +++ b/pyhon/__main__.py @@ -25,8 +25,12 @@ def get_arguments(): keys = subparser.add_parser("keys", help="print as key format") keys.add_argument("keys", help="print as key format", action="store_true") keys.add_argument("--all", help="print also full keys", action="store_true") - translate = subparser.add_parser("translate", help="print available translation keys") - translate.add_argument("translate", help="language (de, en, fr...)", metavar="LANGUAGE") + translate = subparser.add_parser( + "translate", help="print available translation keys" + ) + translate.add_argument( + "translate", help="language (de, en, fr...)", metavar="LANGUAGE" + ) translate.add_argument("--json", help="print as json", action="store_true") return vars(parser.parse_args()) @@ -51,7 +55,9 @@ def pretty_print(data, key="", intend=0, is_list=False): else: pretty_print(value, key=key, intend=intend) else: - print(f"{' ' * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}") + print( + f"{' ' * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}" + ) def key_print(data, key="", start=True): @@ -90,7 +96,12 @@ async def translate(language, json_output=False): if json_output: print(json.dumps(keys, indent=4)) else: - clean_keys = json.dumps(keys).replace("\\n", "\\\\n").replace("\\\\r", "").replace("\\r", "") + clean_keys = ( + json.dumps(keys) + .replace("\\n", "\\\\n") + .replace("\\\\r", "") + .replace("\\r", "") + ) keys = json.loads(clean_keys) pretty_print(keys) @@ -126,5 +137,5 @@ def start(): print("Aborted.") -if __name__ == '__main__': +if __name__ == "__main__": start() diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 1670980..5ce7db3 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -18,7 +18,9 @@ class HonAppliance: self._attributes = {} try: - self._extra = importlib.import_module(f'pyhon.appliances.{self.appliance_type.lower()}').Appliance() + self._extra = importlib.import_module( + f"pyhon.appliances.{self.appliance_type.lower()}" + ).Appliance() except ModuleNotFoundError: self._extra = None @@ -87,7 +89,14 @@ class HonAppliance: async def _recover_last_command_states(self, commands): command_history = await self._api.command_history(self) for name, command in commands.items(): - last = next((index for (index, d) in enumerate(command_history) if d.get("command", {}).get("commandName") == name), None) + last = next( + ( + index + for (index, d) in enumerate(command_history) + if d.get("command", {}).get("commandName") == name + ), + None, + ) if last is None: continue parameters = command_history[last].get("command", {}).get("parameters", {}) @@ -95,7 +104,10 @@ class HonAppliance: command.set_program(parameters.pop("program").split(".")[-1].lower()) command = self.commands[name] for key, data in command.settings.items(): - if not isinstance(data, HonParameterFixed) and parameters.get(key) is not None: + if ( + not isinstance(data, HonParameterFixed) + and parameters.get(key) is not None + ): with suppress(ValueError): data.value = parameters.get(key) @@ -112,7 +124,9 @@ class HonAppliance: multi = {} for program, attr2 in attr.items(): program = program.split(".")[-1].lower() - cmd = HonCommand(command, attr2, self._api, self, multi=multi, program=program) + cmd = HonCommand( + command, attr2, self._api, self, multi=multi, program=program + ) multi[program] = cmd commands[command] = cmd self._commands = commands @@ -149,8 +163,12 @@ class HonAppliance: @property def data(self): - result = {"attributes": self.attributes, "appliance": self.info, "statistics": self.statistics, - **self.parameters} + result = { + "attributes": self.attributes, + "appliance": self.info, + "statistics": self.statistics, + **self.parameters, + } if self._extra: return self._extra.data(result) return result diff --git a/pyhon/appliances/ov.py b/pyhon/appliances/ov.py index 0d3fe18..54eb7a9 100644 --- a/pyhon/appliances/ov.py +++ b/pyhon/appliances/ov.py @@ -4,7 +4,7 @@ from pyhon.parameter import HonParameterEnum class Appliance: def __init__(self): filters = ["receipt", "standard, special"] - data = {'defaultValue': filters[0], 'enumValues': filters} + data = {"defaultValue": filters[0], "enumValues": filters} self._program_filter = HonParameterEnum("program_filter", data) def data(self, data): diff --git a/pyhon/commands.py b/pyhon/commands.py index 7c1e9ee..b12493a 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -1,4 +1,9 @@ -from pyhon.parameter import HonParameterFixed, HonParameterEnum, HonParameterRange, HonParameterProgram +from pyhon.parameter import ( + HonParameterFixed, + HonParameterEnum, + HonParameterRange, + HonParameterProgram, +) class HonCommand: @@ -10,7 +15,9 @@ class HonCommand: self._program = program self._description = attributes.get("description", "") self._parameters = self._create_parameters(attributes.get("parameters", {})) - self._ancillary_parameters = self._create_parameters(attributes.get("ancillaryParameters", {})) + self._ancillary_parameters = self._create_parameters( + attributes.get("ancillaryParameters", {}) + ) def __repr__(self): return f"{self._name} command" @@ -35,11 +42,18 @@ class HonCommand: @property def ancillary_parameters(self): - return {key: parameter.value for key, parameter in self._ancillary_parameters.items()} + return { + key: parameter.value + for key, parameter in self._ancillary_parameters.items() + } async def send(self): - parameters = {name: parameter.value for name, parameter in self._parameters.items()} - return await self._connector.send_command(self._device, self._name, parameters, self.ancillary_parameters) + parameters = { + name: parameter.value for name, parameter in self._parameters.items() + } + return await self._connector.send_command( + self._device, self._name, parameters, self.ancillary_parameters + ) def get_programs(self): return self._multi @@ -61,11 +75,16 @@ class HonCommand: def setting_keys(self): if not self._multi: return self._get_settings_keys() - result = [key for cmd in self._multi.values() for key in self._get_settings_keys(cmd)] + result = [ + key for cmd in self._multi.values() for key in self._get_settings_keys(cmd) + ] return list(set(result + ["program"])) @property def settings(self): """Parameters with typology enum and range""" - return {s: self._parameters.get(s) for s in self.setting_keys if self._parameters.get(s) is not None} - + return { + s: self._parameters.get(s) + for s in self.setting_keys + if self._parameters.get(s) is not None + } diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index 34566cc..c67a7e7 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -71,7 +71,7 @@ class HonAPI: params = { "macAddress": appliance.mac_address, "applianceType": appliance.appliance_type, - "category": "CYCLE" + "category": "CYCLE", } url = f"{const.API_URL}/commands/v1/context" async with self._hon.get(url, params=params) as response: @@ -80,7 +80,7 @@ class HonAPI: async def load_statistics(self, appliance: HonAppliance): params = { "macAddress": appliance.mac_address, - "applianceType": appliance.appliance_type + "applianceType": appliance.appliance_type, } url = f"{const.API_URL}/commands/v1/statistics" async with self._hon.get(url, params=params) as response: @@ -98,11 +98,11 @@ class HonAPI: "attributes": { "channel": "mobileApp", "origin": "standardProgram", - "energyLabel": "0" + "energyLabel": "0", }, "ancillaryParameters": ancillary_parameters, "parameters": parameters, - "applianceType": appliance.appliance_type + "applianceType": appliance.appliance_type, } url = f"{const.API_URL}/commands/v1/send" async with self._hon.post(url, json=data) as resp: @@ -125,9 +125,9 @@ class HonAPI: "languageCode": language, "beta": beta, "appVersion": const.APP_VERSION, - "os": const.OS + "os": const.OS, } - payload = json.dumps(payload, separators=(',', ':')) + payload = json.dumps(payload, separators=(",", ":")) async with self._hon_anonymous.post(url, data=payload) as response: if (result := await response.json()) and (data := result.get("payload")): return data diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 86960bc..2404102 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -45,26 +45,37 @@ class HonAuth: params = { "response_type": "token+id_token", "client_id": const.CLIENT_ID, - "redirect_uri": urllib.parse.quote(f"{const.APP}://mobilesdk/detect/oauth/done"), + "redirect_uri": urllib.parse.quote( + f"{const.APP}://mobilesdk/detect/oauth/done" + ), "display": "touch", "scope": "api openid refresh_token web", - "nonce": nonce + "nonce": nonce, } params = "&".join([f"{k}={v}" for k, v in params.items()]) - async with self._session.get(f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}") as resp: + async with self._session.get( + f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" + ) as resp: if not (login_url := re.findall("url = '(.+?)'", await resp.text())): return False async with self._session.get(login_url[0], allow_redirects=False) as redirect1: if not (url := redirect1.headers.get("Location")): return False async with self._session.get(url, allow_redirects=False) as redirect2: - if not (url := redirect2.headers.get("Location") + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn"): + if not ( + url := redirect2.headers.get("Location") + + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn" + ): return False async with self._session.get(URL(url, encoded=True)) as login_screen: - if context := re.findall('"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text()): + if context := re.findall( + '"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text() + ): fw_uid, loaded_str = context[0] loaded = json.loads(loaded_str) - login_url = login_url[0].replace("/".join(const.AUTH_API.split("/")[:-1]), "") + login_url = login_url[0].replace( + "/".join(const.AUTH_API.split("/")[:-1]), "" + ) return fw_uid, loaded, login_url return False @@ -79,8 +90,10 @@ class HonAuth: "params": { "username": self._email, "password": self._password, - "startUrl": parse.unquote(login_url.split("startURL=")[-1]).split("%3D")[0] - } + "startUrl": parse.unquote( + login_url.split("startURL=")[-1] + ).split("%3D")[0], + }, } ] }, @@ -91,23 +104,28 @@ class HonAuth: "loaded": loaded, "dn": [], "globals": {}, - "uad": False}, + "uad": False, + }, "aura.pageURI": login_url, - "aura.token": None} - + "aura.token": None, + } params = {"r": 3, "other.LightningLoginCustom.login": 1} async with self._session.post( - const.AUTH_API + "/s/sfsites/aura", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), - params=params + const.AUTH_API + "/s/sfsites/aura", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), + params=params, ) as response: if response.status == 200: try: - return (await response.json())["events"][0]["attributes"]["values"]["url"] + return (await response.json())["events"][0]["attributes"]["values"][ + "url" + ] except json.JSONDecodeError: pass - _LOGGER.error("Unable to login: %s\n%s", response.status, await response.text()) + _LOGGER.error( + "Unable to login: %s\n%s", response.status, await response.text() + ) return "" async def _get_token(self, url): @@ -147,7 +165,9 @@ class HonAuth: post_headers = {"id-token": self._id_token} data = self._device.get() - async with self._session.post(f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data) as resp: + async with self._session.post( + f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data + ) as resp: try: json_data = await resp.json() except json.JSONDecodeError: @@ -160,13 +180,13 @@ class HonAuth: params = { "client_id": const.CLIENT_ID, "refresh_token": self._refresh_token, - "grant_type": "refresh_token" + "grant_type": "refresh_token", } - async with self._session.post(f"{const.AUTH_API}/services/oauth2/token", params=params) as resp: + async with self._session.post( + f"{const.AUTH_API}/services/oauth2/token", params=params + ) as resp: if resp.status >= 400: return False data = await resp.json() self._id_token = data["id_token"] self._access_token = data["access_token"] - - diff --git a/pyhon/connection/device.py b/pyhon/connection/device.py index f817885..f7399d9 100644 --- a/pyhon/connection/device.py +++ b/pyhon/connection/device.py @@ -32,5 +32,10 @@ class HonDevice: return self._mobile_id def get(self): - return {"appVersion": self.app_version, "mobileId": self.mobile_id, "osVersion": self.os_version, - "os": self.os, "deviceModel": self.device_model} + return { + "appVersion": self.app_version, + "mobileId": self.mobile_id, + "osVersion": self.os_version, + "os": self.os, + "deviceModel": self.device_model, + } diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index 2a3cf55..428e52d 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -59,7 +59,10 @@ class HonConnectionHandler(HonBaseConnectionHandler): return self async def _check_headers(self, headers): - if "cognito-token" not in self._request_headers or "id-token" not in self._request_headers: + if ( + "cognito-token" not in self._request_headers + or "id-token" not in self._request_headers + ): if await self._auth.authorize(): self._request_headers["cognito-token"] = self._auth.cognito_token self._request_headers["id-token"] = self._auth.id_token @@ -76,19 +79,33 @@ class HonConnectionHandler(HonBaseConnectionHandler): await self._auth.refresh() yield await self._intercept(method, *args, loop=loop + 1, **kwargs) elif response.status == 403 and loop < 2: - _LOGGER.warning("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) + _LOGGER.warning( + "%s - Error %s - %s", + response.request_info.url, + response.status, + await response.text(), + ) await self.create() yield await self._intercept(method, *args, loop=loop + 1, **kwargs) elif loop >= 2: - _LOGGER.error("%s - Error %s - %s", response.request_info.url, response.status, await response.text()) + _LOGGER.error( + "%s - Error %s - %s", + response.request_info.url, + response.status, + await response.text(), + ) raise PermissionError("Login failure") else: try: await response.json() yield response except json.JSONDecodeError: - _LOGGER.warning("%s - JsonDecodeError %s - %s", response.request_info.url, response.status, - await response.text()) + _LOGGER.warning( + "%s - JsonDecodeError %s - %s", + response.request_info.url, + response.status, + await response.text(), + ) yield {} @asynccontextmanager diff --git a/pyhon/hon.py b/pyhon/hon.py index f30783c..0b7c88f 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -29,8 +29,11 @@ class Hon: appliance = HonAppliance(self._api, appliance) if appliance.mac_address is None: continue - await asyncio.gather(*[ - appliance.load_attributes(), - appliance.load_commands(), - appliance.load_statistics()]) + await asyncio.gather( + *[ + appliance.load_attributes(), + appliance.load_commands(), + appliance.load_statistics(), + ] + ) self._appliances.append(appliance) diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 647fb5d..0814823 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -79,7 +79,9 @@ class HonParameterRange(HonParameter): if self._min <= value <= self._max and not value % self._step: self._value = value else: - raise ValueError(f"Allowed: min {self._min} max {self._max} step {self._step}") + raise ValueError( + f"Allowed: min {self._min} max {self._max} step {self._step}" + ) class HonParameterEnum(HonParameter): @@ -138,4 +140,10 @@ class HonParameterProgram(HonParameterEnum): @property def values(self): - return sorted([str(value) for value in self._values if not self._filter or self._filter in str(value)]) + return sorted( + [ + str(value) + for value in self._values + if not self._filter or self._filter in str(value) + ] + ) diff --git a/setup.py b/setup.py index df6672c..dfbfbeb 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( author="Andre Basche", description="Control hOn devices with python", long_description=long_description, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", project_urls={ "GitHub": "https://github.com/Andre0512/pyhOn", "PyPI": "https://pypi.org/project/pyhOn", @@ -33,8 +33,8 @@ setup( "Topic :: Software Development :: Libraries :: Python Modules", ], entry_points={ - 'console_scripts': [ - 'pyhOn = pyhon.__main__:start', + "console_scripts": [ + "pyhOn = pyhon.__main__:start", ] - } + }, ) From 2597cbd8830e8011513d26f23ed0f111b9c2719a Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 9 Apr 2023 21:29:29 +0200 Subject: [PATCH 05/35] Use regex oven filters #9 --- pyhon/appliances/ov.py | 13 ++++++++++--- pyhon/parameter.py | 9 ++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pyhon/appliances/ov.py b/pyhon/appliances/ov.py index 0d3fe18..2fc63d7 100644 --- a/pyhon/appliances/ov.py +++ b/pyhon/appliances/ov.py @@ -2,9 +2,15 @@ from pyhon.parameter import HonParameterEnum class Appliance: + _FILTERS = { + "default": "^(?!iot_(?:recipe|guided))\\S+$", + "recipe": "iot_recipe_", + "guided": "iot_guided_", + } + def __init__(self): - filters = ["receipt", "standard, special"] - data = {'defaultValue': filters[0], 'enumValues': filters} + filters = list(self._FILTERS.values()) + data = {"defaultValue": filters[0], "enumValues": filters} self._program_filter = HonParameterEnum("program_filter", data) def data(self, data): @@ -12,5 +18,6 @@ class Appliance: def settings(self, settings): settings["program_filter"] = self._program_filter - settings["startProgram.program"].filter = self._program_filter.value + value = self._FILTERS[self._program_filter.value] + settings["startProgram.program"].filter = value return settings diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 647fb5d..2e2d25b 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -1,3 +1,6 @@ +import re + + class HonParameter: def __init__(self, key, attributes): self._key = key @@ -138,4 +141,8 @@ class HonParameterProgram(HonParameterEnum): @property def values(self): - return sorted([str(value) for value in self._values if not self._filter or self._filter in str(value)]) + values = [] + for value in self._values: + if not self._filter or re.findall(self._filter, str(value)): + values.append(str(value)) + return sorted(values) From e8531f3fafe02a6edfe01d7e59a0a8e25bbc9083 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 9 Apr 2023 23:47:33 +0200 Subject: [PATCH 06/35] Small fixes, Add checks --- .github/workflows/python-check.yml | 39 ++++++++++++++++++++++++++++++ README.md | 16 ++++++------ pyhon/__init__.py | 4 ++- pyhon/__main__.py | 1 - pyhon/connection/api.py | 9 ++++--- pyhon/connection/handler.py | 4 +-- 6 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/python-check.yml mode change 100644 => 100755 pyhon/__main__.py diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml new file mode 100644 index 0000000..b5b6b8f --- /dev/null +++ b/.github/workflows/python-check.yml @@ -0,0 +1,39 @@ +name: Python check + +on: + push: + branches: [ "main", "refactor" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install flake8 pylint black + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics + # - name: Analysing the code with pylint + # run: | + # pylint --max-line-length 88 $(git ls-files '*.py') + - name: Check black style + run: | + black . --check diff --git a/README.md b/README.md index 46cfb42..b28df60 100644 --- a/README.md +++ b/README.md @@ -47,28 +47,28 @@ settings: ### List devices ```python import asyncio -from pyhon import HonConnection +from pyhon import Hon async def devices_example(): - async with HonConnection(USER, PASSWORD) as hon: - for device in hon.devices: - print(device.nick_name) + async with Hon(USER, PASSWORD) as hon: + for appliance in hon.appliances: + print(appliance.nick_name) asyncio.run(devices_example()) ``` ### Execute a command ```python -async with HonConnection(USER, PASSWORD) as hon: - washing_machine = hon.devices[0] +async with Hon(USER, PASSWORD) as hon: + washing_machine = hon.appliances[0] pause_command = washing_machine.commands["pauseProgram"] await pause_command.send() ``` ### Set command parameter ```python -async with HonConnection(USER, PASSWORD) as hon: - washing_machine = hon.devices[0] +async with Hon(USER, PASSWORD) as hon: + washing_machine = hon.appliances[0] start_command = washing_machine.commands["startProgram"] for name, setting in start_command.settings: print("Setting", name) diff --git a/pyhon/__init__.py b/pyhon/__init__.py index e2b5e87..93728b7 100644 --- a/pyhon/__init__.py +++ b/pyhon/__init__.py @@ -1,2 +1,4 @@ from .connection.api import HonAPI -from hon import Hon +from .hon import Hon + +__all__ = ["Hon", "HonAPI"] diff --git a/pyhon/__main__.py b/pyhon/__main__.py old mode 100644 new mode 100755 index 7eac5b6..a7afac8 --- a/pyhon/__main__.py +++ b/pyhon/__main__.py @@ -6,7 +6,6 @@ import logging import sys from getpass import getpass from pathlib import Path -from pprint import pprint if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index c67a7e7..afe8591 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -10,12 +10,13 @@ _LOGGER = logging.getLogger() class HonAPI: - def __init__(self, email="", password="") -> None: + def __init__(self, email="", password="", anonymous=False) -> None: super().__init__() self._email = email self._password = password + self._anonymous = anonymous self._hon = None - self._hon_anonymous = HonAnonymousConnectionHandler() + self._hon_anonymous = None async def __aenter__(self): return await self.create() @@ -24,7 +25,9 @@ class HonAPI: await self._hon.close() async def create(self): - self._hon = await HonConnectionHandler(self._email, self._password).create() + self._hon_anonymous = HonAnonymousConnectionHandler() + if not self._anonymous: + self._hon = await HonConnectionHandler(self._email, self._password).create() return self async def load_appliances(self): diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index 428e52d..a239a69 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -27,11 +27,11 @@ class HonBaseConnectionHandler: @asynccontextmanager async def get(self, *args, **kwargs): - raise NotImplemented + raise NotImplementedError @asynccontextmanager async def post(self, *args, **kwargs): - raise NotImplemented + raise NotImplementedError async def close(self): await self._session.close() From 3e3fc7ad66c541449110908813dace0ddc2519a7 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Mon, 10 Apr 2023 06:34:19 +0200 Subject: [PATCH 07/35] Fix translation command --- pyhon/__main__.py | 2 +- pyhon/connection/api.py | 18 ++++++++++++----- pyhon/connection/handler.py | 40 ++++++++++++++++--------------------- pyhon/hon.py | 19 +++++++++++++----- 4 files changed, 45 insertions(+), 34 deletions(-) diff --git a/pyhon/__main__.py b/pyhon/__main__.py index a7afac8..38e6958 100755 --- a/pyhon/__main__.py +++ b/pyhon/__main__.py @@ -90,7 +90,7 @@ def create_command(commands, concat=False): async def translate(language, json_output=False): - async with HonAPI() as hon: + async with HonAPI(anonymous=True) as hon: keys = await hon.translation_keys(language) if json_output: print(json.dumps(keys, indent=4)) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index afe8591..dfa2b65 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -10,24 +10,29 @@ _LOGGER = logging.getLogger() class HonAPI: - def __init__(self, email="", password="", anonymous=False) -> None: + def __init__(self, email="", password="", anonymous=False, session=None) -> None: super().__init__() self._email = email self._password = password self._anonymous = anonymous self._hon = None self._hon_anonymous = None + self._session = session async def __aenter__(self): return await self.create() async def __aexit__(self, exc_type, exc_val, exc_tb): - await self._hon.close() + await self.close() async def create(self): - self._hon_anonymous = HonAnonymousConnectionHandler() + self._hon_anonymous = await HonAnonymousConnectionHandler( + self._session + ).create() if not self._anonymous: - self._hon = await HonConnectionHandler(self._email, self._password).create() + self._hon = await HonConnectionHandler( + self._email, self._password, self._session + ).create() return self async def load_appliances(self): @@ -145,4 +150,7 @@ class HonAPI: return {} async def close(self): - await self._hon.close() + if self._hon: + await self._hon.close() + if self._hon_anonymous: + await self._hon_anonymous.close() diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index a239a69..b16f48a 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -11,8 +11,8 @@ from pyhon.connection.device import HonDevice class HonBaseConnectionHandler: _HEADERS = {"user-agent": const.USER_AGENT, "Content-Type": "application/json"} - def __init__(self): - self._session = None + def __init__(self, session=None): + self._session = session self._auth = None async def __aenter__(self): @@ -26,20 +26,26 @@ class HonBaseConnectionHandler: return self @asynccontextmanager - async def get(self, *args, **kwargs): + async def _intercept(self, method, *args, loop=0, **kwargs): raise NotImplementedError + @asynccontextmanager + async def get(self, *args, **kwargs): + async with self._intercept(self._session.get, *args, **kwargs) as response: + yield response + @asynccontextmanager async def post(self, *args, **kwargs): - raise NotImplementedError + async with self._intercept(self._session.post, *args, **kwargs) as response: + yield response async def close(self): await self._session.close() class HonConnectionHandler(HonBaseConnectionHandler): - def __init__(self, email, password): - super().__init__() + def __init__(self, email, password, session=None): + super().__init__(session=session) self._device = HonDevice() self._email = email self._password = password @@ -108,26 +114,14 @@ class HonConnectionHandler(HonBaseConnectionHandler): ) yield {} - @asynccontextmanager - async def get(self, *args, **kwargs): - async with self._intercept(self._session.get, *args, **kwargs) as response: - yield response - - @asynccontextmanager - async def post(self, *args, **kwargs): - async with self._intercept(self._session.post, *args, **kwargs) as response: - yield response - class HonAnonymousConnectionHandler(HonBaseConnectionHandler): _HEADERS = HonBaseConnectionHandler._HEADERS | {"x-api-key": const.API_KEY} @asynccontextmanager - async def get(self, *args, **kwargs): - async with self._session.post(*args, **kwargs) as response: - yield response - - @asynccontextmanager - async def post(self, *args, **kwargs): - async with self._session.post(*args, **kwargs) as response: + async def _intercept(self, method, *args, loop=0, **kwargs): + kwargs["headers"] = kwargs.pop("headers", {}) | self._HEADERS + async with method(*args, **kwargs) as response: + if response.status == 403: + print("Can't authorize") yield response diff --git a/pyhon/hon.py b/pyhon/hon.py index 0b7c88f..322b077 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -6,19 +6,25 @@ from pyhon.appliance import HonAppliance class Hon: - def __init__(self, email, password): + def __init__(self, email, password, session=None): self._email = email self._password = password + self._session = session self._appliances = [] self._api = None async def __aenter__(self): - self._api = await HonAPI(self._email, self._password).create() - await self.setup() - return self + return await self.create() async def __aexit__(self, exc_type, exc_val, exc_tb): - await self._api.close() + await self.close() + + async def create(self): + self._api = await HonAPI( + self._email, self._password, session=self._session + ).create() + await self.setup() + return self @property def appliances(self) -> List[HonAppliance]: @@ -37,3 +43,6 @@ class Hon: ] ) self._appliances.append(appliance) + + async def close(self): + await self._api.close() From ba208ffd98aa8c47731cc12d71f90164bfc52eb3 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Mon, 10 Apr 2023 06:42:40 +0200 Subject: [PATCH 08/35] Improve logging --- pyhon/connection/auth.py | 20 ++++++++++++++------ setup.py | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 2404102..b6e0d5e 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -3,6 +3,7 @@ import logging import re import secrets import urllib +from pprint import pformat from urllib import parse from yarl import URL @@ -118,11 +119,14 @@ class HonAuth: ) as response: if response.status == 200: try: - return (await response.json())["events"][0]["attributes"]["values"][ - "url" - ] + data = await response.json() + return data["events"][0]["attributes"]["values"]["url"] except json.JSONDecodeError: pass + except KeyError: + _LOGGER.error( + "Can't get login url - %s", pformat(await response.json()) + ) _LOGGER.error( "Unable to login: %s\n%s", response.status, await response.text() ) @@ -133,7 +137,10 @@ class HonAuth: if resp.status != 200: _LOGGER.error("Unable to get token: %s", resp.status) return False - url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text()) + url = re.findall("href\\s*=\\s*[\"'](http.+?)[\"']", await resp.text()) + if not url: + _LOGGER.error("Can't get login url - \n%s", await resp.text()) + raise PermissionError async with self._session.get(url[0]) as resp: if resp.status != 200: _LOGGER.error("Unable to get token: %s", resp.status) @@ -188,5 +195,6 @@ class HonAuth: if resp.status >= 400: return False data = await resp.json() - self._id_token = data["id_token"] - self._access_token = data["access_token"] + self._id_token = data["id_token"] + self._access_token = data["access_token"] + return True diff --git a/setup.py b/setup.py index dfbfbeb..83d4176 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.5.0", + version="0.6.0", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 7c4958994487f55aa01f2f13c07cf67fa3fbf260 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Mon, 10 Apr 2023 16:59:10 +0200 Subject: [PATCH 09/35] Use float for range parameter --- pyhon/parameter.py | 14 +++++++++----- setup.py | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyhon/parameter.py b/pyhon/parameter.py index bbb269b..0e31cd3 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -1,6 +1,10 @@ import re +def str_to_float(string): + return float(string.replace(",", ".")) + + class HonParameter: def __init__(self, key, attributes): self._key = key @@ -51,10 +55,10 @@ class HonParameterFixed(HonParameter): class HonParameterRange(HonParameter): def __init__(self, key, attributes): super().__init__(key, attributes) - self._min = int(attributes["minimumValue"]) - self._max = int(attributes["maximumValue"]) - self._step = int(attributes["incrementValue"]) - self._default = int(attributes.get("defaultValue", self._min)) + self._min = str_to_float(attributes["minimumValue"]) + self._max = str_to_float(attributes["maximumValue"]) + self._step = str_to_float(attributes["incrementValue"]) + self._default = str_to_float(attributes.get("defaultValue", self._min)) self._value = self._default def __repr__(self): @@ -78,7 +82,7 @@ class HonParameterRange(HonParameter): @value.setter def value(self, value): - value = int(value) + value = str_to_float(value) if self._min <= value <= self._max and not value % self._step: self._value = value else: diff --git a/setup.py b/setup.py index 83d4176..0151d1a 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.6.0", + version="0.6.1", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 79c967849257427b97a7994301c21b20fdde7c82 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Mon, 10 Apr 2023 18:48:12 +0200 Subject: [PATCH 10/35] Fix float convertion --- pyhon/parameter.py | 5 ++++- setup.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 0e31cd3..31dbda1 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -2,7 +2,10 @@ import re def str_to_float(string): - return float(string.replace(",", ".")) + try: + return int(string) + except ValueError: + return float(str(string.replace(",", "."))) class HonParameter: diff --git a/setup.py b/setup.py index 0151d1a..ad83dd0 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.6.1", + version="0.6.2", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From e857fe91de17baaf4aa93d690db036acaf5df9fd Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Mon, 10 Apr 2023 20:31:55 +0200 Subject: [PATCH 11/35] Fix login issue --- .github/workflows/python-check.yml | 2 +- pyhon/connection/auth.py | 66 ++++++++++++++++++------------ setup.py | 2 +- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index b5b6b8f..d0d9b7a 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -2,7 +2,7 @@ name: Python check on: push: - branches: [ "main", "refactor" ] + branches: [ "main" ] pull_request: branches: [ "main" ] diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index b6e0d5e..553a1f6 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -2,6 +2,7 @@ import json import logging import re import secrets +import sys import urllib from pprint import pformat from urllib import parse @@ -10,7 +11,7 @@ from yarl import URL from pyhon import const -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) class HonAuth: @@ -56,19 +57,23 @@ class HonAuth: params = "&".join([f"{k}={v}" for k, v in params.items()]) async with self._session.get( f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" - ) as resp: - if not (login_url := re.findall("url = '(.+?)'", await resp.text())): + ) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) + if not (login_url := re.findall("url = '(.+?)'", await response.text())): return False async with self._session.get(login_url[0], allow_redirects=False) as redirect1: + _LOGGER.debug("%s - %s", redirect1.status, redirect1.request_info.url) if not (url := redirect1.headers.get("Location")): return False async with self._session.get(url, allow_redirects=False) as redirect2: + _LOGGER.debug("%s - %s", redirect2.status, redirect2.request_info.url) if not ( url := redirect2.headers.get("Location") + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn" ): return False async with self._session.get(URL(url, encoded=True)) as login_screen: + _LOGGER.debug("%s - %s", login_screen.status, login_screen.request_info.url) if context := re.findall( '"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text() ): @@ -117,6 +122,7 @@ class HonAuth: data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), params=params, ) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) if response.status == 200: try: data = await response.json() @@ -133,25 +139,31 @@ class HonAuth: return "" async def _get_token(self, url): - async with self._session.get(url) as resp: - if resp.status != 200: - _LOGGER.error("Unable to get token: %s", resp.status) + async with self._session.get(url) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) + if response.status != 200: + _LOGGER.error("Unable to get token: %s", response.status) return False - url = re.findall("href\\s*=\\s*[\"'](http.+?)[\"']", await resp.text()) - if not url: - _LOGGER.error("Can't get login url - \n%s", await resp.text()) - raise PermissionError - async with self._session.get(url[0]) as resp: - if resp.status != 200: - _LOGGER.error("Unable to get token: %s", resp.status) + url = re.findall("href\\s*=\\s*[\"'](.+?)[\"']", await response.text()) + if not url: + _LOGGER.error("Can't get login url - \n%s", await response.text()) + raise PermissionError + if "ProgressiveLogin" in url[0]: + async with self._session.get(url[0]) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) + if response.status != 200: + _LOGGER.error("Unable to get token: %s", response.status) + return False + url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await response.text()) + url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] + async with self._session.get(url) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) + if response.status != 200: + _LOGGER.error( + "Unable to connect to the login service: %s", response.status + ) return False - url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text()) - url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] - async with self._session.get(url) as resp: - if resp.status != 200: - _LOGGER.error("Unable to connect to the login service: %s", resp.status) - return False - text = await resp.text() + text = await response.text() if access_token := re.findall("access_token=(.*?)&", text): self._access_token = access_token[0] if refresh_token := re.findall("refresh_token=(.*?)&", text): @@ -174,11 +186,12 @@ class HonAuth: data = self._device.get() async with self._session.post( f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data - ) as resp: + ) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) try: - json_data = await resp.json() + json_data = await response.json() except json.JSONDecodeError: - _LOGGER.error("No JSON Data after POST: %s", await resp.text()) + _LOGGER.error("No JSON Data after POST: %s", await response.text()) return False self._cognito_token = json_data["cognitoUser"]["Token"] return True @@ -191,10 +204,11 @@ class HonAuth: } async with self._session.post( f"{const.AUTH_API}/services/oauth2/token", params=params - ) as resp: - if resp.status >= 400: + ) as response: + _LOGGER.debug("%s - %s", response.status, response.request_info.url) + if response.status >= 400: return False - data = await resp.json() + data = await response.json() self._id_token = data["id_token"] self._access_token = data["access_token"] return True diff --git a/setup.py b/setup.py index ad83dd0..2cc8e05 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.6.2", + version="0.6.3", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From b4b782c52c6f6b0bb8dbf3f2fb8f00fd0985e8e7 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Tue, 11 Apr 2023 00:59:00 +0200 Subject: [PATCH 12/35] Improve logging for authentication errors --- pyhon/connection/auth.py | 60 +++++++++++++++++++++++-------------- pyhon/connection/handler.py | 11 +++---- pyhon/exceptions.py | 2 ++ setup.py | 2 +- 4 files changed, 46 insertions(+), 29 deletions(-) create mode 100644 pyhon/exceptions.py diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 553a1f6..ac88fdc 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -2,7 +2,6 @@ import json import logging import re import secrets -import sys import urllib from pprint import pformat from urllib import parse @@ -10,6 +9,7 @@ from urllib import parse from yarl import URL from pyhon import const +from pyhon.exceptions import HonAuthenticationError _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ class HonAuth: self._cognito_token = "" self._id_token = "" self._device = device + self._called_urls = [] @property def cognito_token(self): @@ -41,6 +42,16 @@ class HonAuth: def refresh_token(self): return self._refresh_token + async def _error_logger(self, response, fail=True): + result = "hOn Authentication Error\n" + for i, (status, url) in enumerate(self._called_urls): + result += f" {i + 1: 2d} {status} - {url}\n" + result += f"ERROR - {response.status} - {response.request_info.url}\n" + result += f"{15 * '='} Response {15 * '='}\n{await response.text()}\n{40 * '='}" + _LOGGER.error(result) + if fail: + raise HonAuthenticationError("Can't login") + async def _load_login(self): nonce = secrets.token_hex(16) nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" @@ -58,22 +69,27 @@ class HonAuth: async with self._session.get( f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" ) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) if not (login_url := re.findall("url = '(.+?)'", await response.text())): + await self._error_logger(response) return False async with self._session.get(login_url[0], allow_redirects=False) as redirect1: - _LOGGER.debug("%s - %s", redirect1.status, redirect1.request_info.url) + self._called_urls.append((redirect1.status, redirect1.request_info.url)) if not (url := redirect1.headers.get("Location")): + await self._error_logger(redirect1) return False async with self._session.get(url, allow_redirects=False) as redirect2: - _LOGGER.debug("%s - %s", redirect2.status, redirect2.request_info.url) + self._called_urls.append((redirect2.status, redirect2.request_info.url)) if not ( url := redirect2.headers.get("Location") + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn" ): + await self._error_logger(redirect2) return False async with self._session.get(URL(url, encoded=True)) as login_screen: - _LOGGER.debug("%s - %s", login_screen.status, login_screen.request_info.url) + self._called_urls.append( + (login_screen.status, login_screen.request_info.url) + ) if context := re.findall( '"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text() ): @@ -83,6 +99,7 @@ class HonAuth: "/".join(const.AUTH_API.split("/")[:-1]), "" ) return fw_uid, loaded, login_url + await self._error_logger(login_screen) return False async def _login(self, fw_uid, loaded, login_url): @@ -122,7 +139,7 @@ class HonAuth: data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), params=params, ) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) if response.status == 200: try: data = await response.json() @@ -133,35 +150,31 @@ class HonAuth: _LOGGER.error( "Can't get login url - %s", pformat(await response.json()) ) - _LOGGER.error( - "Unable to login: %s\n%s", response.status, await response.text() - ) + await self._error_logger(response) return "" async def _get_token(self, url): async with self._session.get(url) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) if response.status != 200: - _LOGGER.error("Unable to get token: %s", response.status) + await self._error_logger(response) return False url = re.findall("href\\s*=\\s*[\"'](.+?)[\"']", await response.text()) - if not url: - _LOGGER.error("Can't get login url - \n%s", await response.text()) - raise PermissionError + if not url: + await self._error_logger(response) + return False if "ProgressiveLogin" in url[0]: async with self._session.get(url[0]) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) if response.status != 200: - _LOGGER.error("Unable to get token: %s", response.status) + await self._error_logger(response) return False url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await response.text()) url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] async with self._session.get(url) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) if response.status != 200: - _LOGGER.error( - "Unable to connect to the login service: %s", response.status - ) + await self._error_logger(response) return False text = await response.text() if access_token := re.findall("access_token=(.*?)&", text): @@ -187,11 +200,11 @@ class HonAuth: async with self._session.post( f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data ) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) try: json_data = await response.json() except json.JSONDecodeError: - _LOGGER.error("No JSON Data after POST: %s", await response.text()) + await self._error_logger(response) return False self._cognito_token = json_data["cognitoUser"]["Token"] return True @@ -205,8 +218,9 @@ class HonAuth: async with self._session.post( f"{const.AUTH_API}/services/oauth2/token", params=params ) as response: - _LOGGER.debug("%s - %s", response.status, response.request_info.url) + self._called_urls.append((response.status, response.request_info.url)) if response.status >= 400: + await self._error_logger(response, fail=False) return False data = await response.json() self._id_token = data["id_token"] diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index b16f48a..f56b4b7 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -6,6 +6,7 @@ import aiohttp from pyhon import const from pyhon.connection.auth import HonAuth, _LOGGER from pyhon.connection.device import HonDevice +from pyhon.exceptions import HonAuthenticationError class HonBaseConnectionHandler: @@ -50,9 +51,9 @@ class HonConnectionHandler(HonBaseConnectionHandler): self._email = email self._password = password if not self._email: - raise PermissionError("Login-Error - An email address must be specified") + raise HonAuthenticationError("An email address must be specified") if not self._password: - raise PermissionError("Login-Error - A password address must be specified") + raise HonAuthenticationError("A password address must be specified") self._request_headers = {} @property @@ -73,7 +74,7 @@ class HonConnectionHandler(HonBaseConnectionHandler): self._request_headers["cognito-token"] = self._auth.cognito_token self._request_headers["id-token"] = self._auth.id_token else: - raise PermissionError("Can't Login") + raise HonAuthenticationError("Can't login") return {h: v for h, v in self._request_headers.items() if h not in headers} @asynccontextmanager @@ -100,7 +101,7 @@ class HonConnectionHandler(HonBaseConnectionHandler): response.status, await response.text(), ) - raise PermissionError("Login failure") + raise HonAuthenticationError("Login failure") else: try: await response.json() @@ -123,5 +124,5 @@ class HonAnonymousConnectionHandler(HonBaseConnectionHandler): kwargs["headers"] = kwargs.pop("headers", {}) | self._HEADERS async with method(*args, **kwargs) as response: if response.status == 403: - print("Can't authorize") + _LOGGER.error("Can't authenticate anymore") yield response diff --git a/pyhon/exceptions.py b/pyhon/exceptions.py new file mode 100644 index 0000000..9b6b7fa --- /dev/null +++ b/pyhon/exceptions.py @@ -0,0 +1,2 @@ +class HonAuthenticationError(Exception): + pass diff --git a/setup.py b/setup.py index 2cc8e05..2ef3b45 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.6.3", + version="0.6.4", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 8c832b44cd839dc5dc5bb59a7629aa2c8e604cf9 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Tue, 11 Apr 2023 17:09:02 +0200 Subject: [PATCH 13/35] Fix token refresh problems --- pyhon/connection/auth.py | 4 +++- pyhon/connection/handler.py | 12 +++++++----- pyhon/parameter.py | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index ac88fdc..6f587a2 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -194,7 +194,9 @@ class HonAuth: return False if not await self._get_token(url): return False + return await self._api_auth() + async def _api_auth(self): post_headers = {"id-token": self._id_token} data = self._device.get() async with self._session.post( @@ -225,4 +227,4 @@ class HonAuth: data = await response.json() self._id_token = data["id_token"] self._access_token = data["access_token"] - return True + return await self._api_auth() diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index f56b4b7..8895a36 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -75,17 +75,18 @@ class HonConnectionHandler(HonBaseConnectionHandler): self._request_headers["id-token"] = self._auth.id_token else: raise HonAuthenticationError("Can't login") - return {h: v for h, v in self._request_headers.items() if h not in headers} + return headers | self._request_headers @asynccontextmanager async def _intercept(self, method, *args, loop=0, **kwargs): kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) async with method(*args, **kwargs) as response: - if response.status == 403 and not loop: + if response.status in [401, 403] and loop == 0: _LOGGER.info("Try refreshing token...") await self._auth.refresh() - yield await self._intercept(method, *args, loop=loop + 1, **kwargs) - elif response.status == 403 and loop < 2: + async with self._intercept(method, *args, loop=loop + 1, **kwargs) as result: + yield result + elif response.status in [401, 403] and loop == 1: _LOGGER.warning( "%s - Error %s - %s", response.request_info.url, @@ -93,7 +94,8 @@ class HonConnectionHandler(HonBaseConnectionHandler): await response.text(), ) await self.create() - yield await self._intercept(method, *args, loop=loop + 1, **kwargs) + async with self._intercept(method, *args, loop=loop + 1, **kwargs) as result: + yield result elif loop >= 2: _LOGGER.error( "%s - Error %s - %s", diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 31dbda1..7d9208e 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -5,7 +5,7 @@ def str_to_float(string): try: return int(string) except ValueError: - return float(str(string.replace(",", "."))) + return float(str(string).replace(",", ".")) class HonParameter: From 46e6a85e849496739a1b179778a5802afb860b38 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Tue, 11 Apr 2023 22:14:36 +0200 Subject: [PATCH 14/35] add diagnose property for devices --- pyhon/__main__.py | 83 +++++++++---------------------------- pyhon/appliance.py | 13 ++++++ pyhon/connection/handler.py | 8 +++- pyhon/helper.py | 63 ++++++++++++++++++++++++++++ setup.py | 2 +- 5 files changed, 103 insertions(+), 66 deletions(-) mode change 100755 => 100644 pyhon/__main__.py create mode 100644 pyhon/helper.py diff --git a/pyhon/__main__.py b/pyhon/__main__.py old mode 100755 new mode 100644 index 38e6958..ed28866 --- a/pyhon/__main__.py +++ b/pyhon/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyhon import Hon, HonAPI +from pyhon import Hon, HonAPI, helper _LOGGER = logging.getLogger(__name__) @@ -34,61 +34,6 @@ def get_arguments(): return vars(parser.parse_args()) -# yaml.dump() would be done the same, but needs an additional dependency... -def pretty_print(data, key="", intend=0, is_list=False): - if type(data) is list: - if key: - print(f"{' ' * intend}{'- ' if is_list else ''}{key}:") - intend += 1 - for i, value in enumerate(data): - pretty_print(value, intend=intend, is_list=True) - elif type(data) is dict: - if key: - print(f"{' ' * intend}{'- ' if is_list else ''}{key}:") - intend += 1 - for i, (key, value) in enumerate(sorted(data.items())): - if is_list and not i: - pretty_print(value, key=key, intend=intend, is_list=True) - elif is_list: - pretty_print(value, key=key, intend=intend + 1) - else: - pretty_print(value, key=key, intend=intend) - else: - print( - f"{' ' * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}" - ) - - -def key_print(data, key="", start=True): - if type(data) is list: - for i, value in enumerate(data): - key_print(value, key=f"{key}.{i}", start=False) - elif type(data) is dict: - for k, value in sorted(data.items()): - key_print(value, key=k if start else f"{key}.{k}", start=False) - else: - print(f"{key}: {data}") - - -def create_command(commands, concat=False): - result = {} - for name, command in commands.items(): - if not concat: - result[name] = {} - for parameter, data in command.parameters.items(): - if data.typology == "enum": - value = data.values - elif data.typology == "range": - value = {"min": data.min, "max": data.max, "step": data.step} - else: - continue - if not concat: - result[name][parameter] = value - else: - result[f"{name}.{parameter}"] = value - return result - - async def translate(language, json_output=False): async with HonAPI(anonymous=True) as hon: keys = await hon.translation_keys(language) @@ -102,7 +47,7 @@ async def translate(language, json_output=False): .replace("\\r", "") ) keys = json.loads(clean_keys) - pretty_print(keys) + print(helper.pretty_print(keys)) async def main(): @@ -120,13 +65,25 @@ async def main(): if args.get("keys"): data = device.data.copy() attr = "get" if args.get("all") else "pop" - key_print(data["attributes"].__getattribute__(attr)("parameters")) - key_print(data.__getattribute__(attr)("appliance")) - key_print(data) - pretty_print(create_command(device.commands, concat=True)) + print( + helper.key_print( + data["attributes"].__getattribute__(attr)("parameters") + ) + ) + print(helper.key_print(data.__getattribute__(attr)("appliance"))) + print(helper.key_print(data)) + print( + helper.pretty_print( + helper.create_command(device.commands, concat=True) + ) + ) else: - pretty_print({"data": device.data}) - pretty_print({"settings": create_command(device.commands)}) + print(helper.pretty_print({"data": device.data})) + print( + helper.pretty_print( + {"settings": helper.create_command(device.commands)} + ) + ) def start(): diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 5ce7db3..21d39c6 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -1,6 +1,7 @@ import importlib from contextlib import suppress +from pyhon import helper from pyhon.commands import HonCommand from pyhon.parameter import HonParameterFixed @@ -172,3 +173,15 @@ class HonAppliance: if self._extra: return self._extra.data(result) return result + + @property + def diagnose(self): + data = self.data.copy() + for sensible in ["PK", "SK", "serialNumber", "code"]: + data["appliance"].pop(sensible, None) + result = helper.pretty_print({"data": self.data}, whitespace="\u200B \u200B ") + result += helper.pretty_print( + {"commands": helper.create_command(self.commands)}, + whitespace="\u200B \u200B ", + ) + return result.replace(self.mac_address, "12-34-56-78-90-ab") diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index 8895a36..dc18082 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -84,7 +84,9 @@ class HonConnectionHandler(HonBaseConnectionHandler): if response.status in [401, 403] and loop == 0: _LOGGER.info("Try refreshing token...") await self._auth.refresh() - async with self._intercept(method, *args, loop=loop + 1, **kwargs) as result: + async with self._intercept( + method, *args, loop=loop + 1, **kwargs + ) as result: yield result elif response.status in [401, 403] and loop == 1: _LOGGER.warning( @@ -94,7 +96,9 @@ class HonConnectionHandler(HonBaseConnectionHandler): await response.text(), ) await self.create() - async with self._intercept(method, *args, loop=loop + 1, **kwargs) as result: + async with self._intercept( + method, *args, loop=loop + 1, **kwargs + ) as result: yield result elif loop >= 2: _LOGGER.error( diff --git a/pyhon/helper.py b/pyhon/helper.py new file mode 100644 index 0000000..d126b91 --- /dev/null +++ b/pyhon/helper.py @@ -0,0 +1,63 @@ +def key_print(data, key="", start=True): + result = "" + if isinstance(data, list): + for i, value in enumerate(data): + result += key_print(value, key=f"{key}.{i}", start=False) + elif isinstance(data, dict): + for k, value in sorted(data.items()): + result += key_print(value, key=k if start else f"{key}.{k}", start=False) + else: + result += f"{key}: {data}\n" + return result + + +# yaml.dump() would be done the same, but needs an additional dependency... +def pretty_print(data, key="", intend=0, is_list=False, whitespace=" "): + result = "" + if isinstance(data, list): + if key: + result += f"{whitespace * intend}{'- ' if is_list else ''}{key}:\n" + intend += 1 + for i, value in enumerate(data): + result += pretty_print( + value, intend=intend, is_list=True, whitespace=whitespace + ) + elif isinstance(data, dict): + if key: + result += f"{whitespace * intend}{'- ' if is_list else ''}{key}:\n" + intend += 1 + for i, (key, value) in enumerate(sorted(data.items())): + if is_list and not i: + result += pretty_print( + value, key=key, intend=intend, is_list=True, whitespace=whitespace + ) + elif is_list: + result += pretty_print( + value, key=key, intend=intend + 1, whitespace=whitespace + ) + else: + result += pretty_print( + value, key=key, intend=intend, whitespace=whitespace + ) + else: + result += f"{whitespace * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}\n" + return result + + +def create_command(commands, concat=False): + result = {} + for name, command in commands.items(): + if not concat: + result[name] = {} + for parameter, data in command.parameters.items(): + if data.typology == "enum": + value = data.values + elif data.typology == "range": + value = {"min": data.min, "max": data.max, "step": data.step} + else: + continue + if not concat: + result[name][parameter] = value + else: + result[f"{name}.{parameter}"] = value + return result diff --git a/setup.py b/setup.py index 2ef3b45..71fb903 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.6.4", + version="0.7.0", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 6b2c60d5524903f6ddbc72dd71191b805c4b4f6f Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Wed, 12 Apr 2023 01:07:03 +0200 Subject: [PATCH 15/35] Fix session issues --- pyhon/connection/auth.py | 4 +++- pyhon/connection/handler.py | 6 ++++-- setup.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 6f587a2..5ba1cdd 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -86,7 +86,9 @@ class HonAuth: ): await self._error_logger(redirect2) return False - async with self._session.get(URL(url, encoded=True)) as login_screen: + async with self._session.get( + URL(url, encoded=True), headers={"user-agent": const.USER_AGENT} + ) as login_screen: self._called_urls.append( (login_screen.status, login_screen.request_info.url) ) diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index dc18082..d3c6e63 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -17,13 +17,13 @@ class HonBaseConnectionHandler: self._auth = None async def __aenter__(self): + self._session = aiohttp.ClientSession() return await self.create() async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() async def create(self): - self._session = aiohttp.ClientSession(headers=self._HEADERS) return self @asynccontextmanager @@ -75,7 +75,7 @@ class HonConnectionHandler(HonBaseConnectionHandler): self._request_headers["id-token"] = self._auth.id_token else: raise HonAuthenticationError("Can't login") - return headers | self._request_headers + return self._HEADERS | headers | self._request_headers @asynccontextmanager async def _intercept(self, method, *args, loop=0, **kwargs): @@ -95,6 +95,8 @@ class HonConnectionHandler(HonBaseConnectionHandler): response.status, await response.text(), ) + self._request_headers = {} + self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2]) await self.create() async with self._intercept( method, *args, loop=loop + 1, **kwargs diff --git a/setup.py b/setup.py index 71fb903..3784e51 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.7.0", + version="0.7.1", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 33454f68b8df14aceb88e89672f04a726725215b Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Wed, 12 Apr 2023 02:09:41 +0200 Subject: [PATCH 16/35] Encode username/password --- pyhon/connection/auth.py | 5 +++-- pyhon/connection/handler.py | 7 +++++-- setup.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 5ba1cdd..603eeb7 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -5,6 +5,7 @@ import secrets import urllib from pprint import pformat from urllib import parse +from urllib.parse import quote from yarl import URL @@ -113,8 +114,8 @@ class HonAuth: "descriptor": "apex://LightningLoginCustomController/ACTION$login", "callingDescriptor": "markup://c:loginForm", "params": { - "username": self._email, - "password": self._password, + "username": quote(self._email), + "password": quote(self._password), "startUrl": parse.unquote( login_url.split("startURL=")[-1] ).split("%3D")[0], diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index d3c6e63..0d819e5 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -13,17 +13,19 @@ class HonBaseConnectionHandler: _HEADERS = {"user-agent": const.USER_AGENT, "Content-Type": "application/json"} def __init__(self, session=None): + self._create_session = session is None self._session = session self._auth = None async def __aenter__(self): - self._session = aiohttp.ClientSession() return await self.create() async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() async def create(self): + if self._create_session: + self._session = aiohttp.ClientSession() return self @asynccontextmanager @@ -41,7 +43,8 @@ class HonBaseConnectionHandler: yield response async def close(self): - await self._session.close() + if self._create_session: + await self._session.close() class HonConnectionHandler(HonBaseConnectionHandler): diff --git a/setup.py b/setup.py index 3784e51..d4711aa 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.7.1", + version="0.7.2", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 970b94bfa79760695fb8848f51d8ee345c7d4e57 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Wed, 12 Apr 2023 19:14:14 +0200 Subject: [PATCH 17/35] Fix unclear session errors --- pyhon/connection/auth.py | 59 +++++++++++++++++++++++-------------- pyhon/connection/handler.py | 18 ++++------- pyhon/exceptions.py | 4 +++ setup.py | 2 +- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 603eeb7..c488fc7 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -9,8 +9,7 @@ from urllib.parse import quote from yarl import URL -from pyhon import const -from pyhon.exceptions import HonAuthenticationError +from pyhon import const, exceptions _LOGGER = logging.getLogger(__name__) @@ -51,7 +50,7 @@ class HonAuth: result += f"{15 * '='} Response {15 * '='}\n{await response.text()}\n{40 * '='}" _LOGGER.error(result) if fail: - raise HonAuthenticationError("Can't login") + raise exceptions.HonAuthenticationError("Can't login") async def _load_login(self): nonce = secrets.token_hex(16) @@ -71,7 +70,11 @@ class HonAuth: f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" ) as response: self._called_urls.append((response.status, response.request_info.url)) - if not (login_url := re.findall("url = '(.+?)'", await response.text())): + text = await response.text() + if not (login_url := re.findall("url = '(.+?)'", text)): + if "oauth/done#access_token=" in text: + self._parse_token_data(text) + raise exceptions.HonNoAuthenticationNeeded() await self._error_logger(response) return False async with self._session.get(login_url[0], allow_redirects=False) as redirect1: @@ -156,6 +159,14 @@ class HonAuth: await self._error_logger(response) return "" + def _parse_token_data(self, text): + if access_token := re.findall("access_token=(.*?)&", text): + self._access_token = access_token[0] + if refresh_token := re.findall("refresh_token=(.*?)&", text): + self._refresh_token = refresh_token[0] + if id_token := re.findall("id_token=(.*?)&", text): + self._id_token = id_token[0] + async def _get_token(self, url): async with self._session.get(url) as response: self._called_urls.append((response.status, response.request_info.url)) @@ -179,26 +190,9 @@ class HonAuth: if response.status != 200: await self._error_logger(response) return False - text = await response.text() - if access_token := re.findall("access_token=(.*?)&", text): - self._access_token = access_token[0] - if refresh_token := re.findall("refresh_token=(.*?)&", text): - self._refresh_token = refresh_token[0] - if id_token := re.findall("id_token=(.*?)&", text): - self._id_token = id_token[0] + self._parse_token_data(await response.text()) return True - async def authorize(self): - if login_site := await self._load_login(): - fw_uid, loaded, login_url = login_site - else: - return False - if not (url := await self._login(fw_uid, loaded, login_url)): - return False - if not await self._get_token(url): - return False - return await self._api_auth() - async def _api_auth(self): post_headers = {"id-token": self._id_token} data = self._device.get() @@ -214,6 +208,20 @@ class HonAuth: self._cognito_token = json_data["cognitoUser"]["Token"] return True + async def authenticate(self): + self.clear() + try: + if not (login_site := await self._load_login()): + raise exceptions.HonAuthenticationError("Can't open login page") + if not (url := await self._login(*login_site)): + raise exceptions.HonAuthenticationError("Can't login") + if not await self._get_token(url): + raise exceptions.HonAuthenticationError("Can't get token") + if not await self._api_auth(): + raise exceptions.HonAuthenticationError("Can't get api token") + except exceptions.HonNoAuthenticationNeeded: + return + async def refresh(self): params = { "client_id": const.CLIENT_ID, @@ -231,3 +239,10 @@ class HonAuth: self._id_token = data["id_token"] self._access_token = data["access_token"] return await self._api_auth() + + def clear(self): + self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2]) + self._cognito_token = "" + self._id_token = "" + self._access_token = "" + self._refresh_token = "" diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index 0d819e5..53e45a5 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -57,7 +57,6 @@ class HonConnectionHandler(HonBaseConnectionHandler): raise HonAuthenticationError("An email address must be specified") if not self._password: raise HonAuthenticationError("A password address must be specified") - self._request_headers = {} @property def device(self): @@ -69,16 +68,11 @@ class HonConnectionHandler(HonBaseConnectionHandler): return self async def _check_headers(self, headers): - if ( - "cognito-token" not in self._request_headers - or "id-token" not in self._request_headers - ): - if await self._auth.authorize(): - self._request_headers["cognito-token"] = self._auth.cognito_token - self._request_headers["id-token"] = self._auth.id_token - else: - raise HonAuthenticationError("Can't login") - return self._HEADERS | headers | self._request_headers + if not (self._auth.cognito_token and self._auth.id_token): + await self._auth.authenticate() + headers["cognito-token"] = self._auth.cognito_token + headers["id-token"] = self._auth.id_token + return self._HEADERS | headers @asynccontextmanager async def _intercept(self, method, *args, loop=0, **kwargs): @@ -98,8 +92,6 @@ class HonConnectionHandler(HonBaseConnectionHandler): response.status, await response.text(), ) - self._request_headers = {} - self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2]) await self.create() async with self._intercept( method, *args, loop=loop + 1, **kwargs diff --git a/pyhon/exceptions.py b/pyhon/exceptions.py index 9b6b7fa..2e6a332 100644 --- a/pyhon/exceptions.py +++ b/pyhon/exceptions.py @@ -1,2 +1,6 @@ class HonAuthenticationError(Exception): pass + + +class HonNoAuthenticationNeeded(Exception): + pass diff --git a/setup.py b/setup.py index d4711aa..b632176 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.7.2", + version="0.7.3", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From e82c14ec993361b5ee8511e30e44d71320d5c8c3 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Thu, 13 Apr 2023 23:25:49 +0200 Subject: [PATCH 18/35] Add some type hints --- pyhon/connection/api.py | 135 +++++++++++++++++++++++------------- pyhon/connection/auth.py | 3 +- pyhon/connection/device.py | 25 +++---- pyhon/connection/handler.py | 76 +++++++++++++------- pyhon/exceptions.py | 8 +++ pyhon/hon.py | 27 +++++--- 6 files changed, 177 insertions(+), 97 deletions(-) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index dfa2b65..7704a22 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -1,46 +1,75 @@ import json import logging from datetime import datetime +from typing import Dict, Optional +from typing_extensions import Self -from pyhon import const +from aiohttp import ClientSession + +from pyhon import const, exceptions from pyhon.appliance import HonAppliance +from pyhon.connection.auth import HonAuth from pyhon.connection.handler import HonConnectionHandler, HonAnonymousConnectionHandler _LOGGER = logging.getLogger() class HonAPI: - def __init__(self, email="", password="", anonymous=False, session=None) -> None: + def __init__( + self, + email: str = "", + password: str = "", + anonymous: bool = False, + session: Optional[ClientSession] = None, + ) -> None: super().__init__() - self._email = email - self._password = password - self._anonymous = anonymous - self._hon = None - self._hon_anonymous = None - self._session = session + self._email: str = email + self._password: str = password + self._anonymous: bool = anonymous + self._hon_handler: Optional[HonConnectionHandler] = None + self._hon_anonymous_handler: Optional[HonAnonymousConnectionHandler] = None + self._session: Optional[ClientSession] = session - async def __aenter__(self): + async def __aenter__(self) -> Self: return await self.create() - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: await self.close() - async def create(self): - self._hon_anonymous = await HonAnonymousConnectionHandler( + @property + def auth(self) -> HonAuth: + if self._hon is None or self._hon.auth is None: + raise exceptions.NoAuthenticationException + return self._hon.auth + + @property + def _hon(self): + if self._hon_handler is None: + raise exceptions.NoAuthenticationException + return self._hon_handler + + @property + def _hon_anonymous(self): + if self._hon_anonymous_handler is None: + raise exceptions.NoAuthenticationException + return self._hon_anonymous_handler + + async def create(self) -> Self: + self._hon_anonymous_handler = await HonAnonymousConnectionHandler( self._session ).create() if not self._anonymous: - self._hon = await HonConnectionHandler( + self._hon_handler = await HonConnectionHandler( self._email, self._password, self._session ).create() return self - async def load_appliances(self): + async def load_appliances(self) -> Dict: async with self._hon.get(f"{const.API_URL}/commands/v1/appliance") as resp: return await resp.json() - async def load_commands(self, appliance: HonAppliance): - params = { + async def load_commands(self, appliance: HonAppliance) -> Dict: + params: Dict = { "applianceType": appliance.appliance_type, "code": appliance.info["code"], "applianceModelId": appliance.appliance_model_id, @@ -51,52 +80,60 @@ class HonAPI: "appVersion": const.APP_VERSION, "series": appliance.info["series"], } - url = f"{const.API_URL}/commands/v1/retrieve" + url: str = f"{const.API_URL}/commands/v1/retrieve" async with self._hon.get(url, params=params) as response: - result = (await response.json()).get("payload", {}) + result: Dict = (await response.json()).get("payload", {}) if not result or result.pop("resultCode") != "0": return {} return result - async def command_history(self, appliance: HonAppliance): - url = f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/history" + async def command_history(self, appliance: HonAppliance) -> Dict: + url: str = ( + f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/history" + ) async with self._hon.get(url) as response: - result = await response.json() + result: Dict = await response.json() if not result or not result.get("payload"): return {} return result["payload"]["history"] - async def last_activity(self, appliance: HonAppliance): - url = f"{const.API_URL}/commands/v1/retrieve-last-activity" - params = {"macAddress": appliance.mac_address} + async def last_activity(self, appliance: HonAppliance) -> Dict: + url: str = f"{const.API_URL}/commands/v1/retrieve-last-activity" + params: Dict = {"macAddress": appliance.mac_address} async with self._hon.get(url, params=params) as response: - result = await response.json() + result: Dict = await response.json() if result and (activity := result.get("attributes")): return activity return {} - async def load_attributes(self, appliance: HonAppliance): - params = { + async def load_attributes(self, appliance: HonAppliance) -> Dict: + params: Dict = { "macAddress": appliance.mac_address, "applianceType": appliance.appliance_type, "category": "CYCLE", } - url = f"{const.API_URL}/commands/v1/context" + url: str = f"{const.API_URL}/commands/v1/context" async with self._hon.get(url, params=params) as response: return (await response.json()).get("payload", {}) - async def load_statistics(self, appliance: HonAppliance): - params = { + async def load_statistics(self, appliance: HonAppliance) -> Dict: + params: Dict = { "macAddress": appliance.mac_address, "applianceType": appliance.appliance_type, } - url = f"{const.API_URL}/commands/v1/statistics" + url: str = f"{const.API_URL}/commands/v1/statistics" async with self._hon.get(url, params=params) as response: return (await response.json()).get("payload", {}) - async def send_command(self, appliance, command, parameters, ancillary_parameters): - now = datetime.utcnow().isoformat() - data = { + async def send_command( + self, + appliance: HonAppliance, + command: str, + parameters: Dict, + ancillary_parameters: Dict, + ) -> bool: + now: str = datetime.utcnow().isoformat() + data: Dict = { "macAddress": appliance.mac_address, "timestamp": f"{now[:-3]}Z", "commandName": command, @@ -112,36 +149,36 @@ class HonAPI: "parameters": parameters, "applianceType": appliance.appliance_type, } - url = f"{const.API_URL}/commands/v1/send" + url: str = f"{const.API_URL}/commands/v1/send" async with self._hon.post(url, json=data) as resp: - json_data = await resp.json() + json_data: Dict = await resp.json() if json_data.get("payload", {}).get("resultCode") == "0": return True return False - async def appliance_configuration(self): - url = f"{const.API_URL}/config/v1/appliance-configuration" + async def appliance_configuration(self) -> Dict: + url: str = f"{const.API_URL}/config/v1/appliance-configuration" async with self._hon_anonymous.get(url) as response: - result = await response.json() + result: Dict = await response.json() if result and (data := result.get("payload")): return data return {} - async def app_config(self, language="en", beta=True): - url = f"{const.API_URL}/app-config" - payload = { + async def app_config(self, language: str = "en", beta: bool = True) -> Dict: + url: str = f"{const.API_URL}/app-config" + payload_data: Dict = { "languageCode": language, "beta": beta, "appVersion": const.APP_VERSION, "os": const.OS, } - payload = json.dumps(payload, separators=(",", ":")) + payload: str = json.dumps(payload_data, separators=(",", ":")) async with self._hon_anonymous.post(url, data=payload) as response: if (result := await response.json()) and (data := result.get("payload")): return data return {} - async def translation_keys(self, language="en"): + async def translation_keys(self, language: str = "en") -> Dict: config = await self.app_config(language=language) if url := config.get("language", {}).get("jsonPath"): async with self._hon_anonymous.get(url) as response: @@ -149,8 +186,8 @@ class HonAPI: return result return {} - async def close(self): - if self._hon: - await self._hon.close() - if self._hon_anonymous: - await self._hon_anonymous.close() + async def close(self) -> None: + if self._hon_handler is not None: + await self._hon_handler.close() + if self._hon_anonymous_handler is not None: + await self._hon_anonymous_handler.close() diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index c488fc7..1cc0217 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -4,6 +4,7 @@ import re import secrets import urllib from pprint import pformat +from typing import List, Tuple from urllib import parse from urllib.parse import quote @@ -24,7 +25,7 @@ class HonAuth: self._cognito_token = "" self._id_token = "" self._device = device - self._called_urls = [] + self._called_urls: List[Tuple[int, str]] = [] @property def cognito_token(self): diff --git a/pyhon/connection/device.py b/pyhon/connection/device.py index f7399d9..39e529c 100644 --- a/pyhon/connection/device.py +++ b/pyhon/connection/device.py @@ -1,37 +1,38 @@ import secrets +from typing import Dict from pyhon import const class HonDevice: - def __init__(self): - self._app_version = const.APP_VERSION - self._os_version = const.OS_VERSION - self._os = const.OS - self._device_model = const.DEVICE_MODEL - self._mobile_id = secrets.token_hex(8) + def __init__(self) -> None: + self._app_version: str = const.APP_VERSION + self._os_version: int = const.OS_VERSION + self._os: str = const.OS + self._device_model: str = const.DEVICE_MODEL + self._mobile_id: str = secrets.token_hex(8) @property - def app_version(self): + def app_version(self) -> str: return self._app_version @property - def os_version(self): + def os_version(self) -> int: return self._os_version @property - def os(self): + def os(self) -> str: return self._os @property - def device_model(self): + def device_model(self) -> str: return self._device_model @property - def mobile_id(self): + def mobile_id(self) -> str: return self._mobile_id - def get(self): + def get(self) -> Dict: return { "appVersion": self.app_version, "mobileId": self.mobile_id, diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index 53e45a5..935c614 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -1,73 +1,93 @@ import json +from collections.abc import Generator, AsyncIterator, Coroutine from contextlib import asynccontextmanager +from typing import Optional, Callable, Dict +from typing_extensions import Self import aiohttp -from pyhon import const +from pyhon import const, exceptions from pyhon.connection.auth import HonAuth, _LOGGER from pyhon.connection.device import HonDevice from pyhon.exceptions import HonAuthenticationError class HonBaseConnectionHandler: - _HEADERS = {"user-agent": const.USER_AGENT, "Content-Type": "application/json"} + _HEADERS: Dict = { + "user-agent": const.USER_AGENT, + "Content-Type": "application/json", + } - def __init__(self, session=None): - self._create_session = session is None - self._session = session - self._auth = None + def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None: + self._create_session: bool = session is None + self._session: Optional[aiohttp.ClientSession] = session + self._auth: Optional[HonAuth] = None - async def __aenter__(self): + async def __aenter__(self) -> Self: return await self.create() - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: await self.close() - async def create(self): + @property + def auth(self) -> Optional[HonAuth]: + return self._auth + + async def create(self) -> Self: if self._create_session: self._session = aiohttp.ClientSession() return self @asynccontextmanager - async def _intercept(self, method, *args, loop=0, **kwargs): + def _intercept(self, method: Callable, *args, loop: int = 0, **kwargs): raise NotImplementedError @asynccontextmanager - async def get(self, *args, **kwargs): + async def get(self, *args, **kwargs) -> AsyncIterator[Callable]: + if self._session is None: + raise exceptions.NoSessionException() + response: Callable async with self._intercept(self._session.get, *args, **kwargs) as response: yield response @asynccontextmanager - async def post(self, *args, **kwargs): + async def post(self, *args, **kwargs) -> AsyncIterator[Callable]: + if self._session is None: + raise exceptions.NoSessionException() + response: Callable async with self._intercept(self._session.post, *args, **kwargs) as response: yield response - async def close(self): - if self._create_session: + async def close(self) -> None: + if self._create_session and self._session is not None: await self._session.close() class HonConnectionHandler(HonBaseConnectionHandler): - def __init__(self, email, password, session=None): + def __init__( + self, email: str, password: str, session: Optional[aiohttp.ClientSession] = None + ) -> None: super().__init__(session=session) - self._device = HonDevice() - self._email = email - self._password = password + self._device: HonDevice = HonDevice() + self._email: str = email + self._password: str = password if not self._email: raise HonAuthenticationError("An email address must be specified") if not self._password: raise HonAuthenticationError("A password address must be specified") @property - def device(self): + def device(self) -> HonDevice: return self._device - async def create(self): + async def create(self) -> Self: await super().create() - self._auth = HonAuth(self._session, self._email, self._password, self._device) + self._auth: HonAuth = HonAuth( + self._session, self._email, self._password, self._device + ) return self - async def _check_headers(self, headers): + async def _check_headers(self, headers: Dict) -> Dict: if not (self._auth.cognito_token and self._auth.id_token): await self._auth.authenticate() headers["cognito-token"] = self._auth.cognito_token @@ -75,7 +95,9 @@ class HonConnectionHandler(HonBaseConnectionHandler): return self._HEADERS | headers @asynccontextmanager - async def _intercept(self, method, *args, loop=0, **kwargs): + async def _intercept( + self, method: Callable, *args, loop: int = 0, **kwargs + ) -> AsyncIterator: kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) async with method(*args, **kwargs) as response: if response.status in [401, 403] and loop == 0: @@ -116,14 +138,16 @@ class HonConnectionHandler(HonBaseConnectionHandler): response.status, await response.text(), ) - yield {} + raise HonAuthenticationError("Decode Error") class HonAnonymousConnectionHandler(HonBaseConnectionHandler): - _HEADERS = HonBaseConnectionHandler._HEADERS | {"x-api-key": const.API_KEY} + _HEADERS: Dict = HonBaseConnectionHandler._HEADERS | {"x-api-key": const.API_KEY} @asynccontextmanager - async def _intercept(self, method, *args, loop=0, **kwargs): + async def _intercept( + self, method: Callable, *args, loop: int = 0, **kwargs + ) -> AsyncIterator: kwargs["headers"] = kwargs.pop("headers", {}) | self._HEADERS async with method(*args, **kwargs) as response: if response.status == 403: diff --git a/pyhon/exceptions.py b/pyhon/exceptions.py index 2e6a332..64e8648 100644 --- a/pyhon/exceptions.py +++ b/pyhon/exceptions.py @@ -4,3 +4,11 @@ class HonAuthenticationError(Exception): class HonNoAuthenticationNeeded(Exception): pass + + +class NoSessionException(Exception): + pass + + +class NoAuthenticationException(Exception): + pass diff --git a/pyhon/hon.py b/pyhon/hon.py index 322b077..64ac364 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -1,17 +1,20 @@ import asyncio -from typing import List +from typing import List, Optional +from typing_extensions import Self -from pyhon import HonAPI +from aiohttp import ClientSession + +from pyhon import HonAPI, exceptions from pyhon.appliance import HonAppliance class Hon: - def __init__(self, email, password, session=None): - self._email = email - self._password = password - self._session = session - self._appliances = [] - self._api = None + def __init__(self, email: str, password: str, session: ClientSession | None = None): + self._email: str = email + self._password: str = password + self._session: ClientSession | None = session + self._appliances: List[HonAppliance] = [] + self._api: Optional[HonAPI] = None async def __aenter__(self): return await self.create() @@ -19,7 +22,13 @@ class Hon: async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() - async def create(self): + @property + def api(self) -> HonAPI: + if self._api is None: + raise exceptions.NoAuthenticationException + return self._api + + async def create(self) -> Self: self._api = await HonAPI( self._email, self._password, session=self._session ).create() From 272556586e13037075979b318097b98e0b1d6dd7 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Fri, 14 Apr 2023 23:15:07 +0200 Subject: [PATCH 19/35] Refresh token workaround because expires to fast --- pyhon/connection/auth.py | 19 +++++++++++++++++++ pyhon/connection/handler.py | 8 ++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 1cc0217..96bfd83 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -3,6 +3,7 @@ import logging import re import secrets import urllib +from datetime import datetime, timedelta from pprint import pformat from typing import List, Tuple from urllib import parse @@ -16,6 +17,9 @@ _LOGGER = logging.getLogger(__name__) class HonAuth: + _TOKEN_EXPIRES_AFTER_HOURS = 8 + _TOKEN_EXPIRE_WARNING_HOURS = 7 + def __init__(self, session, email, password, device) -> None: self._session = session self._email = email @@ -26,6 +30,7 @@ class HonAuth: self._id_token = "" self._device = device self._called_urls: List[Tuple[int, str]] = [] + self._expires: datetime = datetime.utcnow() @property def cognito_token(self): @@ -43,6 +48,17 @@ class HonAuth: def refresh_token(self): return self._refresh_token + def _check_token_expiration(self, hours): + return datetime.utcnow() >= self._expires + timedelta(hours=hours) + + @property + def token_is_expired(self) -> bool: + return self._check_token_expiration(self._TOKEN_EXPIRES_AFTER_HOURS) + + @property + def token_expires_soon(self) -> bool: + return self._check_token_expiration(self._TOKEN_EXPIRE_WARNING_HOURS) + async def _error_logger(self, response, fail=True): result = "hOn Authentication Error\n" for i, (status, url) in enumerate(self._called_urls): @@ -72,6 +88,7 @@ class HonAuth: ) as response: self._called_urls.append((response.status, response.request_info.url)) text = await response.text() + self._expires = datetime.utcnow() if not (login_url := re.findall("url = '(.+?)'", text)): if "oauth/done#access_token=" in text: self._parse_token_data(text) @@ -237,12 +254,14 @@ class HonAuth: await self._error_logger(response, fail=False) return False data = await response.json() + self._expires = datetime.utcnow() self._id_token = data["id_token"] self._access_token = data["access_token"] return await self._api_auth() def clear(self): self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2]) + self._called_urls = [] self._cognito_token = "" self._id_token = "" self._access_token = "" diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler.py index 935c614..d12b559 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler.py @@ -100,14 +100,18 @@ class HonConnectionHandler(HonBaseConnectionHandler): ) -> AsyncIterator: kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) async with method(*args, **kwargs) as response: - if response.status in [401, 403] and loop == 0: + if ( + self._auth.token_expires_soon or response.status in [401, 403] + ) and loop == 0: _LOGGER.info("Try refreshing token...") await self._auth.refresh() async with self._intercept( method, *args, loop=loop + 1, **kwargs ) as result: yield result - elif response.status in [401, 403] and loop == 1: + elif ( + self._auth.token_is_expired or response.status in [401, 403] + ) and loop == 1: _LOGGER.warning( "%s - Error %s - %s", response.request_info.url, From 0301427497e2f66f810dfed17397c94686c3e0a9 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Fri, 14 Apr 2023 23:24:31 +0200 Subject: [PATCH 20/35] Remove coords from diagnose --- pyhon/appliance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 21d39c6..d37b294 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -177,7 +177,7 @@ class HonAppliance: @property def diagnose(self): data = self.data.copy() - for sensible in ["PK", "SK", "serialNumber", "code"]: + for sensible in ["PK", "SK", "serialNumber", "code", "coords"]: data["appliance"].pop(sensible, None) result = helper.pretty_print({"data": self.data}, whitespace="\u200B \u200B ") result += helper.pretty_print( From d26e33a055c6cc7fca6ccd2b91270d243f1a0e66 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 00:29:24 +0200 Subject: [PATCH 21/35] Fix error in starting programs --- pyhon/connection/api.py | 7 ++++--- pyhon/connection/device.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index 7704a22..a9fab02 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -139,7 +139,7 @@ class HonAPI: "commandName": command, "transactionId": f"{appliance.mac_address}_{now[:-3]}Z", "applianceOptions": appliance.commands_options, - "appliance": self._hon.device.get(), + "device": self._hon.device.get(mobile=True), "attributes": { "channel": "mobileApp", "origin": "standardProgram", @@ -150,10 +150,11 @@ class HonAPI: "applianceType": appliance.appliance_type, } url: str = f"{const.API_URL}/commands/v1/send" - async with self._hon.post(url, json=data) as resp: - json_data: Dict = await resp.json() + async with self._hon.post(url, json=data) as response: + json_data: Dict = await response.json() if json_data.get("payload", {}).get("resultCode") == "0": return True + _LOGGER.error(await response.text()) return False async def appliance_configuration(self) -> Dict: diff --git a/pyhon/connection/device.py b/pyhon/connection/device.py index 39e529c..ea7f30e 100644 --- a/pyhon/connection/device.py +++ b/pyhon/connection/device.py @@ -32,11 +32,12 @@ class HonDevice: def mobile_id(self) -> str: return self._mobile_id - def get(self) -> Dict: - return { + def get(self, mobile: bool = False) -> Dict: + result = { "appVersion": self.app_version, "mobileId": self.mobile_id, - "osVersion": self.os_version, "os": self.os, + "osVersion": self.os_version, "deviceModel": self.device_model, } + return (result | {"mobileOs": result.pop("os")}) if mobile else result From 9643f66549fe8ac77a047b095f0415687593cff0 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 00:31:45 +0200 Subject: [PATCH 22/35] Bump version to v0.7.4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b632176..76398a1 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.7.3", + version="0.7.4", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From d52d622785b56f3f0249491bacb50ebe5b771352 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 04:12:38 +0200 Subject: [PATCH 23/35] Add zone support --- README.md | 2 -- pyhon/__main__.py | 0 pyhon/appliance.py | 41 +++++++++++++++++++++++++---------------- pyhon/commands.py | 2 ++ pyhon/hon.py | 29 +++++++++++++++++------------ setup.py | 2 +- 6 files changed, 45 insertions(+), 31 deletions(-) mode change 100644 => 100755 pyhon/__main__.py diff --git a/README.md b/README.md index b28df60..cba6f50 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,6 @@ This generates a huge output. It is recommended to pipe this into a file $ pyhOn translate fr > hon_fr.yaml $ pyhOn translate en --json > hon_en.json ``` -## Tested devices -- Haier Washing Machine HW90 ## Usage example This library is used for the custom [HomeAssistant Integration "Haier hOn"](https://github.com/Andre0512/hOn). diff --git a/pyhon/__main__.py b/pyhon/__main__.py old mode 100644 new mode 100755 diff --git a/pyhon/appliance.py b/pyhon/appliance.py index d37b294..693e9c7 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -1,5 +1,6 @@ import importlib from contextlib import suppress +from typing import Optional, Dict from pyhon import helper from pyhon.commands import HonCommand @@ -7,7 +8,7 @@ from pyhon.parameter import HonParameterFixed class HonAppliance: - def __init__(self, api, info): + def __init__(self, api, info: Dict, zone: int = 0) -> None: if attributes := info.get("attributes"): info["attributes"] = {v["parName"]: v["parValue"] for v in attributes} self._info = info @@ -17,6 +18,7 @@ class HonAppliance: self._commands = {} self._statistics = {} self._attributes = {} + self._zone = zone try: self._extra = importlib.import_module( @@ -26,20 +28,21 @@ class HonAppliance: self._extra = None def __getitem__(self, item): + if self._zone: + item += f"Z{self._zone}" if "." in item: result = self.data for key in item.split("."): - if all([k in "0123456789" for k in key]) and type(result) is list: + if all(k in "0123456789" for k in key) and isinstance(result, list): result = result[int(key)] else: result = result[key] return result - else: - if item in self.data: - return self.data[item] - if item in self.attributes["parameters"]: - return self.attributes["parameters"].get(item) - return self.info[item] + if item in self.data: + return self.data[item] + if item in self.attributes["parameters"]: + return self.attributes["parameters"].get(item) + return self.info[item] def get(self, item, default=None): try: @@ -47,25 +50,31 @@ class HonAppliance: except (KeyError, IndexError): return default + def _check_name_zone(self, name: str, frontend: bool = True) -> str: + middle = " Z" if frontend else "_z" + if (attribute := self._info.get(name, "")) and self._zone: + return f"{attribute}{middle}{self._zone}" + return attribute + @property - def appliance_model_id(self): + def appliance_model_id(self) -> str: return self._info.get("applianceModelId") @property - def appliance_type(self): + def appliance_type(self) -> str: return self._info.get("applianceTypeName") @property - def mac_address(self): - return self._info.get("macAddress") + def mac_address(self) -> str: + return self._check_name_zone("macAddress", frontend=False) @property - def model_name(self): - return self._info.get("modelName") + def model_name(self) -> str: + return self._check_name_zone("modelName") @property - def nick_name(self): - return self._info.get("nickName") + def nick_name(self) -> str: + return self._check_name_zone("nickName") @property def commands_options(self): diff --git a/pyhon/commands.py b/pyhon/commands.py index b12493a..b6a5427 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -25,6 +25,8 @@ class HonCommand: def _create_parameters(self, parameters): result = {} for parameter, attributes in parameters.items(): + if parameter == "zoneMap" and self._device.zone: + attributes["default"] = self._device.zone match attributes.get("typology"): case "range": result[parameter] = HonParameterRange(parameter, attributes) diff --git a/pyhon/hon.py b/pyhon/hon.py index 64ac364..327f85e 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -1,5 +1,5 @@ import asyncio -from typing import List, Optional +from typing import List, Optional, Dict from typing_extensions import Self from aiohttp import ClientSession @@ -39,19 +39,24 @@ class Hon: def appliances(self) -> List[HonAppliance]: return self._appliances + async def _create_appliance(self, appliance: Dict, zone=0) -> None: + appliance = HonAppliance(self._api, appliance, zone=zone) + if appliance.mac_address is None: + return + await asyncio.gather( + *[ + appliance.load_attributes(), + appliance.load_commands(), + appliance.load_statistics(), + ] + ) + self._appliances.append(appliance) + async def setup(self): for appliance in (await self._api.load_appliances())["payload"]["appliances"]: - appliance = HonAppliance(self._api, appliance) - if appliance.mac_address is None: - continue - await asyncio.gather( - *[ - appliance.load_attributes(), - appliance.load_commands(), - appliance.load_statistics(), - ] - ) - self._appliances.append(appliance) + for zone in range(int(appliance.get("zone", "0"))): + await self._create_appliance(appliance, zone=zone + 1) + await self._create_appliance(appliance) async def close(self): await self._api.close() diff --git a/setup.py b/setup.py index 76398a1..80cad43 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.7.4", + version="0.8.0b2", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 4a0ee8569b066fafceaa403169266f9e276a2d4e Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 14:22:04 +0200 Subject: [PATCH 24/35] Refactor, add hon auth handler --- pyhon/connection/api.py | 3 +- pyhon/connection/auth.py | 124 ++++++++---------- pyhon/connection/handler/__init__.py | 0 pyhon/connection/handler/anonym.py | 21 +++ pyhon/connection/handler/auth.py | 36 +++++ pyhon/connection/handler/base.py | 57 ++++++++ .../connection/{handler.py => handler/hon.py} | 81 ++---------- 7 files changed, 186 insertions(+), 136 deletions(-) create mode 100644 pyhon/connection/handler/__init__.py create mode 100644 pyhon/connection/handler/anonym.py create mode 100644 pyhon/connection/handler/auth.py create mode 100644 pyhon/connection/handler/base.py rename pyhon/connection/{handler.py => handler/hon.py} (60%) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index a9fab02..3af519a 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -9,7 +9,8 @@ from aiohttp import ClientSession from pyhon import const, exceptions from pyhon.appliance import HonAppliance from pyhon.connection.auth import HonAuth -from pyhon.connection.handler import HonConnectionHandler, HonAnonymousConnectionHandler +from pyhon.connection.handler.hon import HonConnectionHandler +from pyhon.connection.handler.anonym import HonAnonymousConnectionHandler _LOGGER = logging.getLogger() diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 96bfd83..715d7ef 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -5,13 +5,14 @@ import secrets import urllib from datetime import datetime, timedelta from pprint import pformat -from typing import List, Tuple from urllib import parse from urllib.parse import quote +from aiohttp import ClientResponse from yarl import URL from pyhon import const, exceptions +from pyhon.connection.handler.auth import HonAuthConnectionHandler _LOGGER = logging.getLogger(__name__) @@ -22,6 +23,7 @@ class HonAuth: def __init__(self, session, email, password, device) -> None: self._session = session + self._request = HonAuthConnectionHandler(session) self._email = email self._password = password self._access_token = "" @@ -29,26 +31,25 @@ class HonAuth: self._cognito_token = "" self._id_token = "" self._device = device - self._called_urls: List[Tuple[int, str]] = [] self._expires: datetime = datetime.utcnow() @property - def cognito_token(self): + def cognito_token(self) -> str: return self._cognito_token @property - def id_token(self): + def id_token(self) -> str: return self._id_token @property - def access_token(self): + def access_token(self) -> str: return self._access_token @property - def refresh_token(self): + def refresh_token(self) -> str: return self._refresh_token - def _check_token_expiration(self, hours): + def _check_token_expiration(self, hours: int) -> bool: return datetime.utcnow() >= self._expires + timedelta(hours=hours) @property @@ -59,34 +60,38 @@ class HonAuth: def token_expires_soon(self) -> bool: return self._check_token_expiration(self._TOKEN_EXPIRE_WARNING_HOURS) - async def _error_logger(self, response, fail=True): - result = "hOn Authentication Error\n" - for i, (status, url) in enumerate(self._called_urls): - result += f" {i + 1: 2d} {status} - {url}\n" - result += f"ERROR - {response.status} - {response.request_info.url}\n" - result += f"{15 * '='} Response {15 * '='}\n{await response.text()}\n{40 * '='}" - _LOGGER.error(result) + async def _error_logger(self, response: ClientResponse, fail: bool = True) -> None: + output = "hOn Authentication Error\n" + for i, (status, url) in enumerate(self._request.called_urls): + output += f" {i + 1: 2d} {status} - {url}\n" + output += f"ERROR - {response.status} - {response.request_info.url}\n" + output += f"{15 * '='} Response {15 * '='}\n{await response.text()}\n{40 * '='}" + _LOGGER.error(output) if fail: raise exceptions.HonAuthenticationError("Can't login") - async def _load_login(self): + def _generate_nonce(self) -> str: nonce = secrets.token_hex(16) - nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" + return f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" + + async def _load_login(self): + login_url = await self._introduce() + login_url = await self._handle_redirects(login_url) + return await self._login_url(login_url) + + async def _introduce(self) -> str: + redirect_uri = urllib.parse.quote(f"{const.APP}://mobilesdk/detect/oauth/done") params = { "response_type": "token+id_token", "client_id": const.CLIENT_ID, - "redirect_uri": urllib.parse.quote( - f"{const.APP}://mobilesdk/detect/oauth/done" - ), + "redirect_uri": redirect_uri, "display": "touch", "scope": "api openid refresh_token web", - "nonce": nonce, + "nonce": self._generate_nonce(), } params = "&".join([f"{k}={v}" for k, v in params.items()]) - async with self._session.get( - f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" - ) as response: - self._called_urls.append((response.status, response.request_info.url)) + url = f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" + async with self._request.get(url) as response: text = await response.text() self._expires = datetime.utcnow() if not (login_url := re.findall("url = '(.+?)'", text)): @@ -94,37 +99,30 @@ class HonAuth: self._parse_token_data(text) raise exceptions.HonNoAuthenticationNeeded() await self._error_logger(response) - return False - async with self._session.get(login_url[0], allow_redirects=False) as redirect1: - self._called_urls.append((redirect1.status, redirect1.request_info.url)) - if not (url := redirect1.headers.get("Location")): - await self._error_logger(redirect1) - return False - async with self._session.get(url, allow_redirects=False) as redirect2: - self._called_urls.append((redirect2.status, redirect2.request_info.url)) - if not ( - url := redirect2.headers.get("Location") - + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn" - ): - await self._error_logger(redirect2) - return False - async with self._session.get( - URL(url, encoded=True), headers={"user-agent": const.USER_AGENT} - ) as login_screen: - self._called_urls.append( - (login_screen.status, login_screen.request_info.url) - ) - if context := re.findall( - '"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text() - ): + return login_url[0] + + async def _manual_redirect(self, url: str) -> str: + async with self._request.get(url, allow_redirects=False) as response: + if not (new_location := response.headers.get("Location")): + await self._error_logger(response) + return new_location + + async def _handle_redirects(self, login_url) -> str: + redirect1 = await self._manual_redirect(login_url) + redirect2 = await self._manual_redirect(redirect1) + return f"{redirect2}&System=IoT_Mobile_App&RegistrationSubChannel=hOn" + + async def _login_url(self, login_url: str) -> str: + headers = {"user-agent": const.USER_AGENT} + url = URL(login_url, encoded=True) + async with self._request.get(url, headers=headers) as response: + text = await response.text() + if context := re.findall('"fwuid":"(.*?)","loaded":(\\{.*?})', text): fw_uid, loaded_str = context[0] loaded = json.loads(loaded_str) - login_url = login_url[0].replace( - "/".join(const.AUTH_API.split("/")[:-1]), "" - ) - return fw_uid, loaded, login_url - await self._error_logger(login_screen) - return False + result = login_url.replace("/".join(const.AUTH_API.split("/")[:-1]), "") + return fw_uid, loaded, result + await self._error_logger(response) async def _login(self, fw_uid, loaded, login_url): data = { @@ -157,13 +155,12 @@ class HonAuth: "aura.token": None, } params = {"r": 3, "other.LightningLoginCustom.login": 1} - async with self._session.post( + async with self._request.post( const.AUTH_API + "/s/sfsites/aura", headers={"Content-Type": "application/x-www-form-urlencoded"}, data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), params=params, ) as response: - self._called_urls.append((response.status, response.request_info.url)) if response.status == 200: try: data = await response.json() @@ -186,8 +183,7 @@ class HonAuth: self._id_token = id_token[0] async def _get_token(self, url): - async with self._session.get(url) as response: - self._called_urls.append((response.status, response.request_info.url)) + async with self._request.get(url) as response: if response.status != 200: await self._error_logger(response) return False @@ -196,15 +192,13 @@ class HonAuth: await self._error_logger(response) return False if "ProgressiveLogin" in url[0]: - async with self._session.get(url[0]) as response: - self._called_urls.append((response.status, response.request_info.url)) + async with self._request.get(url[0]) as response: if response.status != 200: await self._error_logger(response) return False url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await response.text()) url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] - async with self._session.get(url) as response: - self._called_urls.append((response.status, response.request_info.url)) + async with self._request.get(url) as response: if response.status != 200: await self._error_logger(response) return False @@ -214,10 +208,9 @@ class HonAuth: async def _api_auth(self): post_headers = {"id-token": self._id_token} data = self._device.get() - async with self._session.post( + async with self._request.post( f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data ) as response: - self._called_urls.append((response.status, response.request_info.url)) try: json_data = await response.json() except json.JSONDecodeError: @@ -246,10 +239,9 @@ class HonAuth: "refresh_token": self._refresh_token, "grant_type": "refresh_token", } - async with self._session.post( + async with self._request.post( f"{const.AUTH_API}/services/oauth2/token", params=params ) as response: - self._called_urls.append((response.status, response.request_info.url)) if response.status >= 400: await self._error_logger(response, fail=False) return False @@ -261,7 +253,7 @@ class HonAuth: def clear(self): self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2]) - self._called_urls = [] + self._request.called_urls = [] self._cognito_token = "" self._id_token = "" self._access_token = "" diff --git a/pyhon/connection/handler/__init__.py b/pyhon/connection/handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyhon/connection/handler/anonym.py b/pyhon/connection/handler/anonym.py new file mode 100644 index 0000000..1ed0410 --- /dev/null +++ b/pyhon/connection/handler/anonym.py @@ -0,0 +1,21 @@ +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Callable, Dict + +from pyhon import const +from pyhon.connection.handler.base import ConnectionHandler + +_LOGGER = logging.getLogger(__name__) + + +class HonAnonymousConnectionHandler(ConnectionHandler): + _HEADERS: Dict = ConnectionHandler._HEADERS | {"x-api-key": const.API_KEY} + + @asynccontextmanager + async def _intercept(self, method: Callable, *args, **kwargs) -> AsyncIterator: + kwargs["headers"] = kwargs.pop("headers", {}) | self._HEADERS + async with method(*args, **kwargs) as response: + if response.status == 403: + _LOGGER.error("Can't authenticate anymore") + yield response diff --git a/pyhon/connection/handler/auth.py b/pyhon/connection/handler/auth.py new file mode 100644 index 0000000..ecba4cb --- /dev/null +++ b/pyhon/connection/handler/auth.py @@ -0,0 +1,36 @@ +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Optional, Callable, List, Tuple + +import aiohttp + +from pyhon import const +from pyhon.connection.handler.base import ConnectionHandler + +_LOGGER = logging.getLogger(__name__) + + +class HonAuthConnectionHandler(ConnectionHandler): + _HEADERS = {"user-agent": const.USER_AGENT} + + def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None: + super().__init__(session) + self._called_urls: List[Tuple[int, str]] = [] + + @property + def called_urls(self) -> List[Tuple[int, str]]: + return self._called_urls + + @called_urls.setter + def called_urls(self, called_urls: List[Tuple[int, str]]) -> None: + self._called_urls = called_urls + + @asynccontextmanager + async def _intercept( + self, method: Callable, *args, loop: int = 0, **kwargs + ) -> AsyncIterator: + kwargs["headers"] = kwargs.pop("headers", {}) | self._HEADERS + async with method(*args, **kwargs) as response: + self._called_urls.append((response.status, response.request_info.url)) + yield response diff --git a/pyhon/connection/handler/base.py b/pyhon/connection/handler/base.py new file mode 100644 index 0000000..7542df6 --- /dev/null +++ b/pyhon/connection/handler/base.py @@ -0,0 +1,57 @@ +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Optional, Callable, Dict + +import aiohttp +from typing_extensions import Self + +from pyhon import const, exceptions + +_LOGGER = logging.getLogger(__name__) + + +class ConnectionHandler: + _HEADERS: Dict = { + "user-agent": const.USER_AGENT, + "Content-Type": "application/json", + } + + def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None: + self._create_session: bool = session is None + self._session: Optional[aiohttp.ClientSession] = session + + async def __aenter__(self) -> Self: + return await self.create() + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.close() + + async def create(self) -> Self: + if self._create_session: + self._session = aiohttp.ClientSession() + return self + + @asynccontextmanager + def _intercept(self, method: Callable, *args, loop: int = 0, **kwargs): + raise NotImplementedError + + @asynccontextmanager + async def get(self, *args, **kwargs) -> AsyncIterator[Callable]: + if self._session is None: + raise exceptions.NoSessionException() + response: Callable + async with self._intercept(self._session.get, *args, **kwargs) as response: + yield response + + @asynccontextmanager + async def post(self, *args, **kwargs) -> AsyncIterator[Callable]: + if self._session is None: + raise exceptions.NoSessionException() + response: Callable + async with self._intercept(self._session.post, *args, **kwargs) as response: + yield response + + async def close(self) -> None: + if self._create_session and self._session is not None: + await self._session.close() diff --git a/pyhon/connection/handler.py b/pyhon/connection/handler/hon.py similarity index 60% rename from pyhon/connection/handler.py rename to pyhon/connection/handler/hon.py index d12b559..7da82a8 100644 --- a/pyhon/connection/handler.py +++ b/pyhon/connection/handler/hon.py @@ -1,69 +1,21 @@ import json -from collections.abc import Generator, AsyncIterator, Coroutine +import logging +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Optional, Callable, Dict -from typing_extensions import Self import aiohttp +from typing_extensions import Self -from pyhon import const, exceptions -from pyhon.connection.auth import HonAuth, _LOGGER +from pyhon.connection.auth import HonAuth from pyhon.connection.device import HonDevice +from pyhon.connection.handler.base import ConnectionHandler from pyhon.exceptions import HonAuthenticationError - -class HonBaseConnectionHandler: - _HEADERS: Dict = { - "user-agent": const.USER_AGENT, - "Content-Type": "application/json", - } - - def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None: - self._create_session: bool = session is None - self._session: Optional[aiohttp.ClientSession] = session - self._auth: Optional[HonAuth] = None - - async def __aenter__(self) -> Self: - return await self.create() - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - await self.close() - - @property - def auth(self) -> Optional[HonAuth]: - return self._auth - - async def create(self) -> Self: - if self._create_session: - self._session = aiohttp.ClientSession() - return self - - @asynccontextmanager - def _intercept(self, method: Callable, *args, loop: int = 0, **kwargs): - raise NotImplementedError - - @asynccontextmanager - async def get(self, *args, **kwargs) -> AsyncIterator[Callable]: - if self._session is None: - raise exceptions.NoSessionException() - response: Callable - async with self._intercept(self._session.get, *args, **kwargs) as response: - yield response - - @asynccontextmanager - async def post(self, *args, **kwargs) -> AsyncIterator[Callable]: - if self._session is None: - raise exceptions.NoSessionException() - response: Callable - async with self._intercept(self._session.post, *args, **kwargs) as response: - yield response - - async def close(self) -> None: - if self._create_session and self._session is not None: - await self._session.close() +_LOGGER = logging.getLogger(__name__) -class HonConnectionHandler(HonBaseConnectionHandler): +class HonConnectionHandler(ConnectionHandler): def __init__( self, email: str, password: str, session: Optional[aiohttp.ClientSession] = None ) -> None: @@ -75,6 +27,11 @@ class HonConnectionHandler(HonBaseConnectionHandler): raise HonAuthenticationError("An email address must be specified") if not self._password: raise HonAuthenticationError("A password address must be specified") + self._auth: Optional[HonAuth] = None + + @property + def auth(self) -> Optional[HonAuth]: + return self._auth @property def device(self) -> HonDevice: @@ -143,17 +100,3 @@ class HonConnectionHandler(HonBaseConnectionHandler): await response.text(), ) raise HonAuthenticationError("Decode Error") - - -class HonAnonymousConnectionHandler(HonBaseConnectionHandler): - _HEADERS: Dict = HonBaseConnectionHandler._HEADERS | {"x-api-key": const.API_KEY} - - @asynccontextmanager - async def _intercept( - self, method: Callable, *args, loop: int = 0, **kwargs - ) -> AsyncIterator: - kwargs["headers"] = kwargs.pop("headers", {}) | self._HEADERS - async with method(*args, **kwargs) as response: - if response.status == 403: - _LOGGER.error("Can't authenticate anymore") - yield response From b6ca12ebff90e6e2698d130d1dd353ca3278506d Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 14:37:27 +0200 Subject: [PATCH 25/35] Use dataclass for login data --- pyhon/connection/auth.py | 71 ++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 715d7ef..9c5802a 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -3,8 +3,10 @@ import logging import re import secrets import urllib +from dataclasses import dataclass from datetime import datetime, timedelta from pprint import pformat +from typing import Dict, Optional from urllib import parse from urllib.parse import quote @@ -17,6 +19,15 @@ from pyhon.connection.handler.auth import HonAuthConnectionHandler _LOGGER = logging.getLogger(__name__) +@dataclass +class HonLoginData: + url: str = "" + email: str = "" + password: str = "" + fw_uid: str = "" + loaded: Optional[Dict] = None + + class HonAuth: _TOKEN_EXPIRES_AFTER_HOURS = 8 _TOKEN_EXPIRE_WARNING_HOURS = 7 @@ -24,8 +35,9 @@ class HonAuth: def __init__(self, session, email, password, device) -> None: self._session = session self._request = HonAuthConnectionHandler(session) - self._email = email - self._password = password + self._login_data = HonLoginData() + self._login_data.email = email + self._login_data.password = password self._access_token = "" self._refresh_token = "" self._cognito_token = "" @@ -77,7 +89,7 @@ class HonAuth: async def _load_login(self): login_url = await self._introduce() login_url = await self._handle_redirects(login_url) - return await self._login_url(login_url) + await self._login_url(login_url) async def _introduce(self) -> str: redirect_uri = urllib.parse.quote(f"{const.APP}://mobilesdk/detect/oauth/done") @@ -112,46 +124,47 @@ class HonAuth: redirect2 = await self._manual_redirect(redirect1) return f"{redirect2}&System=IoT_Mobile_App&RegistrationSubChannel=hOn" - async def _login_url(self, login_url: str) -> str: + async def _login_url(self, login_url: str) -> bool: headers = {"user-agent": const.USER_AGENT} url = URL(login_url, encoded=True) async with self._request.get(url, headers=headers) as response: text = await response.text() if context := re.findall('"fwuid":"(.*?)","loaded":(\\{.*?})', text): fw_uid, loaded_str = context[0] - loaded = json.loads(loaded_str) - result = login_url.replace("/".join(const.AUTH_API.split("/")[:-1]), "") - return fw_uid, loaded, result + self._login_data.fw_uid = fw_uid + self._login_data.loaded = json.loads(loaded_str) + self._login_data.url = login_url.replace( + "/".join(const.AUTH_API.split("/")[:-1]), "" + ) + return True await self._error_logger(response) - async def _login(self, fw_uid, loaded, login_url): - data = { - "message": { - "actions": [ - { - "id": "79;a", - "descriptor": "apex://LightningLoginCustomController/ACTION$login", - "callingDescriptor": "markup://c:loginForm", - "params": { - "username": quote(self._email), - "password": quote(self._password), - "startUrl": parse.unquote( - login_url.split("startURL=")[-1] - ).split("%3D")[0], - }, - } - ] + async def _login(self): + start_url = parse.unquote(self._login_data.url.split("startURL=")[-1]).split( + "%3D" + )[0] + action = { + "id": "79;a", + "descriptor": "apex://LightningLoginCustomController/ACTION$login", + "callingDescriptor": "markup://c:loginForm", + "params": { + "username": quote(self._login_data.email), + "password": quote(self._login_data.password), + "startUrl": start_url, }, + } + data = { + "message": {"actions": [action]}, "aura.context": { "mode": "PROD", - "fwuid": fw_uid, + "fwuid": self._login_data.fw_uid, "app": "siteforce:loginApp2", - "loaded": loaded, + "loaded": self._login_data.loaded, "dn": [], "globals": {}, "uad": False, }, - "aura.pageURI": login_url, + "aura.pageURI": self._login_data.url, "aura.token": None, } params = {"r": 3, "other.LightningLoginCustom.login": 1} @@ -222,9 +235,9 @@ class HonAuth: async def authenticate(self): self.clear() try: - if not (login_site := await self._load_login()): + if not await self._load_login(): raise exceptions.HonAuthenticationError("Can't open login page") - if not (url := await self._login(*login_site)): + if not (url := await self._login()): raise exceptions.HonAuthenticationError("Can't login") if not await self._get_token(url): raise exceptions.HonAuthenticationError("Can't get token") From f54b7b2dbfd36ff0e9e9198b0be34043c860b0f5 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 15:55:22 +0200 Subject: [PATCH 26/35] Add mypy checks --- .github/workflows/python-check.yml | 6 ++- pyhon/appliance.py | 26 +++++++----- pyhon/connection/auth.py | 64 +++++++++++++++--------------- pyhon/connection/handler/base.py | 10 ++--- pyhon/connection/handler/hon.py | 24 +++++------ pyhon/hon.py | 6 +-- 6 files changed, 73 insertions(+), 63 deletions(-) diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index d0d9b7a..6731bd8 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -25,12 +25,16 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt - python -m pip install flake8 pylint black + python -m pip install flake8 pylint black mypy - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics + - name: Type check with mypy + run: | + # stop the build if there are Python syntax errors or undefined names + mypy pyhon/ # - name: Analysing the code with pylint # run: | # pylint --max-line-length 88 $(git ls-files '*.py') diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 693e9c7..16d2921 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -1,23 +1,29 @@ import importlib from contextlib import suppress -from typing import Optional, Dict +from typing import Optional, Dict, Any +from typing import TYPE_CHECKING from pyhon import helper from pyhon.commands import HonCommand from pyhon.parameter import HonParameterFixed +if TYPE_CHECKING: + from pyhon import HonAPI + class HonAppliance: - def __init__(self, api, info: Dict, zone: int = 0) -> None: + def __init__( + self, api: Optional["HonAPI"], info: Dict[str, Any], zone: int = 0 + ) -> None: if attributes := info.get("attributes"): info["attributes"] = {v["parName"]: v["parValue"] for v in attributes} - self._info = info - self._api = api - self._appliance_model = {} + self._info: Dict = info + self._api: Optional[HonAPI] = api + self._appliance_model: Dict = {} - self._commands = {} - self._statistics = {} - self._attributes = {} + self._commands: Dict = {} + self._statistics: Dict = {} + self._attributes: Dict = {} self._zone = zone try: @@ -58,11 +64,11 @@ class HonAppliance: @property def appliance_model_id(self) -> str: - return self._info.get("applianceModelId") + return self._info.get("applianceModelId", "") @property def appliance_type(self) -> str: - return self._info.get("applianceTypeName") + return self._info.get("applianceTypeName", "") @property def mac_address(self) -> str: diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 9c5802a..a48c054 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -3,10 +3,11 @@ import logging import re import secrets import urllib +from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta from pprint import pformat -from typing import Dict, Optional +from typing import Dict, Optional, List from urllib import parse from urllib.parse import quote @@ -82,14 +83,15 @@ class HonAuth: if fail: raise exceptions.HonAuthenticationError("Can't login") - def _generate_nonce(self) -> str: + @staticmethod + def _generate_nonce() -> str: nonce = secrets.token_hex(16) return f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" - async def _load_login(self): + async def _load_login(self) -> bool: login_url = await self._introduce() login_url = await self._handle_redirects(login_url) - await self._login_url(login_url) + return await self._login_url(login_url) async def _introduce(self) -> str: redirect_uri = urllib.parse.quote(f"{const.APP}://mobilesdk/detect/oauth/done") @@ -101,8 +103,8 @@ class HonAuth: "scope": "api openid refresh_token web", "nonce": self._generate_nonce(), } - params = "&".join([f"{k}={v}" for k, v in params.items()]) - url = f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" + params_encode = "&".join([f"{k}={v}" for k, v in params.items()]) + url = f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params_encode}" async with self._request.get(url) as response: text = await response.text() self._expires = datetime.utcnow() @@ -115,7 +117,7 @@ class HonAuth: async def _manual_redirect(self, url: str) -> str: async with self._request.get(url, allow_redirects=False) as response: - if not (new_location := response.headers.get("Location")): + if not (new_location := response.headers.get("Location", "")): await self._error_logger(response) return new_location @@ -138,11 +140,11 @@ class HonAuth: ) return True await self._error_logger(response) + return False - async def _login(self): - start_url = parse.unquote(self._login_data.url.split("startURL=")[-1]).split( - "%3D" - )[0] + async def _login(self) -> str: + start_url = self._login_data.url.rsplit("startURL=", maxsplit=1)[-1] + start_url = parse.unquote(start_url).split("%3D")[0] action = { "id": "79;a", "descriptor": "apex://LightningLoginCustomController/ACTION$login", @@ -175,19 +177,13 @@ class HonAuth: params=params, ) as response: if response.status == 200: - try: - data = await response.json() - return data["events"][0]["attributes"]["values"]["url"] - except json.JSONDecodeError: - pass - except KeyError: - _LOGGER.error( - "Can't get login url - %s", pformat(await response.json()) - ) + with suppress(json.JSONDecodeError, KeyError): + result = await response.json() + return result["events"][0]["attributes"]["values"]["url"] await self._error_logger(response) return "" - def _parse_token_data(self, text): + def _parse_token_data(self, text: str) -> None: if access_token := re.findall("access_token=(.*?)&", text): self._access_token = access_token[0] if refresh_token := re.findall("refresh_token=(.*?)&", text): @@ -195,22 +191,26 @@ class HonAuth: if id_token := re.findall("id_token=(.*?)&", text): self._id_token = id_token[0] - async def _get_token(self, url): + async def _get_token(self, url: str) -> bool: async with self._request.get(url) as response: if response.status != 200: await self._error_logger(response) return False - url = re.findall("href\\s*=\\s*[\"'](.+?)[\"']", await response.text()) - if not url: + url_search = re.findall( + "href\\s*=\\s*[\"'](.+?)[\"']", await response.text() + ) + if not url_search: await self._error_logger(response) return False - if "ProgressiveLogin" in url[0]: - async with self._request.get(url[0]) as response: + if "ProgressiveLogin" in url_search[0]: + async with self._request.get(url_search[0]) as response: if response.status != 200: await self._error_logger(response) return False - url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await response.text()) - url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] + url_search = re.findall( + "href\\s*=\\s*[\"'](.*?)[\"']", await response.text() + ) + url = "/".join(const.AUTH_API.split("/")[:-1]) + url_search[0] async with self._request.get(url) as response: if response.status != 200: await self._error_logger(response) @@ -218,7 +218,7 @@ class HonAuth: self._parse_token_data(await response.text()) return True - async def _api_auth(self): + async def _api_auth(self) -> bool: post_headers = {"id-token": self._id_token} data = self._device.get() async with self._request.post( @@ -232,7 +232,7 @@ class HonAuth: self._cognito_token = json_data["cognitoUser"]["Token"] return True - async def authenticate(self): + async def authenticate(self) -> None: self.clear() try: if not await self._load_login(): @@ -246,7 +246,7 @@ class HonAuth: except exceptions.HonNoAuthenticationNeeded: return - async def refresh(self): + async def refresh(self) -> bool: params = { "client_id": const.CLIENT_ID, "refresh_token": self._refresh_token, @@ -264,7 +264,7 @@ class HonAuth: self._access_token = data["access_token"] return await self._api_auth() - def clear(self): + def clear(self) -> None: self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2]) self._request.called_urls = [] self._cognito_token = "" diff --git a/pyhon/connection/handler/base.py b/pyhon/connection/handler/base.py index 7542df6..ad77ebe 100644 --- a/pyhon/connection/handler/base.py +++ b/pyhon/connection/handler/base.py @@ -1,7 +1,7 @@ import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Optional, Callable, Dict +from typing import Optional, Callable, Dict, Any import aiohttp from typing_extensions import Self @@ -37,18 +37,18 @@ class ConnectionHandler: raise NotImplementedError @asynccontextmanager - async def get(self, *args, **kwargs) -> AsyncIterator[Callable]: + async def get(self, *args, **kwargs) -> AsyncIterator[aiohttp.ClientResponse]: if self._session is None: raise exceptions.NoSessionException() - response: Callable + response: aiohttp.ClientResponse async with self._intercept(self._session.get, *args, **kwargs) as response: yield response @asynccontextmanager - async def post(self, *args, **kwargs) -> AsyncIterator[Callable]: + async def post(self, *args, **kwargs) -> AsyncIterator[aiohttp.ClientResponse]: if self._session is None: raise exceptions.NoSessionException() - response: Callable + response: aiohttp.ClientResponse async with self._intercept(self._session.post, *args, **kwargs) as response: yield response diff --git a/pyhon/connection/handler/hon.py b/pyhon/connection/handler/hon.py index 7da82a8..f50c3a9 100644 --- a/pyhon/connection/handler/hon.py +++ b/pyhon/connection/handler/hon.py @@ -10,7 +10,7 @@ from typing_extensions import Self from pyhon.connection.auth import HonAuth from pyhon.connection.device import HonDevice from pyhon.connection.handler.base import ConnectionHandler -from pyhon.exceptions import HonAuthenticationError +from pyhon.exceptions import HonAuthenticationError, NoAuthenticationException _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,9 @@ class HonConnectionHandler(ConnectionHandler): self._auth: Optional[HonAuth] = None @property - def auth(self) -> Optional[HonAuth]: + def auth(self) -> HonAuth: + if self._auth is None: + raise NoAuthenticationException() return self._auth @property @@ -39,16 +41,14 @@ class HonConnectionHandler(ConnectionHandler): async def create(self) -> Self: await super().create() - self._auth: HonAuth = HonAuth( - self._session, self._email, self._password, self._device - ) + self._auth = HonAuth(self._session, self._email, self._password, self._device) return self async def _check_headers(self, headers: Dict) -> Dict: - if not (self._auth.cognito_token and self._auth.id_token): - await self._auth.authenticate() - headers["cognito-token"] = self._auth.cognito_token - headers["id-token"] = self._auth.id_token + if not (self.auth.cognito_token and self.auth.id_token): + await self.auth.authenticate() + headers["cognito-token"] = self.auth.cognito_token + headers["id-token"] = self.auth.id_token return self._HEADERS | headers @asynccontextmanager @@ -58,16 +58,16 @@ class HonConnectionHandler(ConnectionHandler): kwargs["headers"] = await self._check_headers(kwargs.get("headers", {})) async with method(*args, **kwargs) as response: if ( - self._auth.token_expires_soon or response.status in [401, 403] + self.auth.token_expires_soon or response.status in [401, 403] ) and loop == 0: _LOGGER.info("Try refreshing token...") - await self._auth.refresh() + await self.auth.refresh() async with self._intercept( method, *args, loop=loop + 1, **kwargs ) as result: yield result elif ( - self._auth.token_is_expired or response.status in [401, 403] + self.auth.token_is_expired or response.status in [401, 403] ) and loop == 1: _LOGGER.warning( "%s - Error %s - %s", diff --git a/pyhon/hon.py b/pyhon/hon.py index 327f85e..9be1d78 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -1,5 +1,5 @@ import asyncio -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Any from typing_extensions import Self from aiohttp import ClientSession @@ -39,8 +39,8 @@ class Hon: def appliances(self) -> List[HonAppliance]: return self._appliances - async def _create_appliance(self, appliance: Dict, zone=0) -> None: - appliance = HonAppliance(self._api, appliance, zone=zone) + async def _create_appliance(self, appliance_data: Dict[str, Any], zone=0) -> None: + appliance = HonAppliance(self._api, appliance_data, zone=zone) if appliance.mac_address is None: return await asyncio.gather( From a957d7ac0f20832a0ff3563ab54f81c3819171c2 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 21:58:20 +0200 Subject: [PATCH 27/35] Fix error for zone devices --- .github/workflows/python-check.yml | 1 - pyhon/appliance.py | 4 ++++ pyhon/hon.py | 4 +++- setup.py | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index 6731bd8..f2b0bd0 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -33,7 +33,6 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics - name: Type check with mypy run: | - # stop the build if there are Python syntax errors or undefined names mypy pyhon/ # - name: Analysing the code with pylint # run: | diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 16d2921..0549a2e 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -72,6 +72,10 @@ class HonAppliance: @property def mac_address(self) -> str: + return self.info.get("macAddress", "") + + @property + def unique_id(self) -> str: return self._check_name_zone("macAddress", frontend=False) @property diff --git a/pyhon/hon.py b/pyhon/hon.py index 9be1d78..9aa236c 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -1,4 +1,5 @@ import asyncio +import copy from typing import List, Optional, Dict, Any from typing_extensions import Self @@ -53,9 +54,10 @@ class Hon: self._appliances.append(appliance) async def setup(self): + appliance: Dict for appliance in (await self._api.load_appliances())["payload"]["appliances"]: for zone in range(int(appliance.get("zone", "0"))): - await self._create_appliance(appliance, zone=zone + 1) + await self._create_appliance(appliance.copy(), zone=zone + 1) await self._create_appliance(appliance) async def close(self): diff --git a/setup.py b/setup.py index 80cad43..6eaa2fa 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.8.0b2", + version="0.8.0b3", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From a1618bb18a7885e6015db2e70065da3c18910739 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 22:25:34 +0200 Subject: [PATCH 28/35] Fix missing zone attribute --- .gitignore | 1 + README.md | 2 +- pyhon/appliance.py | 6 +++++- setup.py | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6440f3c..078d48e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ dist/ **/*.egg-info/ test* +build/ diff --git a/README.md b/README.md index cba6f50..b999d02 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyhOn)](https://www.python.org/) [![PyPI - License](https://img.shields.io/pypi/l/pyhOn)](https://github.com/Andre0512/pyhOn/blob/main/LICENSE) [![PyPI - Downloads](https://img.shields.io/pypi/dm/pyhOn)](https://pypistats.org/packages/pyhon) -Control your Haier appliances with python! +Control your Haier, Candy and Hoover appliances with python! The idea behind this library is, to make the use of all available commands as simple as possible. ## Installation diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 0549a2e..297f87e 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -24,7 +24,7 @@ class HonAppliance: self._commands: Dict = {} self._statistics: Dict = {} self._attributes: Dict = {} - self._zone = zone + self._zone: int = zone try: self._extra = importlib.import_module( @@ -106,6 +106,10 @@ class HonAppliance: def info(self): return self._info + @property + def zone(self) -> int: + return self._zone + async def _recover_last_command_states(self, commands): command_history = await self._api.command_history(self) for name, command in commands.items(): diff --git a/setup.py b/setup.py index 6eaa2fa..caa5d11 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r") as f: setup( name="pyhOn", - version="0.8.0b3", + version="0.8.0b4", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, From 46ff9be4a2cb07003e7168776274c27503d301ec Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sat, 15 Apr 2023 23:02:37 +0200 Subject: [PATCH 29/35] Fix code depts --- .github/workflows/python-check.yml | 2 +- pyhon/appliance.py | 8 +++----- pyhon/commands.py | 26 ++++++++++++++++---------- pyhon/connection/api.py | 4 ++-- pyhon/connection/auth.py | 3 +-- pyhon/connection/handler/base.py | 2 +- pyhon/hon.py | 3 +-- pyhon/parameter.py | 6 +++--- requirements.txt | 3 ++- requirements_dev.txt | 4 ++++ 10 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 requirements_dev.txt diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index f2b0bd0..813ebd7 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -25,7 +25,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt - python -m pip install flake8 pylint black mypy + python -m pip install -r requirements_dev.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 297f87e..bf070ff 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -124,8 +124,8 @@ class HonAppliance: if last is None: continue parameters = command_history[last].get("command", {}).get("parameters", {}) - if command._multi and parameters.get("program"): - command.set_program(parameters.pop("program").split(".")[-1].lower()) + if command.programs and parameters.get("program"): + command.program = parameters.pop("program").split(".")[-1].lower() command = self.commands[name] for key, data in command.settings.items(): if ( @@ -148,9 +148,7 @@ class HonAppliance: multi = {} for program, attr2 in attr.items(): program = program.split(".")[-1].lower() - cmd = HonCommand( - command, attr2, self._api, self, multi=multi, program=program - ) + cmd = HonCommand(command, attr2, self._api, self, programs=multi, program_name=program) multi[program] = cmd commands[command] = cmd self._commands = commands diff --git a/pyhon/commands.py b/pyhon/commands.py index b6a5427..f9c47f2 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -7,12 +7,12 @@ from pyhon.parameter import ( class HonCommand: - def __init__(self, name, attributes, connector, device, multi=None, program=""): + def __init__(self, name:str, attributes, connector, device, programs=None, program_name=""): self._connector = connector self._device = device self._name = name - self._multi = multi or {} - self._program = program + self._programs = programs or {} + self._program_name = program_name self._description = attributes.get("description", "") self._parameters = self._create_parameters(attributes.get("parameters", {})) self._ancillary_parameters = self._create_parameters( @@ -34,7 +34,7 @@ class HonCommand: result[parameter] = HonParameterEnum(parameter, attributes) case "fixed": result[parameter] = HonParameterFixed(parameter, attributes) - if self._multi: + if self._programs: result["program"] = HonParameterProgram("program", self) return result @@ -57,11 +57,17 @@ class HonCommand: self._device, self._name, parameters, self.ancillary_parameters ) - def get_programs(self): - return self._multi + @property + def programs(self): + return self._programs - def set_program(self, program): - self._device.commands[self._name] = self._multi[program] + @property + def program(self): + return self._program_name + + @program.setter + def program(self, program): + self._device.commands[self._name] = self._programs[program] def _get_settings_keys(self, command=None): command = command or self @@ -75,10 +81,10 @@ class HonCommand: @property def setting_keys(self): - if not self._multi: + if not self._programs: return self._get_settings_keys() result = [ - key for cmd in self._multi.values() for key in self._get_settings_keys(cmd) + key for cmd in self._programs.values() for key in self._get_settings_keys(cmd) ] return list(set(result + ["program"])) diff --git a/pyhon/connection/api.py b/pyhon/connection/api.py index 3af519a..4852a10 100644 --- a/pyhon/connection/api.py +++ b/pyhon/connection/api.py @@ -2,15 +2,15 @@ import json import logging from datetime import datetime from typing import Dict, Optional -from typing_extensions import Self from aiohttp import ClientSession +from typing_extensions import Self from pyhon import const, exceptions from pyhon.appliance import HonAppliance from pyhon.connection.auth import HonAuth -from pyhon.connection.handler.hon import HonConnectionHandler from pyhon.connection.handler.anonym import HonAnonymousConnectionHandler +from pyhon.connection.handler.hon import HonConnectionHandler _LOGGER = logging.getLogger() diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index a48c054..6f26f1b 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -6,8 +6,7 @@ import urllib from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta -from pprint import pformat -from typing import Dict, Optional, List +from typing import Dict, Optional from urllib import parse from urllib.parse import quote diff --git a/pyhon/connection/handler/base.py b/pyhon/connection/handler/base.py index ad77ebe..7ccf5f4 100644 --- a/pyhon/connection/handler/base.py +++ b/pyhon/connection/handler/base.py @@ -1,7 +1,7 @@ import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Optional, Callable, Dict, Any +from typing import Optional, Callable, Dict import aiohttp from typing_extensions import Self diff --git a/pyhon/hon.py b/pyhon/hon.py index 9aa236c..872461e 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -1,9 +1,8 @@ import asyncio -import copy from typing import List, Optional, Dict, Any -from typing_extensions import Self from aiohttp import ClientSession +from typing_extensions import Self from pyhon import HonAPI, exceptions from pyhon.appliance import HonAppliance diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 7d9208e..853c521 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -124,8 +124,8 @@ class HonParameterProgram(HonParameterEnum): def __init__(self, key, command): super().__init__(key, {}) self._command = command - self._value = command._program - self._values = command._multi + self._value = command.program + self._values = command.programs self._typology = "enum" self._filter = "" @@ -136,7 +136,7 @@ class HonParameterProgram(HonParameterEnum): @value.setter def value(self, value): if value in self.values: - self._command.set_program(value) + self._command.program = value else: raise ValueError(f"Allowed values {self._values}") diff --git a/requirements.txt b/requirements.txt index ee4ba4f..9ba048c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -aiohttp +aiohttp==3.8.4 +yarl==1.8.2 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..9bb012e --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,4 @@ +black==23.3.0 +flake8==6.0.0 +mypy==1.2.0 +pylint==2.17.2 From 834f25a6392279da43c51a88910864c564437b8d Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 16 Apr 2023 00:11:50 +0200 Subject: [PATCH 30/35] Remove filters, filter out recies #9 --- pyhon/appliance.py | 9 ++++++++- pyhon/appliances/ov.py | 23 ----------------------- pyhon/commands.py | 8 ++++++-- pyhon/parameter.py | 19 +++---------------- 4 files changed, 17 insertions(+), 42 deletions(-) delete mode 100644 pyhon/appliances/ov.py diff --git a/pyhon/appliance.py b/pyhon/appliance.py index bf070ff..00e9716 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -148,7 +148,14 @@ class HonAppliance: multi = {} for program, attr2 in attr.items(): program = program.split(".")[-1].lower() - cmd = HonCommand(command, attr2, self._api, self, programs=multi, program_name=program) + cmd = HonCommand( + command, + attr2, + self._api, + self, + programs=multi, + program_name=program, + ) multi[program] = cmd commands[command] = cmd self._commands = commands diff --git a/pyhon/appliances/ov.py b/pyhon/appliances/ov.py deleted file mode 100644 index 2fc63d7..0000000 --- a/pyhon/appliances/ov.py +++ /dev/null @@ -1,23 +0,0 @@ -from pyhon.parameter import HonParameterEnum - - -class Appliance: - _FILTERS = { - "default": "^(?!iot_(?:recipe|guided))\\S+$", - "recipe": "iot_recipe_", - "guided": "iot_guided_", - } - - def __init__(self): - filters = list(self._FILTERS.values()) - data = {"defaultValue": filters[0], "enumValues": filters} - self._program_filter = HonParameterEnum("program_filter", data) - - def data(self, data): - return data - - def settings(self, settings): - settings["program_filter"] = self._program_filter - value = self._FILTERS[self._program_filter.value] - settings["startProgram.program"].filter = value - return settings diff --git a/pyhon/commands.py b/pyhon/commands.py index f9c47f2..06e7c2c 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -7,7 +7,9 @@ from pyhon.parameter import ( class HonCommand: - def __init__(self, name:str, attributes, connector, device, programs=None, program_name=""): + def __init__( + self, name: str, attributes, connector, device, programs=None, program_name="" + ): self._connector = connector self._device = device self._name = name @@ -84,7 +86,9 @@ class HonCommand: if not self._programs: return self._get_settings_keys() result = [ - key for cmd in self._programs.values() for key in self._get_settings_keys(cmd) + key + for cmd in self._programs.values() + for key in self._get_settings_keys(cmd) ] return list(set(result + ["program"])) diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 853c521..5944991 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -1,6 +1,3 @@ -import re - - def str_to_float(string): try: return int(string) @@ -14,7 +11,6 @@ class HonParameter: self._category = attributes.get("category") self._typology = attributes.get("typology") self._mandatory = attributes.get("mandatory") - self._value = "" @property def key(self): @@ -121,6 +117,8 @@ class HonParameterEnum(HonParameter): class HonParameterProgram(HonParameterEnum): + _FILTER = ["iot_recipe", "iot_guided"] + def __init__(self, key, command): super().__init__(key, {}) self._command = command @@ -140,18 +138,7 @@ class HonParameterProgram(HonParameterEnum): else: raise ValueError(f"Allowed values {self._values}") - @property - def filter(self): - return self._filter - - @filter.setter - def filter(self, filter): - self._filter = filter - @property def values(self): - values = [] - for value in self._values: - if not self._filter or re.findall(self._filter, str(value)): - values.append(str(value)) + values = [v for v in self._values if all(f not in v for f in self._FILTER)] return sorted(values) From 461a247ad3f7eb9a02b2b87e53d2367d323a04e4 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 16 Apr 2023 01:36:10 +0200 Subject: [PATCH 31/35] More type hints --- pyhon/commands.py | 76 ++++++++++++++++++++++++++---------------- pyhon/hon.py | 20 +++++++---- pyhon/parameter.py | 82 +++++++++++++++++++++++++--------------------- 3 files changed, 104 insertions(+), 74 deletions(-) diff --git a/pyhon/commands.py b/pyhon/commands.py index 06e7c2c..c7c7537 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -1,34 +1,49 @@ +from typing import Optional, Dict, Any, List, TYPE_CHECKING + from pyhon.parameter import ( HonParameterFixed, HonParameterEnum, HonParameterRange, HonParameterProgram, + HonParameter, ) +if TYPE_CHECKING: + from pyhon import HonAPI + from pyhon.appliance import HonAppliance + class HonCommand: def __init__( - self, name: str, attributes, connector, device, programs=None, program_name="" + self, + name: str, + attributes: Dict[str, Any], + api: "HonAPI", + appliance: "HonAppliance", + programs: Optional[Dict[str, "HonCommand"]] = None, + program_name: str = "", ): - self._connector = connector - self._device = device - self._name = name - self._programs = programs or {} - self._program_name = program_name - self._description = attributes.get("description", "") - self._parameters = self._create_parameters(attributes.get("parameters", {})) - self._ancillary_parameters = self._create_parameters( + self._api: HonAPI = api + self._appliance: "HonAppliance" = appliance + self._name: str = name + self._programs: Optional[Dict[str, "HonCommand"]] = programs or {} + self._program_name: str = program_name + self._description: str = attributes.get("description", "") + self._parameters: Dict[str, HonParameter] = self._create_parameters( + attributes.get("parameters", {}) + ) + self._ancillary_parameters: Dict[str, HonParameter] = self._create_parameters( attributes.get("ancillaryParameters", {}) ) - def __repr__(self): + def __repr__(self) -> str: return f"{self._name} command" - def _create_parameters(self, parameters): - result = {} + def _create_parameters(self, parameters: Dict) -> Dict[str, HonParameter]: + result: Dict[str, HonParameter] = {} for parameter, attributes in parameters.items(): - if parameter == "zoneMap" and self._device.zone: - attributes["default"] = self._device.zone + if parameter == "zoneMap" and self._appliance.zone: + attributes["default"] = self._appliance.zone match attributes.get("typology"): case "range": result[parameter] = HonParameterRange(parameter, attributes) @@ -41,38 +56,41 @@ class HonCommand: return result @property - def parameters(self): + def parameters(self) -> Dict[str, HonParameter]: return self._parameters @property - def ancillary_parameters(self): + def ancillary_parameters(self) -> Dict[str, str | float]: return { key: parameter.value for key, parameter in self._ancillary_parameters.items() } - async def send(self): + async def send(self) -> bool: parameters = { name: parameter.value for name, parameter in self._parameters.items() } - return await self._connector.send_command( - self._device, self._name, parameters, self.ancillary_parameters + return await self._api.send_command( + self._appliance, self._name, parameters, self.ancillary_parameters ) @property - def programs(self): + def programs(self) -> Dict[str, "HonCommand"]: + if self._programs is None: + return {} return self._programs @property - def program(self): + def program(self) -> str: return self._program_name @program.setter - def program(self, program): - self._device.commands[self._name] = self._programs[program] + def program(self, program: str) -> None: + self._appliance.commands[self._name] = self.programs[program] - def _get_settings_keys(self, command=None): - command = command or self + def _get_settings_keys(self, command: Optional["HonCommand"] = None) -> List[str]: + if command is None: + command = self keys = [] for key, parameter in command._parameters.items(): if isinstance(parameter, HonParameterFixed): @@ -82,7 +100,7 @@ class HonCommand: return keys @property - def setting_keys(self): + def setting_keys(self) -> List[str]: if not self._programs: return self._get_settings_keys() result = [ @@ -93,10 +111,10 @@ class HonCommand: return list(set(result + ["program"])) @property - def settings(self): + def settings(self) -> Dict[str, HonParameter]: """Parameters with typology enum and range""" return { - s: self._parameters.get(s) + s: param for s in self.setting_keys - if self._parameters.get(s) is not None + if (param := self._parameters.get(s)) is not None } diff --git a/pyhon/hon.py b/pyhon/hon.py index 872461e..830d510 100644 --- a/pyhon/hon.py +++ b/pyhon/hon.py @@ -1,5 +1,6 @@ import asyncio -from typing import List, Optional, Dict, Any +from types import TracebackType +from typing import List, Optional, Dict, Any, Type from aiohttp import ClientSession from typing_extensions import Self @@ -16,10 +17,15 @@ class Hon: self._appliances: List[HonAppliance] = [] self._api: Optional[HonAPI] = None - async def __aenter__(self): + async def __aenter__(self) -> Self: return await self.create() - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: await self.close() @property @@ -52,12 +58,12 @@ class Hon: ) self._appliances.append(appliance) - async def setup(self): + async def setup(self) -> None: appliance: Dict - for appliance in (await self._api.load_appliances())["payload"]["appliances"]: + for appliance in (await self.api.load_appliances())["payload"]["appliances"]: for zone in range(int(appliance.get("zone", "0"))): await self._create_appliance(appliance.copy(), zone=zone + 1) await self._create_appliance(appliance) - async def close(self): - await self._api.close() + async def close(self) -> None: + await self.api.close() diff --git a/pyhon/parameter.py b/pyhon/parameter.py index 5944991..e5fba67 100644 --- a/pyhon/parameter.py +++ b/pyhon/parameter.py @@ -1,4 +1,10 @@ -def str_to_float(string): +from typing import Dict, Any, List, TYPE_CHECKING + +if TYPE_CHECKING: + from pyhon.commands import HonCommand + + +def str_to_float(string: str | float) -> float: try: return int(string) except ValueError: @@ -6,43 +12,44 @@ def str_to_float(string): class HonParameter: - def __init__(self, key, attributes): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: self._key = key - self._category = attributes.get("category") - self._typology = attributes.get("typology") - self._mandatory = attributes.get("mandatory") + self._category: str = attributes.get("category", "") + self._typology: str = attributes.get("typology", "") + self._mandatory: int = attributes.get("mandatory", 0) + self._value: str | float = "" @property - def key(self): + def key(self) -> str: return self._key @property - def value(self): + def value(self) -> str | float: return self._value if self._value is not None else "0" @property - def category(self): + def category(self) -> str: return self._category @property - def typology(self): + def typology(self) -> str: return self._typology @property - def mandatory(self): + def mandatory(self) -> int: return self._mandatory class HonParameterFixed(HonParameter): - def __init__(self, key, attributes): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: super().__init__(key, attributes) self._value = attributes.get("fixedValue", None) - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__} (<{self.key}> fixed)" @property - def value(self): + def value(self) -> str | float: return self._value if self._value is not None else "0" @value.setter @@ -52,35 +59,35 @@ class HonParameterFixed(HonParameter): class HonParameterRange(HonParameter): - def __init__(self, key, attributes): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: super().__init__(key, attributes) - self._min = str_to_float(attributes["minimumValue"]) - self._max = str_to_float(attributes["maximumValue"]) - self._step = str_to_float(attributes["incrementValue"]) - self._default = str_to_float(attributes.get("defaultValue", self._min)) - self._value = self._default + self._min: float = str_to_float(attributes["minimumValue"]) + self._max: float = str_to_float(attributes["maximumValue"]) + self._step: float = str_to_float(attributes["incrementValue"]) + self._default: float = str_to_float(attributes.get("defaultValue", self._min)) + self._value: float = self._default def __repr__(self): return f"{self.__class__} (<{self.key}> [{self._min} - {self._max}])" @property - def min(self): + def min(self) -> float: return self._min @property - def max(self): + def max(self) -> float: return self._max @property - def step(self): + def step(self) -> float: return self._step @property - def value(self): + def value(self) -> float: return self._value if self._value is not None else self._min @value.setter - def value(self, value): + def value(self, value: float) -> None: value = str_to_float(value) if self._min <= value <= self._max and not value % self._step: self._value = value @@ -91,25 +98,25 @@ class HonParameterRange(HonParameter): class HonParameterEnum(HonParameter): - def __init__(self, key, attributes): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: super().__init__(key, attributes) self._default = attributes.get("defaultValue") self._value = self._default or "0" - self._values = attributes.get("enumValues") + self._values: List[str] = attributes.get("enumValues", []) - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__} (<{self.key}> {self.values})" @property - def values(self): + def values(self) -> List[str]: return [str(value) for value in self._values] @property - def value(self): + def value(self) -> str | float: return self._value if self._value is not None else self.values[0] @value.setter - def value(self, value): + def value(self, value: str) -> None: if value in self.values: self._value = value else: @@ -119,26 +126,25 @@ class HonParameterEnum(HonParameter): class HonParameterProgram(HonParameterEnum): _FILTER = ["iot_recipe", "iot_guided"] - def __init__(self, key, command): + def __init__(self, key: str, command: "HonCommand") -> None: super().__init__(key, {}) self._command = command - self._value = command.program - self._values = command.programs - self._typology = "enum" - self._filter = "" + self._value: str = command.program + self._values: List[str] = list(command.programs) + self._typology: str = "enum" @property - def value(self): + def value(self) -> str | float: return self._value @value.setter - def value(self, value): + def value(self, value: str) -> None: if value in self.values: self._command.program = value else: raise ValueError(f"Allowed values {self._values}") @property - def values(self): + def values(self) -> List[str]: values = [v for v in self._values if all(f not in v for f in self._FILTER)] return sorted(values) From 03187745bfe1efb204ec8a6a4ec38f8efe0784eb Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 16 Apr 2023 01:43:37 +0200 Subject: [PATCH 32/35] Split up parameters --- pyhon/appliance.py | 2 +- pyhon/commands.py | 12 ++- pyhon/parameter.py | 150 ------------------------------------ pyhon/parameter/__init__.py | 0 pyhon/parameter/base.py | 30 ++++++++ pyhon/parameter/enum.py | 29 +++++++ pyhon/parameter/fixed.py | 21 +++++ pyhon/parameter/program.py | 33 ++++++++ pyhon/parameter/range.py | 49 ++++++++++++ 9 files changed, 168 insertions(+), 158 deletions(-) delete mode 100644 pyhon/parameter.py create mode 100644 pyhon/parameter/__init__.py create mode 100644 pyhon/parameter/base.py create mode 100644 pyhon/parameter/enum.py create mode 100644 pyhon/parameter/fixed.py create mode 100644 pyhon/parameter/program.py create mode 100644 pyhon/parameter/range.py diff --git a/pyhon/appliance.py b/pyhon/appliance.py index 00e9716..d54df40 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from pyhon import helper from pyhon.commands import HonCommand -from pyhon.parameter import HonParameterFixed +from pyhon.parameter.fixed import HonParameterFixed if TYPE_CHECKING: from pyhon import HonAPI diff --git a/pyhon/commands.py b/pyhon/commands.py index c7c7537..03f70d0 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -1,12 +1,10 @@ from typing import Optional, Dict, Any, List, TYPE_CHECKING -from pyhon.parameter import ( - HonParameterFixed, - HonParameterEnum, - HonParameterRange, - HonParameterProgram, - HonParameter, -) +from pyhon.parameter.base import HonParameter +from pyhon.parameter.enum import HonParameterEnum +from pyhon.parameter.fixed import HonParameterFixed +from pyhon.parameter.program import HonParameterProgram +from pyhon.parameter.range import HonParameterRange if TYPE_CHECKING: from pyhon import HonAPI diff --git a/pyhon/parameter.py b/pyhon/parameter.py deleted file mode 100644 index e5fba67..0000000 --- a/pyhon/parameter.py +++ /dev/null @@ -1,150 +0,0 @@ -from typing import Dict, Any, List, TYPE_CHECKING - -if TYPE_CHECKING: - from pyhon.commands import HonCommand - - -def str_to_float(string: str | float) -> float: - try: - return int(string) - except ValueError: - return float(str(string).replace(",", ".")) - - -class HonParameter: - def __init__(self, key: str, attributes: Dict[str, Any]) -> None: - self._key = key - self._category: str = attributes.get("category", "") - self._typology: str = attributes.get("typology", "") - self._mandatory: int = attributes.get("mandatory", 0) - self._value: str | float = "" - - @property - def key(self) -> str: - return self._key - - @property - def value(self) -> str | float: - return self._value if self._value is not None else "0" - - @property - def category(self) -> str: - return self._category - - @property - def typology(self) -> str: - return self._typology - - @property - def mandatory(self) -> int: - return self._mandatory - - -class HonParameterFixed(HonParameter): - def __init__(self, key: str, attributes: Dict[str, Any]) -> None: - super().__init__(key, attributes) - self._value = attributes.get("fixedValue", None) - - def __repr__(self) -> str: - return f"{self.__class__} (<{self.key}> fixed)" - - @property - def value(self) -> str | float: - return self._value if self._value is not None else "0" - - @value.setter - def value(self, value): - if not value == self._value: - raise ValueError("Can't change fixed value") - - -class HonParameterRange(HonParameter): - def __init__(self, key: str, attributes: Dict[str, Any]) -> None: - super().__init__(key, attributes) - self._min: float = str_to_float(attributes["minimumValue"]) - self._max: float = str_to_float(attributes["maximumValue"]) - self._step: float = str_to_float(attributes["incrementValue"]) - self._default: float = str_to_float(attributes.get("defaultValue", self._min)) - self._value: float = self._default - - def __repr__(self): - return f"{self.__class__} (<{self.key}> [{self._min} - {self._max}])" - - @property - def min(self) -> float: - return self._min - - @property - def max(self) -> float: - return self._max - - @property - def step(self) -> float: - return self._step - - @property - def value(self) -> float: - return self._value if self._value is not None else self._min - - @value.setter - def value(self, value: float) -> None: - value = str_to_float(value) - if self._min <= value <= self._max and not value % self._step: - self._value = value - else: - raise ValueError( - f"Allowed: min {self._min} max {self._max} step {self._step}" - ) - - -class HonParameterEnum(HonParameter): - def __init__(self, key: str, attributes: Dict[str, Any]) -> None: - super().__init__(key, attributes) - self._default = attributes.get("defaultValue") - self._value = self._default or "0" - self._values: List[str] = attributes.get("enumValues", []) - - def __repr__(self) -> str: - return f"{self.__class__} (<{self.key}> {self.values})" - - @property - def values(self) -> List[str]: - return [str(value) for value in self._values] - - @property - def value(self) -> str | float: - return self._value if self._value is not None else self.values[0] - - @value.setter - def value(self, value: str) -> None: - if value in self.values: - self._value = value - else: - raise ValueError(f"Allowed values {self._value}") - - -class HonParameterProgram(HonParameterEnum): - _FILTER = ["iot_recipe", "iot_guided"] - - def __init__(self, key: str, command: "HonCommand") -> None: - super().__init__(key, {}) - self._command = command - self._value: str = command.program - self._values: List[str] = list(command.programs) - self._typology: str = "enum" - - @property - def value(self) -> str | float: - return self._value - - @value.setter - def value(self, value: str) -> None: - if value in self.values: - self._command.program = value - else: - raise ValueError(f"Allowed values {self._values}") - - @property - def values(self) -> List[str]: - values = [v for v in self._values if all(f not in v for f in self._FILTER)] - return sorted(values) diff --git a/pyhon/parameter/__init__.py b/pyhon/parameter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyhon/parameter/base.py b/pyhon/parameter/base.py new file mode 100644 index 0000000..c2c818a --- /dev/null +++ b/pyhon/parameter/base.py @@ -0,0 +1,30 @@ +from typing import Dict, Any + + +class HonParameter: + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: + self._key = key + self._category: str = attributes.get("category", "") + self._typology: str = attributes.get("typology", "") + self._mandatory: int = attributes.get("mandatory", 0) + self._value: str | float = "" + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str | float: + return self._value if self._value is not None else "0" + + @property + def category(self) -> str: + return self._category + + @property + def typology(self) -> str: + return self._typology + + @property + def mandatory(self) -> int: + return self._mandatory diff --git a/pyhon/parameter/enum.py b/pyhon/parameter/enum.py new file mode 100644 index 0000000..c5afcda --- /dev/null +++ b/pyhon/parameter/enum.py @@ -0,0 +1,29 @@ +from typing import Dict, Any, List + +from pyhon.parameter.base import HonParameter + + +class HonParameterEnum(HonParameter): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: + super().__init__(key, attributes) + self._default = attributes.get("defaultValue") + self._value = self._default or "0" + self._values: List[str] = attributes.get("enumValues", []) + + def __repr__(self) -> str: + return f"{self.__class__} (<{self.key}> {self.values})" + + @property + def values(self) -> List[str]: + return [str(value) for value in self._values] + + @property + def value(self) -> str | float: + return self._value if self._value is not None else self.values[0] + + @value.setter + def value(self, value: str) -> None: + if value in self.values: + self._value = value + else: + raise ValueError(f"Allowed values {self._value}") diff --git a/pyhon/parameter/fixed.py b/pyhon/parameter/fixed.py new file mode 100644 index 0000000..c3f60ec --- /dev/null +++ b/pyhon/parameter/fixed.py @@ -0,0 +1,21 @@ +from typing import Dict, Any + +from pyhon.parameter.base import HonParameter + + +class HonParameterFixed(HonParameter): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: + super().__init__(key, attributes) + self._value = attributes.get("fixedValue", None) + + def __repr__(self) -> str: + return f"{self.__class__} (<{self.key}> fixed)" + + @property + def value(self) -> str | float: + return self._value if self._value is not None else "0" + + @value.setter + def value(self, value): + if not value == self._value: + raise ValueError("Can't change fixed value") diff --git a/pyhon/parameter/program.py b/pyhon/parameter/program.py new file mode 100644 index 0000000..a41603c --- /dev/null +++ b/pyhon/parameter/program.py @@ -0,0 +1,33 @@ +from typing import List, TYPE_CHECKING + +from pyhon.parameter.enum import HonParameterEnum + +if TYPE_CHECKING: + from pyhon.commands import HonCommand + + +class HonParameterProgram(HonParameterEnum): + _FILTER = ["iot_recipe", "iot_guided"] + + def __init__(self, key: str, command: "HonCommand") -> None: + super().__init__(key, {}) + self._command = command + self._value: str = command.program + self._values: List[str] = list(command.programs) + self._typology: str = "enum" + + @property + def value(self) -> str | float: + return self._value + + @value.setter + def value(self, value: str) -> None: + if value in self.values: + self._command.program = value + else: + raise ValueError(f"Allowed values {self._values}") + + @property + def values(self) -> List[str]: + values = [v for v in self._values if all(f not in v for f in self._FILTER)] + return sorted(values) diff --git a/pyhon/parameter/range.py b/pyhon/parameter/range.py new file mode 100644 index 0000000..d0d26d9 --- /dev/null +++ b/pyhon/parameter/range.py @@ -0,0 +1,49 @@ +from typing import Dict, Any + +from pyhon.parameter.base import HonParameter + + +def str_to_float(string: str | float) -> float: + try: + return int(string) + except ValueError: + return float(str(string).replace(",", ".")) + + +class HonParameterRange(HonParameter): + def __init__(self, key: str, attributes: Dict[str, Any]) -> None: + super().__init__(key, attributes) + self._min: float = str_to_float(attributes["minimumValue"]) + self._max: float = str_to_float(attributes["maximumValue"]) + self._step: float = str_to_float(attributes["incrementValue"]) + self._default: float = str_to_float(attributes.get("defaultValue", self._min)) + self._value: float = self._default + + def __repr__(self): + return f"{self.__class__} (<{self.key}> [{self._min} - {self._max}])" + + @property + def min(self) -> float: + return self._min + + @property + def max(self) -> float: + return self._max + + @property + def step(self) -> float: + return self._step + + @property + def value(self) -> float: + return self._value if self._value is not None else self._min + + @value.setter + def value(self, value: float) -> None: + value = str_to_float(value) + if self._min <= value <= self._max and not value % self._step: + self._value = value + else: + raise ValueError( + f"Allowed: min {self._min} max {self._max} step {self._step}" + ) From 8da201830271b2b0c8033bbc03178e8df214258c Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 16 Apr 2023 02:44:20 +0200 Subject: [PATCH 33/35] Set fixed values --- pyhon/parameter/fixed.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyhon/parameter/fixed.py b/pyhon/parameter/fixed.py index c3f60ec..52595ed 100644 --- a/pyhon/parameter/fixed.py +++ b/pyhon/parameter/fixed.py @@ -16,6 +16,6 @@ class HonParameterFixed(HonParameter): return self._value if self._value is not None else "0" @value.setter - def value(self, value): - if not value == self._value: - raise ValueError("Can't change fixed value") + def value(self, value: str | float) -> None: + # Fixed values seems being not so fixed as thought + self._value = value From 5b91747ec108dde62bdb6cd1dc7b95735b3f1c19 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 16 Apr 2023 02:46:30 +0200 Subject: [PATCH 34/35] Add dish washer --- pyhon/appliances/dw.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 pyhon/appliances/dw.py diff --git a/pyhon/appliances/dw.py b/pyhon/appliances/dw.py new file mode 100644 index 0000000..62311bd --- /dev/null +++ b/pyhon/appliances/dw.py @@ -0,0 +1,9 @@ +class Appliance: + def data(self, data): + if data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED": + data["attributes"]["parameters"]["machMode"] = "0" + data["active"] = bool(data.get("attributes", {}).get("activity")) + return data + + def settings(self, settings): + return settings From e887371becccb3a1cf1d1ab4d70ef20feb7ff399 Mon Sep 17 00:00:00 2001 From: Andre Basche Date: Sun, 16 Apr 2023 03:29:28 +0200 Subject: [PATCH 35/35] expose ancillary parameter in settings --- pyhon/appliance.py | 8 ++++---- pyhon/commands.py | 12 ++++++------ pyhon/helper.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pyhon/appliance.py b/pyhon/appliance.py index d54df40..ce89187 100644 --- a/pyhon/appliance.py +++ b/pyhon/appliance.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: class HonAppliance: def __init__( - self, api: Optional["HonAPI"], info: Dict[str, Any], zone: int = 0 + self, api: Optional["HonAPI"], info: Dict[str, Any], zone: int = 0 ) -> None: if attributes := info.get("attributes"): info["attributes"] = {v["parName"]: v["parValue"] for v in attributes} @@ -129,8 +129,8 @@ class HonAppliance: command = self.commands[name] for key, data in command.settings.items(): if ( - not isinstance(data, HonParameterFixed) - and parameters.get(key) is not None + not isinstance(data, HonParameterFixed) + and parameters.get(key) is not None ): with suppress(ValueError): data.value = parameters.get(key) @@ -175,7 +175,7 @@ class HonAppliance: def parameters(self): result = {} for name, command in self._commands.items(): - for key, parameter in command.parameters.items(): + for key, parameter in (command.parameters | command.ancillary_parameters).items(): result.setdefault(name, {})[key] = parameter.value return result diff --git a/pyhon/commands.py b/pyhon/commands.py index 03f70d0..33487a3 100644 --- a/pyhon/commands.py +++ b/pyhon/commands.py @@ -58,11 +58,8 @@ class HonCommand: return self._parameters @property - def ancillary_parameters(self) -> Dict[str, str | float]: - return { - key: parameter.value - for key, parameter in self._ancillary_parameters.items() - } + def ancillary_parameters(self) -> Dict[str, HonParameter]: + return self._ancillary_parameters async def send(self) -> bool: parameters = { @@ -90,7 +87,9 @@ class HonCommand: if command is None: command = self keys = [] - for key, parameter in command._parameters.items(): + for key, parameter in ( + command._parameters | command._ancillary_parameters + ).items(): if isinstance(parameter, HonParameterFixed): continue if key not in keys: @@ -115,4 +114,5 @@ class HonCommand: s: param for s in self.setting_keys if (param := self._parameters.get(s)) is not None + or (param := self._ancillary_parameters.get(s)) is not None } diff --git a/pyhon/helper.py b/pyhon/helper.py index d126b91..c820dd1 100644 --- a/pyhon/helper.py +++ b/pyhon/helper.py @@ -49,7 +49,7 @@ def create_command(commands, concat=False): for name, command in commands.items(): if not concat: result[name] = {} - for parameter, data in command.parameters.items(): + for parameter, data in command.settings.items(): if data.typology == "enum": value = data.values elif data.typology == "range":