try:
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
except ImportError:
# Assume Python 2.x
from urllib2 import Request, urlopen
from urllib2 import URLError, HTTPError
import xml.etree.ElementTree as ET
import hashlib
import hmac
import time
import logging
_LOGGER = logging.getLogger(__name__)
ON = 'ON'
OFF = 'OFF'
class SmartPlug(object):
"""
Class to access:
* D-Link Smart Plug Switch W215
* D-Link Smart Plug DSP-W110
Usage example when used as library:
p = SmartPlug("192.168.0.10", ('admin', '1234'))
# change state of plug
p.state = OFF
p.state = ON
# query and print current state of plug
print(p.state)
Note:
The library is greatly inspired by the javascript library by @bikerp (https://github.com/bikerp).
Class layout is inspired by @rkabadi (https://github.com/rkabadi) for the Edimax Smart plug.
"""
def __init__(self, ip, password, user="admin",
use_legacy_protocol=False):
"""
Create a new SmartPlug instance identified by the given URL and password.
:rtype : object
:param host: The IP/hostname of the SmartPlug. E.g. '192.168.0.10'
: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.
"""
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
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):
"""Returns moduleID XML.
:type module: str
:param module: module number/ID
:return XML string with moduleID
"""
return '''{}'''.format(module)
def controlParameters(self, module, status):
"""Returns control parameters as XML.
:type module: str
:type status: str
:param module: The module number/ID
:param status: The state to set (i.e. true (on) or false (off))
:return XML string to join with payload
"""
if self.use_legacy_protocol:
return '''{}Socket 1Socket 1
{}1'''.format(self.moduleParameters(module), status)
else:
return '''{}Socket 1Socket 1
{}'''.format(self.moduleParameters(module), status)
def radioParameters(self, radio):
"""Returns RadioID as XML.
:type radio: str
:param radio: Radio number/ID
"""
return '''{}'''.format(radio)
def requestBody(self, Action, params):
"""Returns the request payload for an action as XML>.
:type Action: str
:type params: str
:param Action: Which action to perform
:param params: Any parameters required for request
:return XML payload for request
"""
return '''
<{} xmlns="http://purenetworks.com/HNAP1/">
{}
{}>
'''.format(Action, params, Action)
def SOAPAction(self, Action, responseElement, params="", recursive=False):
"""Generate 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 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:
return None
payload = self.requestBody(Action, params)
# Timestamp in microseconds
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
headers = {'Content-Type': '"text/xml; charset=utf-8"',
'SOAPAction': '"http://purenetworks.com/HNAP1/{}"'.format(Action),
'HNAP_AUTH': '{}'.format(AUTHKey),
'Cookie': 'uid={}'.format(auth[1])}
try:
response = urlopen(Request(self.url, payload.encode(), headers))
except (HTTPError, URLError):
# Try to re-authenticate once
self.authenticated = 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
xmlData = response.read().decode()
root = ET.fromstring(xmlData)
# Get value from device
try:
value = root.find('.//{http://purenetworks.com/HNAP1/}%s' % (responseElement)).text
except AttributeError:
_LOGGER.warning("Unable to find %s in response." % responseElement)
return None
if value is None and self._error_report is False:
_LOGGER.warning("Could not find %s in response." % responseElement)
self._error_report = True
return None
self._error_report = False
return value
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()
return {line.decode().split(':')[0].strip(): line.decode().split(':')[1].strip() for line in lines}
@property
def current_consumption(self):
"""Get the current power consumption in Watt."""
res = 'N/A'
if self.use_legacy_protocol:
# Use /my_cgi.cgi to retrieve current consumption
try:
res = self.fetchMyCgi()['Meter Watt']
except:
return 'N/A'
else:
try:
res = self.SOAPAction('GetCurrentPowerConsumption', 'CurrentConsumption', self.moduleParameters("2"))
except:
return 'N/A'
if res is None:
return 'N/A'
try:
res = float(res)
except ValueError:
_LOGGER.error("Failed to retrieve current power consumption from SmartPlug")
return res
def get_current_consumption(self):
"""Get the current power consumption in Watt."""
return self.current_consumption
@property
def total_consumption(self):
"""Get the total power consumpuntion in the device lifetime."""
if self.use_legacy_protocol:
# TotalConsumption currently fails on the legacy protocol and
# creates a mess in the logs. Just return 'N/A' for now.
return 'N/A'
res = 'N/A'
try:
res = self.SOAPAction("GetPMWarningThreshold", "TotalConsumption", self.moduleParameters("2"))
except:
return 'N/A'
if res is None:
return 'N/A'
try:
float(res)
except ValueError:
_LOGGER.error("Failed to retrieve total power consumption from SmartPlug")
return res
def get_total_consumption(self):
"""Get the total power consumpuntion in the device lifetime."""
return self.total_consumption
@property
def temperature(self):
"""Get the device temperature in celsius."""
try:
res = self.SOAPAction('GetCurrentTemperature', 'CurrentTemperature', self.moduleParameters("3"))
except:
res = 'N/A'
return res
def get_temperature(self):
"""Get the device temperature in celsius."""
return self.temperature
@property
def state(self):
"""Get the device state (i.e. ON or OFF)."""
response = self.SOAPAction('GetSocketSettings', 'OPStatus', self.moduleParameters("1"))
if response is None:
return 'unknown'
elif response.lower() == 'true':
return ON
elif response.lower() == 'false':
return OFF
else:
_LOGGER.warning("Unknown state %s returned" % str(response.lower()))
return 'unknown'
@state.setter
def state(self, value):
"""Set device state.
:type value: str
:param value: Future state (either ON or OFF)
"""
if value.upper() == ON:
return self.SOAPAction('SetSocketSettings', 'SetSocketSettingsResult', self.controlParameters("1", "true"))
elif value.upper() == OFF:
return self.SOAPAction('SetSocketSettings', 'SetSocketSettingsResult', self.controlParameters("1", "false"))
else:
raise TypeError("State %s is not valid." % str(value))
def get_state(self):
"""Get the device state (i.e. ON or OFF)."""
return self.state
def auth(self):
"""Authenticate using the SOAP interface.
Authentication is a two-step process. First a initial payload
is sent to the device requesting additional login information in the form
of a publickey, a challenge string and a cookie.
These values are then hashed by a MD5 algorithm producing a privatekey
used for the header and a hashed password for the XML payload.
If everything is accepted the XML returned will contain a LoginResult tag with the
string 'success'.
See https://github.com/bikerp/dsp-w215-hnap/wiki/Authentication-process for more information.
"""
payload = self.initial_auth_payload()
# Build initial header
headers = {'Content-Type': '"text/xml; charset=utf-8"',
'SOAPAction': '"http://purenetworks.com/HNAP1/Login"'}
# Request privatekey, cookie and challenge
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
return None
xmlData = response.read().decode()
root = ET.fromstring(xmlData)
# Find responses
ChallengeResponse = root.find('.//{http://purenetworks.com/HNAP1/}Challenge')
CookieResponse = root.find('.//{http://purenetworks.com/HNAP1/}Cookie')
PublickeyResponse = root.find('.//{http://purenetworks.com/HNAP1/}PublicKey')
if (
ChallengeResponse == None or CookieResponse == None or PublickeyResponse == None) and self._error_report is False:
_LOGGER.warning("Failed to receive initial authentication from smartplug.")
self._error_report = True
return None
Challenge = ChallengeResponse.text
Cookie = CookieResponse.text
Publickey = PublickeyResponse.text
# Generate hash responses
PrivateKey = hmac.new((Publickey + self.password).encode(), (Challenge).encode(), digestmod=hashlib.md5).hexdigest().upper()
login_pwd = hmac.new(PrivateKey.encode(), Challenge.encode(), digestmod=hashlib.md5).hexdigest().upper()
response_payload = self.auth_payload(login_pwd)
# Build response to initial request
headers = {'Content-Type': '"text/xml; charset=utf-8"',
'SOAPAction': '"http://purenetworks.com/HNAP1/Login"',
'HNAP_AUTH': '"{}"'.format(PrivateKey),
'Cookie': 'uid={}'.format(Cookie)}
response = urlopen(Request(self.url, response_payload, headers))
xmlData = response.read().decode()
root = ET.fromstring(xmlData)
# Find responses
login_status = root.find('.//{http://purenetworks.com/HNAP1/}LoginResult').text.lower()
if login_status != "success" and self._error_report is False:
_LOGGER.error("Failed to authenticate with SmartPlug {}".format(self.ip))
self._error_report = True
return None
self._error_report = False # Reset error logging
return (PrivateKey, Cookie)
def initial_auth_payload(self):
"""Return the initial authentication payload."""
return b'''
request
admin
'''
def auth_payload(self, login_pwd):
"""Generate a new payload containing generated hash information.
:type login_pwd: str
:param login_pwd: hashed password generated by the auth function.
"""
payload = '''
login
{}
{}
'''.format(self.user, login_pwd)
return payload.encode()