Compare commits

..

No commits in common. "main" and "v0.14.8" have entirely different histories.

36 changed files with 258 additions and 695 deletions

View file

@ -1,3 +0,0 @@
[flake8]
max-line-length = 88
max-complexity = 7

View file

@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11"]
steps:
- uses: actions/checkout@v3
@ -28,13 +28,15 @@ jobs:
python -m pip install -r requirements_dev.txt
- name: Lint with flake8
run: |
flake8 . --count --statistics
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics
- name: Type check with mypy
run: |
mypy pyhon/
- name: Analysing the code with pylint
run: |
pylint $(git ls-files '*.py')
# - name: Analysing the code with pylint
# run: |
# pylint --max-line-length 88 $(git ls-files '*.py')
- name: Check black style
run: |
black . --check

View file

@ -1,9 +0,0 @@
[MESSAGES CONTROL]
disable=missing-docstring
[FORMAT]
max-args=7
max-attributes=8
max-line-length=88

View file

@ -1 +0,0 @@
include pyhon/py.typed

View file

@ -1,24 +1,9 @@
[mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_any_unimported = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
disable_error_code = annotation-unchecked
enable_error_code = ignore-without-code, redundant-self, truthy-iterable
follow_imports = silent
local_partial_types = true
no_implicit_optional = true
no_implicit_reexport = true
show_error_codes = true
strict_concatenate = false
strict_equality = true
warn_incomplete_stub = true
warn_redundant_casts = true
warn_return_any = true
warn_unreachable = true
warn_unused_configs = true
warn_unused_ignores = true
check_untyped_defs = True
disallow_any_generics = True
disallow_untyped_defs = True
disallow_any_unimported = True
no_implicit_optional = True
warn_return_any = True
show_error_codes = True
warn_unused_ignores = True

View file

@ -11,7 +11,6 @@ from typing import Tuple, Dict, Any
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent.parent))
# pylint: disable=wrong-import-position
from pyhon import Hon, HonAPI, diagnose, printer
_LOGGER = logging.getLogger(__name__)
@ -31,14 +30,16 @@ def get_arguments() -> Dict[str, Any]:
export.add_argument("--zip", help="create zip archive", action="store_true")
export.add_argument("--anonymous", help="anonymize data", action="store_true")
export.add_argument("directory", nargs="?", default=Path().cwd())
translation = subparser.add_parser(
translate = subparser.add_parser(
"translate", help="print available translation keys"
)
translation.add_argument(
translate.add_argument(
"translate", help="language (de, en, fr...)", metavar="LANGUAGE"
)
translation.add_argument("--json", help="print as json", action="store_true")
parser.add_argument("-i", "--import", help="import pyhon data", nargs="?")
translate.add_argument("--json", help="print as json", action="store_true")
parser.add_argument(
"-i", "--import", help="import pyhon data", nargs="?", default=Path().cwd()
)
return vars(parser.parse_args())
@ -71,8 +72,9 @@ async def main() -> None:
if language := args.get("translate"):
await translate(language, json_output=args.get("json", ""))
return
test_data_path = Path(path) if (path := args.get("import", "")) else None
async with Hon(*get_login_data(args), test_data_path=test_data_path) as hon:
async with Hon(
*get_login_data(args), test_data_path=Path(args.get("import", ""))
) as hon:
for device in hon.appliances:
if args.get("export"):
anonymous = args.get("anonymous", False)
@ -89,9 +91,11 @@ async def main() -> None:
data = device.data.copy()
attr = "get" if args.get("all") else "pop"
print(
printer.key_print(getattr(data["attributes"], attr)("parameters"))
printer.key_print(
data["attributes"].__getattribute__(attr)("parameters")
)
)
print(printer.key_print(getattr(data, attr)("appliance")))
print(printer.key_print(data.__getattribute__(attr)("appliance")))
print(printer.key_print(data))
print(
printer.pretty_print(

View file

@ -3,7 +3,7 @@ import logging
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, Any, TYPE_CHECKING, List, TypeVar, overload
from typing import Optional, Dict, Any, TYPE_CHECKING, List
from pyhon import diagnose, exceptions
from pyhon.appliances.base import ApplianceBase
@ -11,7 +11,6 @@ from pyhon.attributes import HonAttribute
from pyhon.command_loader import HonCommandLoader
from pyhon.commands import HonCommand
from pyhon.parameter.base import HonParameter
from pyhon.parameter.enum import HonParameterEnum
from pyhon.parameter.range import HonParameterRange
from pyhon.typedefs import Parameter
@ -20,10 +19,7 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
T = TypeVar("T")
# pylint: disable=too-many-public-methods,too-many-instance-attributes
class HonAppliance:
_MINIMAL_UPDATE_INTERVAL = 5 # seconds
@ -43,10 +39,6 @@ class HonAppliance:
self._additional_data: Dict[str, Any] = {}
self._last_update: Optional[datetime] = None
self._default_setting = HonParameter("", {}, "")
self._connection = (
not self._attributes.get("lastConnEvent", {}).get("category", "")
== "DISCONNECTED"
)
try:
self._extra: Optional[ApplianceBase] = importlib.import_module(
@ -55,33 +47,24 @@ class HonAppliance:
except ModuleNotFoundError:
self._extra = None
def _get_nested_item(self, item: str) -> Any:
result: List[Any] | Dict[str, Any] = self.data
for key in item.split("."):
if all(k in "0123456789" for k in key) and isinstance(result, list):
result = result[int(key)]
elif isinstance(result, dict):
result = result[key]
return result
def __getitem__(self, item: str) -> Any:
if self._zone:
item += f"Z{self._zone}"
if "." in item:
return self._get_nested_item(item)
result = self.data
for key in item.split("."):
if all(k in "0123456789" for k in key) and isinstance(result, list):
result = result[int(key)]
else:
result = result[key]
return result
if item in self.data:
return self.data[item]
if item in self.attributes["parameters"]:
return self.attributes["parameters"][item].value
return self.info[item]
@overload
def get(self, item: str, default: None = None) -> Any: ...
@overload
def get(self, item: str, default: T) -> T: ...
def get(self, item: str, default: Optional[T] = None) -> Any:
def get(self, item: str, default: Any = None) -> Any:
try:
return self[item]
except (KeyError, IndexError):
@ -94,25 +77,17 @@ class HonAppliance:
return f"{attribute}{zone}{self._zone}"
return attribute
@property
def connection(self) -> bool:
return self._connection
@connection.setter
def connection(self, connection: bool) -> None:
self._connection = connection
@property
def appliance_model_id(self) -> str:
return str(self._info.get("applianceModelId", ""))
return self._info.get("applianceModelId", "")
@property
def appliance_type(self) -> str:
return str(self._info.get("applianceTypeName", ""))
return self._info.get("applianceTypeName", "")
@property
def mac_address(self) -> str:
return str(self.info.get("macAddress", ""))
return self.info.get("macAddress", "")
@property
def unique_id(self) -> str:
@ -148,11 +123,11 @@ class HonAppliance:
@property
def model_id(self) -> int:
return int(self._info.get("applianceModelId", 0))
return self._info.get("applianceModelId", 0)
@property
def options(self) -> Dict[str, Any]:
return dict(self._appliance_model.get("options", {}))
return self._appliance_model.get("options", {})
@property
def commands(self) -> Dict[str, HonCommand]:
@ -212,8 +187,12 @@ class HonAppliance:
async def update(self, force: bool = False) -> None:
now = datetime.now()
min_age = now - timedelta(seconds=self._MINIMAL_UPDATE_INTERVAL)
if force or not self._last_update or self._last_update < min_age:
if (
force
or not self._last_update
or self._last_update
< now - timedelta(seconds=self._MINIMAL_UPDATE_INTERVAL)
):
self._last_update = now
await self.load_attributes()
self.sync_params_to_command("settings")
@ -273,9 +252,7 @@ class HonAppliance:
if not (command := self.commands.get(command_name)):
return
for key in command.setting_keys:
if (
new := self.attributes.get("parameters", {}).get(key)
) is None or new.value == "":
if (new := self.attributes.get("parameters", {}).get(key)) is None:
continue
setting = command.settings[key]
try:
@ -287,40 +264,23 @@ class HonAppliance:
_LOGGER.info("Can't set %s - %s", key, error)
continue
def sync_command(
self,
main: str,
target: Optional[List[str] | str] = None,
to_sync: Optional[List[str] | bool] = None,
) -> None:
def sync_command(self, main: str, target: Optional[List[str] | str] = None) -> None:
base: Optional[HonCommand] = self.commands.get(main)
if not base:
return
for command, data in self.commands.items():
if command == main or target and command not in target:
continue
for name, target_param in data.parameters.items():
if not (base_param := base.parameters.get(name)):
continue
if to_sync and (
(isinstance(to_sync, list) and name not in to_sync)
or not base_param.mandatory
):
continue
self.sync_parameter(base_param, target_param)
def sync_parameter(self, main: Parameter, target: Parameter) -> None:
if isinstance(main, HonParameterRange) and isinstance(
target, HonParameterRange
):
target.max = main.max
target.min = main.min
target.step = main.step
elif isinstance(target, HonParameterRange):
target.max = int(main.value)
target.min = int(main.value)
target.step = 1
elif isinstance(target, HonParameterEnum):
target.values = main.values
target.value = main.value
for name, parameter in data.parameters.items():
if base_value := base.parameters.get(name):
if isinstance(base_value, HonParameterRange) and isinstance(
parameter, HonParameterRange
):
parameter.max = base_value.max
parameter.min = base_value.min
parameter.step = base_value.step
elif isinstance(parameter, HonParameterRange):
parameter.max = int(base_value.value)
parameter.min = int(base_value.value)
parameter.step = 1
parameter.value = base_value.value

View file

@ -1,4 +1,3 @@
# pylint: disable=duplicate-code
from typing import Any, Dict
from pyhon.appliances.base import ApplianceBase
@ -7,7 +6,7 @@ from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data)
if not self.parent.connection:
if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity"))
return data

View file

@ -1,16 +1,17 @@
from typing import Any, Dict
from pyhon.appliances.base import ApplianceBase
from pyhon.parameter.program import HonParameterProgram
class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data)
if not self.parent.connection:
data["parameters"]["temp"].value = 0
data["parameters"]["onOffStatus"].value = 0
data["parameters"]["remoteCtrValid"].value = 0
data["parameters"]["remainingTimeMM"].value = 0
if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["temp"].value = "0"
data["parameters"]["onOffStatus"].value = "0"
data["parameters"]["remoteCtrValid"].value = "0"
data["parameters"]["remainingTimeMM"].value = "0"
data["active"] = data["parameters"]["onOffStatus"].value == 1
data["active"] = data["parameters"]["onOffStatus"] == "1"
return data

View file

@ -10,12 +10,12 @@ class Appliance(ApplianceBase):
data["modeZ1"] = "holiday"
elif data["parameters"]["intelligenceMode"] == "1":
data["modeZ1"] = "auto_set"
elif data["parameters"].get("quickModeZ1") == "1":
elif data["parameters"]["quickModeZ1"] == "1":
data["modeZ1"] = "super_cool"
else:
data["modeZ1"] = "no_mode"
if data["parameters"].get("quickModeZ2") == "1":
if data["parameters"]["quickModeZ2"] == "1":
data["modeZ2"] = "super_freeze"
elif data["parameters"]["intelligenceMode"] == "1":
data["modeZ2"] = "auto_set"

View file

@ -1,4 +1,3 @@
# pylint: disable=duplicate-code
from typing import Any, Dict
from pyhon.appliances.base import ApplianceBase
@ -8,7 +7,7 @@ from pyhon.parameter.fixed import HonParameterFixed
class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data)
if not self.parent.connection:
if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity"))
data["pause"] = data["parameters"]["machMode"] == "3"

View file

@ -1,4 +1,3 @@
# pylint: disable=duplicate-code
from typing import Dict, Any
from pyhon.appliances.base import ApplianceBase
@ -7,7 +6,7 @@ from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data)
if not self.parent.connection:
if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity"))
data["pause"] = data["parameters"]["machMode"] == "3"

View file

@ -1,16 +0,0 @@
from typing import Any, Dict
from pyhon.appliances.base import ApplianceBase
from pyhon.parameter.base import HonParameter
class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data)
parameter = data.get("parameters", {}).get("onOffStatus")
is_class = isinstance(parameter, HonParameter)
data["active"] = parameter.value == 1 if is_class else parameter == 1
return data
def settings(self, settings: Dict[str, Any]) -> Dict[str, Any]:
return settings

