From 75664927fc0bd4decc64477ae3e5fa3e3e152aa9 Mon Sep 17 00:00:00 2001 From: Ville Sulko Date: Fri, 29 Jan 2021 08:02:38 +0200 Subject: [PATCH 1/5] Implement re-authentication interval --- pyW215/pyW215.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/pyW215/pyW215.py b/pyW215/pyW215.py index 74c7191..c30b118 100644 --- a/pyW215/pyW215.py +++ b/pyW215/pyW215.py @@ -40,7 +40,7 @@ class SmartPlug(object): """ def __init__(self, ip, password, user="admin", - use_legacy_protocol=False): + use_legacy_protocol=False, auth_interval=10): """ Create a new SmartPlug instance identified by the given URL and password. @@ -49,13 +49,16 @@ def __init__(self, ip, password, user="admin", :param password: Password to authenticate with the plug. Located on the plug. :param user: Username for the plug. Default is admin. :param use_legacy_protocol: Support legacy firmware versions. Default is False. + :param auth_interval: Number of seconds between re-authentications. Default is 10 seconds. """ self.ip = ip self.url = "http://{}/HNAP1/".format(ip) self.user = user self.password = password self.use_legacy_protocol = use_legacy_protocol - self.authenticated = None + # Dict with authentication data {"key": PrivateKey, "cookie": Cookie, "authtime": time of authentication (epoch)} + self.auth = None + self.auth_interval = auth_interval if self.use_legacy_protocol: _LOGGER.info("Enabled support for legacy firmware.") self._error_report = False @@ -126,15 +129,11 @@ def SOAPAction(self, Action, responseElement, params="", recursive=False): :param recursive: True if first attempt failed and now attempting to re-authenticate prior :return: Text enclosed in responseElement brackets """ - # Authenticate client - if self.authenticated is None: - self.authenticated = self.auth() - auth = self.authenticated - # If not legacy protocol, ensure auth() is called for every call - if not self.use_legacy_protocol: - self.authenticated = None - - if auth is None: + # Authenticate client if not authenticated or last authentication is too old + if (self.auth is None or (time.time() - self.auth["authtime"]) > self.auth_interval): + self.auth = self.authenticate() + + if self.auth is None: return None payload = self.requestBody(Action, params) @@ -142,18 +141,18 @@ def SOAPAction(self, Action, responseElement, params="", recursive=False): time_stamp = str(round(time.time() / 1e6)) action_url = '"http://purenetworks.com/HNAP1/{}"'.format(Action) - AUTHKey = hmac.new(auth[0].encode(), (time_stamp + action_url).encode(), digestmod=hashlib.md5).hexdigest().upper() + " " + time_stamp + AUTHKey = hmac.new(self.auth["key"].encode(), (time_stamp + action_url).encode(), digestmod=hashlib.md5).hexdigest().upper() + " " + time_stamp headers = {'Content-Type': '"text/xml; charset=utf-8"', 'SOAPAction': '"http://purenetworks.com/HNAP1/{}"'.format(Action), 'HNAP_AUTH': '{}'.format(AUTHKey), - 'Cookie': 'uid={}'.format(auth[1])} + 'Cookie': 'uid={}'.format(self.auth["cookie"])} try: response = urlopen(Request(self.url, payload.encode(), headers)) except (HTTPError, URLError): - # Try to re-authenticate once - self.authenticated = None + # Force re-authentication + self.auth = None # Recursive call to retry action if not recursive: return_value = self.SOAPAction(Action, responseElement, params, True) @@ -298,7 +297,7 @@ def get_state(self): """Get the device state (i.e. ON or OFF).""" return self.state - def auth(self): + def authenticate(self): """Authenticate using the SOAP interface. Authentication is a two-step process. First a initial payload @@ -371,7 +370,7 @@ def auth(self): return None self._error_report = False # Reset error logging - return (PrivateKey, Cookie) + return {"key": PrivateKey, "cookie": Cookie, "authtime": time.time()} def initial_auth_payload(self): """Return the initial authentication payload.""" From 962022c167a0f7cbed41f6278ff69a53183276e9 Mon Sep 17 00:00:00 2001 From: Ville Sulko Date: Fri, 29 Jan 2021 08:44:04 +0200 Subject: [PATCH 2/5] Switch to iteration to remove reauthentication bug in recursion --- pyW215/pyW215.py | 62 ++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/pyW215/pyW215.py b/pyW215/pyW215.py index c30b118..7b3e33c 100644 --- a/pyW215/pyW215.py +++ b/pyW215/pyW215.py @@ -40,7 +40,7 @@ class SmartPlug(object): """ def __init__(self, ip, password, user="admin", - use_legacy_protocol=False, auth_interval=10): + use_legacy_protocol=False, auth_interval=10, retry_count=1): """ Create a new SmartPlug instance identified by the given URL and password. @@ -50,6 +50,7 @@ def __init__(self, ip, password, user="admin", :param user: Username for the plug. Default is admin. :param use_legacy_protocol: Support legacy firmware versions. Default is False. :param auth_interval: Number of seconds between re-authentications. Default is 10 seconds. + :param retry_count: Number of times to retry failed SOAP call before giving up. Default is 1. """ self.ip = ip self.url = "http://{}/HNAP1/".format(ip) @@ -59,9 +60,9 @@ def __init__(self, ip, password, user="admin", # Dict with authentication data {"key": PrivateKey, "cookie": Cookie, "authtime": time of authentication (epoch)} self.auth = None self.auth_interval = auth_interval + self.retry_count = retry_count if self.use_legacy_protocol: _LOGGER.info("Enabled support for legacy firmware.") - self._error_report = False self.model_name = self.SOAPAction(Action="GetDeviceSettings", responseElement="ModelName", params="") def moduleParameters(self, module): @@ -116,17 +117,15 @@ def requestBody(self, Action, params): '''.format(Action, params, Action) - def SOAPAction(self, Action, responseElement, params="", recursive=False): - """Generate the SOAP action call. + def AuthAndSOAPAction(self, Action, responseElement, params=""): + """Authenticate as needed and send the SOAP action call. :type Action: str :type responseElement: str :type params: str - :type recursive: bool :param Action: The action to perform on the device :param responseElement: The XML element that is returned upon success :param params: Any additional parameters required for performing request (i.e. RadioID, moduleID, ect) - :param recursive: True if first attempt failed and now attempting to re-authenticate prior :return: Text enclosed in responseElement brackets """ # Authenticate client if not authenticated or last authentication is too old @@ -151,17 +150,10 @@ def SOAPAction(self, Action, responseElement, params="", recursive=False): try: response = urlopen(Request(self.url, payload.encode(), headers)) except (HTTPError, URLError): - # Force re-authentication + _LOGGER.warning("Failed to open url to {}".format(self.ip)) + # Invalidate authentication as well self.auth = None - # Recursive call to retry action - if not recursive: - return_value = self.SOAPAction(Action, responseElement, params, True) - if recursive or return_value is None: - _LOGGER.warning("Failed to open url to {}".format(self.ip)) - self._error_report = True - return None - else: - return return_value + return None xmlData = response.read().decode() root = ET.fromstring(xmlData) @@ -173,21 +165,37 @@ def SOAPAction(self, Action, responseElement, params="", recursive=False): _LOGGER.warning("Unable to find %s in response." % responseElement) return None - if value is None and self._error_report is False: + if value is None: _LOGGER.warning("Could not find %s in response." % responseElement) - self._error_report = True return None - self._error_report = False return value + def SOAPAction(self, Action, responseElement, params=""): + """Generate the SOAP action call. Retry on error as configured. + + :type Action: str + :type responseElement: str + :type params: str + :param Action: The action to perform on the device + :param responseElement: The XML element that is returned upon success + :param params: Any additional parameters required for performing request (i.e. RadioID, moduleID, ect) + :return: Text enclosed in responseElement brackets + """ + response = None + tries = 0 + while(response is None and tries <= self.retry_count): + tries += 1 + response = self.AuthAndSOAPAction(Action, responseElement, params) + + return response + def fetchMyCgi(self): """Fetches statistics from my_cgi.cgi""" try: response = urlopen(Request('http://{}/my_cgi.cgi'.format(self.ip), b'request=create_chklst')); except (HTTPError, URLError): _LOGGER.warning("Failed to open url to {}".format(self.ip)) - self._error_report = True return None lines = response.readlines() @@ -322,9 +330,7 @@ def authenticate(self): try: response = urlopen(Request(self.url, payload, headers)) except URLError: - if self._error_report is False: - _LOGGER.warning('Unable to open a connection to dlink switch {}'.format(self.ip)) - self._error_report = True + _LOGGER.warning('Unable to open a connection to dlink switch {}'.format(self.ip)) return None xmlData = response.read().decode() root = ET.fromstring(xmlData) @@ -335,12 +341,8 @@ def authenticate(self): PublickeyResponse = root.find('.//{http://purenetworks.com/HNAP1/}PublicKey') if ( - ChallengeResponse == None or CookieResponse == None or PublickeyResponse == None) and self._error_report is False: + ChallengeResponse == None or CookieResponse == None or PublickeyResponse == None): _LOGGER.warning("Failed to receive initial authentication from smartplug.") - self._error_report = True - return None - - if self._error_report is True: return None Challenge = ChallengeResponse.text @@ -364,12 +366,10 @@ def authenticate(self): # Find responses login_status = root.find('.//{http://purenetworks.com/HNAP1/}LoginResult').text.lower() - if login_status != "success" and self._error_report is False: + if login_status != "success": _LOGGER.error("Failed to authenticate with SmartPlug {}".format(self.ip)) - self._error_report = True return None - self._error_report = False # Reset error logging return {"key": PrivateKey, "cookie": Cookie, "authtime": time.time()} def initial_auth_payload(self): From af58c03c8d8c8907b0c1c155d4fff18726d27fec Mon Sep 17 00:00:00 2001 From: Ville Sulko Date: Fri, 29 Jan 2021 09:08:37 +0200 Subject: [PATCH 3/5] Add some debug logging --- pyW215/pyW215.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pyW215/pyW215.py b/pyW215/pyW215.py index 7b3e33c..3a21193 100644 --- a/pyW215/pyW215.py +++ b/pyW215/pyW215.py @@ -150,7 +150,7 @@ def AuthAndSOAPAction(self, Action, responseElement, params=""): try: response = urlopen(Request(self.url, payload.encode(), headers)) except (HTTPError, URLError): - _LOGGER.warning("Failed to open url to {}".format(self.ip)) + _LOGGER.warning("Failed to open url to %s", self.ip) # Invalidate authentication as well self.auth = None return None @@ -162,11 +162,13 @@ def AuthAndSOAPAction(self, Action, responseElement, params=""): try: value = root.find('.//{http://purenetworks.com/HNAP1/}%s' % (responseElement)).text except AttributeError: - _LOGGER.warning("Unable to find %s in response." % responseElement) + _LOGGER.warning("Unable to find %s in response.", responseElement) + _LOGGER.debug("Response: %s", repr(xmlData)) return None if value is None: - _LOGGER.warning("Could not find %s in response." % responseElement) + _LOGGER.warning("Could not find %s in response.", responseElement) + _LOGGER.debug("Response: %s", repr(xmlData)) return None return value @@ -186,6 +188,7 @@ def SOAPAction(self, Action, responseElement, params=""): tries = 0 while(response is None and tries <= self.retry_count): tries += 1 + _LOGGER.debug("SOAPAction #%s %s", tries, Action) response = self.AuthAndSOAPAction(Action, responseElement, params) return response @@ -195,7 +198,7 @@ def fetchMyCgi(self): try: response = urlopen(Request('http://{}/my_cgi.cgi'.format(self.ip), b'request=create_chklst')); except (HTTPError, URLError): - _LOGGER.warning("Failed to open url to {}".format(self.ip)) + _LOGGER.warning("Failed to open url to %s", self.ip) return None lines = response.readlines() @@ -284,7 +287,7 @@ def state(self): elif response.lower() == 'false': return OFF else: - _LOGGER.warning("Unknown state %s returned" % str(response.lower())) + _LOGGER.warning("Unknown state %s returned", response) return 'unknown' @state.setter @@ -319,6 +322,7 @@ def authenticate(self): See https://github.com/bikerp/dsp-w215-hnap/wiki/Authentication-process for more information. """ + _LOGGER.info("Authenticating to %s as %s", self.url, self.user) payload = self.initial_auth_payload() @@ -330,7 +334,7 @@ def authenticate(self): try: response = urlopen(Request(self.url, payload, headers)) except URLError: - _LOGGER.warning('Unable to open a connection to dlink switch {}'.format(self.ip)) + _LOGGER.warning('Unable to open a connection to dlink switch %s', self.ip) return None xmlData = response.read().decode() root = ET.fromstring(xmlData) @@ -343,6 +347,7 @@ def authenticate(self): if ( ChallengeResponse == None or CookieResponse == None or PublickeyResponse == None): _LOGGER.warning("Failed to receive initial authentication from smartplug.") + _LOGGER.debug("Response: %s", repr(xmlData)) return None Challenge = ChallengeResponse.text @@ -367,7 +372,8 @@ def authenticate(self): login_status = root.find('.//{http://purenetworks.com/HNAP1/}LoginResult').text.lower() if login_status != "success": - _LOGGER.error("Failed to authenticate with SmartPlug {}".format(self.ip)) + _LOGGER.error("Failed to authenticate with SmartPlug %s", self.ip) + _LOGGER.debug("Response: %s", repr(xmlData)) return None return {"key": PrivateKey, "cookie": Cookie, "authtime": time.time()} From 0bb9639fe84895b2d18e0f949f016d872336eb05 Mon Sep 17 00:00:00 2001 From: Ville Sulko Date: Fri, 29 Jan 2021 21:53:54 +0200 Subject: [PATCH 4/5] Tune log level for authentication --- pyW215/pyW215.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyW215/pyW215.py b/pyW215/pyW215.py index 3a21193..7984ce2 100644 --- a/pyW215/pyW215.py +++ b/pyW215/pyW215.py @@ -322,7 +322,7 @@ def authenticate(self): See https://github.com/bikerp/dsp-w215-hnap/wiki/Authentication-process for more information. """ - _LOGGER.info("Authenticating to %s as %s", self.url, self.user) + _LOGGER.debug("Authenticating to %s as %s", self.url, self.user) payload = self.initial_auth_payload() From 8d3823b9709da21139a4d3586d003242e4820005 Mon Sep 17 00:00:00 2001 From: Ville Sulko Date: Sat, 30 Jan 2021 11:18:43 +0200 Subject: [PATCH 5/5] Bump version number to 0.7.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7d7cff8..b7e7aa5 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ long_description = f.read() setup(name='pyW215', - version='0.7.0', + version='0.7.1', description='Interface for d-link W215 Smart Plugs.', long_description=long_description, url='https://github.com/linuxchristian/pyW215',