Alternate device_tracker component for ASUSWRT

The existing ASUSWRT component was not working for me, probably because I’m only using my ASUS router as an access point so DHCP server is not enabled on it and therefore /var/lib/misc/dnsmasq.leases does not exist on the router.
So, I wrote up an alternate component, that goes through the web interface instead. Here it is if anyone is interested in using it:

import logging
import re
import threading
try:
    from urllib2 import urlopen
    PYTHON = 2
except ImportError:
    from urllib.request import urlopen
    PYTHON = 3
import json
from collections import defaultdict
from datetime import timedelta

from homeassistant.components.device_tracker import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import validate_config
from homeassistant.util import Throttle

REQUIREMENTS = ['urllib3']

# Return cached results if last scan was less then this time ago.
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)

_LOGGER = logging.getLogger(__name__)

_FTR = [3600,60,1]


# pylint: disable=unused-argument
def get_scanner(hass, config):
    """Validate the configuration and return an ASUS-WRT scanner."""
    if not validate_config(config,
                           {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
                           _LOGGER):
        return None

    scanner = AsusWrtDeviceScanner(config[DOMAIN])

    return scanner if scanner.success_init else None


class AsusWrtDeviceScanner(object):
    """This class queries a router running ASUSWRT firmware."""

    def __init__(self, config):
        """Initialize the scanner."""
        self.host = config[CONF_HOST]
        self.username = str(config[CONF_USERNAME])
        self.password = str(config[CONF_PASSWORD])

        self.lock = threading.Lock()

        self.last_results = {}

        # Test the router is accessible.
        data = self.get_asuswrt_data()
        self.success_init = data is not None

    def scan_devices(self):
        """Scan for new devices and return a list with found device IDs."""
        self._update_info()
        return [client['mac'] for client in self.last_results]

    def get_device_name(self, device):
        """Return the name of the given device or None if we don't know."""
        if not self.last_results:
            return None
        for client in self.last_results:
            if client['mac'] == device and 'name' in client:
                return client['name']
        return None

    @Throttle(MIN_TIME_BETWEEN_SCANS)
    def _update_info(self):
        """Ensure the information from the ASUSWRT router is up to date.
        Return boolean if scanning successful.
        """
        if not self.success_init:
            return False

        with self.lock:
            _LOGGER.info("Checking ASUSWRT clients")
            data = self.get_asuswrt_data()
            if not data:
                return False

            active_clients = [client for client in data.values() if
                              'wifi' in client and
                              client['wifi'] and
                              'connected' in client and
                              client['connected']]

            #_LOGGER.info(active_clients)

            self.last_results = active_clients
            return True

   def get_asuswrt_data(self):
        """Retrieve data from ASUSWRT and return parsed result."""

        data = urlopen('http://%s/update_clients.asp' % self.host).read()
        if PYTHON == 3:
            data = data.decode('utf-8')
        devices = defaultdict(dict)

        device_info = re.findall(r"fromNetworkmapd: '(.*?)'", data)[0].split('<')
        for d in device_info:
            info = d.split('>')
            if len(info) < 2:
                continue

            devices[info[3]] = {
                'name': info[1],
                'ip': info[2],
                'mac': info[3]
            }

        val = re.findall(r'([A-Za-z_0-9]*): (\[.*\])', data)
        json_lists = {x[0]: x[1] for x in val}

        for d in json.loads(json_lists['wlList_2g']):
            mac = d[0]
            devices[mac]['mac'] = mac
            devices[mac]['wifi'] = True
            devices[mac]['2g'] = True
            devices[mac]['signal'] = d[3]
            devices[mac]['connected'] = d[1]=='Yes'

        for d in json.loads(json_lists['wlList_5g']):
            mac = d[0]
            devices[mac]['mac'] = mac
            devices[mac]['wifi'] = True
            devices[mac]['5g'] = True
            devices[mac]['signal'] = d[3]
            devices[mac]['connected'] = d[1]=='Yes'

        for d in json.loads(json_lists['wlListInfo_2g']):
            mac = d[0]
            devices[mac]['mac'] = mac
            devices[mac]['tx'] = d[1]
            devices[mac]['rx'] = d[2]
            devices[mac]['connection_time'] = sum([a*b for a,b in zip(_FTR, map(int,d[3].split(':')))]) #convert from 12:34:12 to seconds

        for d in json.loads(json_lists['wlListInfo_5g']):
            mac = d[0]
            devices[mac]['mac'] = mac
            devices[mac]['tx'] = d[1]
            devices[mac]['rx'] = d[2]
            devices[mac]['connection_time'] = sum([a*b for a,b in zip(_FTR, map(int,d[3].split(':')))])


        return devices

I just modified the existing asuswrt.py component, so this is probably not very clean and has some extra stuff that’s not needed, such as the username and password config fields (surprisingly you don’t need to be authenticated to fetch that URL). Also not sure if it is robust to different versions of the ASUSWRT firmware.

I’m not sure how it compares to the existing component, but since this is checking on the wifi signal info it gets to know almost instantly when devices have disconnected from the network.

Maybe it could be possible to combine the two methods into one component and define in the configuration which one to use. Anyway, just wanted to put this code out there.

1 Like

This URL works on Merlin firmware 380.58 as well, haven’t tried the rest yet.

Currently using zone based triggers & icloud, but they have a bit of a delay and I’ll probably add this/built in one at some point to speed up detection of ‘home’ state. icloud should keep it from leaving home if it drops from wifi.

Lookin towards this being integrated. After just changing asuswrt.py in /hass/lib/python3.4/site-packages/homeassistant/components/device_tracker/ with contents above couldn’t get home assistant to start

@moskovskiy82 there have been a couple of core changes to HASS, so the original platform might need some updating.

You are welcome to do a PR, else I can have a look at it on my medium term todo list

Would love to do a PR but lack of knowledge at this point makes it impossible

Just to bump a thread. Maybe somebody with good knowledge can take a look at the code

I have a WIP seemingly working update: https://github.com/petermnt/home-assistant/blob/feature/asus_wrt_http/homeassistant/components/device_tracker/asuswrt.py

I combined the old tracker and most of the code from the OP. The http method is enabled by using ‘http’ as protocol in the configuration.

I can’t get my environment set up properly for pylint and the dependency script so I can’t get it to a mergeable state for now.

Seems working for me too. Hopefully will get integrated into the HASS

The latest versions of Asus router software block access to SSH after five successful login attempts in few minutes

May 14 10:24:35 syslog: Detect [192.168.1.20] abnormal logins many times, system will block this IP 5 minutes

also the component using http is a very good idea.

1 Like

It looks like your component via http works very well. Can you merge it to the HA repository?

Unfortunately, on the latest router software (Merlin AsusWrt 380.66) the component does not work and displays every few seconds an error:

2017-05-16 10:24:53 ERROR (MainThread) [homeassistant.core] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/usr/lib/python3.4/asyncio/tasks.py", line 233, in _step
    result = coro.throw(exc)
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/__init__.py", line 698, in async_device_tracker_scan
    found_devices = yield from scanner.async_scan_devices()
  File "/usr/lib/python3.4/asyncio/futures.py", line 388, in __iter__
    yield self  # This tells Task to wait for completion.
  File "/usr/lib/python3.4/asyncio/tasks.py", line 286, in _wakeup
    value = future.result()
  File "/usr/lib/python3.4/asyncio/futures.py", line 277, in result
    raise self._exception
  File "/usr/lib/python3.4/concurrent/futures/thread.py", line 54, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 385, in scan_devices
    self._update_info()
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/util/__init__.py", line 303, in wrapper
    result = method(*args, **kwargs)
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 408, in _update_info
    data = self.get_asuswrt_data()
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 435, in get_asuswrt_data
    r"fromNetworkmapd: '(.*?)'", data)[0].split('<')