View file

@ -1,4 +1,3 @@
# pylint: disable=duplicate-code
from typing import Any, Dict
from pyhon.appliances.base import ApplianceBase

View file

@ -1,15 +1,14 @@
import asyncio
from contextlib import suppress
from copy import copy
from typing import Dict, Any, Optional, TYPE_CHECKING, List
from typing import Dict, Any, Optional, TYPE_CHECKING, List, Collection
from pyhon.commands import HonCommand
from pyhon.exceptions import NoAuthenticationException
from pyhon.parameter.fixed import HonParameterFixed
from pyhon.parameter.program import HonParameterProgram
if TYPE_CHECKING:
from pyhon import HonAPI
from pyhon import HonAPI, exceptions
from pyhon.appliance import HonAppliance
@ -17,12 +16,12 @@ class HonCommandLoader:
"""Loads and parses hOn command data"""
def __init__(self, api: "HonAPI", appliance: "HonAppliance") -> None:
self._api: "HonAPI" = api
self._appliance: "HonAppliance" = appliance
self._api_commands: Dict[str, Any] = {}
self._favourites: List[Dict[str, Any]] = []
self._command_history: List[Dict[str, Any]] = []
self._commands: Dict[str, HonCommand] = {}
self._api: "HonAPI" = api
self._appliance: "HonAppliance" = appliance
self._appliance_data: Dict[str, Any] = {}
self._additional_data: Dict[str, Any] = {}
@ -30,7 +29,7 @@ class HonCommandLoader:
def api(self) -> "HonAPI":
"""api connection object"""
if self._api is None:
raise NoAuthenticationException("Missing hOn login")
raise exceptions.NoAuthenticationException("Missing hOn login")
return self._api
@property
@ -109,7 +108,7 @@ class HonCommandLoader:
categories: Optional[Dict[str, "HonCommand"]] = None,
category_name: str = "",
) -> Optional[HonCommand]:
"""Try to create HonCommand object"""
"""Try to crate HonCommand object"""
if not isinstance(data, dict):
self._additional_data[command_name] = data
return None
@ -184,44 +183,22 @@ class HonCommandLoader:
def _add_favourites(self) -> None:
"""Patch program categories with favourites"""
for favourite in self._favourites:
name, command_name, base = self._get_favourite_info(favourite)
if not base:
continue
base_command: HonCommand = copy(base)
self._update_base_command_with_data(base_command, favourite)
self._update_base_command_with_favourite(base_command)
self._update_program_categories(command_name, name, base_command)
def _get_favourite_info(
self, favourite: Dict[str, Any]
) -> tuple[str, str, HonCommand | None]:
name: str = favourite.get("favouriteName", {})
command = favourite.get("command", {})
command_name: str = command.get("commandName", "")
program_name = self._clean_name(command.get("programName", ""))
base_command = self.commands[command_name].categories.get(program_name)
return name, command_name, base_command
def _update_base_command_with_data(
self, base_command: HonCommand, command: Dict[str, Any]
) -> None:
for data in command.values():
if isinstance(data, str):
continue
for key, value in data.items():
if not (parameter := base_command.parameters.get(key)):
name = favourite.get("favouriteName", {})
command = favourite.get("command", {})
command_name = command.get("commandName", "")
program_name = self._clean_name(command.get("programName", ""))
base: HonCommand = copy(
self.commands[command_name].categories[program_name]
)
for data in command.values():
if isinstance(data, str):
continue
with suppress(ValueError):
parameter.value = value
def _update_base_command_with_favourite(self, base_command: HonCommand) -> None:
extra_param = HonParameterFixed("favourite", {"fixedValue": "1"}, "custom")
base_command.parameters.update(favourite=extra_param)
def _update_program_categories(
self, command_name: str, name: str, base_command: HonCommand
) -> None:
program = base_command.parameters["program"]
if isinstance(program, HonParameterProgram):
program.set_value(name)
self.commands[command_name].categories[name] = base_command
for key, value in data.items():
if parameter := base.parameters.get(key):
with suppress(ValueError):
parameter.value = value
extra_param = HonParameterFixed("favourite", {"fixedValue": "1"}, "custom")
base.parameters.update(favourite=extra_param)
if isinstance(program := base.parameters["program"], HonParameterProgram):
program.set_value(name)
self.commands[command_name].categories[name] = base

