From 2efd5f32911d2931208ec22361d8742fd4d14127 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sat, 13 May 2023 04:29:58 -0400 Subject: [PATCH 01/84] move login query to _do_login_query, update parameters --- pyadtpulse/__init__.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index b1e0bf1..9e407ce 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -524,6 +524,24 @@ def loop(self) -> Optional[asyncio.AbstractEventLoop]: with self._attribute_lock: return self._loop + async def _do_login_query( + self, force_login: bool = False, timeout: int = 30 + ) -> ClientResponse | None: + return await self._async_query( + ADT_LOGIN_URI, + method="POST", + extra_params={ + "partner": "adt", + "e": "ns", + "usernameForm": self.username, + "passwordForm": self._password, + "fingerprint": self._fingerprint, + "sun": "yes", + }, + force_login=force_login, + timeout=timeout, + ) + async def async_login(self) -> bool: """Login asynchronously to ADT. @@ -540,20 +558,7 @@ async def async_login(self) -> bool: LOG.debug(f"Authenticating to ADT Pulse cloud service as {self._username}") await self._async_fetch_version() - response = await self._async_query( - ADT_LOGIN_URI, - method="POST", - extra_params={ - "partner": "adt", - "usernameForm": self.username, - "passwordForm": self._password, - "fingerprint": self._fingerprint, - "sun": "yes", - }, - force_login=False, - timeout=30, - ) - + response = await self._do_login_query() if not handle_response( response, logging.ERROR, From ccaa57bedc37a3ab1586062df4a79c2c3f621afb Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sat, 13 May 2023 05:29:00 -0400 Subject: [PATCH 02/84] handle retry-after for http codes 429/503 --- pyadtpulse/__init__.py | 52 +++++++++++++++++++++++++++++++++++------- pyadtpulse/site.py | 1 - 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 9e407ce..966d6e0 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -2,8 +2,9 @@ import logging import asyncio -import re import time +import re +import datetime from random import uniform from threading import Lock, RLock, Thread from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -415,7 +416,36 @@ def _close_response(self, response: Optional[ClientResponse]) -> None: if response is not None and not response.closed: response.close() + def _check_retry_after( + self, response: Optional[ClientResponse], task_name: str + ) -> int: + if response is None: + return 0 + header_value = response.headers.get("Retry-After") + if header_value is None: + return 0 + if header_value.isnumeric(): + retval = int(header_value) + else: + try: + retval = ( + datetime.datetime.strptime(header_value, "%a, %d %b %G %T %Z") + - datetime.datetime.now() + ).seconds + except ValueError: + return 0 + reason = "Unknown" + if response.status == 429: + reason = "Too many requests" + elif response.status == 503: + reason = "Service unavailable" + LOG.warning(f"Task {task_name} received Retry-After {retval} due to {reason}") + return retval + + return retval + async def _keepalive_task(self) -> None: + retry_after = 0 if self._timeout_task is not None: task_name = self._timeout_task.get_name() else: @@ -429,12 +459,13 @@ async def _keepalive_task(self) -> None: ) while self._authenticated.is_set(): try: - await asyncio.sleep(ADT_TIMEOUT_INTERVAL) + await asyncio.sleep(ADT_TIMEOUT_INTERVAL + retry_after) LOG.debug("Resetting timeout") response = await self._async_query(ADT_TIMEOUT_URI, "POST") if handle_response( response, logging.INFO, "Failed resetting ADT Pulse cloud timeout" ): + retry_after = self._check_retry_after(response, "Keepalive task") self._close_response(response) continue self._close_response(response) @@ -656,10 +687,9 @@ async def _sync_check_task(self) -> None: LOG.debug(f"creating {task_name}") response = None + retry_after = 0 if self._updates_exist is None: - raise RuntimeError( - "Sync check task started without update event initialized" - ) + raise RuntimeError(f"{task_name} started without update event initialized") while True: try: if self.gateway_online: @@ -671,7 +701,10 @@ async def _sync_check_task(self) -> None: ) pi = ADT_GATEWAY_OFFLINE_POLL_INTERVAL - await asyncio.sleep(pi) + if retry_after == 0: + await asyncio.sleep(pi) + else: + await asyncio.sleep(retry_after) response = await self._async_query( ADT_SYNC_CHECK_URI, extra_params={"ts": int(self._sync_timestamp * 1000)}, @@ -679,7 +712,10 @@ async def _sync_check_task(self) -> None: if response is None: continue - + retry_after = self._check_retry_after(response, f"{task_name}") + if retry_after != 0: + self._close_response(response) + continue text = await response.text() if not handle_response( response, logging.ERROR, "Error querying ADT sync" @@ -707,7 +743,7 @@ async def _sync_check_task(self) -> None: self._sync_timestamp = time.time() self._updates_exist.set() if await self.async_update() is False: - LOG.debug("Pulse data update from sync task failed") + LOG.debug(f"Pulse data update from {task_name} failed") continue LOG.debug(f"Sync token {text} indicates no remote updates to process") diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 4555817..9aff917 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -435,7 +435,6 @@ async def _fetch_zones( if not soup: return None - temp_zone: ADTPulseZoneData regexDevice = r"goToUrl\('device.jsp\?id=(\d*)'\);" with self._site_lock: for row in soup.find_all("tr", {"class": "p_listRow", "onclick": True}): From 87db1776b6c7118ba8dda87bffdc65e057f57ebe Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sat, 13 May 2023 06:17:35 -0400 Subject: [PATCH 03/84] add periodic re-login by keepalive task --- pyadtpulse/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index 6b4c040..d963732 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -16,7 +16,9 @@ ADT_STATES_URI = "/ajax/currentStates.jsp" ADT_SYNC_CHECK_URI = "/Ajax/SyncCheckServ" ADT_TIMEOUT_URI = "/KeepAlive" +# Intervals are all in seconds ADT_TIMEOUT_INTERVAL = 300.0 +ADT_RELOGIN_INTERVAL = 7200.0 # ADT sets their keepalive to 1 second, so poll a little more often # than that From daa75dd6b67a9f90bc32b01819bd7e4509e43552 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sat, 13 May 2023 06:29:58 -0400 Subject: [PATCH 04/84] add periodic re-login by keepalive task --- pyadtpulse/__init__.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 966d6e0..d007b0f 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -1,10 +1,11 @@ """Base Python Class for pyadtpulse.""" -import logging import asyncio -import time -import re import datetime +import logging +import re +import time +from contextlib import suppress from random import uniform from threading import Lock, RLock, Thread from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -29,6 +30,7 @@ ADT_LOGIN_URI, ADT_LOGOUT_URI, ADT_ORB_URI, + ADT_RELOGIN_INTERVAL, ADT_SUMMARY_URI, ADT_SYNC_CHECK_URI, ADT_SYSTEM_URI, @@ -457,7 +459,30 @@ async def _keepalive_task(self) -> None: raise RuntimeError( "Keepalive task is running without an authenticated event" ) + last_login = time.time() while self._authenticated.is_set(): + if time.time() - last_login > ADT_RELOGIN_INTERVAL: + with self._attribute_lock: + if self._sync_task is not None: + self._sync_task.cancel() + with suppress(Exception): + await self._sync_task + await self._async_query(ADT_LOGOUT_URI) + try: + response = await self._do_login_query() + except Exception as e: + LOG.error( + f"{task_name} could not re-login to ADT Pulse due to" + f" exception {e}, exiting" + ) + self._close_response(response) + return + self._close_response(response) + if self._sync_task is not None: + coro = self._sync_check_task() + self._sync_task = self._create_task_cb( + coro, name=f"{SYNC_CHECK_TASK_NAME}: Async session" + ) try: await asyncio.sleep(ADT_TIMEOUT_INTERVAL + retry_after) LOG.debug("Resetting timeout") From b6edde6a8aa1e797579deca6e00c9e5b06a3428c Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 06:57:13 -0400 Subject: [PATCH 05/84] add _do_logout_query --- pyadtpulse/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index d007b0f..9a497fd 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -598,6 +598,14 @@ async def _do_login_query( timeout=timeout, ) + async def _do_logout_query(self) -> None: + params = {} + network: ADTPulseSite = self.sites[0] + if network is not None: + params.update({"network": str(network.id)}) + params.update({"partner": "adt"}) + await self._async_query(ADT_LOGOUT_URI, extra_params=params, timeout=10) + async def async_login(self) -> bool: """Login asynchronously to ADT. @@ -686,7 +694,7 @@ async def async_logout(self) -> None: LOG.debug(f"{SYNC_CHECK_TASK_NAME} successfully cancelled") await self._sync_task self._timeout_task = self._sync_task = None - await self._async_query(ADT_LOGOUT_URI, timeout=10) + await self._do_logout_query() self._last_timeout_reset = time.time() if self._authenticated is not None: self._authenticated.clear() @@ -755,6 +763,7 @@ async def _sync_check_task(self) -> None: ) LOG.debug(f"Received {text} from ADT Pulse site") self._close_response(response) + await self._do_logout_query() await self.async_login() continue From 6dc2dffa3b000f5818686defeda406b39b56cc1e Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 07:33:50 -0400 Subject: [PATCH 06/84] remove _sync_timestamp --- pyadtpulse/__init__.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 9a497fd..8897669 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -73,7 +73,6 @@ class PyADTPulse: "_session_thread", "_attribute_lock", "_last_timeout_reset", - "_sync_timestamp", "_sites", "_api_host", "_poll_interval", @@ -135,7 +134,6 @@ def __init__( self._user_agent = user_agent self._sync_task: Optional[asyncio.Task] = None - self._sync_timestamp = 0.0 self._timeout_task: Optional[asyncio.Task] = None # FIXME use thread event/condition, regular condition? @@ -152,7 +150,7 @@ def __init__( self._attribute_lock = RLock() else: self._attribute_lock = DebugRLock("PyADTPulse._attribute_lock") - self._sync_timestamp = self._last_timeout_reset = time.time() + self._last_timeout_reset = time.time() # fixme circular import, should be an ADTPulseSite if TYPE_CHECKING: @@ -668,7 +666,6 @@ async def async_login(self) -> bool: # since we received fresh data on the status of the alarm, go ahead # and update the sites with the alarm status. - self._sync_timestamp = time.time() if self._timeout_task is None: self._timeout_task = self._create_task_cb( self._keepalive_task(), name=f"{KEEPALIVE_TASK_NAME}" @@ -740,7 +737,7 @@ async def _sync_check_task(self) -> None: await asyncio.sleep(retry_after) response = await self._async_query( ADT_SYNC_CHECK_URI, - extra_params={"ts": int(self._sync_timestamp * 1000)}, + extra_params={"ts": int(time.time() * 1000)}, ) if response is None: @@ -774,7 +771,6 @@ async def _sync_check_task(self) -> None: f"Sync token {text} indicates updates may exist, requerying" ) self._close_response(response) - self._sync_timestamp = time.time() self._updates_exist.set() if await self.async_update() is False: LOG.debug(f"Pulse data update from {task_name} failed") @@ -782,7 +778,6 @@ async def _sync_check_task(self) -> None: LOG.debug(f"Sync token {text} indicates no remote updates to process") self._close_response(response) - self._sync_timestamp = time.time() except asyncio.CancelledError: LOG.debug(f"{task_name} cancelled") From d1132d1e3bb44969c0fa8d19e569fa9143aeeea4 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 07:49:23 -0400 Subject: [PATCH 07/84] add relogin_interval property --- pyadtpulse/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index d963732..0b5ff2b 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -18,7 +18,7 @@ ADT_TIMEOUT_URI = "/KeepAlive" # Intervals are all in seconds ADT_TIMEOUT_INTERVAL = 300.0 -ADT_RELOGIN_INTERVAL = 7200.0 +ADT_RELOGIN_INTERVAL = 7200 # ADT sets their keepalive to 1 second, so poll a little more often # than that From f6f4cbbbf4bce3f0987747caaf6bd2dfc23f4358 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 07:49:51 -0400 Subject: [PATCH 08/84] add re-login interval property --- pyadtpulse/__init__.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 8897669..2ef02fa 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -82,6 +82,7 @@ class PyADTPulse: "_login_exception", "_gateway_online", "_create_task_cb", + "_relogin_interval", ) _api_version = ADT_DEFAULT_VERSION _class_threadlock = Lock() @@ -162,6 +163,7 @@ def __init__( self._poll_interval = poll_interval # FIXME: I have no idea how to type hint this self._create_task_cb = create_task_cb + self._relogin_interval = ADT_RELOGIN_INTERVAL # authenticate the user if do_login and self._session is None: @@ -288,6 +290,34 @@ def gateway_online(self) -> bool: with self._attribute_lock: return self._gateway_online + @property + def relogin_interval(self) -> int: + """Get re-login interval. + + Returns: + int: number of minutes to re-login to Pulse + 0 means disabled + """ + with self._attribute_lock: + return self._relogin_interval + + @relogin_interval.setter + def relogin_interval(self, interval: int) -> None: + """Set re-login interval. + + Args: + interval (int): The number of minutes between logins. + 0 means disable + + Raises: + ValueError: if a relogin interval of less than 10 minutes + is specified + """ + if interval > 0 and interval < 10: + raise ValueError("Cannot set relogin interval to less than 10 minutes") + with self._attribute_lock: + self._relogin_interval = interval + def _set_gateway_status(self, status: bool) -> None: """Set gateway status. @@ -459,7 +489,8 @@ async def _keepalive_task(self) -> None: ) last_login = time.time() while self._authenticated.is_set(): - if time.time() - last_login > ADT_RELOGIN_INTERVAL: + relogin_interval = self.relogin_interval + if relogin_interval != 0 and time.time() - last_login > relogin_interval: with self._attribute_lock: if self._sync_task is not None: self._sync_task.cancel() From 162c5ebe9575982a07d09e80fc02cdf71c81f7ff Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 15:00:32 -0400 Subject: [PATCH 09/84] logging updates --- pyadtpulse/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 2ef02fa..16f4f61 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -491,6 +491,7 @@ async def _keepalive_task(self) -> None: while self._authenticated.is_set(): relogin_interval = self.relogin_interval if relogin_interval != 0 and time.time() - last_login > relogin_interval: + LOG.info("Login timeout reached, re-logging in") with self._attribute_lock: if self._sync_task is not None: self._sync_task.cancel() @@ -941,8 +942,8 @@ async def _async_query( if response.status in RECOVERABLE_ERRORS: retry = retry + 1 - LOG.warning( - f"pyadtpulse query returned recover error code " + LOG.info( + f"pyadtpulse query returned recoverable error code " f"{response.status}, retrying (count ={retry})" ) if retry == max_retries: @@ -962,7 +963,7 @@ async def _async_query( ClientConnectionError, ClientConnectorError, ) as ex: - LOG.warning( + LOG.info( f"Error {ex} occurred making {method} request to {url}, retrying" ) await asyncio.sleep(2**retry + uniform(0.0, 1.0)) From 7a26b5fcfb6be22b0912703972358a5c6f61af50 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 15:12:43 -0400 Subject: [PATCH 10/84] remove force_login from async_query --- pyadtpulse/__init__.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 16f4f61..28b56da 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -610,9 +610,7 @@ def loop(self) -> Optional[asyncio.AbstractEventLoop]: with self._attribute_lock: return self._loop - async def _do_login_query( - self, force_login: bool = False, timeout: int = 30 - ) -> ClientResponse | None: + async def _do_login_query(self, timeout: int = 30) -> ClientResponse | None: return await self._async_query( ADT_LOGIN_URI, method="POST", @@ -624,7 +622,6 @@ async def _do_login_query( "fingerprint": self._fingerprint, "sun": "yes", }, - force_login=force_login, timeout=timeout, ) @@ -878,7 +875,6 @@ async def _async_query( method: str = "GET", extra_params: Optional[Dict] = None, extra_headers: Optional[Dict] = None, - force_login: Optional[bool] = True, timeout=1, ) -> Optional[ClientResponse]: """Query ADT Pulse async. @@ -889,8 +885,6 @@ async def _async_query( extra_params (Optional[Dict], optional): query parameters. Defaults to None. extra_headers (Optional[Dict], optional): extra HTTP headers. Defaults to None. - force_login (Optional[bool], optional): login if not connected. - Defaults to True. timeout (int, optional): timeout in seconds. Defaults to 1. Returns: @@ -899,11 +893,6 @@ async def _async_query( ClientResponse will already be closed. """ response = None - - # automatically attempt to login, if not connected - if force_login and not self.is_connected: - await self.async_login() - if self._session is None: raise RuntimeError("ClientSession not initialized") url = self.make_url(uri) @@ -994,7 +983,6 @@ def query( method: str = "GET", extra_params: Optional[Dict] = None, extra_headers: Optional[Dict] = None, - force_login: Optional[bool] = True, timeout=1, ) -> Optional[ClientResponse]: """Query ADT Pulse async. @@ -1005,8 +993,6 @@ def query( extra_params (Optional[Dict], optional): query parameters. Defaults to None. extra_headers (Optional[Dict], optional): extra HTTP headers. Defaults to None. - force_login (Optional[bool], optional): login if not connected. - Defaults to True. timeout (int, optional): timeout in seconds. Defaults to 1. Returns: Optional[ClientResponse]: aiohttp.ClientResponse object @@ -1015,9 +1001,7 @@ def query( """ if self._loop is None: raise RuntimeError("Attempting to run sync query from async login") - coro = self._async_query( - uri, method, extra_params, extra_headers, force_login, timeout - ) + coro = self._async_query(uri, method, extra_params, extra_headers, timeout) return asyncio.run_coroutine_threadsafe(coro, self._loop).result() # FIXME? might have to move this to site for multiple sites From 98caaed63caf516f256abd697c7083393e9b64ff Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 15:41:11 -0400 Subject: [PATCH 11/84] make relogin_interval minutes --- pyadtpulse/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 28b56da..c63ec5d 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -316,7 +316,7 @@ def relogin_interval(self, interval: int) -> None: if interval > 0 and interval < 10: raise ValueError("Cannot set relogin interval to less than 10 minutes") with self._attribute_lock: - self._relogin_interval = interval + self._relogin_interval = interval * 60 def _set_gateway_status(self, status: bool) -> None: """Set gateway status. From ed33a626ec48a9db8c37be1cba5286b4282b2bc1 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 16:04:28 -0400 Subject: [PATCH 12/84] use self._last_timeout_reset for relogin --- pyadtpulse/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index c63ec5d..b9d48cd 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -151,7 +151,7 @@ def __init__( self._attribute_lock = RLock() else: self._attribute_lock = DebugRLock("PyADTPulse._attribute_lock") - self._last_timeout_reset = time.time() + self._last_timeout_reset = 0.0 # fixme circular import, should be an ADTPulseSite if TYPE_CHECKING: @@ -487,10 +487,12 @@ async def _keepalive_task(self) -> None: raise RuntimeError( "Keepalive task is running without an authenticated event" ) - last_login = time.time() while self._authenticated.is_set(): relogin_interval = self.relogin_interval - if relogin_interval != 0 and time.time() - last_login > relogin_interval: + if ( + relogin_interval != 0 + and time.time() - self._last_timeout_reset > relogin_interval + ): LOG.info("Login timeout reached, re-logging in") with self._attribute_lock: if self._sync_task is not None: @@ -645,6 +647,7 @@ async def async_login(self) -> bool: self._authenticated = asyncio.locks.Event() else: self._authenticated.clear() + self._last_timeout_reset = time.time() LOG.debug(f"Authenticating to ADT Pulse cloud service as {self._username}") await self._async_fetch_version() From 767ef62ae783ca889e5b6bb8b9bc2b47a0b95674 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 16:18:14 -0400 Subject: [PATCH 13/84] add extra_params, timeout to async_query debug log msg --- pyadtpulse/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index b9d48cd..d27b676 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -907,7 +907,7 @@ async def _async_query( LOG.debug(f"Updating HTTP headers: {new_headers}") self._session.headers.update(new_headers) - LOG.debug(f"Attempting {method} {url}") + LOG.debug(f"Attempting {method} {url} params={extra_params} timeout={timeout}") # FIXME: reauthenticate if received: # "You have not yet signed in or you From fa35c3aadda8206bc2711d034632936feda39ed2 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 18 May 2023 16:24:36 -0400 Subject: [PATCH 14/84] use _do_logout_query in keepalive task --- pyadtpulse/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index d27b676..b5fbeef 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -499,7 +499,7 @@ async def _keepalive_task(self) -> None: self._sync_task.cancel() with suppress(Exception): await self._sync_task - await self._async_query(ADT_LOGOUT_URI) + await self._do_logout_query() try: response = await self._do_login_query() except Exception as e: From c5a05c2bd3d7f3b1c576bbe94acdc86229ea5157 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 19 May 2023 04:50:31 -0400 Subject: [PATCH 15/84] add alarm panel and gateway dataclasses --- pyadtpulse/alarm_panel.py | 11 +++++++++++ pyadtpulse/gateway.py | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 pyadtpulse/alarm_panel.py create mode 100644 pyadtpulse/gateway.py diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py new file mode 100644 index 0000000..df67b72 --- /dev/null +++ b/pyadtpulse/alarm_panel.py @@ -0,0 +1,11 @@ +"""ADT Alarm Panel Dataclass.""" +from dataclasses import dataclass + + +@dataclass(slots=True) +class ADTPulseAlarmPanel: + """ADT Alarm Panel information.""" + + model: str + manufacturer: str = "ADT" + online: bool = True diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py new file mode 100644 index 0000000..645118c --- /dev/null +++ b/pyadtpulse/gateway.py @@ -0,0 +1,27 @@ +"""ADT Pulse Gateway Dataclass.""" + +from dataclasses import dataclass +from ipaddress import IPv4Address + + +@dataclass(slots=True) +class ADTPulseGateway: + """ADT Pulse Gateway information.""" + + manufacturer: str + model: str + serial_number: str + next_update: float + last_update: float + firmware_version: str + hardware_version: str + primary_connection_type: str + broadband_connection_status: str + cellular_connection_status: str + cellular_connection_signal_strength: float + broadband_lan_ip_address: IPv4Address + broadband_lan_mac_address: str + device_lan_ip_address: IPv4Address + device_lan_mac_address: str + router_lan_ip_address: IPv4Address + router_wan_ip_address: IPv4Address From 8ba542ae5d1fac698babc4509dbd432201c8e5f4 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 19 May 2023 05:20:47 -0400 Subject: [PATCH 16/84] remove extra return --- pyadtpulse/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index b5fbeef..7f9b57d 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -472,8 +472,6 @@ def _check_retry_after( LOG.warning(f"Task {task_name} received Retry-After {retval} due to {reason}") return retval - return retval - async def _keepalive_task(self) -> None: retry_after = 0 if self._timeout_task is not None: From 7dab7761b02c156c64374b3163ded0a98e9f2a67 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 04:07:30 -0400 Subject: [PATCH 17/84] Remove circular dependency --- pyadtpulse/__init__.py | 367 +++++++-------------------------- pyadtpulse/const.py | 2 + pyadtpulse/pulse_connection.py | 288 ++++++++++++++++++++++++++ pyadtpulse/site.py | 61 +++--- pyadtpulse/util.py | 10 + 5 files changed, 405 insertions(+), 323 deletions(-) create mode 100644 pyadtpulse/pulse_connection.py diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 7f9b57d..421fe34 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -6,55 +6,44 @@ import re import time from contextlib import suppress -from random import uniform -from threading import Lock, RLock, Thread -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +from threading import RLock, Thread +from typing import List, Optional, Union import uvloop from aiohttp import ( - ClientConnectionError, - ClientConnectorError, ClientResponse, - ClientResponseError, ClientSession, ) from bs4 import BeautifulSoup -from pyadtpulse.const import ( +from .const import ( + LOG, ADT_DEFAULT_HTTP_HEADERS, ADT_DEFAULT_POLL_INTERVAL, ADT_DEFAULT_VERSION, - ADT_DEVICE_URI, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, - ADT_HTTP_REFERER_URIS, ADT_LOGIN_URI, ADT_LOGOUT_URI, - ADT_ORB_URI, ADT_RELOGIN_INTERVAL, ADT_SUMMARY_URI, ADT_SYNC_CHECK_URI, - ADT_SYSTEM_URI, ADT_TIMEOUT_INTERVAL, ADT_TIMEOUT_URI, - API_PREFIX, DEFAULT_API_HOST, ) -from pyadtpulse.util import ( +from .pulse_connection import ADTPulseConnection +from .util import ( AuthenticationException, DebugRLock, handle_response, + close_response, make_soup, ) -# FIXME -- circular reference -# from pyadtpulse.site import ADTPulseSite - -if TYPE_CHECKING: - from pyadtpulse.site import ADTPulseSite +from .site import ADTPulseSite -LOG = logging.getLogger(__name__) -RECOVERABLE_ERRORS = [429, 500, 502, 503, 504] SYNC_CHECK_TASK_NAME = "ADT Pulse Sync Check Task" KEEPALIVE_TASK_NAME = "ADT Pulse Keepalive Task" @@ -63,18 +52,15 @@ class PyADTPulse: """Base object for ADT Pulse service.""" __slots__ = ( - "_session", - "_user_agent", + "_pulse_connection", "_sync_task", "_timeout_task", "_authenticated", "_updates_exist", - "_loop", "_session_thread", "_attribute_lock", "_last_timeout_reset", "_sites", - "_api_host", "_poll_interval", "_username", "_password", @@ -84,8 +70,6 @@ class PyADTPulse: "_create_task_cb", "_relogin_interval", ) - _api_version = ADT_DEFAULT_VERSION - _class_threadlock = Lock() def __init__( self, @@ -127,12 +111,14 @@ def __init__( """ self._gateway_online: bool = False - self._session = websession - if self._session is not None: - self._session.headers.update(ADT_DEFAULT_HTTP_HEADERS) - self._init_login_info(username, password, fingerprint) - self._user_agent = user_agent + self._pulse_connection = ADTPulseConnection( + service_host, + ADT_DEFAULT_VERSION, + session=websession, + user_agent=user_agent, + debug_locks=debug_locks, + ) self._sync_task: Optional[asyncio.Task] = None self._timeout_task: Optional[asyncio.Task] = None @@ -144,7 +130,6 @@ def __init__( self._updates_exist: Optional[asyncio.locks.Event] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None self._session_thread: Optional[Thread] = None self._attribute_lock: Union[RLock, DebugRLock] if not debug_locks: @@ -154,19 +139,14 @@ def __init__( self._last_timeout_reset = 0.0 # fixme circular import, should be an ADTPulseSite - if TYPE_CHECKING: - self._sites: List[ADTPulseSite] - else: - self._sites: List[Any] = [] - - self._api_host = service_host + self._sites: List[ADTPulseSite] self._poll_interval = poll_interval # FIXME: I have no idea how to type hint this self._create_task_cb = create_task_cb self._relogin_interval = ADT_RELOGIN_INTERVAL # authenticate the user - if do_login and self._session is None: + if do_login and websession is None: self.login() def _init_login_info(self, username: str, password: str, fingerprint: str) -> None: @@ -186,14 +166,6 @@ def _init_login_info(self, username: str, password: str, fingerprint: str) -> No raise ValueError("Fingerprint is required") self._fingerprint = fingerprint - def __del__(self) -> None: - """Destructor. - - Closes aiohttp session if one exists - """ - if self._session is not None and not self._session.closed: - self._session.detach() - def __repr__(self) -> str: """Object representation.""" return "<{}: {}>".format(self.__class__.__name__, self._username) @@ -208,8 +180,7 @@ def service_host(self) -> str: Returns: (str): the ADT Pulse endpoint host """ - with self._attribute_lock: - return self._api_host + return self._pulse_connection.service_host @service_host.setter def service_host(self, host: str) -> None: @@ -218,28 +189,12 @@ def service_host(self, host: str) -> None: Args: host (str): name of Pulse endpoint host """ - with self._attribute_lock: - self._api_host = f"https://{host}" - if self._session is not None: - self._session.headers.update({"Host": host}) - self._session.headers.update(ADT_DEFAULT_HTTP_HEADERS) + self._pulse_connection.service_host = host def set_service_host(self, host: str) -> None: """Backward compatibility for service host property setter.""" self.service_host = host - def make_url(self, uri: str) -> str: - """Create a URL to service host from a URI. - - Args: - uri (str): the URI to convert - - Returns: - str: the converted string - """ - with self._attribute_lock: - return f"{self._api_host}{API_PREFIX}{self.version}{uri}" - @property def poll_interval(self) -> float: """Get polling interval. @@ -277,8 +232,8 @@ def version(self) -> str: Returns: str: a string containing the version """ - with PyADTPulse._class_threadlock: - return PyADTPulse._api_version + with ADTPulseConnection._class_threadlock: + return ADTPulseConnection._api_version @property def gateway_online(self) -> bool: @@ -340,47 +295,6 @@ def _set_gateway_status(self, status: bool) -> None: ) self._gateway_online = status - async def _async_fetch_version(self) -> None: - with PyADTPulse._class_threadlock: - if PyADTPulse._api_version != ADT_DEFAULT_VERSION: - return - response = None - signin_url = f"{self.service_host}/myhome{ADT_LOGIN_URI}" - if self._session: - try: - async with self._session.get(signin_url) as response: - # we only need the headers here, don't parse response - response.raise_for_status() - except (ClientResponseError, ClientConnectionError): - LOG.warning( - "Error occurred during API version fetch, defaulting to" - f"{ADT_DEFAULT_VERSION}" - ) - self._close_response(response) - return - - if response is None: - LOG.warning( - "Error occurred during API version fetch, defaulting to" - f"{ADT_DEFAULT_VERSION}" - ) - return - - m = re.search("/myhome/(.+)/[a-z]*/", response.real_url.path) - self._close_response(response) - if m is not None: - PyADTPulse._api_version = m.group(1) - LOG.debug( - "Discovered ADT Pulse version" - f" {PyADTPulse._api_version} at {self.service_host}" - ) - return - - LOG.warning( - "Couldn't auto-detect ADT Pulse version, " - f"defaulting to {ADT_DEFAULT_VERSION}" - ) - async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: if len(self._sites) == 0: @@ -412,13 +326,11 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: if signout_link: m = re.search("networkid=(.+)&", signout_link) if m and m.group(1) and m.group(1): - from pyadtpulse.site import ADTPulseSite - site_id = m.group(1) LOG.debug(f"Discovered site id {site_id}: {site_name}") # FIXME ADTPulseSite circular reference - new_site = ADTPulseSite(self, site_id, site_name) + new_site = ADTPulseSite(self._pulse_connection, site_id, site_name) # fetch zones first, so that we can have the status # updated with _update_alarm_status @@ -442,10 +354,6 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: # # ... or perhaps better, just extract all from /system/settings.jsp - def _close_response(self, response: Optional[ClientResponse]) -> None: - if response is not None and not response.closed: - response.close() - def _check_retry_after( self, response: Optional[ClientResponse], task_name: str ) -> int: @@ -505,9 +413,9 @@ async def _keepalive_task(self) -> None: f"{task_name} could not re-login to ADT Pulse due to" f" exception {e}, exiting" ) - self._close_response(response) + close_response(response) return - self._close_response(response) + close_response(response) if self._sync_task is not None: coro = self._sync_check_task() self._sync_task = self._create_task_cb( @@ -516,17 +424,19 @@ async def _keepalive_task(self) -> None: try: await asyncio.sleep(ADT_TIMEOUT_INTERVAL + retry_after) LOG.debug("Resetting timeout") - response = await self._async_query(ADT_TIMEOUT_URI, "POST") + response = await self._pulse_connection._async_query( + ADT_TIMEOUT_URI, "POST" + ) if handle_response( response, logging.INFO, "Failed resetting ADT Pulse cloud timeout" ): retry_after = self._check_retry_after(response, "Keepalive task") - self._close_response(response) + close_response(response) continue - self._close_response(response) + close_response(response) except asyncio.CancelledError: LOG.debug(f"{task_name} cancelled") - self._close_response(response) + close_response(response) return def _pulse_session_thread(self) -> None: @@ -535,11 +445,12 @@ def _pulse_session_thread(self) -> None: LOG.debug("Creating ADT Pulse background thread") asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) - self._loop = asyncio.new_event_loop() - self._loop.run_until_complete(self._sync_loop()) + loop = asyncio.new_event_loop() + self._pulse_connection.loop = loop + loop.run_until_complete(self._sync_loop()) - self._loop.close() - self._loop = None + loop.close() + self._pulse_connection.loop = None self._session_thread = None async def _sync_loop(self) -> None: @@ -607,11 +518,10 @@ def loop(self) -> Optional[asyncio.AbstractEventLoop]: Optional[asyncio.AbstractEventLoop]: the event loop object or None if no thread is running """ - with self._attribute_lock: - return self._loop + return self._pulse_connection.loop async def _do_login_query(self, timeout: int = 30) -> ClientResponse | None: - return await self._async_query( + return await self._pulse_connection._async_query( ADT_LOGIN_URI, method="POST", extra_params={ @@ -631,16 +541,15 @@ async def _do_logout_query(self) -> None: if network is not None: params.update({"network": str(network.id)}) params.update({"partner": "adt"}) - await self._async_query(ADT_LOGOUT_URI, extra_params=params, timeout=10) + await self._pulse_connection._async_query( + ADT_LOGOUT_URI, extra_params=params, timeout=10 + ) async def async_login(self) -> bool: """Login asynchronously to ADT. Returns: True if login successful """ - if self._session is None: - self._session = ClientSession() - self._session.headers.update(ADT_DEFAULT_HTTP_HEADERS) if self._authenticated is None: self._authenticated = asyncio.locks.Event() else: @@ -648,7 +557,7 @@ async def async_login(self) -> bool: self._last_timeout_reset = time.time() LOG.debug(f"Authenticating to ADT Pulse cloud service as {self._username}") - await self._async_fetch_version() + await self._pulse_connection._async_fetch_version() response = await self._do_login_query() if not handle_response( @@ -656,14 +565,14 @@ async def async_login(self) -> bool: logging.ERROR, "Error encountered communicating with Pulse site on login", ): - self._close_response(response) + close_response(response) return False - if str(response.url) != self.make_url(ADT_SUMMARY_URI): # type: ignore + if self._pulse_connection.make_url(ADT_SUMMARY_URI) != str(response.url): # type: ignore # more specifically: # redirect to signin.jsp = username/password error # redirect to mfaSignin.jsp = fingerprint error LOG.error("Authentication error encountered logging into ADT Pulse") - self._close_response(response) + close_response(response) return False soup = await make_soup( @@ -728,13 +637,13 @@ async def async_logout(self) -> None: def logout(self) -> None: """Log out of ADT Pulse.""" - with self._attribute_lock: - if self._loop is None: - raise RuntimeError("Attempting to call sync logout without sync login") - sync_thread = self._session_thread + loop = self._pulse_connection.loop + if loop is None: + raise RuntimeError("Attempting to call sync logout without sync login") + sync_thread = self._session_thread coro = self.async_logout() - asyncio.run_coroutine_threadsafe(coro, self._loop) + asyncio.run_coroutine_threadsafe(coro, loop) if sync_thread is not None: sync_thread.join() @@ -765,22 +674,22 @@ async def _sync_check_task(self) -> None: await asyncio.sleep(pi) else: await asyncio.sleep(retry_after) - response = await self._async_query( + response = await self._pulse_connection._async_query( ADT_SYNC_CHECK_URI, - extra_params={"ts": int(time.time() * 1000)}, + extra_params={"ts": str(int(time.time() * 1000))}, ) if response is None: continue retry_after = self._check_retry_after(response, f"{task_name}") if retry_after != 0: - self._close_response(response) + close_response(response) continue text = await response.text() if not handle_response( response, logging.ERROR, "Error querying ADT sync" ): - self._close_response(response) + close_response(response) continue pattern = r"\d+[-]\d+[-]\d+" @@ -789,29 +698,32 @@ async def _sync_check_task(self) -> None: f"Unexpected sync check format ({pattern}), forcing re-auth" ) LOG.debug(f"Received {text} from ADT Pulse site") - self._close_response(response) + close_response(response) await self._do_logout_query() await self.async_login() continue # we can have 0-0-0 followed by 1-0-0 followed by 2-0-0, etc # wait until these settle + # FIXME this is incorrect, and if we get here we know the gateway + # is online. Plus, we shouldn't set updates_exist until + # async_update succeeds if text.endswith("-0-0"): LOG.debug( f"Sync token {text} indicates updates may exist, requerying" ) - self._close_response(response) + close_response(response) self._updates_exist.set() if await self.async_update() is False: LOG.debug(f"Pulse data update from {task_name} failed") continue LOG.debug(f"Sync token {text} indicates no remote updates to process") - self._close_response(response) + close_response(response) except asyncio.CancelledError: LOG.debug(f"{task_name} cancelled") - self._close_response(response) + close_response(response) return @property @@ -823,13 +735,14 @@ def updates_exist(self) -> bool: """ with self._attribute_lock: if self._sync_task is None: - if self._loop is None: + loop = self._pulse_connection.loop + if loop is None: raise RuntimeError( "ADT pulse sync function updates_exist() " "called from async session" ) coro = self._sync_check_task() - self._sync_task = self._loop.create_task( + self._sync_task = loop.create_task( coro, name=f"{SYNC_CHECK_TASK_NAME}: Sync session" ) if self._updates_exist is None: @@ -870,148 +783,7 @@ def is_connected(self) -> bool: return False return self._authenticated.is_set() - async def _async_query( - self, - uri: str, - method: str = "GET", - extra_params: Optional[Dict] = None, - extra_headers: Optional[Dict] = None, - timeout=1, - ) -> Optional[ClientResponse]: - """Query ADT Pulse async. - - Args: - uri (str): URI to query - method (str, optional): method to use. Defaults to "GET". - extra_params (Optional[Dict], optional): query parameters. Defaults to None. - extra_headers (Optional[Dict], optional): extra HTTP headers. - Defaults to None. - timeout (int, optional): timeout in seconds. Defaults to 1. - - Returns: - Optional[ClientResponse]: aiohttp.ClientResponse object - None on failure - ClientResponse will already be closed. - """ - response = None - if self._session is None: - raise RuntimeError("ClientSession not initialized") - url = self.make_url(uri) - if uri in ADT_HTTP_REFERER_URIS: - new_headers = {"Accept": ADT_DEFAULT_HTTP_HEADERS["Accept"]} - else: - new_headers = {"Accept": "*/*"} - - LOG.debug(f"Updating HTTP headers: {new_headers}") - self._session.headers.update(new_headers) - - LOG.debug(f"Attempting {method} {url} params={extra_params} timeout={timeout}") - - # FIXME: reauthenticate if received: - # "You have not yet signed in or you - # have been signed out due to inactivity." - - # define connection method - retry = 0 - max_retries = 3 - while retry < max_retries: - try: - if method == "GET": - async with self._session.get( - url, headers=extra_headers, params=extra_params, timeout=timeout - ) as response: - await response.text() - elif method == "POST": - async with self._session.post( - url, headers=extra_headers, data=extra_params, timeout=timeout - ) as response: - await response.text() - else: - LOG.error(f"Invalid request method {method}") - return None - - if response.status in RECOVERABLE_ERRORS: - retry = retry + 1 - LOG.info( - f"pyadtpulse query returned recoverable error code " - f"{response.status}, retrying (count ={retry})" - ) - if retry == max_retries: - LOG.warning( - "pyadtpulse exceeded max retries of " - f"{max_retries}, giving up" - ) - response.raise_for_status() - await asyncio.sleep(2**retry + uniform(0.0, 1.0)) - continue - - response.raise_for_status() - # success, break loop - retry = 4 - except ( - asyncio.TimeoutError, - ClientConnectionError, - ClientConnectorError, - ) as ex: - LOG.info( - f"Error {ex} occurred making {method} request to {url}, retrying" - ) - await asyncio.sleep(2**retry + uniform(0.0, 1.0)) - continue - except ClientResponseError as err: - code = err.code - LOG.exception( - f"Received HTTP error code {code} in request to ADT Pulse" - ) - return None - - # success! - # FIXME? login uses redirects so final url is wrong - if uri in ADT_HTTP_REFERER_URIS: - if uri == ADT_DEVICE_URI: - referer = self.make_url(ADT_SYSTEM_URI) - else: - if response is not None and response.url is not None: - referer = str(response.url) - LOG.debug(f"Setting Referer to: {referer}") - self._session.headers.update({"Referer": referer}) - - return response - - def query( - self, - uri: str, - method: str = "GET", - extra_params: Optional[Dict] = None, - extra_headers: Optional[Dict] = None, - timeout=1, - ) -> Optional[ClientResponse]: - """Query ADT Pulse async. - - Args: - uri (str): URI to query - method (str, optional): method to use. Defaults to "GET". - extra_params (Optional[Dict], optional): query parameters. Defaults to None. - extra_headers (Optional[Dict], optional): extra HTTP headers. - Defaults to None. - timeout (int, optional): timeout in seconds. Defaults to 1. - Returns: - Optional[ClientResponse]: aiohttp.ClientResponse object - None on failure - ClientResponse will already be closed. - """ - if self._loop is None: - raise RuntimeError("Attempting to run sync query from async login") - coro = self._async_query(uri, method, extra_params, extra_headers, timeout) - return asyncio.run_coroutine_threadsafe(coro, self._loop).result() - # FIXME? might have to move this to site for multiple sites - async def _query_orb( - self, level: int, error_message: str - ) -> Optional[BeautifulSoup]: - response = await self._async_query(ADT_ORB_URI) - - return await make_soup(response, level, error_message) async def async_update(self) -> bool: """Update ADT Pulse data. @@ -1022,7 +794,7 @@ async def async_update(self) -> bool: LOG.debug("Checking ADT Pulse cloud service for updates") # FIXME will have to query other URIs for camera/zwave/etc - soup = await self._query_orb( + soup = await self._pulse_connection._query_orb( logging.INFO, "Error returned from ADT Pulse service check" ) if soup is not None: @@ -1037,15 +809,16 @@ def update(self) -> bool: Returns: bool: True on success """ - if self._loop is None: + loop = self._pulse_connection.loop + if loop is None: raise RuntimeError("Attempting to run sync update from async login") coro = self.async_update() - return asyncio.run_coroutine_threadsafe(coro, self._loop).result() + return asyncio.run_coroutine_threadsafe(coro, loop).result() # FIXME circular reference, should be ADTPulseSite @property - def sites(self) -> List[Any]: + def sites(self) -> List[ADTPulseSite]: """Return all sites for this ADT Pulse account.""" with self._attribute_lock: return self._sites diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index 0b5ff2b..df6fa10 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -1,5 +1,7 @@ """Constants for pyadtpulse.""" +from logging import getLogger +LOG = getLogger(__name__) DEFAULT_API_HOST = "https://portal.adtpulse.com" API_HOST_CA = "https://portal-ca.adtpulse.com" # Canada diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py new file mode 100644 index 0000000..6c5824c --- /dev/null +++ b/pyadtpulse/pulse_connection.py @@ -0,0 +1,288 @@ +"""ADT Pulse connection.""" + +import asyncio +from bs4 import BeautifulSoup +from random import uniform +import re +from typing import Dict, Optional, Union +from threading import RLock, Lock + +from aiohttp import ( + ClientResponse, + ClientSession, + ClientConnectionError, + ClientConnectorError, + ClientResponseError, +) + +from .const import ( + ADT_DEFAULT_HTTP_HEADERS, + ADT_HTTP_REFERER_URIS, + LOG, + ADT_DEVICE_URI, + ADT_SYSTEM_URI, + API_PREFIX, + ADT_ORB_URI, + ADT_DEFAULT_VERSION, + ADT_LOGIN_URI, +) +from .util import DebugRLock, make_soup, close_response + +RECOVERABLE_ERRORS = [429, 500, 502, 503, 504] + + +class ADTPulseConnection: + """ADT Pulse connection related attributes.""" + + _api_version = ADT_DEFAULT_VERSION + _class_threadlock = Lock() + + def __init__( + self, + host: str, + version: str, + session: Optional[ClientSession] = None, + user_agent: str = ADT_DEFAULT_HTTP_HEADERS["User-Agent"], + debug_locks: bool = False, + ): + """Initialize ADT Pulse connection.""" + self._api_host = host + self._version = version + self._allocated_session = False + if session is None: + self._allocate_session = True + self._session = ClientSession() + else: + self._session = session + self._session.headers.update({"User-Agent": user_agent}) + self._attribute_lock: Union[RLock, DebugRLock] + if not debug_locks: + self._attribute_lock = RLock() + else: + self._attribute_lock = DebugRLock("ADTPulseConnection._attribute_lock") + self._loop: Optional[asyncio.AbstractEventLoop] = None + + def __del__(self): + """Destructor for ADTPulseConnection.""" + if self._allocated_session and self._session is not None: + self._session.detach() + + @property + def service_host(self) -> str: + """Get the host prefix for connections.""" + with self._attribute_lock: + return self._api_host + + @service_host.setter + def service_host(self, host: str) -> None: + """Set the host prefix for connections.""" + with self._attribute_lock: + self._session.headers.update({"Host": host}) + self._api_host = host + + @property + def loop(self) -> Optional[asyncio.AbstractEventLoop]: + """Get the event loop.""" + with self._attribute_lock: + return self._loop + + @loop.setter + def loop(self, loop: Optional[asyncio.AbstractEventLoop]) -> None: + """Set the event loop.""" + with self._attribute_lock: + self._loop = loop + + async def _async_query( + self, + uri: str, + method: str = "GET", + extra_params: Optional[Dict[str, str]] = None, + extra_headers: Optional[Dict[str, str]] = None, + timeout=1, + ) -> Optional[ClientResponse]: + """Query ADT Pulse async. + + Args: + uri (str): URI to query + method (str, optional): method to use. Defaults to "GET". + extra_params (Optional[Dict], optional): query parameters. Defaults to None. + extra_headers (Optional[Dict], optional): extra HTTP headers. + Defaults to None. + timeout (int, optional): timeout in seconds. Defaults to 1. + + Returns: + Optional[ClientResponse]: aiohttp.ClientResponse object + None on failure + ClientResponse will already be closed. + """ + response = None + if self._session is None: + raise RuntimeError("ClientSession not initialized") + url = self.make_url(uri) + if uri in ADT_HTTP_REFERER_URIS: + new_headers = {"Accept": ADT_DEFAULT_HTTP_HEADERS["Accept"]} + else: + new_headers = {"Accept": "*/*"} + + LOG.debug(f"Updating HTTP headers: {new_headers}") + self._session.headers.update(new_headers) + + LOG.debug(f"Attempting {method} {url} params={extra_params} timeout={timeout}") + + # FIXME: reauthenticate if received: + # "You have not yet signed in or you + # have been signed out due to inactivity." + + # define connection method + retry = 0 + max_retries = 3 + while retry < max_retries: + try: + if method == "GET": + async with self._session.get( + url, headers=extra_headers, params=extra_params, timeout=timeout + ) as response: + await response.text() + elif method == "POST": + async with self._session.post( + url, headers=extra_headers, data=extra_params, timeout=timeout + ) as response: + await response.text() + else: + LOG.error(f"Invalid request method {method}") + return None + + if response.status in RECOVERABLE_ERRORS: + retry = retry + 1 + LOG.info( + f"pyadtpulse query returned recoverable error code " + f"{response.status}, retrying (count ={retry})" + ) + if retry == max_retries: + LOG.warning( + "pyadtpulse exceeded max retries of " + f"{max_retries}, giving up" + ) + response.raise_for_status() + await asyncio.sleep(2**retry + uniform(0.0, 1.0)) + continue + + response.raise_for_status() + # success, break loop + retry = 4 + except ( + asyncio.TimeoutError, + ClientConnectionError, + ClientConnectorError, + ) as ex: + LOG.info( + f"Error {ex} occurred making {method} request to {url}, retrying" + ) + await asyncio.sleep(2**retry + uniform(0.0, 1.0)) + continue + except ClientResponseError as err: + code = err.code + LOG.exception( + f"Received HTTP error code {code} in request to ADT Pulse" + ) + return None + + # success! + # FIXME? login uses redirects so final url is wrong + if uri in ADT_HTTP_REFERER_URIS: + if uri == ADT_DEVICE_URI: + referer = self.make_url(ADT_SYSTEM_URI) + else: + if response is not None and response.url is not None: + referer = str(response.url) + LOG.debug(f"Setting Referer to: {referer}") + self._session.headers.update({"Referer": referer}) + + return response + + def query( + self, + uri: str, + method: str = "GET", + extra_params: Optional[Dict[str, str]] = None, + extra_headers: Optional[Dict[str, str]] = None, + timeout=1, + ) -> Optional[ClientResponse]: + """Query ADT Pulse async. + + Args: + uri (str): URI to query + method (str, optional): method to use. Defaults to "GET". + extra_params (Optional[Dict], optional): query parameters. Defaults to None. + extra_headers (Optional[Dict], optional): extra HTTP headers. + Defaults to None. + timeout (int, optional): timeout in seconds. Defaults to 1. + Returns: + Optional[ClientResponse]: aiohttp.ClientResponse object + None on failure + ClientResponse will already be closed. + """ + if self._loop is None: + raise RuntimeError("Attempting to run sync query from async login") + coro = self._async_query(uri, method, extra_params, extra_headers, timeout) + return asyncio.run_coroutine_threadsafe(coro, self._loop).result() + + async def _query_orb( + self, level: int, error_message: str + ) -> Optional[BeautifulSoup]: + response = await self._async_query(ADT_ORB_URI) + + return await make_soup(response, level, error_message) + + def make_url(self, uri: str) -> str: + """Create a URL to service host from a URI. + + Args: + uri (str): the URI to convert + + Returns: + str: the converted string + """ + with self._attribute_lock: + return f"{self._api_host}{API_PREFIX}{self._version}{uri}" + + async def _async_fetch_version(self) -> None: + with ADTPulseConnection._class_threadlock: + if ADTPulseConnection._api_version != ADT_DEFAULT_VERSION: + return + response = None + signin_url = f"{self.service_host}/myhome{ADT_LOGIN_URI}" + if self._session: + try: + async with self._session.get(signin_url) as response: + # we only need the headers here, don't parse response + response.raise_for_status() + except (ClientResponseError, ClientConnectionError): + LOG.warning( + "Error occurred during API version fetch, defaulting to" + f"{ADT_DEFAULT_VERSION}" + ) + close_response(response) + return + + if response is None: + LOG.warning( + "Error occurred during API version fetch, defaulting to" + f"{ADT_DEFAULT_VERSION}" + ) + return + + m = re.search("/myhome/(.+)/[a-z]*/", response.real_url.path) + close_response(response) + if m is not None: + ADTPulseConnection._api_version = m.group(1) + LOG.debug( + "Discovered ADT Pulse version" + f" {ADTPulseConnection._api_version} at {self.service_host}" + ) + return + + LOG.warning( + "Couldn't auto-detect ADT Pulse version, " + f"defaulting to {ADT_DEFAULT_VERSION}" + ) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 9aff917..6111011 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -6,15 +6,16 @@ from datetime import datetime, timedelta from threading import RLock from typing import List, Optional, Union +from warnings import warn # import dateparser from bs4 import BeautifulSoup from dateutil import relativedelta -from pyadtpulse import PyADTPulse -from pyadtpulse.const import ADT_ARM_DISARM_URI, ADT_DEVICE_URI, ADT_SYSTEM_URI -from pyadtpulse.util import DebugRLock, make_soup, remove_prefix -from pyadtpulse.zones import ( +from .const import ADT_ARM_DISARM_URI, ADT_DEVICE_URI, ADT_SYSTEM_URI, LOG +from .pulse_connection import ADTPulseConnection +from .util import DebugRLock, make_soup, remove_prefix +from .zones import ( ADT_NAME_TO_DEFAULT_TAGS, ADTPulseFlattendZone, ADTPulseZoneData, @@ -30,9 +31,6 @@ ADT_ARM_DISARM_TIMEOUT = timedelta(seconds=20) -LOG = logging.getLogger(__name__) - - class ADTPulseSite: """Represents an individual ADT Pulse site.""" @@ -49,7 +47,7 @@ class ADTPulseSite: "_site_lock", ) - def __init__(self, adt_service: PyADTPulse, site_id: str, name: str): + def __init__(self, adt_service: ADTPulseConnection, site_id: str, name: str): """Initialize. Args: @@ -66,7 +64,7 @@ def __init__(self, adt_service: PyADTPulse, site_id: str, name: str): self._last_updated = self._last_arm_disarm = datetime(1970, 1, 1) self._zones = ADTPulseZones() self._site_lock: Union[RLock, DebugRLock] - if isinstance(self._adt_service.attribute_lock, DebugRLock): + if isinstance(self._adt_service._attribute_lock, DebugRLock): self._site_lock = DebugRLock("ADTPulseSite._site_lock") else: self._site_lock = RLock() @@ -377,8 +375,9 @@ def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: self._status = ADT_ALARM_HOME self._last_updated = last_updated else: + # FIXME: fix when have gateway device LOG.warning(f"Failed to get alarm status from '{text}'") - self._adt_service._set_gateway_status(False) + # self._adt_service._set_gateway_status(False) self._status = ADT_ALARM_UNKNOWN self._last_updated = last_updated return @@ -638,7 +637,8 @@ def _update_zone_from_soup(self, soup: BeautifulSoup) -> Optional[ADTPulseZones] f"Set zone {zone} - to {state}, status {status} " f"with timestamp {last_update}" ) - self._adt_service._set_gateway_status(gateway_online) + # FIXME: fix when have gateway device + # self._adt_service._set_gateway_status(gateway_online) self._last_updated = datetime.now() return self._zones @@ -671,33 +671,42 @@ def update_zones(self) -> Optional[List[ADTPulseFlattendZone]]: def updates_may_exist(self) -> bool: """Query whether updated sensor data exists. - Returns: - bool: True if updated data exists + Deprecated, use method on pyADTPulse object instead """ # FIXME: this should actually capture the latest version # and compare if different!!! # ...this doesn't actually work if other components are also checking # if updates exist - return self._adt_service.updates_exist + warn( + "updates_may_exist on site object is deprecated, " + "use method on pyADTPulse object instead", + DeprecationWarning, + stacklevel=2, + ) + return False async def async_update(self) -> bool: """Force update site/zone data async with current data. - Returns: - bool: True if update succeeded + Deprecated, use method on pyADTPulse object instead """ - retval = await self._adt_service.async_update() - if retval: - self._last_updated = datetime.now() - return retval + warn( + "updating zones from site object is deprecated, " + "use method on pyADTPulse object instead", + DeprecationWarning, + stacklevel=2, + ) + return False def update(self) -> bool: """Force update site/zones with current data. - Returns: - bool: True if update succeeded + Deprecated, use method on pyADTPulse object instead """ - retval = self._adt_service.update() - if retval: - self._last_updated = datetime.now() - return retval + warn( + "updating zones from site object is deprecated, " + "use method on pyADTPulse object instead", + DeprecationWarning, + stacklevel=2, + ) + return False diff --git a/pyadtpulse/util.py b/pyadtpulse/util.py index ae320fb..d06be2a 100644 --- a/pyadtpulse/util.py +++ b/pyadtpulse/util.py @@ -39,6 +39,16 @@ def handle_response( return False +def close_response(response: Optional[ClientResponse]) -> None: + """Close a response object, handles None. + + Args: + response (Optional[ClientResponse]): ClientResponse object to close + """ + if response is not None and not response.closed: + response.close() + + def remove_prefix(text: str, prefix: str) -> str: """Remove prefix from a string. From 3dae80fca128cf1ffc0c092670a70819220f3c66 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 04:08:21 -0400 Subject: [PATCH 18/84] pre-commit fixes --- .gitignore | 1 - .vscode/launch.json | 2 +- .vscode/settings.json | 2 +- pyadtpulse/.vscode/settings.json | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 62dfb92..f4b91e9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ build __pycache__ .mypy_cache *.swp - diff --git a/.vscode/launch.json b/.vscode/launch.json index d523f81..94db57b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,4 +23,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 457f44d..12901f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { "python.analysis.typeCheckingMode": "basic" -} \ No newline at end of file +} diff --git a/pyadtpulse/.vscode/settings.json b/pyadtpulse/.vscode/settings.json index 457f44d..12901f9 100644 --- a/pyadtpulse/.vscode/settings.json +++ b/pyadtpulse/.vscode/settings.json @@ -1,3 +1,3 @@ { "python.analysis.typeCheckingMode": "basic" -} \ No newline at end of file +} From 510012dc61d6f2a39feb3d0032cf46fe7e827c62 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 04:10:45 -0400 Subject: [PATCH 19/84] more pre-commit fixes --- example-client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/example-client.py b/example-client.py index a74607e..3bb93d7 100755 --- a/example-client.py +++ b/example-client.py @@ -69,14 +69,14 @@ def usage() -> None: print(f"Usage {sys.argv[0]}: [json-file]") print(f" {USER.upper()}, {PASSWD.upper()}, and {FINGERPRINT.upper()}") print(" must be set either through the json file, or environment variables.") - print("") + print() print(f" Set {PULSE_DEBUG} to True to enable debugging") print(f" Set {TEST_ALARM} to True to test alarm arming/disarming") print(f" Set {SLEEP_INTERVAL} to the number of seconds to sleep between each call") print(" Default: 10 seconds") print(f" Set {USE_ASYNC} to true to use asyncio (default: false)") print(f" Set {DEBUG_LOCKS} to true to debug thread locks") - print("") + print() print(" values can be passed on the command line i.e.") print(f" {USER}=someone@example.com") @@ -153,7 +153,7 @@ def test_alarm(site: ADTPulseSite, adt: PyADTPulse, sleep_interval: int) -> None else: print("Force arm failed") - print("") + print() print_site(site) print("Disarming alarm") @@ -168,7 +168,7 @@ def test_alarm(site: ADTPulseSite, adt: PyADTPulse, sleep_interval: int) -> None else: print("Disarming failed") - print("") + print() print_site(site) print("Arming alarm away") @@ -178,7 +178,7 @@ def test_alarm(site: ADTPulseSite, adt: PyADTPulse, sleep_interval: int) -> None else: print("Arm away failed") - print("") + print() print_site(site) site.disarm() print("Disarmed") @@ -299,7 +299,7 @@ async def async_test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None: else: print("Force arm failed") - print("") + print() print_site(site) print("Disarming alarm") @@ -314,7 +314,7 @@ async def async_test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None: else: print("Disarming failed") - print("") + print() print_site(site) print("Arming alarm away") @@ -324,7 +324,7 @@ async def async_test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None: else: print("Arm away failed") - print("") + print() print_site(site) await site.async_disarm() print("Disarmed") From b6d9be614f8d8da9434fbfd046637a48e4a5c690 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 04:12:14 -0400 Subject: [PATCH 20/84] even more pre-commit hooks --- .pre-commit-config.yaml | 12 ++++++------ pyadtpulse/__init__.py | 16 +++++----------- pyadtpulse/pulse_connection.py | 22 +++++++++++----------- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 333f847..7789acb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,12 +14,12 @@ repos: hooks: - id: pycln args: [--config=pyproject.toml] -#- repo: https://github.com/pycqa/isort -# rev: 5.12.0 -# hooks: -# - id: isort -# files: "\\.(py)$" -# args: [--settings-path=pyproject.toml] +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + files: "\\.(py)$" + args: [--settings-path=pyproject.toml] - repo: https://github.com/dosisod/refurb rev: v1.15.0 hooks: diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 421fe34..044c6f5 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -1,24 +1,19 @@ """Base Python Class for pyadtpulse.""" +import logging import asyncio import datetime -import logging import re import time from contextlib import suppress - from threading import RLock, Thread from typing import List, Optional, Union import uvloop -from aiohttp import ( - ClientResponse, - ClientSession, -) +from aiohttp import ClientResponse, ClientSession from bs4 import BeautifulSoup from .const import ( - LOG, ADT_DEFAULT_HTTP_HEADERS, ADT_DEFAULT_POLL_INTERVAL, ADT_DEFAULT_VERSION, @@ -31,19 +26,18 @@ ADT_TIMEOUT_INTERVAL, ADT_TIMEOUT_URI, DEFAULT_API_HOST, + LOG, ) from .pulse_connection import ADTPulseConnection +from .site import ADTPulseSite from .util import ( AuthenticationException, DebugRLock, - handle_response, close_response, + handle_response, make_soup, ) -from .site import ADTPulseSite - - SYNC_CHECK_TASK_NAME = "ADT Pulse Sync Check Task" KEEPALIVE_TASK_NAME = "ADT Pulse Keepalive Task" diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index 6c5824c..4a51870 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -1,32 +1,32 @@ """ADT Pulse connection.""" import asyncio -from bs4 import BeautifulSoup -from random import uniform import re +from random import uniform +from threading import Lock, RLock from typing import Dict, Optional, Union -from threading import RLock, Lock from aiohttp import ( - ClientResponse, - ClientSession, ClientConnectionError, ClientConnectorError, + ClientResponse, ClientResponseError, + ClientSession, ) +from bs4 import BeautifulSoup from .const import ( ADT_DEFAULT_HTTP_HEADERS, - ADT_HTTP_REFERER_URIS, - LOG, + ADT_DEFAULT_VERSION, ADT_DEVICE_URI, + ADT_HTTP_REFERER_URIS, + ADT_LOGIN_URI, + ADT_ORB_URI, ADT_SYSTEM_URI, API_PREFIX, - ADT_ORB_URI, - ADT_DEFAULT_VERSION, - ADT_LOGIN_URI, + LOG, ) -from .util import DebugRLock, make_soup, close_response +from .util import DebugRLock, close_response, make_soup RECOVERABLE_ERRORS = [429, 500, 502, 503, 504] From 8feac2a1bc84c5f405b65652adc0ed02b0790dc2 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 05:04:24 -0400 Subject: [PATCH 21/84] remove multiple sites --- example-client.py | 136 ++++++++++++++++++++--------------------- pyadtpulse/__init__.py | 41 +++++++------ 2 files changed, 90 insertions(+), 87 deletions(-) diff --git a/example-client.py b/example-client.py index 3bb93d7..c3af8ef 100755 --- a/example-client.py +++ b/example-client.py @@ -220,47 +220,44 @@ def sync_example( adt.logout() return - for site in adt.sites: - site.site_lock.acquire() - print(f"Gateway online: {adt.gateway_online}") - print_site(site) - if not site.zones: - print("Error: no zones exist, exiting") - site.site_lock.release() - adt.logout() - return - for zone in site.zones: - print(zone) - site.site_lock.release() - if run_alarm_test: - test_alarm(site, adt, sleep_interval) + adt.site.site_lock.acquire() + print(f"Gateway online: {adt.gateway_online}") + print_site(adt.site) + if not adt.site.zones: + print("Error: no zones exist, exiting") + adt.site.site_lock.release() + adt.logout() + return + for zone in adt.site.zones: + print(zone) + adt.site.site_lock.release() + if run_alarm_test: + test_alarm(adt.site, adt, sleep_interval) done = False while not done: try: - for site in adt.sites: - with site.site_lock: - print_site(site) - print("----") - if not site.zones: - print("Error, no zones exist, exiting...") + print_site(adt.site) + print("----") + if not adt.site.zones: + print("Error, no zones exist, exiting...") + done = True + break + if adt.updates_exist: + print("Updates exist, refreshing") + # Don't need to explicitly call update() anymore + # Background thread will already have updated + if not adt.update(): + print("Error occurred fetching updates, exiting..") done = True break - if site.updates_may_exist: - print("Updates exist, refreshing") - # Don't need to explicitly call update() anymore - # Background thread will already have updated - if not adt.update(): - print("Error occurred fetching updates, exiting..") - done = True - break - print("\nZones:") - with site.site_lock: - for zone in site.zones: - print(zone) - print(f"{site.zones_as_dict}") - else: - print("No updates exist") + print("\nZones:") + with adt.site.site_lock: + for zone in adt.site.zones: + print(zone) + print(f"{adt.site.zones_as_dict}") + else: + print("No updates exist") sleep(sleep_interval) except KeyboardInterrupt: print("exiting...") @@ -270,7 +267,7 @@ def sync_example( adt.logout() -async def async_test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None: +async def async_test_alarm(adt: PyADTPulse) -> None: """Test alarm functions. Args: @@ -278,36 +275,36 @@ async def async_test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None: adt (PyADTPulse): ADT Pulse connection objecct """ print("Arming alarm stay") - if await site.async_arm_home(): + if await adt.site.async_arm_home(): print("Alarm arming home succeeded") # check_updates(site, adt, False) print("Testing invalid alarm state change from armed home to armed away") - if await site.async_arm_away(): + if await adt.site.async_arm_away(): print("Error, armed away while already armed") else: print("Test succeeded") print("Testing changing alarm status to same value") - if await site.async_arm_home(): + if await adt.site.async_arm_home(): print("Error, allowed arming to same state") else: print("Test succeeded") else: print("Alarm arming home failed, attempting force arm") - if await site.async_arm_home(True): + if await adt.site.async_arm_home(True): print("Force arm succeeded") else: print("Force arm failed") print() - print_site(site) + print_site(adt.site) print("Disarming alarm") - if await site.async_disarm(): + if await adt.site.async_disarm(): print("Disarming succeeded") # check_updates(site, adt, False) print("Testing disarming twice") - if await site.async_disarm(): + if await adt.site.async_disarm(): print("Test failed") else: print("Test succeeded") @@ -315,18 +312,18 @@ async def async_test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None: print("Disarming failed") print() - print_site(site) + print_site(adt.site) print("Arming alarm away") - if await site.async_arm_away(): + if await adt.site.async_arm_away(): print("Arm away succeeded") # check_updates(site, adt, False) else: print("Arm away failed") print() - print_site(site) - await site.async_disarm() + print_site(adt.site) + await adt.site.async_disarm() print("Disarmed") @@ -358,37 +355,40 @@ async def async_example( print("Error: could not log into ADT Pulse site") return - if len(adt.sites) == 0: + if adt.site is None: print("Error: could not retrieve sites") await adt.async_logout() return - for site in adt.sites: - print_site(site) - for zone in site.zones: - print(zone) - if run_alarm_test: - await async_test_alarm(site, adt) + print_site(adt.site) + if adt.site.zones is None: + print("Error: no zones exist") + await adt.async_logout() + return + + for zone in adt.site.zones: + print(zone) + if run_alarm_test: + await async_test_alarm(adt) done = False while not done: try: - for site in adt.sites: - print(f"Gateway online: {adt.gateway_online}") - print_site(site) - print("----") - if not site.zones: - print("No zones exist, exiting...") - done = True - break - print("\nZones:") - for zone in site.zones: - print(zone) + print(f"Gateway online: {adt.gateway_online}") + print_site(adt.site) + print("----") + if not adt.site.zones: + print("No zones exist, exiting...") + done = True + break + print("\nZones:") + for zone in adt.site.zones: + print(zone) # print(f"{site.zones_as_dict}") - await adt.wait_for_update() - print("Updates exist, refreshing") - # no need to call an update method + await adt.wait_for_update() + print("Updates exist, refreshing") + # no need to call an update method except KeyboardInterrupt: print("exiting...") done = True diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 044c6f5..8c154c8 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -8,6 +8,7 @@ from contextlib import suppress from threading import RLock, Thread from typing import List, Optional, Union +from warnings import warn import uvloop from aiohttp import ClientResponse, ClientSession @@ -54,7 +55,7 @@ class PyADTPulse: "_session_thread", "_attribute_lock", "_last_timeout_reset", - "_sites", + "_site", "_poll_interval", "_username", "_password", @@ -133,7 +134,7 @@ def __init__( self._last_timeout_reset = 0.0 # fixme circular import, should be an ADTPulseSite - self._sites: List[ADTPulseSite] + self._site: ADTPulseSite self._poll_interval = poll_interval # FIXME: I have no idea how to type hint this self._create_task_cb = create_task_cb @@ -291,21 +292,14 @@ def _set_gateway_status(self, status: bool) -> None: async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: - if len(self._sites) == 0: + if self._site is not None: await self._initialize_sites(soup) else: - # FIXME: this will have to be fixed once multiple ADT sites - # are supported, since the summary_html only represents the - # alarm status of the current site!! - if len(self._sites) > 1: - LOG.error( - "pyadtpulse lacks support for ADT accounts " - "with multiple sites!!!" - ) + LOG.error("pyadtpulse returned no sites") + return - for site in self._sites: - site._update_alarm_from_soup(soup) - site._update_zone_from_soup(soup) + self._site._update_alarm_from_soup(soup) + self._site._update_zone_from_soup(soup) async def _initialize_sites(self, soup: BeautifulSoup) -> None: # typically, ADT Pulse accounts have only a single site (premise/location) @@ -322,8 +316,6 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: if m and m.group(1) and m.group(1): site_id = m.group(1) LOG.debug(f"Discovered site id {site_id}: {site_name}") - - # FIXME ADTPulseSite circular reference new_site = ADTPulseSite(self._pulse_connection, site_id, site_name) # fetch zones first, so that we can have the status @@ -332,7 +324,7 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: new_site._update_alarm_from_soup(soup) new_site._update_zone_from_soup(soup) with self._attribute_lock: - self._sites.append(new_site) + self._site = new_site return else: LOG.warning( @@ -590,7 +582,7 @@ async def async_login(self) -> bool: # need to set authenticated here to prevent login loop self._authenticated.set() await self._update_sites(soup) - if len(self._sites) == 0: + if self._site is None: LOG.error("Could not retrieve any sites, login failed") self._authenticated.clear() return False @@ -814,5 +806,16 @@ def update(self) -> bool: @property def sites(self) -> List[ADTPulseSite]: """Return all sites for this ADT Pulse account.""" + warn( + "multiple sites being removed, use pyADTPulse.site instead", + PendingDeprecationWarning, + stacklevel=2, + ) + with self._attribute_lock: + return [self._site] + + @property + def site(self) -> ADTPulseSite: + """Return the site associated with the Pulse login.""" with self._attribute_lock: - return self._sites + return self._site From f30f1a3b40c96ae7996e6b1274ae79f7a25fef72 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 05:07:26 -0400 Subject: [PATCH 22/84] remove create_task_cb --- pyadtpulse/__init__.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 8c154c8..893579a 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -62,7 +62,6 @@ class PyADTPulse: "_fingerprint", "_login_exception", "_gateway_online", - "_create_task_cb", "_relogin_interval", ) @@ -77,7 +76,6 @@ def __init__( do_login: bool = True, poll_interval: float = ADT_DEFAULT_POLL_INTERVAL, debug_locks: bool = False, - create_task_cb=asyncio.create_task, ): """Create a PyADTPulse object. @@ -101,8 +99,6 @@ def __init__( poll_interval (float, optional): number of seconds between update checks debug_locks: (bool, optional): use debugging locks Defaults to False - create_task_cb (callback, optional): callback to use to create async tasks - Defaults to asyncio.create_task() """ self._gateway_online: bool = False @@ -133,11 +129,9 @@ def __init__( self._attribute_lock = DebugRLock("PyADTPulse._attribute_lock") self._last_timeout_reset = 0.0 - # fixme circular import, should be an ADTPulseSite self._site: ADTPulseSite self._poll_interval = poll_interval # FIXME: I have no idea how to type hint this - self._create_task_cb = create_task_cb self._relogin_interval = ADT_RELOGIN_INTERVAL # authenticate the user @@ -404,7 +398,7 @@ async def _keepalive_task(self) -> None: close_response(response) if self._sync_task is not None: coro = self._sync_check_task() - self._sync_task = self._create_task_cb( + self._sync_task = asyncio.create_task( coro, name=f"{SYNC_CHECK_TASK_NAME}: Async session" ) try: @@ -592,7 +586,7 @@ async def async_login(self) -> bool: # and update the sites with the alarm status. if self._timeout_task is None: - self._timeout_task = self._create_task_cb( + self._timeout_task = asyncio.create_task( self._keepalive_task(), name=f"{KEEPALIVE_TASK_NAME}" ) if self._updates_exist is None: @@ -748,7 +742,7 @@ async def wait_for_update(self) -> None: with self._attribute_lock: if self._sync_task is None: coro = self._sync_check_task() - self._sync_task = self._create_task_cb( + self._sync_task = asyncio.create_task( coro, name=f"{SYNC_CHECK_TASK_NAME}: Async session" ) if self._updates_exist is None: From 5d0ecba8b85c075f628415609012dd420ea219ab Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 05:28:12 -0400 Subject: [PATCH 23/84] remove api version from adtpulseconnection constructor --- pyadtpulse/__init__.py | 2 -- pyadtpulse/pulse_connection.py | 9 ++++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 893579a..c6c25e9 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -17,7 +17,6 @@ from .const import ( ADT_DEFAULT_HTTP_HEADERS, ADT_DEFAULT_POLL_INTERVAL, - ADT_DEFAULT_VERSION, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, ADT_LOGIN_URI, ADT_LOGOUT_URI, @@ -105,7 +104,6 @@ def __init__( self._init_login_info(username, password, fingerprint) self._pulse_connection = ADTPulseConnection( service_host, - ADT_DEFAULT_VERSION, session=websession, user_agent=user_agent, debug_locks=debug_locks, diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index 4a51870..6e75ea1 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -40,14 +40,12 @@ class ADTPulseConnection: def __init__( self, host: str, - version: str, session: Optional[ClientSession] = None, user_agent: str = ADT_DEFAULT_HTTP_HEADERS["User-Agent"], debug_locks: bool = False, ): """Initialize ADT Pulse connection.""" self._api_host = host - self._version = version self._allocated_session = False if session is None: self._allocate_session = True @@ -116,8 +114,9 @@ async def _async_query( ClientResponse will already be closed. """ response = None - if self._session is None: - raise RuntimeError("ClientSession not initialized") + with ADTPulseConnection._class_threadlock: + if ADTPulseConnection._api_version == ADT_DEFAULT_VERSION: + await self._async_fetch_version() url = self.make_url(uri) if uri in ADT_HTTP_REFERER_URIS: new_headers = {"Accept": ADT_DEFAULT_HTTP_HEADERS["Accept"]} @@ -244,7 +243,7 @@ def make_url(self, uri: str) -> str: str: the converted string """ with self._attribute_lock: - return f"{self._api_host}{API_PREFIX}{self._version}{uri}" + return f"{self._api_host}{API_PREFIX}{ADTPulseConnection._api_version}{uri}" async def _async_fetch_version(self) -> None: with ADTPulseConnection._class_threadlock: From 6e90d3f91233796e9a6ca68b4a9d5c8b99b6c2a7 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 05:31:29 -0400 Subject: [PATCH 24/84] add slots to pulse connection --- pyadtpulse/pulse_connection.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index 6e75ea1..8f00e38 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -37,6 +37,14 @@ class ADTPulseConnection: _api_version = ADT_DEFAULT_VERSION _class_threadlock = Lock() + __slots__ = ( + "_api_host", + "_allocated_session", + "_session", + "_attribute_lock", + "_loop", + ) + def __init__( self, host: str, @@ -48,7 +56,7 @@ def __init__( self._api_host = host self._allocated_session = False if session is None: - self._allocate_session = True + self._allocated_session = True self._session = ClientSession() else: self._session = session From 36ecd48ce16f4f0cd5615054724434503a2470b7 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 25 May 2023 05:43:24 -0400 Subject: [PATCH 25/84] add property exceptions to where site == None --- pyadtpulse/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index c6c25e9..be4b1b2 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -127,7 +127,7 @@ def __init__( self._attribute_lock = DebugRLock("PyADTPulse._attribute_lock") self._last_timeout_reset = 0.0 - self._site: ADTPulseSite + self._site: Optional[ADTPulseSite] = None self._poll_interval = poll_interval # FIXME: I have no idea how to type hint this self._relogin_interval = ADT_RELOGIN_INTERVAL @@ -287,6 +287,7 @@ async def _update_sites(self, soup: BeautifulSoup) -> None: if self._site is not None: await self._initialize_sites(soup) else: + # FIXME: wrong error? LOG.error("pyadtpulse returned no sites") return @@ -804,10 +805,18 @@ def sites(self) -> List[ADTPulseSite]: stacklevel=2, ) with self._attribute_lock: + if self._site is None: + raise RuntimeError( + "No sites have been retrieved, have you logged in yet?" + ) return [self._site] @property def site(self) -> ADTPulseSite: """Return the site associated with the Pulse login.""" with self._attribute_lock: + if self._site is None: + raise RuntimeError( + "No sites have been retrieved, have you logged in yet?" + ) return self._site From 96dc63fd2ae2ec7d39756db3280b8b9b56ec28b0 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 8 Jun 2023 04:41:04 -0400 Subject: [PATCH 26/84] raise exception on _update_sites if no sites exist --- pyadtpulse/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index be4b1b2..654e45b 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -284,8 +284,10 @@ def _set_gateway_status(self, status: bool) -> None: async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: - if self._site is not None: + if self._site is None: await self._initialize_sites(soup) + if self._site is None: + raise RuntimeError("pyadtpulse could not retrieve site") else: # FIXME: wrong error? LOG.error("pyadtpulse returned no sites") From ce13ba14423e59747fa1a5e9b94410c10a69a4c0 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 8 Jun 2023 06:24:48 -0400 Subject: [PATCH 27/84] move alarm methods to alarm dataclass --- pyadtpulse/__init__.py | 5 +- pyadtpulse/alarm_panel.py | 300 ++++++++++++++++++++++++++++++++++++ pyadtpulse/site.py | 315 ++++++-------------------------------- 3 files changed, 350 insertions(+), 270 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 654e45b..91de595 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -316,7 +316,10 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: # fetch zones first, so that we can have the status # updated with _update_alarm_status await new_site._fetch_zones(None) - new_site._update_alarm_from_soup(soup) + if new_site.alarm_control_panel is not None: + new_site.alarm_control_panel._update_alarm_from_soup(soup) + else: + LOG.error("Could not fetch control panel information") new_site._update_zone_from_soup(soup) with self._attribute_lock: self._site = new_site diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index df67b72..b3bdf43 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -1,5 +1,26 @@ """ADT Alarm Panel Dataclass.""" + +import logging +import re +from asyncio import run_coroutine_threadsafe from dataclasses import dataclass +from threading import RLock +from time import time + +from bs4 import BeautifulSoup + +from .const import ADT_ARM_DISARM_URI, LOG +from .pulse_connection import ADTPulseConnection +from .util import make_soup + +ADT_ALARM_AWAY = "away" +ADT_ALARM_HOME = "stay" +ADT_ALARM_OFF = "off" +ADT_ALARM_UNKNOWN = "unknown" +ADT_ALARM_ARMING = "arming" +ADT_ALARM_DISARMING = "disarming" + +ADT_ARM_DISARM_TIMEOUT: float = 20 @dataclass(slots=True) @@ -7,5 +28,284 @@ class ADTPulseAlarmPanel: """ADT Alarm Panel information.""" model: str + _sat: str = "" + _status: str = "Unknown" manufacturer: str = "ADT" online: bool = True + _is_force_armed: bool = False + _state_lock = RLock() + _last_arm_disarm: float = time() + + @property + def status(self) -> str: + """Get alarm status. + + Returns: + str: the alarm status + """ + with self._state_lock: + return self._status + + @property + def is_away(self) -> bool: + """Return wheter the system is armed away. + + Returns: + bool: True if armed away + """ + with self._state_lock: + return self._status == ADT_ALARM_AWAY + + @property + def is_home(self) -> bool: + """Return whether system is armed at home/stay. + + Returns: + bool: True if system is armed home/stay + """ + with self._state_lock: + return self._status == ADT_ALARM_HOME + + @property + def is_disarmed(self) -> bool: + """Return whether the system is disarmed. + + Returns: + bool: True if the system is disarmed + """ + with self._state_lock: + return self._status == ADT_ALARM_OFF + + @property + def is_force_armed(self) -> bool: + """Return whether the system is armed in bypass mode. + + Returns: + bool: True if system armed in bypass mode + """ + with self._state_lock: + return self._is_force_armed + + @property + def is_arming(self) -> bool: + """Return if system is attempting to arm. + + Returns: + bool: True if system is attempting to arm + """ + with self._state_lock: + return self._status == ADT_ALARM_ARMING + + @property + def is_disarming(self) -> bool: + """Return if system is attempting to disarm. + + Returns: + bool: True if system is attempting to disarm + """ + with self._state_lock: + return self._status == ADT_ALARM_DISARMING + + async def _arm( + self, connection: ADTPulseConnection, mode: str, force_arm: bool + ) -> bool: + """Set arm status. + + Args: + mode (str) + force_arm (bool): True if arm force + + Returns: + bool: True if operation successful + """ + LOG.debug(f"Setting ADT alarm '{self._sat}' to '{mode}, force = {force_arm}'") + with self._state_lock: + if self._status == mode: + LOG.warning( + f"Attempting to set alarm status {mode} to " + f"existing status {self._status}" + ) + return False + if self._status != ADT_ALARM_OFF and mode != ADT_ALARM_OFF: + LOG.warning(f"Cannot set alarm status from {self._status} to {mode}") + return False + params = { + "href": "rest/adt/ui/client/security/setArmState", + "armstate": self._status, # existing state + "arm": mode, # new state + "sat": self._sat, + } + if force_arm and mode != ADT_ALARM_OFF: + params = { + "href": "rest/adt/ui/client/security/setForceArm", + "armstate": "forcearm", # existing state + "arm": mode, # new state + "sat": self._sat, + } + + response = await connection._async_query( + ADT_ARM_DISARM_URI, + method="POST", + extra_params=params, + timeout=10, + ) + + soup = await make_soup( + response, + logging.WARNING, + f"Failed updating ADT Pulse alarm {self._sat} to {mode}", + ) + if soup is None: + return False + + arm_result = soup.find("div", {"class": "p_armDisarmWrapper"}) + if arm_result is not None: + error_block = arm_result.find("div") + if error_block is not None: + error_text = arm_result.get_text().replace( + "Arm AnywayCancel\n\n", "" + ) + LOG.warning( + f"Could not set alarm state to {mode} " f"because {error_text}" + ) + return False + self._is_force_armed = force_arm + if mode == ADT_ALARM_OFF: + self._status = ADT_ALARM_DISARMING + else: + self._status = ADT_ALARM_ARMING + self._last_arm_disarm = time() + return True + + def _sync_set_alarm_mode( + self, + connection: ADTPulseConnection, + mode: str, + force_arm: bool = False, + ) -> bool: + loop = connection.loop + if loop is None: + raise RuntimeError( + "Attempting to sync change alarm mode from async session" + ) + coro = self._arm(connection, mode, force_arm) + return run_coroutine_threadsafe(coro, loop).result() + + def arm_away(self, connection: ADTPulseConnection, force_arm: bool = False) -> bool: + """Arm the alarm in Away mode. + + Args: + force_arm (bool, Optional): force system to arm + + Returns: + bool: True if arm succeeded + """ + return self._sync_set_alarm_mode(connection, ADT_ALARM_AWAY, force_arm) + + def arm_home(self, connection: ADTPulseConnection, force_arm: bool = False) -> bool: + """Arm the alarm in Home mode. + + Args: + force_arm (bool, Optional): force system to arm + + Returns: + bool: True if arm succeeded + """ + return self._sync_set_alarm_mode(connection, ADT_ALARM_HOME, force_arm) + + def disarm(self, connection: ADTPulseConnection) -> bool: + """Disarm the alarm. + + Returns: + bool: True if disarm succeeded + """ + return self._sync_set_alarm_mode(connection, ADT_ALARM_OFF, False) + + async def async_arm_away( + self, connection: ADTPulseConnection, force_arm: bool = False + ) -> bool: + """Arm alarm away async. + + Args: + force_arm (bool, Optional): force system to arm + + Returns: + bool: True if arm succeeded + """ + return await self._arm(connection, ADT_ALARM_AWAY, force_arm) + + async def async_arm_home( + self, connection: ADTPulseConnection, force_arm: bool = False + ) -> bool: + """Arm alarm home async. + + Args: + force_arm (bool, Optional): force system to arm + Returns: + bool: True if arm succeeded + """ + return await self._arm(connection, ADT_ALARM_HOME, force_arm) + + async def async_disarm(self, connection: ADTPulseConnection) -> bool: + """Disarm alarm async. + + Returns: + bool: True if disarm succeeded + """ + return await self._arm(connection, ADT_ALARM_OFF, False) + + def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: + LOG.debug("Updating alarm status") + value = summary_html_soup.find("span", {"class": "p_boldNormalTextLarge"}) + sat_location = "security_button_0" + with self._state_lock: + if value: + text = value.text + last_updated = time() + + if re.match("Disarmed", text): + if ( + self._status != ADT_ALARM_ARMING + or last_updated - self._last_arm_disarm > ADT_ARM_DISARM_TIMEOUT + ): + self._status = ADT_ALARM_OFF + self._last_arm_disarm = last_updated + elif re.match("Armed Away", text): + if ( + self._status != ADT_ALARM_DISARMING + or last_updated - self._last_arm_disarm > ADT_ARM_DISARM_TIMEOUT + ): + self._status = ADT_ALARM_AWAY + self._last_arm_disarm = last_updated + elif re.match("Armed Stay", text): + if ( + self._status != ADT_ALARM_DISARMING + or last_updated - self._last_arm_disarm > ADT_ARM_DISARM_TIMEOUT + ): + self._status = ADT_ALARM_HOME + self._last_arm_disarm = last_updated + else: + # FIXME: fix when have gateway device + LOG.warning(f"Failed to get alarm status from '{text}'") + # self._adt_service._set_gateway_status(False) + self._status = ADT_ALARM_UNKNOWN + self._last_arm_disarm = last_updated + return + LOG.debug(f"Alarm status = {self._status}") + + if self._sat == "": + sat_button = summary_html_soup.find( + "input", {"type": "button", "id": sat_location} + ) + if sat_button and sat_button.has_attr("onclick"): + on_click = sat_button["onclick"] + match = re.search(r"sat=([a-z0-9\-]+)", on_click) + if match: + self._sat = match.group(1) + elif len(self._sat) == 0: + LOG.warning("No sat recorded and was unable extract sat.") + + if len(self._sat) > 0: + LOG.debug("Extracted sat = %s", self._sat) + else: + LOG.warning("Unable to extract sat") diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 6111011..469db28 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -12,7 +12,8 @@ from bs4 import BeautifulSoup from dateutil import relativedelta -from .const import ADT_ARM_DISARM_URI, ADT_DEVICE_URI, ADT_SYSTEM_URI, LOG +from .alarm_panel import ADTPulseAlarmPanel +from .const import ADT_DEVICE_URI, ADT_SYSTEM_URI, LOG from .pulse_connection import ADTPulseConnection from .util import DebugRLock, make_soup, remove_prefix from .zones import ( @@ -22,14 +23,6 @@ ADTPulseZones, ) -ADT_ALARM_AWAY = "away" -ADT_ALARM_HOME = "stay" -ADT_ALARM_OFF = "off" -ADT_ALARM_UNKNOWN = "unknown" -ADT_ALARM_ARMING = "arming" -ADT_ALARM_DISARMING = "disarming" -ADT_ARM_DISARM_TIMEOUT = timedelta(seconds=20) - class ADTPulseSite: """Represents an individual ADT Pulse site.""" @@ -38,11 +31,8 @@ class ADTPulseSite: "_adt_service", "_id", "_name", - "_status", - "_is_force_armed", - "_sat", "_last_updated", - "_last_arm_disarm", + "_alarm_panel", "_zones", "_site_lock", ) @@ -58,16 +48,14 @@ def __init__(self, adt_service: ADTPulseConnection, site_id: str, name: str): self._adt_service = adt_service self._id = site_id self._name = name - self._status = ADT_ALARM_UNKNOWN - self._is_force_armed: bool = False - self._sat = "" - self._last_updated = self._last_arm_disarm = datetime(1970, 1, 1) + self._last_updated = datetime(1970, 1, 1) self._zones = ADTPulseZones() self._site_lock: Union[RLock, DebugRLock] if isinstance(self._adt_service._attribute_lock, DebugRLock): self._site_lock = DebugRLock("ADTPulseSite._site_lock") else: self._site_lock = RLock() + self._alarm_panel: Optional[ADTPulseAlarmPanel] = None @property def id(self) -> str: @@ -89,75 +77,6 @@ def name(self) -> str: # FIXME: should this actually return if the alarm is going off!? How do we # return state that shows the site is compromised?? - @property - def status(self) -> str: - """Get alarm status. - - Returns: - str: the alarm status - """ - with self._site_lock: - return self._status - - @property - def is_away(self) -> bool: - """Return wheter the system is armed away. - - Returns: - bool: True if armed away - """ - with self._site_lock: - return self._status == ADT_ALARM_AWAY - - @property - def is_home(self) -> bool: - """Return whether system is armed at home/stay. - - Returns: - bool: True if system is armed home/stay - """ - with self._site_lock: - return self._status == ADT_ALARM_HOME - - @property - def is_disarmed(self) -> bool: - """Return whether the system is disarmed. - - Returns: - bool: True if the system is disarmed - """ - with self._site_lock: - return self._status == ADT_ALARM_OFF - - @property - def is_force_armed(self) -> bool: - """Return whether the system is armed in bypass mode. - - Returns: - bool: True if system armed in bypass mode - """ - with self._site_lock: - return self._is_force_armed - - @property - def is_arming(self) -> bool: - """Return if system is attempting to arm. - - Returns: - bool: True if system is attempting to arm - """ - with self._site_lock: - return self._status == ADT_ALARM_ARMING - - @property - def is_disarming(self) -> bool: - """Return if system is attempting to disarm. - - Returns: - bool: True if system is attempting to disarm - """ - with self._site_lock: - return self._status == ADT_ALARM_DISARMING @property def last_updated(self) -> datetime: @@ -180,140 +99,45 @@ def site_lock(self) -> Union[RLock, DebugRLock]: """ return self._site_lock - async def _arm(self, mode: str, force_arm: bool) -> bool: - """Set arm status. + def arm_home(self, force_arm: bool) -> bool: + """Arm system home.""" + if self.alarm_control_panel is None: + raise RuntimeError("Cannot arm system home, no control panels exist") + return self.alarm_control_panel.arm_home(self._adt_service, force_arm=force_arm) - Args: - mode (str) - force_arm (bool): True if arm force + def arm_away(self, force_arm: bool) -> bool: + """Arm system away.""" + if self.alarm_control_panel is None: + raise RuntimeError("Cannot arm system away, no control panels exist") + return self.alarm_control_panel.arm_away(self._adt_service, force_arm=force_arm) - Returns: - bool: True if operation successful - """ - LOG.debug(f"Setting ADT alarm '{self._name}' to '{mode}, force = {force_arm}'") - if self._status == mode: - LOG.warning( - f"Attempting to set alarm status {mode} to " - f"existing status {self._status}" - ) - return False - if self._status != ADT_ALARM_OFF and mode != ADT_ALARM_OFF: - LOG.warning(f"Cannot set alarm status from {self._status} to {mode}") - return False - params = { - "href": "rest/adt/ui/client/security/setArmState", - "armstate": self._status, # existing state - "arm": mode, # new state - "sat": self._sat, - } - if force_arm and mode != ADT_ALARM_OFF: - params = { - "href": "rest/adt/ui/client/security/setForceArm", - "armstate": "forcearm", # existing state - "arm": mode, # new state - "sat": self._sat, - } - - response = await self._adt_service._async_query( - ADT_ARM_DISARM_URI, - method="POST", - extra_params=params, - timeout=10, + def disarm(self) -> bool: + """Disarm system.""" + if self.alarm_control_panel is None: + raise RuntimeError("Cannot disarm system, no control panels exist") + return self.alarm_control_panel.disarm(self._adt_service) + + async def async_arm_home(self, force_arm: bool) -> bool: + """Arm system home async.""" + if self.alarm_control_panel is None: + raise RuntimeError("Cannot arm system home, no control panels exist") + return await self.alarm_control_panel.async_arm_home( + self._adt_service, force_arm=force_arm ) - soup = await make_soup( - response, - logging.WARNING, - f"Failed updating ADT Pulse alarm {self._name} to {mode}", + async def async_arm_away(self, force_arm: bool) -> bool: + """Arm system away async.""" + if self.alarm_control_panel is None: + raise RuntimeError("Cannot arm system away, no control panels exist") + return await self.alarm_control_panel.async_arm_away( + self._adt_service, force_arm=force_arm ) - if soup is None: - return False - - arm_result = soup.find("div", {"class": "p_armDisarmWrapper"}) - if arm_result is not None: - error_block = arm_result.find("div") - if error_block is not None: - error_text = arm_result.get_text().replace("Arm AnywayCancel\n\n", "") - LOG.warning( - f"Could not set alarm state to {mode} " f"because {error_text}" - ) - return False - with self._site_lock: - self._is_force_armed = force_arm - if mode == ADT_ALARM_OFF: - self._status = ADT_ALARM_DISARMING - else: - self._status = ADT_ALARM_ARMING - self._last_arm_disarm = self._last_updated = datetime.now() - return True - - def _sync_set_alarm_mode(self, mode: str, force_arm: bool = False) -> bool: - loop = self._adt_service.loop - if loop is None: - raise RuntimeError( - "Attempting to sync change alarm mode from async session" - ) - coro = self._arm(mode, force_arm) - return run_coroutine_threadsafe(coro, loop).result() - - def arm_away(self, force_arm: bool = False) -> bool: - """Arm the alarm in Away mode. - - Args: - force_arm (bool, Optional): force system to arm - - Returns: - bool: True if arm succeeded - """ - return self._sync_set_alarm_mode(ADT_ALARM_AWAY, force_arm) - - def arm_home(self, force_arm: bool = False) -> bool: - """Arm the alarm in Home mode. - - Args: - force_arm (bool, Optional): force system to arm - - Returns: - bool: True if arm succeeded - """ - return self._sync_set_alarm_mode(ADT_ALARM_HOME, force_arm) - - def disarm(self) -> bool: - """Disarm the alarm. - - Returns: - bool: True if disarm succeeded - """ - return self._sync_set_alarm_mode(ADT_ALARM_OFF, False) - - async def async_arm_away(self, force_arm: bool = False) -> bool: - """Arm alarm away async. - - Args: - force_arm (bool, Optional): force system to arm - - Returns: - bool: True if arm succeeded - """ - return await self._arm(ADT_ALARM_AWAY, force_arm) - - async def async_arm_home(self, force_arm: bool = False) -> bool: - """Arm alarm home async. - - Args: - force_arm (bool, Optional): force system to arm - Returns: - bool: True if arm succeeded - """ - return await self._arm(ADT_ALARM_HOME, force_arm) async def async_disarm(self) -> bool: - """Disarm alarm async. - - Returns: - bool: True if disarm succeeded - """ - return await self._arm(ADT_ALARM_OFF, False) + """Disarm system async.""" + if self.alarm_control_panel is None: + raise RuntimeError("Cannot disarm system, no control panels exist") + return await self.alarm_control_panel.async_disarm(self._adt_service) @property def zones(self) -> Optional[List[ADTPulseFlattendZone]]: @@ -339,67 +163,20 @@ def zones_as_dict(self) -> Optional[ADTPulseZones]: raise RuntimeError("No zones exist") return self._zones + @property + def alarm_control_panel(self) -> Optional[ADTPulseAlarmPanel]: + """Return the alarm panel object for the site. + + Returns: + Optional[ADTPulseAlarmPanel]: the alarm panel object + """ + return self._alarm_panel + @property def history(self): """Return log of history for this zone (NOT IMPLEMENTED).""" raise NotImplementedError - def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: - LOG.debug("Updating alarm status") - value = summary_html_soup.find("span", {"class": "p_boldNormalTextLarge"}) - sat_location = "security_button_0" - with self._site_lock: - if value: - text = value.text - last_updated = datetime.now() - - if re.match("Disarmed", text): - if ( - self._status != ADT_ALARM_ARMING - or last_updated - self._last_arm_disarm > ADT_ARM_DISARM_TIMEOUT - ): - self._status = ADT_ALARM_OFF - self._last_updated = last_updated - elif re.match("Armed Away", text): - if ( - self._status != ADT_ALARM_DISARMING - or last_updated - self._last_arm_disarm > ADT_ARM_DISARM_TIMEOUT - ): - self._status = ADT_ALARM_AWAY - self._last_updated = last_updated - elif re.match("Armed Stay", text): - if ( - self._status != ADT_ALARM_DISARMING - or last_updated - self._last_arm_disarm > ADT_ARM_DISARM_TIMEOUT - ): - self._status = ADT_ALARM_HOME - self._last_updated = last_updated - else: - # FIXME: fix when have gateway device - LOG.warning(f"Failed to get alarm status from '{text}'") - # self._adt_service._set_gateway_status(False) - self._status = ADT_ALARM_UNKNOWN - self._last_updated = last_updated - return - LOG.debug(f"Alarm status = {self._status}") - - if self._sat == "": - sat_button = summary_html_soup.find( - "input", {"type": "button", "id": sat_location} - ) - if sat_button and sat_button.has_attr("onclick"): - on_click = sat_button["onclick"] - match = re.search(r"sat=([a-z0-9\-]+)", on_click) - if match: - self._sat = match.group(1) - elif len(self._sat) == 0: - LOG.warning("No sat recorded and was unable extract sat.") - - if len(self._sat) > 0: - LOG.debug("Extracted sat = %s", self._sat) - else: - LOG.warning("Unable to extract sat") - # status_orb = summary_html_soup.find('canvas', {'id': 'ic_orb'}) # if status_orb: # self._status = status_orb['orb'] From bab161580bab3684716bf29c9af090149497269b Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 03:29:04 -0400 Subject: [PATCH 28/84] rework gateway and alarm panel --- pyadtpulse/__init__.py | 63 +---------------------- pyadtpulse/alarm_panel.py | 2 +- pyadtpulse/gateway.py | 104 +++++++++++++++++++++++++++++++------- pyadtpulse/site.py | 19 +++++-- 4 files changed, 104 insertions(+), 84 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 91de595..534eaf0 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -17,7 +17,6 @@ from .const import ( ADT_DEFAULT_HTTP_HEADERS, ADT_DEFAULT_POLL_INTERVAL, - ADT_GATEWAY_OFFLINE_POLL_INTERVAL, ADT_LOGIN_URI, ADT_LOGOUT_URI, ADT_RELOGIN_INTERVAL, @@ -182,26 +181,6 @@ def set_service_host(self, host: str) -> None: """Backward compatibility for service host property setter.""" self.service_host = host - @property - def poll_interval(self) -> float: - """Get polling interval. - - Returns: - float: interval in seconds to poll for updates - """ - with self._attribute_lock: - return self._poll_interval - - @poll_interval.setter - def poll_interval(self, interval: float) -> None: - """Set polling interval. - - Args: - interval (float): interval in seconds to poll for updates - """ - with self._attribute_lock: - self._poll_interval = interval - @property def username(self) -> str: """Get username. @@ -222,16 +201,6 @@ def version(self) -> str: with ADTPulseConnection._class_threadlock: return ADTPulseConnection._api_version - @property - def gateway_online(self) -> bool: - """Retrieve whether Pulse Gateway is online. - - Returns: - bool: True if gateway is online - """ - with self._attribute_lock: - return self._gateway_online - @property def relogin_interval(self) -> int: """Get re-login interval. @@ -260,28 +229,6 @@ def relogin_interval(self, interval: int) -> None: with self._attribute_lock: self._relogin_interval = interval * 60 - def _set_gateway_status(self, status: bool) -> None: - """Set gateway status. - - Private method used by site object - - Args: - status (bool): True if gateway is online - """ - with self._attribute_lock: - if status == self._gateway_online: - return - - status_text = "ONLINE" - if not status: - status_text = "OFFLINE" - self._poll_interval = ADT_GATEWAY_OFFLINE_POLL_INTERVAL - - LOG.info( - f"ADT Pulse gateway {status_text}, poll interval={self._poll_interval}" - ) - self._gateway_online = status - async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: if self._site is None: @@ -645,15 +592,7 @@ async def _sync_check_task(self) -> None: raise RuntimeError(f"{task_name} started without update event initialized") while True: try: - if self.gateway_online: - pi = self.poll_interval - else: - LOG.info( - "Pulse gateway detected offline, polling every " - f"{ADT_GATEWAY_OFFLINE_POLL_INTERVAL} seconds" - ) - pi = ADT_GATEWAY_OFFLINE_POLL_INTERVAL - + pi = self.site.gateway.poll_interval if retry_after == 0: await asyncio.sleep(pi) else: diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index b3bdf43..c442c87 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -27,7 +27,7 @@ class ADTPulseAlarmPanel: """ADT Alarm Panel information.""" - model: str + model: str = "Unknown" _sat: str = "" _status: str = "Unknown" manufacturer: str = "ADT" diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index 645118c..7dda685 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -2,26 +2,96 @@ from dataclasses import dataclass from ipaddress import IPv4Address +from threading import RLock + +from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, LOG @dataclass(slots=True) class ADTPulseGateway: """ADT Pulse Gateway information.""" - manufacturer: str - model: str - serial_number: str - next_update: float - last_update: float - firmware_version: str - hardware_version: str - primary_connection_type: str - broadband_connection_status: str - cellular_connection_status: str - cellular_connection_signal_strength: float - broadband_lan_ip_address: IPv4Address - broadband_lan_mac_address: str - device_lan_ip_address: IPv4Address - device_lan_mac_address: str - router_lan_ip_address: IPv4Address - router_wan_ip_address: IPv4Address + manufacturer: str = "Unknown" + _is_online: bool = False + _status_text: str = "OFFLINE" + _current_poll_interval: float = ADT_DEFAULT_POLL_INTERVAL + _initial_poll_interval: float = ADT_DEFAULT_POLL_INTERVAL + _attribute_lock = RLock() + model: str = "Unknown" + serial_number: str = "Unknown" + next_update: float = 0.0 + last_update: float = 0.0 + firmware_version: str = "Unknown" + hardware_version: str = "Unknown" + primary_connection_type: str = "Unknown" + broadband_connection_status: str = "Unknown" + cellular_connection_status: str = "Unknown" + cellular_connection_signal_strength: float = 0.0 + broadband_lan_ip_address: IPv4Address = IPv4Address("0.0.0.0") + broadband_lan_mac_address: str = "Unknown" + device_lan_ip_address: IPv4Address = IPv4Address("0.0.0.0") + device_lan_mac_address: str = "Unknown" + router_lan_ip_address: IPv4Address = IPv4Address("0.0.0.0") + router_wan_ip_address: IPv4Address = IPv4Address("0.0.0.0") + + @property + def is_online(self) -> bool: + """Returns whether gateway is online. + + Returns: + bool: True if gateway is online + """ + with self._attribute_lock: + return self._is_online + + @is_online.setter + def is_online(self, status: bool) -> None: + """Set gateway status. + + Args: + status (bool): True if gateway is online + """ + with self._attribute_lock: + if status == self._is_online: + return + + self._status_text = "ONLINE" + if not status: + self._status_text = "OFFLINE" + self._current_poll_interval = ADT_GATEWAY_OFFLINE_POLL_INTERVAL + else: + self._current_poll_interval = self._initial_poll_interval + + LOG.info( + f"ADT Pulse gateway {self._status_text}, " + "poll interval={self._current_poll_interval}" + ) + self._is_online = status + + @property + def poll_interval(self) -> float: + """Set polling interval. + + Returns: + float: number of seconds between polls + """ + with self._attribute_lock: + return self._current_poll_interval + + @poll_interval.setter + def poll_interval(self, new_interval: float) -> None: + """Set polling interval. + + Args: + new_interval (float): polling interval if gateway is online + + Raises: + ValueError: if new_interval is less than 0 + """ + if new_interval < 0.0: + raise ValueError("ADT Pulse polling interval must be greater than 0") + with self._attribute_lock: + self._initial_poll_interval = new_interval + if self._current_poll_interval != ADT_GATEWAY_OFFLINE_POLL_INTERVAL: + self._current_poll_interval = new_interval + LOG.debug(f"Set poll interval to {self._initial_poll_interval}") diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 469db28..39bd5e5 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -14,6 +14,7 @@ from .alarm_panel import ADTPulseAlarmPanel from .const import ADT_DEVICE_URI, ADT_SYSTEM_URI, LOG +from .gateway import ADTPulseGateway from .pulse_connection import ADTPulseConnection from .util import DebugRLock, make_soup, remove_prefix from .zones import ( @@ -35,6 +36,7 @@ class ADTPulseSite: "_alarm_panel", "_zones", "_site_lock", + "_gateway", ) def __init__(self, adt_service: ADTPulseConnection, site_id: str, name: str): @@ -55,7 +57,8 @@ def __init__(self, adt_service: ADTPulseConnection, site_id: str, name: str): self._site_lock = DebugRLock("ADTPulseSite._site_lock") else: self._site_lock = RLock() - self._alarm_panel: Optional[ADTPulseAlarmPanel] = None + self._alarm_panel = ADTPulseAlarmPanel() + self._gateway = ADTPulseGateway() @property def id(self) -> str: @@ -164,7 +167,7 @@ def zones_as_dict(self) -> Optional[ADTPulseZones]: return self._zones @property - def alarm_control_panel(self) -> Optional[ADTPulseAlarmPanel]: + def alarm_control_panel(self) -> ADTPulseAlarmPanel: """Return the alarm panel object for the site. Returns: @@ -172,6 +175,15 @@ def alarm_control_panel(self) -> Optional[ADTPulseAlarmPanel]: """ return self._alarm_panel + @property + def gateway(self) -> ADTPulseGateway: + """Get gateway device object. + + Returns: + ADTPulseGateway: Gateway device + """ + return self._gateway + @property def history(self): """Return log of history for this zone (NOT IMPLEMENTED).""" @@ -414,8 +426,7 @@ def _update_zone_from_soup(self, soup: BeautifulSoup) -> Optional[ADTPulseZones] f"Set zone {zone} - to {state}, status {status} " f"with timestamp {last_update}" ) - # FIXME: fix when have gateway device - # self._adt_service._set_gateway_status(gateway_online) + self._gateway.is_online = gateway_online self._last_updated = datetime.now() return self._zones From 45b3a0367ba9d24d17acf11fdfecac78530dee08 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 03:34:31 -0400 Subject: [PATCH 29/84] rename _adt_service to _pulse_connection in site.py --- pyadtpulse/site.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 39bd5e5..f52d10c 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -29,7 +29,7 @@ class ADTPulseSite: """Represents an individual ADT Pulse site.""" __slots__ = ( - "_adt_service", + "_pulse_connection", "_id", "_name", "_last_updated", @@ -39,7 +39,7 @@ class ADTPulseSite: "_gateway", ) - def __init__(self, adt_service: ADTPulseConnection, site_id: str, name: str): + def __init__(self, pulse_connection: ADTPulseConnection, site_id: str, name: str): """Initialize. Args: @@ -47,13 +47,13 @@ def __init__(self, adt_service: ADTPulseConnection, site_id: str, name: str): site_id (str): site ID name (str): site name """ - self._adt_service = adt_service + self._pulse_connection = pulse_connection self._id = site_id self._name = name self._last_updated = datetime(1970, 1, 1) self._zones = ADTPulseZones() self._site_lock: Union[RLock, DebugRLock] - if isinstance(self._adt_service._attribute_lock, DebugRLock): + if isinstance(self._pulse_connection._attribute_lock, DebugRLock): self._site_lock = DebugRLock("ADTPulseSite._site_lock") else: self._site_lock = RLock() @@ -106,26 +106,30 @@ def arm_home(self, force_arm: bool) -> bool: """Arm system home.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system home, no control panels exist") - return self.alarm_control_panel.arm_home(self._adt_service, force_arm=force_arm) + return self.alarm_control_panel.arm_home( + self._pulse_connection, force_arm=force_arm + ) def arm_away(self, force_arm: bool) -> bool: """Arm system away.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system away, no control panels exist") - return self.alarm_control_panel.arm_away(self._adt_service, force_arm=force_arm) + return self.alarm_control_panel.arm_away( + self._pulse_connection, force_arm=force_arm + ) def disarm(self) -> bool: """Disarm system.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot disarm system, no control panels exist") - return self.alarm_control_panel.disarm(self._adt_service) + return self.alarm_control_panel.disarm(self._pulse_connection) async def async_arm_home(self, force_arm: bool) -> bool: """Arm system home async.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system home, no control panels exist") return await self.alarm_control_panel.async_arm_home( - self._adt_service, force_arm=force_arm + self._pulse_connection, force_arm=force_arm ) async def async_arm_away(self, force_arm: bool) -> bool: @@ -133,14 +137,14 @@ async def async_arm_away(self, force_arm: bool) -> bool: if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system away, no control panels exist") return await self.alarm_control_panel.async_arm_away( - self._adt_service, force_arm=force_arm + self._pulse_connection, force_arm=force_arm ) async def async_disarm(self) -> bool: """Disarm system async.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot disarm system, no control panels exist") - return await self.alarm_control_panel.async_disarm(self._adt_service) + return await self.alarm_control_panel.async_disarm(self._pulse_connection) @property def zones(self) -> Optional[List[ADTPulseFlattendZone]]: @@ -214,7 +218,7 @@ async def _fetch_zones( None if an error occurred """ if not soup: - response = await self._adt_service._async_query(ADT_SYSTEM_URI) + response = await self._pulse_connection._async_query(ADT_SYSTEM_URI) soup = await make_soup( response, logging.WARNING, @@ -239,7 +243,7 @@ async def _fetch_zones( continue device_id = result[0] - deviceResponse = await self._adt_service._async_query( + deviceResponse = await self._pulse_connection._async_query( ADT_DEVICE_URI, extra_params={"id": device_id} ) deviceResponseSoup = await make_soup( @@ -312,7 +316,7 @@ async def _async_update_zones_as_dict( LOG.debug(f"fetching zones for site { self._id}") if not soup: # call ADT orb uri - soup = await self._adt_service._query_orb( + soup = await self._pulse_connection._query_orb( logging.WARNING, "Could not fetch zone status updates" ) if soup is None: From 3f9609ee4ede0ab74e5578b43c98c77557bff9c4 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 04:40:10 -0400 Subject: [PATCH 30/84] add _get_device_attributes to site.py --- pyadtpulse/site.py | 96 ++++++++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index f52d10c..f16ebd2 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -204,6 +204,30 @@ def history(self): # if we should also update the zone details, force a fresh fetch # of data from ADT Pulse + async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str]]: + result: dict[str, str] = {} + deviceResponse = await self._pulse_connection._async_query( + ADT_DEVICE_URI, extra_params={"id": device_id} + ) + deviceResponseSoup = await make_soup( + deviceResponse, + logging.DEBUG, + "Failed loading zone data from ADT Pulse service", + ) + if deviceResponseSoup is None: + return None + for devInfoRow in deviceResponseSoup.find_all( + "td", {"class", "InputFieldDescriptionL"} + ): + identityText = str(devInfoRow.get_text()) + sibling = devInfoRow.find_next_sibling() + if not sibling: + value = "" + else: + value = str(sibling.get_text()).strip() + result.update({identityText: value}) + return result + async def _fetch_zones( self, soup: Optional[BeautifulSoup] ) -> Optional[ADTPulseZones]: @@ -242,59 +266,47 @@ async def _fetch_zones( ) continue - device_id = result[0] - deviceResponse = await self._pulse_connection._async_query( - ADT_DEVICE_URI, extra_params={"id": device_id} - ) - deviceResponseSoup = await make_soup( - deviceResponse, - logging.DEBUG, - "Failed loading zone data from ADT Pulse service", - ) - if deviceResponseSoup is None: - return None - - dName = dType = dZone = dStatus = "" - # dMan = "" - for devInfoRow in deviceResponseSoup.find_all( - "td", {"class", "InputFieldDescriptionL"} - ): - identityText = devInfoRow.get_text().upper() - sibling = devInfoRow.find_next_sibling() - if not sibling: - continue - value = sibling.get_text().strip() - if identityText == "NAME:": - dName = value - elif identityText == "TYPE/MODEL:": - dType = value - elif identityText == "ZONE:": - dZone = value - elif identityText == "STATUS:": - dStatus = value - # elif identityText == "MANUFACTURER/PROVIDER:": - # dMan = value + dev_attr = await self._get_device_attributes(result[0]) + if dev_attr is None: + continue + dName = dev_attr.get("Name:") + dType = dev_attr.get("Type:") + dZone = dev_attr.get("Zone:") + dStatus = dev_attr.get("Status:") # NOTE: if empty string, this is the control panel - if dZone != "": + if dZone is not None and dZone != "": tags = None - for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): - # convert to uppercase first - if search_term.upper() in dType.upper(): - tags = default_tags - break - + if dType is not None: + for ( + search_term, + default_tags, + ) in ADT_NAME_TO_DEFAULT_TAGS.items(): + # convert to uppercase first + if search_term.upper() in dType.upper(): + tags = default_tags + break if not tags: LOG.warning( f"Unknown sensor type for '{dType}', " "defaulting to doorWindow" ) tags = ("sensor", "doorWindow") - LOG.debug(f"Adding sensor {dName} id: sensor-{dZone}") + LOG.debug(f"Retrieved sensor {dName} id: sensor-{dZone}") LOG.debug(f"Status: {dStatus}, tags {tags}") - tmpzone = ADTPulseZoneData(dName, f"sensor-{dZone}", tags, dStatus) - self._zones.update({int(dZone): tmpzone}) + if dName is None or dStatus is None or dZone is None: + LOG.debug("Zone data incomplete, skipping...") + else: + tmpzone = ADTPulseZoneData( + dName, f"sensor-{dZone}", tags, dStatus + ) + self._zones.update({int(dZone): tmpzone}) + else: + LOG.debug( + f"Skipping incomplete zone name: {dName}, zone: {dZone} ", + f"status: {dStatus}, tags: {dType}", + ) self._last_updated = datetime.now() return self._zones From 4d8acf38acd756563e70640e95dae6c5f6968c64 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 04:57:17 -0400 Subject: [PATCH 31/84] have alarm panel set gateway status on site creation --- pyadtpulse/__init__.py | 4 +++- pyadtpulse/alarm_panel.py | 8 +++++--- pyadtpulse/gateway.py | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 534eaf0..48096b1 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -264,7 +264,9 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: # updated with _update_alarm_status await new_site._fetch_zones(None) if new_site.alarm_control_panel is not None: - new_site.alarm_control_panel._update_alarm_from_soup(soup) + new_site.alarm_control_panel._update_alarm_from_soup( + soup, new_site + ) else: LOG.error("Could not fetch control panel information") new_site._update_zone_from_soup(soup) diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index c442c87..0da62e2 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -11,6 +11,7 @@ from .const import ADT_ARM_DISARM_URI, LOG from .pulse_connection import ADTPulseConnection +from .site import ADTPulseSite from .util import make_soup ADT_ALARM_AWAY = "away" @@ -254,7 +255,9 @@ async def async_disarm(self, connection: ADTPulseConnection) -> bool: """ return await self._arm(connection, ADT_ALARM_OFF, False) - def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: + def _update_alarm_from_soup( + self, summary_html_soup: BeautifulSoup, site: ADTPulseSite + ) -> None: LOG.debug("Updating alarm status") value = summary_html_soup.find("span", {"class": "p_boldNormalTextLarge"}) sat_location = "security_button_0" @@ -285,9 +288,8 @@ def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: self._status = ADT_ALARM_HOME self._last_arm_disarm = last_updated else: - # FIXME: fix when have gateway device LOG.warning(f"Failed to get alarm status from '{text}'") - # self._adt_service._set_gateway_status(False) + site.gateway.is_online = False self._status = ADT_ALARM_UNKNOWN self._last_arm_disarm = last_updated return diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index 7dda685..5849ff2 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -50,6 +50,8 @@ def is_online(self, status: bool) -> None: Args: status (bool): True if gateway is online + + Also changes the polling intervals """ with self._attribute_lock: if status == self._is_online: From 1f167731760e40754151e66fd5c5ac3553595385 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 05:07:16 -0400 Subject: [PATCH 32/84] fix circular reference --- pyadtpulse/__init__.py | 11 ++++------- pyadtpulse/alarm_panel.py | 6 +----- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 48096b1..f03b60c 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -14,6 +14,7 @@ from aiohttp import ClientResponse, ClientSession from bs4 import BeautifulSoup +from .alarm_panel import ADT_ALARM_UNKNOWN from .const import ( ADT_DEFAULT_HTTP_HEADERS, ADT_DEFAULT_POLL_INTERVAL, @@ -128,7 +129,6 @@ def __init__( self._site: Optional[ADTPulseSite] = None self._poll_interval = poll_interval - # FIXME: I have no idea how to type hint this self._relogin_interval = ADT_RELOGIN_INTERVAL # authenticate the user @@ -263,12 +263,9 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: # fetch zones first, so that we can have the status # updated with _update_alarm_status await new_site._fetch_zones(None) - if new_site.alarm_control_panel is not None: - new_site.alarm_control_panel._update_alarm_from_soup( - soup, new_site - ) - else: - LOG.error("Could not fetch control panel information") + new_site.alarm_control_panel._update_alarm_from_soup(soup) + if new_site.alarm_control_panel.status == ADT_ALARM_UNKNOWN: + new_site.gateway.is_online = False new_site._update_zone_from_soup(soup) with self._attribute_lock: self._site = new_site diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index 0da62e2..8c6e773 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -11,7 +11,6 @@ from .const import ADT_ARM_DISARM_URI, LOG from .pulse_connection import ADTPulseConnection -from .site import ADTPulseSite from .util import make_soup ADT_ALARM_AWAY = "away" @@ -255,9 +254,7 @@ async def async_disarm(self, connection: ADTPulseConnection) -> bool: """ return await self._arm(connection, ADT_ALARM_OFF, False) - def _update_alarm_from_soup( - self, summary_html_soup: BeautifulSoup, site: ADTPulseSite - ) -> None: + def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: LOG.debug("Updating alarm status") value = summary_html_soup.find("span", {"class": "p_boldNormalTextLarge"}) sat_location = "security_button_0" @@ -289,7 +286,6 @@ def _update_alarm_from_soup( self._last_arm_disarm = last_updated else: LOG.warning(f"Failed to get alarm status from '{text}'") - site.gateway.is_online = False self._status = ADT_ALARM_UNKNOWN self._last_arm_disarm = last_updated return From 43469af01252f179fc97f8810e965a4c22dcd17a Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 05:27:39 -0400 Subject: [PATCH 33/84] cleanup moved methods --- example-client.py | 24 +++++++++++++----------- pyadtpulse/__init__.py | 5 +---- pyadtpulse/site.py | 8 ++++---- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/example-client.py b/example-client.py index c3af8ef..b91ba26 100755 --- a/example-client.py +++ b/example-client.py @@ -88,11 +88,11 @@ def print_site(site: ADTPulseSite) -> None: site (ADTPulseSite): The site to display """ print(f"Site: {site.name} (id={site.id})") - print(f"Alarm Status = {site.status}") - print(f"Disarmed? = {site.is_disarmed}") - print(f"Armed Away? = {site.is_away}") - print(f"Armed Home? = {site.is_home}") - print(f"Force armed? = {site.is_force_armed}") + print(f"Alarm Status = {site.alarm_control_panel.status}") + print(f"Disarmed? = {site.alarm_control_panel.is_disarmed}") + print(f"Armed Away? = {site.alarm_control_panel.is_away}") + print(f"Armed Home? = {site.alarm_control_panel.is_home}") + print(f"Force armed? = {site.alarm_control_panel.is_force_armed}") print(f"Last updated: {site.last_updated}") @@ -110,13 +110,15 @@ def check_updates(site: ADTPulseSite, adt: PyADTPulse, test_alarm: bool) -> bool Returns: bool: True if update successful """ if test_alarm: - while site.is_arming or site.is_disarming: + while ( + site.alarm_control_panel.is_arming or site.alarm_control_panel.is_disarming + ): print( - f"site is_arming: {site.is_arming}, " - f"site is_disarming: {site.is_disarming}" + f"site is_arming: {site.alarm_control_panel.is_arming}, " + f"site is_disarming: {site.alarm_control_panel.is_disarming}" ) sleep(1) - print(f"Gateway online: {adt.gateway_online}") + print(f"Gateway online: {adt.site.gateway.is_online}") if adt.update(): print("ADT Data updated, at " f"{site.last_updated}, refreshing") return True @@ -221,7 +223,7 @@ def sync_example( return adt.site.site_lock.acquire() - print(f"Gateway online: {adt.gateway_online}") + print(f"Gateway online: {adt.site.gateway.is_online}") print_site(adt.site) if not adt.site.zones: print("Error: no zones exist, exiting") @@ -374,7 +376,7 @@ async def async_example( done = False while not done: try: - print(f"Gateway online: {adt.gateway_online}") + print(f"Gateway online: {adt.site.gateway.is_online}") print_site(adt.site) print("----") if not adt.site.zones: diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index f03b60c..20d3313 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -60,7 +60,6 @@ class PyADTPulse: "_password", "_fingerprint", "_login_exception", - "_gateway_online", "_relogin_interval", ) @@ -99,8 +98,6 @@ def __init__( debug_locks: (bool, optional): use debugging locks Defaults to False """ - self._gateway_online: bool = False - self._init_login_info(username, password, fingerprint) self._pulse_connection = ADTPulseConnection( service_host, @@ -240,7 +237,7 @@ async def _update_sites(self, soup: BeautifulSoup) -> None: LOG.error("pyadtpulse returned no sites") return - self._site._update_alarm_from_soup(soup) + self._site.alarm_control_panel._update_alarm_from_soup(soup) self._site._update_zone_from_soup(soup) async def _initialize_sites(self, soup: BeautifulSoup) -> None: diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index f16ebd2..c7bfbb4 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -102,7 +102,7 @@ def site_lock(self) -> Union[RLock, DebugRLock]: """ return self._site_lock - def arm_home(self, force_arm: bool) -> bool: + def arm_home(self, force_arm: bool = False) -> bool: """Arm system home.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system home, no control panels exist") @@ -110,7 +110,7 @@ def arm_home(self, force_arm: bool) -> bool: self._pulse_connection, force_arm=force_arm ) - def arm_away(self, force_arm: bool) -> bool: + def arm_away(self, force_arm: bool = False) -> bool: """Arm system away.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system away, no control panels exist") @@ -124,7 +124,7 @@ def disarm(self) -> bool: raise RuntimeError("Cannot disarm system, no control panels exist") return self.alarm_control_panel.disarm(self._pulse_connection) - async def async_arm_home(self, force_arm: bool) -> bool: + async def async_arm_home(self, force_arm: bool = False) -> bool: """Arm system home async.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system home, no control panels exist") @@ -132,7 +132,7 @@ async def async_arm_home(self, force_arm: bool) -> bool: self._pulse_connection, force_arm=force_arm ) - async def async_arm_away(self, force_arm: bool) -> bool: + async def async_arm_away(self, force_arm: bool = False) -> bool: """Arm system away async.""" if self.alarm_control_panel is None: raise RuntimeError("Cannot arm system away, no control panels exist") From fc6cd7924869fa4cd1a5dcd959eb9828b62279a1 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 06:02:35 -0400 Subject: [PATCH 34/84] Fix zone attribute parse --- pyadtpulse/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index c7bfbb4..6fea867 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -270,7 +270,7 @@ async def _fetch_zones( if dev_attr is None: continue dName = dev_attr.get("Name:") - dType = dev_attr.get("Type:") + dType = dev_attr.get("Type/Model:") dZone = dev_attr.get("Zone:") dStatus = dev_attr.get("Status:") From 24f6e9b046c7a0401a2d0bca7e33afe9491374fc Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 06:33:31 -0400 Subject: [PATCH 35/84] more fixes to site._fetch_zones --- pyadtpulse/site.py | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 6fea867..48346d5 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -222,7 +222,7 @@ async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str identityText = str(devInfoRow.get_text()) sibling = devInfoRow.find_next_sibling() if not sibling: - value = "" + value = "Unknown" else: value = str(sibling.get_text()).strip() result.update({identityText: value}) @@ -269,33 +269,30 @@ async def _fetch_zones( dev_attr = await self._get_device_attributes(result[0]) if dev_attr is None: continue - dName = dev_attr.get("Name:") - dType = dev_attr.get("Type/Model:") - dZone = dev_attr.get("Zone:") - dStatus = dev_attr.get("Status:") + dName = dev_attr.get("Name:", "Unknown") + dType = dev_attr.get("Type/Model:", "Unknown") + dZone = dev_attr.get("Zone:", "Unknown") + dStatus = dev_attr.get("Status:", "Unknown") # NOTE: if empty string, this is the control panel - if dZone is not None and dZone != "": + if dZone != "Unknown": tags = None - - if dType is not None: - for ( - search_term, - default_tags, - ) in ADT_NAME_TO_DEFAULT_TAGS.items(): - # convert to uppercase first - if search_term.upper() in dType.upper(): - tags = default_tags - break + for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): + # convert to uppercase first + if search_term.upper() in dType.upper(): + tags = default_tags + break if not tags: LOG.warning( f"Unknown sensor type for '{dType}', " "defaulting to doorWindow" ) tags = ("sensor", "doorWindow") - LOG.debug(f"Retrieved sensor {dName} id: sensor-{dZone}") - LOG.debug(f"Status: {dStatus}, tags {tags}") - if dName is None or dStatus is None or dZone is None: + LOG.debug( + f"Retrieved sensor {dName} id: sensor-{dZone} " + f"Status: {dStatus}, tags {tags}" + ) + if "Unknown" in (dName, dStatus, dZone) or not dZone.isdecimal(): LOG.debug("Zone data incomplete, skipping...") else: tmpzone = ADTPulseZoneData( @@ -304,8 +301,8 @@ async def _fetch_zones( self._zones.update({int(dZone): tmpzone}) else: LOG.debug( - f"Skipping incomplete zone name: {dName}, zone: {dZone} ", - f"status: {dStatus}, tags: {dType}", + f"Skipping incomplete zone name: {dName}, zone: {dZone} " + f"status: {dStatus}" ) self._last_updated = datetime.now() return self._zones From 9a50114defa92def967c514dd55c1e875eed8f0b Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 06:38:57 -0400 Subject: [PATCH 36/84] change site_fetch_zones to return bool --- pyadtpulse/__init__.py | 3 ++- pyadtpulse/site.py | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 20d3313..1a3c391 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -259,7 +259,8 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: # fetch zones first, so that we can have the status # updated with _update_alarm_status - await new_site._fetch_zones(None) + if not await new_site._fetch_zones(None): + LOG.error("Could not fetch zones from ADT site") new_site.alarm_control_panel._update_alarm_from_soup(soup) if new_site.alarm_control_panel.status == ADT_ALARM_UNKNOWN: new_site.gateway.is_online = False diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 48346d5..2b71ce5 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -228,9 +228,7 @@ async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str result.update({identityText: value}) return result - async def _fetch_zones( - self, soup: Optional[BeautifulSoup] - ) -> Optional[ADTPulseZones]: + async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: """Fetch zones for a site. Args: @@ -249,7 +247,7 @@ async def _fetch_zones( "Failed loading zone status from ADT Pulse service", ) if not soup: - return None + return False regexDevice = r"goToUrl\('device.jsp\?id=(\d*)'\);" with self._site_lock: @@ -305,7 +303,7 @@ async def _fetch_zones( f"status: {dStatus}" ) self._last_updated = datetime.now() - return self._zones + return True # FIXME: ensure the zones for the correct site are being loaded!!! From 1378e20af34aec6304b6e0f3175fea1f44ab4220 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 14 Jul 2023 07:33:29 -0400 Subject: [PATCH 37/84] create zones via concurrent tasks --- pyadtpulse/site.py | 80 ++++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 2b71ce5..ae861a6 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -2,7 +2,7 @@ import logging import re -from asyncio import get_event_loop, run_coroutine_threadsafe +from asyncio import Task, create_task, gather, get_event_loop, run_coroutine_threadsafe from datetime import datetime, timedelta from threading import RLock from typing import List, Optional, Union @@ -228,6 +228,43 @@ async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str result.update({identityText: value}) return result + async def _create_zone(self, device_id: str) -> None: + dev_attr = await self._get_device_attributes(device_id) + if dev_attr is None: + return + dName = dev_attr.get("Name:", "Unknown") + dType = dev_attr.get("Type/Model:", "Unknown") + dZone = dev_attr.get("Zone:", "Unknown") + dStatus = dev_attr.get("Status:", "Unknown") + + # NOTE: if empty string, this is the control panel + if dZone != "Unknown": + tags = None + for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): + # convert to uppercase first + if search_term.upper() in dType.upper(): + tags = default_tags + break + if not tags: + LOG.warning( + f"Unknown sensor type for '{dType}', " "defaulting to doorWindow" + ) + tags = ("sensor", "doorWindow") + LOG.debug( + f"Retrieved sensor {dName} id: sensor-{dZone} " + f"Status: {dStatus}, tags {tags}" + ) + if "Unknown" in (dName, dStatus, dZone) or not dZone.isdecimal(): + LOG.debug("Zone data incomplete, skipping...") + else: + tmpzone = ADTPulseZoneData(dName, f"sensor-{dZone}", tags, dStatus) + self._zones.update({int(dZone): tmpzone}) + else: + LOG.debug( + f"Skipping incomplete zone name: {dName}, zone: {dZone} " + f"status: {dStatus}" + ) + async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: """Fetch zones for a site. @@ -239,6 +276,7 @@ async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: None if an error occurred """ + task_list: list[Task] = [] if not soup: response = await self._pulse_connection._async_query(ADT_SYSTEM_URI) soup = await make_soup( @@ -263,45 +301,9 @@ async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: "from ADT Pulse service, ignoring" ) continue + task_list.append(create_task(self._create_zone(result[0]))) - dev_attr = await self._get_device_attributes(result[0]) - if dev_attr is None: - continue - dName = dev_attr.get("Name:", "Unknown") - dType = dev_attr.get("Type/Model:", "Unknown") - dZone = dev_attr.get("Zone:", "Unknown") - dStatus = dev_attr.get("Status:", "Unknown") - - # NOTE: if empty string, this is the control panel - if dZone != "Unknown": - tags = None - for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): - # convert to uppercase first - if search_term.upper() in dType.upper(): - tags = default_tags - break - if not tags: - LOG.warning( - f"Unknown sensor type for '{dType}', " - "defaulting to doorWindow" - ) - tags = ("sensor", "doorWindow") - LOG.debug( - f"Retrieved sensor {dName} id: sensor-{dZone} " - f"Status: {dStatus}, tags {tags}" - ) - if "Unknown" in (dName, dStatus, dZone) or not dZone.isdecimal(): - LOG.debug("Zone data incomplete, skipping...") - else: - tmpzone = ADTPulseZoneData( - dName, f"sensor-{dZone}", tags, dStatus - ) - self._zones.update({int(dZone): tmpzone}) - else: - LOG.debug( - f"Skipping incomplete zone name: {dName}, zone: {dZone} " - f"status: {dStatus}" - ) + await gather(*task_list) self._last_updated = datetime.now() return True From 5e31e76660b8a041c4b5acba9cd2a345e75a964c Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sat, 15 Jul 2023 04:05:44 -0400 Subject: [PATCH 38/84] change gateway optional arguments, remove _is_online --- pyadtpulse/gateway.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index 5849ff2..a047100 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from ipaddress import IPv4Address from threading import RLock +from typing import Optional from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, LOG @@ -12,27 +13,26 @@ class ADTPulseGateway: """ADT Pulse Gateway information.""" manufacturer: str = "Unknown" - _is_online: bool = False _status_text: str = "OFFLINE" _current_poll_interval: float = ADT_DEFAULT_POLL_INTERVAL _initial_poll_interval: float = ADT_DEFAULT_POLL_INTERVAL _attribute_lock = RLock() - model: str = "Unknown" - serial_number: str = "Unknown" + model: Optional[str] = None + serial_number: Optional[str] = None next_update: float = 0.0 last_update: float = 0.0 - firmware_version: str = "Unknown" - hardware_version: str = "Unknown" - primary_connection_type: str = "Unknown" - broadband_connection_status: str = "Unknown" - cellular_connection_status: str = "Unknown" + firmware_version: Optional[str] = None + hardware_version: Optional[str] = None + primary_connection_type: Optional[str] = None + broadband_connection_status: Optional[str] = None + cellular_connection_status: Optional[str] = None cellular_connection_signal_strength: float = 0.0 - broadband_lan_ip_address: IPv4Address = IPv4Address("0.0.0.0") - broadband_lan_mac_address: str = "Unknown" - device_lan_ip_address: IPv4Address = IPv4Address("0.0.0.0") - device_lan_mac_address: str = "Unknown" - router_lan_ip_address: IPv4Address = IPv4Address("0.0.0.0") - router_wan_ip_address: IPv4Address = IPv4Address("0.0.0.0") + broadband_lan_ip_address: Optional[IPv4Address] = None + broadband_lan_mac_address: Optional[str] = None + device_lan_ip_address: Optional[IPv4Address] = None + device_lan_mac_address: Optional[str] = None + router_lan_ip_address: Optional[IPv4Address] = None + router_wan_ip_address: Optional[IPv4Address] = None @property def is_online(self) -> bool: @@ -42,7 +42,7 @@ def is_online(self) -> bool: bool: True if gateway is online """ with self._attribute_lock: - return self._is_online + return self._status_text == "ONLINE" @is_online.setter def is_online(self, status: bool) -> None: @@ -54,7 +54,7 @@ def is_online(self, status: bool) -> None: Also changes the polling intervals """ with self._attribute_lock: - if status == self._is_online: + if status == self.is_online: return self._status_text = "ONLINE" @@ -68,7 +68,6 @@ def is_online(self, status: bool) -> None: f"ADT Pulse gateway {self._status_text}, " "poll interval={self._current_poll_interval}" ) - self._is_online = status @property def poll_interval(self) -> float: From c468ff9fc76b962e73ba2ef8593e2c6e6119a387 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 28 Aug 2023 18:54:43 -0400 Subject: [PATCH 39/84] add gateway attributes update --- pyadtpulse/gateway.py | 64 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index a047100..a9e2449 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -1,12 +1,35 @@ """ADT Pulse Gateway Dataclass.""" from dataclasses import dataclass -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv6Address, ip_address from threading import RLock from typing import Optional from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, LOG +STRING_UPDATEABLE_FIELDS = ( + "manufacturer", + "model", + "serial_number", + "firmware_version", + "hardware_version", + "primary_connection_type", + "broadband_connection_status", + "cellular_connection_status", + "cellular_connection_signal_strength", + "broadband_lan_mac_address", + "device_lan_mac_address", +) + +DATE_UPDATEABLE_FIELDS = ("next_update", "last_update") + +IPADDR_UPDATEABLE_FIELDS = ( + "broadband_lan_ip_address", + "device_lan_ip_address", + "router_lan_ip_address", + "router_wan_ip_address", +) + @dataclass(slots=True) class ADTPulseGateway: @@ -27,12 +50,12 @@ class ADTPulseGateway: broadband_connection_status: Optional[str] = None cellular_connection_status: Optional[str] = None cellular_connection_signal_strength: float = 0.0 - broadband_lan_ip_address: Optional[IPv4Address] = None + broadband_lan_ip_address: Optional[IPv4Address | IPv6Address] = None broadband_lan_mac_address: Optional[str] = None - device_lan_ip_address: Optional[IPv4Address] = None + device_lan_ip_address: Optional[IPv4Address | IPv6Address] = None device_lan_mac_address: Optional[str] = None - router_lan_ip_address: Optional[IPv4Address] = None - router_wan_ip_address: Optional[IPv4Address] = None + router_lan_ip_address: Optional[IPv4Address | IPv6Address] = None + router_wan_ip_address: Optional[IPv4Address | IPv6Address] = None @property def is_online(self) -> bool: @@ -96,3 +119,34 @@ def poll_interval(self, new_interval: float) -> None: if self._current_poll_interval != ADT_GATEWAY_OFFLINE_POLL_INTERVAL: self._current_poll_interval = new_interval LOG.debug(f"Set poll interval to {self._initial_poll_interval}") + + def set_gateway_attributes(self, gateway_attributes: dict[str, str]) -> None: + """Set gateway attributes from dictionary. + + Args: + gateway_attributes (dict[str,str]): dictionary of gateway attributes + """ """""" + for i in STRING_UPDATEABLE_FIELDS + IPADDR_UPDATEABLE_FIELDS: + temp = gateway_attributes.get(i) + if temp == "": + temp = None + if i in IPADDR_UPDATEABLE_FIELDS: + temp2 = None + if temp is not None: + try: + temp2 = ip_address(temp) + except ValueError: + temp2 = None + setattr(self, i, temp2) + else: + setattr(self, i, temp) + """ + for i in DATE_UPDATEABLE_FIELDS: + temp = gateway_attributes.get(i) + if temp is not None: + try: + temp = datetime.strftime(temp,"DD/MM/YY HH:MI:SS") + except ValueError: + temp = None + setattr(self,i,temp) + """ From caf647a8768530801b6403aa1882a26b546d6428 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 28 Aug 2023 19:57:44 -0400 Subject: [PATCH 40/84] add parse_pulse_datetime() --- pyadtpulse/site.py | 50 +++++++--------------------------------------- pyadtpulse/util.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index ae861a6..ae17e37 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -3,20 +3,19 @@ import logging import re from asyncio import Task, create_task, gather, get_event_loop, run_coroutine_threadsafe -from datetime import datetime, timedelta +from datetime import datetime from threading import RLock from typing import List, Optional, Union from warnings import warn # import dateparser from bs4 import BeautifulSoup -from dateutil import relativedelta from .alarm_panel import ADTPulseAlarmPanel from .const import ADT_DEVICE_URI, ADT_SYSTEM_URI, LOG from .gateway import ADTPulseGateway from .pulse_connection import ADTPulseConnection -from .util import DebugRLock, make_soup, remove_prefix +from .util import DebugRLock, make_soup, parse_pulse_datetime, remove_prefix from .zones import ( ADT_NAME_TO_DEFAULT_TAGS, ADTPulseFlattendZone, @@ -340,48 +339,13 @@ def _update_zone_from_soup(self, soup: BeautifulSoup) -> Optional[ADTPulseZones] temp = row.find("span", {"class": "devStatIcon"}) if temp is None: break - t = datetime.today() last_update = datetime(1970, 1, 1) - datestring = remove_prefix(temp.get("title"), "Last Event:").split( - "\xa0" - ) - if len(datestring) < 3: - LOG.warning( - "Warning, could not retrieve last update for zone, " - f"defaulting to {last_update}" + try: + last_update = parse_pulse_datetime( + remove_prefix(temp.get("title"), "Last Event:") ) - else: - if datestring[0].lstrip() == "Today": - last_update = t - else: - if datestring[0].lstrip() == "Yesterday": - last_update = t - timedelta(days=1) - else: - tempdate = ("/".join((datestring[0], str(t.year)))).lstrip() - try: - last_update = datetime.strptime(tempdate, "%m/%d/%Y") - except ValueError: - LOG.warning( - f"pyadtpulse couldn't convert date {last_update}, " - f"defaulting to {last_update}" - ) - if last_update > t: - last_update = last_update - relativedelta.relativedelta( - years=1 - ) - try: - update_time = datetime.time( - datetime.strptime(datestring[1] + datestring[2], "%I:%M%p") - ) - except ValueError: - update_time = datetime.time(last_update) - LOG.warning( - f"pyadtpulse couldn't convert time " - f"{datestring[1] + datestring[2]}, " - f"defaulting to {update_time}" - ) - last_update = datetime.combine(last_update, update_time) - + except ValueError: + last_update = datetime(1970, 1, 1) # name = row.find("a", {"class": "p_deviceNameText"}).get_text() temp = row.find("span", {"class": "p_grayNormalText"}) if temp is None: diff --git a/pyadtpulse/util.py b/pyadtpulse/util.py index d06be2a..ffddb55 100644 --- a/pyadtpulse/util.py +++ b/pyadtpulse/util.py @@ -3,6 +3,7 @@ import string import sys from base64 import urlsafe_b64encode +from datetime import datetime, timedelta from pathlib import Path from random import randint from threading import RLock, current_thread @@ -10,6 +11,7 @@ from aiohttp import ClientResponse from bs4 import BeautifulSoup +from dateutil import relativedelta LOG = logging.getLogger(__name__) @@ -200,6 +202,40 @@ def __exit__(self, t, v, b): self._Rlock.release() +def parse_pulse_datetime(datestring: str) -> datetime: + """Parse pulse date strings. + + Args: + datestring (str): the string to parse + + Raises: + ValueError: pass through of value error if string + cannot be converted + + Returns: + datetime: time value of given string + """ + split_string = datestring.split("\xa0") + if len(split_string) < 3: + raise ValueError("Invalid datestring") + t = datetime.today() + if split_string[0].lstrip() == "Today": + last_update = t + else: + if split_string[0].lstrip() == "Yesterday": + last_update = t - timedelta(days=1) + else: + tempdate = ("/".join((split_string[0], str(t.year)))).lstrip() + last_update = datetime.strptime(tempdate, "%m/%d/%Y") + if last_update > t: + last_update = last_update - relativedelta.relativedelta(years=1) + update_time = datetime.time( + datetime.strptime(split_string[1] + split_string[2], "%I:%M%p") + ) + last_update = datetime.combine(last_update, update_time) + return last_update + + class AuthenticationException(RuntimeError): """Raised when a login failed.""" From 9340c3094f5256a4dae3a6c6c0b1e2dd8f856b05 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 28 Aug 2023 19:58:12 -0400 Subject: [PATCH 41/84] add parse_pulse_datetime() --- pyadtpulse/gateway.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index a9e2449..bb56758 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -3,9 +3,10 @@ from dataclasses import dataclass from ipaddress import IPv4Address, IPv6Address, ip_address from threading import RLock -from typing import Optional +from typing import Any, Optional from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, LOG +from .util import parse_pulse_datetime STRING_UPDATEABLE_FIELDS = ( "manufacturer", @@ -21,7 +22,7 @@ "device_lan_mac_address", ) -DATE_UPDATEABLE_FIELDS = ("next_update", "last_update") +DATETIME_UPDATEABLE_FIELDS = ("next_update", "last_update") IPADDR_UPDATEABLE_FIELDS = ( "broadband_lan_ip_address", @@ -126,27 +127,25 @@ def set_gateway_attributes(self, gateway_attributes: dict[str, str]) -> None: Args: gateway_attributes (dict[str,str]): dictionary of gateway attributes """ """""" - for i in STRING_UPDATEABLE_FIELDS + IPADDR_UPDATEABLE_FIELDS: - temp = gateway_attributes.get(i) + for i in ( + STRING_UPDATEABLE_FIELDS + + IPADDR_UPDATEABLE_FIELDS + + DATETIME_UPDATEABLE_FIELDS + ): + temp: Any = gateway_attributes.get(i) if temp == "": temp = None + if temp is None: + setattr(self, i, None) + continue if i in IPADDR_UPDATEABLE_FIELDS: - temp2 = None - if temp is not None: - try: - temp2 = ip_address(temp) - except ValueError: - temp2 = None - setattr(self, i, temp2) - else: - setattr(self, i, temp) - """ - for i in DATE_UPDATEABLE_FIELDS: - temp = gateway_attributes.get(i) - if temp is not None: try: - temp = datetime.strftime(temp,"DD/MM/YY HH:MI:SS") + temp = ip_address(temp) except ValueError: temp = None - setattr(self,i,temp) - """ + elif i in DATETIME_UPDATEABLE_FIELDS: + try: + temp = parse_pulse_datetime(temp).timestamp() + except ValueError: + temp = None + setattr(self, i, temp) From 0b9edf321495ff7b40549e14102c9f215df88b06 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 28 Aug 2023 20:17:17 -0400 Subject: [PATCH 42/84] add set_alarm_attributes --- pyadtpulse/alarm_panel.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index 8c6e773..4a055f5 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -307,3 +307,6 @@ def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: LOG.debug("Extracted sat = %s", self._sat) else: LOG.warning("Unable to extract sat") + + def set_alarm_attributes(self, alarm_attributes: dict[str, str]) -> None: + setattr(self, "model", alarm_attributes.get("Type/Model:", "Unknown")) From 3903e4e3a9699a87aba27d0ee2ced09504958c7e Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 31 Aug 2023 23:35:02 -0400 Subject: [PATCH 43/84] slugify device attributes --- pyadtpulse/site.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index ae17e37..60564db 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -218,7 +218,14 @@ async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str for devInfoRow in deviceResponseSoup.find_all( "td", {"class", "InputFieldDescriptionL"} ): - identityText = str(devInfoRow.get_text()) + identityText = ( + str(devInfoRow.get_text()) + .lower() + .strip() + .rstrip(":") + .replace(" ", "_") + .replace("/", "_") + ) sibling = devInfoRow.find_next_sibling() if not sibling: value = "Unknown" @@ -231,10 +238,10 @@ async def _create_zone(self, device_id: str) -> None: dev_attr = await self._get_device_attributes(device_id) if dev_attr is None: return - dName = dev_attr.get("Name:", "Unknown") - dType = dev_attr.get("Type/Model:", "Unknown") - dZone = dev_attr.get("Zone:", "Unknown") - dStatus = dev_attr.get("Status:", "Unknown") + dName = dev_attr.get("name", "Unknown") + dType = dev_attr.get("type_model", "Unknown") + dZone = dev_attr.get("zone", "Unknown") + dStatus = dev_attr.get("status", "Unknown") # NOTE: if empty string, this is the control panel if dZone != "Unknown": From 54927bd9b8a9f4d67c2368bc40a3798b33e56783 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 1 Sep 2023 01:03:48 -0400 Subject: [PATCH 44/84] Add gateway and alarm panel attributes --- example-client.py | 2 ++ pyadtpulse/gateway.py | 8 ++++---- pyadtpulse/site.py | 26 ++++++++++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/example-client.py b/example-client.py index b91ba26..1275712 100755 --- a/example-client.py +++ b/example-client.py @@ -94,6 +94,8 @@ def print_site(site: ADTPulseSite) -> None: print(f"Armed Home? = {site.alarm_control_panel.is_home}") print(f"Force armed? = {site.alarm_control_panel.is_force_armed}") print(f"Last updated: {site.last_updated}") + print() + print(f"Gateway: {site.gateway}") def check_updates(site: ADTPulseSite, adt: PyADTPulse, test_alarm: bool) -> bool: diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index bb56758..9146bcb 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -18,8 +18,8 @@ "broadband_connection_status", "cellular_connection_status", "cellular_connection_signal_strength", - "broadband_lan_mac_address", - "device_lan_mac_address", + "broadband_lan_mac", + "device_lan_mac", ) DATETIME_UPDATEABLE_FIELDS = ("next_update", "last_update") @@ -52,9 +52,9 @@ class ADTPulseGateway: cellular_connection_status: Optional[str] = None cellular_connection_signal_strength: float = 0.0 broadband_lan_ip_address: Optional[IPv4Address | IPv6Address] = None - broadband_lan_mac_address: Optional[str] = None + broadband_lan_mac: Optional[str] = None device_lan_ip_address: Optional[IPv4Address | IPv6Address] = None - device_lan_mac_address: Optional[str] = None + device_lan_mac: Optional[str] = None router_lan_ip_address: Optional[IPv4Address | IPv6Address] = None router_wan_ip_address: Optional[IPv4Address | IPv6Address] = None diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 60564db..e53cdf2 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -205,13 +205,18 @@ def history(self): async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str]]: result: dict[str, str] = {} - deviceResponse = await self._pulse_connection._async_query( - ADT_DEVICE_URI, extra_params={"id": device_id} - ) + if device_id == "gateway": + deviceResponse = await self._pulse_connection._async_query( + "/system/gateway.jsp", timeout=10 + ) + else: + deviceResponse = await self._pulse_connection._async_query( + ADT_DEVICE_URI, extra_params={"id": device_id} + ) deviceResponseSoup = await make_soup( deviceResponse, logging.DEBUG, - "Failed loading zone data from ADT Pulse service", + "Failed loading device attributes from ADT Pulse service", ) if deviceResponseSoup is None: return None @@ -234,10 +239,16 @@ async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str result.update({identityText: value}) return result - async def _create_zone(self, device_id: str) -> None: + async def _set_device(self, device_id: str) -> None: dev_attr = await self._get_device_attributes(device_id) if dev_attr is None: return + if device_id == "gateway": + self._gateway.set_gateway_attributes(dev_attr) + return + if device_id == "1": + self._alarm_panel.set_alarm_attributes(dev_attr) + return dName = dev_attr.get("name", "Unknown") dType = dev_attr.get("type_model", "Unknown") dZone = dev_attr.get("zone", "Unknown") @@ -297,6 +308,9 @@ async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: with self._site_lock: for row in soup.find_all("tr", {"class": "p_listRow", "onclick": True}): onClickValueText = row.get("onclick") + if onClickValueText == "goToUrl('gateway.jsp');": + task_list.append(create_task(self._set_device("gateway"))) + continue result = re.findall(regexDevice, onClickValueText) # only proceed if regex succeeded, as some users have onClick @@ -307,7 +321,7 @@ async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: "from ADT Pulse service, ignoring" ) continue - task_list.append(create_task(self._create_zone(result[0]))) + task_list.append(create_task(self._set_device(result[0]))) await gather(*task_list) self._last_updated = datetime.now() From 9982b545b4756dd4d7de0be4347f68acd29ae6ea Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 1 Sep 2023 01:15:11 -0400 Subject: [PATCH 45/84] rename _fetch_zones to _fetch_devices --- pyadtpulse/__init__.py | 2 +- pyadtpulse/site.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 1a3c391..16de59c 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -259,7 +259,7 @@ async def _initialize_sites(self, soup: BeautifulSoup) -> None: # fetch zones first, so that we can have the status # updated with _update_alarm_status - if not await new_site._fetch_zones(None): + if not await new_site._fetch_devices(None): LOG.error("Could not fetch zones from ADT site") new_site.alarm_control_panel._update_alarm_from_soup(soup) if new_site.alarm_control_panel.status == ADT_ALARM_UNKNOWN: diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index e53cdf2..3c9ae07 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -282,7 +282,7 @@ async def _set_device(self, device_id: str) -> None: f"status: {dStatus}" ) - async def _fetch_zones(self, soup: Optional[BeautifulSoup]) -> bool: + async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: """Fetch zones for a site. Args: From ac696dc5a77ba6e202b9af711fb48f181a82348d Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 1 Sep 2023 01:15:49 -0400 Subject: [PATCH 46/84] rename _fetch_zones to _fetch_devices --- pyadtpulse/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 3c9ae07..ed42a0b 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -283,7 +283,7 @@ async def _set_device(self, device_id: str) -> None: ) async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: - """Fetch zones for a site. + """Fetch devices for a site. Args: soup (BeautifulSoup, Optional): a BS4 object with data fetched from From 4090f61ee88ab624006671eb0c2863bf330ffe03 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 1 Sep 2023 01:32:09 -0400 Subject: [PATCH 47/84] move update_zone_attributes to zones.py --- pyadtpulse/gateway.py | 2 +- pyadtpulse/site.py | 41 ++++------------------------------------- pyadtpulse/zones.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index 9146bcb..bf63ea1 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -90,7 +90,7 @@ def is_online(self, status: bool) -> None: LOG.info( f"ADT Pulse gateway {self._status_text}, " - "poll interval={self._current_poll_interval}" + f"poll interval={self._current_poll_interval}" ) @property diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index ed42a0b..58c25ea 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -16,12 +16,7 @@ from .gateway import ADTPulseGateway from .pulse_connection import ADTPulseConnection from .util import DebugRLock, make_soup, parse_pulse_datetime, remove_prefix -from .zones import ( - ADT_NAME_TO_DEFAULT_TAGS, - ADTPulseFlattendZone, - ADTPulseZoneData, - ADTPulseZones, -) +from .zones import ADTPulseFlattendZone, ADTPulseZones class ADTPulseSite: @@ -249,38 +244,10 @@ async def _set_device(self, device_id: str) -> None: if device_id == "1": self._alarm_panel.set_alarm_attributes(dev_attr) return - dName = dev_attr.get("name", "Unknown") - dType = dev_attr.get("type_model", "Unknown") - dZone = dev_attr.get("zone", "Unknown") - dStatus = dev_attr.get("status", "Unknown") - - # NOTE: if empty string, this is the control panel - if dZone != "Unknown": - tags = None - for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): - # convert to uppercase first - if search_term.upper() in dType.upper(): - tags = default_tags - break - if not tags: - LOG.warning( - f"Unknown sensor type for '{dType}', " "defaulting to doorWindow" - ) - tags = ("sensor", "doorWindow") - LOG.debug( - f"Retrieved sensor {dName} id: sensor-{dZone} " - f"Status: {dStatus}, tags {tags}" - ) - if "Unknown" in (dName, dStatus, dZone) or not dZone.isdecimal(): - LOG.debug("Zone data incomplete, skipping...") - else: - tmpzone = ADTPulseZoneData(dName, f"sensor-{dZone}", tags, dStatus) - self._zones.update({int(dZone): tmpzone}) + if device_id.isdigit(): + self._zones.update_zone_attributes(int(device_id), dev_attr) else: - LOG.debug( - f"Skipping incomplete zone name: {dName}, zone: {dZone} " - f"status: {dStatus}" - ) + LOG.debug(f"Zone {device_id} is not an integer, skipping") async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: """Fetch devices for a site. diff --git a/pyadtpulse/zones.py b/pyadtpulse/zones.py index e95bb7b..b06298f 100644 --- a/pyadtpulse/zones.py +++ b/pyadtpulse/zones.py @@ -4,6 +4,8 @@ from datetime import datetime from typing import List, Tuple, TypedDict +from .const import LOG + ADT_NAME_TO_DEFAULT_TAGS = { "Door": ("sensor", "doorWindow"), "Window": ("sensor", "doorWindow"), @@ -189,3 +191,37 @@ def flatten(self) -> List[ADTPulseFlattendZone]: } ) return result + + def update_zone_attributes(self, key: int, dev_attr: dict[str, str]) -> None: + """Update zone attributes.""" + dName = dev_attr.get("name", "Unknown") + dType = dev_attr.get("type_model", "Unknown") + dZone = dev_attr.get("zone", "Unknown") + dStatus = dev_attr.get("status", "Unknown") + + if dZone != "Unknown": + tags = None + for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): + # convert to uppercase first + if search_term.upper() in dType.upper(): + tags = default_tags + break + if not tags: + LOG.warning( + f"Unknown sensor type for '{dType}', " "defaulting to doorWindow" + ) + tags = ("sensor", "doorWindow") + LOG.debug( + f"Retrieved sensor {dName} id: sensor-{dZone} " + f"Status: {dStatus}, tags {tags}" + ) + if "Unknown" in (dName, dStatus, dZone) or not dZone.isdecimal(): + LOG.debug("Zone data incomplete, skipping...") + else: + tmpzone = ADTPulseZoneData(dName, f"sensor-{dZone}", tags, dStatus) + self.update({int(dZone): tmpzone}) + else: + LOG.debug( + f"Skipping incomplete zone name: {dName}, zone: {dZone} " + f"status: {dStatus}" + ) From 26e5ffa092e348649e13294c9e390fd8eb6bd327 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 1 Sep 2023 01:40:11 -0400 Subject: [PATCH 48/84] add const ADT_GATEWAY_STRING --- pyadtpulse/const.py | 1 + pyadtpulse/site.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index df6fa10..3733d84 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -21,6 +21,7 @@ # Intervals are all in seconds ADT_TIMEOUT_INTERVAL = 300.0 ADT_RELOGIN_INTERVAL = 7200 +ADT_GATEWAY_STRING = "gateway" # ADT sets their keepalive to 1 second, so poll a little more often # than that diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 58c25ea..413397c 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -12,7 +12,7 @@ from bs4 import BeautifulSoup from .alarm_panel import ADTPulseAlarmPanel -from .const import ADT_DEVICE_URI, ADT_SYSTEM_URI, LOG +from .const import ADT_DEVICE_URI, ADT_GATEWAY_STRING, ADT_SYSTEM_URI, LOG from .gateway import ADTPulseGateway from .pulse_connection import ADTPulseConnection from .util import DebugRLock, make_soup, parse_pulse_datetime, remove_prefix @@ -200,7 +200,7 @@ def history(self): async def _get_device_attributes(self, device_id: str) -> Optional[dict[str, str]]: result: dict[str, str] = {} - if device_id == "gateway": + if device_id == ADT_GATEWAY_STRING: deviceResponse = await self._pulse_connection._async_query( "/system/gateway.jsp", timeout=10 ) @@ -238,7 +238,7 @@ async def _set_device(self, device_id: str) -> None: dev_attr = await self._get_device_attributes(device_id) if dev_attr is None: return - if device_id == "gateway": + if device_id == ADT_GATEWAY_STRING: self._gateway.set_gateway_attributes(dev_attr) return if device_id == "1": @@ -276,7 +276,7 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: for row in soup.find_all("tr", {"class": "p_listRow", "onclick": True}): onClickValueText = row.get("onclick") if onClickValueText == "goToUrl('gateway.jsp');": - task_list.append(create_task(self._set_device("gateway"))) + task_list.append(create_task(self._set_device(ADT_GATEWAY_STRING))) continue result = re.findall(regexDevice, onClickValueText) From 495baf20c431ec016d07239ef947bfd309d4427a Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 1 Sep 2023 01:52:03 -0400 Subject: [PATCH 49/84] update gateway info during keepalive task --- pyadtpulse/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 16de59c..dfa57df 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -18,6 +18,7 @@ from .const import ( ADT_DEFAULT_HTTP_HEADERS, ADT_DEFAULT_POLL_INTERVAL, + ADT_GATEWAY_STRING, ADT_LOGIN_URI, ADT_LOGOUT_URI, ADT_RELOGIN_INTERVAL, @@ -362,6 +363,8 @@ async def _keepalive_task(self) -> None: close_response(response) continue close_response(response) + if self.site.gateway.next_update > time.time(): + await self.site._set_device(ADT_GATEWAY_STRING) except asyncio.CancelledError: LOG.debug(f"{task_name} cancelled") close_response(response) From 136ed66b4c1d24c277ecbe7a9fdd0045152e7295 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 4 Sep 2023 23:46:02 -0400 Subject: [PATCH 50/84] move LOG from const.py --- pyadtpulse/__init__.py | 3 ++- pyadtpulse/alarm_panel.py | 3 ++- pyadtpulse/const.py | 2 -- pyadtpulse/gateway.py | 5 ++++- pyadtpulse/pulse_connection.py | 3 ++- pyadtpulse/site.py | 5 +++-- pyadtpulse/zones.py | 5 +++-- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index dfa57df..a675e7d 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -27,7 +27,6 @@ ADT_TIMEOUT_INTERVAL, ADT_TIMEOUT_URI, DEFAULT_API_HOST, - LOG, ) from .pulse_connection import ADTPulseConnection from .site import ADTPulseSite @@ -39,6 +38,8 @@ make_soup, ) +LOG = logging.getLogger(__name__) + SYNC_CHECK_TASK_NAME = "ADT Pulse Sync Check Task" KEEPALIVE_TASK_NAME = "ADT Pulse Keepalive Task" diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index 4a055f5..e6060f3 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -9,10 +9,11 @@ from bs4 import BeautifulSoup -from .const import ADT_ARM_DISARM_URI, LOG +from .const import ADT_ARM_DISARM_URI from .pulse_connection import ADTPulseConnection from .util import make_soup +LOG = logging.getLogger(__name__) ADT_ALARM_AWAY = "away" ADT_ALARM_HOME = "stay" ADT_ALARM_OFF = "off" diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index 3733d84..82b5832 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -1,7 +1,5 @@ """Constants for pyadtpulse.""" -from logging import getLogger -LOG = getLogger(__name__) DEFAULT_API_HOST = "https://portal.adtpulse.com" API_HOST_CA = "https://portal-ca.adtpulse.com" # Canada diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index bf63ea1..b2d6e72 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -1,13 +1,16 @@ """ADT Pulse Gateway Dataclass.""" +import logging from dataclasses import dataclass from ipaddress import IPv4Address, IPv6Address, ip_address from threading import RLock from typing import Any, Optional -from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_OFFLINE_POLL_INTERVAL, LOG +from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_OFFLINE_POLL_INTERVAL from .util import parse_pulse_datetime +LOG = logging.getLogger(__name__) + STRING_UPDATEABLE_FIELDS = ( "manufacturer", "model", diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index 8f00e38..35c8dc1 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -1,5 +1,6 @@ """ADT Pulse connection.""" +import logging import asyncio import re from random import uniform @@ -24,11 +25,11 @@ ADT_ORB_URI, ADT_SYSTEM_URI, API_PREFIX, - LOG, ) from .util import DebugRLock, close_response, make_soup RECOVERABLE_ERRORS = [429, 500, 502, 503, 504] +LOG = logging.getLogger(__name__) class ADTPulseConnection: diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 413397c..cf92f53 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -1,5 +1,4 @@ """Module representing an ADT Pulse Site.""" - import logging import re from asyncio import Task, create_task, gather, get_event_loop, run_coroutine_threadsafe @@ -12,12 +11,14 @@ from bs4 import BeautifulSoup from .alarm_panel import ADTPulseAlarmPanel -from .const import ADT_DEVICE_URI, ADT_GATEWAY_STRING, ADT_SYSTEM_URI, LOG +from .const import ADT_DEVICE_URI, ADT_GATEWAY_STRING, ADT_SYSTEM_URI from .gateway import ADTPulseGateway from .pulse_connection import ADTPulseConnection from .util import DebugRLock, make_soup, parse_pulse_datetime, remove_prefix from .zones import ADTPulseFlattendZone, ADTPulseZones +LOG = logging.getLogger(__name__) + class ADTPulseSite: """Represents an individual ADT Pulse site.""" diff --git a/pyadtpulse/zones.py b/pyadtpulse/zones.py index b06298f..55add98 100644 --- a/pyadtpulse/zones.py +++ b/pyadtpulse/zones.py @@ -1,11 +1,10 @@ """ADT Pulse zone info.""" +import logging from collections import UserDict from dataclasses import dataclass from datetime import datetime from typing import List, Tuple, TypedDict -from .const import LOG - ADT_NAME_TO_DEFAULT_TAGS = { "Door": ("sensor", "doorWindow"), "Window": ("sensor", "doorWindow"), @@ -19,6 +18,8 @@ "Moisture": ("sensor", "flood"), } +LOG = logging.getLogger(__name__) + @dataclass(slots=True) class ADTPulseZoneData: From 8462ed2bdb8b30ecaa36eb30000dc5e0c306ff57 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 00:28:42 -0400 Subject: [PATCH 51/84] add more detail to query exception logging --- pyadtpulse/pulse_connection.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index 35c8dc1..119918d 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -183,8 +183,9 @@ async def _async_query( ClientConnectionError, ClientConnectorError, ) as ex: - LOG.info( - f"Error {ex} occurred making {method} request to {url}, retrying" + LOG.debug( + f"Error {ex.__str__} occurred making {method} request to {url}, retrying", + exc_info=True, ) await asyncio.sleep(2**retry + uniform(0.0, 1.0)) continue From 606f0ce9f44c7f5a48534c9d2c2af21decf25981 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 00:49:19 -0400 Subject: [PATCH 52/84] fix alarm panel attribute update --- example-client.py | 3 +++ pyadtpulse/alarm_panel.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/example-client.py b/example-client.py index 1275712..f6d52b2 100755 --- a/example-client.py +++ b/example-client.py @@ -88,6 +88,9 @@ def print_site(site: ADTPulseSite) -> None: site (ADTPulseSite): The site to display """ print(f"Site: {site.name} (id={site.id})") + print(f"Alarm Model: {site.alarm_control_panel.model}") + print(f"Manufacturer: {site.alarm_control_panel.manufacturer}") + print(f"Alarm Online? = {site.alarm_control_panel.online}") print(f"Alarm Status = {site.alarm_control_panel.status}") print(f"Disarmed? = {site.alarm_control_panel.is_disarmed}") print(f"Armed Away? = {site.alarm_control_panel.is_away}") diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index e6060f3..9518aa9 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -310,4 +310,16 @@ def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: LOG.warning("Unable to extract sat") def set_alarm_attributes(self, alarm_attributes: dict[str, str]) -> None: - setattr(self, "model", alarm_attributes.get("Type/Model:", "Unknown")) + """ + Set alarm attributes including model, manufacturer, and online status. + + Args: + self (object): The instance of the alarm. + alarm_attributes (dict[str, str]): A dictionary containing alarm attributes. + + Returns: + None + """ + self.model = alarm_attributes.get("type_model", "Unknown") + self.manufacturer = alarm_attributes.get("manufacturer_provider", "ADT") + self.online = alarm_attributes.get("status", "Offline") == "Online" From c23d3a1822ca00e5a6481548e6ebec82569ce32f Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 01:02:55 -0400 Subject: [PATCH 53/84] fix gateway update in sync task --- pyadtpulse/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index a675e7d..38093a2 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -364,7 +364,7 @@ async def _keepalive_task(self) -> None: close_response(response) continue close_response(response) - if self.site.gateway.next_update > time.time(): + if self.site.gateway.next_update < time.time(): await self.site._set_device(ADT_GATEWAY_STRING) except asyncio.CancelledError: LOG.debug(f"{task_name} cancelled") From b0896e4ca66a46cb78d877197bce0f522014d95d Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 01:22:23 -0400 Subject: [PATCH 54/84] remove incorrect no site exist message --- pyadtpulse/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 38093a2..2e55da7 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -234,11 +234,6 @@ async def _update_sites(self, soup: BeautifulSoup) -> None: await self._initialize_sites(soup) if self._site is None: raise RuntimeError("pyadtpulse could not retrieve site") - else: - # FIXME: wrong error? - LOG.error("pyadtpulse returned no sites") - return - self._site.alarm_control_panel._update_alarm_from_soup(soup) self._site._update_zone_from_soup(soup) From 31fc74ef1adada562e6b1178b73514b9a31239b1 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 01:46:59 -0400 Subject: [PATCH 55/84] fix sync token check logic --- pyadtpulse/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 2e55da7..b1439c5 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -586,9 +586,12 @@ async def _sync_check_task(self) -> None: retry_after = 0 if self._updates_exist is None: raise RuntimeError(f"{task_name} started without update event initialized") + have_update = False while True: try: pi = self.site.gateway.poll_interval + if have_update: + pi = pi / 2.0 if retry_after == 0: await asyncio.sleep(pi) else: @@ -624,19 +627,19 @@ async def _sync_check_task(self) -> None: # we can have 0-0-0 followed by 1-0-0 followed by 2-0-0, etc # wait until these settle - # FIXME this is incorrect, and if we get here we know the gateway - # is online. Plus, we shouldn't set updates_exist until - # async_update succeeds if text.endswith("-0-0"): LOG.debug( f"Sync token {text} indicates updates may exist, requerying" ) close_response(response) - self._updates_exist.set() + have_update = True + continue + if have_update: + have_update = False if await self.async_update() is False: LOG.debug(f"Pulse data update from {task_name} failed") - continue - + continue + self._updates_exist.set() LOG.debug(f"Sync token {text} indicates no remote updates to process") close_response(response) From 0fd80f0139da2db647e100d28b912561337a9f11 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 01:48:14 -0400 Subject: [PATCH 56/84] fix line too long --- pyadtpulse/pulse_connection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index 119918d..c384c39 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -184,7 +184,8 @@ async def _async_query( ClientConnectorError, ) as ex: LOG.debug( - f"Error {ex.__str__} occurred making {method} request to {url}, retrying", + f"Error {ex.__str__} occurred making {method}" + f" request to {url}, retrying", exc_info=True, ) await asyncio.sleep(2**retry + uniform(0.0, 1.0)) From 2b8a93a7ce01b61e8608138cad913d80aee96690 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 02:58:54 -0400 Subject: [PATCH 57/84] avoid call to device.jsp for camera/keyfob, etc --- pyadtpulse/site.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index cf92f53..1b26179 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -275,8 +275,12 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: regexDevice = r"goToUrl\('device.jsp\?id=(\d*)'\);" with self._site_lock: for row in soup.find_all("tr", {"class": "p_listRow", "onclick": True}): + device_name = row.find("a").get_text() onClickValueText = row.get("onclick") - if onClickValueText == "goToUrl('gateway.jsp');": + if ( + onClickValueText == "goToUrl('gateway.jsp');" + or device_name == "Gateway" + ): task_list.append(create_task(self._set_device(ADT_GATEWAY_STRING))) continue result = re.findall(regexDevice, onClickValueText) @@ -289,7 +293,15 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: "from ADT Pulse service, ignoring" ) continue - task_list.append(create_task(self._set_device(result[0]))) + # Don't bother querying if we don't have a zone id + if ( + result[0] == "1" + or device_name == "Security Panel" + or row.find("td", {"align": "right"}).get_text().strip().isdigit() + ): + task_list.append(create_task(self._set_device(result[0]))) + else: + LOG.debug(f"Skipping {device_name} as it doesn't have an ID") await gather(*task_list) self._last_updated = datetime.now() From 2fcf1c9005552cbf74ccc62216f0fdae510cdfda Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 03:00:20 -0400 Subject: [PATCH 58/84] remove key from site.update_zone_attributes --- pyadtpulse/site.py | 2 +- pyadtpulse/zones.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 1b26179..783129b 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -246,7 +246,7 @@ async def _set_device(self, device_id: str) -> None: self._alarm_panel.set_alarm_attributes(dev_attr) return if device_id.isdigit(): - self._zones.update_zone_attributes(int(device_id), dev_attr) + self._zones.update_zone_attributes(dev_attr) else: LOG.debug(f"Zone {device_id} is not an integer, skipping") diff --git a/pyadtpulse/zones.py b/pyadtpulse/zones.py index 55add98..fdcfb4c 100644 --- a/pyadtpulse/zones.py +++ b/pyadtpulse/zones.py @@ -193,7 +193,7 @@ def flatten(self) -> List[ADTPulseFlattendZone]: ) return result - def update_zone_attributes(self, key: int, dev_attr: dict[str, str]) -> None: + def update_zone_attributes(self, dev_attr: dict[str, str]) -> None: """Update zone attributes.""" dName = dev_attr.get("name", "Unknown") dType = dev_attr.get("type_model", "Unknown") From b89fb76316e60f4b99aae3995ea38b2db7b8df8c Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 04:03:03 -0400 Subject: [PATCH 59/84] skip call to device.jsp for zones if possible --- pyadtpulse/site.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 783129b..2b2d1f2 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -276,6 +276,28 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: with self._site_lock: for row in soup.find_all("tr", {"class": "p_listRow", "onclick": True}): device_name = row.find("a").get_text() + row_tds = row.find_all("td") + zone_id = None + # see if we can create a zone without calling device.jsp + if row_tds is not None and len(row_tds) > 4: + zone_name = row_tds[1].get_text().strip() + zone_id = row_tds[2].get_text().strip() + zone_type = row_tds[4].get_text().strip() + zone_status = row_tds[0].find("canvas").get("title").strip() + if ( + zone_id.isdecimal() + and zone_name is not None + and zone_type is not None + ): + self._zones.update_zone_attributes( + { + "name": zone_name, + "zone": zone_id, + "type_model": zone_type, + "status": zone_status, + } + ) + continue onClickValueText = row.get("onclick") if ( onClickValueText == "goToUrl('gateway.jsp');" @@ -293,13 +315,14 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: "from ADT Pulse service, ignoring" ) continue - # Don't bother querying if we don't have a zone id - if ( - result[0] == "1" - or device_name == "Security Panel" - or row.find("td", {"align": "right"}).get_text().strip().isdigit() - ): + # alarm panel case + if result[0] == "1" or device_name == "Security Panel": task_list.append(create_task(self._set_device(result[0]))) + continue + # zone case if we couldn't just call update_zone_attributes + if zone_id is not None and zone_id.isdecimal(): + task_list.append(create_task(self._set_device(result[0]))) + continue else: LOG.debug(f"Skipping {device_name} as it doesn't have an ID") From 71bf9e07c1c6f7c0ba47c663212a21791a83b665 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 04:07:16 -0400 Subject: [PATCH 60/84] add debug logging to set_alarm_attributes --- pyadtpulse/alarm_panel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index 9518aa9..03e7d5f 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -323,3 +323,7 @@ def set_alarm_attributes(self, alarm_attributes: dict[str, str]) -> None: self.model = alarm_attributes.get("type_model", "Unknown") self.manufacturer = alarm_attributes.get("manufacturer_provider", "ADT") self.online = alarm_attributes.get("status", "Offline") == "Online" + LOG.debug( + f"Set alarm attributes: Model = {self.model}, Manufacturer = " + f"{self.manufacturer}, Online = {self.online}" + ) From b1dc0370e41e93500fe7530b20114e974aab33cb Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 16:42:22 -0400 Subject: [PATCH 61/84] use argparse for example-client --- .vscode/launch.json | 2 +- example-client.py | 242 ++++++++++++++++++++++++++------------------ 2 files changed, 143 insertions(+), 101 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 94db57b..fbe98a3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,7 +19,7 @@ "module": "example-client", "justMyCode": true, "args": [ - "/home/rlippmann/pulse.json" + "~/pulse.json" ] } ] diff --git a/example-client.py b/example-client.py index f6d52b2..cd46485 100755 --- a/example-client.py +++ b/example-client.py @@ -2,9 +2,9 @@ """Sample client for using pyadtpulse.""" import logging +import argparse import asyncio import json -import os import sys from time import sleep from typing import Dict, Optional @@ -22,6 +22,21 @@ USE_ASYNC = "use_async" DEBUG_LOCKS = "debug_locks" +BOOLEAN_PARAMS = {USE_ASYNC, DEBUG_LOCKS, PULSE_DEBUG, TEST_ALARM} +INT_PARAMS = {SLEEP_INTERVAL} + +# Default values +DEFAULT_USE_ASYNC = True +DEFAULT_DEBUG = False +DEFAULT_TEST_ALARM = False +DEFAULT_SLEEP_INTERVAL = 5 +DEFAULT_DEBUG_LOCKS = False + +# Constants for environment variable names +ENV_USER = "USER" +ENV_PASSWORD = "PASSWORD" +ENV_FINGERPRINT = "FINGERPRINT" + def setup_logger(level: int): """Set up logger.""" @@ -36,49 +51,117 @@ def setup_logger(level: int): logger.addHandler(handler) -def handle_args() -> Optional[Dict]: - """Handle program arguments. +def handle_args() -> argparse.Namespace: + """Handle program arguments using argparse. Returns: - Optional[Dict]: parsed parameters. + argparse.Namespace: Parsed command-line arguments. """ - result: Dict = {} - - for curr_arg in sys.argv[1:]: - if "." in curr_arg: - f = open(curr_arg, "rb") - parameters = json.load(f) - result.update(parameters) - if "=" in curr_arg: - curr_value = curr_arg.split("=") - result.update({curr_value[0]: curr_value[1]}) - - if USER not in result: - result.update({USER: os.getenv(USER.upper(), None)}) - if PASSWD not in result: - result.update({PASSWD: os.getenv(PASSWD.upper(), None)}) - if FINGERPRINT not in result: - result.update({FINGERPRINT: os.getenv(FINGERPRINT.upper(), None)}) - if PULSE_DEBUG not in result: - result.update({PULSE_DEBUG: os.getenv(PULSE_DEBUG, None)}) - return result - - -def usage() -> None: - """Print program usage.""" - print(f"Usage {sys.argv[0]}: [json-file]") - print(f" {USER.upper()}, {PASSWD.upper()}, and {FINGERPRINT.upper()}") - print(" must be set either through the json file, or environment variables.") - print() - print(f" Set {PULSE_DEBUG} to True to enable debugging") - print(f" Set {TEST_ALARM} to True to test alarm arming/disarming") - print(f" Set {SLEEP_INTERVAL} to the number of seconds to sleep between each call") - print(" Default: 10 seconds") - print(f" Set {USE_ASYNC} to true to use asyncio (default: false)") - print(f" Set {DEBUG_LOCKS} to true to debug thread locks") - print() - print(" values can be passed on the command line i.e.") - print(f" {USER}=someone@example.com") + parser = argparse.ArgumentParser(description="ADT Pulse example client") + parser.add_argument("json_file", nargs="?", help="JSON file containing parameters") + parser.add_argument( + f"--{USER}", + help="Pulse username (can be set in JSON file or environment variable)", + ) + parser.add_argument( + f"--{PASSWD}", + help="Pulse password (can be set in JSON file or environment variable)", + ) + parser.add_argument( + f"--{FINGERPRINT}", + help="Pulse fingerprint (can be set in JSON file or environment variable)", + ) + parser.add_argument( + f"--{PULSE_DEBUG}", + type=bool, + default=None, + help="Set True to enable debugging", + ) + parser.add_argument( + f"--{TEST_ALARM}", + type=bool, + default=None, + help="Set True to test alarm arming/disarming", + ) + parser.add_argument( + f"--{SLEEP_INTERVAL}", + type=int, + default=None, + help="Number of seconds to sleep between each call (default: 10 seconds)", + ) + parser.add_argument( + f"--{USE_ASYNC}", + type=bool, + default=None, + help="Set to true to use asyncio (default: true)", + ) + parser.add_argument( + f"--{DEBUG_LOCKS}", + type=bool, + default=None, + help="Set to true to debug thread locks", + ) + + args = parser.parse_args() + + json_params = load_parameters_from_json(args.json_file) + + # Update arguments with values from the JSON file + # load_parameters_from_json() will handle incorrect types + if json_params is not None: + for key, value in json_params.items(): + if getattr(args, key) is None and value is not None: + setattr(args, key, value) + + # Set default values for specific parameters + if args.use_async is None: + args.use_async = DEFAULT_USE_ASYNC + if args.debug_locks is None: + args.debug_locks = DEFAULT_DEBUG_LOCKS + if args.debug is None: + args.debug = DEFAULT_DEBUG + if args.sleep_interval is None: + args.sleep_interval = DEFAULT_SLEEP_INTERVAL + return args + + +def load_parameters_from_json(json_file: str) -> Optional[Dict]: + """Load parameters from a JSON file. + + Args: + json_file (str): Path to the JSON file. + + Returns: + Optional[Dict]: Loaded parameters as a dictionary, + or None if there was an error. + """ + try: + with open(json_file) as file: + parameters = json.load(file) + for key, value in parameters.items(): + if key in BOOLEAN_PARAMS: + if not isinstance(value, bool): + print( + "Invalid boolean value for " + f"{key}: {value}" + " in JSON file, ignoring..." + ) + parameters.pop(key) + elif key in INT_PARAMS: + if not isinstance(value, int): + print( + "Invalid integer value for " + f"{key}: {value}" + " in JSON file, ignoring..." + ) + parameters.pop(key) + return parameters + except FileNotFoundError: + print(f"JSON file not found: {json_file}") + return None + except json.JSONDecodeError as e: + print(f"Error parsing JSON: {e}") + return None def print_site(site: ADTPulseSite) -> None: @@ -405,78 +488,33 @@ async def async_example( def main(): - """Run main program. + """Run main program.""" + args = handle_args() - Raises: - SystemExit: Environment variables not set. - """ - args = None - if len(sys.argv) > 1: - if sys.argv[1] == "--help": - usage() - sys.exit(0) - args = handle_args() - - if not args or USER not in args or PASSWD not in args or FINGERPRINT not in args: + if not args or not any( + [args.adtpulse_user, args.adtpulse_password, args.adtpulse_fingerprint] + ): print(f"ERROR! {USER}, {PASSWD}, and {FINGERPRINT} must all be set") raise SystemExit - debug = False - try: - debug = bool(args[PULSE_DEBUG]) - except ValueError: - print(f"{PULSE_DEBUG} must be True or False, defaulting to False") - except KeyError: - pass - + debug = args.debug if debug: level = logging.DEBUG else: level = logging.ERROR - run_alarm_test = False - try: - run_alarm_test = bool(args[TEST_ALARM]) - except ValueError: - print(f"{TEST_ALARM} must be True or False, defaulting to False") - except KeyError: - pass - - use_async = False - try: - use_async = bool(args[USE_ASYNC]) - except ValueError: - print(f"{USE_ASYNC} must be an boolean, defaulting to False") - except KeyError: - pass - - debug_locks = False - try: - debug_locks = bool(args[DEBUG_LOCKS]) - except ValueError: - print(f"{DEBUG_LOCKS} must be an boolean, defaulting to False") - except KeyError: - pass - - # don't need to sleep with async - sleep_interval = 10 - try: - sleep_interval = int(args[SLEEP_INTERVAL]) - except ValueError: - if use_async: - print(f"{SLEEP_INTERVAL} must be an integer, defaulting to 10 seconds") - except KeyError: - pass + run_alarm_test = args.test_alarm + use_async = args.use_async + debug_locks = args.debug_locks + sleep_interval = args.sleep_interval setup_logger(level) - #### - if not use_async: sync_example( - args[USER], - args[PASSWD], - args[FINGERPRINT], + args.adtpulse_user, + args.adtpulse_password, + args.adtpulse_fingerprint, run_alarm_test, sleep_interval, debug_locks, @@ -484,7 +522,11 @@ def main(): else: asyncio.run( async_example( - args[USER], args[PASSWD], args[FINGERPRINT], run_alarm_test, debug_locks + args.adtpulse_user, + args.adtpulse_password, + args.adtpulse_fingerprint, + run_alarm_test, + debug_locks, ) ) From d1af36e9948a9db9ecd77bc98007287ebb1f0635 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 5 Sep 2023 17:49:57 -0400 Subject: [PATCH 62/84] use pprint --- example-client.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/example-client.py b/example-client.py index cd46485..7bab163 100755 --- a/example-client.py +++ b/example-client.py @@ -6,6 +6,7 @@ import asyncio import json import sys +from pprint import pprint from time import sleep from typing import Dict, Optional @@ -171,17 +172,10 @@ def print_site(site: ADTPulseSite) -> None: site (ADTPulseSite): The site to display """ print(f"Site: {site.name} (id={site.id})") - print(f"Alarm Model: {site.alarm_control_panel.model}") - print(f"Manufacturer: {site.alarm_control_panel.manufacturer}") - print(f"Alarm Online? = {site.alarm_control_panel.online}") - print(f"Alarm Status = {site.alarm_control_panel.status}") - print(f"Disarmed? = {site.alarm_control_panel.is_disarmed}") - print(f"Armed Away? = {site.alarm_control_panel.is_away}") - print(f"Armed Home? = {site.alarm_control_panel.is_home}") - print(f"Force armed? = {site.alarm_control_panel.is_force_armed}") - print(f"Last updated: {site.last_updated}") - print() - print(f"Gateway: {site.gateway}") + print("Alarm panel: ") + pprint(site.alarm_control_panel, compact=True) + print("Gateway: ") + pprint(site.gateway, compact=True) def check_updates(site: ADTPulseSite, adt: PyADTPulse, test_alarm: bool) -> bool: @@ -318,8 +312,8 @@ def sync_example( adt.site.site_lock.release() adt.logout() return - for zone in adt.site.zones: - print(zone) + + pprint(adt.site.zones, compact=True) adt.site.site_lock.release() if run_alarm_test: test_alarm(adt.site, adt, sleep_interval) @@ -343,9 +337,7 @@ def sync_example( break print("\nZones:") with adt.site.site_lock: - for zone in adt.site.zones: - print(zone) - print(f"{adt.site.zones_as_dict}") + pprint(adt.site.zones, compact=True) else: print("No updates exist") sleep(sleep_interval) @@ -456,8 +448,7 @@ async def async_example( await adt.async_logout() return - for zone in adt.site.zones: - print(zone) + pprint(adt.site.zones, compact=True) if run_alarm_test: await async_test_alarm(adt) @@ -472,10 +463,7 @@ async def async_example( done = True break print("\nZones:") - for zone in adt.site.zones: - print(zone) - # print(f"{site.zones_as_dict}") - + pprint(adt.site.zones, compact=True) await adt.wait_for_update() print("Updates exist, refreshing") # no need to call an update method From 32c323ca6ece751c273308959b294c388b551c7d Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 01:47:33 -0400 Subject: [PATCH 63/84] change relogin and timeout intervals to minutes --- pyadtpulse/__init__.py | 8 ++++---- pyadtpulse/const.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index b1439c5..c8f8c7b 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -128,7 +128,7 @@ def __init__( self._site: Optional[ADTPulseSite] = None self._poll_interval = poll_interval - self._relogin_interval = ADT_RELOGIN_INTERVAL + self._relogin_interval: int = ADT_RELOGIN_INTERVAL # authenticate the user if do_login and websession is None: @@ -226,7 +226,7 @@ def relogin_interval(self, interval: int) -> None: if interval > 0 and interval < 10: raise ValueError("Cannot set relogin interval to less than 10 minutes") with self._attribute_lock: - self._relogin_interval = interval * 60 + self._relogin_interval = interval async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: @@ -319,7 +319,7 @@ async def _keepalive_task(self) -> None: "Keepalive task is running without an authenticated event" ) while self._authenticated.is_set(): - relogin_interval = self.relogin_interval + relogin_interval = self.relogin_interval * 60 if ( relogin_interval != 0 and time.time() - self._last_timeout_reset > relogin_interval @@ -347,7 +347,7 @@ async def _keepalive_task(self) -> None: coro, name=f"{SYNC_CHECK_TASK_NAME}: Async session" ) try: - await asyncio.sleep(ADT_TIMEOUT_INTERVAL + retry_after) + await asyncio.sleep(ADT_TIMEOUT_INTERVAL * 60.0 + retry_after) LOG.debug("Resetting timeout") response = await self._pulse_connection._async_query( ADT_TIMEOUT_URI, "POST" diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index 82b5832..e925a8c 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -16,9 +16,9 @@ ADT_STATES_URI = "/ajax/currentStates.jsp" ADT_SYNC_CHECK_URI = "/Ajax/SyncCheckServ" ADT_TIMEOUT_URI = "/KeepAlive" -# Intervals are all in seconds -ADT_TIMEOUT_INTERVAL = 300.0 -ADT_RELOGIN_INTERVAL = 7200 +# Intervals are all in minutes +ADT_TIMEOUT_INTERVAL: int = 5 +ADT_RELOGIN_INTERVAL: int = 120 ADT_GATEWAY_STRING = "gateway" # ADT sets their keepalive to 1 second, so poll a little more often From 380a61ae01b3d331312b180eb134ef03e07667bd Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 04:00:45 -0400 Subject: [PATCH 64/84] add STATE_ONLINE to const --- pyadtpulse/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index e925a8c..944d126 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -48,6 +48,7 @@ STATE_TAMPER = "Tamper" STATE_ALARM = "Alarm" STATE_UNKNOWN = "Unknown" +STATE_ONLINE = "Online" ADT_SENSOR_DOOR = "doorWindow" ADT_SENSOR_WINDOW = "glass" From ee95d1a0c639fb2c3ab58434d9a0a07f7e374905 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 04:03:38 -0400 Subject: [PATCH 65/84] add last_update property to alarm_panel --- pyadtpulse/alarm_panel.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index 03e7d5f..86e2cf7 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -107,6 +107,16 @@ def is_disarming(self) -> bool: with self._state_lock: return self._status == ADT_ALARM_DISARMING + @property + def last_update(self) -> float: + """Return last update time. + + Returns: + float: last arm/disarm time + """ + with self._state_lock: + return self._last_arm_disarm + async def _arm( self, connection: ADTPulseConnection, mode: str, force_arm: bool ) -> bool: From 03f2e2219a7ecc0c2f24170fa8301971649f5443 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 18:44:22 -0400 Subject: [PATCH 66/84] rework _do_login_query --- pyadtpulse/__init__.py | 70 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index c8f8c7b..cbaf23b 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -55,7 +55,7 @@ class PyADTPulse: "_updates_exist", "_session_thread", "_attribute_lock", - "_last_timeout_reset", + "_last_login_time", "_site", "_poll_interval", "_username", @@ -124,7 +124,7 @@ def __init__( self._attribute_lock = RLock() else: self._attribute_lock = DebugRLock("PyADTPulse._attribute_lock") - self._last_timeout_reset = 0.0 + self._last_login_time = 0.0 self._site: Optional[ADTPulseSite] = None self._poll_interval = poll_interval @@ -322,23 +322,21 @@ async def _keepalive_task(self) -> None: relogin_interval = self.relogin_interval * 60 if ( relogin_interval != 0 - and time.time() - self._last_timeout_reset > relogin_interval + and time.time() - self._last_login_time > relogin_interval ): LOG.info("Login timeout reached, re-logging in") + # FIXME?: should we just pause the task? with self._attribute_lock: if self._sync_task is not None: self._sync_task.cancel() with suppress(Exception): await self._sync_task await self._do_logout_query() - try: - response = await self._do_login_query() - except Exception as e: + response = await self._do_login_query() + if response is None: LOG.error( - f"{task_name} could not re-login to ADT Pulse due to" - f" exception {e}, exiting" + f"{task_name} could not re-login to ADT Pulse, exiting..." ) - close_response(response) return close_response(response) if self._sync_task is not None: @@ -448,23 +446,39 @@ def loop(self) -> Optional[asyncio.AbstractEventLoop]: return self._pulse_connection.loop async def _do_login_query(self, timeout: int = 30) -> ClientResponse | None: - return await self._pulse_connection._async_query( - ADT_LOGIN_URI, - method="POST", - extra_params={ - "partner": "adt", - "e": "ns", - "usernameForm": self.username, - "passwordForm": self._password, - "fingerprint": self._fingerprint, - "sun": "yes", - }, - timeout=timeout, - ) + try: + retval = await self._pulse_connection._async_query( + ADT_LOGIN_URI, + method="POST", + extra_params={ + "partner": "adt", + "e": "ns", + "usernameForm": self.username, + "passwordForm": self._password, + "fingerprint": self._fingerprint, + "sun": "yes", + }, + timeout=timeout, + ) + except Exception as e: + LOG.error(f"Could not log into Pulse site: {e}") + return None + if retval is None: + LOG.error("Could not log into Pulse site.") + return None + if not handle_response( + retval, + logging.ERROR, + "Error encountered communicating with Pulse site on login", + ): + close_response(retval) + return None + self._last_login_time = time.time() + return retval async def _do_logout_query(self) -> None: params = {} - network: ADTPulseSite = self.sites[0] + network: ADTPulseSite = self.site if network is not None: params.update({"network": str(network.id)}) params.update({"partner": "adt"}) @@ -481,18 +495,12 @@ async def async_login(self) -> bool: self._authenticated = asyncio.locks.Event() else: self._authenticated.clear() - self._last_timeout_reset = time.time() LOG.debug(f"Authenticating to ADT Pulse cloud service as {self._username}") await self._pulse_connection._async_fetch_version() response = await self._do_login_query() - if not handle_response( - response, - logging.ERROR, - "Error encountered communicating with Pulse site on login", - ): - close_response(response) + if response is None: return False if self._pulse_connection.make_url(ADT_SUMMARY_URI) != str(response.url): # type: ignore # more specifically: @@ -527,7 +535,6 @@ async def async_login(self) -> bool: LOG.error("Could not retrieve any sites, login failed") self._authenticated.clear() return False - self._last_timeout_reset = time.time() # since we received fresh data on the status of the alarm, go ahead # and update the sites with the alarm status. @@ -558,7 +565,6 @@ async def async_logout(self) -> None: await self._sync_task self._timeout_task = self._sync_task = None await self._do_logout_query() - self._last_timeout_reset = time.time() if self._authenticated is not None: self._authenticated.clear() From 12cb54df921dec11ec5a8061bf46c232fbda4224 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 19:16:16 -0400 Subject: [PATCH 67/84] add keepalive_interval property --- pyadtpulse/__init__.py | 45 ++++++++++++++++++++++++++++++++++++++---- pyadtpulse/const.py | 4 ++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index cbaf23b..dedb199 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -17,14 +17,14 @@ from .alarm_panel import ADT_ALARM_UNKNOWN from .const import ( ADT_DEFAULT_HTTP_HEADERS, + ADT_DEFAULT_KEEPALIVE_INTERVAL, ADT_DEFAULT_POLL_INTERVAL, + ADT_DEFAULT_RELOGIN_INTERVAL, ADT_GATEWAY_STRING, ADT_LOGIN_URI, ADT_LOGOUT_URI, - ADT_RELOGIN_INTERVAL, ADT_SUMMARY_URI, ADT_SYNC_CHECK_URI, - ADT_TIMEOUT_INTERVAL, ADT_TIMEOUT_URI, DEFAULT_API_HOST, ) @@ -63,6 +63,7 @@ class PyADTPulse: "_fingerprint", "_login_exception", "_relogin_interval", + "_keepalive_interval", ) def __init__( @@ -76,6 +77,8 @@ def __init__( do_login: bool = True, poll_interval: float = ADT_DEFAULT_POLL_INTERVAL, debug_locks: bool = False, + keepalive_interval: int = ADT_DEFAULT_KEEPALIVE_INTERVAL, + relogin_interval: int = ADT_DEFAULT_RELOGIN_INTERVAL, ): """Create a PyADTPulse object. @@ -99,6 +102,10 @@ def __init__( poll_interval (float, optional): number of seconds between update checks debug_locks: (bool, optional): use debugging locks Defaults to False + keepalive_interval (int, optional): number of seconds between + keepalive checks, defaults to ADT_DEFAULT_KEEPALIVE_INTERVAL + relogin_interval (int, optional): number of seconds between relogin checks + defaults to ADT_DEFAULT_RELOGIN_INTERVAL """ self._init_login_info(username, password, fingerprint) self._pulse_connection = ADTPulseConnection( @@ -128,7 +135,9 @@ def __init__( self._site: Optional[ADTPulseSite] = None self._poll_interval = poll_interval - self._relogin_interval: int = ADT_RELOGIN_INTERVAL + self._check_keepalive_relogin_intervals(keepalive_interval, relogin_interval) + self._relogin_interval = relogin_interval + self._keepalive_interval = keepalive_interval # authenticate the user if do_login and websession is None: @@ -200,6 +209,16 @@ def version(self) -> str: with ADTPulseConnection._class_threadlock: return ADTPulseConnection._api_version + @staticmethod + def _check_keepalive_relogin_intervals( + keepalive_interval: int, relogin_interval: int + ) -> None: + if keepalive_interval > relogin_interval: + raise ValueError( + f"relogin_interval ({relogin_interval}) must be " + f"greater than keepalive_interval ({keepalive_interval})" + ) + @property def relogin_interval(self) -> int: """Get re-login interval. @@ -226,8 +245,26 @@ def relogin_interval(self, interval: int) -> None: if interval > 0 and interval < 10: raise ValueError("Cannot set relogin interval to less than 10 minutes") with self._attribute_lock: + self._check_keepalive_relogin_intervals(interval, self._relogin_interval) self._relogin_interval = interval + @property + def keepalive_interval(self) -> int: + """Get the keepalive interval in minutes. + + Returns: + int: the keepalive interval + """ + with self._attribute_lock: + return self._keepalive_interval + + @keepalive_interval.setter + def keepalive_interval(self, interval: int) -> None: + """Set the keepalive interval in minutes.""" + with self._attribute_lock: + self._check_keepalive_relogin_intervals(self._keepalive_interval, interval) + self._keepalive_interval = interval + async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: if self._site is None: @@ -345,7 +382,7 @@ async def _keepalive_task(self) -> None: coro, name=f"{SYNC_CHECK_TASK_NAME}: Async session" ) try: - await asyncio.sleep(ADT_TIMEOUT_INTERVAL * 60.0 + retry_after) + await asyncio.sleep(self.keepalive_interval * 60.0 + retry_after) LOG.debug("Resetting timeout") response = await self._pulse_connection._async_query( ADT_TIMEOUT_URI, "POST" diff --git a/pyadtpulse/const.py b/pyadtpulse/const.py index 944d126..0d49b10 100644 --- a/pyadtpulse/const.py +++ b/pyadtpulse/const.py @@ -17,8 +17,8 @@ ADT_SYNC_CHECK_URI = "/Ajax/SyncCheckServ" ADT_TIMEOUT_URI = "/KeepAlive" # Intervals are all in minutes -ADT_TIMEOUT_INTERVAL: int = 5 -ADT_RELOGIN_INTERVAL: int = 120 +ADT_DEFAULT_KEEPALIVE_INTERVAL: int = 5 +ADT_DEFAULT_RELOGIN_INTERVAL: int = 120 ADT_GATEWAY_STRING = "gateway" # ADT sets their keepalive to 1 second, so poll a little more often From 042378c54e33351b696a6bde93c44fa42cd763a1 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 19:54:52 -0400 Subject: [PATCH 68/84] validate service_host --- pyadtpulse/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index dedb199..b7147d0 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -26,6 +26,7 @@ ADT_SUMMARY_URI, ADT_SYNC_CHECK_URI, ADT_TIMEOUT_URI, + API_HOST_CA, DEFAULT_API_HOST, ) from .pulse_connection import ADTPulseConnection @@ -107,6 +108,7 @@ def __init__( relogin_interval (int, optional): number of seconds between relogin checks defaults to ADT_DEFAULT_RELOGIN_INTERVAL """ + self._check_service_host(service_host) self._init_login_info(username, password, fingerprint) self._pulse_connection = ADTPulseConnection( service_host, @@ -168,6 +170,15 @@ def __repr__(self) -> str: # support testing as well as alternative ADT Pulse endpoints such as # portal-ca.adtpulse.com + @staticmethod + def _check_service_host(service_host: str) -> None: + if service_host is None or service_host == "": + raise ValueError("Service host is mandatory") + if service_host not in (DEFAULT_API_HOST, API_HOST_CA): + raise ValueError( + "Service host must be one of {DEFAULT_API_HOST}" f" or {API_HOST_CA}" + ) + @property def service_host(self) -> str: """Get the Pulse host. @@ -183,7 +194,9 @@ def service_host(self, host: str) -> None: Args: host (str): name of Pulse endpoint host """ - self._pulse_connection.service_host = host + self._check_service_host(host) + with self._attribute_lock: + self._pulse_connection.service_host = host def set_service_host(self, host: str) -> None: """Backward compatibility for service host property setter.""" From 075fb16b6d2c3034668168db9fd0080dd1401028 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Sun, 10 Sep 2023 20:15:41 -0400 Subject: [PATCH 69/84] add new properties to example-client --- example-client.json | 5 ++++- example-client.py | 47 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/example-client.json b/example-client.json index 38fa0d0..32f3fe1 100644 --- a/example-client.json +++ b/example-client.json @@ -6,5 +6,8 @@ "debug_locks": false, "adtpulse_user": "me@myisp.com", "adtpulse_password": "supersecretpassword", - "adtpulse_fingerprint": "areallyreallyreallyreallyreallylongstring" + "adtpulse_fingerprint": "areallyreallyreallyreallyreallylongstring", + "service_host": "https://portal.adtpulse.com", + "relogin_interval": 60, + "keepalive_interval": 10 } diff --git a/example-client.py b/example-client.py index 7bab163..38ef860 100755 --- a/example-client.py +++ b/example-client.py @@ -11,6 +11,12 @@ from typing import Dict, Optional from pyadtpulse import PyADTPulse +from pyadtpulse.const import ( + ADT_DEFAULT_KEEPALIVE_INTERVAL, + ADT_DEFAULT_RELOGIN_INTERVAL, + API_HOST_CA, + DEFAULT_API_HOST, +) from pyadtpulse.site import ADTPulseSite from pyadtpulse.util import AuthenticationException @@ -22,9 +28,12 @@ SLEEP_INTERVAL = "sleep_interval" USE_ASYNC = "use_async" DEBUG_LOCKS = "debug_locks" +KEEPALIVE_INTERVAL = "keepalive_interval" +RELOGIN_INTERVAL = "relogin_interval" +SERVICE_HOST = "service_host" BOOLEAN_PARAMS = {USE_ASYNC, DEBUG_LOCKS, PULSE_DEBUG, TEST_ALARM} -INT_PARAMS = {SLEEP_INTERVAL} +INT_PARAMS = {SLEEP_INTERVAL, KEEPALIVE_INTERVAL, RELOGIN_INTERVAL} # Default values DEFAULT_USE_ASYNC = True @@ -33,6 +42,7 @@ DEFAULT_SLEEP_INTERVAL = 5 DEFAULT_DEBUG_LOCKS = False + # Constants for environment variable names ENV_USER = "USER" ENV_PASSWORD = "PASSWORD" @@ -72,6 +82,11 @@ def handle_args() -> argparse.Namespace: f"--{FINGERPRINT}", help="Pulse fingerprint (can be set in JSON file or environment variable)", ) + parser.add_argument( + f"--{SERVICE_HOST}", + help=f"Pulse service host, must be {DEFAULT_API_HOST} or {API_HOST_CA}, " + f"default is {DEFAULT_API_HOST}", + ) parser.add_argument( f"--{PULSE_DEBUG}", type=bool, @@ -88,19 +103,35 @@ def handle_args() -> argparse.Namespace: f"--{SLEEP_INTERVAL}", type=int, default=None, - help="Number of seconds to sleep between each call (default: 10 seconds)", + help="Number of seconds to sleep between each call " + f"(default: {DEFAULT_SLEEP_INTERVAL} seconds)," + " not used for async", ) parser.add_argument( f"--{USE_ASYNC}", type=bool, default=None, - help="Set to true to use asyncio (default: true)", + help=f"Set to true to use asyncio (default: {DEFAULT_USE_ASYNC})", ) parser.add_argument( f"--{DEBUG_LOCKS}", type=bool, default=None, - help="Set to true to debug thread locks", + help=f"Set to true to debug thread locks, default: {DEFAULT_DEBUG_LOCKS}", + ) + parser.add_argument( + f"--{KEEPALIVE_INTERVAL}", + type=int, + default=None, + help="Number of minutes to wait between keepalive calls (default: " + f"{ADT_DEFAULT_KEEPALIVE_INTERVAL} minutes)", + ) + parser.add_argument( + f"--{RELOGIN_INTERVAL}", + type=int, + default=None, + help="Number of minutes to wait between relogin calls " + f"(default: {ADT_DEFAULT_RELOGIN_INTERVAL} minutes)", ) args = parser.parse_args() @@ -121,8 +152,14 @@ def handle_args() -> argparse.Namespace: args.debug_locks = DEFAULT_DEBUG_LOCKS if args.debug is None: args.debug = DEFAULT_DEBUG - if args.sleep_interval is None: + if args.sleep_interval is None and args.use_async is False: args.sleep_interval = DEFAULT_SLEEP_INTERVAL + if args.keepalive_interval is None: + args.keepalive_interval = ADT_DEFAULT_KEEPALIVE_INTERVAL + if args.relogin_interval is None: + args.relogin_interval = ADT_DEFAULT_RELOGIN_INTERVAL + if args.service_host is None: + args.service_host = DEFAULT_API_HOST return args From 3c00c1ecbd22aef8c29f7e30eb5995cc00ce768e Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 11 Sep 2023 02:00:51 -0400 Subject: [PATCH 70/84] add poll_interval to example-client --- example-client.json | 3 +- example-client.py | 115 +++++++++++++++++++++++++++++--------------- 2 files changed, 77 insertions(+), 41 deletions(-) diff --git a/example-client.json b/example-client.json index 32f3fe1..9784edb 100644 --- a/example-client.json +++ b/example-client.json @@ -9,5 +9,6 @@ "adtpulse_fingerprint": "areallyreallyreallyreallyreallylongstring", "service_host": "https://portal.adtpulse.com", "relogin_interval": 60, - "keepalive_interval": 10 + "keepalive_interval": 10, + "poll_interval": 0.9 } diff --git a/example-client.py b/example-client.py index 38ef860..11e9819 100755 --- a/example-client.py +++ b/example-client.py @@ -13,6 +13,7 @@ from pyadtpulse import PyADTPulse from pyadtpulse.const import ( ADT_DEFAULT_KEEPALIVE_INTERVAL, + ADT_DEFAULT_POLL_INTERVAL, ADT_DEFAULT_RELOGIN_INTERVAL, API_HOST_CA, DEFAULT_API_HOST, @@ -31,9 +32,11 @@ KEEPALIVE_INTERVAL = "keepalive_interval" RELOGIN_INTERVAL = "relogin_interval" SERVICE_HOST = "service_host" +POLL_INTERVAL = "poll_interval" BOOLEAN_PARAMS = {USE_ASYNC, DEBUG_LOCKS, PULSE_DEBUG, TEST_ALARM} INT_PARAMS = {SLEEP_INTERVAL, KEEPALIVE_INTERVAL, RELOGIN_INTERVAL} +FLOAT_PARAMS = {POLL_INTERVAL} # Default values DEFAULT_USE_ASYNC = True @@ -134,6 +137,14 @@ def handle_args() -> argparse.Namespace: f"(default: {ADT_DEFAULT_RELOGIN_INTERVAL} minutes)", ) + parser.add_argument( + f"--{POLL_INTERVAL}", + type=float, + default=None, + help="Number of seconds to wait between polling calls " + f"(default: {ADT_DEFAULT_POLL_INTERVAL} seconds)", + ) + args = parser.parse_args() json_params = load_parameters_from_json(args.json_file) @@ -146,20 +157,32 @@ def handle_args() -> argparse.Namespace: setattr(args, key, value) # Set default values for specific parameters - if args.use_async is None: - args.use_async = DEFAULT_USE_ASYNC - if args.debug_locks is None: - args.debug_locks = DEFAULT_DEBUG_LOCKS - if args.debug is None: - args.debug = DEFAULT_DEBUG - if args.sleep_interval is None and args.use_async is False: + args.use_async = args.use_async if args.use_async is not None else DEFAULT_USE_ASYNC + args.debug_locks = ( + args.debug_locks if args.debug_locks is not None else DEFAULT_DEBUG_LOCKS + ) + args.debug = args.debug if args.debug is not None else DEFAULT_DEBUG + if args.use_async is False and args.sleep_interval is None: args.sleep_interval = DEFAULT_SLEEP_INTERVAL - if args.keepalive_interval is None: - args.keepalive_interval = ADT_DEFAULT_KEEPALIVE_INTERVAL - if args.relogin_interval is None: - args.relogin_interval = ADT_DEFAULT_RELOGIN_INTERVAL - if args.service_host is None: - args.service_host = DEFAULT_API_HOST + args.keepalive_interval = ( + args.keepalive_interval + if args.keepalive_interval is not None + else ADT_DEFAULT_KEEPALIVE_INTERVAL + ) + args.relogin_interval = ( + args.relogin_interval + if args.relogin_interval is not None + else ADT_DEFAULT_RELOGIN_INTERVAL + ) + args.service_host = ( + args.service_host if args.service_host is not None else DEFAULT_API_HOST + ) + args.poll_interval = ( + args.poll_interval + if args.poll_interval is not None + else ADT_DEFAULT_POLL_INTERVAL + ) + return args @@ -177,22 +200,27 @@ def load_parameters_from_json(json_file: str) -> Optional[Dict]: with open(json_file) as file: parameters = json.load(file) for key, value in parameters.items(): - if key in BOOLEAN_PARAMS: - if not isinstance(value, bool): - print( - "Invalid boolean value for " - f"{key}: {value}" - " in JSON file, ignoring..." - ) - parameters.pop(key) - elif key in INT_PARAMS: - if not isinstance(value, int): - print( - "Invalid integer value for " - f"{key}: {value}" - " in JSON file, ignoring..." - ) - parameters.pop(key) + if key in BOOLEAN_PARAMS and not isinstance(value, bool): + print( + "Invalid boolean value for " + f"{key}: {value}" + " in JSON file, ignoring..." + ) + parameters.pop(key) + elif key in INT_PARAMS and not isinstance(value, int): + print( + "Invalid integer value for " + f"{key}: {value}" + " in JSON file, ignoring..." + ) + parameters.pop(key) + elif key in FLOAT_PARAMS and not isinstance(value, float): + print( + "Invalid float value for " + f"{key}: {value}" + " in JSON file, ignoring..." + ) + parameters.pop(key) return parameters except FileNotFoundError: print(f"JSON file not found: {json_file}") @@ -312,6 +340,7 @@ def sync_example( run_alarm_test: bool, sleep_interval: int, debug_locks: bool, + poll_interval: float, ) -> None: """Run example of sync pyadtpulse calls. @@ -322,6 +351,7 @@ def sync_example( run_alarm_test (bool): True if alarm test to be run sleep_interval (int): how long in seconds to sleep between update checks debug_locks: bool: True to enable thread lock debugging + poll_interval (float): polling interval in seconds """ try: adt = PyADTPulse(username, password, fingerprint, debug_locks=debug_locks) @@ -452,6 +482,7 @@ async def async_example( fingerprint: str, run_alarm_test: bool, debug_locks: bool, + poll_interval: float, ) -> None: """Run example of pytadtpulse async usage. @@ -461,9 +492,15 @@ async def async_example( fingerprint (str): Pulse fingerprint run_alarm_test (bool): True if alarm tests should be run debug_locks (bool): True to enable thread lock debugging + poll_interval (float): polling interval in seconds """ adt = PyADTPulse( - username, password, fingerprint, do_login=False, debug_locks=debug_locks + username, + password, + fingerprint, + do_login=False, + debug_locks=debug_locks, + poll_interval=poll_interval, ) if not await adt.async_login(): @@ -522,16 +559,12 @@ def main(): print(f"ERROR! {USER}, {PASSWD}, and {FINGERPRINT} must all be set") raise SystemExit - debug = args.debug - if debug: + if args.debug: level = logging.DEBUG else: level = logging.ERROR - run_alarm_test = args.test_alarm use_async = args.use_async - debug_locks = args.debug_locks - sleep_interval = args.sleep_interval setup_logger(level) @@ -540,9 +573,10 @@ def main(): args.adtpulse_user, args.adtpulse_password, args.adtpulse_fingerprint, - run_alarm_test, - sleep_interval, - debug_locks, + args.run_alarm_test, + args.sleep_interval, + args.debug_locks, + args.poll_interval, ) else: asyncio.run( @@ -550,8 +584,9 @@ def main(): args.adtpulse_user, args.adtpulse_password, args.adtpulse_fingerprint, - run_alarm_test, - debug_locks, + args.run_alarm_test, + args.debug_locks, + args.poll_interval, ) ) From c0621fdc72641fc7306451da8d5587af033e21ff Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 11 Sep 2023 02:19:42 -0400 Subject: [PATCH 71/84] add missing test_alarm default to example-client --- example-client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/example-client.py b/example-client.py index 11e9819..eb80e02 100755 --- a/example-client.py +++ b/example-client.py @@ -162,6 +162,9 @@ def handle_args() -> argparse.Namespace: args.debug_locks if args.debug_locks is not None else DEFAULT_DEBUG_LOCKS ) args.debug = args.debug if args.debug is not None else DEFAULT_DEBUG + args.test_alarm = ( + args.test_alarm if args.test_alarm is not None else DEFAULT_TEST_ALARM + ) if args.use_async is False and args.sleep_interval is None: args.sleep_interval = DEFAULT_SLEEP_INTERVAL args.keepalive_interval = ( @@ -573,7 +576,7 @@ def main(): args.adtpulse_user, args.adtpulse_password, args.adtpulse_fingerprint, - args.run_alarm_test, + args.test_alarm, args.sleep_interval, args.debug_locks, args.poll_interval, @@ -584,7 +587,7 @@ def main(): args.adtpulse_user, args.adtpulse_password, args.adtpulse_fingerprint, - args.run_alarm_test, + args.test_alarm, args.debug_locks, args.poll_interval, ) From 2dc00fb9074ff39d80369d3a9aa8d057c012986a Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 11 Sep 2023 02:32:28 -0400 Subject: [PATCH 72/84] add missing new parameters to examples --- example-client.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/example-client.py b/example-client.py index eb80e02..8162afa 100755 --- a/example-client.py +++ b/example-client.py @@ -344,6 +344,8 @@ def sync_example( sleep_interval: int, debug_locks: bool, poll_interval: float, + keepalive_interval: int, + relogin_interval: int, ) -> None: """Run example of sync pyadtpulse calls. @@ -355,9 +357,19 @@ def sync_example( sleep_interval (int): how long in seconds to sleep between update checks debug_locks: bool: True to enable thread lock debugging poll_interval (float): polling interval in seconds + keepalive_interval (int): keepalive interval in minutes + relogin_interval (int): relogin interval in minutes """ try: - adt = PyADTPulse(username, password, fingerprint, debug_locks=debug_locks) + adt = PyADTPulse( + username, + password, + fingerprint, + debug_locks=debug_locks, + poll_interval=poll_interval, + keepalive_interval=keepalive_interval, + relogin_interval=relogin_interval, + ) except AuthenticationException: print("Invalid credentials for ADT Pulse site") sys.exit() @@ -486,6 +498,8 @@ async def async_example( run_alarm_test: bool, debug_locks: bool, poll_interval: float, + keepalive_interval: int, + relogin_interval: int, ) -> None: """Run example of pytadtpulse async usage. @@ -496,6 +510,8 @@ async def async_example( run_alarm_test (bool): True if alarm tests should be run debug_locks (bool): True to enable thread lock debugging poll_interval (float): polling interval in seconds + keepalive_interval (int): keepalive interval in minutes + relogin_interval (int): relogin interval in minutes """ adt = PyADTPulse( username, @@ -504,6 +520,8 @@ async def async_example( do_login=False, debug_locks=debug_locks, poll_interval=poll_interval, + keepalive_interval=keepalive_interval, + relogin_interval=relogin_interval, ) if not await adt.async_login(): @@ -580,6 +598,8 @@ def main(): args.sleep_interval, args.debug_locks, args.poll_interval, + args.keepalive_interval, + args.relogin_interval, ) else: asyncio.run( @@ -590,6 +610,8 @@ def main(): args.test_alarm, args.debug_locks, args.poll_interval, + args.keepalive_interval, + args.relogin_interval, ) ) From fa1a78ec78e6104b2b5f2b807fa52f5a999c9a88 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 11 Sep 2023 02:37:23 -0400 Subject: [PATCH 73/84] fix dictionary change in size while parsing json in example-client --- example-client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/example-client.py b/example-client.py index 8162afa..4ae4ff6 100755 --- a/example-client.py +++ b/example-client.py @@ -202,6 +202,7 @@ def load_parameters_from_json(json_file: str) -> Optional[Dict]: try: with open(json_file) as file: parameters = json.load(file) + invalid_keys = [] for key, value in parameters.items(): if key in BOOLEAN_PARAMS and not isinstance(value, bool): print( @@ -209,21 +210,23 @@ def load_parameters_from_json(json_file: str) -> Optional[Dict]: f"{key}: {value}" " in JSON file, ignoring..." ) - parameters.pop(key) + invalid_keys.append(key) elif key in INT_PARAMS and not isinstance(value, int): print( "Invalid integer value for " f"{key}: {value}" " in JSON file, ignoring..." ) - parameters.pop(key) + invalid_keys.append(key) elif key in FLOAT_PARAMS and not isinstance(value, float): print( "Invalid float value for " f"{key}: {value}" " in JSON file, ignoring..." ) - parameters.pop(key) + invalid_keys.append(key) + for key in invalid_keys: + del parameters[key] return parameters except FileNotFoundError: print(f"JSON file not found: {json_file}") From ae2d3d41525f429c19c838dcb5ece0264effac08 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 11 Sep 2023 02:40:44 -0400 Subject: [PATCH 74/84] allow ints for floats in json in example-client --- example-client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/example-client.py b/example-client.py index 4ae4ff6..5b16f0c 100755 --- a/example-client.py +++ b/example-client.py @@ -218,7 +218,11 @@ def load_parameters_from_json(json_file: str) -> Optional[Dict]: " in JSON file, ignoring..." ) invalid_keys.append(key) - elif key in FLOAT_PARAMS and not isinstance(value, float): + elif ( + key in FLOAT_PARAMS + and not isinstance(value, float) + and not isinstance(value, int) + ): print( "Invalid float value for " f"{key}: {value}" From 37e5a0eda85564f1fd96dc2e385289deb251c546 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 11 Sep 2023 03:16:14 -0400 Subject: [PATCH 75/84] pass pyadtpulse._poll_interval to gateway --- pyadtpulse/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index b7147d0..9e97404 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -586,6 +586,7 @@ async def async_login(self) -> bool: self._authenticated.clear() return False + self.site.gateway.poll_interval = self._poll_interval # since we received fresh data on the status of the alarm, go ahead # and update the sites with the alarm status. From 0faa43f35b091db22da42f1b897b99278da8fd24 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 12 Sep 2023 19:17:44 -0400 Subject: [PATCH 76/84] change float timestamps to int (we don't need that much precision) --- pyadtpulse/__init__.py | 4 ++-- pyadtpulse/alarm_panel.py | 6 +++--- pyadtpulse/gateway.py | 6 +++--- pyadtpulse/site.py | 11 ++++++----- pyadtpulse/zones.py | 8 ++++---- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 9e97404..a00a5f4 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -133,7 +133,7 @@ def __init__( self._attribute_lock = RLock() else: self._attribute_lock = DebugRLock("PyADTPulse._attribute_lock") - self._last_login_time = 0.0 + self._last_login_time: int = 0 self._site: Optional[ADTPulseSite] = None self._poll_interval = poll_interval @@ -523,7 +523,7 @@ async def _do_login_query(self, timeout: int = 30) -> ClientResponse | None: ): close_response(retval) return None - self._last_login_time = time.time() + self._last_login_time = int(time.time()) return retval async def _do_logout_query(self) -> None: diff --git a/pyadtpulse/alarm_panel.py b/pyadtpulse/alarm_panel.py index 86e2cf7..0ca537b 100644 --- a/pyadtpulse/alarm_panel.py +++ b/pyadtpulse/alarm_panel.py @@ -35,7 +35,7 @@ class ADTPulseAlarmPanel: online: bool = True _is_force_armed: bool = False _state_lock = RLock() - _last_arm_disarm: float = time() + _last_arm_disarm: int = int(time()) @property def status(self) -> str: @@ -185,7 +185,7 @@ async def _arm( self._status = ADT_ALARM_DISARMING else: self._status = ADT_ALARM_ARMING - self._last_arm_disarm = time() + self._last_arm_disarm = int(time()) return True def _sync_set_alarm_mode( @@ -272,7 +272,7 @@ def _update_alarm_from_soup(self, summary_html_soup: BeautifulSoup) -> None: with self._state_lock: if value: text = value.text - last_updated = time() + last_updated = int(time()) if re.match("Disarmed", text): if ( diff --git a/pyadtpulse/gateway.py b/pyadtpulse/gateway.py index b2d6e72..2e875a1 100644 --- a/pyadtpulse/gateway.py +++ b/pyadtpulse/gateway.py @@ -46,8 +46,8 @@ class ADTPulseGateway: _attribute_lock = RLock() model: Optional[str] = None serial_number: Optional[str] = None - next_update: float = 0.0 - last_update: float = 0.0 + next_update: int = 0 + last_update: int = 0 firmware_version: Optional[str] = None hardware_version: Optional[str] = None primary_connection_type: Optional[str] = None @@ -148,7 +148,7 @@ def set_gateway_attributes(self, gateway_attributes: dict[str, str]) -> None: temp = None elif i in DATETIME_UPDATEABLE_FIELDS: try: - temp = parse_pulse_datetime(temp).timestamp() + temp = int(parse_pulse_datetime(temp).timestamp()) except ValueError: temp = None setattr(self, i, temp) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index 2b2d1f2..bf80d7d 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -4,6 +4,7 @@ from asyncio import Task, create_task, gather, get_event_loop, run_coroutine_threadsafe from datetime import datetime from threading import RLock +from time import time from typing import List, Optional, Union from warnings import warn @@ -45,7 +46,7 @@ def __init__(self, pulse_connection: ADTPulseConnection, site_id: str, name: str self._pulse_connection = pulse_connection self._id = site_id self._name = name - self._last_updated = datetime(1970, 1, 1) + self._last_updated: int = 0 self._zones = ADTPulseZones() self._site_lock: Union[RLock, DebugRLock] if isinstance(self._pulse_connection._attribute_lock, DebugRLock): @@ -77,11 +78,11 @@ def name(self) -> str: # return state that shows the site is compromised?? @property - def last_updated(self) -> datetime: + def last_updated(self) -> int: """Return time site last updated. Returns: - datetime: the time site last updated as datetime + int: the time site last updated as datetime """ with self._site_lock: return self._last_updated @@ -327,7 +328,7 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: LOG.debug(f"Skipping {device_name} as it doesn't have an ID") await gather(*task_list) - self._last_updated = datetime.now() + self._last_updated = int(time.time()) return True # FIXME: ensure the zones for the correct site are being loaded!!! @@ -428,7 +429,7 @@ def _update_zone_from_soup(self, soup: BeautifulSoup) -> Optional[ADTPulseZones] f"with timestamp {last_update}" ) self._gateway.is_online = gateway_online - self._last_updated = datetime.now() + self._last_updated = int(time.time()) return self._zones async def _async_update_zones(self) -> Optional[List[ADTPulseFlattendZone]]: diff --git a/pyadtpulse/zones.py b/pyadtpulse/zones.py index fdcfb4c..7fe2967 100644 --- a/pyadtpulse/zones.py +++ b/pyadtpulse/zones.py @@ -41,7 +41,7 @@ class ADTPulseZoneData: tags: Tuple = ADT_NAME_TO_DEFAULT_TAGS["Window"] status: str = "Unknown" state: str = "Unknown" - last_activity_timestamp: float = 0.0 + last_activity_timestamp: int = 0 class ADTPulseFlattendZone(TypedDict): @@ -63,7 +63,7 @@ class ADTPulseFlattendZone(TypedDict): tags: Tuple status: str state: str - last_activity_timestamp: float + last_activity_timestamp: int class ADTPulseZones(UserDict): @@ -142,7 +142,7 @@ def update_last_activity_timestamp(self, key: int, dt: datetime) -> None: dt (datetime): timestamp to set """ temp = self._get_zonedata(key) - temp.last_activity_timestamp = dt.timestamp() + temp.last_activity_timestamp = int(dt.timestamp()) self.__setitem__(key, temp) def update_device_info( @@ -167,7 +167,7 @@ def update_device_info( temp = self._get_zonedata(key) temp.state = state temp.status = status - temp.last_activity_timestamp = last_activity.timestamp() + temp.last_activity_timestamp = int(last_activity.timestamp()) self.__setitem__(key, temp) def flatten(self) -> List[ADTPulseFlattendZone]: From 696d8e546ec5686d9e1c416b59fc526ea603db22 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 12 Sep 2023 19:26:01 -0400 Subject: [PATCH 77/84] oops --- pyadtpulse/site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyadtpulse/site.py b/pyadtpulse/site.py index bf80d7d..f0602cd 100644 --- a/pyadtpulse/site.py +++ b/pyadtpulse/site.py @@ -328,7 +328,7 @@ async def _fetch_devices(self, soup: Optional[BeautifulSoup]) -> bool: LOG.debug(f"Skipping {device_name} as it doesn't have an ID") await gather(*task_list) - self._last_updated = int(time.time()) + self._last_updated = int(time()) return True # FIXME: ensure the zones for the correct site are being loaded!!! @@ -429,7 +429,7 @@ def _update_zone_from_soup(self, soup: BeautifulSoup) -> Optional[ADTPulseZones] f"with timestamp {last_update}" ) self._gateway.is_online = gateway_online - self._last_updated = int(time.time()) + self._last_updated = int(time()) return self._zones async def _async_update_zones(self) -> Optional[List[ADTPulseFlattendZone]]: From 4a63d13e31ee13ca600d9804190719e4063a377e Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 18 Sep 2023 08:10:03 -0400 Subject: [PATCH 78/84] change _query exception to use args instead of __str__ --- pyadtpulse/pulse_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/pulse_connection.py b/pyadtpulse/pulse_connection.py index c384c39..7780806 100644 --- a/pyadtpulse/pulse_connection.py +++ b/pyadtpulse/pulse_connection.py @@ -184,7 +184,7 @@ async def _async_query( ClientConnectorError, ) as ex: LOG.debug( - f"Error {ex.__str__} occurred making {method}" + f"Error {ex.args} occurred making {method}" f" request to {url}, retrying", exc_info=True, ) From 556382c57232fffea38d102c7cc0b6085067f0f7 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 18 Sep 2023 08:22:43 -0400 Subject: [PATCH 79/84] remove unnecessary type: ignore --- pyadtpulse/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index a00a5f4..62a6d24 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -552,7 +552,7 @@ async def async_login(self) -> bool: response = await self._do_login_query() if response is None: return False - if self._pulse_connection.make_url(ADT_SUMMARY_URI) != str(response.url): # type: ignore + if self._pulse_connection.make_url(ADT_SUMMARY_URI) != str(response.url): # more specifically: # redirect to signin.jsp = username/password error # redirect to mfaSignin.jsp = fingerprint error From e99483f7b0a3cbf0f57460262997ac018511b19d Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 18 Sep 2023 08:50:20 -0400 Subject: [PATCH 80/84] move static methods to top of class defintion --- pyadtpulse/__init__.py | 38 +++++++++++++++++++------------------- pyadtpulse/zones.py | 20 ++++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 62a6d24..9466ae7 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -67,6 +67,25 @@ class PyADTPulse: "_keepalive_interval", ) + @staticmethod + def _check_service_host(service_host: str) -> None: + if service_host is None or service_host == "": + raise ValueError("Service host is mandatory") + if service_host not in (DEFAULT_API_HOST, API_HOST_CA): + raise ValueError( + "Service host must be one of {DEFAULT_API_HOST}" f" or {API_HOST_CA}" + ) + + @staticmethod + def _check_keepalive_relogin_intervals( + keepalive_interval: int, relogin_interval: int + ) -> None: + if keepalive_interval > relogin_interval: + raise ValueError( + f"relogin_interval ({relogin_interval}) must be " + f"greater than keepalive_interval ({keepalive_interval})" + ) + def __init__( self, username: str, @@ -170,15 +189,6 @@ def __repr__(self) -> str: # support testing as well as alternative ADT Pulse endpoints such as # portal-ca.adtpulse.com - @staticmethod - def _check_service_host(service_host: str) -> None: - if service_host is None or service_host == "": - raise ValueError("Service host is mandatory") - if service_host not in (DEFAULT_API_HOST, API_HOST_CA): - raise ValueError( - "Service host must be one of {DEFAULT_API_HOST}" f" or {API_HOST_CA}" - ) - @property def service_host(self) -> str: """Get the Pulse host. @@ -222,16 +232,6 @@ def version(self) -> str: with ADTPulseConnection._class_threadlock: return ADTPulseConnection._api_version - @staticmethod - def _check_keepalive_relogin_intervals( - keepalive_interval: int, relogin_interval: int - ) -> None: - if keepalive_interval > relogin_interval: - raise ValueError( - f"relogin_interval ({relogin_interval}) must be " - f"greater than keepalive_interval ({keepalive_interval})" - ) - @property def relogin_interval(self) -> int: """Get re-login interval. diff --git a/pyadtpulse/zones.py b/pyadtpulse/zones.py index 7fe2967..99f996b 100644 --- a/pyadtpulse/zones.py +++ b/pyadtpulse/zones.py @@ -69,6 +69,16 @@ class ADTPulseFlattendZone(TypedDict): class ADTPulseZones(UserDict): """Dictionary containing ADTPulseZoneData with zone as the key.""" + @staticmethod + def _check_value(value: ADTPulseZoneData) -> None: + if not isinstance(value, ADTPulseZoneData): + raise ValueError("ADT Pulse zone data must be of type ADTPulseZoneData") + + @staticmethod + def _check_key(key: int) -> None: + if not isinstance(key, int): + raise ValueError("ADT Pulse Zone must be an integer") + def __getitem__(self, key: int) -> ADTPulseZoneData: """Get a Zone. @@ -80,16 +90,6 @@ def __getitem__(self, key: int) -> ADTPulseZoneData: """ return super().__getitem__(key) - @staticmethod - def _check_value(value: ADTPulseZoneData) -> None: - if not isinstance(value, ADTPulseZoneData): - raise ValueError("ADT Pulse zone data must be of type ADTPulseZoneData") - - @staticmethod - def _check_key(key: int) -> None: - if not isinstance(key, int): - raise ValueError("ADT Pulse Zone must be an integer") - def _get_zonedata(self, key: int) -> ADTPulseZoneData: self._check_key(key) result: ADTPulseZoneData = self.data[key] From 180151fcae0899edf6c3c622990b28134092546b Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 18 Sep 2023 10:06:08 -0400 Subject: [PATCH 81/84] doc updates --- CHANGELOG.md | 12 ++++++++++++ README.md | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d7b58..c9a8899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 1.1 (2023-09-20) + +* bug fixes +* relogin support +* device dataclasses + +## 1.0 (2023-03-28) + +* async support +* background refresh +* bug fixes + ## 0.1.0 (2019-12-16) * added ability to override the ADT API host (example: Canada endpoint portal-ca.adtpulse.com) diff --git a/README.md b/README.md index 866e575..a411f30 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,18 @@ for site in adt.sites: await site.async_arm_away(force=True) ``` +The pyadtpulse object runs background tasks and refreshes its data automatically. + +Certain parameters can be set to control how often certain actions are run. + +Namely: + +```python +adt.poll_interval = 0.75 # check for updates every 0.75 seconds +adt.relogin_interval = 60 # relogin every 60 minutes +adt.keepalive_interval = 10 # run keepalive (prevent logout) every 10 minutes +``` + See [example-client.py](example-client.py) for a working example. ## Browser Fingerprinting From 3a781e2fe34c65ab1c6653d905147ba57e4be537 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 18 Sep 2023 17:12:23 -0400 Subject: [PATCH 82/84] add virtualenv locally with .gitignore --- .gitignore | 1 + .vscode/settings.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f4b91e9..1c513bc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build __pycache__ .mypy_cache *.swp +.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 12901f9..ac7a5e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "python.analysis.typeCheckingMode": "basic" + "python.analysis.typeCheckingMode": "basic", + "python.terminal.activateEnvironment": true } From 3b4e35307379cf7d47c70c6cd42def1fc67f7811 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 28 Sep 2023 00:56:42 -0400 Subject: [PATCH 83/84] change optional poll, relogin, keepalive interval handling on __init__ of pyadtpulse --- pyadtpulse/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 9466ae7..674bbce 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -95,10 +95,10 @@ def __init__( user_agent=ADT_DEFAULT_HTTP_HEADERS["User-Agent"], websession: Optional[ClientSession] = None, do_login: bool = True, - poll_interval: float = ADT_DEFAULT_POLL_INTERVAL, + poll_interval: Optional[float] = ADT_DEFAULT_POLL_INTERVAL, debug_locks: bool = False, - keepalive_interval: int = ADT_DEFAULT_KEEPALIVE_INTERVAL, - relogin_interval: int = ADT_DEFAULT_RELOGIN_INTERVAL, + keepalive_interval: Optional[int] = ADT_DEFAULT_KEEPALIVE_INTERVAL, + relogin_interval: Optional[int] = ADT_DEFAULT_RELOGIN_INTERVAL, ): """Create a PyADTPulse object. @@ -155,7 +155,14 @@ def __init__( self._last_login_time: int = 0 self._site: Optional[ADTPulseSite] = None - self._poll_interval = poll_interval + if poll_interval is None: + self._poll_interval = ADT_DEFAULT_POLL_INTERVAL + else: + self._poll_interval = poll_interval + if keepalive_interval is None: + keepalive_interval = ADT_DEFAULT_KEEPALIVE_INTERVAL + if relogin_interval is None: + relogin_interval = ADT_DEFAULT_RELOGIN_INTERVAL self._check_keepalive_relogin_intervals(keepalive_interval, relogin_interval) self._relogin_interval = relogin_interval self._keepalive_interval = keepalive_interval From 1af01396f7aaba3561c6daaf638b171c9292aa7b Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Thu, 28 Sep 2023 03:05:38 -0400 Subject: [PATCH 84/84] add debug log for setting keepalive, relogin intervals --- pyadtpulse/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyadtpulse/__init__.py b/pyadtpulse/__init__.py index 674bbce..eda68a3 100644 --- a/pyadtpulse/__init__.py +++ b/pyadtpulse/__init__.py @@ -267,6 +267,7 @@ def relogin_interval(self, interval: int) -> None: with self._attribute_lock: self._check_keepalive_relogin_intervals(interval, self._relogin_interval) self._relogin_interval = interval + LOG.debug(f"relogin interval set to {self._relogin_interval}") @property def keepalive_interval(self) -> int: @@ -284,6 +285,7 @@ def keepalive_interval(self, interval: int) -> None: with self._attribute_lock: self._check_keepalive_relogin_intervals(self._keepalive_interval, interval) self._keepalive_interval = interval + LOG.debug(f"keepalive interval set to {self._keepalive_interval}") async def _update_sites(self, soup: BeautifulSoup) -> None: with self._attribute_lock: