├── __init__.py ├── manifest.json ├── README.md └── device_tracker.py /__init__.py: -------------------------------------------------------------------------------- 1 | """TP-Link Router Device Tracker.""" 2 | 3 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "tplink_router", 3 | "name": "TP Link Router Device Tracker", 4 | "documentation": "", 5 | "dependencies": [], 6 | "codeowners": [], 7 | "version": "1.0.0", 8 | "requirements": [] 9 | } 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TPLink Router Device Tracker for Home Assistant 2 | 3 | Recently the tplink device tracker code was [removed](https://github.com/home-assistant/home-assistant/pull/27936) from Home Assistant. 4 | 5 | This repository allows users to create a custom component and continue to use the tplink device tracker. 6 | 7 | Installation: 8 | 1. In the Home Assistant config folder, create a custom_components/tplink_router folder. 9 | 2. Copy \_\_init__.py, device_tracker.py and manifest.json from this repository into the new folder. 10 | 3. Restart Home Assistant 11 | 4. Add the device_tracker / tplink_router configuration into the configuration.yaml file. 12 | 5. Restart Home Assistant 13 | 14 | 15 | Example Config: 16 | 17 | ``` 18 | device_tracker: 19 | - platform: tplink_router 20 | host: ROUTER_IP_ADDRESS 21 | username: ROUTER_USERNAME 22 | password: !secret tplink_router_password 23 | ``` 24 | 25 | To verify Home Assistant is seeing the devices that are connected to the router, navigate to Developer Tools -> States and search for entities that start with device_tracker. The devices should show up with a source_type attribute of router. 26 | 27 | A known_devices.yaml file should be created that can be edited to further customize the appearance of the tracked devices. For more information see https://www.home-assistant.io/integrations/device_tracker/. 28 | 29 | 30 | 31 | For XDR Series, the password encryption algorithm is still unknown, so a encrypted password has to be used: 32 | 1. Go to the login page of your router. (default: 192.168.0.1) 33 | 2. Type in the password you use to login into the password field. 34 | 3. Open the Network monitor of your browser (usually by pressing F12 and then clicking on "Network"). 35 | 4. Clear the screen before pressing "Confirm" 36 | 5. Upon successful login, right click on the first one and select "Copy as cURL" 37 | 6. Paste the content somewhere else and copy the value of the password in the last line without the quotation marks 38 | (example: --data-binary '{"method":"do","login":{"password":"**SoMeEnCrYpTeDtExT**"}}') 39 | -------------------------------------------------------------------------------- /device_tracker.py: -------------------------------------------------------------------------------- 1 | """Support for TP-Link routers.""" 2 | import base64 3 | from datetime import datetime 4 | import hashlib 5 | import logging 6 | import re 7 | 8 | from aiohttp.hdrs import ( 9 | ACCEPT, 10 | COOKIE, 11 | PRAGMA, 12 | REFERER, 13 | CONNECTION, 14 | KEEP_ALIVE, 15 | USER_AGENT, 16 | CONTENT_TYPE, 17 | CACHE_CONTROL, 18 | ACCEPT_ENCODING, 19 | ACCEPT_LANGUAGE, 20 | ) 21 | import requests 22 | import voluptuous as vol 23 | 24 | from homeassistant.components.device_tracker import ( 25 | DOMAIN, 26 | PLATFORM_SCHEMA, 27 | DeviceScanner, 28 | ) 29 | from homeassistant.const import ( 30 | CONF_HOST, 31 | CONF_PASSWORD, 32 | CONF_USERNAME, 33 | HTTP_HEADER_X_REQUESTED_WITH, 34 | ) 35 | import homeassistant.helpers.config_validation as cv 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | HTTP_HEADER_NO_CACHE = "no-cache" 40 | 41 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 42 | { 43 | vol.Required(CONF_HOST): cv.string, 44 | vol.Required(CONF_PASSWORD): cv.string, 45 | vol.Required(CONF_USERNAME): cv.string, 46 | } 47 | ) 48 | 49 | 50 | def get_scanner(hass, config): 51 | """ 52 | Validate the configuration and return a TP-Link scanner. 53 | 54 | The default way of integrating devices is to use a pypi 55 | 56 | package, The TplinkDeviceScanner has been refactored 57 | 58 | to depend on a pypi package, the other implementations 59 | 60 | should be gradually migrated in the pypi package 61 | 62 | """ 63 | _LOGGER.warning( 64 | "TP-Link device tracker is unmaintained and will be " 65 | "removed in the future releases if no maintainer is " 66 | "found. If you have interest in this integration, " 67 | "feel free to create a pull request to move this code " 68 | "to a new 'tplink_router' integration and refactoring " 69 | "the device-specific parts to the tplink library" 70 | ) 71 | for cls in [ 72 | XDRSeriesTplinkDeviceScanner, 73 | TplinkDeviceScanner, 74 | Tplink5DeviceScanner, 75 | Tplink4DeviceScanner, 76 | Tplink3DeviceScanner, 77 | Tplink2DeviceScanner, 78 | Tplink1DeviceScanner, 79 | ]: 80 | scanner = cls(config[DOMAIN]) 81 | if scanner.success_init: 82 | return scanner 83 | 84 | return None 85 | 86 | 87 | class TplinkDeviceScanner(DeviceScanner): 88 | """Queries the router for connected devices.""" 89 | 90 | def __init__(self, config): 91 | """Initialize the scanner.""" 92 | from tplink.tplink import TpLinkClient 93 | 94 | host = config[CONF_HOST] 95 | password = config[CONF_PASSWORD] 96 | username = config[CONF_USERNAME] 97 | 98 | self.success_init = False 99 | try: 100 | self.tplink_client = TpLinkClient(password, host=host, username=username) 101 | 102 | self.last_results = {} 103 | 104 | self.success_init = self._update_info() 105 | except requests.exceptions.RequestException: 106 | _LOGGER.debug("RequestException in %s", __class__.__name__) 107 | 108 | def scan_devices(self): 109 | """Scan for new devices and return a list with found device IDs.""" 110 | self._update_info() 111 | return self.last_results.keys() 112 | 113 | def get_device_name(self, device): 114 | """Get the name of the device.""" 115 | return self.last_results.get(device) 116 | 117 | def _update_info(self): 118 | """Ensure the information from the TP-Link router is up to date. 119 | 120 | Return boolean if scanning successful. 121 | """ 122 | _LOGGER.info("Loading wireless clients...") 123 | result = self.tplink_client.get_connected_devices() 124 | 125 | if result: 126 | self.last_results = result 127 | return True 128 | 129 | return False 130 | 131 | 132 | class Tplink1DeviceScanner(DeviceScanner): 133 | """This class queries a wireless router running TP-Link firmware.""" 134 | 135 | def __init__(self, config): 136 | """Initialize the scanner.""" 137 | host = config[CONF_HOST] 138 | username, password = config[CONF_USERNAME], config[CONF_PASSWORD] 139 | 140 | self.parse_macs = re.compile( 141 | "[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-" 142 | + "[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}" 143 | ) 144 | 145 | self.host = host 146 | self.username = username 147 | self.password = password 148 | 149 | self.last_results = {} 150 | self.success_init = False 151 | try: 152 | self.success_init = self._update_info() 153 | except requests.exceptions.RequestException: 154 | _LOGGER.debug("RequestException in %s", __class__.__name__) 155 | 156 | def scan_devices(self): 157 | """Scan for new devices and return a list with found device IDs.""" 158 | self._update_info() 159 | return self.last_results 160 | 161 | def get_device_name(self, device): 162 | """Get firmware doesn't save the name of the wireless device.""" 163 | return None 164 | 165 | def _update_info(self): 166 | """Ensure the information from the TP-Link router is up to date. 167 | 168 | Return boolean if scanning successful. 169 | """ 170 | _LOGGER.info("Loading wireless clients...") 171 | 172 | url = f"http://{self.host}/userRpm/WlanStationRpm.htm" 173 | referer = f"http://{self.host}" 174 | page = requests.get( 175 | url, 176 | auth=(self.username, self.password), 177 | headers={REFERER: referer}, 178 | timeout=4, 179 | ) 180 | 181 | result = self.parse_macs.findall(page.text) 182 | 183 | if result: 184 | self.last_results = [mac.replace("-", ":") for mac in result] 185 | return True 186 | 187 | return False 188 | 189 | 190 | class Tplink2DeviceScanner(Tplink1DeviceScanner): 191 | """This class queries a router with newer version of TP-Link firmware.""" 192 | 193 | def scan_devices(self): 194 | """Scan for new devices and return a list with found device IDs.""" 195 | self._update_info() 196 | return self.last_results.keys() 197 | 198 | def get_device_name(self, device): 199 | """Get firmware doesn't save the name of the wireless device.""" 200 | return self.last_results.get(device) 201 | 202 | def _update_info(self): 203 | """Ensure the information from the TP-Link router is up to date. 204 | 205 | Return boolean if scanning successful. 206 | """ 207 | _LOGGER.info("Loading wireless clients...") 208 | 209 | url = f"http://{self.host}/data/map_access_wireless_client_grid.json" 210 | referer = f"http://{self.host}" 211 | 212 | # Router uses Authorization cookie instead of header 213 | # Let's create the cookie 214 | username_password = f"{self.username}:{self.password}" 215 | b64_encoded_username_password = base64.b64encode( 216 | username_password.encode("ascii") 217 | ).decode("ascii") 218 | cookie = f"Authorization=Basic {b64_encoded_username_password}" 219 | 220 | response = requests.post( 221 | url, headers={REFERER: referer, "Cookie": cookie}, timeout=4 222 | ) 223 | 224 | try: 225 | result = response.json().get("data") 226 | except ValueError: 227 | _LOGGER.error( 228 | "Router didn't respond with JSON. " "Check if credentials are correct." 229 | ) 230 | return False 231 | 232 | if result: 233 | self.last_results = { 234 | device["mac_addr"].replace("-", ":"): device["name"] 235 | for device in result 236 | } 237 | return True 238 | 239 | return False 240 | 241 | 242 | class Tplink3DeviceScanner(Tplink1DeviceScanner): 243 | """This class queries the Archer C9 router with version 150811 or high.""" 244 | 245 | def __init__(self, config): 246 | """Initialize the scanner.""" 247 | self.stok = "" 248 | self.sysauth = "" 249 | super().__init__(config) 250 | 251 | def scan_devices(self): 252 | """Scan for new devices and return a list with found device IDs.""" 253 | self._update_info() 254 | self._log_out() 255 | return self.last_results.keys() 256 | 257 | def get_device_name(self, device): 258 | """Get the firmware doesn't save the name of the wireless device. 259 | 260 | We are forced to use the MAC address as name here. 261 | """ 262 | return self.last_results.get(device) 263 | 264 | def _get_auth_tokens(self): 265 | """Retrieve auth tokens from the router.""" 266 | _LOGGER.info("Retrieving auth tokens...") 267 | 268 | url = f"http://{self.host}/cgi-bin/luci/;stok=/login?form=login" 269 | referer = f"http://{self.host}/webpages/login.html" 270 | 271 | # If possible implement RSA encryption of password here. 272 | response = requests.post( 273 | url, 274 | params={ 275 | "operation": "login", 276 | "username": self.username, 277 | "password": self.password, 278 | }, 279 | headers={REFERER: referer}, 280 | timeout=4, 281 | ) 282 | 283 | try: 284 | self.stok = response.json().get("data").get("stok") 285 | _LOGGER.info(self.stok) 286 | regex_result = re.search("sysauth=(.*);", response.headers["set-cookie"]) 287 | self.sysauth = regex_result.group(1) 288 | _LOGGER.info(self.sysauth) 289 | return True 290 | except (ValueError, KeyError): 291 | _LOGGER.error("Couldn't fetch auth tokens! Response was: %s", response.text) 292 | return False 293 | 294 | def _update_info(self): 295 | """Ensure the information from the TP-Link router is up to date. 296 | 297 | Return boolean if scanning successful. 298 | """ 299 | if (self.stok == "") or (self.sysauth == ""): 300 | self._get_auth_tokens() 301 | 302 | _LOGGER.info("Loading wireless clients...") 303 | 304 | url = ( 305 | "http://{}/cgi-bin/luci/;stok={}/admin/wireless?" "form=statistics" 306 | ).format(self.host, self.stok) 307 | referer = f"http://{self.host}/webpages/index.html" 308 | 309 | response = requests.post( 310 | url, 311 | params={"operation": "load"}, 312 | headers={REFERER: referer}, 313 | cookies={"sysauth": self.sysauth}, 314 | timeout=5, 315 | ) 316 | 317 | try: 318 | json_response = response.json() 319 | 320 | if json_response.get("success"): 321 | result = response.json().get("data") 322 | else: 323 | if json_response.get("errorcode") == "timeout": 324 | _LOGGER.info("Token timed out. Relogging on next scan") 325 | self.stok = "" 326 | self.sysauth = "" 327 | return False 328 | _LOGGER.error("An unknown error happened while fetching data") 329 | return False 330 | except ValueError: 331 | _LOGGER.error( 332 | "Router didn't respond with JSON. " "Check if credentials are correct" 333 | ) 334 | return False 335 | 336 | if result: 337 | self.last_results = { 338 | device["mac"].replace("-", ":"): device["mac"] for device in result 339 | } 340 | return True 341 | 342 | return False 343 | 344 | def _log_out(self): 345 | _LOGGER.info("Logging out of router admin interface...") 346 | 347 | url = ("http://{}/cgi-bin/luci/;stok={}/admin/system?" "form=logout").format( 348 | self.host, self.stok 349 | ) 350 | referer = f"http://{self.host}/webpages/index.html" 351 | 352 | requests.post( 353 | url, 354 | params={"operation": "write"}, 355 | headers={REFERER: referer}, 356 | cookies={"sysauth": self.sysauth}, 357 | ) 358 | self.stok = "" 359 | self.sysauth = "" 360 | 361 | 362 | class Tplink4DeviceScanner(Tplink1DeviceScanner): 363 | """This class queries an Archer C7 router with TP-Link firmware 150427.""" 364 | 365 | def __init__(self, config): 366 | """Initialize the scanner.""" 367 | self.credentials = "" 368 | self.token = "" 369 | super().__init__(config) 370 | 371 | def scan_devices(self): 372 | """Scan for new devices and return a list with found device IDs.""" 373 | self._update_info() 374 | return self.last_results 375 | 376 | def get_device_name(self, device): 377 | """Get the name of the wireless device.""" 378 | return None 379 | 380 | def _get_auth_tokens(self): 381 | """Retrieve auth tokens from the router.""" 382 | _LOGGER.info("Retrieving auth tokens...") 383 | url = f"http://{self.host}/userRpm/LoginRpm.htm?Save=Save" 384 | 385 | # Generate md5 hash of password. The C7 appears to use the first 15 386 | # characters of the password only, so we truncate to remove additional 387 | # characters from being hashed. 388 | password = hashlib.md5(self.password.encode("utf")[:15]).hexdigest() 389 | credentials = f"{self.username}:{password}".encode("utf") 390 | 391 | # Encode the credentials to be sent as a cookie. 392 | self.credentials = base64.b64encode(credentials).decode("utf") 393 | 394 | # Create the authorization cookie. 395 | cookie = f"Authorization=Basic {self.credentials}" 396 | 397 | response = requests.get(url, headers={"Cookie": cookie}) 398 | 399 | try: 400 | result = re.search( 401 | r"window.parent.location.href = " 402 | r'"https?:\/\/.*\/(.*)\/userRpm\/Index.htm";', 403 | response.text, 404 | ) 405 | if not result: 406 | return False 407 | self.token = result.group(1) 408 | return True 409 | except ValueError: 410 | _LOGGER.error("Couldn't fetch auth tokens") 411 | return False 412 | 413 | def _update_info(self): 414 | """Ensure the information from the TP-Link router is up to date. 415 | 416 | Return boolean if scanning successful. 417 | """ 418 | if (self.credentials == "") or (self.token == ""): 419 | self._get_auth_tokens() 420 | 421 | _LOGGER.info("Loading wireless clients...") 422 | 423 | mac_results = [] 424 | 425 | # Check both the 2.4GHz and 5GHz client list URLs 426 | for clients_url in ("WlanStationRpm.htm", "WlanStationRpm_5g.htm"): 427 | url = f"http://{self.host}/{self.token}/userRpm/{clients_url}" 428 | referer = f"http://{self.host}" 429 | cookie = f"Authorization=Basic {self.credentials}" 430 | 431 | page = requests.get(url, headers={"Cookie": cookie, "Referer": referer}) 432 | mac_results.extend(self.parse_macs.findall(page.text)) 433 | 434 | if not mac_results: 435 | return False 436 | 437 | self.last_results = [mac.replace("-", ":") for mac in mac_results] 438 | return True 439 | 440 | 441 | class Tplink5DeviceScanner(Tplink1DeviceScanner): 442 | """This class queries a TP-Link EAP-225 AP with newer TP-Link FW.""" 443 | 444 | def scan_devices(self): 445 | """Scan for new devices and return a list with found MAC IDs.""" 446 | self._update_info() 447 | return self.last_results.keys() 448 | 449 | def get_device_name(self, device): 450 | """Get firmware doesn't save the name of the wireless device.""" 451 | return None 452 | 453 | def _update_info(self): 454 | """Ensure the information from the TP-Link AP is up to date. 455 | 456 | Return boolean if scanning successful. 457 | """ 458 | _LOGGER.info("Loading wireless clients...") 459 | 460 | base_url = f"http://{self.host}" 461 | 462 | header = { 463 | USER_AGENT: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" 464 | " rv:53.0) Gecko/20100101 Firefox/53.0", 465 | ACCEPT: "application/json, text/javascript, */*; q=0.01", 466 | ACCEPT_LANGUAGE: "Accept-Language: en-US,en;q=0.5", 467 | ACCEPT_ENCODING: "gzip, deflate", 468 | CONTENT_TYPE: "application/x-www-form-urlencoded; charset=UTF-8", 469 | HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", 470 | REFERER: f"http://{self.host}/", 471 | CONNECTION: KEEP_ALIVE, 472 | PRAGMA: HTTP_HEADER_NO_CACHE, 473 | CACHE_CONTROL: HTTP_HEADER_NO_CACHE, 474 | } 475 | 476 | password_md5 = hashlib.md5(self.password.encode("utf")).hexdigest().upper() 477 | 478 | # Create a session to handle cookie easier 479 | session = requests.session() 480 | session.get(base_url, headers=header) 481 | 482 | login_data = {"username": self.username, "password": password_md5} 483 | session.post(base_url, login_data, headers=header) 484 | 485 | # A timestamp is required to be sent as get parameter 486 | timestamp = int(datetime.now().timestamp() * 1e3) 487 | 488 | client_list_url = f"{base_url}/data/monitor.client.client.json" 489 | 490 | get_params = {"operation": "load", "_": timestamp} 491 | 492 | response = session.get(client_list_url, headers=header, params=get_params) 493 | session.close() 494 | try: 495 | list_of_devices = response.json() 496 | except ValueError: 497 | _LOGGER.error( 498 | "AP didn't respond with JSON. " "Check if credentials are correct" 499 | ) 500 | return False 501 | 502 | if list_of_devices: 503 | self.last_results = { 504 | device["MAC"].replace("-", ":"): device["DeviceName"] 505 | for device in list_of_devices["data"] 506 | } 507 | return True 508 | 509 | return False 510 | 511 | 512 | class XDRSeriesTplinkDeviceScanner(TplinkDeviceScanner): 513 | """This class requires a XDR series with routers with 1.0.10 firmware or above""" 514 | 515 | def __init__(self, config): 516 | """Initialize the scanner.""" 517 | self.stok = '' 518 | self.sysauth = '' 519 | super(XDRSeriesTplinkDeviceScanner, self).__init__(config) 520 | 521 | def _get_auth_tokens(self): 522 | """Retrieve auth tokens from the router.""" 523 | _LOGGER.info("Retrieving auth tokens...") 524 | 525 | url = 'http://{}'.format(self.host) 526 | referer = url 527 | data = {"method":"do","login":{"password":"{}".format(self.password)}} 528 | 529 | response = requests.post(url, headers={REFERER: referer}, data='{}'.format(data), timeout=4) 530 | 531 | try: 532 | self.stok = response.json().get('stok') 533 | return True 534 | except (ValueError, KeyError, AttributeError) as _: 535 | _LOGGER.error("Couldn't fetch auth tokens! Response was: %s", 536 | response.text) 537 | return False 538 | 539 | 540 | def _update_info(self): 541 | """Ensure the information from the TP-Link router is up to date. 542 | Return boolean if scanning successful. 543 | """ 544 | _LOGGER.info("[XDRSeries] Loading wireless clients...") 545 | 546 | if (self.stok == ''): 547 | self._get_auth_tokens() 548 | 549 | url = 'http://{}/stok={}/ds'.format(self.host, self.stok) 550 | referer = 'http://{}'.format(self.host) 551 | data = '{"hosts_info":{"table":"online_host"},"method":"get"}' 552 | 553 | response = requests.post(url, headers={REFERER:referer}, data=data, timeout=5) 554 | 555 | try: 556 | json_response = response.json() 557 | 558 | if json_response.get('error_code') == 0: 559 | result = response.json().get('hosts_info').get('online_host') 560 | else: 561 | _LOGGER.error( 562 | "An unknown error happened while fetching data") 563 | return False 564 | except ValueError: 565 | _LOGGER.error("Router didn't respond with JSON. " 566 | "Check if credentials are correct") 567 | return False 568 | 569 | if result: 570 | # restructure result 571 | result_cache = [] 572 | for i in result: 573 | result_cache.append(list(i.values())[0]) 574 | 575 | self.last_results = { 576 | device['mac'].replace('-', ':'): device['mac'] 577 | for device in result_cache 578 | } 579 | return True 580 | 581 | return False 582 | --------------------------------------------------------------------------------