Compare commits

..

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

28 changed files with 102 additions and 394 deletions

View file

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12"] python-version: ["3.10", "3.11"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -34,7 +34,7 @@ jobs:
mypy pyhon/ mypy pyhon/
- name: Analysing the code with pylint - name: Analysing the code with pylint
run: | run: |
pylint $(git ls-files '*.py') pylint $(git ls-files '*.py')
- name: Check black style - name: Check black style
run: | run: |
black . --check black . --check

View file

@ -4,6 +4,6 @@ disable=missing-docstring
[FORMAT] [FORMAT]
max-args=7 max-args=6
max-attributes=8 max-attributes=8
max-line-length=88 max-line-length=88

View file

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

View file

@ -38,7 +38,9 @@ def get_arguments() -> Dict[str, Any]:
"translate", help="language (de, en, fr...)", metavar="LANGUAGE" "translate", help="language (de, en, fr...)", metavar="LANGUAGE"
) )
translation.add_argument("--json", help="print as json", action="store_true") translation.add_argument("--json", help="print as json", action="store_true")
parser.add_argument("-i", "--import", help="import pyhon data", nargs="?") parser.add_argument(
"-i", "--import", help="import pyhon data", nargs="?", default=Path().cwd()
)
return vars(parser.parse_args()) return vars(parser.parse_args())
@ -71,8 +73,9 @@ 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
test_data_path = Path(path) if (path := args.get("import", "")) else None async with Hon(
async with Hon(*get_login_data(args), test_data_path=test_data_path) as hon: *get_login_data(args), test_data_path=Path(args.get("import", ""))
) 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)

View file