View file

@ -27,16 +27,17 @@ class HonCommand:
categories: Optional[Dict[str, "HonCommand"]] = None,
category_name: str = "",
):
self._name: str = name
self._api: Optional[HonAPI] = None
self._api: Optional[HonAPI] = appliance.api
self._appliance: "HonAppliance" = appliance
self._name: str = name
self._categories: Optional[Dict[str, "HonCommand"]] = categories
self._category_name: str = category_name
self._parameters: Dict[str, Parameter] = {}
self._description: str = attributes.pop("description", "")
self._protocol_type: str = attributes.pop("protocolType", "")
self._parameters: Dict[str, HonParameter] = {}
self._data: Dict[str, Any] = {}
self._available_settings: Dict[str, HonParameter] = {}
self._rules: List[HonRuleSet] = []
attributes.pop("description", "")
attributes.pop("protocolType", "")
self._load_parameters(attributes)
def __repr__(self) -> str:
@ -48,8 +49,6 @@ class HonCommand:
@property
def api(self) -> "HonAPI":
if self._api is None and self._appliance:
self._api = self._appliance.api
if self._api is None:
raise exceptions.NoAuthenticationException("Missing hOn login")
return self._api
@ -77,23 +76,12 @@ class HonCommand:
result.setdefault(parameter.group, {})[name] = parameter.intern_value
return result
@property
def mandatory_parameter_groups(self) -> Dict[str, Dict[str, Union[str, float]]]:
result: Dict[str, Dict[str, Union[str, float]]] = {}
for name, parameter in self._parameters.items():
if parameter.mandatory:
result.setdefault(parameter.group, {})[name] = parameter.intern_value
return result
@property
def parameter_value(self) -> Dict[str, Union[str, float]]:
return {n: p.value for n, p in self._parameters.items()}
def _load_parameters(self, attributes: Dict[str, Dict[str, Any] | Any]) -> None:
def _load_parameters(self, attributes: Dict[str, Dict[str, Any]]) -> None:
for key, items in attributes.items():
if not isinstance(items, dict):
_LOGGER.info("Loading Attributes - Skipping %s", str(items))
continue
for name, data in items.items():
self._create_parameters(data, name, key)
for rule in self._rules:
@ -105,12 +93,10 @@ class HonCommand:
if name == "zoneMap" and self._appliance.zone:
data["default"] = self._appliance.zone
if data.get("category") == "rule":
if "fixedValue" in data:
self._rules.append(HonRuleSet(self, data["fixedValue"]))
elif "enumValues" in data:
self._rules.append(HonRuleSet(self, data["enumValues"]))
if "fixedValue" not in data:
_LOGGER.error("Rule not supported: %s", data)
else:
_LOGGER.warning("Rule not supported: %s", data)
self._rules.append(HonRuleSet(self, data["fixedValue"]))
match data.get("typology"):
case "range":
self._parameters[name] = HonParameterRange(name, data, parameter)
@ -125,33 +111,14 @@ class HonCommand:
name = "program" if "PROGRAM" in self._category_name else "category"
self._parameters[name] = HonParameterProgram(name, self, "custom")
async def send(self, only_mandatory: bool = False) -> bool:
grouped_params = (
self.mandatory_parameter_groups if only_mandatory else self.parameter_groups
)
params = grouped_params.get("parameters", {})
return await self.send_parameters(params)
async def send_specific(self, param_names: List[str]) -> bool:
params: Dict[str, str | float] = {}
for key, parameter in self._parameters.items():
if key in param_names or parameter.mandatory:
params[key] = parameter.value
return await self.send_parameters(params)
async def send_parameters(self, params: Dict[str, str | float]) -> bool:
async def send(self) -> bool:
params = self.parameter_groups.get("parameters", {})
ancillary_params = self.parameter_groups.get("ancillaryParameters", {})
ancillary_params.pop("programRules", None)
if "prStr" in params:
params["prStr"] = self._category_name.upper()
self.appliance.sync_command_to_params(self.name)
try:
result = await self.api.send_command(
self._appliance,
self._name,
params,
ancillary_params,
self._category_name,
self._appliance, self._name, params, ancillary_params
)
if not result:
_LOGGER.error(result)
@ -202,7 +169,3 @@ class HonCommand:
else:
result[name] = parameter
return result
def reset(self) -> None:
for parameter in self._parameters.values():
parameter.reset()

