From ed31b6a5271dc5c3f2600e1feed0d1a2075abc60 Mon Sep 17 00:00:00 2001 From: Markus Stenberg Date: Mon, 21 Apr 2014 22:51:50 +0300 Subject: [PATCH 1/2] Added LuciDeviceScanner to scan OpenWrt Luci-RPC enabled router's state. --- homeassistant/bootstrap.py | 10 ++ homeassistant/components/device_tracker.py | 148 +++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 48933f8c87c..3bd62ba535e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -83,6 +83,16 @@ def from_config_file(config_path): get_opt('device_tracker.netgear', 'username'), get_opt('device_tracker.netgear', 'password')) + elif has_section('device_tracker.luci'): + device_tracker = load_module('device_tracker') + + dev_scan_name = "Luci" + + dev_scan = device_tracker.LuciDeviceScanner( + get_opt('device_tracker.luci', 'host'), + get_opt('device_tracker.luci', 'username'), + get_opt('device_tracker.luci', 'password')) + except configparser.NoOptionError: # If one of the options didn't exist logger.exception(("Error initializing {}DeviceScanner, " diff --git a/homeassistant/components/device_tracker.py b/homeassistant/components/device_tracker.py index 3c57b85f2c1..445c74b245f 100644 --- a/homeassistant/components/device_tracker.py +++ b/homeassistant/components/device_tracker.py @@ -456,3 +456,151 @@ class NetgearDeviceScanner(object): else: return + +class LuciDeviceScanner(object): + """ This class queries a wireless router running OpenWrt firmware + for connected devices. Adapted from Tomato scanner. + + # opkg install luci-mod-rpc + for this to work on the router. + + The API is described here: + http://luci.subsignal.org/trac/wiki/Documentation/JsonRpcHowTo + + (Currently, we do only wifi iwscan, and no DHCP lease access.) + """ + + def __init__(self, host, username, password): + self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") + + self.logger = logging.getLogger(__name__) + self.lock = threading.Lock() + + self.date_updated = None + self.last_results = {} + + self.token = self.get_token(host, username, password) + self.host = host + + self.mac2name = None + self.success_init = self.token + + def get_token(self, host, username, password): + data = json.dumps({'method': 'login', + 'params': [username, password]}) + try: + r = requests.post('http://{}/cgi-bin/luci/rpc/auth'.format(host), data=data, timeout=3) + if r.status_code == 200: + token = r.json()['result'] + self.logger.info('Authenticated') + return token + elif r.status_code == 401: + # Authentication error + self.logger.exception( + "Failed to authenticate, " + "please check your username and password") + return + else: + self.logger.error("Invalid response: %s" % r) + except requests.exceptions.Timeout: + self.logger.exception("Connection to the router timed out") + except ValueError: + # If json decoder could not parse the response + self.logger.exception("Failed to parse response from router") + + def scan_devices(self): + """ Scans for new devices and return a + list containing found device ids. """ + + self._update_info() + + return self.last_results + + def get_device_name(self, device): + """ Returns the name of the given device or None if we don't know. """ + + with self.lock: + if self.mac2name is None: + try: + data = json.dumps({'method': 'get_all', + 'params': ['dhcp']}) + + r = requests.post('http://{}/cgi-bin/luci/rpc/uci'.format(self.host), params={'auth': self.token}, data=data, timeout=3) + + # Calling and parsing the Luci api here. We only need the + # wldev and dhcpd_lease values. For API description see: + # http://paulusschoutsen.nl/ + # blog/2013/10/tomato-api-documentation/ + if r.status_code == 200: + self.mac2name = dict(map(lambda x:(x['mac'], x['name']), + filter(lambda x:x['.type'] == 'host' and 'mac' in x and 'name' in x, + r.json()['result'].values()))) + # Passthrough + else: + self.logger.error("Invalid response: %s" % r) + return + + except requests.exceptions.Timeout: + # We get this if we could not connect to the router or + # an invalid http_id was supplied + self.logger.exception( + "Connection to the router timed out") + return + + except ValueError: + # If json decoder could not parse the response + self.logger.exception( + "Failed to parse response from router") + + return + return self.mac2name.get(device, None) + + def _update_info(self): + """ Ensures the information from the Luci router is up to date. + Returns boolean if scanning successful. """ + if not self.success_init: + return False + with self.lock: + + # if date_updated is None or the date is too old we scan for new data + if (not self.date_updated or datetime.now() - self.date_updated > + MIN_TIME_BETWEEN_SCANS): + + self.logger.info("Checking ARP") + + try: + data = json.dumps({'method': 'net.arptable', + 'params': []}) + + r = requests.post('http://{}/cgi-bin/luci/rpc/sys'.format(self.host), params={'auth': self.token}, data=data, timeout=3) + + # Calling and parsing the Luci api here. We only need the + # wldev and dhcpd_lease values. For API description see: + # http://paulusschoutsen.nl/ + # blog/2013/10/tomato-api-documentation/ + if r.status_code == 200: + self.last_results = list(map(lambda x:x['HW address'], r.json()['result'])) + self.date_updated = datetime.now() + return True + + else: + self.logger.error("Invalid response: %s" % r) + return False + + except requests.exceptions.Timeout: + # We get this if we could not connect to the router or + # an invalid http_id was supplied + self.logger.exception( + "Connection to the router timed out") + + return False + + except ValueError: + # If json decoder could not parse the response + self.logger.exception( + "Failed to parse response from router") + + return False + + return True + From 9af3d2b914cdd2a80806d71bf5bd8d968b6e7bbc Mon Sep 17 00:00:00 2001 From: Markus Stenberg Date: Thu, 24 Apr 2014 16:58:15 +0300 Subject: [PATCH 2/2] pep8 + pylint run fixes. Also refactored the json-rpc to happen only in one place (external library would be nice but oh well). --- homeassistant/components/device_tracker.py | 138 ++++++++------------- 1 file changed, 52 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/device_tracker.py b/homeassistant/components/device_tracker.py index bf805962a02..3b26f09fefc 100644 --- a/homeassistant/components/device_tracker.py +++ b/homeassistant/components/device_tracker.py @@ -456,6 +456,7 @@ class NetgearDeviceScanner(object): else: return + class LuciDeviceScanner(object): """ This class queries a wireless router running OpenWrt firmware for connected devices. Adapted from Tomato scanner. @@ -484,28 +485,39 @@ class LuciDeviceScanner(object): self.mac2name = None self.success_init = self.token - def get_token(self, host, username, password): - data = json.dumps({'method': 'login', - 'params': [username, password]}) + def _req_json_rpc(self, url, method, *args, **kwargs): + """ Perform one JSON RPC operation. """ + data = json.dumps({'method': method, 'params': args}) try: - r = requests.post('http://{}/cgi-bin/luci/rpc/auth'.format(host), data=data, timeout=3) - if r.status_code == 200: - token = r.json()['result'] - self.logger.info('Authenticated') - return token - elif r.status_code == 401: - # Authentication error - self.logger.exception( - "Failed to authenticate, " - "please check your username and password") - return - else: - self.logger.error("Invalid response: %s" % r) + res = requests.post(url, data=data, **kwargs) except requests.exceptions.Timeout: self.logger.exception("Connection to the router timed out") - except ValueError: - # If json decoder could not parse the response - self.logger.exception("Failed to parse response from router") + return + if res.status_code == 200: + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + self.logger.exception("Failed to parse response from luci") + return + try: + return result['result'] + except KeyError: + self.logger.exception("No result in response from luci") + return + elif res.status_code == 401: + # Authentication error + self.logger.exception( + "Failed to authenticate, " + "please check your username and password") + return + else: + self.logger.error("Invalid response from luci: {}".format(res)) + + def get_token(self, host, username, password): + """ Get authentication token for the given host+username+password """ + url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host) + return self._req_json_rpc(url, 'login', username, password) def scan_devices(self): """ Scans for new devices and return a @@ -520,37 +532,17 @@ class LuciDeviceScanner(object): with self.lock: if self.mac2name is None: - try: - data = json.dumps({'method': 'get_all', - 'params': ['dhcp']}) - - r = requests.post('http://{}/cgi-bin/luci/rpc/uci'.format(self.host), params={'auth': self.token}, data=data, timeout=3) - - # Calling and parsing the Luci api here. We only need the - # wldev and dhcpd_lease values. For API description see: - # http://paulusschoutsen.nl/ - # blog/2013/10/tomato-api-documentation/ - if r.status_code == 200: - self.mac2name = dict(map(lambda x:(x['mac'], x['name']), - filter(lambda x:x['.type'] == 'host' and 'mac' in x and 'name' in x, - r.json()['result'].values()))) - # Passthrough - else: - self.logger.error("Invalid response: %s" % r) - return - - except requests.exceptions.Timeout: - # We get this if we could not connect to the router or - # an invalid http_id was supplied - self.logger.exception( - "Connection to the router timed out") - return - - except ValueError: - # If json decoder could not parse the response - self.logger.exception( - "Failed to parse response from router") - + url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host) + result = self._req_json_rpc(url, 'get_all', 'dhcp', + params={'auth': self.token}) + if result: + hosts = [x for x in result.values() + if x['.type'] == 'host' and + 'mac' in x and 'name' in x] + mac2name_list = [(x['mac'], x['name']) for x in hosts] + self.mac2name = dict(mac2name_list) + else: + # Error, handled in the _req_json_rpc return return self.mac2name.get(device, None) @@ -560,46 +552,20 @@ class LuciDeviceScanner(object): if not self.success_init: return False with self.lock: - - # if date_updated is None or the date is too old we scan for new data + # if date_updated is None or the date is too old we scan + # for new data if (not self.date_updated or datetime.now() - self.date_updated > MIN_TIME_BETWEEN_SCANS): self.logger.info("Checking ARP") - try: - data = json.dumps({'method': 'net.arptable', - 'params': []}) - - r = requests.post('http://{}/cgi-bin/luci/rpc/sys'.format(self.host), params={'auth': self.token}, data=data, timeout=3) - - # Calling and parsing the Luci api here. We only need the - # wldev and dhcpd_lease values. For API description see: - # http://paulusschoutsen.nl/ - # blog/2013/10/tomato-api-documentation/ - if r.status_code == 200: - self.last_results = list(map(lambda x:x['HW address'], r.json()['result'])) - self.date_updated = datetime.now() - return True - - else: - self.logger.error("Invalid response: %s" % r) - return False - - except requests.exceptions.Timeout: - # We get this if we could not connect to the router or - # an invalid http_id was supplied - self.logger.exception( - "Connection to the router timed out") - - return False - - except ValueError: - # If json decoder could not parse the response - self.logger.exception( - "Failed to parse response from router") - - return False + url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) + result = self._req_json_rpc(url, 'net.arptable', + params={'auth': self.token}) + if result: + self.last_results = [x['HW address'] for x in result] + self.date_updated = datetime.now() + return True + return False return True -