@ -20,7 +20,7 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar('T')
# pylint: disable=too-many-public-methods,too-many-instance-attributes # pylint: disable=too-many-public-methods,too-many-instance-attributes
@ -43,10 +43,6 @@ 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(
@ -76,10 +72,12 @@ class HonAppliance:
return self.info[item] return self.info[item]
@overload @overload
def get(self, item: str, default: None = None) -> Any: ... def get(self, item: str, default: None = None) -> Any:
...
@overload @overload
def get(self, item: str, default: T) -> T: ... def get(self, item: str, default: T) -> T:
...
def get(self, item: str, default: Optional[T] = None) -> Any: def get(self, item: str, default: Optional[T] = None) -> Any:
try: try:
@ -94,25 +92,17 @@ 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 str(self._info.get("applianceModelId", "")) return self._info.get("applianceModelId", "")
@property @property
def appliance_type(self) -> str: def appliance_type(self) -> str:
return str(self._info.get("applianceTypeName", "")) return self._info.get("applianceTypeName", "")
@property @property
def mac_address(self) -> str: def mac_address(self) -> str:
return str(self.info.get("macAddress", "")) return self.info.get("macAddress", "")
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
@ -148,11 +138,11 @@ class HonAppliance:
@property @property
def model_id(self) -> int: def model_id(self) -> int:
return int(self._info.get("applianceModelId", 0)) return self._info.get("applianceModelId", 0)
@property @property
def options(self) -> Dict[str, Any]: def options(self) -> Dict[str, Any]:
return dict(self._appliance_model.get("options", {})) return self._appliance_model.get("options", {})
@property @property
def commands(self) -> Dict[str, HonCommand]: def commands(self) -> Dict[str, HonCommand]:
@ -287,12 +277,7 @@ 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( def sync_command(self, main: str, target: Optional[List[str] | str] = None) -> None:
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
@ -302,12 +287,7 @@ class HonAppliance:
for name, target_param in data.parameters.items(): for name, target_param in data.parameters.items():
if not (base_param := base.parameters.get(name)): if not (base_param := base.parameters.get(name)):
continue return
if to_sync and (
(isinstance(to_sync, list) and name not in to_sync)
or not base_param.mandatory
):
continue
self.sync_parameter(base_param, target_param) self.sync_parameter(base_param, target_param)
def sync_parameter(self, main: Parameter, target: Parameter) -> None: def sync_parameter(self, main: Parameter, target: Parameter) -> None:

View file

@ -7,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 not self.parent.connection: if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
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

@ -6,11 +6,11 @@ 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 not self.parent.connection: if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
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"].value == 1 data["active"] = data["parameters"]["onOffStatus"] == "1"
return data return data

View file

@ -8,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 not self.parent.connection: if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
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

@ -7,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 not self.parent.connection: if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
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,16 +1,11 @@
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.base import HonParameter
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)
parameter = data.get("parameters", {}).get("onOffStatus") data["active"] = data["parameters"]["onOffStatus"] == "1"
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 data
return settings

View file

@ -109,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 create HonCommand object""" """Try to crate 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
@ -184,44 +184,23 @@ 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, command_name, base = self._get_favourite_info(favourite) name = favourite.get("favouriteName", {})
if not base: command = favourite.get("command", {})
command_name = command.get("commandName", "")
program_name = self._clean_name(command.get("programName", ""))
if not (base := self.commands[command_name].categories.get(program_name)):
continue continue
base_command: HonCommand = copy(base) base_command: HonCommand = copy(base)
self._update_base_command_with_data(base_command, favourite) for data in command.values():
self._update_base_command_with_favourite(base_command) if isinstance(data, str):
self._update_program_categories(command_name, name, base_command)
def _get_favourite_info(
self, favourite: Dict[str, Any]
) -> tuple[str, str, HonCommand | None]:
name: str = favourite.get("favouriteName", {})
command = favourite.get("command", {})
command_name: str = command.get("commandName", "")
program_name = self._clean_name(command.get("programName", ""))
base_command = self.commands[command_name].categories.get(program_name)
return name, command_name, base_command
def _update_base_command_with_data(
self, base_command: HonCommand, command: Dict[str, Any]
) -> None:
for data in command.values():
if isinstance(data, str):
continue
for key, value in data.items():
if not (parameter := base_command.parameters.get(key)):
continue continue
with suppress(ValueError): for key, value in data.items():
parameter.value = value if parameter := base_command.parameters.get(key):
with suppress(ValueError):
def _update_base_command_with_favourite(self, base_command: HonCommand) -> None: parameter.value = value
extra_param = HonParameterFixed("favourite", {"fixedValue": "1"}, "custom") extra_param = HonParameterFixed("favourite", {"fixedValue": "1"}, "custom")
base_command.parameters.update(favourite=extra_param) base_command.parameters.update(favourite=extra_param)
program = base_command.parameters["program"]
def _update_program_categories( if isinstance(program, HonParameterProgram):
self, command_name: str, name: str, base_command: HonCommand program.set_value(name)
) -> None: self.commands[command_name].categories[name] = base_command
program = base_command.parameters["program"]
if isinstance(program, HonParameterProgram):
program.set_value(name)
self.commands[command_name].categories[name] = base_command

View file

@ -89,11 +89,8 @@ class HonCommand:
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] | Any]) -> None: def _load_parameters(self, attributes: Dict[str, Dict[str, 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:
@ -105,12 +102,10 @@ 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" in data: if "fixedValue" not in data:
self._rules.append(HonRuleSet(self, data["fixedValue"])) _LOGGER.error("Rule not supported: %s", data)
elif "enumValues" in data:
self._rules.append(HonRuleSet(self, data["enumValues"]))
else: else:
_LOGGER.warning("Rule not supported: %s", data) self._rules.append(HonRuleSet(self, data["fixedValue"]))
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)
@ -135,23 +130,17 @@ class HonCommand:
async def send_specific(self, param_names: List[str]) -> bool: async def send_specific(self, param_names: List[str]) -> bool:
params: Dict[str, str | float] = {} params: Dict[str, str | float] = {}
for key, parameter in self._parameters.items(): for key, parameter in self._parameters.items():
if key in param_names or parameter.mandatory: if key in param_names:
params[key] = parameter.value params[key] = parameter.value
return await self.send_parameters(params) return await self.send_parameters(params)
async def send_parameters(self, params: Dict[str, str | float]) -> bool: 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._appliance, self._name, params, ancillary_params
self._name,
params,
ancillary_params,
self._category_name,
) )
if not result: if not result:
_LOGGER.error(result) _LOGGER.error(result)

View file

@ -18,23 +18,18 @@ 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
@ -74,11 +69,7 @@ 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._email, self._password, self._session
self._password,
session=self._session,
mobile_id=self._mobile_id,
refresh_token=self._refresh_token,
).create() ).create()
return self return self
@ -193,20 +184,12 @@ 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] = {
@ -225,8 +208,6 @@ 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()
@ -338,12 +319,6 @@ 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( _LOGGER.info("%s - %s", str(parameters), str(ancillary_parameters))
"%s - %s - %s",
str(parameters),
str(ancillary_parameters),
str(program_name),
)
return True return True

View file

@ -120,23 +120,18 @@ 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|href) ?= ?'(.+?)'", text) login_url: List[str] = re.findall("url = '(.+?)'", 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:
new_location = response.headers.get("Location", "") if not (new_location := response.headers.get("Location", "")):
if not new_location: await self._error_logger(response)
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:
@ -204,7 +199,7 @@ class HonAuth:
if access_token := re.findall("access_token=(.*?)&", text): if access_token := re.findall("access_token=(.*?)&", text):
self._auth.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._auth.refresh_token = parse.unquote(refresh_token[0]) self._auth.refresh_token = refresh_token[0]
if id_token := re.findall("id_token=(.*?)&", text): if id_token := re.findall("id_token=(.*?)&", text):
self._auth.id_token = id_token[0] self._auth.id_token = id_token[0]
return bool(access_token and refresh_token and id_token) return bool(access_token and refresh_token and id_token)
@ -269,9 +264,7 @@ class HonAuth:
except exceptions.HonNoAuthenticationNeeded: except exceptions.HonNoAuthenticationNeeded:
return return
async def refresh(self, refresh_token: str = "") -> bool: async def refresh(self) -> 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._auth.refresh_token, "refresh_token": self._auth.refresh_token,

View file

@ -1,15 +1,16 @@
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, mobile_id: str = "") -> None: def __init__(self) -> 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 = mobile_id or const.MOBILE_ID self._mobile_id: str = secrets.token_hex(8)
@property @property
def app_version(self) -> str: def app_version(self) -> str:

View file