View file

@ -18,23 +18,18 @@ from pyhon.connection.handler.hon import HonConnectionHandler
_LOGGER = logging.getLogger(__name__)
# pylint: disable=too-many-instance-attributes
class HonAPI:
def __init__(
self,
email: str = "",
password: str = "",
anonymous: bool = False,
mobile_id: str = "",
refresh_token: str = "",
session: Optional[ClientSession] = None,
) -> None:
super().__init__()
self._email: str = email
self._password: str = password
self._anonymous: bool = anonymous
self._mobile_id: str = mobile_id
self._refresh_token: str = refresh_token
self._hon_handler: Optional[HonConnectionHandler] = None
self._hon_anonymous_handler: Optional[HonAnonymousConnectionHandler] = None
self._session: Optional[ClientSession] = session
@ -74,11 +69,7 @@ class HonAPI:
).create()
if not self._anonymous:
self._hon_handler = await HonConnectionHandler(
self._email,
self._password,
session=self._session,
mobile_id=self._mobile_id,
refresh_token=self._refresh_token,
self._email, self._password, self._session
).create()
return self
@ -193,20 +184,12 @@ class HonAPI:
maintenance: Dict[str, Any] = (await response.json()).get("payload", {})
return maintenance
async def load_aws_token(self) -> str:
url: str = f"{const.API_URL}/auth/v1/introspection"
async with self._hon.get(url) as response:
introspection: Dict[str, Any] = (await response.json()).get("payload", {})
result: str = introspection.get("tokenSigned", "")
return result
async def send_command(
self,
appliance: HonAppliance,
command: str,
parameters: Dict[str, Any],
ancillary_parameters: Dict[str, Any],
program_name: str = "",
) -> bool:
now: str = datetime.utcnow().isoformat()
data: Dict[str, Any] = {
@ -225,8 +208,6 @@ class HonAPI:
"parameters": parameters,
"applianceType": appliance.appliance_type,
}
if command == "startProgram" and program_name:
data.update({"programName": program_name.upper()})
url: str = f"{const.API_URL}/commands/v1/send"
async with self._hon.post(url, json=data) as response:
json_data: Dict[str, Any] = await response.json()
@ -297,12 +278,10 @@ class TestAPI(HonAPI):
async def load_appliances(self) -> List[Dict[str, Any]]:
result = []
for appliance in self._path.glob("*/"):
file = appliance / "appliance_data.json"
with open(file, "r", encoding="utf-8") as json_file:
try:
result.append(json.loads(json_file.read()))
except json.decoder.JSONDecodeError as error:
_LOGGER.error("%s - %s", str(file), error)
with open(
appliance / "appliance_data.json", "r", encoding="utf-8"
) as json_file:
result.append(json.loads(json_file.read()))
return result
async def load_commands(self, appliance: HonAppliance) -> Dict[str, Any]:
@ -338,12 +317,5 @@ class TestAPI(HonAPI):
command: str,
parameters: Dict[str, Any],
ancillary_parameters: Dict[str, Any],
program_name: str = "",
) -> bool:
_LOGGER.info(
"%s - %s - %s",
str(parameters),
str(ancillary_parameters),
str(program_name),
)
return True

View file

@ -30,14 +30,6 @@ class HonLoginData:
loaded: Optional[Dict[str, Any]] = None
@dataclass
class HonAuthData:
access_token: str = ""
refresh_token: str = ""
cognito_token: str = ""
id_token: str = ""
class HonAuth:
_TOKEN_EXPIRES_AFTER_HOURS = 8
_TOKEN_EXPIRE_WARNING_HOURS = 7
@ -54,25 +46,28 @@ class HonAuth:
self._login_data = HonLoginData()
self._login_data.email = email
self._login_data.password = password
self._access_token = ""
self._refresh_token = ""
self._cognito_token = ""
self._id_token = ""
self._device = device
self._expires: datetime = datetime.utcnow()
self._auth = HonAuthData()
@property
def cognito_token(self) -> str:
return self._auth.cognito_token
return self._cognito_token
@property
def id_token(self) -> str:
return self._auth.id_token
return self._id_token
@property
def access_token(self) -> str:
return self._auth.access_token
return self._access_token
@property
def refresh_token(self) -> str:
return self._auth.refresh_token
return self._refresh_token
def _check_token_expiration(self, hours: int) -> bool:
return datetime.utcnow() >= self._expires + timedelta(hours=hours)
@ -120,23 +115,18 @@ class HonAuth:
async with self._request.get(url) as response:
text = await response.text()
self._expires = datetime.utcnow()
login_url: List[str] = re.findall("(?:url|href) ?= ?'(.+?)'", text)
login_url: List[str] = re.findall("url = '(.+?)'", text)
if not login_url:
if "oauth/done#access_token=" in text:
self._parse_token_data(text)
raise exceptions.HonNoAuthenticationNeeded()
await self._error_logger(response)
# As of July 2024 the login page has changed, and we started getting a /NewhOnLogin based relative URL in JS to parse
if login_url[0].startswith("/NewhOnLogin"):
# Force use of the old login page to avoid having to make the new one work..
login_url[0] = f"{const.AUTH_API}/s/login{login_url[0]}"
return login_url[0]
async def _manual_redirect(self, url: str) -> str:
async with self._request.get(url, allow_redirects=False) as response:
new_location = response.headers.get("Location", "")
if not new_location:
return url
if not (new_location := response.headers.get("Location", "")):
await self._error_logger(response)
return new_location
async def _handle_redirects(self, login_url: str) -> str:
@ -202,12 +192,12 @@ class HonAuth:
def _parse_token_data(self, text: str) -> bool:
if access_token := re.findall("access_token=(.*?)&", text):
self._auth.access_token = access_token[0]
self._access_token = access_token[0]
if refresh_token := re.findall("refresh_token=(.*?)&", text):
self._auth.refresh_token = parse.unquote(refresh_token[0])
self._refresh_token = refresh_token[0]
if id_token := re.findall("id_token=(.*?)&", text):
self._auth.id_token = id_token[0]
return bool(access_token and refresh_token and id_token)
self._id_token = id_token[0]
return True if access_token and refresh_token and id_token else False
async def _get_token(self, url: str) -> bool:
async with self._request.get(url) as response:
@ -239,7 +229,7 @@ class HonAuth:
return True
async def _api_auth(self) -> bool:
post_headers = {"id-token": self._auth.id_token}
post_headers = {"id-token": self._id_token}
data = self._device.get()
async with self._request.post(
f"{const.API_URL}/auth/v1/login", headers=post_headers, json=data
@ -249,8 +239,8 @@ class HonAuth:
except json.JSONDecodeError:
await self._error_logger(response)
return False
self._auth.cognito_token = json_data.get("cognitoUser", {}).get("Token", "")
if not self._auth.cognito_token:
self._cognito_token = json_data.get("cognitoUser", {}).get("Token", "")
if not self._cognito_token:
_LOGGER.error(json_data)
raise exceptions.HonAuthenticationError()
return True
@ -269,12 +259,10 @@ class HonAuth:
except exceptions.HonNoAuthenticationNeeded:
return
async def refresh(self, refresh_token: str = "") -> bool:
if refresh_token:
self._auth.refresh_token = refresh_token
async def refresh(self) -> bool:
params = {
"client_id": const.CLIENT_ID,
"refresh_token": self._auth.refresh_token,
"refresh_token": self._refresh_token,
"grant_type": "refresh_token",
}
async with self._request.post(
@ -285,14 +273,14 @@ class HonAuth:
return False
data = await response.json()
self._expires = datetime.utcnow()
self._auth.id_token = data["id_token"]
self._auth.access_token = data["access_token"]
self._id_token = data["id_token"]
self._access_token = data["access_token"]
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._auth.cognito_token = ""
self._auth.id_token = ""
self._auth.access_token = ""
self._auth.refresh_token = ""
self._cognito_token = ""
self._id_token = ""
self._access_token = ""
self._refresh_token = ""

View file

@ -1,15 +1,16 @@
import secrets
from typing import Dict
from pyhon import const
class HonDevice:
def __init__(self, mobile_id: str = "") -> None:
def __init__(self) -> None:
self._app_version: str = const.APP_VERSION
self._os_version: int = const.OS_VERSION
self._os: str = const.OS
self._device_model: str = const.DEVICE_MODEL
self._mobile_id: str = mobile_id or const.MOBILE_ID
self._mobile_id: str = secrets.token_hex(8)
@property
def app_version(self) -> str:
@ -20,7 +21,7 @@ class HonDevice:
return self._os_version
@property
def os_type(self) -> str:
def os(self) -> str:
return self._os
@property
@ -35,7 +36,7 @@ class HonDevice:
result: Dict[str, str | int] = {
"appVersion": self.app_version,
"mobileId": self.mobile_id,
"os": self.os_type,
"os": self.os,
"osVersion": self.os_version,
"deviceModel": self.device_model,
}

View file

@ -2,7 +2,7 @@ import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from types import TracebackType
from typing import Optional, Dict, Type, Any
from typing import Optional, Dict, Type, Any, Protocol
import aiohttp
from typing_extensions import Self
@ -47,11 +47,10 @@ class ConnectionHandler:
return self
@asynccontextmanager
async def _intercept(
def _intercept(
self, method: Callback, url: str | URL, *args: Any, **kwargs: Dict[str, Any]
) -> AsyncIterator[aiohttp.ClientResponse]:
async with method(url, *args, **kwargs) as response:
yield response
raise NotImplementedError
@asynccontextmanager
async def get(
@ -60,8 +59,7 @@ class ConnectionHandler:
if self._session is None:
raise exceptions.NoSessionException()
response: aiohttp.ClientResponse
args = self._session.get, *args
async with self._intercept(*args, **kwargs) as response:
async with self._intercept(self._session.get, *args, **kwargs) as response: # type: ignore[arg-type]
yield response
@asynccontextmanager
@ -71,8 +69,7 @@ class ConnectionHandler:
if self._session is None:
raise exceptions.NoSessionException()
response: aiohttp.ClientResponse
args = self._session.post, *args
async with self._intercept(*args, **kwargs) as response:
async with self._intercept(self._session.post, *args, **kwargs) as response: # type: ignore[arg-type]
yield response
async def close(self) -> None:

View file

@ -19,18 +19,12 @@ _LOGGER = logging.getLogger(__name__)
class HonConnectionHandler(ConnectionHandler):
def __init__(
self,
email: str,
password: str,
session: Optional[aiohttp.ClientSession] = None,
mobile_id: str = "",
refresh_token: str = "",
self, email: str, password: str, session: Optional[aiohttp.ClientSession] = None
) -> None:
super().__init__(session=session)
self._device: HonDevice = HonDevice(mobile_id)
self._device: HonDevice = HonDevice()
self._email: str = email
self._password: str = password
self._refresh_token: str = refresh_token
if not self._email:
raise HonAuthenticationError("An email address must be specified")
if not self._password:
@ -49,20 +43,12 @@ class HonConnectionHandler(ConnectionHandler):
async def create(self) -> Self:
await super().create()
self._auth = HonAuth(
self.session,
self._email,
self._password,
self._device,
)
self._auth = HonAuth(self.session, self._email, self._password, self._device)
return self
async def _check_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
if self._refresh_token:
await self.auth.refresh(self._refresh_token)
if not (self.auth.cognito_token and self.auth.id_token):
await self.auth.authenticate()
self._refresh_token = self.auth.refresh_token
headers["cognito-token"] = self.auth.cognito_token
headers["id-token"] = self.auth.id_token
return self._HEADERS | headers
@ -78,7 +64,7 @@ class HonConnectionHandler(ConnectionHandler):
self.auth.token_expires_soon or response.status in [401, 403]
) and loop == 0:
_LOGGER.info("Try refreshing token...")
await self.auth.refresh(self._refresh_token)
await self.auth.refresh()
async with self._intercept(
method, url, *args, loop=loop + 1, **kwargs
) as result:
@ -109,11 +95,11 @@ class HonConnectionHandler(ConnectionHandler):
try:
await response.json()
yield response
except json.JSONDecodeError as exc:
except json.JSONDecodeError:
_LOGGER.warning(
"%s - JsonDecodeError %s - %s",
response.request_info.url,
response.status,
await response.text(),
)
raise HonAuthenticationError("Decode Error") from exc
raise HonAuthenticationError("Decode Error")

View file

@ -1,146 +0,0 @@
import asyncio
import json
import logging
import secrets
from typing import TYPE_CHECKING
from awscrt import mqtt5
from awsiot import mqtt5_client_builder # type: ignore[import-untyped]
from pyhon import const
from pyhon.appliance import HonAppliance
if TYPE_CHECKING:
from pyhon import Hon
_LOGGER = logging.getLogger(__name__)
class MQTTClient:
def __init__(self, hon: "Hon", mobile_id: str) -> None:
self._client: mqtt5.Client | None = None
self._hon = hon
self._mobile_id = mobile_id or const.MOBILE_ID
self._api = hon.api
self._appliances = hon.appliances
self._connection = False
self._watchdog_task: asyncio.Task[None] | None = None
@property
def client(self) -> mqtt5.Client:
if self._client is not None:
return self._client
raise AttributeError("Client is not set")
async def create(self) -> "MQTTClient":
await self._start()
self._subscribe_appliances()
await self.start_watchdog()
return self
def _on_lifecycle_stopped(
self, lifecycle_stopped_data: mqtt5.LifecycleStoppedData
) -> None:
_LOGGER.info("Lifecycle Stopped: %s", str(lifecycle_stopped_data))
def _on_lifecycle_connection_success(
self,
lifecycle_connect_success_data: mqtt5.LifecycleConnectSuccessData,
) -> None:
self._connection = True
_LOGGER.info(
"Lifecycle Connection Success: %s", str(lifecycle_connect_success_data)
)
def _on_lifecycle_attempting_connect(
self,
lifecycle_attempting_connect_data: mqtt5.LifecycleAttemptingConnectData,
) -> None:
_LOGGER.info(
"Lifecycle Attempting Connect - %s", str(lifecycle_attempting_connect_data)
)
def _on_lifecycle_connection_failure(
self,
lifecycle_connection_failure_data: mqtt5.LifecycleConnectFailureData,
) -> None:
self._connection = False
_LOGGER.info(
"Lifecycle Connection Failure - %s", str(lifecycle_connection_failure_data)
)
def _on_lifecycle_disconnection(
self,
lifecycle_disconnect_data: mqtt5.LifecycleDisconnectData,
) -> None:
self._connection = False
_LOGGER.info("Lifecycle Disconnection - %s", str(lifecycle_disconnect_data))
def _on_publish_received(self, data: mqtt5.PublishReceivedData) -> None:
if not (data and data.publish_packet and data.publish_packet.payload):
return
payload = json.loads(data.publish_packet.payload.decode())
topic = data.publish_packet.topic
appliance = next(
a for a in self._appliances if topic in a.info["topics"]["subscribe"]
)
if topic and "appliancestatus" in topic:
for parameter in payload["parameters"]:
appliance.attributes["parameters"][parameter["parName"]].update(
parameter
)
appliance.sync_params_to_command("settings")
elif topic and "disconnected" in topic:
_LOGGER.info(
"Disconnected %s: %s",
appliance.nick_name,
payload.get("disconnectReason"),
)
appliance.connection = False
elif topic and "connected" in topic:
appliance.connection = True
_LOGGER.info("Connected %s", appliance.nick_name)
elif topic and "discovery" in topic:
_LOGGER.info("Discovered %s", appliance.nick_name)
self._hon.notify()
_LOGGER.info("%s - %s", topic, payload)
async def _start(self) -> None:
self._client = mqtt5_client_builder.websockets_with_custom_authorizer(
endpoint=const.AWS_ENDPOINT,
auth_authorizer_name=const.AWS_AUTHORIZER,
auth_authorizer_signature=await self._api.load_aws_token(),
auth_token_key_name="token",
auth_token_value=self._api.auth.id_token,
client_id=f"{self._mobile_id}_{secrets.token_hex(8)}",
on_lifecycle_stopped=self._on_lifecycle_stopped,
on_lifecycle_connection_success=self._on_lifecycle_connection_success,
on_lifecycle_attempting_connect=self._on_lifecycle_attempting_connect,
on_lifecycle_connection_failure=self._on_lifecycle_connection_failure,
on_lifecycle_disconnection=self._on_lifecycle_disconnection,
on_publish_received=self._on_publish_received,
)
self.client.start()
def _subscribe_appliances(self) -> None:
for appliance in self._appliances:
self._subscribe(appliance)
def _subscribe(self, appliance: HonAppliance) -> None:
for topic in appliance.info.get("topics", {}).get("subscribe", []):
self.client.subscribe(
mqtt5.SubscribePacket([mqtt5.Subscription(topic)])
).result(10)
_LOGGER.info("Subscribed to topic %s", topic)
async def start_watchdog(self) -> None:
if not self._watchdog_task or self._watchdog_task.done():
self._watchdog_task = asyncio.create_task(self._watchdog())
async def _watchdog(self) -> None:
while True:
await asyncio.sleep(5)
if not self._connection:
_LOGGER.info("Restart mqtt connection")
await self._start()
self._subscribe_appliances()

View file

@ -1,16 +1,11 @@
AUTH_API = "https://account2.hon-smarthome.com"
API_URL = "https://api-iot.he.services"
API_KEY = "GRCqFhC6Gk@ikWXm1RmnSmX1cm,MxY-configuration"
AWS_ENDPOINT = "a30f6tqw0oh1x0-ats.iot.eu-west-1.amazonaws.com"
AWS_AUTHORIZER = "candy-iot-authorizer"
APP = "hon"
CLIENT_ID = (
"3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9."
"HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6"
)
APP_VERSION = "2.6.5"
OS_VERSION = 999
# All seen id's (different accounts, different devices) are the same, so I guess this hash is static
CLIENT_ID = "3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9.HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6"
APP_VERSION = "2.1.2"
OS_VERSION = 31
OS = "android"
DEVICE_MODEL = "pyhOn"
USER_AGENT = "Chrome/999.999.999.999"
MOBILE_ID = "pyhOn"
DEVICE_MODEL = "exynos9820"
USER_AGENT = "Chrome/110.0.5481.153"

View file

@ -1,29 +1,25 @@
import asyncio
import logging
from pathlib import Path
from types import TracebackType
from typing import List, Optional, Dict, Any, Type, Callable
from typing import List, Optional, Dict, Any, Type
from aiohttp import ClientSession
from typing_extensions import Self
from pyhon import HonAPI, exceptions
from pyhon.appliance import HonAppliance
from pyhon.connection.api import HonAPI
from pyhon.connection.api import TestAPI
from pyhon.connection.mqtt import MQTTClient
from pyhon.exceptions import NoAuthenticationException
_LOGGER = logging.getLogger(__name__)
# pylint: disable=too-many-instance-attributes
class Hon:
def __init__(
self,
email: Optional[str] = "",
password: Optional[str] = "",
session: Optional[ClientSession] = None,
mobile_id: str = "",
refresh_token: str = "",
test_data_path: Optional[Path] = None,
):
self._email: Optional[str] = email
@ -32,10 +28,6 @@ class Hon:
self._appliances: List[HonAppliance] = []
self._api: Optional[HonAPI] = None
self._test_data_path: Path = test_data_path or Path().cwd()
self._mobile_id: str = mobile_id
self._refresh_token: str = refresh_token
self._mqtt_client: MQTTClient | None = None
self._notify_function: Optional[Callable[[Any], None]] = None
async def __aenter__(self) -> Self:
return await self.create()
@ -51,7 +43,7 @@ class Hon:
@property
def api(self) -> HonAPI:
if self._api is None:
raise NoAuthenticationException
raise exceptions.NoAuthenticationException
return self._api
@property
@ -68,11 +60,7 @@ class Hon:
async def create(self) -> Self:
self._api = await HonAPI(
self.email,
self.password,
session=self._session,
mobile_id=self._mobile_id,
refresh_token=self._refresh_token,
self.email, self.password, session=self._session
).create()
await self.setup()
return self
@ -92,9 +80,13 @@ class Hon:
if appliance.mac_address == "":
return
try:
await appliance.load_commands()
await appliance.load_attributes()
await appliance.load_statistics()
await asyncio.gather(
*[
appliance.load_attributes(),
appliance.load_commands(),
appliance.load_statistics(),
]
)
except (KeyError, ValueError, IndexError) as error:
_LOGGER.exception(error)
_LOGGER.error("Device data - %s", appliance_data)
@ -110,24 +102,11 @@ class Hon:
)
await self._create_appliance(appliance, self.api)
if (
self._test_data_path
and (
test_data := self._test_data_path / "hon-test-data" / "test_data"
).exists()
or (test_data := test_data / "..").exists()
):
test_data := self._test_data_path / "hon-test-data" / "test_data"
).exists() or (test_data := test_data / "test_data").exists():
api = TestAPI(test_data)
for appliance in await api.load_appliances():
await self._create_appliance(appliance, api)
if not self._mqtt_client:
self._mqtt_client = await MQTTClient(self, self._mobile_id).create()
def subscribe_updates(self, notify_function: Callable[[Any], None]) -> None:
self._notify_function = notify_function
def notify(self) -> None:
if self._notify_function:
self._notify_function(None)
async def close(self) -> None:
await self.api.close()

View file

@ -7,21 +7,14 @@ if TYPE_CHECKING:
class HonParameter:
def __init__(self, key: str, attributes: Dict[str, Any], group: str) -> None:
self._key = key
self._attributes = attributes
self._category: str = ""
self._typology: str = ""
self._mandatory: int = 0
self._category: str = attributes.get("category", "")
self._typology: str = attributes.get("typology", "")
self._mandatory: int = attributes.get("mandatory", 0)
self._value: str | float = ""
self._group: str = group
self._triggers: Dict[
str, List[Tuple[Callable[["HonRule"], None], "HonRule"]]
] = {}
self._set_attributes()
def _set_attributes(self) -> None:
self._category = self._attributes.get("category", "")
self._typology = self._attributes.get("typology", "")
self._mandatory = self._attributes.get("mandatory", 0)
@property
def key(self) -> str:
@ -68,9 +61,8 @@ class HonParameter:
self._triggers.setdefault(value, []).append((func, data))
def check_trigger(self, value: str | float) -> None:
triggers = {str(k).lower(): v for k, v in self._triggers.items()}
if str(value).lower() in triggers:
for trigger in triggers[str(value)]:
if str(value) in self._triggers:
for trigger in self._triggers[str(value)]:
func, args = trigger
func(args)
@ -93,6 +85,3 @@ class HonParameter:
param[rule.param_key] = rule.param_data.get("defaultValue", "")
return result
def reset(self) -> None:
self._set_attributes()

View file

@ -10,19 +10,12 @@ def clean_value(value: str | float) -> str:
class HonParameterEnum(HonParameter):
def __init__(self, key: str, attributes: Dict[str, Any], group: str) -> None:
super().__init__(key, attributes, group)
self._default: str | float = ""
self._value: str | float = ""
self._values: List[str] = []
self._set_attributes()
self._default = attributes.get("defaultValue")
self._value = self._default or "0"
self._values: List[str] = attributes.get("enumValues", [])
if self._default and clean_value(self._default.strip("[]")) not in self.values:
self._values.append(self._default)
def _set_attributes(self) -> None:
super()._set_attributes()
self._default = self._attributes.get("defaultValue", "")
self._value = self._default or "0"
self._values = self._attributes.get("enumValues", [])
def __repr__(self) -> str:
return f"{self.__class__} (<{self.key}> {self.values})"

View file

@ -6,19 +6,14 @@ from pyhon.parameter.base import HonParameter
class HonParameterFixed(HonParameter):
def __init__(self, key: str, attributes: Dict[str, Any], group: str) -> None:
super().__init__(key, attributes, group)
self._value: str | float = ""
self._set_attributes()
def _set_attributes(self) -> None:
super()._set_attributes()
self._value = self._attributes.get("fixedValue", "")
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 != "" else "0"
return self._value if self._value is not None else "0"
@value.setter
def value(self, value: str | float) -> None:

View file

@ -37,19 +37,17 @@ class HonParameterProgram(HonParameterEnum):
@values.setter
def values(self, values: List[str]) -> None:
raise ValueError("Cant set values {values}")
return
@property
def ids(self) -> Dict[int, str]:
values: Dict[int, str] = {}
for name, parameter in self._programs.items():
if "iot_" in name:
continue
if not parameter.parameters.get("prCode"):
continue
if (fav := parameter.parameters.get("favourite")) and fav.value == "1":
continue
values[int(parameter.parameters["prCode"].value)] = name
values = {
int(p.parameters["prCode"].value): n
for i, (n, p) in enumerate(self._programs.items())
if "iot_" not in n
and p.parameters.get("prCode")
and not ((fav := p.parameters.get("favourite")) and fav.value == "1")
}
return dict(sorted(values.items()))
def set_value(self, value: str) -> None:

View file

@ -7,20 +7,11 @@ from pyhon.parameter.base import HonParameter
class HonParameterRange(HonParameter):
def __init__(self, key: str, attributes: Dict[str, Any], group: str) -> None:
super().__init__(key, attributes, group)
self._min: float = 0
self._max: float = 0
self._step: float = 0
self._default: float = 0
self._value: float = 0
self._set_attributes()
def _set_attributes(self) -> None:
super()._set_attributes()
self._min = str_to_float(self._attributes.get("minimumValue", 0))
self._max = str_to_float(self._attributes.get("maximumValue", 0))
self._step = str_to_float(self._attributes.get("incrementValue", 0))
self._default = str_to_float(self._attributes.get("defaultValue", self.min))
self._value = self._default
self._min: float = str_to_float(attributes["minimumValue"])
self._max: float = str_to_float(attributes["maximumValue"])
self._step: float = str_to_float(attributes["incrementValue"])
self._default: float = str_to_float(attributes.get("defaultValue", self.min))
self._value: float = self._default
def __repr__(self) -> str:
return f"{self.__class__} (<{self.key}> [{self.min} - {self.max}])"
@ -64,14 +55,10 @@ class HonParameterRange(HonParameter):
self._value = value
self.check_trigger(value)
else:
allowed = f"min {self.min} max {self.max} step {self.step}"
raise ValueError(f"Allowed: {allowed} But was: {value}")
raise ValueError(
f"Allowed: min {self.min} max {self.max} step {self.step} But was: {value}"
)
@property
def values(self) -> List[str]:
result = []
i = self.min
while i <= self.max:
result.append(str(i))
i += self.step
return result
return [str(i) for i in range(int(self.min), int(self.max) + 1, int(self.step))]

View file

@ -29,26 +29,33 @@ def pretty_print(
whitespace: str = " ",
) -> str:
result = ""
space = whitespace * intend
if isinstance(data, (dict, list)) and key:
result += f"{space}{'- ' if is_list else ''}{key}:\n"
intend += 1
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):
for i, (list_key, value) in enumerate(sorted(data.items())):
result += pretty_print(
value,
key=list_key,
intend=intend + (is_list if i else 0),
is_list=is_list and not i,
whitespace=whitespace,
)
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"{space}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}\n"
result += f"{whitespace * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}\n"
return result

