Compare commits

..

31 Commits

Author SHA1 Message Date
b37715d0ca Bump version to 0.8.0 2023-04-19 17:37:55 +02:00
411effd814 Fix disappearing programs 2023-04-17 00:37:55 +02:00
04f19c4609 Fix error when sending commands 2023-04-17 00:07:22 +02:00
69be63df2a Bump version to v0.8.0b5 2023-04-16 13:57:40 +02:00
6c44aa895d Disable zones for devices with just 1 zone, fixes #11 2023-04-16 13:33:21 +02:00
8372c75e30 expose ancillary parameter in settings 2023-04-16 13:11:56 +02:00
5b91747ec1 Add dish washer 2023-04-16 02:46:30 +02:00
8da2018302 Set fixed values 2023-04-16 02:44:20 +02:00
03187745bf Split up parameters 2023-04-16 01:43:37 +02:00
461a247ad3 More type hints 2023-04-16 01:36:10 +02:00
834f25a639 Remove filters, filter out recies #9 2023-04-16 00:40:59 +02:00
46ff9be4a2 Fix code depts 2023-04-15 23:02:37 +02:00
a1618bb18a Fix missing zone attribute 2023-04-15 22:25:34 +02:00
a957d7ac0f Fix error for zone devices 2023-04-15 21:58:20 +02:00
f54b7b2dbf Add mypy checks 2023-04-15 15:57:36 +02:00
b6ca12ebff Use dataclass for login data 2023-04-15 14:37:27 +02:00
4a0ee8569b Refactor, add hon auth handler 2023-04-15 14:22:04 +02:00
d52d622785 Add zone support 2023-04-15 04:16:50 +02:00
9643f66549 Bump version to v0.7.4 2023-04-15 00:31:45 +02:00
d26e33a055 Fix error in starting programs 2023-04-15 00:29:24 +02:00
0301427497 Remove coords from diagnose 2023-04-14 23:24:31 +02:00
272556586e Refresh token workaround because expires to fast 2023-04-14 23:15:27 +02:00
e82c14ec99 Add some type hints 2023-04-13 23:35:43 +02:00
970b94bfa7 Fix unclear session errors 2023-04-12 19:14:50 +02:00
33454f68b8 Encode username/password 2023-04-12 02:10:37 +02:00
6b2c60d552 Fix session issues 2023-04-12 01:07:33 +02:00
46e6a85e84 add diagnose property for devices 2023-04-11 22:14:36 +02:00
8c832b44cd Fix token refresh problems 2023-04-11 17:09:02 +02:00
b4b782c52c Improve logging for authentication errors 2023-04-11 00:59:00 +02:00
e857fe91de Fix login issue 2023-04-10 20:32:35 +02:00
79c9678492 Fix float convertion 2023-04-10 18:48:46 +02:00
30 changed files with 979 additions and 650 deletions

View File

@ -2,7 +2,7 @@ name: Python check
on: on:
push: push:
branches: [ "main", "refactor" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
@ -25,12 +25,15 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
python -m pip install flake8 pylint black python -m pip install -r requirements_dev.txt
- name: Lint with flake8 - name: Lint with flake8
run: | run: |
# stop the build if there are Python syntax errors or undefined names # stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics
- name: Type check with mypy
run: |
mypy pyhon/
# - name: Analysing the code with pylint # - name: Analysing the code with pylint
# run: | # run: |
# pylint --max-line-length 88 $(git ls-files '*.py') # pylint --max-line-length 88 $(git ls-files '*.py')

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ __pycache__/
dist/ dist/
**/*.egg-info/ **/*.egg-info/
test* test*
build/

View File

@ -6,7 +6,7 @@
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyhOn)](https://www.python.org/) [![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 - 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) [![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. The idea behind this library is, to make the use of all available commands as simple as possible.
## Installation ## Installation
@ -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 fr > hon_fr.yaml
$ pyhOn translate en --json > hon_en.json $ pyhOn translate en --json > hon_en.json
``` ```
## Tested devices
- Haier Washing Machine HW90
## Usage example ## Usage example
This library is used for the custom [HomeAssistant Integration "Haier hOn"](https://github.com/Andre0512/hOn). This library is used for the custom [HomeAssistant Integration "Haier hOn"](https://github.com/Andre0512/hOn).

View File

@ -10,7 +10,7 @@ from pathlib import Path
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 Hon, HonAPI from pyhon import Hon, HonAPI, helper
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -34,61 +34,6 @@ def get_arguments():
return vars(parser.parse_args()) 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 def translate(language, json_output=False):
async with HonAPI(anonymous=True) as hon: async with HonAPI(anonymous=True) as hon:
keys = await hon.translation_keys(language) keys = await hon.translation_keys(language)
@ -102,7 +47,7 @@ async def translate(language, json_output=False):
.replace("\\r", "") .replace("\\r", "")
) )
keys = json.loads(clean_keys) keys = json.loads(clean_keys)
pretty_print(keys) print(helper.pretty_print(keys))
async def main(): async def main():
@ -120,13 +65,25 @@ async def main():
if args.get("keys"): if args.get("keys"):
data = device.data.copy() data = device.data.copy()
attr = "get" if args.get("all") else "pop" attr = "get" if args.get("all") else "pop"
key_print(data["attributes"].__getattribute__(attr)("parameters")) print(
key_print(data.__getattribute__(attr)("appliance")) helper.key_print(
key_print(data) data["attributes"].__getattribute__(attr)("parameters")
pretty_print(create_command(device.commands, concat=True)) )
)
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: else:
pretty_print({"data": device.data}) print(helper.pretty_print({"data": device.data}))
pretty_print({"settings": create_command(device.commands)}) print(
helper.pretty_print(
{"settings": helper.create_command(device.commands)}
)
)
def start(): def start():

View File

@ -1,21 +1,30 @@
import importlib import importlib
from contextlib import suppress from contextlib import suppress
from typing import Optional, Dict, Any
from typing import TYPE_CHECKING
from pyhon import helper
from pyhon.commands import HonCommand from pyhon.commands import HonCommand
from pyhon.parameter import HonParameterFixed from pyhon.parameter.fixed import HonParameterFixed
if TYPE_CHECKING:
from pyhon import HonAPI
class HonAppliance: class HonAppliance:
def __init__(self, api, info): def __init__(
self, api: Optional["HonAPI"], info: Dict[str, Any], zone: int = 0
) -> None:
if attributes := info.get("attributes"): if attributes := info.get("attributes"):
info["attributes"] = {v["parName"]: v["parValue"] for v in attributes} info["attributes"] = {v["parName"]: v["parValue"] for v in attributes}
self._info = info self._info: Dict = info
self._api = api self._api: Optional[HonAPI] = api
self._appliance_model = {} self._appliance_model: Dict = {}
self._commands = {} self._commands: Dict = {}
self._statistics = {} self._statistics: Dict = {}
self._attributes = {} self._attributes: Dict = {}
self._zone: int = zone
try: try:
self._extra = importlib.import_module( self._extra = importlib.import_module(
@ -25,20 +34,21 @@ class HonAppliance:
self._extra = None self._extra = None
def __getitem__(self, item): def __getitem__(self, item):
if self._zone:
item += f"Z{self._zone}"
if "." in item: if "." in item:
result = self.data result = self.data
for key in item.split("."): 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)] result = result[int(key)]
else: else:
result = result[key] result = result[key]
return result return result
else: if item in self.data:
if item in self.data: return self.data[item]
return self.data[item] if item in self.attributes["parameters"]:
if item in self.attributes["parameters"]: return self.attributes["parameters"].get(item)
return self.attributes["parameters"].get(item) return self.info[item]
return self.info[item]
def get(self, item, default=None): def get(self, item, default=None):
try: try:
@ -46,25 +56,35 @@ class HonAppliance:
except (KeyError, IndexError): except (KeyError, IndexError):
return default return default
@property def _check_name_zone(self, name: str, frontend: bool = True) -> str:
def appliance_model_id(self): middle = " Z" if frontend else "_z"
return self._info.get("applianceModelId") if (attribute := self._info.get(name, "")) and self._zone:
return f"{attribute}{middle}{self._zone}"
return attribute
@property @property
def appliance_type(self): def appliance_model_id(self) -> str:
return self._info.get("applianceTypeName") return self._info.get("applianceModelId", "")
@property @property
def mac_address(self): def appliance_type(self) -> str:
return self._info.get("macAddress") return self._info.get("applianceTypeName", "")
@property @property
def model_name(self): def mac_address(self) -> str:
return self._info.get("modelName") return self.info.get("macAddress", "")
@property @property
def nick_name(self): def unique_id(self) -> str:
return self._info.get("nickName") return self._check_name_zone("macAddress", frontend=False)
@property
def model_name(self) -> str:
return self._check_name_zone("modelName")
@property
def nick_name(self) -> str:
return self._check_name_zone("nickName")
@property @property
def commands_options(self): def commands_options(self):
@ -86,6 +106,10 @@ class HonAppliance:
def info(self): def info(self):
return self._info return self._info
@property
def zone(self) -> int:
return self._zone
async def _recover_last_command_states(self, commands): async def _recover_last_command_states(self, commands):
command_history = await self._api.command_history(self) command_history = await self._api.command_history(self)
for name, command in commands.items(): for name, command in commands.items():
@ -100,8 +124,8 @@ class HonAppliance:
if last is None: if last is None:
continue continue
parameters = command_history[last].get("command", {}).get("parameters", {}) parameters = command_history[last].get("command", {}).get("parameters", {})
if command._multi and parameters.get("program"): if command.programs and parameters.get("program"):
command.set_program(parameters.pop("program").split(".")[-1].lower()) command.program = parameters.pop("program").split(".")[-1].lower()
command = self.commands[name] command = self.commands[name]
for key, data in command.settings.items(): for key, data in command.settings.items():
if ( if (
@ -125,7 +149,12 @@ class HonAppliance:
for program, attr2 in attr.items(): for program, attr2 in attr.items():
program = program.split(".")[-1].lower() program = program.split(".")[-1].lower()
cmd = HonCommand( cmd = HonCommand(
command, attr2, self._api, self, multi=multi, program=program command,
attr2,
self._api,
self,
programs=multi,
program_name=program,
) )
multi[program] = cmd multi[program] = cmd
commands[command] = cmd commands[command] = cmd
@ -146,7 +175,9 @@ class HonAppliance:
def parameters(self): def parameters(self):
result = {} result = {}
for name, command in self._commands.items(): 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 result.setdefault(name, {})[key] = parameter.value
return result return result
@ -172,3 +203,15 @@ class HonAppliance:
if self._extra: if self._extra:
return self._extra.data(result) return self._extra.data(result)
return result return result
@property
def diagnose(self):
data = self.data.copy()
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(
{"commands": helper.create_command(self.commands)},
whitespace="\u200B \u200B ",
)
return result.replace(self.mac_address, "12-34-56-78-90-ab")

9
pyhon/appliances/dw.py Normal file
View File

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

View File

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

View File

@ -1,30 +1,47 @@
from pyhon.parameter import ( from typing import Optional, Dict, Any, List, TYPE_CHECKING
HonParameterFixed,
HonParameterEnum, from pyhon.parameter.base import HonParameter
HonParameterRange, from pyhon.parameter.enum import HonParameterEnum
HonParameterProgram, 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
from pyhon.appliance import HonAppliance
class HonCommand: class HonCommand:
def __init__(self, name, attributes, connector, device, multi=None, program=""): def __init__(
self._connector = connector self,
self._device = device name: str,
self._name = name attributes: Dict[str, Any],
self._multi = multi or {} api: "HonAPI",
self._program = program appliance: "HonAppliance",
self._description = attributes.get("description", "") programs: Optional[Dict[str, "HonCommand"]] = None,
self._parameters = self._create_parameters(attributes.get("parameters", {})) program_name: str = "",
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", {}) attributes.get("ancillaryParameters", {})
) )
def __repr__(self): def __repr__(self) -> str:
return f"{self._name} command" return f"{self._name} command"
def _create_parameters(self, parameters): def _create_parameters(self, parameters: Dict) -> Dict[str, HonParameter]:
result = {} result: Dict[str, HonParameter] = {}
for parameter, attributes in parameters.items(): for parameter, attributes in parameters.items():
if parameter == "zoneMap" and self._appliance.zone:
attributes["default"] = self._appliance.zone
match attributes.get("typology"): match attributes.get("typology"):
case "range": case "range":
result[parameter] = HonParameterRange(parameter, attributes) result[parameter] = HonParameterRange(parameter, attributes)
@ -32,39 +49,46 @@ class HonCommand:
result[parameter] = HonParameterEnum(parameter, attributes) result[parameter] = HonParameterEnum(parameter, attributes)
case "fixed": case "fixed":
result[parameter] = HonParameterFixed(parameter, attributes) result[parameter] = HonParameterFixed(parameter, attributes)
if self._multi: if self._programs:
result["program"] = HonParameterProgram("program", self) result["program"] = HonParameterProgram("program", self)
return result return result
@property @property
def parameters(self): def parameters(self) -> Dict[str, HonParameter]:
return self._parameters return self._parameters
@property @property
def ancillary_parameters(self): def ancillary_parameters(self) -> Dict[str, HonParameter]:
return { return self._ancillary_parameters
key: parameter.value
for key, parameter in self._ancillary_parameters.items()
}
async def send(self): async def send(self) -> bool:
parameters = { params = {k: v.value for k, v in self._parameters.items()}
name: parameter.value for name, parameter in self._parameters.items() ancillary_params = {k: v.value for k, v in self._ancillary_parameters.items()}
} return await self._api.send_command(
return await self._connector.send_command( self._appliance, self._name, params, ancillary_params
self._device, self._name, parameters, self.ancillary_parameters
) )
def get_programs(self): @property
return self._multi def programs(self) -> Dict[str, "HonCommand"]:
if self._programs is None:
return {}
return self._programs
def set_program(self, program): @property
self._device.commands[self._name] = self._multi[program] def program(self) -> str:
return self._program_name
def _get_settings_keys(self, command=None): @program.setter
command = command or self def program(self, program: str) -> None:
self._appliance.commands[self._name] = self.programs[program]
def _get_settings_keys(self, command: Optional["HonCommand"] = None) -> List[str]:
if command is None:
command = self
keys = [] keys = []
for key, parameter in command._parameters.items(): for key, parameter in (
command._parameters | command._ancillary_parameters
).items():
if isinstance(parameter, HonParameterFixed): if isinstance(parameter, HonParameterFixed):
continue continue
if key not in keys: if key not in keys:
@ -72,19 +96,22 @@ class HonCommand:
return keys return keys
@property @property
def setting_keys(self): def setting_keys(self) -> List[str]:
if not self._multi: if not self._programs:
return self._get_settings_keys() return self._get_settings_keys()
result = [ 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"])) return list(set(result + ["program"]))
@property @property
def settings(self): def settings(self) -> Dict[str, HonParameter]:
"""Parameters with typology enum and range""" """Parameters with typology enum and range"""
return { return {
s: self._parameters.get(s) s: param
for s in self.setting_keys for s in self.setting_keys
if self._parameters.get(s) is not None if (param := self._parameters.get(s)) is not None
or (param := self._ancillary_parameters.get(s)) is not None
} }

View File

@ -1,46 +1,76 @@
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, Optional
from pyhon import const from aiohttp import ClientSession
from typing_extensions import Self
from pyhon import const, exceptions
from pyhon.appliance import HonAppliance from pyhon.appliance import HonAppliance
from pyhon.connection.handler import HonConnectionHandler, HonAnonymousConnectionHandler from pyhon.connection.auth import HonAuth
from pyhon.connection.handler.anonym import HonAnonymousConnectionHandler
from pyhon.connection.handler.hon import HonConnectionHandler
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
class HonAPI: 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__() super().__init__()
self._email = email self._email: str = email
self._password = password self._password: str = password
self._anonymous = anonymous self._anonymous: bool = anonymous
self._hon = None self._hon_handler: Optional[HonConnectionHandler] = None
self._hon_anonymous = None self._hon_anonymous_handler: Optional[HonAnonymousConnectionHandler] = None
self._session = session self._session: Optional[ClientSession] = session
async def __aenter__(self): async def __aenter__(self) -> Self:
return await self.create() 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() await self.close()
async def create(self): @property
self._hon_anonymous = await HonAnonymousConnectionHandler( 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 self._session
).create() ).create()
if not self._anonymous: if not self._anonymous:
self._hon = await HonConnectionHandler( self._hon_handler = await HonConnectionHandler(
self._email, self._password, self._session self._email, self._password, self._session
).create() ).create()
return self 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: async with self._hon.get(f"{const.API_URL}/commands/v1/appliance") as resp:
return await resp.json() return await resp.json()
async def load_commands(self, appliance: HonAppliance): async def load_commands(self, appliance: HonAppliance) -> Dict:
params = { params: Dict = {
"applianceType": appliance.appliance_type, "applianceType": appliance.appliance_type,
"code": appliance.info["code"], "code": appliance.info["code"],
"applianceModelId": appliance.appliance_model_id, "applianceModelId": appliance.appliance_model_id,
@ -51,58 +81,66 @@ class HonAPI:
"appVersion": const.APP_VERSION, "appVersion": const.APP_VERSION,
"series": appliance.info["series"], "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: 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": if not result or result.pop("resultCode") != "0":
return {} return {}
return result return result
async def command_history(self, appliance: HonAppliance): async def command_history(self, appliance: HonAppliance) -> Dict:
url = f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/history" url: str = (
f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/history"
)
async with self._hon.get(url) as response: 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"): if not result or not result.get("payload"):
return {} return {}
return result["payload"]["history"] return result["payload"]["history"]
async def last_activity(self, appliance: HonAppliance): async def last_activity(self, appliance: HonAppliance) -> Dict:
url = f"{const.API_URL}/commands/v1/retrieve-last-activity" url: str = f"{const.API_URL}/commands/v1/retrieve-last-activity"
params = {"macAddress": appliance.mac_address} params: Dict = {"macAddress": appliance.mac_address}
async with self._hon.get(url, params=params) as response: 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")): if result and (activity := result.get("attributes")):
return activity return activity
return {} return {}
async def load_attributes(self, appliance: HonAppliance): async def load_attributes(self, appliance: HonAppliance) -> Dict:
params = { params: Dict = {
"macAddress": appliance.mac_address, "macAddress": appliance.mac_address,
"applianceType": appliance.appliance_type, "applianceType": appliance.appliance_type,
"category": "CYCLE", "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: async with self._hon.get(url, params=params) as response:
return (await response.json()).get("payload", {}) return (await response.json()).get("payload", {})
async def load_statistics(self, appliance: HonAppliance): async def load_statistics(self, appliance: HonAppliance) -> Dict:
params = { params: Dict = {
"macAddress": appliance.mac_address, "macAddress": appliance.mac_address,
"applianceType": appliance.appliance_type, "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: 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, appliance, command, parameters, ancillary_parameters): async def send_command(
now = datetime.utcnow().isoformat() self,
data = { appliance: HonAppliance,
command: str,
parameters: Dict,
ancillary_parameters: Dict,
) -> bool:
now: str = datetime.utcnow().isoformat()
data: Dict = {
"macAddress": appliance.mac_address, "macAddress": appliance.mac_address,
"timestamp": f"{now[:-3]}Z", "timestamp": f"{now[:-3]}Z",
"commandName": command, "commandName": command,
"transactionId": f"{appliance.mac_address}_{now[:-3]}Z", "transactionId": f"{appliance.mac_address}_{now[:-3]}Z",
"applianceOptions": appliance.commands_options, "applianceOptions": appliance.commands_options,
"appliance": self._hon.device.get(), "device": self._hon.device.get(mobile=True),
"attributes": { "attributes": {
"channel": "mobileApp", "channel": "mobileApp",
"origin": "standardProgram", "origin": "standardProgram",
@ -112,36 +150,37 @@ class HonAPI:
"parameters": parameters, "parameters": parameters,
"applianceType": appliance.appliance_type, "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: async with self._hon.post(url, json=data) as response:
json_data = await resp.json() json_data: Dict = await response.json()
if json_data.get("payload", {}).get("resultCode") == "0": if json_data.get("payload", {}).get("resultCode") == "0":
return True return True
_LOGGER.error(await response.text())
return False return False
async def appliance_configuration(self): async def appliance_configuration(self) -> Dict:
url = f"{const.API_URL}/config/v1/appliance-configuration" url: str = f"{const.API_URL}/config/v1/appliance-configuration"
async with self._hon_anonymous.get(url) as response: 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")): if result and (data := result.get("payload")):
return data return data
return {} return {}
async def app_config(self, language="en", beta=True): async def app_config(self, language: str = "en", beta: bool = True) -> Dict:
url = f"{const.API_URL}/app-config" url: str = f"{const.API_URL}/app-config"
payload = { payload_data: Dict = {
"languageCode": language, "languageCode": language,
"beta": beta, "beta": beta,
"appVersion": const.APP_VERSION, "appVersion": const.APP_VERSION,
"os": const.OS, "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: async with self._hon_anonymous.post(url, data=payload) as response:
if (result := await response.json()) and (data := result.get("payload")): if (result := await response.json()) and (data := result.get("payload")):
return data return data
return {} return {}
async def translation_keys(self, language="en"): async def translation_keys(self, language: str = "en") -> Dict:
config = await self.app_config(language=language) config = await self.app_config(language=language)
if url := config.get("language", {}).get("jsonPath"): if url := config.get("language", {}).get("jsonPath"):
async with self._hon_anonymous.get(url) as response: async with self._hon_anonymous.get(url) as response:
@ -149,8 +188,8 @@ class HonAPI:
return result return result
return {} return {}
async def close(self): async def close(self) -> None:
if self._hon: if self._hon_handler is not None:
await self._hon.close() await self._hon_handler.close()
if self._hon_anonymous: if self._hon_anonymous_handler is not None:
await self._hon_anonymous.close() await self._hon_anonymous_handler.close()

View File

@ -3,198 +3,270 @@ import logging
import re import re
import secrets import secrets
import urllib import urllib
from pprint import pformat from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, Optional
from urllib import parse from urllib import parse
from urllib.parse import quote
from aiohttp import ClientResponse
from yarl import URL from yarl import URL
from pyhon import const from pyhon import const, exceptions
from pyhon.connection.handler.auth import HonAuthConnectionHandler
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
@dataclass
class HonLoginData:
url: str = ""
email: str = ""
password: str = ""
fw_uid: str = ""
loaded: Optional[Dict] = None
class HonAuth: class HonAuth:
_TOKEN_EXPIRES_AFTER_HOURS = 8
_TOKEN_EXPIRE_WARNING_HOURS = 7
def __init__(self, session, email, password, device) -> None: def __init__(self, session, email, password, device) -> None:
self._session = session self._session = session
self._email = email self._request = HonAuthConnectionHandler(session)
self._password = password self._login_data = HonLoginData()
self._login_data.email = email
self._login_data.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 self._device = device
self._expires: datetime = datetime.utcnow()
@property @property
def cognito_token(self): def cognito_token(self) -> str:
return self._cognito_token return self._cognito_token
@property @property
def id_token(self): def id_token(self) -> str:
return self._id_token return self._id_token
@property @property
def access_token(self): def access_token(self) -> str:
return self._access_token return self._access_token
@property @property
def refresh_token(self): def refresh_token(self) -> str:
return self._refresh_token return self._refresh_token
async def _load_login(self): def _check_token_expiration(self, hours: int) -> bool:
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: 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")
@staticmethod
def _generate_nonce() -> str:
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:]}" return f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}"
async def _load_login(self) -> bool:
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 = { params = {
"response_type": "token+id_token", "response_type": "token+id_token",
"client_id": const.CLIENT_ID, "client_id": const.CLIENT_ID,
"redirect_uri": urllib.parse.quote( "redirect_uri": redirect_uri,
f"{const.APP}://mobilesdk/detect/oauth/done"
),
"display": "touch", "display": "touch",
"scope": "api openid refresh_token web", "scope": "api openid refresh_token web",
"nonce": nonce, "nonce": self._generate_nonce(),
} }
params = "&".join([f"{k}={v}" for k, v in params.items()]) params_encode = "&".join([f"{k}={v}" for k, v in params.items()])
async with self._session.get( url = f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params_encode}"
f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}" async with self._request.get(url) as response:
) as resp: text = await response.text()
if not (login_url := re.findall("url = '(.+?)'", await resp.text())): self._expires = datetime.utcnow()
return False if not (login_url := re.findall("url = '(.+?)'", text)):
async with self._session.get(login_url[0], allow_redirects=False) as redirect1: if "oauth/done#access_token=" in text:
if not (url := redirect1.headers.get("Location")): self._parse_token_data(text)
return False raise exceptions.HonNoAuthenticationNeeded()
async with self._session.get(url, allow_redirects=False) as redirect2: await self._error_logger(response)
if not ( return login_url[0]
url := redirect2.headers.get("Location")
+ "&System=IoT_Mobile_App&RegistrationSubChannel=hOn" async def _manual_redirect(self, url: str) -> str:
): async with self._request.get(url, allow_redirects=False) as response:
return False if not (new_location := response.headers.get("Location", "")):
async with self._session.get(URL(url, encoded=True)) as login_screen: await self._error_logger(response)
if context := re.findall( return new_location
'"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text()
): 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) -> 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] fw_uid, loaded_str = context[0]
loaded = json.loads(loaded_str) self._login_data.fw_uid = fw_uid
login_url = login_url[0].replace( self._login_data.loaded = json.loads(loaded_str)
self._login_data.url = login_url.replace(
"/".join(const.AUTH_API.split("/")[:-1]), "" "/".join(const.AUTH_API.split("/")[:-1]), ""
) )
return fw_uid, loaded, login_url return True
await self._error_logger(response)
return False return False
async def _login(self, fw_uid, loaded, login_url): async def _login(self) -> str:
data = { start_url = self._login_data.url.rsplit("startURL=", maxsplit=1)[-1]
"message": { start_url = parse.unquote(start_url).split("%3D")[0]
"actions": [ action = {
{ "id": "79;a",
"id": "79;a", "descriptor": "apex://LightningLoginCustomController/ACTION$login",
"descriptor": "apex://LightningLoginCustomController/ACTION$login", "callingDescriptor": "markup://c:loginForm",
"callingDescriptor": "markup://c:loginForm", "params": {
"params": { "username": quote(self._login_data.email),
"username": self._email, "password": quote(self._login_data.password),
"password": self._password, "startUrl": start_url,
"startUrl": parse.unquote(
login_url.split("startURL=")[-1]
).split("%3D")[0],
},
}
]
}, },
}
data = {
"message": {"actions": [action]},
"aura.context": { "aura.context": {
"mode": "PROD", "mode": "PROD",
"fwuid": fw_uid, "fwuid": self._login_data.fw_uid,
"app": "siteforce:loginApp2", "app": "siteforce:loginApp2",
"loaded": loaded, "loaded": self._login_data.loaded,
"dn": [], "dn": [],
"globals": {}, "globals": {},
"uad": False, "uad": False,
}, },
"aura.pageURI": login_url, "aura.pageURI": self._login_data.url,
"aura.token": None, "aura.token": None,
} }
params = {"r": 3, "other.LightningLoginCustom.login": 1} params = {"r": 3, "other.LightningLoginCustom.login": 1}
async with self._session.post( async with self._request.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()),
params=params, params=params,
) as response: ) as response:
if response.status == 200: if response.status == 200:
try: with suppress(json.JSONDecodeError, KeyError):
data = await response.json() result = await response.json()
return data["events"][0]["attributes"]["values"]["url"] return result["events"][0]["attributes"]["values"]["url"]
except json.JSONDecodeError: await self._error_logger(response)
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()
)
return "" return ""
async def _get_token(self, url): def _parse_token_data(self, text: str) -> None:
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*[\"'](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)
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()
if access_token := re.findall("access_token=(.*?)&", text): if access_token := re.findall("access_token=(.*?)&", text):
self._access_token = access_token[0] self._access_token = access_token[0]
if refresh_token := re.findall("refresh_token=(.*?)&", text): if refresh_token := re.findall("refresh_token=(.*?)&", text):
self._refresh_token = refresh_token[0] self._refresh_token = refresh_token[0]
if id_token := re.findall("id_token=(.*?)&", text): if id_token := re.findall("id_token=(.*?)&", text):
self._id_token = id_token[0] self._id_token = id_token[0]
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_search = re.findall(
"href\\s*=\\s*[\"'](.+?)[\"']", await response.text()
)
if not url_search:
await self._error_logger(response)
return False
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_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)
return False
self._parse_token_data(await response.text())
return True return True
async def authorize(self): async def _api_auth(self) -> bool:
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 = {"id-token": self._id_token} post_headers = {"id-token": self._id_token}
data = self._device.get() 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 f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data
) as resp: ) as response:
try: try:
json_data = await resp.json() json_data = await response.json()
except json.JSONDecodeError: except json.JSONDecodeError:
_LOGGER.error("No JSON Data after POST: %s", await resp.text()) await self._error_logger(response)
return False return False
self._cognito_token = json_data["cognitoUser"]["Token"] self._cognito_token = json_data["cognitoUser"]["Token"]
return True return True
async def refresh(self): async def authenticate(self) -> None:
self.clear()
try:
if not await self._load_login():
raise exceptions.HonAuthenticationError("Can't open login page")
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")
if not await self._api_auth():
raise exceptions.HonAuthenticationError("Can't get api token")
except exceptions.HonNoAuthenticationNeeded:
return
async def refresh(self) -> bool:
params = { params = {
"client_id": const.CLIENT_ID, "client_id": const.CLIENT_ID,
"refresh_token": self._refresh_token, "refresh_token": self._refresh_token,
"grant_type": "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 f"{const.AUTH_API}/services/oauth2/token", params=params
) as resp: ) as response:
if resp.status >= 400: if response.status >= 400:
await self._error_logger(response, fail=False)
return False return False
data = await resp.json() data = await response.json()
self._expires = datetime.utcnow()
self._id_token = data["id_token"] self._id_token = data["id_token"]
self._access_token = data["access_token"] self._access_token = data["access_token"]
return True return await self._api_auth()
def clear(self) -> None:
self._session.cookie_jar.clear_domain(const.AUTH_API.split("/")[-2])
self._request.called_urls = []
self._cognito_token = ""
self._id_token = ""
self._access_token = ""
self._refresh_token = ""

View File

@ -1,41 +1,43 @@
import secrets import secrets
from typing import Dict
from pyhon import const from pyhon import const
class HonDevice: class HonDevice:
def __init__(self): def __init__(self) -> None:
self._app_version = const.APP_VERSION self._app_version: str = const.APP_VERSION
self._os_version = const.OS_VERSION self._os_version: int = const.OS_VERSION
self._os = const.OS self._os: str = const.OS
self._device_model = const.DEVICE_MODEL self._device_model: str = const.DEVICE_MODEL
self._mobile_id = secrets.token_hex(8) self._mobile_id: str = secrets.token_hex(8)
@property @property
def app_version(self): def app_version(self) -> str:
return self._app_version return self._app_version
@property @property
def os_version(self): def os_version(self) -> int:
return self._os_version return self._os_version
@property @property
def os(self): def os(self) -> str:
return self._os return self._os
@property @property
def device_model(self): def device_model(self) -> str:
return self._device_model return self._device_model
@property @property
def mobile_id(self): def mobile_id(self) -> str:
return self._mobile_id return self._mobile_id
def get(self): def get(self, mobile: bool = False) -> Dict:
return { result = {
"appVersion": self.app_version, "appVersion": self.app_version,
"mobileId": self.mobile_id, "mobileId": self.mobile_id,
"osVersion": self.os_version,
"os": self.os, "os": self.os,
"osVersion": self.os_version,
"deviceModel": self.device_model, "deviceModel": self.device_model,
} }
return (result | {"mobileOs": result.pop("os")}) if mobile else result

View File

@ -1,127 +0,0 @@
import json
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, session=None):
self._session = session
self._auth = None
async def __aenter__(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 _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):
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, session=None):
super().__init__(session=session)
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)
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 PermissionError("Can't Login")
return {h: v for h, v in self._request_headers.items() if h not in 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:
_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._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("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(),
)
yield {}
class HonAnonymousConnectionHandler(HonBaseConnectionHandler):
_HEADERS = HonBaseConnectionHandler._HEADERS | {"x-api-key": const.API_KEY}
@asynccontextmanager
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

View File

View File

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

View File

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

View File

@ -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[aiohttp.ClientResponse]:
if self._session is None:
raise exceptions.NoSessionException()
response: aiohttp.ClientResponse
async with self._intercept(self._session.get, *args, **kwargs) as response:
yield response
@asynccontextmanager
async def post(self, *args, **kwargs) -> AsyncIterator[aiohttp.ClientResponse]:
if self._session is None:
raise exceptions.NoSessionException()
response: aiohttp.ClientResponse
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()

View File

@ -0,0 +1,102 @@
import json
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.connection.auth import HonAuth
from pyhon.connection.device import HonDevice
from pyhon.connection.handler.base import ConnectionHandler
from pyhon.exceptions import HonAuthenticationError, NoAuthenticationException
_LOGGER = logging.getLogger(__name__)
class HonConnectionHandler(ConnectionHandler):
def __init__(
self, email: str, password: str, session: Optional[aiohttp.ClientSession] = None
) -> None:
super().__init__(session=session)
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")
self._auth: Optional[HonAuth] = None
@property
def auth(self) -> HonAuth:
if self._auth is None:
raise NoAuthenticationException()
return self._auth
@property
def device(self) -> HonDevice:
return self._device
async def create(self) -> Self:
await super().create()
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
return self._HEADERS | headers
@asynccontextmanager
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 (
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 (
self.auth.token_is_expired or response.status in [401, 403]
) and loop == 1:
_LOGGER.warning(
"%s - Error %s - %s",
response.request_info.url,
response.status,
await response.text(),
)
await self.create()
async with self._intercept(
method, *args, loop=loop + 1, **kwargs
) as result:
yield result
elif loop >= 2:
_LOGGER.error(
"%s - Error %s - %s",
response.request_info.url,
response.status,
await response.text(),
)
raise HonAuthenticationError("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(),
)
raise HonAuthenticationError("Decode Error")

14
pyhon/exceptions.py Normal file
View File

@ -0,0 +1,14 @@
class HonAuthenticationError(Exception):
pass
class HonNoAuthenticationNeeded(Exception):
pass
class NoSessionException(Exception):
pass
class NoAuthenticationException(Exception):
pass

63
pyhon/helper.py Normal file
View File

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

View File

@ -1,25 +1,40 @@
import asyncio import asyncio
from typing import List from types import TracebackType
from typing import List, Optional, Dict, Any, Type
from pyhon import HonAPI from aiohttp import ClientSession
from typing_extensions import Self
from pyhon import HonAPI, exceptions
from pyhon.appliance import HonAppliance from pyhon.appliance import HonAppliance
class Hon: class Hon:
def __init__(self, email, password, session=None): def __init__(self, email: str, password: str, session: ClientSession | None = None):
self._email = email self._email: str = email
self._password = password self._password: str = password
self._session = session self._session: ClientSession | None = session
self._appliances = [] self._appliances: List[HonAppliance] = []
self._api = None self._api: Optional[HonAPI] = None
async def __aenter__(self): async def __aenter__(self) -> Self:
return await self.create() 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() 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._api = await HonAPI(
self._email, self._password, session=self._session self._email, self._password, session=self._session
).create() ).create()
@ -30,19 +45,26 @@ class Hon:
def appliances(self) -> List[HonAppliance]: def appliances(self) -> List[HonAppliance]:
return self._appliances return self._appliances
async def setup(self): async def _create_appliance(self, appliance_data: Dict[str, Any], zone=0) -> None:
for appliance in (await self._api.load_appliances())["payload"]["appliances"]: appliance = HonAppliance(self._api, appliance_data, zone=zone)
appliance = HonAppliance(self._api, appliance) if appliance.mac_address is None:
if appliance.mac_address is None: return
continue await asyncio.gather(
await asyncio.gather( *[
*[ appliance.load_attributes(),
appliance.load_attributes(), appliance.load_commands(),
appliance.load_commands(), appliance.load_statistics(),
appliance.load_statistics(), ]
] )
) self._appliances.append(appliance)
self._appliances.append(appliance)
async def close(self): async def setup(self) -> None:
await self._api.close() appliance: Dict
for appliance in (await self.api.load_appliances())["payload"]["appliances"]:
if (zones := int(appliance.get("zone", "0"))) > 1:
for zone in range(zones):
await self._create_appliance(appliance.copy(), zone=zone + 1)
await self._create_appliance(appliance)
async def close(self) -> None:
await self.api.close()

View File

@ -1,154 +0,0 @@
import re
def str_to_float(string):
return float(string.replace(",", "."))
class HonParameter:
def __init__(self, key, attributes):
self._key = key
self._category = attributes.get("category")
self._typology = attributes.get("typology")
self._mandatory = attributes.get("mandatory")
self._value = ""
@property
def key(self):
return self._key
@property
def value(self):
return self._value if self._value is not None else "0"
@property
def category(self):
return self._category
@property
def typology(self):
return self._typology
@property
def mandatory(self):
return self._mandatory
class HonParameterFixed(HonParameter):
def __init__(self, key, attributes):
super().__init__(key, attributes)
self._value = attributes.get("fixedValue", None)
def __repr__(self):
return f"{self.__class__} (<{self.key}> fixed)"
@property
def value(self):
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, attributes):
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
def __repr__(self):
return f"{self.__class__} (<{self.key}> [{self._min} - {self._max}])"
@property
def min(self):
return self._min
@property
def max(self):
return self._max
@property
def step(self):
return self._step
@property
def value(self):
return self._value if self._value is not None else self._min
@value.setter
def value(self, value):
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, attributes):
super().__init__(key, attributes)
self._default = attributes.get("defaultValue")
self._value = self._default or "0"
self._values = attributes.get("enumValues")
def __repr__(self):
return f"{self.__class__} (<{self.key}> {self.values})"
@property
def values(self):
return [str(value) for value in self._values]
@property
def value(self):
return self._value if self._value is not None else self.values[0]
@value.setter
def value(self, value):
if value in self.values:
self._value = value
else:
raise ValueError(f"Allowed values {self._value}")
class HonParameterProgram(HonParameterEnum):
def __init__(self, key, command):
super().__init__(key, {})
self._command = command
self._value = command._program
self._values = command._multi
self._typology = "enum"
self._filter = ""
@property
def value(self):
return self._value
@value.setter
def value(self, value):
if value in self.values:
self._command.set_program(value)
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))
return sorted(values)

View File

30
pyhon/parameter/base.py Normal file
View File

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

29
pyhon/parameter/enum.py Normal file
View File

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

21
pyhon/parameter/fixed.py Normal file
View File

@ -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: str | float) -> None:
# Fixed values seems being not so fixed as thought
self._value = value

View File

@ -0,0 +1,33 @@
from typing import List, TYPE_CHECKING, Dict
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._programs: Dict[str, "HonCommand"] = 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._programs if all(f not in v for f in self._FILTER)]
return sorted(values)

49
pyhon/parameter/range.py Normal file
View File

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

View File

@ -1 +1,2 @@
aiohttp aiohttp==3.8.4
yarl==1.8.2

4
requirements_dev.txt Normal file
View File

@ -0,0 +1,4 @@
black==23.3.0
flake8==6.0.0
mypy==1.2.0
pylint==2.17.2

View File

@ -7,7 +7,7 @@ with open("README.md", "r") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.6.1", version="0.8.0",
author="Andre Basche", author="Andre Basche",
description="Control hOn devices with python", description="Control hOn devices with python",
long_description=long_description, long_description=long_description,