mirror of
				https://github.com/python-kasa/python-kasa.git
				synced 2025-10-31 04:31:54 +00:00 
			
		
		
		
	Merge pull request #8 from mweinelt/master
Externalize the TP-Link Smart Home Protocol
This commit is contained in:
		| @@ -1,257 +1,372 @@ | ||||
| # Parts of this code reuse code and concepts by Lubomir Stroetmann from softScheck GmbH | ||||
| # licensed under the Apache License v 2.0. | ||||
| # Copy of the Apache License can be found at http://www.apache.org/licenses/LICENSE-2.0 | ||||
| # The code from Lubomir Stroetmann is located at http://github.com/softScheck/tplink-smartplug | ||||
| """ | ||||
| pyHS100 | ||||
| Python library supporting TP-Link Smart Plugs/Switches (HS100/HS110/Hs200). | ||||
|  | ||||
| The communication protocol was reverse engineered by Lubomir Stroetmann and | ||||
| Tobias Esser in 'Reverse Engineering the TP-Link HS110': | ||||
| https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ | ||||
|  | ||||
| This library reuses codes and concepts of the TP-Link WiFi SmartPlug Client | ||||
| at https://github.com/softScheck/tplink-smartplug, developed by Lubomir | ||||
| Stroetmann which is licensed under the Apache License, Version 2.0. | ||||
|  | ||||
| You may obtain a copy of the license at | ||||
| http://www.apache.org/licenses/LICENSE-2.0 | ||||
| """ | ||||
|  | ||||
| import datetime | ||||
| import json | ||||
| import logging | ||||
| import socket | ||||
| import codecs | ||||
| import json | ||||
| import datetime | ||||
| import sys | ||||
| import re | ||||
|  | ||||
| _LOGGER = logging.getLogger(__name__) | ||||
|  | ||||
| # switch states | ||||
| SWITCH_STATE_ON = 'on' | ||||
| SWITCH_STATE_OFF = 'off' | ||||
| SWITCH_STATE_UNKNOWN = 'unknown' | ||||
|  | ||||
| class SmartPlug(object): | ||||
|     """Class to access TPLink Switch. | ||||
| # possible device features | ||||
| FEATURE_ENERGY_METER = 'ENE' | ||||
| FEATURE_TIMER = 'TIM' | ||||
|  | ||||
| ALL_FEATURES = (FEATURE_ENERGY_METER, FEATURE_TIMER) | ||||
|  | ||||
|  | ||||
| class SmartPlug: | ||||
|     """Representation of a TP-Link Smart Switch. | ||||
|  | ||||
|     Usage example when used as library: | ||||
|     p = SmartPlug("192.168.1.105") | ||||
|     # print the devices alias | ||||
|     print(p.alias) | ||||
|     # change state of plug | ||||
|     p.state = "OFF" | ||||
|     p.state = "ON" | ||||
|     # query and print current state of plug | ||||
|     print(p.state) | ||||
|  | ||||
|     Note: | ||||
|     The library references the same structure as defined for the D-Link Switch | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, ip): | ||||
|         """Create a new SmartPlug instance identified by the IP.""" | ||||
|         self.ip = ip | ||||
|         self.port = 9999 | ||||
|         self._error_report = False | ||||
|         self.model = self._identify_model() | ||||
|     def __init__(self, ip_address): | ||||
|         """ | ||||
|         Create a new SmartPlug instance, identified through its IP address. | ||||
|  | ||||
|         :param ip_address: ip address on which the device listens | ||||
|         """ | ||||
|         socket.inet_pton(socket.AF_INET, ip_address) | ||||
|         self.ip_address = ip_address | ||||
|  | ||||
|         self.alias, self.model, self.features = self.identify() | ||||
|  | ||||
|     @property | ||||
|     def state(self): | ||||
|         """Get the device state (i.e. ON or OFF).""" | ||||
|         response = self.get_info() | ||||
|         relay_state = response["system"]["get_sysinfo"]["relay_state"] | ||||
|         """ | ||||
|         Retrieve the switch state | ||||
|  | ||||
|         if relay_state is None: | ||||
|             return 'unknown' | ||||
|         elif relay_state == 0: | ||||
|             return "OFF" | ||||
|         :returns: one of | ||||
|                   SWITCH_STATE_ON | ||||
|                   SWITCH_STATE_OFF | ||||
|                   SWITCH_STATE_UNKNOWN | ||||
|         """ | ||||
|         response = self.get_sysinfo() | ||||
|         relay_state = response['relay_state'] | ||||
|  | ||||
|         if relay_state == 0: | ||||
|             return SWITCH_STATE_OFF | ||||
|         elif relay_state == 1: | ||||
|             return "ON" | ||||
|             return SWITCH_STATE_ON | ||||
|         else: | ||||
|             _LOGGER.warning("Unknown state %s returned" % str(relay_state)) | ||||
|             return 'unknown' | ||||
|             _LOGGER.warning("Unknown state %s returned.", relay_state) | ||||
|             return SWITCH_STATE_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': | ||||
|         Set the new switch state | ||||
|  | ||||
|         :param value: one of | ||||
|                     SWITCH_STATE_ON | ||||
|                     SWITCH_STATE_OFF | ||||
|         :return: True if new state was successfully set | ||||
|                  False if an error occured | ||||
|         """ | ||||
|         if value.lower() == SWITCH_STATE_ON: | ||||
|             self.turn_on() | ||||
|  | ||||
|         elif value.upper() == 'OFF': | ||||
|         elif value.lower() == SWITCH_STATE_OFF: | ||||
|             self.turn_off() | ||||
|  | ||||
|         else: | ||||
|             raise TypeError("State %s is not valid." % str(value)) | ||||
|  | ||||
|     def get_info(self): | ||||
|         """Interrogate the switch""" | ||||
|         return self._send_command('{"system":{"get_sysinfo":{}}}') | ||||
|             raise ValueError("State %s is not valid.", value) | ||||
|  | ||||
|     def get_sysinfo(self): | ||||
|         """ | ||||
|         Retrieve system information. | ||||
|  | ||||
|         :return: dict sysinfo | ||||
|         """ | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, | ||||
|             request={'system': {'get_sysinfo': {}}} | ||||
|         )['system']['get_sysinfo'] | ||||
|  | ||||
|         if response['err_code'] != 0: | ||||
|             return False | ||||
|  | ||||
|         return response | ||||
|  | ||||
|     def turn_on(self): | ||||
|         """Turns the switch on | ||||
|  | ||||
|           Return values: | ||||
|             True on success | ||||
|             False on failure | ||||
|         """ | ||||
|         response = self._send_command('{"system":{"set_relay_state":{"state":1}}}') | ||||
|         Turn the switch on. | ||||
|  | ||||
|         if response["system"]["set_relay_state"]["err_code"] == 0: | ||||
|             return True | ||||
|         :return: True on success | ||||
|         :raises ProtocolError when device responds with err_code != 0 | ||||
|         """ | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, | ||||
|             request={'system': {'set_relay_state': {'state': 1}}} | ||||
|         )['system']['set_relay_state'] | ||||
|  | ||||
|         return False | ||||
|         if response['err_code'] != 0: | ||||
|             return False | ||||
|  | ||||
|         return True | ||||
|  | ||||
|     def turn_off(self): | ||||
|         """Turns the switch off | ||||
|  | ||||
|           Return values: | ||||
|             True on success | ||||
|             False on failure | ||||
|         """ | ||||
|         response = self._send_command('{"system":{"set_relay_state":{"state":0}}}') | ||||
|         Turn the switch off. | ||||
|  | ||||
|         if response["system"]["set_relay_state"]["err_code"] == 0: | ||||
|             return True | ||||
|         :return: True on success | ||||
|                  False on error | ||||
|         """ | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, | ||||
|             request={'system': {'set_relay_state': {'state': 0}}} | ||||
|         )['system']['set_relay_state'] | ||||
|  | ||||
|         return False | ||||
|         if response['err_code'] != 0: | ||||
|             return False | ||||
|  | ||||
|         return True | ||||
|  | ||||
|     @property | ||||
|     def has_emeter(self): | ||||
|         """ | ||||
|         Checks feature list for energey meter support. | ||||
|  | ||||
|         :return: True if energey meter is available | ||||
|                  False if energymeter is missing | ||||
|         """ | ||||
|         return FEATURE_ENERGY_METER in self.features | ||||
|  | ||||
|     def get_emeter_realtime(self): | ||||
|         """Gets the current energy readings from the switch | ||||
|  | ||||
|           Return values: | ||||
|             False if command is not successful or the switch doesn't support energy metering | ||||
|             Dict with the current readings | ||||
|         """ | ||||
|         if self.model != 110: | ||||
|             return False | ||||
|         Retrive current energy readings from device. | ||||
|  | ||||
|         response = self._send_command('{"emeter":{"get_realtime":{}}}') | ||||
|  | ||||
|         if response["emeter"]["get_realtime"]["err_code"] != 0: | ||||
|             return False | ||||
|  | ||||
|         response["emeter"]["get_realtime"].pop('err_code', None) | ||||
|         return response["emeter"]["get_realtime"] | ||||
|  | ||||
|     def get_emeter_daily(self, year=datetime.datetime.now().year, month=datetime.datetime.now().month): | ||||
|         """Gets daily statistics for a given month. | ||||
|  | ||||
|           Arguments: | ||||
|             year (optional): The year for which to retrieve statistics, defaults to current year | ||||
|             month (optional): The mont for which to retrieve statistics, defaults to current month | ||||
|  | ||||
|           Return values: | ||||
|             False if command is not successful or the switch doesn't support energy metering | ||||
|             Dict where the keys represent the days, and the values are the aggregated statistics | ||||
|         :returns: dict with current readings | ||||
|                   False if device has no energy meter or error occured | ||||
|         """ | ||||
|         if self.model != 110: | ||||
|         if not self.has_emeter: | ||||
|             return False | ||||
|  | ||||
|         response = self._send_command('{"emeter":{"get_daystat":{"month":' + str(month) + ',"year":' + str(year) + '}}}') | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, request={'emeter': {'get_realtime': {}}} | ||||
|         )['emeter']['get_realtime'] | ||||
|  | ||||
|         if response["emeter"]["get_daystat"]["err_code"] != 0: | ||||
|         if response['err_code'] != 0: | ||||
|             return False | ||||
|  | ||||
|         data = dict() | ||||
|         del response['err_code'] | ||||
|  | ||||
|         for i, j in enumerate(response["emeter"]["get_daystat"]["day_list"]): | ||||
|             if j["energy"] > 0: | ||||
|                 data[j["day"]] = j["energy"] | ||||
|         return response | ||||
|  | ||||
|         return data | ||||
|     def get_emeter_daily(self, year=None, month=None): | ||||
|         """ | ||||
|         Retrieve daily statistics for a given month | ||||
|  | ||||
|         :param year: year for which to retrieve statistics (default: this year) | ||||
|         :param month: month for which to retrieve statistcs (default: this | ||||
|                       month) | ||||
|         :return: dict: mapping of day of month to value | ||||
|                  False if device has no energy meter or error occured | ||||
|         """ | ||||
|         if not self.has_emeter: | ||||
|             return False | ||||
|  | ||||
|         if year is None: | ||||
|             year = datetime.datetime.now().year | ||||
|         if month is None: | ||||
|             month = datetime.datetime.now().month | ||||
|  | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, | ||||
|             request={'emeter': {'get_daystat': {'month': str(month), | ||||
|                                                 'year': str(year)}}} | ||||
|         )['emeter']['get_daystat'] | ||||
|  | ||||
|         if response['err_code'] != 0: | ||||
|             return False | ||||
|  | ||||
|         return {entry['day']: entry['energy'] | ||||
|                 for entry in response['day_list']} | ||||
|  | ||||
|     def get_emeter_monthly(self, year=datetime.datetime.now().year): | ||||
|         """Gets monthly statistics for a given year. | ||||
|  | ||||
|         Arguments: | ||||
|           year (optional): The year for which to retrieve statistics, defaults to current year | ||||
|  | ||||
|         Return values: | ||||
|           False if command is not successful or the switch doesn't support energy metering | ||||
|           Dict - the keys represent the months, the values are the aggregated statistics | ||||
|         """ | ||||
|         if self.model != 110: | ||||
|         Retrieve monthly statistics for a given year. | ||||
|  | ||||
|         :param year: year for which to retrieve statistics (default: this year) | ||||
|         :return: dict: mapping of month to value | ||||
|                  False if device has no energy meter or error occured | ||||
|         """ | ||||
|         if not self.has_emeter: | ||||
|             return False | ||||
|  | ||||
|         response = self._send_command('{"emeter":{"get_monthstat":{"year":' + str(year) + '}}}') | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, | ||||
|             request={'emeter': {'get_monthstat': {'year': str(year)}}} | ||||
|         )['emeter']['get_monthstat'] | ||||
|  | ||||
|         if response["emeter"]["get_monthstat"]["err_code"] != 0: | ||||
|         if response['err_code'] != 0: | ||||
|             return False | ||||
|  | ||||
|         data = dict() | ||||
|  | ||||
|         for i, j in enumerate(response["emeter"]["get_monthstat"]["month_list"]): | ||||
|             if j["energy"] > 0: | ||||
|                 data[j["month"]] = j["energy"] | ||||
|  | ||||
|         return data | ||||
|         return {entry['month']: entry['energy'] | ||||
|                 for entry in response['month_list']} | ||||
|  | ||||
|     def erase_emeter_stats(self): | ||||
|         """Erases all statistics. | ||||
|  | ||||
|           Return values: | ||||
|             True: Success | ||||
|             False: Failure or not supported by switch | ||||
|         """ | ||||
|         if self.model != 110: | ||||
|         Erase energy meter statistics | ||||
|  | ||||
|         :return: True if statistics were deleted | ||||
|                  False if device has no energy meter or error occured | ||||
|         """ | ||||
|         if not self.has_emeter: | ||||
|             return False | ||||
|  | ||||
|         response = self._send_command('{"emeter":{"erase_emeter_stat":null}}') | ||||
|         response = TPLinkSmartHomeProtocol.query( | ||||
|             host=self.ip_address, | ||||
|             request={'emeter': {'erase_emeter_stat': None}} | ||||
|         )['emeter']['erase_emeter_stat'] | ||||
|  | ||||
|         if response["emeter"]["erase_emeter_stat"]["err_code"] != 0: | ||||
|             return False | ||||
|         else: | ||||
|             return True | ||||
|         return response['err_code'] == 0 | ||||
|  | ||||
|     def current_consumption(self): | ||||
|         """Get the current power consumption in Watt.""" | ||||
|         if self.model != 110: | ||||
|         """ | ||||
|         Get the current power consumption in Watt. | ||||
|  | ||||
|         :return: the current power consumption in Watt. | ||||
|                  False if device has no energy meter of error occured. | ||||
|         """ | ||||
|         if not self.has_emeter: | ||||
|             return False | ||||
|  | ||||
|         response = self.get_emeter_realtime() | ||||
|  | ||||
|         return response["power"] | ||||
|  | ||||
|     def _encrypt(self, string): | ||||
|         """Encrypts a command.""" | ||||
|         return response['power'] | ||||
|  | ||||
|     def identify(self): | ||||
|         """ | ||||
|         Taken from https://raw.githubusercontent.com/softScheck/tplink-smartplug/master/tplink-smartplug.py | ||||
|         Changes: the return value is encoded in latin-1 in Python 3 and later | ||||
|         Query device information to identify model and featureset | ||||
|  | ||||
|         :return: str model, list of supported features | ||||
|         """ | ||||
|         key = 171 | ||||
|         result = "\0\0\0\0" | ||||
|         for i in string: | ||||
|             a = key ^ ord(i) | ||||
|             key = a | ||||
|             result += chr(a) | ||||
|         sys_info = self.get_sysinfo() | ||||
|  | ||||
|         if sys.version_info.major > 2: | ||||
|             return result.encode('latin-1') | ||||
|         alias = sys_info['alias'] | ||||
|         model = sys_info['model'] | ||||
|         features = sys_info['feature'].split(':') | ||||
|  | ||||
|         return result | ||||
|         for feature in features: | ||||
|             if feature not in ALL_FEATURES: | ||||
|                 _LOGGER.warning("Unknown feature %s on device %s.", | ||||
|                                 feature, model) | ||||
|  | ||||
|     def _decrypt(self, string): | ||||
|         """Decrypts a command.""" | ||||
|         return alias, model, features | ||||
|  | ||||
|  | ||||
| class TPLinkSmartHomeProtocol: | ||||
|     """ | ||||
|     Implementation of the TP-Link Smart Home Protocol | ||||
|  | ||||
|     Encryption/Decryption methods based on the works of | ||||
|     Lubomir Stroetmann and Tobias Esser | ||||
|  | ||||
|     https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ | ||||
|     https://github.com/softScheck/tplink-smartplug/ | ||||
|  | ||||
|     which are licensed under the Apache License, Version 2.0 | ||||
|     http://www.apache.org/licenses/LICENSE-2.0 | ||||
|     """ | ||||
|     initialization_vector = 171 | ||||
|  | ||||
|     @staticmethod | ||||
|     def query(host, request, port=9999): | ||||
|         """ | ||||
|         Taken from https://raw.githubusercontent.com/softScheck/tplink-smartplug/master/tplink-smartplug.py | ||||
|         Changes: the string parameter is decoded from latin-1 in Python 3 and later | ||||
|         Request information from a TP-Link SmartHome Device and return the | ||||
|         response. | ||||
|  | ||||
|         :param host: ip address of the device | ||||
|         :param port: port on the device (default: 9999) | ||||
|         :param request: command to send to the device (can be either dict or | ||||
|         json string) | ||||
|         :return: | ||||
|         """ | ||||
|         if sys.version_info.major > 2: | ||||
|             string = string.decode('latin-1') | ||||
|         if isinstance(request, dict): | ||||
|             request = json.dumps(request) | ||||
|  | ||||
|         key = 171 | ||||
|         result = "" | ||||
|         for i in string: | ||||
|             a = key ^ ord(i) | ||||
|             key = ord(i) | ||||
|             result += chr(a) | ||||
|  | ||||
|         return result | ||||
|  | ||||
|     def _send_command(self, command): | ||||
|         """Sends a command to the switch. | ||||
|  | ||||
|           Accepts one argument - the command as a string | ||||
|  | ||||
|           Return values: | ||||
|             The decrypted JSON | ||||
|         """ | ||||
|         s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
|         s.connect((self.ip, self.port)) | ||||
|         s.send(self._encrypt(command)) | ||||
|         response = self._decrypt(s.recv(4096)[4:]) | ||||
|         s.shutdown(1) | ||||
|         s.close() | ||||
|         sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
|         sock.connect((host, port)) | ||||
|         sock.send(TPLinkSmartHomeProtocol.encrypt(request)) | ||||
|         buffer = sock.recv(4096)[4:] | ||||
|         sock.shutdown(socket.SHUT_RDWR) | ||||
|         sock.close() | ||||
|  | ||||
|         response = TPLinkSmartHomeProtocol.decrypt(buffer) | ||||
|         return json.loads(response) | ||||
|  | ||||
|     def _identify_model(self): | ||||
|         """Query sysinfo and determine model""" | ||||
|         sys_info = self.get_info() | ||||
|         model = re.sub('HS', '', sys_info["system"]["get_sysinfo"]["model"][:5]) | ||||
|         return model | ||||
|     @staticmethod | ||||
|     def encrypt(request): | ||||
|         """ | ||||
|         Encrypt a request for a TP-Link Smart Home Device. | ||||
|  | ||||
|         :param request: plaintext request data | ||||
|         :return: ciphertext request | ||||
|         """ | ||||
|         key = TPLinkSmartHomeProtocol.initialization_vector | ||||
|         buffer = ['\0\0\0\0'] | ||||
|  | ||||
|         for char in request: | ||||
|             cipher = key ^ ord(char) | ||||
|             key = cipher | ||||
|             buffer.append(chr(cipher)) | ||||
|  | ||||
|         ciphertext = ''.join(buffer) | ||||
|         if sys.version_info.major > 2: | ||||
|             ciphertext = ciphertext.encode('latin-1') | ||||
|  | ||||
|         return ciphertext | ||||
|  | ||||
|     @staticmethod | ||||
|     def decrypt(ciphertext): | ||||
|         """ | ||||
|         Decrypt a response of a TP-Link Smart Home Device. | ||||
|  | ||||
|         :param ciphertext: encrypted response data | ||||
|         :return: plaintext response | ||||
|         """ | ||||
|         key = TPLinkSmartHomeProtocol.initialization_vector | ||||
|         buffer = [] | ||||
|  | ||||
|         if sys.version_info.major > 2: | ||||
|             ciphertext = ciphertext.decode('latin-1') | ||||
|  | ||||
|         for char in ciphertext: | ||||
|             plain = key ^ ord(char) | ||||
|             key = ord(char) | ||||
|             buffer.append(chr(plain)) | ||||
|  | ||||
|         plaintext = ''.join(buffer) | ||||
|  | ||||
|         return plaintext | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 GadgetReactor
					GadgetReactor