From ca270aa26841506fba0a70fc1b71938cb9c13d6a Mon Sep 17 00:00:00 2001 From: James Nimmo Date: Thu, 3 Aug 2023 00:58:23 +0000 Subject: [PATCH] Improved error handling --- examples/cli_example.py | 1 - examples/dhw_aquarea_domoticz.py | 1 - pyintesishome/intesisbase.py | 15 ++-- pyintesishome/intesishome.py | 3 +- pyintesishome/intesishomelocal.py | 135 +++++++++++++++++++----------- requirements_test.txt | 2 +- setup.py | 2 +- 7 files changed, 98 insertions(+), 61 deletions(-) diff --git a/examples/cli_example.py b/examples/cli_example.py index c490659..8b04af3 100644 --- a/examples/cli_example.py +++ b/examples/cli_example.py @@ -69,7 +69,6 @@ async def main(loop): device_type=args.device, ) else: - controller = IntesisHome( args.user, args.password, diff --git a/examples/dhw_aquarea_domoticz.py b/examples/dhw_aquarea_domoticz.py index 7789c0c..d2bc34a 100755 --- a/examples/dhw_aquarea_domoticz.py +++ b/examples/dhw_aquarea_domoticz.py @@ -174,7 +174,6 @@ def aquarea_to_domoticz( async def main(loop): - username = "xxxxxx" password = "yyyyyyyyy" idd = "zzzzzzzz" diff --git a/pyintesishome/intesisbase.py b/pyintesishome/intesisbase.py index 901a17a..86d0db5 100644 --- a/pyintesishome/intesisbase.py +++ b/pyintesishome/intesisbase.py @@ -33,7 +33,7 @@ def __init__( loop=None, websession=None, device_type=DEVICE_INTESISHOME, - ): + ) -> None: """Initialize IntesisBox controller.""" # Select correct API for device type self._username = username @@ -50,7 +50,7 @@ def __init__( self._error_message = None self._web_session = websession self._own_session = False - self._controller_id = username + self._controller_id = None self._controller_name = username self._writer: StreamWriter = None self._reader: StreamReader = None @@ -87,8 +87,7 @@ async def _send_command(self, command: str): ) except asyncio.TimeoutError: print("oops took longer than 5s!") - # need to close the connection as device not responding due to hung state - self._writer.write.close() + await self.stop() except OSError as exc: _LOGGER.error("%s Exception. %s / %s", type(exc), exc.args, exc) @@ -106,8 +105,8 @@ async def _data_received(self): _LOGGER.debug("Resolving set_value's await") self._received_response.set() except IncompleteReadError: - _LOGGER.info( - "pyIntesisHome lost connection to the %s server.", self._device_type + _LOGGER.debug( + "pyIntesisHome lost connection to the %s server", self._device_type ) except asyncio.CancelledError: pass @@ -117,7 +116,7 @@ async def _data_received(self): OSError, ) as exc: _LOGGER.error( - "pyIntesisHome lost connection to the %s server. Exception: %s", + "PyIntesisHome lost connection to the %s server. Exception: %s", self._device_type, exc, ) @@ -519,7 +518,7 @@ def controller_id(self) -> str: """Returns an account/device identifier - Serial, MAC or username.""" if self._controller_id: return self._controller_id.lower() - return None + raise ValueError("Controller ID has not been set yet") @property def name(self) -> str: diff --git a/pyintesishome/intesishome.py b/pyintesishome/intesishome.py index cef706f..6ceac32 100644 --- a/pyintesishome/intesishome.py +++ b/pyintesishome/intesishome.py @@ -37,6 +37,7 @@ def __init__( self._cmd_server = None self._cmd_server_port = None self._auth_token = None + self._controller_id = username async def _parse_response(self, decoded_data): _LOGGER.debug("%s API Received: %s", self._device_type, decoded_data) @@ -151,7 +152,7 @@ async def poll_status(self, sendcallback=False): ) as resp: status_response = await resp.json(content_type=None) _LOGGER.debug(status_response) - except (aiohttp.client_exceptions.ClientConnectorError) as exc: + except aiohttp.client_exceptions.ClientConnectorError as exc: raise IHConnectionError from exc except (aiohttp.client_exceptions.ClientError, socket.gaierror) as exc: self._error_message = f"Error connecting to {self._device_type} API: {exc}" diff --git a/pyintesishome/intesishomelocal.py b/pyintesishome/intesishomelocal.py index 0626c9d..8c63067 100644 --- a/pyintesishome/intesishomelocal.py +++ b/pyintesishome/intesishomelocal.py @@ -2,6 +2,7 @@ import asyncio import logging +from json import JSONDecodeError import aiohttp @@ -25,7 +26,7 @@ class IntesisHomeLocal(IntesisBase): """pyintesishome local class.""" - def __init__(self, host, username, password, loop=None, websession=None): + def __init__(self, host, username, password, loop=None, websession=None) -> None: """Constructor""" device_type = DEVICE_INTESISHOME_LOCAL self._session_id: str = "" @@ -70,7 +71,7 @@ async def _run_updater(self): self._update_device_state(self._device_id, uid, value) await self._send_update_callback(self._device_id) - except (IHConnectionError) as exc: + except IHConnectionError as exc: _LOGGER.error("Error during updater task: %s", exc) await asyncio.sleep(self._scan_interval) except asyncio.CancelledError: @@ -83,14 +84,35 @@ async def _authenticate(self) -> bool: "command": LOCAL_CMD_LOGIN, "data": {"username": self._username, "password": self._password}, } - async with self._web_session.post( - f"http://{self._host}/api.cgi", json=payload - ) as response: - if response.status != 200: - raise IHConnectionError("HTTP response status is unexpected (not 200)") - json_response = await response.json() - self._session_id = json_response["data"].get("id").get("sessionID") - _LOGGER.debug("Authenticated with new session ID %s", self._session_id) + try: + async with self._web_session.post( + f"http://{self._host}/api.cgi", json=payload + ) as response: + if response.status != 200: + raise IHConnectionError( + "HTTP response status is unexpected (not 200)" + ) + json_response = await response.json() + # Check if the response has the expected format + if ( + "data" in json_response + and "id" in json_response["data"] + and "sessionID" in json_response["data"]["id"] + ): + self._session_id = json_response["data"]["id"]["sessionID"] + _LOGGER.debug( + "Authenticated with new session ID %s", self._session_id + ) + else: + _LOGGER.error("Unexpected response format during authentication") + except ( + aiohttp.ClientConnectionError, + aiohttp.ClientResponseError, + aiohttp.ClientPayloadError, + aiohttp.ContentTypeError, + JSONDecodeError, + ) as exception: + _LOGGER.error("Error during authentication: %s", str(exception)) async def _request(self, command: str, **kwargs) -> dict: """Make a request.""" @@ -105,7 +127,9 @@ async def _request(self, command: str, **kwargs) -> dict: "command": command, "data": {"sessionID": self._session_id, **kwargs}, } - _LOGGER.debug("Sending intesishome_local command %s to %s", command, self._host) + _LOGGER.debug( + "Sending intesishome_local command %s to %s", command, self._host + ) timeout = aiohttp.ClientTimeout(total=10) json_response = {} try: @@ -116,9 +140,8 @@ async def _request(self, command: str, **kwargs) -> dict: ) as response: if response.status != 200: raise IHConnectionError( - "HTTP response status is unexpected for %s (got %s, want 200)", - self._host, - response.status, + f"HTTP response status is unexpected for {self._host}" + "(got {response.status}, want 200)" ) json_response = await response.json() except asyncio.exceptions.TimeoutError as exc: @@ -127,7 +150,7 @@ async def _request(self, command: str, **kwargs) -> dict: self._host, exc, ) - except (aiohttp.ClientError) as exc: + except aiohttp.ClientError as exc: _LOGGER.error( "IntesisHome HTTP error for %s: %s", self._host, @@ -147,25 +170,30 @@ async def _request(self, command: str, **kwargs) -> dict: # wonky, so log an error plus the entire response. if json_response.get("success", False): return json_response.get("data") - elif "error" in json_response: + if "error" in json_response: error = json_response["error"] if error.get("code") in [1, 5]: self._session_id = "" - _LOGGER.debug("Request failed for %s (code=%s, message=%r). Clearing session key to force re-authentication", - self._host, - error.get("code"), - error.get("message"), - ) + _LOGGER.debug( + "Request failed for %s (code=%s, message=%r)." + "Clearing session key to force re-authentication", + self._host, + error.get("code"), + error.get("message"), + ) else: - _LOGGER.debug("Request failed for %s (code=%s, message=%r). Error not handled.", - self._host, - error.get("code"), - error.get("message"), - ) - else: - _LOGGER.debug("Request failed for %s - no 'success' or 'error' keys. json_response=%r", + _LOGGER.debug( + "Request failed for %s (code=%s, message=%r). Error not handled", self._host, - json_response) + error.get("code"), + error.get("message"), + ) + else: + _LOGGER.debug( + "Request failed for %s - no 'success' or 'error' keys. json_response=%r", + self._host, + json_response, + ) async def _request_value(self, name: str) -> dict: """Get entity value by uid.""" @@ -202,15 +230,15 @@ def _has_datapoint(self, datapoint: str): async def connect(self): """Connect to the device and start periodic updater.""" await self.poll_status() - _LOGGER.debug("Successful authenticated and polled. Fetching Datapoints.") + _LOGGER.debug("Successful authenticated and polled. Fetching Datapoints") await self.get_datapoints() self._connected = True - _LOGGER.debug("Starting updater task.") + _LOGGER.debug("Starting updater task") self._update_task = asyncio.create_task(self._run_updater()) async def stop(self): """Disconnect and stop periodic updater.""" - _LOGGER.debug("Stopping updater task.") + _LOGGER.debug("Stopping updater task") await self._cancel_task_if_exists(self._update_task) self._connected = False @@ -225,25 +253,36 @@ async def get_info(self) -> dict: async def poll_status(self, sendcallback=False): """Get device info for setup purposes.""" - await self._authenticate() - info = await self.get_info() - self._device_id = info["sn"] - self._controller_id = info["sn"].lower() - self._controller_name = f"{self._info['deviceModel']} ({info['ownSSID']})" - # Setup devices - self._devices[self._device_id] = { - "name": info["ownSSID"], - "widgets": [], - "model": info["deviceModel"], - } + try: + await self._authenticate() + info = await self.get_info() + + # Extract device_id up to the first space, if there is a space + raw_id = info.get("sn") + if raw_id: + device_id, *_ = raw_id.split(" ") + self._device_id = device_id + self._controller_id = device_id.lower() + + self._controller_name = ( + f"{self._info.get('deviceModel')} ({info.get('ownSSID')})" + ) + # Setup devices + self._devices[self._device_id] = { + "name": info.get("ownSSID"), + "widgets": [], + "model": info.get("deviceModel"), + } - await self.get_datapoints() - _LOGGER.debug(repr(self._devices)) + await self.get_datapoints() + _LOGGER.debug(repr(self._devices)) - self._update_device_state(self._device_id, "acStatus", info["acStatus"]) + self._update_device_state(self._device_id, "acStatus", info.get("acStatus")) - if sendcallback: - await self._send_update_callback(str(self._device_id)) + if sendcallback: + await self._send_update_callback(str(self._device_id)) + except (IHConnectionError, KeyError) as exception: + _LOGGER.error("Error during polling status: %s", str(exception)) def get_mode_list(self, device_id) -> list: """Get possible entity modes.""" diff --git a/requirements_test.txt b/requirements_test.txt index 28dda73..b72f5c6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ # types-* that have versions roughly corresponding to the packages they # contain hints for available should be kept in sync with them -codecov==2.1.12 +codecov==2.1.13 mock-open==1.4.0 mypy==0.971 pre-commit==2.20.0 diff --git a/setup.py b/setup.py index b4d1fab..c216855 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="pyintesishome", - version="1.8.4", + version="1.8.5", description="A python3 library for running asynchronus communications with IntesisHome Smart AC Controllers", long_description=long_description, long_description_content_type="text/markdown",