Advertisement
Guest User

dlink.py

a guest
Oct 25th, 2017
57
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 19.47 KB | None | 0 0
  1. """
  2. Support for D-link W215 smart switch.
  3.  
  4. For more details about this platform, please refer to the documentation at
  5. https://home-assistant.io/components/switch.dlink/
  6. """
  7. import logging
  8. import time
  9. import voluptuous as vol
  10.  
  11. from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
  12. from homeassistant.const import (
  13.     CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
  14. import homeassistant.helpers.config_validation as cv
  15. from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN
  16.  
  17.  
  18. try:
  19.     from urllib.request import Request, urlopen
  20.     from urllib.error import URLError, HTTPError
  21. except ImportError:
  22.     # Assume Python 2.x
  23.     from urllib2 import Request, urlopen
  24.     from urllib2 import URLError, HTTPError
  25. import xml.etree.ElementTree as ET
  26. import hmac
  27. import time
  28. import logging
  29.  
  30.  
  31. ON = 'ON'
  32. OFF = 'OFF'
  33.  
  34. _LOGGER = logging.getLogger(__name__)
  35.  
  36. class SmartPlug(object):
  37.     """
  38.    Class to access:
  39.        * D-Link Smart Plug Switch W215
  40.        * D-Link Smart Plug DSP-W110
  41.  
  42.    Usage example when used as library:
  43.    p = SmartPlug("192.168.0.10", ('admin', '1234'))
  44.  
  45.    # change state of plug
  46.    p.state = OFF
  47.    p.state = ON
  48.  
  49.    # query and print current state of plug
  50.    print(p.state)
  51.  
  52.    Note:
  53.    The library is greatly inspired by the javascript library by @bikerp (https://github.com/bikerp).
  54.    Class layout is inspired by @rkabadi (https://github.com/rkabadi) for the Edimax Smart plug.
  55.    """
  56.  
  57.     def __init__(self, ip, password, user = "admin",
  58.                  use_legacy_protocol = False):
  59.         """
  60.        Create a new SmartPlug instance identified by the given URL and password.
  61.  
  62.        :rtype : object
  63.        :param host: The IP/hostname of the SmartPlug. E.g. '192.168.0.10'
  64.        :param password: Password to authenticate with the plug. Located on the plug.
  65.        :param user: Username for the plug. Default is admin.
  66.        :param use_legacy_protocol: Support legacy firmware versions. Default is False.
  67.        """
  68.         self.ip = ip
  69.         self.url = "http://{}/HNAP1/".format(ip)
  70.         self.user = user
  71.         self.password = password
  72.         self.use_legacy_protocol = use_legacy_protocol
  73.         self.authenticated = None
  74.         if self.use_legacy_protocol:
  75.             _LOGGER.info("Enabled support for legacy firmware.")
  76.         self._error_report = False
  77.         self.model_name = self.SOAPAction(Action="GetDeviceSettings", responseElement="ModelName", params = "")
  78.  
  79.     def moduleParameters(self, module):
  80.         """Returns moduleID XML.
  81.  
  82.        :type module: str
  83.        :param module: module number/ID
  84.        :return XML string with moduleID
  85.        """
  86.         return '''<ModuleID>{}</ModuleID>'''.format(module)
  87.  
  88.     def controlParameters(self, module, status):
  89.         """Returns control parameters as XML.
  90.  
  91.        :type module: str
  92.        :type status: str
  93.        :param module: The module number/ID
  94.        :param status: The state to set (i.e. true (on) or false (off))
  95.        :return XML string to join with payload
  96.        """
  97.         if self.use_legacy_protocol :
  98.             return '''{}<NickName>Socket 1</NickName><Description>Socket 1</Description>
  99.                      <OPStatus>{}</OPStatus><Controller>1</Controller>'''.format(self.moduleParameters(module), status)
  100.         else:
  101.             return '''{}<NickName>Socket 1</NickName><Description>Socket 1</Description>
  102.                      <OPStatus>{}</OPStatus>'''.format(self.moduleParameters(module), status)
  103.  
  104.     def radioParameters(self, radio):
  105.         """Returns RadioID as XML.
  106.  
  107.        :type radio: str
  108.        :param radio: Radio number/ID
  109.        """
  110.         return '''<RadioID>{}</RadioID>'''.format(radio)
  111.  
  112.  
  113.     def requestBody(self, Action, params):
  114.         """Returns the request payload for an action as XML>.
  115.  
  116.        :type Action: str
  117.        :type params: str
  118.        :param Action: Which action to perform
  119.        :param params: Any parameters required for request
  120.        :return XML payload for request
  121.        """
  122.         return '''<?xml version="1.0" encoding="UTF-8"?>
  123.        <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  124.        <soap:Body>
  125.            <{} xmlns="http://purenetworks.com/HNAP1/">
  126.            {}
  127.        </{}>
  128.        </soap:Body>
  129.        </soap:Envelope>
  130.               '''.format(Action, params, Action)
  131.    
  132.  
  133.     def SOAPAction(self, Action, responseElement, params = "", recursive = False):
  134.         """Generate the SOAP action call.
  135.  
  136.        :type Action: str
  137.        :type responseElement: str
  138.        :type params: str
  139.        :type recursive: bool
  140.        :param Action: The action to perform on the device
  141.        :param responseElement: The XML element that is returned upon success
  142.        :param params: Any additional parameters required for performing request (i.e. RadioID, moduleID, ect)
  143.        :param recursive: True if first attempt failed and now attempting to re-authenticate prior
  144.        :return: Text enclosed in responseElement brackets
  145.        """
  146.         # Authenticate client
  147.         if self.authenticated is None:
  148.             self.authenticated = self.auth()
  149.         auth = self.authenticated
  150.         #If not legacy protocol, ensure auth() is called for every call
  151.         if not self.use_legacy_protocol:
  152.             self.authenticated = None
  153.  
  154.         if auth is None:
  155.             return None
  156.         payload = self.requestBody(Action, params)
  157.  
  158.         # Timestamp in microseconds
  159.         time_stamp = str(round(time.time()/1e6))
  160.  
  161.         action_url = '"http://purenetworks.com/HNAP1/{}"'.format(Action)
  162.         AUTHKey = hmac.new(auth[0].encode(), (time_stamp+action_url).encode()).hexdigest().upper() + " " + time_stamp
  163.  
  164.         headers = {'Content-Type' : '"text/xml; charset=utf-8"',
  165.                    'SOAPAction': '"http://purenetworks.com/HNAP1/{}"'.format(Action),
  166.                    'HNAP_AUTH' : '{}'.format(AUTHKey),
  167.                    'Cookie' : 'uid={}'.format(auth[1])}
  168.  
  169.         try:
  170.             response = urlopen(Request(self.url, payload.encode(), headers))
  171.         except (HTTPError, URLError):
  172.             # Try to re-authenticate once
  173.             self.authenticated = None
  174.             # Recursive call to retry action
  175.             if not recursive:
  176.                 return_value = self.SOAPAction(Action, responseElement, params, True)
  177.             if recursive or return_value is None:
  178.                 _LOGGER.warning("Failed to open url to {}".format(self.ip))
  179.                 self._error_report = True
  180.                 return None
  181.             else:
  182.                 return return_value
  183.  
  184.         xmlData = response.read().decode()
  185.         root = ET.fromstring(xmlData)
  186.  
  187.         # Get value from device
  188.         try:
  189.             value = root.find('.//{http://purenetworks.com/HNAP1/}%s' % (responseElement)).text
  190.         except AttributeError:
  191.             _LOGGER.warning("Unable to find %s in response." % responseElement)
  192.             return None
  193.  
  194.         if value is None and self._error_report is False:
  195.             _LOGGER.warning("Could not find %s in response." % responseElement)
  196.             self._error_report = True
  197.             return None
  198.  
  199.         self._error_report = False
  200.         return value
  201.  
  202.     def fetchMyCgi(self):
  203.         """Fetches statistics from my_cgi.cgi"""
  204.         try:
  205.             response = urlopen(Request('http://{}/my_cgi.cgi'.format(self.ip), b'request=create_chklst'));
  206.         except (HTTPError, URLError):
  207.             _LOGGER.warning("Failed to open url to {}".format(self.ip))
  208.             self._error_report = True
  209.             return None
  210.  
  211.         lines = response.readlines()
  212.         return {line.decode().split(':')[0].strip(): line.decode().split(':')[1].strip() for line in lines}
  213.  
  214.     @property
  215.     def current_consumption(self):
  216.         """Get the current power consumption in Watt."""
  217.         res = 'N/A'
  218.         if self.use_legacy_protocol:
  219.             # Use /my_cgi.cgi to retrieve current consumption
  220.             try:
  221.                 res = self.fetchMyCgi()['Meter Watt']
  222.             except:
  223.                 return 'N/A'
  224.         else:
  225.             try:
  226.                 res = self.SOAPAction('GetCurrentPowerConsumption', 'CurrentConsumption', self.moduleParameters("2"))
  227.             except:
  228.                 return 'N/A'
  229.  
  230.         if res is None:
  231.             return 'N/A'
  232.  
  233.         try:
  234.             res = float(res)
  235.         except ValueError:
  236.             _LOGGER.error("Failed to retrieve current power consumption from SmartPlug")
  237.  
  238.         return res
  239.  
  240.     @property
  241.     def total_consumption(self):
  242.         """Get the total power consumpuntion in the device lifetime."""
  243.         if self.use_legacy_protocol:
  244.             # TotalConsumption currently fails on the legacy protocol and
  245.             # creates a mess in the logs. Just return 'N/A' for now.
  246.             return 'N/A'
  247.  
  248.         res = 'N/A'
  249.         try:
  250.             res = self.SOAPAction("GetPMWarningThreshold", "TotalConsumption", self.moduleParameters("2"))
  251.         except:
  252.             return 'N/A'
  253.  
  254.         if res is None:
  255.             return 'N/A'
  256.  
  257.         try:
  258.             float(res)
  259.         except ValueError:
  260.             _LOGGER.error("Failed to retrieve total power consumption from SmartPlug")
  261.  
  262.         return res
  263.  
  264.     @property
  265.     def temperature(self):
  266.         """Get the device temperature in celsius."""
  267.         try:
  268.             res = self.SOAPAction('GetCurrentTemperature', 'CurrentTemperature', self.moduleParameters("3"))
  269.         except:
  270.             res = 'N/A'
  271.  
  272.         return res
  273.  
  274.     @property
  275.     def state(self):
  276.         """Get the device state (i.e. ON or OFF)."""
  277.         response =  self.SOAPAction('GetSocketSettings', 'OPStatus', self.moduleParameters("1"))
  278.         if response is None:
  279.             return 'unknown'
  280.         elif response.lower() == 'true':
  281.             return ON
  282.         elif response.lower() == 'false':
  283.             return OFF
  284.         else:
  285.             _LOGGER.warning("Unknown state %s returned" % str(response.lower()))
  286.             return 'unknown'
  287.  
  288.     @state.setter
  289.     def state(self, value):
  290.         """Set device state.
  291.  
  292.        :type value: str
  293.        :param value: Future state (either ON or OFF)
  294.        """
  295.         if value.upper() == ON:
  296.             return self.SOAPAction('SetSocketSettings', 'SetSocketSettingsResult', self.controlParameters("1", "true"))
  297.         elif value.upper() == OFF:
  298.             return self.SOAPAction('SetSocketSettings', 'SetSocketSettingsResult', self.controlParameters("1", "false"))
  299.         else:
  300.             raise TypeError("State %s is not valid." % str(value))
  301.  
  302.     def auth(self):
  303.         """Authenticate using the SOAP interface.
  304.  
  305.        Authentication is a two-step process. First a initial payload
  306.        is sent to the device requesting additional login information in the form
  307.        of a publickey, a challenge string and a cookie.
  308.        These values are then hashed by a MD5 algorithm producing a privatekey
  309.        used for the header and a hashed password for the XML payload.
  310.  
  311.        If everything is accepted the XML returned will contain a LoginResult tag with the
  312.        string 'success'.
  313.  
  314.        See https://github.com/bikerp/dsp-w215-hnap/wiki/Authentication-process for more information.
  315.        """
  316.  
  317.         payload = self.initial_auth_payload()
  318.  
  319.         # Build initial header
  320.         headers = {'Content-Type' : '"text/xml; charset=utf-8"',
  321.            'SOAPAction': '"http://purenetworks.com/HNAP1/Login"'}
  322.  
  323.         # Request privatekey, cookie and challenge
  324.         try:
  325.             response = urlopen(Request(self.url, payload, headers))
  326.         except URLError:
  327.             if self._error_report is False:
  328.                 _LOGGER.warning('Unable to open a connection to dlink switch {}'.format(self.ip))
  329.                 self._error_report = True
  330.                 raise
  331.             return None
  332.         xmlData = response.read().decode()
  333.         root = ET.fromstring(xmlData)
  334.  
  335.         # Find responses
  336.         Challenge = root.find('.//{http://purenetworks.com/HNAP1/}Challenge').text
  337.         Cookie = root.find('.//{http://purenetworks.com/HNAP1/}Cookie').text
  338.         Publickey = root.find('.//{http://purenetworks.com/HNAP1/}PublicKey').text
  339.  
  340.         if (Challenge == None or Cookie == None or Publickey == None) and self._error_report is False:
  341.             _LOGGER.warning("Failed to receive initial authentication from smartplug.")
  342.             self._error_report = True
  343.             return None
  344.  
  345.         # Generate hash responses
  346.         PrivateKey = hmac.new((Publickey+self.password).encode(), (Challenge).encode()).hexdigest().upper()
  347.         login_pwd = hmac.new(PrivateKey.encode(), Challenge.encode()).hexdigest().upper()
  348.  
  349.         response_payload = self.auth_payload(login_pwd)
  350.         # Build response to initial request
  351.         headers = {'Content-Type' : '"text/xml; charset=utf-8"',
  352.            'SOAPAction': '"http://purenetworks.com/HNAP1/Login"',
  353.            'HNAP_AUTH' : '"{}"'.format(PrivateKey),
  354.            'Cookie' : 'uid={}'.format(Cookie)}
  355.         response = urlopen(Request(self.url, response_payload, headers))
  356.         xmlData = response.read().decode()
  357.         root = ET.fromstring(xmlData)
  358.  
  359.         # Find responses
  360.         login_status = root.find('.//{http://purenetworks.com/HNAP1/}LoginResult').text.lower()
  361.  
  362.         if login_status != "success" and self._error_report is False:
  363.             _LOGGER.error("Failed to authenticate with SmartPlug {}".format(self.ip))
  364.             self._error_report = True
  365.             return None
  366.  
  367.         self._error_report = False # Reset error logging
  368.         return (PrivateKey, Cookie)
  369.  
  370.     def initial_auth_payload(self):
  371.         """Return the initial authentication payload."""
  372.  
  373.         return b'''<?xml version="1.0" encoding="utf-8"?>
  374.        <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  375.        <soap:Body>
  376.        <Login xmlns="http://purenetworks.com/HNAP1/">
  377.        <Action>request</Action>
  378.        <Username>admin</Username>
  379.        <LoginPassword/>
  380.        <Captcha/>
  381.        </Login>
  382.        </soap:Body>
  383.        </soap:Envelope>
  384.        '''
  385.  
  386.     def auth_payload(self, login_pwd):
  387.         """Generate a new payload containing generated hash information.
  388.  
  389.        :type login_pwd: str
  390.        :param login_pwd: hashed password generated by the auth function.
  391.        """
  392.  
  393.         payload = '''<?xml version="1.0" encoding="utf-8"?>
  394.        <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  395.        <soap:Body>
  396.        <Login xmlns="http://purenetworks.com/HNAP1/">
  397.        <Action>login</Action>
  398.        <Username>{}</Username>
  399.        <LoginPassword>{}</LoginPassword>
  400.        <Captcha/>
  401.        </Login>
  402.        </soap:Body>
  403.        </soap:Envelope>
  404.        '''.format(self.user, login_pwd)
  405.  
  406.         return payload.encode()
  407.  
  408.  
  409.  
  410.  
  411.  
  412.  
  413.  
  414. #REQUIREMENTS = ['pyW215==0.6.0']
  415.  
  416. _LOGGER = logging.getLogger(__name__)
  417.  
  418. DEFAULT_NAME = 'D-link Smart Plug W215'
  419. DEFAULT_PASSWORD = ''
  420. DEFAULT_USERNAME = 'admin'
  421. CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol'
  422.  
  423. ATTR_CURRENT_CONSUMPTION = 'power_consumption'
  424. ATTR_TOTAL_CONSUMPTION = 'total_consumption'
  425. ATTR_TEMPERATURE = 'temperature'
  426.  
  427. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
  428.     vol.Required(CONF_HOST): cv.string,
  429.     vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
  430.     vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
  431.     vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean,
  432.     vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
  433. })
  434.  
  435.  
  436. # pylint: disable=unused-argument
  437. def setup_platform(hass, config, add_devices, discovery_info=None):
  438.     """Set up a D-Link Smart Plug."""
  439.     #from pyW215.pyW215 import SmartPlug
  440.  
  441.     host = config.get(CONF_HOST)
  442.     username = config.get(CONF_USERNAME)
  443.     password = config.get(CONF_PASSWORD)
  444.     use_legacy_protocol = config.get(CONF_USE_LEGACY_PROTOCOL)
  445.     name = config.get(CONF_NAME)
  446.  
  447.     data = SmartPlugData(SmartPlug(host,
  448.                                    password,
  449.                                    username,
  450.                                    use_legacy_protocol))
  451.  
  452.     add_devices([SmartPlugSwitch(hass, data, name)], True)
  453.  
  454.  
  455. class SmartPlugSwitch(SwitchDevice):
  456.     """Representation of a D-link Smart Plug switch."""
  457.  
  458.     def __init__(self, hass, data, name):
  459.         """Initialize the switch."""
  460.         self.units = hass.config.units
  461.         self.data = data
  462.         self._name = name
  463.  
  464.     @property
  465.     def name(self):
  466.         """Return the name of the Smart Plug, if any."""
  467.         return self._name
  468.  
  469.     @property
  470.     def device_state_attributes(self):
  471.         """Return the state attributes of the device."""
  472.         try:
  473.             ui_temp = self.units.temperature(int(self.data.temperature),
  474.                                              TEMP_CELSIUS)
  475.             temperature = "%i %s" % \
  476.                           (ui_temp, self.units.temperature_unit)
  477.         except (ValueError, TypeError):
  478.             temperature = STATE_UNKNOWN
  479.  
  480.         try:
  481.             current_consumption = "%.2f W" % \
  482.                                   float(self.data.current_consumption)
  483.         except ValueError:
  484.             current_consumption = STATE_UNKNOWN
  485.  
  486.         try:
  487.             total_consumption = "%.1f kWh" % \
  488.                                 float(self.data.total_consumption)
  489.         except ValueError:
  490.             total_consumption = STATE_UNKNOWN
  491.  
  492.         attrs = {
  493.             ATTR_CURRENT_CONSUMPTION: current_consumption,
  494.             ATTR_TOTAL_CONSUMPTION: total_consumption,
  495.             ATTR_TEMPERATURE: temperature
  496.         }
  497.  
  498.         return attrs
  499.  
  500.     @property
  501.     def current_power_watt(self):
  502.         """Return the current power usage in Watt."""
  503.         try:
  504.             return float(self.data.current_consumption)
  505.         except ValueError:
  506.             return None
  507.  
  508.     @property
  509.     def is_on(self):
  510.         """Return true if switch is on."""
  511.         return self.data.state == 'ON'
  512.  
  513.     def turn_on(self, **kwargs):
  514.         """Turn the switch on."""
  515.         _LOGGER.debug("turning dlink ON")
  516.         self.data.smartplug.state = 'ON'
  517.         time.sleep(2)
  518.         self.schedule_update_ha_state()
  519.  
  520.     def turn_off(self):
  521.         """Turn the switch off."""
  522.         _LOGGER.debug("turning dlink OFF")
  523.         self.data.smartplug.state = 'OFF'
  524.         time.sleep(2)
  525.         self.schedule_update_ha_state()
  526.  
  527.     def update(self):
  528.         """Get the latest data from the smart plug and updates the states."""
  529.         self.data.update()
  530.  
  531.  
  532. class SmartPlugData(object):
  533.     """Get the latest data from smart plug."""
  534.  
  535.     def __init__(self, smartplug):
  536.         """Initialize the data object."""
  537.         self.smartplug = smartplug
  538.         self.state = None
  539.         self.temperature = None
  540.         self.current_consumption = None
  541.         self.total_consumption = None
  542.  
  543.     def update(self):
  544.         """Get the latest data from the smart plug."""
  545.         self.state = self.smartplug.state
  546.         self.temperature = self.smartplug.temperature
  547.         self.current_consumption = self.smartplug.current_consumption if self.state == 'ON' else 0
  548.         self.total_consumption = self.smartplug.total_consumption
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement