Compare commits

...

23 Commits

Author SHA1 Message Date
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
18 changed files with 243 additions and 41 deletions
+1 -1
View File
@@ -4,6 +4,6 @@ disable=missing-docstring
[FORMAT] [FORMAT]
max-args=6 max-args=7
max-attributes=8 max-attributes=8
max-line-length=88 max-line-length=88
+3 -6
View File
@@ -38,9 +38,7 @@ 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( 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())
@@ -73,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)
+2 -4
View File
@@ -72,12 +72,10 @@ 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:
+5 -5
View File
@@ -7,10 +7,10 @@ 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 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"] == "1" data["active"] = data["parameters"]["onOffStatus"].value == 1
return data return data
+1 -1
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 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
+4 -1
View File
@@ -89,8 +89,11 @@ 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]]) -> 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:
+16 -1
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,10 @@ 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,
).create() ).create()
return self return self
@@ -184,6 +192,13 @@ 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,
+4 -2
View File
@@ -199,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 = 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._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)
@@ -264,7 +264,9 @@ 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._auth.refresh_token, "refresh_token": self._auth.refresh_token,
+2 -3
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:
+16 -3
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,10 +49,17 @@ 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()
headers["cognito-token"] = self.auth.cognito_token headers["cognito-token"] = self.auth.cognito_token
+137
View File
@@ -0,0 +1,137 @@
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"):
self._client: mqtt5.Client | None = None
self._hon = hon
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()
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:
_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")
self._hon.notify()
elif topic and "connected" in topic:
_LOGGER.info("Connected %s", appliance.nick_name)
elif topic and "disconnected" in topic:
_LOGGER.info("Disconnected %s", appliance.nick_name)
elif topic and "discovery" in topic:
_LOGGER.info("Discovered %s", appliance.nick_name)
_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"{const.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():
await 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()
+7 -4
View File
@@ -1,13 +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"
CLIENT_ID = ( CLIENT_ID = (
"3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9." "3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9."
"HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6" "HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6"
) )
APP_VERSION = "2.4.7" APP_VERSION = "2.6.5"
OS_VERSION = 31 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"
+28 -4
View File
@@ -2,7 +2,7 @@ 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
@@ -10,6 +10,7 @@ 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__)
@@ -21,6 +22,8 @@ class Hon:
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
@@ -29,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()
@@ -61,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
@@ -103,11 +114,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).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()
+6 -1
View File
@@ -69,4 +69,9 @@ class HonParameterRange(HonParameter):
@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
+1 -2
View File
@@ -14,8 +14,7 @@ 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[
+2 -1
View File
@@ -1,3 +1,4 @@
aiohttp>=3.8 aiohttp>=3.8.6
yarl>=1.8 yarl>=1.8
typing-extensions>=4.8 typing-extensions>=4.8
awsiotsdk>=1.21.0
+1
View File
@@ -3,3 +3,4 @@ flake8>=6.0
mypy>=0.991 mypy>=0.991
pylint>=2.15 pylint>=2.15
setuptools>=62.3 setuptools>=62.3
types-awscrt
+7 -2
View File
@@ -7,7 +7,7 @@ with open("README.md", "r", encoding="utf-8") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.15.14", version="0.17.2",
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", "typing-extensions>=4.8", "yarl>=1.8"], 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",