├── .gitignore ├── LICENSE ├── README.rst ├── pyW215 ├── __init__.py └── pyW215.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Christian Brædstrup 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pyW215 2 | ====== 3 | 4 | pyW215 is a python3 library for interfacing with the d-link W215 Smart 5 | Plug. 6 | 7 | The library is largely inspired by the javascript implementation by 8 | @bikerp `dsp-w215-hnap`_. 9 | 10 | Installing 11 | ========== 12 | Install using the PyPI index 13 | 14 | .. code:: bash 15 | 16 | pip install pyW215 17 | 18 | Usage 19 | ===== 20 | 21 | .. code:: python 22 | 23 | #!python3 24 | from pyW215.pyW215 import SmartPlug, ON, OFF 25 | 26 | sp = SmartPlug('192.168.1.110', '******') 27 | # Where ****** is the "code pin" printed on the setup card 28 | 29 | # Get values if available otherwise return N/A 30 | print(sp.current_consumption) 31 | print(sp.temperature) 32 | print(sp.total_consumption) 33 | 34 | # Turn switch on and off 35 | sp.state = ON 36 | sp.state = OFF 37 | 38 | Note: You need to know the IP and password of your device. The password is written on the side. 39 | 40 | Contributions 41 | ========================= 42 | I personally no longer use my W215 but contributions are always welcome. **If you do submit a PR please ping @LinuxChristian.** If I don't respond within a few days just ping me again. 43 | 44 | Working firmware versions 45 | ========================= 46 | 47 | - v2.02 48 | - v2.03 49 | - v2.22 50 | 51 | Note: If you experience problems with the switch upgrade to the latest supported firmware through the D-Link app. If the problem persists feel free to open an issue about the problem. 52 | 53 | Partial support 54 | --------------- 55 | 56 | - v1.24 and v1.25 (State changing and current consumption working, but no support for reading temperature) 57 | - D-Link W110 smart switch D-Link W110 smart switch (only state viewing and changing is supported) 58 | 59 | If you have it working on other firmware or hardware versions please let me know. 60 | 61 | Need support for W115 or W245? 62 | ------------------------------- 63 | Checkout this library, https://github.com/jonassjoh/dspW245 64 | 65 | .. _dsp-w215-hnap: https://github.com/bikerp/dsp-w215-hnap 66 | 67 | 68 | Need support for W218 69 | ------------------------------- 70 | DSP-W218 uses `a completly different protocol `_ compare to earlier versions. There is no roadmap to add support for W218 but PR's are always welcome. 71 | -------------------------------------------------------------------------------- /pyW215/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinuxChristian/pyW215/af9d5b83567031c48e5ae5bd8da02efe7cf347de/pyW215/__init__.py -------------------------------------------------------------------------------- /pyW215/pyW215.py: -------------------------------------------------------------------------------- 1 | 2 | try: 3 | from urllib.request import Request, urlopen 4 | from urllib.error import URLError, HTTPError 5 | except ImportError: 6 | # Assume Python 2.x 7 | from urllib2 import Request, urlopen 8 | from urllib2 import URLError, HTTPError 9 | import xml.etree.ElementTree as ET 10 | import hashlib 11 | import hmac 12 | import time 13 | import logging 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | ON = 'ON' 18 | OFF = 'OFF' 19 | 20 | 21 | class SmartPlug(object): 22 | """ 23 | Class to access: 24 | * D-Link Smart Plug Switch W215 25 | * D-Link Smart Plug DSP-W110 26 | 27 | Usage example when used as library: 28 | p = SmartPlug("192.168.0.10", ('admin', '1234')) 29 | 30 | # change state of plug 31 | p.state = OFF 32 | p.state = ON 33 | 34 | # query and print current state of plug 35 | print(p.state) 36 | 37 | Note: 38 | The library is greatly inspired by the javascript library by @bikerp (https://github.com/bikerp). 39 | Class layout is inspired by @rkabadi (https://github.com/rkabadi) for the Edimax Smart plug. 40 | """ 41 | 42 | def __init__(self, ip, password, user="admin", 43 | use_legacy_protocol=False): 44 | """ 45 | Create a new SmartPlug instance identified by the given URL and password. 46 | 47 | :rtype : object 48 | :param host: The IP/hostname of the SmartPlug. E.g. '192.168.0.10' 49 | :param password: Password to authenticate with the plug. Located on the plug. 50 | :param user: Username for the plug. Default is admin. 51 | :param use_legacy_protocol: Support legacy firmware versions. Default is False. 52 | """ 53 | self.ip = ip 54 | self.url = "http://{}/HNAP1/".format(ip) 55 | self.user = user 56 | self.password = password 57 | self.use_legacy_protocol = use_legacy_protocol 58 | self.authenticated = None 59 | if self.use_legacy_protocol: 60 | _LOGGER.info("Enabled support for legacy firmware.") 61 | self._error_report = False 62 | self.model_name = self.SOAPAction(Action="GetDeviceSettings", responseElement="ModelName", params="") 63 | 64 | def moduleParameters(self, module): 65 | """Returns moduleID XML. 66 | 67 | :type module: str 68 | :param module: module number/ID 69 | :return XML string with moduleID 70 | """ 71 | return '''{}'''.format(module) 72 | 73 | def controlParameters(self, module, status): 74 | """Returns control parameters as XML. 75 | 76 | :type module: str 77 | :type status: str 78 | :param module: The module number/ID 79 | :param status: The state to set (i.e. true (on) or false (off)) 80 | :return XML string to join with payload 81 | """ 82 | if self.use_legacy_protocol: 83 | return '''{}Socket 1Socket 1 84 | {}1'''.format(self.moduleParameters(module), status) 85 | else: 86 | return '''{}Socket 1Socket 1 87 | {}'''.format(self.moduleParameters(module), status) 88 | 89 | def radioParameters(self, radio): 90 | """Returns RadioID as XML. 91 | 92 | :type radio: str 93 | :param radio: Radio number/ID 94 | """ 95 | return '''{}'''.format(radio) 96 | 97 | def requestBody(self, Action, params): 98 | """Returns the request payload for an action as XML>. 99 | 100 | :type Action: str 101 | :type params: str 102 | :param Action: Which action to perform 103 | :param params: Any parameters required for request 104 | :return XML payload for request 105 | """ 106 | return ''' 107 | 108 | 109 | <{} xmlns="http://purenetworks.com/HNAP1/"> 110 | {} 111 | 112 | 113 | 114 | '''.format(Action, params, Action) 115 | 116 | def SOAPAction(self, Action, responseElement, params="", recursive=False): 117 | """Generate the SOAP action call. 118 | 119 | :type Action: str 120 | :type responseElement: str 121 | :type params: str 122 | :type recursive: bool 123 | :param Action: The action to perform on the device 124 | :param responseElement: The XML element that is returned upon success 125 | :param params: Any additional parameters required for performing request (i.e. RadioID, moduleID, ect) 126 | :param recursive: True if first attempt failed and now attempting to re-authenticate prior 127 | :return: Text enclosed in responseElement brackets 128 | """ 129 | # Authenticate client 130 | if self.authenticated is None: 131 | self.authenticated = self.auth() 132 | auth = self.authenticated 133 | # If not legacy protocol, ensure auth() is called for every call 134 | if not self.use_legacy_protocol: 135 | self.authenticated = None 136 | 137 | if auth is None: 138 | return None 139 | payload = self.requestBody(Action, params) 140 | 141 | # Timestamp in microseconds 142 | time_stamp = str(round(time.time() / 1e6)) 143 | 144 | action_url = '"http://purenetworks.com/HNAP1/{}"'.format(Action) 145 | AUTHKey = hmac.new(auth[0].encode(), (time_stamp + action_url).encode(), digestmod=hashlib.md5).hexdigest().upper() + " " + time_stamp 146 | 147 | headers = {'Content-Type': '"text/xml; charset=utf-8"', 148 | 'SOAPAction': '"http://purenetworks.com/HNAP1/{}"'.format(Action), 149 | 'HNAP_AUTH': '{}'.format(AUTHKey), 150 | 'Cookie': 'uid={}'.format(auth[1])} 151 | 152 | try: 153 | response = urlopen(Request(self.url, payload.encode(), headers)) 154 | except (HTTPError, URLError): 155 | # Try to re-authenticate once 156 | self.authenticated = None 157 | # Recursive call to retry action 158 | if not recursive: 159 | return_value = self.SOAPAction(Action, responseElement, params, True) 160 | if recursive or return_value is None: 161 | _LOGGER.warning("Failed to open url to {}".format(self.ip)) 162 | self._error_report = True 163 | return None 164 | else: 165 | return return_value 166 | 167 | xmlData = response.read().decode() 168 | root = ET.fromstring(xmlData) 169 | 170 | # Get value from device 171 | try: 172 | value = root.find('.//{http://purenetworks.com/HNAP1/}%s' % (responseElement)).text 173 | except AttributeError: 174 | _LOGGER.warning("Unable to find %s in response." % responseElement) 175 | return None 176 | 177 | if value is None and self._error_report is False: 178 | _LOGGER.warning("Could not find %s in response." % responseElement) 179 | self._error_report = True 180 | return None 181 | 182 | self._error_report = False 183 | return value 184 | 185 | def fetchMyCgi(self): 186 | """Fetches statistics from my_cgi.cgi""" 187 | try: 188 | response = urlopen(Request('http://{}/my_cgi.cgi'.format(self.ip), b'request=create_chklst')); 189 | except (HTTPError, URLError): 190 | _LOGGER.warning("Failed to open url to {}".format(self.ip)) 191 | self._error_report = True 192 | return None 193 | 194 | lines = response.readlines() 195 | return {line.decode().split(':')[0].strip(): line.decode().split(':')[1].strip() for line in lines} 196 | 197 | @property 198 | def current_consumption(self): 199 | """Get the current power consumption in Watt.""" 200 | res = 'N/A' 201 | if self.use_legacy_protocol: 202 | # Use /my_cgi.cgi to retrieve current consumption 203 | try: 204 | res = self.fetchMyCgi()['Meter Watt'] 205 | except: 206 | return 'N/A' 207 | else: 208 | try: 209 | res = self.SOAPAction('GetCurrentPowerConsumption', 'CurrentConsumption', self.moduleParameters("2")) 210 | except: 211 | return 'N/A' 212 | 213 | if res is None: 214 | return 'N/A' 215 | 216 | try: 217 | res = float(res) 218 | except ValueError: 219 | _LOGGER.error("Failed to retrieve current power consumption from SmartPlug") 220 | 221 | return res 222 | 223 | def get_current_consumption(self): 224 | """Get the current power consumption in Watt.""" 225 | return self.current_consumption 226 | 227 | @property 228 | def total_consumption(self): 229 | """Get the total power consumpuntion in the device lifetime.""" 230 | if self.use_legacy_protocol: 231 | # TotalConsumption currently fails on the legacy protocol and 232 | # creates a mess in the logs. Just return 'N/A' for now. 233 | return 'N/A' 234 | 235 | res = 'N/A' 236 | try: 237 | res = self.SOAPAction("GetPMWarningThreshold", "TotalConsumption", self.moduleParameters("2")) 238 | except: 239 | return 'N/A' 240 | 241 | if res is None: 242 | return 'N/A' 243 | 244 | try: 245 | float(res) 246 | except ValueError: 247 | _LOGGER.error("Failed to retrieve total power consumption from SmartPlug") 248 | 249 | return res 250 | 251 | def get_total_consumption(self): 252 | """Get the total power consumpuntion in the device lifetime.""" 253 | return self.total_consumption 254 | 255 | @property 256 | def temperature(self): 257 | """Get the device temperature in celsius.""" 258 | try: 259 | res = self.SOAPAction('GetCurrentTemperature', 'CurrentTemperature', self.moduleParameters("3")) 260 | except: 261 | res = 'N/A' 262 | 263 | return res 264 | 265 | def get_temperature(self): 266 | """Get the device temperature in celsius.""" 267 | return self.temperature 268 | 269 | @property 270 | def state(self): 271 | """Get the device state (i.e. ON or OFF).""" 272 | response = self.SOAPAction('GetSocketSettings', 'OPStatus', self.moduleParameters("1")) 273 | if response is None: 274 | return 'unknown' 275 | elif response.lower() == 'true': 276 | return ON 277 | elif response.lower() == 'false': 278 | return OFF 279 | else: 280 | _LOGGER.warning("Unknown state %s returned" % str(response.lower())) 281 | return 'unknown' 282 | 283 | @state.setter 284 | def state(self, value): 285 | """Set device state. 286 | 287 | :type value: str 288 | :param value: Future state (either ON or OFF) 289 | """ 290 | if value.upper() == ON: 291 | return self.SOAPAction('SetSocketSettings', 'SetSocketSettingsResult', self.controlParameters("1", "true")) 292 | elif value.upper() == OFF: 293 | return self.SOAPAction('SetSocketSettings', 'SetSocketSettingsResult', self.controlParameters("1", "false")) 294 | else: 295 | raise TypeError("State %s is not valid." % str(value)) 296 | 297 | def get_state(self): 298 | """Get the device state (i.e. ON or OFF).""" 299 | return self.state 300 | 301 | def auth(self): 302 | """Authenticate using the SOAP interface. 303 | 304 | Authentication is a two-step process. First a initial payload 305 | is sent to the device requesting additional login information in the form 306 | of a publickey, a challenge string and a cookie. 307 | These values are then hashed by a MD5 algorithm producing a privatekey 308 | used for the header and a hashed password for the XML payload. 309 | 310 | If everything is accepted the XML returned will contain a LoginResult tag with the 311 | string 'success'. 312 | 313 | See https://github.com/bikerp/dsp-w215-hnap/wiki/Authentication-process for more information. 314 | """ 315 | 316 | payload = self.initial_auth_payload() 317 | 318 | # Build initial header 319 | headers = {'Content-Type': '"text/xml; charset=utf-8"', 320 | 'SOAPAction': '"http://purenetworks.com/HNAP1/Login"'} 321 | 322 | # Request privatekey, cookie and challenge 323 | try: 324 | response = urlopen(Request(self.url, payload, headers)) 325 | except URLError: 326 | if self._error_report is False: 327 | _LOGGER.warning('Unable to open a connection to dlink switch {}'.format(self.ip)) 328 | self._error_report = True 329 | return None 330 | xmlData = response.read().decode() 331 | root = ET.fromstring(xmlData) 332 | 333 | # Find responses 334 | ChallengeResponse = root.find('.//{http://purenetworks.com/HNAP1/}Challenge') 335 | CookieResponse = root.find('.//{http://purenetworks.com/HNAP1/}Cookie') 336 | PublickeyResponse = root.find('.//{http://purenetworks.com/HNAP1/}PublicKey') 337 | 338 | if ( 339 | ChallengeResponse == None or CookieResponse == None or PublickeyResponse == None) and self._error_report is False: 340 | _LOGGER.warning("Failed to receive initial authentication from smartplug.") 341 | self._error_report = True 342 | return None 343 | 344 | if self._error_report is True: 345 | return None 346 | 347 | Challenge = ChallengeResponse.text 348 | Cookie = CookieResponse.text 349 | Publickey = PublickeyResponse.text 350 | 351 | # Generate hash responses 352 | PrivateKey = hmac.new((Publickey + self.password).encode(), (Challenge).encode(), digestmod=hashlib.md5).hexdigest().upper() 353 | login_pwd = hmac.new(PrivateKey.encode(), Challenge.encode(), digestmod=hashlib.md5).hexdigest().upper() 354 | 355 | response_payload = self.auth_payload(login_pwd) 356 | # Build response to initial request 357 | headers = {'Content-Type': '"text/xml; charset=utf-8"', 358 | 'SOAPAction': '"http://purenetworks.com/HNAP1/Login"', 359 | 'HNAP_AUTH': '"{}"'.format(PrivateKey), 360 | 'Cookie': 'uid={}'.format(Cookie)} 361 | response = urlopen(Request(self.url, response_payload, headers)) 362 | xmlData = response.read().decode() 363 | root = ET.fromstring(xmlData) 364 | 365 | # Find responses 366 | login_status = root.find('.//{http://purenetworks.com/HNAP1/}LoginResult').text.lower() 367 | 368 | if login_status != "success" and self._error_report is False: 369 | _LOGGER.error("Failed to authenticate with SmartPlug {}".format(self.ip)) 370 | self._error_report = True 371 | return None 372 | 373 | self._error_report = False # Reset error logging 374 | return (PrivateKey, Cookie) 375 | 376 | def initial_auth_payload(self): 377 | """Return the initial authentication payload.""" 378 | 379 | return b''' 380 | 381 | 382 | 383 | request 384 | admin 385 | 386 | 387 | 388 | 389 | 390 | ''' 391 | 392 | def auth_payload(self, login_pwd): 393 | """Generate a new payload containing generated hash information. 394 | 395 | :type login_pwd: str 396 | :param login_pwd: hashed password generated by the auth function. 397 | """ 398 | 399 | payload = ''' 400 | 401 | 402 | 403 | login 404 | {} 405 | {} 406 | 407 | 408 | 409 | 410 | '''.format(self.user, login_pwd) 411 | 412 | return payload.encode() 413 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | # Get the long description from the README file 8 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 9 | long_description = f.read() 10 | 11 | setup(name='pyW215', 12 | version='0.7.0', 13 | description='Interface for d-link W215 Smart Plugs.', 14 | long_description=long_description, 15 | url='https://github.com/linuxchristian/pyW215', 16 | author='Christian Juncker Brædstrup', 17 | author_email='christian@junckerbraedstrup.dk', 18 | license='MIT', 19 | keywords='D-Link W215 W110 Smartplug', 20 | packages=['pyW215'], 21 | install_requires=[], 22 | zip_safe=False) 23 | --------------------------------------------------------------------------------