Compare commits

...

11 Commits

Author SHA1 Message Date
76bd189e7b Bump version 2023-06-25 17:49:03 +02:00
ef67188b93 Create data archive and use it to test 2023-06-25 17:30:15 +02:00
66cb7bcc24 Merge branch 'refactor2' 2023-06-22 00:03:07 +02:00
c25e898b42 Bump version 2023-06-21 19:56:09 +02:00
55966dd52f Fix typeerror hon#77 2023-06-21 18:02:07 +02:00
8c65a37f29 Add command loader class 2023-06-15 02:16:03 +02:00
1ca89995a2 Lock attributes 2023-06-13 00:39:18 +02:00
f6139db0b5 Use class for attributes 2023-06-13 00:12:29 +02:00
310d1bafd7 Improve rule parsing 2023-06-10 06:47:37 +02:00
9e35dcf9cf Don't send optional program rules 2023-06-10 06:40:55 +02:00
f9d0fa4ae8 Add wine cellar 2023-06-09 22:52:33 +02:00
21 changed files with 648 additions and 261 deletions

View File

@ -107,3 +107,6 @@ This library is used for the custom [HomeAssistant Integration "Haier hOn"](http
## Contribution ## Contribution
Any kind of contribution is welcome! Any kind of contribution is welcome!
| Please add your appliances data to our [hon-test-data collection](https://github.com/Andre0512/hon-test-data). <br/>This helps us to develop new features and not to break compatibility in newer versions. |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

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, helper from pyhon import Hon, HonAPI, helper, diagnose
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -24,6 +24,11 @@ def get_arguments():
keys = subparser.add_parser("keys", help="print as key format") keys = subparser.add_parser("keys", help="print as key format")
keys.add_argument("keys", help="print as key format", action="store_true") keys.add_argument("keys", help="print as key format", action="store_true")
keys.add_argument("--all", help="print also full keys", action="store_true") keys.add_argument("--all", help="print also full keys", action="store_true")
export = subparser.add_parser("export")
export.add_argument("export", help="export pyhon data", action="store_true")
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())
translate = subparser.add_parser( translate = subparser.add_parser(
"translate", help="print available translation keys" "translate", help="print available translation keys"
) )
@ -50,17 +55,31 @@ async def translate(language, json_output=False):
print(helper.pretty_print(keys)) print(helper.pretty_print(keys))
def get_login_data(args):
if not (user := args["user"]):
user = input("User for hOn account: ")
if not (password := args["password"]):
password = getpass("Password for hOn account: ")
return user, password
async def main(): async def main():
args = get_arguments() args = get_arguments()
if language := args.get("translate"): if language := args.get("translate"):
await translate(language, json_output=args.get("json")) await translate(language, json_output=args.get("json"))
return return
if not (user := args["user"]): async with Hon(*get_login_data(args)) as hon:
user = input("User for hOn account: ")
if not (password := args["password"]):
password = getpass("Password for hOn account: ")
async with Hon(user, password) as hon:
for device in hon.appliances: for device in hon.appliances:
if args.get("export"):
anonymous = args.get("anonymous", False)
path = Path(args.get("directory"))
if not args.get("zip"):
for file in await diagnose.appliance_data(device, path, anonymous):
print(f"Created {file}")
else:
file = await diagnose.zip_archive(device, path, anonymous)
print(f"Created {file}")
continue
print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10) print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10)
if args.get("keys"): if args.get("keys"):
data = device.data.copy() data = device.data.copy()
@ -78,7 +97,7 @@ async def main():
) )
) )
else: else:
print(device.diagnose(" ")) print(diagnose.yaml_export(device))
def start(): def start():

View File

