2015-07-30 09:30:31 +00:00
|
|
|
"""
|
2016-03-07 17:12:06 +00:00
|
|
|
Support for ASUSWRT routers.
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2015-11-09 12:12:18 +00:00
|
|
|
For more details about this platform, please refer to the documentation at
|
|
|
|
https://home-assistant.io/components/device_tracker.asuswrt/
|
2015-07-30 09:30:31 +00:00
|
|
|
"""
|
|
|
|
import logging
|
|
|
|
import re
|
2016-06-15 05:17:32 +00:00
|
|
|
import socket
|
2016-05-17 22:55:12 +00:00
|
|
|
import telnetlib
|
2016-02-19 05:27:50 +00:00
|
|
|
import threading
|
2016-06-15 05:17:32 +00:00
|
|
|
from collections import namedtuple
|
2016-02-19 05:27:50 +00:00
|
|
|
from datetime import timedelta
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2016-08-27 20:30:06 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
|
|
|
from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA
|
2016-02-19 05:27:50 +00:00
|
|
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
2015-07-30 09:30:31 +00:00
|
|
|
from homeassistant.util import Throttle
|
2016-08-27 20:30:06 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2016-03-07 17:12:06 +00:00
|
|
|
# Return cached results if last scan was less then this time ago.
|
2015-07-30 09:30:31 +00:00
|
|
|
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
|
|
|
|
2016-08-27 20:30:06 +00:00
|
|
|
CONF_PROTOCOL = 'protocol'
|
|
|
|
CONF_MODE = 'mode'
|
|
|
|
CONF_SSH_KEY = 'ssh_key'
|
|
|
|
CONF_PUB_KEY = 'pub_key'
|
2016-09-03 23:32:43 +00:00
|
|
|
SECRET_GROUP = 'Password or SSH Key'
|
2016-08-27 20:30:06 +00:00
|
|
|
|
|
|
|
PLATFORM_SCHEMA = vol.All(
|
|
|
|
cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY),
|
|
|
|
PLATFORM_SCHEMA.extend({
|
|
|
|
vol.Required(CONF_HOST): cv.string,
|
|
|
|
vol.Required(CONF_USERNAME): cv.string,
|
|
|
|
vol.Optional(CONF_PROTOCOL, default='ssh'):
|
2016-08-28 23:34:01 +00:00
|
|
|
vol.In(['ssh', 'telnet']),
|
2016-08-27 20:30:06 +00:00
|
|
|
vol.Optional(CONF_MODE, default='router'):
|
2016-08-29 06:23:20 +00:00
|
|
|
vol.In(['router', 'ap']),
|
2016-09-03 23:32:43 +00:00
|
|
|
vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string,
|
|
|
|
vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile,
|
|
|
|
vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile
|
2016-08-27 20:30:06 +00:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
2015-07-30 09:30:31 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2016-05-15 19:20:17 +00:00
|
|
|
REQUIREMENTS = ['pexpect==4.0.1']
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2016-05-17 22:55:12 +00:00
|
|
|
_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases'
|
2015-07-30 09:30:31 +00:00
|
|
|
_LEASES_REGEX = re.compile(
|
|
|
|
r'\w+\s' +
|
|
|
|
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
|
|
|
|
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
|
|
|
r'(?P<host>([^\s]+))')
|
|
|
|
|
2016-06-10 04:30:47 +00:00
|
|
|
# command to get both 5GHz and 2.4GHz clients
|
|
|
|
_WL_CMD = '{ wl -i eth2 assoclist & wl -i eth1 assoclist ; }'
|
|
|
|
_WL_REGEX = re.compile(
|
|
|
|
r'\w+\s' +
|
|
|
|
r'(?P<mac>(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))')
|
|
|
|
|
|
|
|
_ARP_CMD = 'arp -n'
|
|
|
|
_ARP_REGEX = re.compile(
|
|
|
|
r'.+\s' +
|
|
|
|
r'\((?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' +
|
|
|
|
r'.+\s' +
|
|
|
|
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' +
|
|
|
|
r'\s' +
|
|
|
|
r'.*')
|
|
|
|
|
2016-05-17 22:55:12 +00:00
|
|
|
_IP_NEIGH_CMD = 'ip neigh'
|
2015-07-30 09:30:31 +00:00
|
|
|
_IP_NEIGH_REGEX = re.compile(
|
|
|
|
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
|
|
|
r'\w+\s' +
|
|
|
|
r'\w+\s' +
|
|
|
|
r'(\w+\s(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' +
|
|
|
|
r'(?P<status>(\w+))')
|
|
|
|
|
2016-11-11 06:46:58 +00:00
|
|
|
_NVRAM_CMD = 'nvram get client_info_tmp'
|
|
|
|
_NVRAM_REGEX = re.compile(
|
|
|
|
r'.*>.*>' +
|
|
|
|
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})' +
|
|
|
|
r'>' +
|
|
|
|
r'(?P<mac>(([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})))' +
|
|
|
|
r'>' +
|
|
|
|
r'.*')
|
|
|
|
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2015-08-05 08:22:03 +00:00
|
|
|
# pylint: disable=unused-argument
|
2015-07-30 09:30:31 +00:00
|
|
|
def get_scanner(hass, config):
|
2016-03-07 20:18:53 +00:00
|
|
|
"""Validate the configuration and return an ASUS-WRT scanner."""
|
2015-07-30 09:30:31 +00:00
|
|
|
scanner = AsusWrtDeviceScanner(config[DOMAIN])
|
|
|
|
|
|
|
|
return scanner if scanner.success_init else None
|
|
|
|
|
2016-11-11 06:46:58 +00:00
|
|
|
AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases arp nvram')
|
2016-06-15 05:17:32 +00:00
|
|
|
|
2015-07-30 09:30:31 +00:00
|
|
|
|
|
|
|
class AsusWrtDeviceScanner(object):
|
2016-03-07 20:18:53 +00:00
|
|
|
"""This class queries a router running ASUSWRT firmware."""
|
|
|
|
|
2016-06-10 04:30:47 +00:00
|
|
|
# Eighth attribute needed for mode (AP mode vs router mode)
|
2015-07-30 09:30:31 +00:00
|
|
|
def __init__(self, config):
|
2016-03-07 20:18:53 +00:00
|
|
|
"""Initialize the scanner."""
|
2015-07-30 09:30:31 +00:00
|
|
|
self.host = config[CONF_HOST]
|
2016-08-27 20:30:06 +00:00
|
|
|
self.username = config[CONF_USERNAME]
|
|
|
|
self.password = config.get(CONF_PASSWORD, '')
|
|
|
|
self.ssh_key = config.get('ssh_key', config.get('pub_key', ''))
|
|
|
|
self.protocol = config[CONF_PROTOCOL]
|
|
|
|
self.mode = config[CONF_MODE]
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2016-09-03 23:32:43 +00:00
|
|
|
if self.protocol == 'ssh':
|
|
|
|
if self.ssh_key:
|
|
|
|
self.ssh_secret = {'ssh_key': self.ssh_key}
|
|
|
|
elif self.password:
|
|
|
|
self.ssh_secret = {'password': self.password}
|
|
|
|
else:
|
|
|
|
_LOGGER.error('No password or private key specified')
|
|
|
|
self.success_init = False
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
if not self.password:
|
|
|
|
_LOGGER.error('No password specified')
|
|
|
|
self.success_init = False
|
|
|
|
return
|
|
|
|
|
2015-07-30 09:30:31 +00:00
|
|
|
self.lock = threading.Lock()
|
|
|
|
|
|
|
|
self.last_results = {}
|
|
|
|
|
2016-03-07 20:18:53 +00:00
|
|
|
# Test the router is accessible.
|
2015-07-30 09:30:31 +00:00
|
|
|
data = self.get_asuswrt_data()
|
|
|
|
self.success_init = data is not None
|
|
|
|
|
|
|
|
def scan_devices(self):
|
2016-03-07 20:18:53 +00:00
|
|
|
"""Scan for new devices and return a list with found device IDs."""
|
2015-07-30 09:30:31 +00:00
|
|
|
self._update_info()
|
|
|
|
return [client['mac'] for client in self.last_results]
|
|
|
|
|
|
|
|
def get_device_name(self, device):
|
2016-03-07 20:18:53 +00:00
|
|
|
"""Return the name of the given device or None if we don't know."""
|
2015-07-30 09:30:31 +00:00
|
|
|
if not self.last_results:
|
|
|
|
return None
|
|
|
|
for client in self.last_results:
|
|
|
|
if client['mac'] == device:
|
|
|
|
return client['host']
|
|
|
|
return None
|
|
|
|
|
|
|
|
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
|
|
|
def _update_info(self):
|
2016-03-07 20:18:53 +00:00
|
|
|
"""Ensure the information from the ASUSWRT router is up to date.
|
|
|
|
|
|
|
|
Return boolean if scanning successful.
|
2015-09-07 17:19:11 +00:00
|
|
|
"""
|
2015-07-30 09:30:31 +00:00
|
|
|
if not self.success_init:
|
|
|
|
return False
|
|
|
|
|
|
|
|
with self.lock:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.info('Checking ARP')
|
2015-07-30 09:30:31 +00:00
|
|
|
data = self.get_asuswrt_data()
|
|
|
|
if not data:
|
|
|
|
return False
|
|
|
|
|
|
|
|
active_clients = [client for client in data.values() if
|
|
|
|
client['status'] == 'REACHABLE' or
|
|
|
|
client['status'] == 'DELAY' or
|
2016-11-11 06:46:58 +00:00
|
|
|
client['status'] == 'STALE' or
|
|
|
|
client['status'] == 'IN_NVRAM']
|
2015-07-30 09:30:31 +00:00
|
|
|
self.last_results = active_clients
|
|
|
|
return True
|
|
|
|
|
2016-05-17 22:55:12 +00:00
|
|
|
def ssh_connection(self):
|
|
|
|
"""Retrieve data from ASUSWRT via the ssh protocol."""
|
2016-06-15 05:17:32 +00:00
|
|
|
from pexpect import pxssh, exceptions
|
2016-06-13 04:27:41 +00:00
|
|
|
|
2016-09-03 23:32:43 +00:00
|
|
|
ssh = pxssh.pxssh()
|
|
|
|
try:
|
|
|
|
ssh.login(self.host, self.username, **self.ssh_secret)
|
|
|
|
except exceptions.EOF as err:
|
|
|
|
_LOGGER.error('Connection refused. Is SSH enabled?')
|
|
|
|
return None
|
|
|
|
except pxssh.ExceptionPxssh as err:
|
|
|
|
_LOGGER.error('Unable to connect via SSH: %s', str(err))
|
|
|
|
return None
|
|
|
|
|
2015-07-30 09:30:31 +00:00
|
|
|
try:
|
2016-05-17 22:55:12 +00:00
|
|
|
ssh.sendline(_IP_NEIGH_CMD)
|
2016-05-15 19:20:17 +00:00
|
|
|
ssh.prompt()
|
|
|
|
neighbors = ssh.before.split(b'\n')[1:-1]
|
2016-06-10 04:30:47 +00:00
|
|
|
if self.mode == 'ap':
|
|
|
|
ssh.sendline(_ARP_CMD)
|
|
|
|
ssh.prompt()
|
|
|
|
arp_result = ssh.before.split(b'\n')[1:-1]
|
|
|
|
ssh.sendline(_WL_CMD)
|
|
|
|
ssh.prompt()
|
|
|
|
leases_result = ssh.before.split(b'\n')[1:-1]
|
2016-11-11 06:46:58 +00:00
|
|
|
ssh.sendline(_NVRAM_CMD)
|
|
|
|
ssh.prompt()
|
|
|
|
nvram_result = ssh.before.split(b'\n')[1].split(b'<')[1:]
|
2016-06-10 04:30:47 +00:00
|
|
|
else:
|
|
|
|
arp_result = ['']
|
2016-11-11 06:46:58 +00:00
|
|
|
nvram_result = ['']
|
2016-06-10 04:30:47 +00:00
|
|
|
ssh.sendline(_LEASES_CMD)
|
|
|
|
ssh.prompt()
|
|
|
|
leases_result = ssh.before.split(b'\n')[1:-1]
|
2016-05-15 19:20:17 +00:00
|
|
|
ssh.logout()
|
2016-11-11 06:46:58 +00:00
|
|
|
return AsusWrtResult(neighbors, leases_result, arp_result,
|
|
|
|
nvram_result)
|
2016-05-15 19:20:17 +00:00
|
|
|
except pxssh.ExceptionPxssh as exc:
|
2016-06-15 05:17:32 +00:00
|
|
|
_LOGGER.error('Unexpected response from router: %s', exc)
|
|
|
|
return None
|
2016-05-17 22:55:12 +00:00
|
|
|
|
|
|
|
def telnet_connection(self):
|
|
|
|
"""Retrieve data from ASUSWRT via the telnet protocol."""
|
|
|
|
try:
|
|
|
|
telnet = telnetlib.Telnet(self.host)
|
|
|
|
telnet.read_until(b'login: ')
|
|
|
|
telnet.write((self.username + '\n').encode('ascii'))
|
|
|
|
telnet.read_until(b'Password: ')
|
|
|
|
telnet.write((self.password + '\n').encode('ascii'))
|
|
|
|
prompt_string = telnet.read_until(b'#').split(b'\n')[-1]
|
|
|
|
telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii'))
|
|
|
|
neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
2016-06-10 04:30:47 +00:00
|
|
|
if self.mode == 'ap':
|
|
|
|
telnet.write('{}\n'.format(_ARP_CMD).encode('ascii'))
|
|
|
|
arp_result = (telnet.read_until(prompt_string).
|
|
|
|
split(b'\n')[1:-1])
|
|
|
|
telnet.write('{}\n'.format(_WL_CMD).encode('ascii'))
|
|
|
|
leases_result = (telnet.read_until(prompt_string).
|
|
|
|
split(b'\n')[1:-1])
|
2016-11-11 06:46:58 +00:00
|
|
|
telnet.write('{}\n'.format(_NVRAM_CMD).encode('ascii'))
|
|
|
|
nvram_result = (telnet.read_until(prompt_string).
|
|
|
|
split(b'\n')[1].split(b'<')[1:])
|
2016-06-10 04:30:47 +00:00
|
|
|
else:
|
|
|
|
arp_result = ['']
|
2016-11-11 06:46:58 +00:00
|
|
|
nvram_result = ['']
|
2016-06-10 04:30:47 +00:00
|
|
|
telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii'))
|
|
|
|
leases_result = (telnet.read_until(prompt_string).
|
|
|
|
split(b'\n')[1:-1])
|
2016-05-17 22:55:12 +00:00
|
|
|
telnet.write('exit\n'.encode('ascii'))
|
2016-11-11 06:46:58 +00:00
|
|
|
return AsusWrtResult(neighbors, leases_result, arp_result,
|
|
|
|
nvram_result)
|
2016-05-17 22:55:12 +00:00
|
|
|
except EOFError:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.error('Unexpected response from router')
|
2016-06-15 05:17:32 +00:00
|
|
|
return None
|
2016-05-17 22:55:12 +00:00
|
|
|
except ConnectionRefusedError:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.error('Connection refused by router, is telnet enabled?')
|
2016-06-15 05:17:32 +00:00
|
|
|
return None
|
|
|
|
except socket.gaierror as exc:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.error('Socket exception: %s', exc)
|
2016-06-15 05:17:32 +00:00
|
|
|
return None
|
|
|
|
except OSError as exc:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.error('OSError: %s', exc)
|
2016-06-15 05:17:32 +00:00
|
|
|
return None
|
2016-05-17 22:55:12 +00:00
|
|
|
|
|
|
|
def get_asuswrt_data(self):
|
|
|
|
"""Retrieve data from ASUSWRT and return parsed result."""
|
2016-06-15 05:17:32 +00:00
|
|
|
if self.protocol == 'ssh':
|
|
|
|
result = self.ssh_connection()
|
|
|
|
elif self.protocol == 'telnet':
|
|
|
|
result = self.telnet_connection()
|
2016-05-17 22:55:12 +00:00
|
|
|
else:
|
2016-06-15 05:17:32 +00:00
|
|
|
# autodetect protocol
|
|
|
|
result = self.ssh_connection()
|
|
|
|
if result:
|
|
|
|
self.protocol = 'ssh'
|
|
|
|
else:
|
|
|
|
result = self.telnet_connection()
|
|
|
|
if result:
|
|
|
|
self.protocol = 'telnet'
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
return {}
|
2015-07-30 09:30:31 +00:00
|
|
|
|
|
|
|
devices = {}
|
2016-06-10 04:30:47 +00:00
|
|
|
if self.mode == 'ap':
|
2016-06-15 05:17:32 +00:00
|
|
|
for lease in result.leases:
|
2016-06-10 04:30:47 +00:00
|
|
|
match = _WL_REGEX.search(lease.decode('utf-8'))
|
2015-09-15 01:33:14 +00:00
|
|
|
|
2016-06-10 04:30:47 +00:00
|
|
|
if not match:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.warning('Could not parse wl row: %s', lease)
|
2016-06-10 04:30:47 +00:00
|
|
|
continue
|
2015-11-12 19:10:25 +00:00
|
|
|
|
|
|
|
host = ''
|
2015-09-15 01:33:14 +00:00
|
|
|
|
2016-06-10 04:30:47 +00:00
|
|
|
# match mac addresses to IP addresses in ARP table
|
2016-06-15 05:17:32 +00:00
|
|
|
for arp in result.arp:
|
2016-06-10 04:30:47 +00:00
|
|
|
if match.group('mac').lower() in arp.decode('utf-8'):
|
|
|
|
arp_match = _ARP_REGEX.search(arp.decode('utf-8'))
|
|
|
|
if not arp_match:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.warning('Could not parse arp row: %s', arp)
|
2016-06-10 04:30:47 +00:00
|
|
|
continue
|
|
|
|
|
|
|
|
devices[arp_match.group('ip')] = {
|
|
|
|
'host': host,
|
|
|
|
'status': '',
|
|
|
|
'ip': arp_match.group('ip'),
|
|
|
|
'mac': match.group('mac').upper(),
|
|
|
|
}
|
2016-11-11 06:46:58 +00:00
|
|
|
|
|
|
|
# match mac addresses to IP addresses in NVRAM table
|
|
|
|
for nvr in result.nvram:
|
|
|
|
if match.group('mac').upper() in nvr.decode('utf-8'):
|
|
|
|
nvram_match = _NVRAM_REGEX.search(nvr.decode('utf-8'))
|
|
|
|
if not nvram_match:
|
|
|
|
_LOGGER.warning('Could not parse nvr row: %s', nvr)
|
|
|
|
continue
|
|
|
|
|
|
|
|
# skip current check if already in ARP table
|
|
|
|
if nvram_match.group('ip') in devices.keys():
|
|
|
|
continue
|
|
|
|
|
|
|
|
devices[nvram_match.group('ip')] = {
|
|
|
|
'host': host,
|
|
|
|
'status': 'IN_NVRAM',
|
|
|
|
'ip': nvram_match.group('ip'),
|
|
|
|
'mac': match.group('mac').upper(),
|
|
|
|
}
|
|
|
|
|
2016-06-10 04:30:47 +00:00
|
|
|
else:
|
2016-06-15 05:17:32 +00:00
|
|
|
for lease in result.leases:
|
2016-06-10 04:30:47 +00:00
|
|
|
match = _LEASES_REGEX.search(lease.decode('utf-8'))
|
|
|
|
|
|
|
|
if not match:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.warning('Could not parse lease row: %s', lease)
|
2016-06-10 04:30:47 +00:00
|
|
|
continue
|
|
|
|
|
|
|
|
# For leases where the client doesn't set a hostname, ensure it
|
|
|
|
# is blank and not '*', which breaks entity_id down the line.
|
|
|
|
host = match.group('host')
|
|
|
|
if host == '*':
|
|
|
|
host = ''
|
|
|
|
|
|
|
|
devices[match.group('ip')] = {
|
|
|
|
'host': host,
|
|
|
|
'status': '',
|
|
|
|
'ip': match.group('ip'),
|
|
|
|
'mac': match.group('mac').upper(),
|
|
|
|
}
|
2015-07-30 09:30:31 +00:00
|
|
|
|
2016-06-15 05:17:32 +00:00
|
|
|
for neighbor in result.neighbors:
|
2015-07-30 09:30:31 +00:00
|
|
|
match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8'))
|
2015-11-12 19:10:25 +00:00
|
|
|
if not match:
|
2016-07-08 15:58:31 +00:00
|
|
|
_LOGGER.warning('Could not parse neighbor row: %s', neighbor)
|
2015-11-12 19:10:25 +00:00
|
|
|
continue
|
|
|
|
if match.group('ip') in devices:
|
2015-07-30 09:30:31 +00:00
|
|
|
devices[match.group('ip')]['status'] = match.group('status')
|
|
|
|
return devices
|