From 4c8cf58042126323e81add19212466b921abddcc Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Tue, 17 Jul 2018 23:29:59 +0700 Subject: [PATCH 1/5] Fixed NDMS for latest firmware. Now using telnet instead of Web Interface --- .../device_tracker/keenetic_ndms2.py | 143 ++++++++++++++---- 1 file changed, 113 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 36dc1182a9294e..806e6c9ec4c555 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -5,16 +5,17 @@ https://home-assistant.io/components/device_tracker.keenetic_ndms2/ """ import logging +import telnetlib +import re from collections import namedtuple -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME + CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME ) _LOGGER = logging.getLogger(__name__) @@ -25,16 +26,27 @@ CONF_INTERFACE = 'interface' DEFAULT_INTERFACE = 'Home' +DEFAULT_PORT = 23 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, }) +_ARP_CMD = 'show ip arp' +_ARP_REGEX = re.compile( + r'(?P([^\ ]+))\s+' + + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+' + + r'(?P([^\ ]+))\s+' +) + + def get_scanner(_hass, config): """Validate the configuration and return a Nmap scanner.""" scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) @@ -42,7 +54,22 @@ def get_scanner(_hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'name']) +def _parse_lines(lines, regex): + """Parse the lines using the given regular expression. + + If a line can't be parsed it is logged and skipped in the output. + """ + results = [] + for line in lines: + match = regex.search(line) + if not match: + _LOGGER.debug("Could not parse line: %s", line) + continue + results.append(match.groupdict()) + return results + + +Device = namedtuple('Device', ['mac', 'name', 'ip']) class KeeneticNDMS2DeviceScanner(DeviceScanner): @@ -52,12 +79,20 @@ def __init__(self, config): """Initialize the scanner.""" self.last_results = [] - self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._host = config[CONF_HOST] + self._port = config[CONF_PORT] self._interface = config[CONF_INTERFACE] self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) + self.connection = TelnetConnection( + self._host, + self._port, + self._username, + self._password, + ) + self.success_init = self._update_info() _LOGGER.info("Scanner initialized") @@ -67,55 +102,103 @@ def scan_devices(self): return [device.mac for device in self.last_results] - def get_device_name(self, device): + def get_device_name(self, mac): """Return the name of the given device or None if we don't know.""" - filter_named = [result.name for result in self.last_results - if result.mac == device] + filter_name = [device.name for device in self.last_results + if device.mac == mac] - if filter_named: - return filter_named[0] + if filter_name: + return filter_name[0] return None + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + filter_ip = [result.ip for result in self.last_results + if result.mac == device] + if filter_ip: + return {'ip': filter_ip[0]} + return {'ip': None} + def _update_info(self): """Get ARP from keenetic router.""" _LOGGER.info("Fetching...") last_results = [] - # 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.error( - "Connection to the router timed out at URL %s", self._url) - return False - if res.status_code != 200: - _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.error("Failed to parse response from router") + lines = self.connection.run_command(_ARP_CMD) + if not lines: return False - # parsing response + result = _parse_lines(lines, _ARP_REGEX) + for info in result: if info.get('interface') != self._interface: continue mac = info.get('mac') name = info.get('name') + ip = info.get('ip') # No address = no item :) if mac is None: continue - last_results.append(Device(mac.upper(), name)) + last_results.append(Device(mac.upper(), name, ip)) self.last_results = last_results _LOGGER.info("Request successful") return True + + +class TelnetConnection(object): + """Maintains a Telnet connection to a router.""" + + def __init__(self, host, port, username, password): + """Initialize the Telnet connection properties.""" + + self._connected = False + self._telnet = None + self._host = host + self._port = port + self._username = username + self._password = password + self._prompt_string = None + + def run_command(self, command): + """Run a command through a Telnet connection. + + Connect to the Telnet server if not currently connected, otherwise + use the existing connection. + """ + try: + if not self._telnet: + self.connect() + + self._telnet.write('{}\n'.format(command).encode('ascii')) + return self._telnet.read_until(self._prompt_string, 30)\ + .decode('ascii')\ + .split('\n')[1:-1] + except Exception as e: + _LOGGER.error("Telnet error: $s", e) + self.disconnect() + return None + + def connect(self): + """Connect to the ASUS-WRT Telnet server.""" + self._telnet = telnetlib.Telnet(self._host) + self._telnet.read_until(b'Login: ', 30) + self._telnet.write((self._username + '\n').encode('ascii')) + self._telnet.read_until(b'Password: ', 30) + self._telnet.write((self._password + '\n').encode('ascii')) + self._prompt_string = self._telnet.read_until(b'>').split(b'\n')[-1] + + self._connected = True + + def disconnect(self): + """Disconnect the current Telnet connection.""" + try: + self._telnet.write(b'exit\n') + except Exception as e: + _LOGGER.error("Telnet error on exit: $s", e) + pass + + self._telnet = None From 18290c0ec0caf5cd54ad9111ed6e377bd7c3f522 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Tue, 31 Jul 2018 14:19:22 +0700 Subject: [PATCH 2/5] Using external library for NDMS interactions --- .../device_tracker/keenetic_ndms2.py | 146 +++--------------- 1 file changed, 24 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 806e6c9ec4c555..6e725e101209ce 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -5,9 +5,6 @@ https://home-assistant.io/components/device_tracker.keenetic_ndms2/ """ import logging -import telnetlib -import re -from collections import namedtuple import voluptuous as vol @@ -18,6 +15,8 @@ CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME ) +REQUIREMENTS = ['ndms2_client==0.0.2'] + _LOGGER = logging.getLogger(__name__) # Interface name to track devices for. Most likely one will not need to @@ -38,15 +37,6 @@ }) -_ARP_CMD = 'show ip arp' -_ARP_REGEX = re.compile( - r'(?P([^\ ]+))\s+' + - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+' + - r'(?P([^\ ]+))\s+' -) - - def get_scanner(_hass, config): """Validate the configuration and return a Nmap scanner.""" scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) @@ -54,44 +44,22 @@ def get_scanner(_hass, config): return scanner if scanner.success_init else None -def _parse_lines(lines, regex): - """Parse the lines using the given regular expression. - - If a line can't be parsed it is logged and skipped in the output. - """ - results = [] - for line in lines: - match = regex.search(line) - if not match: - _LOGGER.debug("Could not parse line: %s", line) - continue - results.append(match.groupdict()) - return results - - -Device = namedtuple('Device', ['mac', 'name', 'ip']) - - class KeeneticNDMS2DeviceScanner(DeviceScanner): """This class scans for devices using keenetic NDMS2 web interface.""" def __init__(self, config): """Initialize the scanner.""" + from ndms2_client import Client, TelnetConnection self.last_results = [] - self._host = config[CONF_HOST] - self._port = config[CONF_PORT] self._interface = config[CONF_INTERFACE] - self._username = config.get(CONF_USERNAME) - self._password = config.get(CONF_PASSWORD) - - self.connection = TelnetConnection( - self._host, - self._port, - self._username, - self._password, - ) + self._client = Client(TelnetConnection( + config.get(CONF_HOST), + config.get(CONF_PORT), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + )) self.success_init = self._update_info() _LOGGER.info("Scanner initialized") @@ -111,94 +79,28 @@ def get_device_name(self, mac): return filter_name[0] return None - def get_extra_attributes(self, device): + def get_extra_attributes(self, mac): """Return the IP of the given device.""" filter_ip = [result.ip for result in self.last_results - if result.mac == device] + if result.mac == mac] if filter_ip: return {'ip': filter_ip[0]} return {'ip': None} def _update_info(self): """Get ARP from keenetic router.""" - _LOGGER.info("Fetching...") - - last_results = [] - - lines = self.connection.run_command(_ARP_CMD) - if not lines: - return False + _LOGGER.debug("Fetching devices from router...") - result = _parse_lines(lines, _ARP_REGEX) - - for info in result: - if info.get('interface') != self._interface: - continue - mac = info.get('mac') - name = info.get('name') - ip = info.get('ip') - # No address = no item :) - if mac is None: - continue - - last_results.append(Device(mac.upper(), name, ip)) - - self.last_results = last_results - - _LOGGER.info("Request successful") - return True - - -class TelnetConnection(object): - """Maintains a Telnet connection to a router.""" - - def __init__(self, host, port, username, password): - """Initialize the Telnet connection properties.""" - - self._connected = False - self._telnet = None - self._host = host - self._port = port - self._username = username - self._password = password - self._prompt_string = None - - def run_command(self, command): - """Run a command through a Telnet connection. - - Connect to the Telnet server if not currently connected, otherwise - use the existing connection. - """ + from ndms2_client import ConnectionException try: - if not self._telnet: - self.connect() - - self._telnet.write('{}\n'.format(command).encode('ascii')) - return self._telnet.read_until(self._prompt_string, 30)\ - .decode('ascii')\ - .split('\n')[1:-1] - except Exception as e: - _LOGGER.error("Telnet error: $s", e) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT Telnet server.""" - self._telnet = telnetlib.Telnet(self._host) - self._telnet.read_until(b'Login: ', 30) - self._telnet.write((self._username + '\n').encode('ascii')) - self._telnet.read_until(b'Password: ', 30) - self._telnet.write((self._password + '\n').encode('ascii')) - self._prompt_string = self._telnet.read_until(b'>').split(b'\n')[-1] - - self._connected = True - - def disconnect(self): - """Disconnect the current Telnet connection.""" - try: - self._telnet.write(b'exit\n') - except Exception as e: - _LOGGER.error("Telnet error on exit: $s", e) - pass - - self._telnet = None + self.last_results = [ + dev + for dev in self._client.get_devices() + if dev.interface == self._interface + ] + _LOGGER.debug("Successfully fetched data from router") + return True + + except ConnectionException: + _LOGGER.error("Error fetching data from router") + return False From c2256b0f3281aa27173c0535e531464c5bc32ef6 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Tue, 31 Jul 2018 14:28:29 +0700 Subject: [PATCH 3/5] updated requirements_all --- homeassistant/components/device_tracker/keenetic_ndms2.py | 2 +- requirements_all.txt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 6e725e101209ce..c5176603e5a0d2 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -15,7 +15,7 @@ CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME ) -REQUIREMENTS = ['ndms2_client==0.0.2'] +REQUIREMENTS = ['ndms2_client==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f524dc28a8b633..82f61292664393 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -570,6 +570,9 @@ nad_receiver==0.0.9 # homeassistant.components.light.nanoleaf_aurora nanoleaf==0.4.1 +# homeassistant.components.device_tracker.keenetic_ndms2 +ndms2_client==0.0.3 + # homeassistant.components.sensor.netdata netdata==0.1.2 From 0a52dd1bfbdf0d67b06c760d670e5779bd0818d6 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Tue, 31 Jul 2018 14:44:59 +0700 Subject: [PATCH 4/5] renamed `mac` to `device` back --- homeassistant/components/device_tracker/keenetic_ndms2.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index c5176603e5a0d2..7b32a6d185c3e5 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -70,19 +70,19 @@ def scan_devices(self): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" filter_name = [device.name for device in self.last_results - if device.mac == mac] + if device.mac == device] if filter_name: return filter_name[0] return None - def get_extra_attributes(self, mac): + def get_extra_attributes(self, device): """Return the IP of the given device.""" filter_ip = [result.ip for result in self.last_results - if result.mac == mac] + if result.mac == device] if filter_ip: return {'ip': filter_ip[0]} return {'ip': None} From 9b50fa932f345560d4277653cc757f36d9cc37d6 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Tue, 31 Jul 2018 15:12:56 +0700 Subject: [PATCH 5/5] Using generators for name and attributes fetching --- .../device_tracker/keenetic_ndms2.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 7b32a6d185c3e5..4b5e3d6333d909 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -72,20 +72,17 @@ def scan_devices(self): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_name = [device.name for device in self.last_results - if device.mac == device] - - if filter_name: - return filter_name[0] - return None + name = next(( + result.name for result in self.last_results + if result.mac == device), None) + return name def get_extra_attributes(self, device): """Return the IP of the given device.""" - filter_ip = [result.ip for result in self.last_results - if result.mac == device] - if filter_ip: - return {'ip': filter_ip[0]} - return {'ip': None} + attributes = next(( + {'ip': result.ip} for result in self.last_results + if result.mac == device), {}) + return attributes def _update_info(self): """Get ARP from keenetic router."""