Use connection handler

This commit is contained in:
Andre Basche 2023-04-09 18:13:50 +02:00
parent 36079c3c77
commit 8fa01343bc
8 changed files with 247 additions and 140 deletions

View file

@ -1 +1 @@
from .api import HonConnection from .connection.api import HonAPI

6
pyhon/__main__.py Executable file → Normal file
View file

@ -11,7 +11,7 @@ from pprint import pprint
if __name__ == "__main__": if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from pyhon import HonConnection from pyhon import HonAPI
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -85,7 +85,7 @@ def create_command(commands, concat=False):
async def translate(language, json_output=False): async def translate(language, json_output=False):
async with HonConnection() as hon: async with HonAPI() as hon:
keys = await hon.translation_keys(language) keys = await hon.translation_keys(language)
if json_output: if json_output:
print(json.dumps(keys, indent=4)) print(json.dumps(keys, indent=4))
@ -104,7 +104,7 @@ async def main():
user = input("User for hOn account: ") user = input("User for hOn account: ")
if not (password := args["password"]): if not (password := args["password"]):
password = getpass("Password for hOn account: ") 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: for device in hon.devices:
print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10) print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10)
if args.get("keys"): if args.get("keys"):

View file

@ -5,7 +5,7 @@ from pyhon.commands import HonCommand
from pyhon.parameter import HonParameterFixed from pyhon.parameter import HonParameterFixed
class HonDevice: class HonAppliance:
def __init__(self, connector, appliance): def __init__(self, connector, appliance):
if attributes := appliance.get("attributes"): if attributes := appliance.get("attributes"):
appliance["attributes"] = {v["parName"]: v["parValue"] for v in attributes} appliance["attributes"] = {v["parName"]: v["parValue"] for v in attributes}

View file

View file