@ -1,17 +1,14 @@
import importlib import importlib
import json
import logging import logging
from contextlib import suppress
from copy import copy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, TYPE_CHECKING
from typing import TYPE_CHECKING
from pyhon import helper from pyhon import diagnose
from pyhon.attributes import HonAttribute
from pyhon.command_loader import HonCommandLoader
from pyhon.commands import HonCommand from pyhon.commands import HonCommand
from pyhon.parameter.base import HonParameter from pyhon.parameter.base import HonParameter
from pyhon.parameter.fixed import HonParameterFixed
from pyhon.parameter.range import HonParameterRange from pyhon.parameter.range import HonParameterRange
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,7 +29,7 @@ class HonAppliance:
self._api: Optional[HonAPI] = api self._api: Optional[HonAPI] = api
self._appliance_model: Dict = {} self._appliance_model: Dict = {}
self._commands: Dict = {} self._commands: Dict[str, HonCommand] = {}
self._statistics: Dict = {} self._statistics: Dict = {}
self._attributes: Dict = {} self._attributes: Dict = {}
self._zone: int = zone self._zone: int = zone
@ -61,7 +58,7 @@ class HonAppliance:
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"][item].value
return self.info[item] return self.info[item]
def get(self, item, default=None): def get(self, item, default=None):
@ -96,6 +93,10 @@ class HonAppliance:
def model_name(self) -> str: def model_name(self) -> str:
return self._check_name_zone("modelName") return self._check_name_zone("modelName")
@property
def brand(self) -> str:
return self._check_name_zone("brand")
@property @property
def nick_name(self) -> str: def nick_name(self) -> str:
return self._check_name_zone("nickName") return self._check_name_zone("nickName")
@ -107,12 +108,16 @@ class HonAppliance:
serial_number = self.info.get("serialNumber", "") serial_number = self.info.get("serialNumber", "")
return serial_number[:8] if len(serial_number) < 18 else serial_number[:11] return serial_number[:8] if len(serial_number) < 18 else serial_number[:11]
@property
def model_id(self) -> int:
return self._info.get("applianceModelId", 0)
@property @property
def options(self): def options(self):
return self._appliance_model.get("options", {}) return self._appliance_model.get("options", {})
@property @property
def commands(self): def commands(self) -> Dict[str, HonCommand]:
return self._commands return self._commands
@property @property
@ -139,109 +144,22 @@ class HonAppliance:
def api(self) -> Optional["HonAPI"]: def api(self) -> Optional["HonAPI"]:
return self._api return self._api
async def _recover_last_command_states(self):
command_history = await self.api.command_history(self)
for name, command in self._commands.items():
last = next(
(
index
for (index, d) in enumerate(command_history)
if d.get("command", {}).get("commandName") == name
),
None,
)
if last is None:
continue
parameters = command_history[last].get("command", {}).get("parameters", {})
if command.categories and (
parameters.get("program") or parameters.get("category")
):
if parameters.get("program"):
command.category = parameters.pop("program").split(".")[-1].lower()
else:
command.category = parameters.pop("category")
command = self.commands[name]
for key, data in command.settings.items():
if (
not isinstance(data, HonParameterFixed)
and parameters.get(key) is not None
):
with suppress(ValueError):
data.value = parameters.get(key)
def _get_categories(self, command, data):
categories = {}
for category, value in data.items():
result = self._get_command(value, command, category, categories)
if result:
if "PROGRAM" in category:
category = category.split(".")[-1].lower()
categories[category] = result[0]
if categories:
if "setParameters" in categories:
return [categories["setParameters"]]
return [list(categories.values())[0]]
return []
def _get_commands(self, data):
commands = []
for command, value in data.items():
commands += self._get_command(value, command, "")
return {c.name: c for c in commands}
def _get_command(self, data, command="", category="", categories=None):
commands = []
if isinstance(data, dict):
if data.get("description") and data.get("protocolType", None):
commands += [
HonCommand(
command,
data,
self,
category_name=category,
categories=categories,
)
]
else:
commands += self._get_categories(command, data)
elif category:
self._additional_data.setdefault(command, {})[category] = data
else:
self._additional_data[command] = data
return commands
async def load_commands(self): async def load_commands(self):
raw = await self.api.load_commands(self) command_loader = HonCommandLoader(self.api, self)
self._appliance_model = raw.pop("applianceModel") await command_loader.load_commands()
raw.pop("dictionaryId", None) self._commands = command_loader.commands
self._commands = self._get_commands(raw) self._additional_data = command_loader.additional_data
await self._add_favourites() self._appliance_model = command_loader.appliance_data
await self._recover_last_command_states()
async def _add_favourites(self):
favourites = await self._api.command_favourites(self)
for favourite in favourites:
name = favourite.get("favouriteName")
command = favourite.get("command")
command_name = command.get("commandName")
program_name = command.get("programName", "").split(".")[-1].lower()
base = copy(self._commands[command_name].categories[program_name])
for data in command.values():
if isinstance(data, str):
continue
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)
base.parameters["program"].set_value(name)
self._commands[command_name].categories[name] = base
async def load_attributes(self): async def load_attributes(self):
self._attributes = await self.api.load_attributes(self) self._attributes = await self.api.load_attributes(self)
for name, values in self._attributes.pop("shadow").get("parameters").items(): for name, values in self._attributes.pop("shadow").get("parameters").items():
self._attributes.setdefault("parameters", {})[name] = values["parNewVal"] if name in self._attributes.get("parameters", {}):
self._attributes["parameters"][name].update(values)
else:
self._attributes.setdefault("parameters", {})[name] = HonAttribute(
values
)
if self._extra: if self._extra:
self._attributes = self._extra.attributes(self._attributes) self._attributes = self._extra.attributes(self._attributes)
@ -295,41 +213,25 @@ class HonAppliance:
} }
return result return result
def diagnose(self, whitespace=" ", command_only=False): @property
data = { def diagnose(self) -> str:
"attributes": self.attributes.copy(), return diagnose.yaml_export(self, anonymous=True)
"appliance": self.info,
"statistics": self.statistics, async def data_archive(self, path: Path) -> str:
"additional_data": self._additional_data, return await diagnose.zip_archive(self, path, anonymous=True)
}
if command_only:
data.pop("attributes")
data.pop("appliance")
data.pop("statistics")
data |= {n: c.parameter_groups for n, c in self._commands.items()}
extra = {n: c.data for n, c in self._commands.items() if c.data}
if extra:
data |= {"extra_command_data": extra}
for sensible in ["PK", "SK", "serialNumber", "coords", "device"]:
data.get("appliance", {}).pop(sensible, None)
result = helper.pretty_print({"data": data}, whitespace=whitespace)
result += helper.pretty_print(
{
"commands": helper.create_command(self.commands),
"rules": helper.create_rules(self.commands),
},
whitespace=whitespace,
)
return result.replace(self.mac_address, "xx-xx-xx-xx-xx-xx")
def sync_to_params(self, command_name): def sync_to_params(self, command_name):
command: HonCommand = self.commands.get(command_name) command: HonCommand = self.commands.get(command_name)
for key, value in self.attributes.get("parameters", {}).items(): for key, value in self.attributes.get("parameters", {}).items():
if isinstance(value, str) and (new := command.parameters.get(key)): if isinstance(value, str) and (new := command.parameters.get(key)):
self.attributes["parameters"][key] = str(new.intern_value) self.attributes["parameters"][key].update(
str(new.intern_value), shield=True
)
def sync_command(self, main, target=None) -> None: def sync_command(self, main, target=None) -> None:
base: HonCommand = self.commands.get(main) base: Optional[HonCommand] = self.commands.get(main)
if not base:
return
for command, data in self.commands.items(): for command, data in self.commands.items():
if command == main or target and command not in target: if command == main or target and command not in target:
continue continue
@ -346,35 +248,3 @@ class HonAppliance:
parameter.min = int(base_value.value) parameter.min = int(base_value.value)
parameter.step = 1 parameter.step = 1
parameter.value = base_value.value parameter.value = base_value.value
class HonApplianceTest(HonAppliance):
def __init__(self, name):
super().__init__(None, {})
self._name = name
self.load_commands()
self.load_attributes()
self._info = self._appliance_model
def load_commands(self):
device = Path(__file__).parent / "test_data" / f"{self._name}.json"
with open(str(device)) as f:
raw = json.loads(f.read())
self._appliance_model = raw.pop("applianceModel")
raw.pop("dictionaryId", None)
self._commands = self._get_commands(raw)
async def update(self):
return
@property
def nick_name(self) -> str:
return self._name
@property
def unique_id(self) -> str:
return self._name
@property
def mac_address(self) -> str:
return "xx-xx-xx-xx-xx-xx"

