From 64a994024763dbb2e3b243a24d36e3403c00ab52 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Wed, 6 Sep 2017 16:20:10 +0700 Subject: [PATCH 1/4] Added Zyxel Keenetic NDMS2 based routers support for device tracking --- .../device_tracker/keenetic_ndms2.py | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 homeassistant/components/device_tracker/keenetic_ndms2.py diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py new file mode 100644 index 00000000000000..583f268df37963 --- /dev/null +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -0,0 +1,149 @@ +""" +Support for Zyxel Keenetic NDMS2 based routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.keenetic_ndms2/ +""" +from datetime import timedelta +import logging + +import requests +from collections import namedtuple + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD +) + +_LOGGER = logging.getLogger(__name__) + +CONF_EXCLUDE = 'exclude' +# Interval in minutes to assume these hosts are still home +CONF_HOME_INTERVAL = 'home_interval' +# Interface name to track devices for. Most likely one will not need to +# change it from default 'Home'. This is needed not to track Guest WI-FI- +# clients and router itself +CONF_INTERFACE = 'interface' + +DEFAULT_INTERFACE = 'Home' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, + vol.Optional(CONF_EXCLUDE, default=[]): + vol.All(cv.ensure_list, vol.Length(min=1)), +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) + + +class KeeneticNDMS2DeviceScanner(DeviceScanner): + """This class scans for devices using keenetic NDMS2 web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._interface = config[CONF_INTERFACE] + self._exclude = config[CONF_EXCLUDE] + self._home_interval = timedelta(minutes=config[CONF_HOME_INTERVAL]) + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, mac): + """Return the name of the given device or None if we don't know.""" + filter_named = [device.name for device in self.last_results + if device.mac == mac] + + if filter_named: + return filter_named[0] + return None + + def _update_info(self): + """Get ARP from keenetic router + """ + _LOGGER.info("Fetching...") + + if self._home_interval: + boundary = dt_util.now() - self._home_interval + last_results = [device for device in self.last_results + if device.last_update > boundary] + if last_results: + exclude_hosts = self._exclude + [device.ip for device + in last_results] + else: + exclude_hosts = self._exclude + else: + last_results = [] + exclude_hosts = self._exclude + + # doing a request + try: + from requests.auth import HTTPDigestAuth + res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( + self._username, self._password + )) + except requests.exceptions.Timeout: + _LOGGER.exception( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.exception( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.exception("Failed to parse response from router") + return False + + # parsing response + now = dt_util.now() + for info in result: + if info.get('interface') != self._interface: + continue + mac = info.get('mac') + ipv4 = info.get('ip') + # No address = no item :) + if mac is None or ipv4 is None: + continue + # exclusions + if ipv4 in exclude_hosts: + continue + + name = info.get('name') + last_results.append(Device(mac.upper(), name, ipv4, now)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True From ce040055ffcbd8158c349948024415f9875cd8bc Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Thu, 14 Sep 2017 01:03:16 +0700 Subject: [PATCH 2/4] Review feedback --- .coveragerc | 1 + .../device_tracker/keenetic_ndms2.py | 33 +++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/.coveragerc b/.coveragerc index 2fc424e91f67b0..34554e0813e973 100644 --- a/.coveragerc +++ b/.coveragerc @@ -283,6 +283,7 @@ omit = homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 583f268df37963..166e46c33751a4 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -4,12 +4,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.keenetic_ndms2/ """ -from datetime import timedelta import logging - -import requests from collections import namedtuple +import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -17,14 +15,12 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD + CONF_HOST, CONF_PASSWORD, CONF_USERNAME ) _LOGGER = logging.getLogger(__name__) CONF_EXCLUDE = 'exclude' -# Interval in minutes to assume these hosts are still home -CONF_HOME_INTERVAL = 'home_interval' # Interface name to track devices for. Most likely one will not need to # change it from default 'Home'. This is needed not to track Guest WI-FI- # clients and router itself @@ -38,7 +34,6 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, - vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, vol.Length(min=1)), }) @@ -64,7 +59,6 @@ def __init__(self, config): self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] self._interface = config[CONF_INTERFACE] self._exclude = config[CONF_EXCLUDE] - self._home_interval = timedelta(minutes=config[CONF_HOME_INTERVAL]) self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) @@ -88,22 +82,11 @@ def get_device_name(self, mac): return None def _update_info(self): - """Get ARP from keenetic router - """ + """Get ARP from keenetic router.""" _LOGGER.info("Fetching...") - if self._home_interval: - boundary = dt_util.now() - self._home_interval - last_results = [device for device in self.last_results - if device.last_update > boundary] - if last_results: - exclude_hosts = self._exclude + [device.ip for device - in last_results] - else: - exclude_hosts = self._exclude - else: - last_results = [] - exclude_hosts = self._exclude + last_results = [] + exclude_hosts = self._exclude # doing a request try: @@ -112,18 +95,18 @@ def _update_info(self): self._username, self._password )) except requests.exceptions.Timeout: - _LOGGER.exception( + _LOGGER.error( "Connection to the router timed out at URL %s", self._url) return False if res.status_code != 200: - _LOGGER.exception( + _LOGGER.error( "Connection failed with http code %s", res.status_code) return False try: result = res.json() except ValueError: # If json decoder could not parse the response - _LOGGER.exception("Failed to parse response from router") + _LOGGER.error("Failed to parse response from router") return False # parsing response From 96c796dbc403afb3068651ee071677daf511318c Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Thu, 14 Sep 2017 23:07:23 +0700 Subject: [PATCH 3/4] Review feedback+ --- homeassistant/components/device_tracker/keenetic_ndms2.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 166e46c33751a4..562b0b34d785d0 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__name__) -CONF_EXCLUDE = 'exclude' # Interface name to track devices for. Most likely one will not need to # change it from default 'Home'. This is needed not to track Guest WI-FI- # clients and router itself @@ -34,8 +33,6 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, - vol.Optional(CONF_EXCLUDE, default=[]): - vol.All(cv.ensure_list, vol.Length(min=1)), }) @@ -58,7 +55,6 @@ def __init__(self, config): self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] self._interface = config[CONF_INTERFACE] - self._exclude = config[CONF_EXCLUDE] self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) @@ -86,7 +82,6 @@ def _update_info(self): _LOGGER.info("Fetching...") last_results = [] - exclude_hosts = self._exclude # doing a request try: @@ -119,9 +114,6 @@ def _update_info(self): # No address = no item :) if mac is None or ipv4 is None: continue - # exclusions - if ipv4 in exclude_hosts: - continue name = info.get('name') last_results.append(Device(mac.upper(), name, ipv4, now)) From 980c614de2351d4cdd65f53473f1bb1c4e659e13 Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Fri, 15 Sep 2017 06:51:48 +0700 Subject: [PATCH 4/4] Review feedback: removed unneeded code --- .../components/device_tracker/keenetic_ndms2.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 562b0b34d785d0..5a7db36e4798ab 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -11,7 +11,6 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( @@ -43,7 +42,7 @@ def get_scanner(_hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) +Device = namedtuple('Device', ['mac', 'name']) class KeeneticNDMS2DeviceScanner(DeviceScanner): @@ -105,18 +104,16 @@ def _update_info(self): return False # parsing response - now = dt_util.now() for info in result: if info.get('interface') != self._interface: continue mac = info.get('mac') - ipv4 = info.get('ip') + name = info.get('name') # No address = no item :) - if mac is None or ipv4 is None: + if mac is None: continue - name = info.get('name') - last_results.append(Device(mac.upper(), name, ipv4, now)) + last_results.append(Device(mac.upper(), name)) self.last_results = last_results