IndexError: list index out of range

I can send you a update_clients.asp file if you want.

I have this same problem. Did you find a fix/work around?

It looks like a new feature was implemented in the firmware:

May 18 14:08:49 syslog: Detect [xxxxxxxxx] abnormal logins many times, system will block this IP 5 minutes.

I’m having this same problem. Temporary workaround for me was

interval_seconds: 20

Might be able to go more frequent, but I tried 20 and dropbear was happy. 10 seconds still got me blocked.

1 Like

Dont know how the ssh component is coded, but perhaps it can stay connected to get what it needs, rather than reconnecting every second.

1 Like

Thanks for sharing this. Setting 20 seconds seems to have worked for me too.

That’s a good idea. It is coded, I’ll look into updating the code to keep it connected.

Had to increase the interval to 30. Anyway, I looked into the code of the component, and made the changes required to maintain the connection. PR (https://github.com/home-assistant/home-assistant/pull/7728) submitted, hopefully it’s accepted soon and the issue will be behind us.

3 Likes

Awesome, thanks aronsky!

I have tried the component with your changes and it works very well. Only during restart of the router generates a lot of errors in the HA log:

2017-05-23 19:29:40 ERROR (MainThread) [homeassistant.core] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/usr/lib/python3.4/asyncio/tasks.py", line 233, in _step
    result = coro.throw(exc)
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/__init__.py", line 698, in async_device_tracker_scan
    found_devices = yield from scanner.async_scan_devices()
  File "/usr/lib/python3.4/asyncio/futures.py", line 388, in __iter__
    yield self  # This tells Task to wait for completion.
  File "/usr/lib/python3.4/asyncio/tasks.py", line 286, in _wakeup
    value = future.result()
  File "/usr/lib/python3.4/asyncio/futures.py", line 277, in result
    raise self._exception
  File "/usr/lib/python3.4/concurrent/futures/thread.py", line 54, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 154, in scan_devices
    self._update_info()
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/util/__init__.py", line 303, in wrapper
    result = method(*args, **kwargs)
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 177, in _update_info
    data = self.get_asuswrt_data()
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 191, in get_asuswrt_data
    result = self.connection.get_result()
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 321, in get_result
    self.connect()
  File "/srv/homeassistant/homeassistant_venv/lib/python3.4/site-packages/homeassistant/components/device_tracker/asuswrt.py", line 359, in connect
    password=self._password, port=self._port)
  File "/home/homeassistant/.homeassistant/deps/pexpect/pxssh.py", line 279, in login
    spawn._spawn(self, cmd)
  File "/home/homeassistant/.homeassistant/deps/pexpect/pty_spawn.py", line 273, in _spawn
    assert self.pid is None, 'The pid member must be None.'
AssertionError: The pid member must be None.

@aronsky Yes, the connection after the restart was resumed and the component was still working properly. I just updated the router software so the restart took longer than usual and in the HA log there were several dozens of errors as in my previous post.