@ -19,18 +19,12 @@ _LOGGER = logging.getLogger(__name__)
class HonConnectionHandler(ConnectionHandler): class HonConnectionHandler(ConnectionHandler):
def __init__( def __init__(
self, self, email: str, password: str, session: Optional[aiohttp.ClientSession] = None
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(mobile_id) self._device: HonDevice = HonDevice()
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:
@ -49,20 +43,12 @@ class HonConnectionHandler(ConnectionHandler):
async def create(self) -> Self: async def create(self) -> Self:
await super().create() await super().create()
self._auth = HonAuth( self._auth = HonAuth(self.session, self._email, self._password, self._device)
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
@ -78,7 +64,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(self._refresh_token) await self.auth.refresh()
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:

View file

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

View file

@ -1,16 +1,13 @@
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"
CLIENT_ID = ( CLIENT_ID = (
"3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9." "3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9."
"HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6" "HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6"
) )
APP_VERSION = "2.6.5" APP_VERSION = "2.1.2"
OS_VERSION = 999 OS_VERSION = 31
OS = "android" OS = "android"
DEVICE_MODEL = "pyhOn" DEVICE_MODEL = "exynos9820"
USER_AGENT = "Chrome/999.999.999.999" USER_AGENT = "Chrome/110.0.5481.153"
MOBILE_ID = "pyhOn"

View file

@ -1,7 +1,8 @@
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, Callable from typing import List, Optional, Dict, Any, Type
from aiohttp import ClientSession from aiohttp import ClientSession
from typing_extensions import Self from typing_extensions import Self
@ -9,21 +10,17 @@ from typing_extensions import Self
from pyhon.appliance import HonAppliance from pyhon.appliance import HonAppliance
from pyhon.connection.api import HonAPI 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 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
@ -32,10 +29,6 @@ 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()
@ -68,11 +61,7 @@ class Hon:
async def create(self) -> Self: async def create(self) -> Self:
self._api = await HonAPI( self._api = await HonAPI(
self.email, self.email, self.password, session=self._session
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
@ -92,9 +81,13 @@ class Hon:
if appliance.mac_address == "": if appliance.mac_address == "":
return return
try: try:
await appliance.load_commands() await asyncio.gather(
await appliance.load_attributes() *[
await appliance.load_statistics() appliance.load_attributes(),
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)
@ -110,24 +103,11 @@ class Hon:
) )
await self._create_appliance(appliance, self.api) await self._create_appliance(appliance, self.api)
if ( if (
self._test_data_path test_data := self._test_data_path / "hon-test-data" / "test_data"
and ( ).exists() or (test_data := test_data / "test_data").exists():
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

@ -68,9 +68,8 @@ 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:
triggers = {str(k).lower(): v for k, v in self._triggers.items()} if str(value) in self._triggers:
if str(value).lower() in triggers: for trigger in self._triggers[str(value)]:
for trigger in triggers[str(value)]:
func, args = trigger func, args = trigger
func(args) func(args)

View file

@ -18,7 +18,7 @@ class HonParameterFixed(HonParameter):
@property @property
def value(self) -> str | float: def value(self) -> str | float:
return self._value if self._value != "" else "0" return self._value if self._value is not None else "0"
@value.setter @value.setter
def value(self, value: str | float) -> None: def value(self, value: str | float) -> None:

View file

@ -45,7 +45,7 @@ class HonParameterProgram(HonParameterEnum):
for name, parameter in self._programs.items(): for name, parameter in self._programs.items():
if "iot_" in name: if "iot_" in name:
continue continue
if not parameter.parameters.get("prCode"): if parameter.parameters.get("prCode"):
continue continue
if (fav := parameter.parameters.get("favourite")) and fav.value == "1": if (fav := parameter.parameters.get("favourite")) and fav.value == "1":
continue continue

View file

@ -69,9 +69,4 @@ class HonParameterRange(HonParameter):
@property @property
def values(self) -> List[str]: def values(self) -> List[str]:
result = [] return [str(i) for i in range(int(self.min), int(self.max) + 1, int(self.step))]
i = self.min
while i <= self.max:
result.append(str(i))
i += self.step
return result

View file

@ -56,11 +56,6 @@ 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,
@ -107,10 +102,6 @@ class HonRuleSet:
param.values = [str(value)] param.values = [str(value)]
param.value = str(value) param.value = str(value)
elif isinstance(param, HonParameterRange): 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) param.value = float(value)
return return
param.value = str(value) param.value = str(value)

View file

@ -14,7 +14,8 @@ if TYPE_CHECKING:
class Callback(Protocol): # pylint: disable=too-few-public-methods 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,4 +1,3 @@
aiohttp>=3.8.6 aiohttp==3.8.5
yarl>=1.8 yarl==1.9.2
typing-extensions>=4.8 typing-extensions==4.7.1
awsiotsdk>=1.21.0

View file

@ -1,6 +1,4 @@
black>=22.12 black==23.7.0
flake8>=6.0 flake8==6.0.0
mypy>=0.991 mypy==1.4.1
pylint>=2.15 pylint==2.17.4
setuptools>=62.3
types-awscrt

View file

@ -7,7 +7,7 @@ with open("README.md", "r", encoding="utf-8") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.17.5", version="0.15.1",
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,12 +21,7 @@ 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=[ install_requires=["aiohttp==3.8.5", "typing-extensions==4.7.1"],
"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",
@ -35,7 +30,6 @@ 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={