Automating TP-Link Archer AX6000 router (and others) from Python

By | January 31, 2021

Recently, I was confronted with the problem of automating the activation and deactivation of parental controls on my TP-Link Archer AX6000 router. Specifically, I needed a command-line tool that would allow me to disable or enable internet access for a specific parental controls profile.

The router only provides the ability to set separate schedules for “school nights” and “weekends,” i.e., it does not allow every day to be configured separately. Furthermore it doesn’t allow one-time schedules, e.g., “Turn internet off for this profile from midnight tonight to 6am tomorrow and then forget about this schedule.” I needed both of these.

I’ve been automating stuff like this for many years, but this particular problem turned out to be more complicated than usual, so now that I’ve managed to figure it out, I figured I would share my work on the off chance it might be useful to others.

Typically, something like this is automated in one of four ways:

  1. The router provides a full-fledged HTTP API designed for automation like this, and you just use that.
  2. The administration web app is straightforward enough that it’s easy to write a script that impersonates a web browser to do what you need.
  3. The router provides a command-line interface accessible via SSH or telnet, and you can write a script that connects to that interface and executes the appropriate commands.
  4. If all else fails, you can use Selenium in your script to manipulate the administration web app into doing what you want.

Initially, I couldn’t get any of these to work:

  1. I couldn’t find any evidence of a documented HTTP API for this router.
  2. The administration web app’s login page uses extremely complicated JavaScript which makes up for the fact that the app doesn’t use SSL by encrypting the login password in a convoluted way that is difficult to understand and reproduce in a script.
  3. While the router does have an SSH port, it’s supposedly only accessible via the TP-Link Tether app, i.e., TP-Link doesn’t document how to connect to it or use it and I couldn’t find any indication that anyone had reverse-engineered it and documented how to use it.
  4. The same complicated, convoluted JavaScript used to make up for the fact that the web app doesn’t use SSL also plays all sorts of games with the login form, making straightforward techniques fail when Selenium tries to fill in the password and click the Login button.

Frustrating!

However, after hammering on the problem for a few hours and trying several different approaches, I did manage to get Selenium to work. The trick turned out to be that instead of trying to send keys directly to the password field and then click the login button directly, both of which the login page code makes very difficult, use an action chain to move the mouse to the right part of the login window, send a TAB key to the window to get input to focus on the password field, send the password to the window instead of specifically to the password field, and then sent an ENTER key to trigger the login.

Anyway, here’s the working script, which is probably adaptable with minor modifications to other TP-Link router models.

#!/usr/bin/env python3
 
import argparse
import os
from selenium import webdriver
from selenium.common.exceptions import ElementClickInterceptedException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
import sys
import time
 
 
def parse_args():
    parser = argparse.ArgumentParser(description='Turn internet off or on for '
                                     'a TP-Link parental controls profile')
    parser.add_argument('--host', action='store', required=True,
                        help='Host name of router')
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument('--off', action='store_true', help='Turn internet off')
    group.add_argument('--on', action='store_true', help='Turn internet on')
    parser.add_argument('--headless', action='store_true', default=False)
    deflt = os.path.expanduser('~/.tplink-password.txt')
    parser.add_argument('--password-file', action='store', default=deflt,
                        help='File containing router password (default '
                        f'{deflt}')
    parser.add_argument('profile')
    args = parser.parse_args()
    if '"' in args.profile:
        sys.exit('Profile names containing quotation marks are not supported')
    try:
        args.password = open(args.password_file, 'r').read().strip()
    except Exception:
        sys.exit(f'Error reading router password from {args.password_file}')
    return args
 
 
def init_selenium(args):
    options = webdriver.ChromeOptions()
    if args.headless:
        options.add_argument('headless')
    driver = webdriver.Chrome(options=options)
    return driver
 
 
def wait_for_and_click_text(driver, text):
    wait = WebDriverWait(driver, 10)
    elt = wait.until(EC.element_to_be_clickable(
        (By.XPATH, f'//*[text()="{text}"]')))
    while True:
        try:
            elt.click()
            break
        except ElementClickInterceptedException:
            time.sleep(1)
    return elt
 
 
def main():
    args = parse_args()
    driver = init_selenium(args)
    try:
        driver.get(f'http://{args.host}/')
        origin = driver.find_element_by_xpath('//html')
        form = driver.find_element_by_id('login-note-cnt')
        ActionChains(driver).\
            move_to_element(origin).\
            move_by_offset(form.location['x'], form.location['y']).\
            send_keys(Keys.TAB).\
            send_keys(args.password).\
            send_keys(Keys.ENTER).\
            perform()
        wait_for_and_click_text(driver, 'HomeCare')
        wait_for_and_click_text(driver, 'Parental Controls')
        wait_for_and_click_text(driver, args.profile)
        table_row = driver.find_element_by_xpath(
            f'//div[text()="{args.profile}"]/../..')
        button = table_row.find_element_by_class_name('internet-pause-icon')
        paused = button.get_attribute('class').endswith('pause')
        if args.off and paused:
            sys.exit(f'Profile {args.profile} is already paused')
        elif args.on and not paused:
            sys.exit(f'Profile {args.profile} is already on')
        button.click()
    finally:
        driver.quit()
 
 
if __name__ == '__main__':
    main()
Print Friendly, PDF & Email
Share

Leave a Reply

Your email address will not be published.