@ -1,74 +1,58 @@
import asyncio import asyncio
import json import json
import logging import logging
import secrets
from datetime import datetime from datetime import datetime
from typing import List from typing import List
import aiohttp as aiohttp
from pyhon import const from pyhon import const
from pyhon.auth import HonAuth from pyhon.appliance import HonAppliance
from pyhon.device import HonDevice from pyhon.connection.connection import HonConnectionHandler, HonAnonymousConnectionHandler
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
class HonConnection: class HonAPI:
def __init__(self, email="", password="", session=None) -> None: def __init__(self, email="", password="") -> None:
super().__init__() super().__init__()
self._email = email self._email = email
self._password = password self._password = password
self._request_headers = {"Content-Type": "application/json"}
self._session = session
self._devices = [] self._devices = []
self._mobile_id = secrets.token_hex(8) self._hon = None
self._hon_anonymous = HonAnonymousConnectionHandler()
async def __aenter__(self): async def __aenter__(self):
self._session = aiohttp.ClientSession() self._hon = HonConnectionHandler(self._email, self._password)
if self._email and self._password: await self._hon.create()
await self.setup() await self.setup()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._session.close() await self._hon.close()
@property @property
def devices(self) -> List[HonDevice]: def devices(self) -> List[HonAppliance]:
return self._devices 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 def setup(self):
async with aiohttp.ClientSession() as session: async with self._hon.get(f"{const.API_URL}/commands/v1/appliance") as resp:
async with session.get(f"{const.API_URL}/commands/v1/appliance", try:
headers=await self._headers) as resp: appliances = (await resp.json())["payload"]["appliances"]
try: for appliance in appliances:
appliances = (await resp.json())["payload"]["appliances"] device = HonAppliance(self, appliance)
for appliance in appliances: if device.mac_address is None:
device = HonDevice(self, appliance) continue
if device.mac_address is None: await asyncio.gather(*[
continue device.load_attributes(),
await asyncio.gather(*[ device.load_commands(),
device.load_attributes(), device.load_statistics()])
device.load_commands(), self._devices.append(device)
device.load_statistics()]) except json.JSONDecodeError:
self._devices.append(device) _LOGGER.error("No JSON Data after GET: %s", await resp.text())
except json.JSONDecodeError: return False
_LOGGER.error("No JSON Data after GET: %s", await resp.text())
return False
return True return True
async def load_commands(self, device: HonDevice): async def load_commands(self, device: HonAppliance):
params = { params = {
"applianceType": device.appliance_type, "applianceType": device.appliance_type,
"code": device.appliance["code"], "code": device.appliance["code"],
@ -81,84 +65,46 @@ class HonConnection:
"series": device.appliance["series"], "series": device.appliance["series"],
} }
url = f"{const.API_URL}/commands/v1/retrieve" 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", {}) result = (await response.json()).get("payload", {})
if not result or result.pop("resultCode") != "0": if not result or result.pop("resultCode") != "0":
return {} return {}
return result 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" 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() result = await response.json()
if not result or not result.get("payload"): if not result or not result.get("payload"):
return {} return {}
return result["payload"]["history"] 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" url = f"{const.API_URL}/commands/v1/retrieve-last-activity"
params = {"macAddress": device.mac_address} 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() result = await response.json()
if result and (activity := result.get("attributes")): if result and (activity := result.get("attributes")):
return activity return activity
return {} return {}
async def appliance_configuration(self): async def load_attributes(self, device: HonAppliance):
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):
params = { params = {
"macAddress": device.mac_address, "macAddress": device.mac_address,
"applianceType": device.appliance_type, "applianceType": device.appliance_type,
"category": "CYCLE" "category": "CYCLE"
} }
url = f"{const.API_URL}/commands/v1/context" url = f"{const.API_URL}/commands/v1/context"
async with self._session.get(url, params=params, headers=await self._headers) as response: async with self._hon.get(url, params=params) 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)
return (await response.json()).get("payload", {}) return (await response.json()).get("payload", {})
async def load_statistics(self, device: HonDevice): async def load_statistics(self, device: HonAppliance):
params = { params = {
"macAddress": device.mac_address, "macAddress": device.mac_address,
"applianceType": device.appliance_type "applianceType": device.appliance_type
} }
url = f"{const.API_URL}/commands/v1/statistics" 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", {}) return (await response.json()).get("payload", {})
async def send_command(self, device, command, parameters, ancillary_parameters): async def send_command(self, device, command, parameters, ancillary_parameters):
@ -169,13 +115,7 @@ class HonConnection:
"commandName": command, "commandName": command,
"transactionId": f"{device.mac_address}_{now[:-3]}Z", "transactionId": f"{device.mac_address}_{now[:-3]}Z",
"applianceOptions": device.commands_options, "applianceOptions": device.commands_options,
"device": { "device": self._hon.device.get(),
"mobileId": self._mobile_id,
"mobileOs": const.OS,
"osVersion": const.OS_VERSION,
"appVersion": const.APP_VERSION,
"deviceModel": const.DEVICE_MODEL
},
"attributes": { "attributes": {
"channel": "mobileApp", "channel": "mobileApp",
"origin": "standardProgram", "origin": "standardProgram",
@ -186,7 +126,7 @@ class HonConnection:
"applianceType": device.appliance_type "applianceType": device.appliance_type
} }
url = f"{const.API_URL}/commands/v1/send" 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: try:
json_data = await resp.json() json_data = await resp.json()
except json.JSONDecodeError: except json.JSONDecodeError:
@ -194,3 +134,33 @@ class HonConnection:
if json_data["payload"]["resultCode"] == "0": if json_data["payload"]["resultCode"] == "0":
return True return True
return False 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 {}

View file