View File

@ -4,7 +4,7 @@ class ApplianceBase:
def attributes(self, data): def attributes(self, data):
program_name = "No Program" program_name = "No Program"
if program := int(data["parameters"].get("prCode", "0")): if program := int(str(data.get("parameters", {}).get("prCode", "0"))):
if start_cmd := self.parent.settings.get("startProgram.program"): if start_cmd := self.parent.settings.get("startProgram.program"):
if ids := start_cmd.ids: if ids := start_cmd.ids:
program_name = ids.get(program, program_name) program_name = ids.get(program, program_name)

View File

@ -4,7 +4,7 @@ from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase): class Appliance(ApplianceBase):
def attributes(self, data): def attributes(self, data):
data = super().attributes(data) data = super().attributes(data)
if data["lastConnEvent"]["category"] == "DISCONNECTED": if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"] = "0" data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity")) data["active"] = bool(data.get("activity"))
return data return data

View File

@ -4,11 +4,11 @@ from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase): class Appliance(ApplianceBase):
def attributes(self, data): def attributes(self, data):
data = super().attributes(data) data = super().attributes(data)
if data["lastConnEvent"]["category"] == "DISCONNECTED": if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["temp"] = "0" data["parameters"]["temp"].value = "0"
data["parameters"]["onOffStatus"] = "0" data["parameters"]["onOffStatus"].value = "0"
data["parameters"]["remoteCtrValid"] = "0" data["parameters"]["remoteCtrValid"].value = "0"
data["parameters"]["remainingTimeMM"] = "0" data["parameters"]["remainingTimeMM"].value = "0"
data["active"] = data["parameters"]["onOffStatus"] == "1" data["active"] = data["parameters"]["onOffStatus"] == "1"

View File

@ -5,8 +5,8 @@ from pyhon.parameter.fixed import HonParameterFixed
class Appliance(ApplianceBase): class Appliance(ApplianceBase):
def attributes(self, data): def attributes(self, data):
data = super().attributes(data) data = super().attributes(data)
if data["lastConnEvent"]["category"] == "DISCONNECTED": if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"] = "0" data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity")) data["active"] = bool(data.get("activity"))
data["pause"] = data["parameters"]["machMode"] == "3" data["pause"] = data["parameters"]["machMode"] == "3"
return data return data

5
pyhon/appliances/wc.py Normal file
View File

@ -0,0 +1,5 @@
from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase):
pass

View File

@ -4,8 +4,8 @@ from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase): class Appliance(ApplianceBase):
def attributes(self, data): def attributes(self, data):
data = super().attributes(data) data = super().attributes(data)
if data["lastConnEvent"]["category"] == "DISCONNECTED": if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"] = "0" data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity")) data["active"] = bool(data.get("activity"))
data["pause"] = data["parameters"]["machMode"] == "3" data["pause"] = data["parameters"]["machMode"] == "3"
return data return data

View File

@ -4,8 +4,8 @@ from pyhon.appliances.base import ApplianceBase
class Appliance(ApplianceBase): class Appliance(ApplianceBase):
def attributes(self, data): def attributes(self, data):
data = super().attributes(data) data = super().attributes(data)
if data["lastConnEvent"]["category"] == "DISCONNECTED": if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["machMode"] = "0" data["parameters"]["machMode"].value = "0"
data["active"] = bool(data.get("activity")) data["active"] = bool(data.get("activity"))
data["pause"] = data["parameters"]["machMode"] == "3" data["pause"] = data["parameters"]["machMode"] == "3"
return data return data

58
pyhon/attributes.py Normal file
View File

@ -0,0 +1,58 @@
from datetime import datetime, timedelta
from typing import Optional, Final, Dict
from pyhon.helper import str_to_float
class HonAttribute:
_LOCK_TIMEOUT: Final = 10
def __init__(self, data):
self._value: str = ""
self._last_update: Optional[datetime] = None
self._lock_timestamp: Optional[datetime] = None
self.update(data)
@property
def value(self) -> float | str:
"""Attribute value"""
try:
return str_to_float(self._value)
except ValueError:
return self._value
@value.setter
def value(self, value) -> None:
self._value = value
@property
def last_update(self) -> Optional[datetime]:
"""Timestamp of last api update"""
return self._last_update
@property
def lock(self) -> bool:
"""Shows if value changes are forbidden"""
if not self._lock_timestamp:
return False
lock_until = self._lock_timestamp + timedelta(seconds=self._LOCK_TIMEOUT)
return lock_until >= datetime.utcnow()
def update(self, data: Dict[str, str] | str, shield: bool = False) -> bool:
if self.lock and not shield:
return False
if shield:
self._lock_timestamp = datetime.utcnow()
if isinstance(data, str):
self.value = data
return True
self.value = data.get("parNewVal", "")
if last_update := data.get("lastUpdate"):
try:
self._last_update = datetime.fromisoformat(last_update)
except ValueError:
self._last_update = None
return True
def __str__(self) -> str:
return self._value

193
pyhon/command_loader.py Normal file
View File

@ -0,0 +1,193 @@
import asyncio
from contextlib import suppress
from copy import copy
from typing import Dict, Any, Optional, TYPE_CHECKING, List
from pyhon.commands import HonCommand
from pyhon.parameter.fixed import HonParameterFixed
from pyhon.parameter.program import HonParameterProgram
if TYPE_CHECKING:
from pyhon import HonAPI, exceptions
from pyhon.appliance import HonAppliance
class HonCommandLoader:
"""Loads and parses hOn command data"""
def __init__(self, api, 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] = {}
@property
def api(self) -> "HonAPI":
"""api connection object"""
if self._api is None:
raise exceptions.NoAuthenticationException("Missing hOn login")
return self._api
@property
def appliance(self) -> "HonAppliance":
"""appliance object"""
return self._appliance
@property
def commands(self) -> Dict[str, HonCommand]:
"""Get list of hon commands"""
return self._commands
@property
def appliance_data(self) -> Dict[str, Any]:
"""Get command appliance data"""
return self._appliance_data
@property
def additional_data(self) -> Dict[str, Any]:
"""Get command additional data"""
return self._additional_data
async def load_commands(self):
"""Trigger loading of command data"""
await self._load_data()
self._appliance_data = self._api_commands.pop("applianceModel")
self._get_commands()
self._add_favourites()
self._recover_last_command_states()
async def _load_commands(self):
self._api_commands = await self._api.load_commands(self._appliance)
async def _load_favourites(self):
self._favourites = await self._api.load_favourites(self._appliance)
async def _load_command_history(self):
self._command_history = await self._api.load_command_history(self._appliance)
async def _load_data(self):
"""Request parallel all relevant data"""
await asyncio.gather(
*[
self._load_commands(),
self._load_favourites(),
self._load_command_history(),
]
)
@staticmethod
def _is_command(data: Dict[str, Any]) -> bool:
"""Check if dict can be parsed as command"""
return (
data.get("description") is not None and data.get("protocolType") is not None
)
@staticmethod
def _clean_name(category: str) -> str:
"""Clean up category name"""
if "PROGRAM" in category:
return category.split(".")[-1].lower()
return category
def _get_commands(self) -> None:
"""Generates HonCommand dict from api data"""
commands = []
for name, data in self._api_commands.items():
if command := self._parse_command(data, name):
commands.append(command)
self._commands = {c.name: c for c in commands}
def _parse_command(
self, data: Dict[str, Any] | str, command_name: str, **kwargs
) -> Optional[HonCommand]:
"""Try to crate HonCommand object"""
if not isinstance(data, dict):
self._additional_data[command_name] = data
return None
if self._is_command(data):
return HonCommand(command_name, data, self._appliance, **kwargs)
if category := self._parse_categories(data, command_name):
return category
return None
def _parse_categories(
self, data: Dict[str, Any], command_name: str
) -> Optional[HonCommand]:
"""Parse categories and create reference to other"""
categories: Dict[str, HonCommand] = {}
for category, value in data.items():
kwargs = {"category_name": category, "categories": categories}
if command := self._parse_command(value, command_name, **kwargs):
categories[self._clean_name(category)] = command
if categories:
# setParameters should be at first place
if "setParameters" in categories:
return categories["setParameters"]
return list(categories.values())[0]
return None
def _get_last_command_index(self, name: str) -> Optional[int]:
"""Get index of last command execution"""
return next(
(
index
for (index, d) in enumerate(self._command_history)
if d.get("command", {}).get("commandName") == name
),
None,
)
def _set_last_category(
self, command: HonCommand, name: str, parameters: Dict[str, Any]
) -> HonCommand:
"""Set category to last state"""
if command.categories:
if program := parameters.pop("program", None):
command.category = self._clean_name(program)
elif category := parameters.pop("category", None):
command.category = category
else:
return command
return self.commands[name]
return command
def _recover_last_command_states(self) -> None:
"""Set commands to last state"""
for name, command in self.commands.items():
if (last_index := self._get_last_command_index(name)) is None:
continue
last_command = self._command_history[last_index]
parameters = last_command.get("command", {}).get("parameters", {})
command = self._set_last_category(command, name, parameters)
for key, data in command.settings.items():
if parameters.get(key) is None:
continue
with suppress(ValueError):
data.value = parameters.get(key)
def _add_favourites(self) -> None:
"""Patch program categories with favourites"""
for favourite in self._favourites:
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
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