View file

View file

@ -3,7 +3,6 @@ from typing import List, Dict, TYPE_CHECKING, Any, Optional
from pyhon.parameter.enum import HonParameterEnum
from pyhon.parameter.range import HonParameterRange
from pyhon.typedefs import Parameter
if TYPE_CHECKING:
from pyhon.commands import HonCommand
@ -25,10 +24,6 @@ class HonRuleSet:
self._rules: Dict[str, List[HonRule]] = {}
self._parse_rule(rule)
@property
def rules(self) -> Dict[str, List[HonRule]]:
return self._rules
def _parse_rule(self, rule: Dict[str, Any]) -> None:
for param_key, params in rule.items():
param_key = self._command.appliance.options.get(param_key, param_key)
@ -56,11 +51,6 @@ class HonRuleSet:
extra[trigger_key] = trigger_value
for extra_key, extra_data in param_data.items():
self._parse_conditions(param_key, extra_key, extra_data, extra)
else:
param_data = {"typology": "fixed", "fixedValue": param_data}
self._create_rule(
param_key, trigger_key, trigger_value, param_data, extra
)
def _create_rule(
self,
@ -93,46 +83,28 @@ class HonRuleSet:
for rule in rules:
self._rules.setdefault(key, []).append(rule)
def _extra_rules_matches(self, rule: HonRule) -> bool:
if rule.extras:
for key, value in rule.extras.items():
if not self._command.parameters.get(key):
return False
if str(self._command.parameters.get(key)) != str(value):
return False
return True
def _apply_fixed(self, param: Parameter, value: str | float) -> None:
if isinstance(param, HonParameterEnum) and set(param.values) != {str(value)}:
param.values = [str(value)]
param.value = str(value)
elif isinstance(param, HonParameterRange):
if float(value) < param.min:
param.min = float(value)
elif float(value) > param.max:
param.max = float(value)
param.value = float(value)
return
param.value = str(value)
def _apply_enum(self, param: Parameter, rule: HonRule) -> None:
if not isinstance(param, HonParameterEnum):
return
if enum_values := rule.param_data.get("enumValues"):
param.values = enum_values.split("|")
if default_value := rule.param_data.get("defaultValue"):
param.value = default_value
def _add_trigger(self, parameter: "HonParameter", data: HonRule) -> None:
def apply(rule: HonRule) -> None:
if not self._extra_rules_matches(rule):
return
if not (param := self._command.parameters.get(rule.param_key)):
return
if fixed_value := rule.param_data.get("fixedValue", ""):
self._apply_fixed(param, fixed_value)
elif rule.param_data.get("typology") == "enum":
self._apply_enum(param, rule)
if rule.extras is not None:
for key, value in rule.extras.items():
if str(self._command.parameters.get(key)) != str(value):
return
if param := self._command.parameters.get(rule.param_key):
if value := rule.param_data.get("fixedValue", ""):
if isinstance(param, HonParameterEnum) and set(param.values) != {
str(value)
}:
param.values = [str(value)]
elif isinstance(param, HonParameterRange):
param.value = float(value)
return
param.value = str(value)
elif rule.param_data.get("typology") == "enum":
if isinstance(param, HonParameterEnum):
if enum_values := rule.param_data.get("enumValues"):
param.values = enum_values.split("|")
if default_value := rule.param_data.get("defaultValue"):
param.value = default_value
parameter.add_trigger(data.trigger_value, apply, data)

View file

@ -11,10 +11,11 @@ if TYPE_CHECKING:
from pyhon.parameter.range import HonParameterRange
class Callback(Protocol): # pylint: disable=too-few-public-methods
class Callback(Protocol):
def __call__(
self, url: str | URL, *args: Any, **kwargs: Any
) -> aiohttp.client._RequestContextManager: ...
) -> aiohttp.client._RequestContextManager:
...
Parameter = Union[

View file

@ -1,4 +1,2 @@
aiohttp>=3.8.6
yarl>=1.8
typing-extensions>=4.8
awsiotsdk>=1.21.0
aiohttp==3.8.4
yarl==1.8.2

View file

@ -1,6 +1,4 @@
black>=22.12
flake8>=6.0
mypy>=0.991
pylint>=2.15
setuptools>=62.3
types-awscrt
black==23.3.0
flake8==6.0.0
mypy==1.2.0
pylint==2.17.2

View file

@ -2,12 +2,12 @@
from setuptools import setup, find_packages
with open("README.md", "r", encoding="utf-8") as f:
with open("README.md", "r") as f:
long_description = f.read()
setup(
name="pyhOn",
version="0.17.5",
version="0.14.8",
author="Andre Basche",
description="Control hOn devices with python",
long_description=long_description,
@ -21,12 +21,7 @@ setup(
packages=find_packages(),
include_package_data=True,
python_requires=">=3.10",
install_requires=[
"aiohttp>=3.8.6",
"typing-extensions>=4.8",
"yarl>=1.8",
"awsiotsdk>=1.21.0",
],
install_requires=["aiohttp"],
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Console",
@ -35,7 +30,6 @@ setup(
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
],
entry_points={