@ -3,9 +3,9 @@ import logging
import re import re
import secrets import secrets
import urllib import urllib
from pprint import pprint
from urllib import parse from urllib import parse
import aiohttp as aiohttp
from yarl import URL from yarl import URL
from pyhon import const from pyhon import const
@ -14,11 +14,15 @@ _LOGGER = logging.getLogger()
class HonAuth: 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._access_token = ""
self._refresh_token = "" self._refresh_token = ""
self._cognito_token = "" self._cognito_token = ""
self._id_token = "" self._id_token = ""
self._device = device
@property @property
def cognito_token(self): def cognito_token(self):
@ -36,7 +40,7 @@ class HonAuth:
def refresh_token(self): def refresh_token(self):
return self._refresh_token return self._refresh_token
async def _load_login(self, session): async def _load_login(self):
nonce = secrets.token_hex(16) nonce = secrets.token_hex(16)
nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}"
params = { params = {
@ -48,16 +52,16 @@ class HonAuth:
"nonce": nonce "nonce": nonce
} }
params = "&".join([f"{k}={v}" for k, v in params.items()]) 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())): if not (login_url := re.findall("url = '(.+?)'", await resp.text())):
return False 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")): if not (url := redirect1.headers.get("Location")):
return False 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"): if not (url := redirect2.headers.get("Location") + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn"):
return False 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()): if context := re.findall('"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text()):
fw_uid, loaded_str = context[0] fw_uid, loaded_str = context[0]
loaded = json.loads(loaded_str) loaded = json.loads(loaded_str)
@ -65,7 +69,7 @@ class HonAuth:
return fw_uid, loaded, login_url return fw_uid, loaded, login_url
return False return False
async def _login(self, session, email, password, fw_uid, loaded, login_url): async def _login(self, fw_uid, loaded, login_url):
data = { data = {
"message": { "message": {
"actions": [ "actions": [
@ -74,8 +78,8 @@ class HonAuth:
"descriptor": "apex://LightningLoginCustomController/ACTION$login", "descriptor": "apex://LightningLoginCustomController/ACTION$login",
"callingDescriptor": "markup://c:loginForm", "callingDescriptor": "markup://c:loginForm",
"params": { "params": {
"username": email, "username": self._email,
"password": password, "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]
} }
} }
@ -93,7 +97,7 @@ class HonAuth:
"aura.token": None} "aura.token": None}
params = {"r": 3, "other.LightningLoginCustom.login": 1} params = {"r": 3, "other.LightningLoginCustom.login": 1}
async with session.post( async with self._session.post(
const.AUTH_API + "/s/sfsites/aura", const.AUTH_API + "/s/sfsites/aura",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()), 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()) _LOGGER.error("Unable to login: %s\n%s", response.status, await response.text())
return "" return ""
async def _get_token(self, session, url): async def _get_token(self, url):
async with session.get(url) as resp: async with self._session.get(url) as resp:
if resp.status != 200: if resp.status != 200:
_LOGGER.error("Unable to get token: %s", resp.status) _LOGGER.error("Unable to get token: %s", resp.status)
return False return False
url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text()) 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: if resp.status != 200:
_LOGGER.error("Unable to get token: %s", resp.status) _LOGGER.error("Unable to get token: %s", resp.status)
return False return False
url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text()) url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text())
url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0] 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: if resp.status != 200:
_LOGGER.error("Unable to connect to the login service: %s", resp.status) _LOGGER.error("Unable to connect to the login service: %s", resp.status)
return False return False
@ -132,26 +136,23 @@ class HonAuth:
self._id_token = id_token[0] self._id_token = id_token[0]
return True return True
async def authorize(self, email, password, mobile_id): async def authorize(self):
headers = {"user-agent": const.USER_AGENT} if login_site := await self._load_login():
async with aiohttp.ClientSession(headers=headers) as session: fw_uid, loaded, login_url = login_site
if login_site := await self._load_login(session): else:
fw_uid, loaded, login_url = login_site return False
else: if not (url := await self._login(fw_uid, loaded, login_url)):
return False return False
if not (url := await self._login(session, email, password, fw_uid, loaded, login_url)): if not await self._get_token(url):
return False return False
if not await self._get_token(session, url):
return False
post_headers = {"Content-Type": "application/json", "id-token": self._id_token} post_headers = {"Content-Type": "application/json", "id-token": self._id_token}
data = {"appVersion": const.APP_VERSION, "mobileId": mobile_id, "osVersion": const.OS_VERSION, data = self._device.get()
"os": const.OS, "deviceModel": const.DEVICE_MODEL} async with self._session.post(f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data) as resp:
async with session.post(f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data) as resp: try:
try: json_data = await resp.json()
json_data = await resp.json() except json.JSONDecodeError:
except json.JSONDecodeError: _LOGGER.error("No JSON Data after POST: %s", await resp.text())
_LOGGER.error("No JSON Data after POST: %s", await resp.text()) return False
return False self._cognito_token = json_data["cognitoUser"]["Token"]
self._cognito_token = json_data["cognitoUser"]["Token"]
return True return True

View file

@ -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

View file

@ -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}