Compare commits

...

65 commits

Author SHA1 Message Date
Andre Basche 59e3d9949f
Merge pull request #29 from addshore/updatedLogin
Update for new hOn login
2024-08-14 22:42:39 +02:00
addshore 3f39be53f3 Bump 0.17.5 2024-07-10 12:47:30 +01:00
addshore afb5b8c3ea Update for new hOn login
https://github.com/Andre0512/hon/issues/230
2024-07-10 12:39:18 +01:00
Andre Basche 63752e13d6 Fix connection failures 2024-04-09 22:47:28 +02:00
Andre Basche 6aa7dd8112 Bump version 2024-03-29 14:40:13 +01:00
Andre Basche 86c2956d69 Handle mqtt connection events 2024-03-29 14:39:08 +01:00
Andre Basche bdf9d31be3 Fix missing program name Andre0512/hon#188 2024-03-29 13:51:35 +01:00
Andre Basche 53691e383e Use correct mobile id 2024-03-29 13:21:49 +01:00
Andre Basche 11da4ebfbc Improve mqtt client 2024-03-29 01:10:27 +01:00
Andre Basche bef55f7abc Fix checks 2024-03-26 00:19:54 +01:00
Andre Basche 79cabfd7b2 Bump version 2024-03-26 00:15:56 +01:00
Andre Basche 1583e6beaa Fix missing update when using same client_id 2024-03-26 00:15:26 +01:00
Andre Basche 33f34e1c20 Bump version 2024-03-25 02:15:25 +01:00
Andre Basche 7e59f76784 subscribe to updates 2024-03-25 02:14:17 +01:00
Andre Basche f108005a4d Support cloud push with wss mqtt 2024-03-18 19:59:38 +01:00
Andre Basche a1347f7a46
Bump version 2024-03-18 01:08:52 +01:00
Vadym 8ef5bd9889
Range.min is always skipped (#23) 2024-03-18 01:06:46 +01:00
Andre Basche 6162cd3f98 Bump version 2024-02-11 05:11:21 +01:00
Andre Basche e225a850bc Fix checks 2024-02-11 05:11:21 +01:00
Andre Basche c4e8038652 Fix problems with refresh token 2024-02-11 05:11:21 +01:00
Andre Basche 767cbe35de Set refresh token 2024-02-11 05:11:21 +01:00
Andre Basche df27729c2e Change mobile id 2024-02-11 05:11:21 +01:00
Andre Basche a5595f47bf Fix another issue with data path 2024-02-11 05:11:21 +01:00
Andre Basche 29c936b045 Fix path for #22 2024-02-11 05:11:21 +01:00
Andre Basche 2a74000ff3 Update requirements 2024-02-11 05:11:21 +01:00
Andre Basche 8a46bd4b53 Fix for float steps #22 2024-02-11 05:11:21 +01:00
Andre Basche 3acd12f345
Update README.md 2024-02-02 23:49:49 +01:00
Andre Basche 327d4a1814
Update README.md 2024-01-15 10:36:18 +01:00
Andre Basche 4dc60c29ee Fix crash in loading attributs Andre0512/hon#134 2024-01-11 01:21:38 +01:00
Andre Basche 7bc9e718a0
Merge pull request #17 from MiguelAngelLV/main
Use class attribute to check active
2023-11-26 13:43:44 +01:00
Mguel Ángel d4e5793186 Fix check active oven 2023-11-26 13:36:41 +01:00
Andre Basche e9f2bb9f4f Send program name Andre0512/hon#124 2023-11-21 02:23:53 +01:00
Andre Basche ea81e2891f Bump version 2023-11-20 17:51:32 +01:00
Andre Basche c71f8f17f5 Fix error in wh 2023-11-20 17:50:28 +01:00
Andre Basche 27d974abba Fix dependencies 2023-11-20 16:38:39 +01:00
Andre Basche ab18e45f97 Add python3.12 support 2023-11-19 23:53:21 +01:00
Andre Basche e44b9b6773 Update mypy 2023-10-12 22:26:12 +02:00
Andre Basche 5c650d722b Bump version 2023-10-12 22:16:02 +02:00
Vadym 6ae40481e3
Issue with sync_command (#16)
* Added water heater appliance. Added ability to send only mandatory parameters

* fixed build

* formatting

* cleanup

* cleanup

* reformatting

* Added ability to send specific parameters. Useful in case the command has many not mandatory parameters and you want to send only one/few

* cleanup

* Fixed code style

* sync_command - fixed typos, skip to sync(actually reset) parameters of different types. Improved WaterHeater appliance

* cleanup

* cleanup

* clean code style

* check if base parameter is mandatory

* Reverted back sync_command, send mandatory parameters beside with specified

---------

Co-authored-by: Vadym Melnychuk <vme@primexm.com>
2023-10-12 16:43:41 +02:00
Andre Basche ff8ae160bb Fix empty prStr 2023-10-06 01:29:38 +02:00
Andre Basche 658e80a8f4 Change dependencies to variable major version 2023-10-02 03:21:51 +02:00
Andre Basche ffba85bf0d Bump version 2023-10-02 01:38:54 +02:00
Andre Basche 10c8d961c4 Support new style rules hon#112 2023-10-02 01:38:40 +02:00
Andre Basche 61dd470588 Set versions of dependant packages to 'compatible releases' 2023-07-27 19:16:23 +02:00
Andre Basche 1ed81c2a77 Simplify get favorites 2023-07-24 02:33:45 +02:00
Andre Basche e4dc3cb1d0 Next try to add py.typed in package 2023-07-24 01:47:45 +02:00
Andre Basche 2523069ce9 Fix false name caused by chatgpt's wrong advice 2023-07-23 23:18:09 +02:00
Andre Basche eeb458cb1b Add py.typed into package 2023-07-23 22:54:46 +02:00
Andre Basche 2764700bc7 Bump version 2023-07-23 21:56:16 +02:00
Andre Basche e6c796e822 Improve type hints 2023-07-23 21:55:42 +02:00
Andre Basche 454f2d8916 Use equal mypy cnofig as home assistant 2023-07-22 12:39:50 +02:00
Andre Basche 59ca8b6caf Not loading favorite if base program renamed 2023-07-22 11:53:39 +02:00
Andre Basche 44c55c681d Update requirements 2023-07-20 23:55:40 +02:00
Andre Basche cfee10df5f Improve logging for test api 2023-07-20 23:52:46 +02:00
Andre Basche e0774677eb Add and apply some mypy rules 2023-07-20 23:52:07 +02:00
Andre Basche fc60d15e60 Fix error for fridge without quickmode 2023-07-19 23:55:37 +02:00
Andre Basche 8ef8c0405d Fix empty value in settings 2023-07-19 19:52:21 +02:00
Andre Basche 5575b240e1 Bump version 2023-07-18 21:32:12 +02:00
Andre Basche 22367242a2 Add flake8 config 2023-07-18 21:31:26 +02:00
Vadym 4f7d4860db
Water Heater. Ability to send only mandatory parameters (#14)
* Added water heater appliance. Added ability to send only mandatory parameters

* fixed build

* formatting

* cleanup

* cleanup

* reformatting

* Added ability to send specific parameters. Useful in case the command has many not mandatory parameters and you want to send only one/few

* cleanup

* Fixed code style

---------

Co-authored-by: Vadym Melnychuk <vme@primexm.com>
2023-07-18 21:26:11 +02:00
Andre Basche 5a778373b6 Enable more pylint checks 2023-07-16 05:53:23 +02:00
Andre Basche e1c8bc5835 Reduce complexity and line length for flake8 2023-07-16 04:42:29 +02:00
Andre Basche 442e7a07dd Fix pylint check 2023-07-14 00:40:48 +02:00
Andre Basche b0e3b15ff0 Add pylint checks 2023-07-12 19:36:32 +02:00
Andre Basche 2788a3ac91 Reset commands to defaults 2023-07-12 00:05:27 +02:00
36 changed files with 694 additions and 261 deletions

3
.flake8 Normal file
View file

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

View file

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

9
.pylintrc Normal file
View file

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

1
MANIFEST.in Normal file
View file

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

View file

@ -1,9 +1,24 @@
[mypy] [mypy]
check_untyped_defs = True check_untyped_defs = true
disallow_any_generics = True disallow_any_generics = true
disallow_untyped_defs = True disallow_any_unimported = true
disallow_any_unimported = True disallow_incomplete_defs = true
no_implicit_optional = True disallow_subclassing_any = true
warn_return_any = True disallow_untyped_calls = true
show_error_codes = True disallow_untyped_decorators = true
warn_unused_ignores = 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

16
pyhon/appliances/wh.py Normal file
View file

@ -0,0 +1,16 @@
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,3 +1,4 @@
# pylint: disable=duplicate-code
from typing import Any, Dict from typing import Any, Dict
from pyhon.appliances.base import ApplianceBase from pyhon.appliances.base import ApplianceBase

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

146
pyhon/connection/mqtt.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -6,14 +6,19 @@ from pyhon.parameter.base import HonParameter
class HonParameterFixed(HonParameter): class HonParameterFixed(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)
self._value = attributes.get("fixedValue", None) self._value: str | float = ""
self._set_attributes()
def _set_attributes(self) -> None:
super()._set_attributes()
self._value = self._attributes.get("fixedValue", "")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.__class__} (<{self.key}> fixed)" return f"{self.__class__} (<{self.key}> fixed)"
@property @property
def value(self) -> str | float: def value(self) -> str | float:
return self._value if self._value is not None else "0" return self._value if self._value != "" else "0"
@value.setter @value.setter
def value(self, value: str | float) -> None: def value(self, value: str | float) -> None:

View file

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

View file

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

View file

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

0
pyhon/py.typed Normal file
View file

View file

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

View file

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

View file

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

View file

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

View file

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