@ -2,7 +2,7 @@ import logging
from typing import Optional, Dict, Any, List, TYPE_CHECKING, Union from typing import Optional, Dict, Any, List, TYPE_CHECKING, Union
from pyhon import exceptions from pyhon import exceptions
from pyhon.exceptions import ApiError from pyhon.exceptions import ApiError, NoAuthenticationException
from pyhon.parameter.base import HonParameter from pyhon.parameter.base import HonParameter
from pyhon.parameter.enum import HonParameterEnum from pyhon.parameter.enum import HonParameterEnum
from pyhon.parameter.fixed import HonParameterFixed from pyhon.parameter.fixed import HonParameterFixed
@ -111,12 +111,18 @@ class HonCommand:
async def send(self) -> bool: async def send(self) -> bool:
params = self.parameter_groups.get("parameters", {}) params = self.parameter_groups.get("parameters", {})
ancillary_params = self.parameter_groups.get("ancillaryParameters", {}) ancillary_params = self.parameter_groups.get("ancillaryParameters", {})
ancillary_params.pop("programRules", None)
self.appliance.sync_to_params(self.name) self.appliance.sync_to_params(self.name)
try:
result = await self.api.send_command( result = await self.api.send_command(
self._appliance, self._name, params, ancillary_params self._appliance, self._name, params, ancillary_params
) )
if not result: if not result:
_LOGGER.error(result)
raise ApiError("Can't send command") raise ApiError("Can't send command")
except NoAuthenticationException:
_LOGGER.error("No Authentication")
return False
return result return result
@property @property

View File

@ -1,8 +1,9 @@
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Dict, Optional from typing import Dict, Optional, Any, List, no_type_check
from aiohttp import ClientSession from aiohttp import ClientSession
from typing_extensions import Self from typing_extensions import Self
@ -66,11 +67,13 @@ class HonAPI:
).create() ).create()
return self return self
async def load_appliances(self) -> Dict: async def load_appliances(self) -> List[Dict[str, Any]]:
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() if result := await resp.json():
return result.get("payload", {}).get("appliances", {})
return []
async def load_commands(self, appliance: HonAppliance) -> Dict: async def load_commands(self, appliance: HonAppliance) -> Dict[str, Any]:
params: Dict = { params: Dict = {
"applianceType": appliance.appliance_type, "applianceType": appliance.appliance_type,
"applianceModelId": appliance.appliance_model_id, "applianceModelId": appliance.appliance_model_id,
@ -93,27 +96,29 @@ class HonAPI:
return {} return {}
return result return result
async def command_history(self, appliance: HonAppliance) -> Dict: async def load_command_history(
self, appliance: HonAppliance
) -> List[Dict[str, Any]]:
url: str = ( url: str = (
f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/history" 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: Dict = 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 command_favourites(self, appliance: HonAppliance) -> Dict: async def load_favourites(self, appliance: HonAppliance) -> List[Dict[str, Any]]:
url: str = ( url: str = (
f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/favourite" f"{const.API_URL}/commands/v1/appliance/{appliance.mac_address}/favourite"
) )
async with self._hon.get(url) as response: async with self._hon.get(url) as response:
result: Dict = 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"]["favourites"] return result["payload"]["favourites"]
async def last_activity(self, appliance: HonAppliance) -> Dict: async def load_last_activity(self, appliance: HonAppliance) -> Dict[str, Any]:
url: str = f"{const.API_URL}/commands/v1/retrieve-last-activity" url: str = f"{const.API_URL}/commands/v1/retrieve-last-activity"
params: Dict = {"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:
@ -122,19 +127,19 @@ class HonAPI:
return activity return activity
return {} return {}
async def appliance_model(self, appliance: HonAppliance) -> Dict: async def load_appliance_data(self, appliance: HonAppliance) -> Dict[str, Any]:
url: str = f"{const.API_URL}/commands/v1/appliance-model" url: str = f"{const.API_URL}/commands/v1/appliance-model"
params: Dict = { params: Dict = {
"code": appliance.info["code"], "code": appliance.code,
"macAddress": appliance.mac_address, "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: Dict = await response.json() result: Dict = await response.json()
if result and (activity := result.get("attributes")): if result:
return activity return result.get("payload", {}).get("applianceModel", {})
return {} return {}
async def load_attributes(self, appliance: HonAppliance) -> Dict: async def load_attributes(self, appliance: HonAppliance) -> Dict[str, Any]:
params: Dict = { params: Dict = {
"macAddress": appliance.mac_address, "macAddress": appliance.mac_address,
"applianceType": appliance.appliance_type, "applianceType": appliance.appliance_type,
@ -144,7 +149,7 @@ class HonAPI:
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) -> Dict: async def load_statistics(self, appliance: HonAppliance) -> Dict[str, Any]:
params: Dict = { params: Dict = {
"macAddress": appliance.mac_address, "macAddress": appliance.mac_address,
"applianceType": appliance.appliance_type, "applianceType": appliance.appliance_type,
@ -153,7 +158,7 @@ class HonAPI:
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_maintenance(self, appliance: HonAppliance): async def load_maintenance(self, appliance: HonAppliance) -> Dict[str, Any]:
url = f"{const.API_URL}/commands/v1/maintenance-cycle" url = f"{const.API_URL}/commands/v1/maintenance-cycle"
params = {"macAddress": appliance.mac_address} params = {"macAddress": appliance.mac_address}
async with self._hon.get(url, params=params) as response: async with self._hon.get(url, params=params) as response:
@ -192,7 +197,7 @@ class HonAPI:
_LOGGER.error("%s - Payload:\n%s", url, pformat(data)) _LOGGER.error("%s - Payload:\n%s", url, pformat(data))
return False return False
async def appliance_configuration(self) -> Dict: async def appliance_configuration(self) -> Dict[str, Any]:
url: str = f"{const.API_URL}/config/v1/program-list-rules" url: str = f"{const.API_URL}/config/v1/program-list-rules"
async with self._hon_anonymous.get(url) as response: async with self._hon_anonymous.get(url) as response:
result: Dict = await response.json() result: Dict = await response.json()
@ -200,7 +205,9 @@ class HonAPI:
return data return data
return {} return {}
async def app_config(self, language: str = "en", beta: bool = True) -> Dict: async def app_config(
self, language: str = "en", beta: bool = True
) -> Dict[str, Any]:
url: str = f"{const.API_URL}/app-config" url: str = f"{const.API_URL}/app-config"
payload_data: Dict = { payload_data: Dict = {
"languageCode": language, "languageCode": language,
@ -214,7 +221,7 @@ class HonAPI:
return data return data
return {} return {}
async def translation_keys(self, language: str = "en") -> Dict: async def translation_keys(self, language: str = "en") -> Dict[str, Any]:
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:
@ -227,3 +234,61 @@ class HonAPI:
await self._hon_handler.close() await self._hon_handler.close()
if self._hon_anonymous_handler is not None: if self._hon_anonymous_handler is not None:
await self._hon_anonymous_handler.close() await self._hon_anonymous_handler.close()
class TestAPI(HonAPI):
def __init__(self, path):
super().__init__()
self._anonymous = True
self._path: Path = path
def _load_json(self, appliance: HonAppliance, file) -> Dict[str, Any]:
directory = f"{appliance.appliance_type}_{appliance.appliance_model_id}".lower()
path = f"{self._path}/{directory}/{file}.json"
with open(path, "r", encoding="utf-8") as json_file:
return json.loads(json_file.read())
async def load_appliances(self) -> List[Dict[str, Any]]:
result = []
for appliance in self._path.glob("*/"):
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]:
return self._load_json(appliance, "commands")
@no_type_check
async def load_command_history(
self, appliance: HonAppliance
) -> List[Dict[str, Any]]:
return self._load_json(appliance, "command_history")
async def load_favourites(self, appliance: HonAppliance) -> List[Dict[str, Any]]:
return []
async def load_last_activity(self, appliance: HonAppliance) -> Dict[str, Any]:
return {}
async def load_appliance_data(self, appliance: HonAppliance) -> Dict[str, Any]:
return self._load_json(appliance, "appliance_data")
async def load_attributes(self, appliance: HonAppliance) -> Dict[str, Any]:
return self._load_json(appliance, "attributes")
async def load_statistics(self, appliance: HonAppliance) -> Dict[str, Any]:
return self._load_json(appliance, "statistics")
async def load_maintenance(self, appliance: HonAppliance) -> Dict[str, Any]:
return self._load_json(appliance, "maintenance")
async def send_command(
self,
appliance: HonAppliance,
command: str,
parameters: Dict,
ancillary_parameters: Dict,
) -> bool:
return True

97
pyhon/diagnose.py Normal file
View File

@ -0,0 +1,97 @@
import asyncio
import json
import re
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, List, Tuple
from pyhon import helper
if TYPE_CHECKING:
from pyhon.appliance import HonAppliance
def anonymize_data(data: str) -> str:
default_date = "1970-01-01T00:00:00.0Z"
default_mac = "xx-xx-xx-xx-xx-xx"
data = re.sub("[0-9A-Fa-f]{2}(-[0-9A-Fa-f]{2}){5}", default_mac, data)
data = re.sub("[\\d-]{10}T[\\d:]{8}(.\\d+)?Z", default_date, data)
for sensible in [
"serialNumber",
"code",
"nickName",
"mobileId",
"PK",
"SK",
"lat",
"lng",
]:
for match in re.findall(f'"{sensible}.*?":\\s"?(.+?)"?,?\\n', data):
replace = re.sub("[a-z]", "x", match)
replace = re.sub("[A-Z]", "X", replace)
replace = re.sub("\\d", "0", replace)
data = data.replace(match, replace)
return data
async def load_data(appliance: "HonAppliance", topic: str) -> Tuple[str, str]:
return topic, await getattr(appliance.api, f"load_{topic}")(appliance)
def write_to_json(data: str, topic: str, path: Path, anonymous: bool = False):
json_data = json.dumps(data, indent=4)
if anonymous:
json_data = anonymize_data(json_data)
file = path / f"{topic}.json"
with open(file, "w", encoding="utf-8") as json_file:
json_file.write(json_data)
return file
async def appliance_data(
appliance: "HonAppliance", path: Path, anonymous: bool = False
) -> List[Path]:
requests = [
"commands",
"attributes",
"command_history",
"statistics",
"maintenance",
"appliance_data",
]
path /= f"{appliance.appliance_type}_{appliance.model_id}".lower()
path.mkdir(parents=True, exist_ok=True)
api_data = await asyncio.gather(*[load_data(appliance, name) for name in requests])
return [write_to_json(data, topic, path, anonymous) for topic, data in api_data]
async def zip_archive(appliance: "HonAppliance", path: Path, anonymous: bool = False):
data = await appliance_data(appliance, path, anonymous)
shutil.make_archive(str(path), "zip", path)
shutil.rmtree(path)
return f"{data[0].parent.stem}.zip"
def yaml_export(appliance: "HonAppliance", anonymous=False) -> str:
data = {
"attributes": appliance.attributes.copy(),
"appliance": appliance.info,
"statistics": appliance.statistics,
"additional_data": appliance.additional_data,
}
data |= {n: c.parameter_groups for n, c in appliance.commands.items()}
extra = {n: c.data for n, c in appliance.commands.items() if c.data}
if extra:
data |= {"extra_command_data": extra}
if anonymous:
for sensible in ["serialNumber", "coords"]:
data.get("appliance", {}).pop(sensible, None)
data = {
"data": data,
"commands": helper.create_command(appliance.commands),
"rules": helper.create_rules(appliance.commands),
}
result = helper.pretty_print(data)
if anonymous:
result = anonymize_data(result)
return result

View File

@ -73,3 +73,10 @@ def create_rules(commands, concat=False):
else: else:
result[f"{name}.{parameter}"] = value result[f"{name}.{parameter}"] = value
return result return result
def str_to_float(string: str | float) -> float:
try:
return int(string)
except ValueError:
return float(str(string).replace(",", "."))

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import List, Optional, Dict, Any, Type from typing import List, Optional, Dict, Any, Type
@ -8,6 +9,7 @@ from typing_extensions import Self
from pyhon import HonAPI, exceptions from pyhon import HonAPI, exceptions
from pyhon.appliance import HonAppliance from pyhon.appliance import HonAppliance
from pyhon.connection.api import TestAPI
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -18,12 +20,14 @@ class Hon:
email: Optional[str] = "", email: Optional[str] = "",
password: Optional[str] = "", password: Optional[str] = "",
session: Optional[ClientSession] = None, session: Optional[ClientSession] = None,
test_data_path: Optional[Path] = None,
): ):
self._email: Optional[str] = email self._email: Optional[str] = email
self._password: Optional[str] = password self._password: Optional[str] = password
self._session: ClientSession | None = session self._session: ClientSession | None = session
self._appliances: List[HonAppliance] = [] self._appliances: List[HonAppliance] = []
self._api: Optional[HonAPI] = None self._api: Optional[HonAPI] = None
self._test_data_path: Path = test_data_path or Path().cwd()
async def __aenter__(self) -> Self: async def __aenter__(self) -> Self:
return await self.create() return await self.create()
@ -69,8 +73,10 @@ class Hon:
def appliances(self, appliances) -> None: def appliances(self, appliances) -> None:
self._appliances = appliances self._appliances = appliances
async def _create_appliance(self, appliance_data: Dict[str, Any], zone=0) -> None: async def _create_appliance(
appliance = HonAppliance(self._api, appliance_data, zone=zone) self, appliance_data: Dict[str, Any], api: HonAPI, zone=0
) -> None:
appliance = HonAppliance(api, appliance_data, zone=zone)
if appliance.mac_address == "": if appliance.mac_address == "":
return return
try: try:
@ -87,12 +93,20 @@ class Hon:
self._appliances.append(appliance) self._appliances.append(appliance)
async def setup(self) -> None: async def setup(self) -> None:
appliance: Dict appliances = await self.api.load_appliances()
for appliance in (await self.api.load_appliances())["payload"]["appliances"]: for appliance in appliances:
if (zones := int(appliance.get("zone", "0"))) > 1: if (zones := int(appliance.get("zone", "0"))) > 1:
for zone in range(zones): for zone in range(zones):
await self._create_appliance(appliance.copy(), zone=zone + 1) await self._create_appliance(
await self._create_appliance(appliance) appliance.copy(), self.api, zone=zone + 1
)
await self._create_appliance(appliance, self.api)
if (
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)
async def close(self) -> None: async def close(self) -> None:
await self.api.close() await self.api.close()

View File

@ -66,5 +66,18 @@ class HonParameter:
def triggers(self): def triggers(self):
result = {} result = {}
for value, rules in self._triggers.items(): for value, rules in self._triggers.items():
result[value] = {rule.param_key: rule.param_value for _, rule in rules} for _, rule in rules:
if rule.extras:
param = result.setdefault(value, {})
for extra_key, extra_value in rule.extras.items():
param = param.setdefault(extra_key, {}).setdefault(
extra_value, {}
)
else:
param = result.setdefault(value, {})
if fixed_value := rule.param_data.get("fixedValue"):
param[rule.param_key] = fixed_value
else:
param[rule.param_key] = rule.param_data.get("defaultValue", "")
return result return result

View File

@ -1,15 +1,9 @@
from typing import Dict, Any, List from typing import Dict, Any, List
from pyhon.helper import str_to_float
from pyhon.parameter.base import HonParameter 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): class HonParameterRange(HonParameter):
def __init__(self, key: str, attributes: Dict[str, Any], group: str) -> None: def __init__(self, key: str, attributes: Dict[str, Any], group: str) -> None:
super().__init__(key, attributes, group) super().__init__(key, attributes, group)

View File

@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Dict, TYPE_CHECKING from typing import List, Dict, TYPE_CHECKING, Any, Optional
from pyhon.parameter.enum import HonParameterEnum from pyhon.parameter.enum import HonParameterEnum
from pyhon.parameter.range import HonParameterRange from pyhon.parameter.range import HonParameterRange
@ -13,7 +13,8 @@ class HonRule:
trigger_key: str trigger_key: str
trigger_value: str trigger_value: str
param_key: str param_key: str
param_value: str param_data: Dict[str, Any]
extras: Optional[Dict[str, str]] = None
class HonRuleSet: class HonRuleSet:
@ -23,40 +24,82 @@ class HonRuleSet:
self._parse_rule(rule) self._parse_rule(rule)
def _parse_rule(self, rule): def _parse_rule(self, rule):
for entity_key, params in rule.items(): for param_key, params in rule.items():
entity_key = self._command.appliance.options.get(entity_key, entity_key) param_key = self._command.appliance.options.get(param_key, param_key)
for trigger_key, values in params.items(): for trigger_key, trigger_data in params.items():
self._parse_conditions(param_key, trigger_key, trigger_data)
def _parse_conditions(self, param_key, trigger_key, trigger_data, extra=None):
trigger_key = trigger_key.replace("@", "") trigger_key = trigger_key.replace("@", "")
trigger_key = self._command.appliance.options.get( trigger_key = self._command.appliance.options.get(trigger_key, trigger_key)
trigger_key, trigger_key for multi_trigger_value, param_data in trigger_data.items():
for trigger_value in multi_trigger_value.split("|"):
if isinstance(param_data, dict) and "typology" in param_data:
self._create_rule(
param_key, trigger_key, trigger_value, param_data, extra
) )
for trigger_value, entity_value in values.items(): elif isinstance(param_data, dict):
if entity_value.get("fixedValue") == f"@{entity_key}": if extra is None:
continue extra = {}
extra[trigger_key] = trigger_value
for extra_key, extra_data in param_data.items():
self._parse_conditions(param_key, extra_key, extra_data, extra)
def _create_rule(
self, param_key, trigger_key, trigger_value, param_data, extras=None
):
if param_data.get("fixedValue") == f"@{param_key}":
return
self._rules.setdefault(trigger_key, []).append( self._rules.setdefault(trigger_key, []).append(
HonRule( HonRule(trigger_key, trigger_value, param_key, param_data, extras)
trigger_key,
trigger_value,
entity_key,
entity_value.get("fixedValue"),
)
) )
def _duplicate_for_extra_conditions(self):
new = {}
for rules in self._rules.values():
for rule in rules:
if rule.extras is None:
continue
for key, value in rule.extras.items():
extras = rule.extras.copy()
extras.pop(key)
extras[rule.trigger_key] = rule.trigger_value
new.setdefault(key, []).append(
HonRule(key, value, rule.param_key, rule.param_data, extras)
)
for key, rules in new.items():
for rule in rules:
self._rules.setdefault(key, []).append(rule)
def _add_trigger(self, parameter, data):
def apply(rule: HonRule):
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)
def patch(self): def patch(self):
self._duplicate_for_extra_conditions()
for name, parameter in self._command.parameters.items(): for name, parameter in self._command.parameters.items():
if name not in self._rules: if name not in self._rules:
continue continue
for data in self._rules.get(name): for data in self._rules.get(name):
self._add_trigger(parameter, data)
def apply(rule):
if param := self._command.parameters.get(rule.param_key):
if isinstance(param, HonParameterEnum) and set(
param.values
) != {str(rule.param_value)}:
param.values = [str(rule.param_value)]
elif isinstance(param, HonParameterRange):
param.value = float(rule.param_value)
return
param.value = str(rule.param_value)
parameter.add_trigger(data.trigger_value, apply, data)

View File

@ -7,7 +7,7 @@ with open("README.md", "r") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.12.3", version="0.14.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,