From f3496c59c987113e3fb7a51a7d300ae3e45fed8d Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Fri, 9 Aug 2024 15:05:02 +0200 Subject: [PATCH] Remove awsiotsdk dependency --- pyhon/connection/auth.py | 15 +++-- pyhon/connection/mqtt.py | 129 ++++++++++++++------------------------- requirements.txt | 2 +- requirements_dev.txt | 2 +- setup.py | 4 +- 5 files changed, 60 insertions(+), 92 deletions(-) diff --git a/pyhon/connection/auth.py b/pyhon/connection/auth.py index 2096010..385f145 100644 --- a/pyhon/connection/auth.py +++ b/pyhon/connection/auth.py @@ -6,7 +6,7 @@ import urllib from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Dict, Optional, Any, List +from typing import Any, Dict, List, Optional from urllib import parse from urllib.parse import quote @@ -120,18 +120,25 @@ class HonAuth: async with self._request.get(url) as response: text = await response.text() self._expires = datetime.utcnow() - login_url: List[str] = re.findall("url = '(.+?)'", text) + login_url: List[str] = re.findall("(?:url|href) ?= ?'(.+?)'", text) if not login_url: if "oauth/done#access_token=" in text: self._parse_token_data(text) raise exceptions.HonNoAuthenticationNeeded() await self._error_logger(response) + # As of July 2024 the login page has changed, + # and we started getting a /NewhOnLogin based relative URL in JS to parse + if login_url[0].startswith("/NewhOnLogin"): + # Force use of the old login page to avoid having + # to make the new one work.. + login_url[0] = f"{const.AUTH_API}/s/login{login_url[0]}" return login_url[0] async def _manual_redirect(self, url: str) -> str: async with self._request.get(url, allow_redirects=False) as response: - if not (new_location := response.headers.get("Location", "")): - await self._error_logger(response) + new_location = response.headers.get("Location", "") + if not new_location: + return url return new_location async def _handle_redirects(self, login_url: str) -> str: diff --git a/pyhon/connection/mqtt.py b/pyhon/connection/mqtt.py index 7c60a53..10be890 100644 --- a/pyhon/connection/mqtt.py +++ b/pyhon/connection/mqtt.py @@ -1,33 +1,32 @@ -import asyncio import json import logging import secrets from typing import TYPE_CHECKING +from urllib.parse import urlencode -from awscrt import mqtt5 -from awsiot import mqtt5_client_builder # type: ignore[import-untyped] +from paho.mqtt.client import Client, MQTTv5 from pyhon import const -from pyhon.appliance import HonAppliance if TYPE_CHECKING: + from paho.mqtt.client import MQTTMessage, _UserData + from pyhon import Hon + from pyhon.appliance import HonAppliance _LOGGER = logging.getLogger(__name__) class MQTTClient: def __init__(self, hon: "Hon", mobile_id: str) -> None: - self._client: mqtt5.Client | None = None + self._client: 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: + def client(self) -> Client: if self._client is not None: return self._client raise AttributeError("Client is not set") @@ -35,112 +34,74 @@ class MQTTClient: 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( + def _on_message( self, - lifecycle_connect_success_data: mqtt5.LifecycleConnectSuccessData, + client: Client, # pylint: disable=unused-argument + userdata: "_UserData", # pylint: disable=unused-argument + message: "MQTTMessage", ) -> 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): + if not message.payload or not message.topic: return - payload = json.loads(data.publish_packet.payload.decode()) - topic = data.publish_packet.topic + + payload = json.loads(message.payload) + topic = message.topic appliance = next( a for a in self._appliances if topic in a.info["topics"]["subscribe"] ) - if topic and "appliancestatus" in topic: + + topic_parts = topic.split("/") + if "appliancestatus" in topic_parts: for parameter in payload["parameters"]: appliance.attributes["parameters"][parameter["parName"]].update( parameter ) appliance.sync_params_to_command("settings") - elif topic and "disconnected" in topic: + elif "disconnected" in topic_parts: _LOGGER.info( "Disconnected %s: %s", appliance.nick_name, payload.get("disconnectReason"), ) appliance.connection = False - elif topic and "connected" in topic: + elif "connected" in topic_parts: appliance.connection = True _LOGGER.info("Connected %s", appliance.nick_name) - elif topic and "discovery" in topic: + elif "discovery" in topic_parts: _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, + self._client = Client( 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, + protocol=MQTTv5, + reconnect_on_failure=True, ) - self.client.start() + + self._client.on_message = self._on_message + self._client.enable_logger(_LOGGER) + + query_params = urlencode( + { + "x-amz-customauthorizer-name": const.AWS_AUTHORIZER, + "x-amz-customauthorizer-signature": await self._api.load_aws_token(), + "token": self._api.auth.id_token, + } + ) + + self._client.username_pw_set(f"?{query_params}") + + self._client.connect_async(const.AWS_ENDPOINT, 443) + self._client.loop_start() def _subscribe_appliances(self) -> None: for appliance in self._appliances: self._subscribe(appliance) - def _subscribe(self, appliance: HonAppliance) -> None: + 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() + if self._client: + self._client.subscribe(topic) + _LOGGER.info("Subscribed to topic %s", topic) diff --git a/requirements.txt b/requirements.txt index 6d63be7..b40476a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ aiohttp>=3.8.6 yarl>=1.8 typing-extensions>=4.8 -awsiotsdk>=1.21.0 \ No newline at end of file +paho-mqtt==1.6.1 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 277568c..991f88d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,4 +3,4 @@ flake8>=6.0 mypy>=0.991 pylint>=2.15 setuptools>=62.3 -types-awscrt \ No newline at end of file +types-paho-mqtt \ No newline at end of file diff --git a/setup.py b/setup.py index 115205d..83610d9 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md", "r", encoding="utf-8") as f: setup( name="pyhOn", - version="0.17.4", + version="0.17.5", author="Andre Basche", description="Control hOn devices with python", long_description=long_description, @@ -25,7 +25,7 @@ setup( "aiohttp>=3.8.6", "typing-extensions>=4.8", "yarl>=1.8", - "awsiotsdk>=1.21.0", + "paho-mqtt==1.6.1", ], classifiers=[ "Development Status :: 4 - Beta",