"""Support for TP-Link routers.""" import base64 from datetime import datetime import hashlib import logging import re from aiohttp.hdrs import ( ACCEPT, COOKIE, PRAGMA, REFERER, CONNECTION, KEEP_ALIVE, USER_AGENT, CONTENT_TYPE, CACHE_CONTROL, ACCEPT_ENCODING, ACCEPT_LANGUAGE, ) import requests import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_HEADER_X_REQUESTED_WITH, ) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) HTTP_HEADER_NO_CACHE = "no-cache" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, } ) def get_scanner(hass, config): """ Validate the configuration and return a TP-Link scanner. The default way of integrating devices is to use a pypi package, The TplinkDeviceScanner has been refactored to depend on a pypi package, the other implementations should be gradually migrated in the pypi package """ _LOGGER.warning( "TP-Link device tracker is unmaintained and will be " "removed in the future releases if no maintainer is " "found. If you have interest in this integration, " "feel free to create a pull request to move this code " "to a new 'tplink_router' integration and refactoring " "the device-specific parts to the tplink library" ) for cls in [ TplinkDeviceScanner, Tplink5DeviceScanner, Tplink4DeviceScanner, Tplink3DeviceScanner, Tplink2DeviceScanner, Tplink1DeviceScanner, ]: scanner = cls(config[DOMAIN]) if scanner.success_init: return scanner return None class TplinkDeviceScanner(DeviceScanner): """Queries the router for connected devices.""" def __init__(self, config): """Initialize the scanner.""" from tplink.tplink import TpLinkClient host = config[CONF_HOST] password = config[CONF_PASSWORD] username = config[CONF_USERNAME] self.success_init = False try: self.tplink_client = TpLinkClient(password, host=host, username=username) self.last_results = {} self.success_init = self._update_info() except requests.exceptions.RequestException: _LOGGER.debug("RequestException in %s", self.__class__.__name__) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() return self.last_results.keys() def get_device_name(self, device): """Get the name of the device.""" return self.last_results.get(device) def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ _LOGGER.info("Loading wireless clients...") result = self.tplink_client.get_connected_devices() if result: self.last_results = result return True return False class Tplink1DeviceScanner(DeviceScanner): """This class queries a wireless router running TP-Link firmware.""" def __init__(self, config): """Initialize the scanner.""" host = config[CONF_HOST] username, password = config[CONF_USERNAME], config[CONF_PASSWORD] self.parse_macs = re.compile( "[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-" + "[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}" ) self.host = host self.username = username self.password = password self.last_results = {} self.success_init = False try: self.success_init = self._update_info() except requests.exceptions.RequestException: _LOGGER.debug("RequestException in %s", self.__class__.__name__) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() return self.last_results def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return None def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ _LOGGER.info("Loading wireless clients...") url = f"http://{self.host}/userRpm/WlanStationRpm.htm" referer = f"http://{self.host}" page = requests.get( url, auth=(self.username, self.password), headers={REFERER: referer}, timeout=4, ) result = self.parse_macs.findall(page.text) if result: self.last_results = [mac.replace("-", ":") for mac in result] return True return False class Tplink2DeviceScanner(Tplink1DeviceScanner): """This class queries a router with newer version of TP-Link firmware.""" def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() return self.last_results.keys() def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return self.last_results.get(device) def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ _LOGGER.info("Loading wireless clients...") url = f"http://{self.host}/data/map_access_wireless_client_grid.json" referer = f"http://{self.host}" # Router uses Authorization cookie instead of header # Let's create the cookie username_password = f"{self.username}:{self.password}" b64_encoded_username_password = base64.b64encode( username_password.encode("ascii") ).decode("ascii") cookie = f"Authorization=Basic {b64_encoded_username_password}" response = requests.post( url, headers={REFERER: referer, COOKIE: cookie}, timeout=4 ) try: result = response.json().get("data") except ValueError: _LOGGER.error( "Router didn't respond with JSON. " "Check if credentials are correct." ) return False if result: self.last_results = { device["mac_addr"].replace("-", ":"): device["name"] for device in result } return True return False class Tplink3DeviceScanner(Tplink1DeviceScanner): """This class queries the Archer C9 router with version 150811 or high.""" def __init__(self, config): """Initialize the scanner.""" self.stok = "" self.sysauth = "" super().__init__(config) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() self._log_out() return self.last_results.keys() def get_device_name(self, device): """Get the firmware doesn't save the name of the wireless device. We are forced to use the MAC address as name here. """ return self.last_results.get(device) def _get_auth_tokens(self): """Retrieve auth tokens from the router.""" _LOGGER.info("Retrieving auth tokens...") url = f"http://{self.host}/cgi-bin/luci/;stok=/login?form=login" referer = f"http://{self.host}/webpages/login.html" # If possible implement RSA encryption of password here. response = requests.post( url, params={ "operation": "login", "username": self.username, "password": self.password, }, headers={REFERER: referer}, timeout=4, ) try: self.stok = response.json().get("data").get("stok") _LOGGER.info(self.stok) regex_result = re.search("sysauth=(.*);", response.headers["set-cookie"]) self.sysauth = regex_result.group(1) _LOGGER.info(self.sysauth) return True except (ValueError, KeyError): _LOGGER.error("Couldn't fetch auth tokens! Response was: %s", response.text) return False def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ if (self.stok == "") or (self.sysauth == ""): self._get_auth_tokens() _LOGGER.info("Loading wireless clients...") url = ( "http://{}/cgi-bin/luci/;stok={}/admin/wireless?" "form=statistics" ).format(self.host, self.stok) referer = f"http://{self.host}/webpages/index.html" response = requests.post( url, params={"operation": "load"}, headers={REFERER: referer}, cookies={"sysauth": self.sysauth}, timeout=5, ) try: json_response = response.json() if json_response.get("success"): result = response.json().get("data") else: if json_response.get("errorcode") == "timeout": _LOGGER.info("Token timed out. Relogging on next scan") self.stok = "" self.sysauth = "" return False _LOGGER.error("An unknown error happened while fetching data") return False except ValueError: _LOGGER.error( "Router didn't respond with JSON. " "Check if credentials are correct" ) return False if result: self.last_results = { device["mac"].replace("-", ":"): device["mac"] for device in result } return True return False def _log_out(self): _LOGGER.info("Logging out of router admin interface...") url = ("http://{}/cgi-bin/luci/;stok={}/admin/system?" "form=logout").format( self.host, self.stok ) referer = f"http://{self.host}/webpages/index.html" requests.post( url, params={"operation": "write"}, headers={REFERER: referer}, cookies={"sysauth": self.sysauth}, ) self.stok = "" self.sysauth = "" class Tplink4DeviceScanner(Tplink1DeviceScanner): """This class queries an Archer C7 router with TP-Link firmware 150427.""" def __init__(self, config): """Initialize the scanner.""" self.credentials = "" self.token = "" super().__init__(config) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() return self.last_results def get_device_name(self, device): """Get the name of the wireless device.""" return None def _get_auth_tokens(self): """Retrieve auth tokens from the router.""" _LOGGER.info("Retrieving auth tokens...") url = f"http://{self.host}/userRpm/LoginRpm.htm?Save=Save" # Generate md5 hash of password. The C7 appears to use the first 15 # characters of the password only, so we truncate to remove additional # characters from being hashed. password = hashlib.md5(self.password.encode("utf")[:15]).hexdigest() credentials = f"{self.username}:{password}".encode("utf") # Encode the credentials to be sent as a cookie. self.credentials = base64.b64encode(credentials).decode("utf") # Create the authorization cookie. cookie = f"Authorization=Basic {self.credentials}" response = requests.get(url, headers={COOKIE: cookie}) try: result = re.search( r"window.parent.location.href = " r'"https?:\/\/.*\/(.*)\/userRpm\/Index.htm";', response.text, ) if not result: return False self.token = result.group(1) return True except ValueError: _LOGGER.error("Couldn't fetch auth tokens") return False def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ if (self.credentials == "") or (self.token == ""): self._get_auth_tokens() _LOGGER.info("Loading wireless clients...") mac_results = [] # Check both the 2.4GHz and 5GHz client list URLs for clients_url in ("WlanStationRpm.htm", "WlanStationRpm_5g.htm"): url = f"http://{self.host}/{self.token}/userRpm/{clients_url}" referer = f"http://{self.host}" cookie = f"Authorization=Basic {self.credentials}" page = requests.get(url, headers={COOKIE: cookie, REFERER: referer}) mac_results.extend(self.parse_macs.findall(page.text)) if not mac_results: return False self.last_results = [mac.replace("-", ":") for mac in mac_results] return True class Tplink5DeviceScanner(Tplink1DeviceScanner): """This class queries a TP-Link EAP-225 AP with newer TP-Link FW.""" def scan_devices(self): """Scan for new devices and return a list with found MAC IDs.""" self._update_info() return self.last_results.keys() def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return None def _update_info(self): """Ensure the information from the TP-Link AP is up to date. Return boolean if scanning successful. """ _LOGGER.info("Loading wireless clients...") base_url = f"http://{self.host}" header = { USER_AGENT: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" " rv:53.0) Gecko/20100101 Firefox/53.0", ACCEPT: "application/json, text/javascript, */*; q=0.01", ACCEPT_LANGUAGE: "Accept-Language: en-US,en;q=0.5", ACCEPT_ENCODING: "gzip, deflate", CONTENT_TYPE: "application/x-www-form-urlencoded; charset=UTF-8", HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", REFERER: f"http://{self.host}/", CONNECTION: KEEP_ALIVE, PRAGMA: HTTP_HEADER_NO_CACHE, CACHE_CONTROL: HTTP_HEADER_NO_CACHE, } password_md5 = hashlib.md5(self.password.encode("utf")).hexdigest().upper() # Create a session to handle cookie easier session = requests.session() session.get(base_url, headers=header) login_data = {"username": self.username, "password": password_md5} session.post(base_url, login_data, headers=header) # A timestamp is required to be sent as get parameter timestamp = int(datetime.now().timestamp() * 1e3) client_list_url = f"{base_url}/data/monitor.client.client.json" get_params = {"operation": "load", "_": timestamp} response = session.get(client_list_url, headers=header, params=get_params) session.close() try: list_of_devices = response.json() except ValueError: _LOGGER.error( "AP didn't respond with JSON. " "Check if credentials are correct" ) return False if list_of_devices: self.last_results = { device["MAC"].replace("-", ":"): device["DeviceName"] for device in list_of_devices["data"] } return True return False