pax_global_header00006660000000000000000000000064145336645460014532gustar00rootroot0000000000000052 comment=f3b0cb1afb10ab7f7669fb55a5b22062c417da32 foxel-python_ndms2_client-f3b0cb1/000077500000000000000000000000001453366454600173115ustar00rootroot00000000000000foxel-python_ndms2_client-f3b0cb1/.gitignore000066400000000000000000000000441453366454600212770ustar00rootroot00000000000000/.idea/ /dist/ /build/ /*.egg-info/ foxel-python_ndms2_client-f3b0cb1/.travis.yml000066400000000000000000000030301453366454600214160ustar00rootroot00000000000000language: python python: - '3.6' - '3.7' - '3.8' - '3.9' script: pytest deploy: provider: pypi user: secure: Z1NJ4HXxuaTNSGAYXVy0kFoZm8IZ5edNQAZlEAB3ixUxMrf1WMaRm6/ZHVoC7DXeZPLjdoYczdCqTGJ35N2lAlz3JhoSiA4wBPQKoPnx7S0nqs+1HczzvjRwEO0TyUhArDLrYbT3g2f/iNrlTfS3ixOLUIfo1oGj6rrEwtOxcOvzXnUPaQ06IK4OLjLUMV7QfazMBxonnbA4Q184eppWx6ig8TU+uDSMbIpI5GI/18N2hE7X94AepkgxDPn6acVnFu4fQv/VX6dm14RfVTPea9UnWJdeVCpuPypVwiF7HgKdEK23eAYIIdZhwytNUozy6BCZcF2Q/HYEuX4xUFCdpHcMz9rNQotiuyoWGB8L0Piy++rPqDLHrWPFe1bMBMTyjb2doXACNtlllCl8bQT/BHjnjVCBxzldhHYB8X89S4h8XNPA0kPqNl9OomrJH3CEVybcie+g5muigDTvb67YttQNgKFOEnJZr0xVscnYlMGIUCOPBOiaY4XmOsfW4sWyLcdk2mKVQoFP3bpM2UnRwFhHt81JYvqV6L0aRy9sJ+U09+pMLlVS65y6C5zugdME67qWQ8LlwizwUsSwl9R/HC+MkTkfuS1De796eTMbzIVYrUebE3jANvsHoAYCQpxvsdsG+1MHFNIcKRHyramlRmaeptJYhbQhUpce5UEQ5iU= password: secure: HEScrNTTzpAN67SHgNIUH+Fo56K9EfAuSfnW9i3BIIo6Yf3qgFNPND1UeTDx+mMsYNo7RmCBmaqc8kzKt0j7mxsNMcQgHSvGUSdPd+RfMt9hfaRZcMPeZGKlh38TleRxsZoFAUCyGDap8CPq18xFWfSPQIxSyda3FdlfaE2SL4l4KRSh3kFWyR8p3UxRYpmjtt/NYxI3jcqR/+qzzW9IcTwQmeOh5WDdXy2kEfcsZxRIgfXQzHYjjxx9GwCRpv/NUiSDNHolOP6eacMbBriDCU1PSNX53kahCw8/WFuCT0jxFOJfAxmQ0xwFtkF4BZpwL2KPzeWommGcN1H+ogy6OgFiOwmZz3kZcYiARfhu7a4RFymr5D/529n+JhgMQRUxgkga1oPaMey6CBS74L6rLiZds1+Q3fgX9nqQ0u+hc6ior+AHIW6R/MPdoxLxsTfDrY4Qm6WT30kP9XnAxj80Ne61v736u/Sg4u92bJB1gQGuWI13bZtmNOE2zLJ0toX2GxzWP3Bn5Wqju5tiQNn+SqPTFzSDSNj8Y2XcoAYRTTHp/FgBYCvDM46HWWFbN9CVT76wwWBZSRtdoQyDinenJCrxG0EKv9dIikQEDd4VyIkqSwCHjAgaY2LHlznMjTCkypagYj0xQdIa+nlBafb3Y0PAuaPPAAR5Hl3d+QY+VOU= on: tags: true python: '3.6' foxel-python_ndms2_client-f3b0cb1/LICENSE000066400000000000000000000020651453366454600203210ustar00rootroot00000000000000MIT License Copyright (c) 2018 Andrey F. Kupreychik Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. foxel-python_ndms2_client-f3b0cb1/README.md000066400000000000000000000002601453366454600205660ustar00rootroot00000000000000[![Build Status](https://travis-ci.com/foxel/python_ndms2_client.svg?branch=master)](https://travis-ci.com/foxel/python_ndms2_client) ### Keenetic NDMS v2 client library ### foxel-python_ndms2_client-f3b0cb1/ndms2_client/000077500000000000000000000000001453366454600216725ustar00rootroot00000000000000foxel-python_ndms2_client-f3b0cb1/ndms2_client/__init__.py000066400000000000000000000002101453366454600237740ustar00rootroot00000000000000from .connection import Connection, ConnectionException, TelnetConnection from .client import Client, Device, RouterInfo, InterfaceInfo foxel-python_ndms2_client-f3b0cb1/ndms2_client/client.py000066400000000000000000000324041453366454600235250ustar00rootroot00000000000000import logging import re from typing import Dict, List, Tuple, Union, NamedTuple, Optional from .connection import Connection _LOGGER = logging.getLogger(__name__) _VERSION_CMD = 'show version' _ARP_CMD = 'show ip arp' _ASSOCIATIONS_CMD = 'show associations' _HOTSPOT_CMD = 'show ip hotspot' _INTERFACE_CMD = 'show interface %s' _SAVE_CONFIGURATION_CMD = 'system configuration save' _FAILSAFE_COMMIT_CONFIGURATION_CMD = 'system configuration fail-safe commit' _INTERFACES_CMD = 'show interface' _SET_INTERFACE_STATE_CMD = 'interface {interface} {state}' _INTERFACE_STATE_UP = 'up' _INTERFACE_STATE_DOWN = 'down' _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+' ) _ERROR_REGEX = re.compile(r'error\[(?P\d+)\]:\s*(?P.*)') class Device(NamedTuple): mac: str name: str ip: str interface: str class RouterInfo(NamedTuple): name: str fw_version: str fw_channel: str model: str hw_version: str manufacturer: str vendor: str region: str @classmethod def from_dict(cls, info: dict) -> "RouterInfo": return RouterInfo( name=str(info.get('description', info.get('model', 'NDMS2 Router'))), fw_version=str(info.get('title', info.get('release'))), fw_channel=str(info.get('sandbox', 'unknown')), model=str(info.get('model', info.get('hw_id'))), hw_version=str(info.get('hw_version', 'N/A')), manufacturer=str(info.get('manufacturer')), vendor=str(info.get('vendor')), region=str(info.get('region', 'N/A')), ) class InterfaceInfo(NamedTuple): name: str type: Optional[str] description: Optional[str] link: Optional[str] connected: Optional[str] state: Optional[str] mtu: Optional[int] address: Optional[str] mask: Optional[str] uptime: Optional[int] security_level: Optional[str] mac: Optional[str] ssid: Optional[str] plugged: Optional[str] @classmethod def from_dict(cls, info: dict) -> "InterfaceInfo": return InterfaceInfo( name=_str(info.get('interface-name')) or str(info['id']), type=_str(info.get('type')), description=_str(info.get('description')), link=_str(info.get('link')), connected=_str(info.get('connected')), state=_str(info.get('state')), mtu=_int(info.get('mtu')), address=_str(info.get('address')), mask=_str(info.get('mask')), uptime=_int(info.get('uptime')), security_level=_str(info.get('security-level')), mac=_str(info.get('mac')), ssid=_str(info.get('ssid')), plugged=_str(info.get('plugged')), ) class Client(object): def __init__(self, connection: Connection): self._connection = connection def get_router_info(self) -> RouterInfo: info = _parse_dict_lines(self._connection.run_command(_VERSION_CMD)) _LOGGER.debug('Raw router info: %s', str(info)) assert isinstance(info, dict), 'Router info response is not a dictionary' return RouterInfo.from_dict(info) def get_interfaces(self) -> List[InterfaceInfo]: collection = _parse_collection_lines(self._connection.run_command(_INTERFACES_CMD)) _LOGGER.debug('Raw interfaces info: %s', str(collection)) assert isinstance(collection, list), 'Interfaces info response is not a collection' return [InterfaceInfo.from_dict(info) for info in collection] def get_interface_info(self, interface_name) -> Optional[InterfaceInfo]: info = _parse_dict_lines(self._connection.run_command(_INTERFACE_CMD % interface_name)) _LOGGER.debug('Raw interface info: %s', str(info)) assert isinstance(info, dict), 'Interface info response is not a dictionary' if 'id' in info: return InterfaceInfo.from_dict(info) return None def get_devices(self, *, try_hotspot=True, include_arp=True, include_associated=True) -> List[Device]: """ Fetches a list of connected devices online :param try_hotspot: first try `ip hotspot` command. This is the most precise information on devices known to be online :param include_arp: if try_hotspot is False or no hotspot devices detected :param include_associated: :return: """ devices = [] if try_hotspot: devices = _merge_devices(devices, self.get_hotspot_devices()) if len(devices) > 0: return devices if include_arp: devices = _merge_devices(devices, self.get_arp_devices()) if include_associated: devices = _merge_devices(devices, self.get_associated_devices()) return devices def get_hotspot_devices(self) -> List[Device]: hotspot_info = self.__get_hotspot_info() return [Device( mac=info.get('mac').upper(), name=info.get('name'), ip=info.get('ip'), interface=info['interface'].get('name', '') ) for info in hotspot_info.values() if 'interface' in info and info.get('link') == 'up'] def get_arp_devices(self) -> List[Device]: lines = self._connection.run_command(_ARP_CMD) result = _parse_table_lines(lines, _ARP_REGEX) return [Device( mac=info.get('mac').upper(), name=info.get('name') or None, ip=info.get('ip'), interface=info.get('interface') ) for info in result if info.get('mac') is not None] def get_associated_devices(self): associations = _parse_dict_lines(self._connection.run_command(_ASSOCIATIONS_CMD)) items = associations.get('station', []) if not isinstance(items, list): items = [items] aps = set([info.get('ap') for info in items]) ap_to_bridge = {} for ap in aps: ap_info = _parse_dict_lines(self._connection.run_command(_INTERFACE_CMD % ap)) ap_to_bridge[ap] = ap_info.get('group') or ap_info.get('interface-name') # try enriching the results with hotspot additional info hotspot_info = self.__get_hotspot_info() devices = [] for info in items: mac = info.get('mac') if mac is not None and info.get('authenticated') in ['1', 'yes']: host_info = hotspot_info.get(mac) devices.append(Device( mac=mac.upper(), name=host_info.get('name') if host_info else None, ip=host_info.get('ip') if host_info else None, interface=ap_to_bridge.get(info.get('ap'), info.get('ap')) )) return devices def save_configuration(self): _check_command_result(self._connection.run_command(_SAVE_CONFIGURATION_CMD)) def commit_failsafe_configuration(self): _check_command_result(self._connection.run_command(_FAILSAFE_COMMIT_CONFIGURATION_CMD)) def set_interface_state(self, interface_id: str, is_up: bool): state_str = _INTERFACE_STATE_UP if is_up else _INTERFACE_STATE_DOWN _check_command_result(self._connection.run_command(_SET_INTERFACE_STATE_CMD.format( interface=interface_id, state=state_str ))) # hotspot info is only available in newest firmware (2.09 and up) and in router mode # however missing command error will lead to empty dict returned def __get_hotspot_info(self): info = _parse_dict_lines(self._connection.run_command(_HOTSPOT_CMD)) items = info.get('host', []) if not isinstance(items, list): items = [items] return {item.get('mac'): item for item in items} def _str(value: Optional[any]) -> Optional[str]: if value is None: return None return str(value) def _int(value: Optional[any]) -> Optional[int]: if value is None: return None return int(value) def _merge_devices(*lists: List[Device]) -> List[Device]: res = {} for l in lists: for dev in l: key = (dev.interface, dev.mac) if key in res: old_dev = res.get(key) res[key] = Device( mac=old_dev.mac, name=old_dev.name or dev.name, ip=old_dev.ip or dev.ip, interface=old_dev.interface ) else: res[key] = dev return list(res.values()) def _parse_table_lines(lines: List[str], regex: re) -> List[Dict[str, any]]: """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 def _fix_continuation_lines(lines: List[str]) -> List[str]: indent = 0 continuation_possible = False fixed_lines = [] # type: List[str] for line in lines: if len(line.strip()) == 0: continue if continuation_possible and len(line[:indent].strip()) == 0: prev_line = fixed_lines.pop() line = prev_line.rstrip() + line[(indent + 1):].lstrip() else: assert ':' in line, 'Found a line with no colon when continuation is not possible: ' + line colon_pos = line.index(':') comma_pos = line.index(',') if ',' in line[:colon_pos] else None indent = comma_pos if comma_pos is not None else colon_pos continuation_possible = len(line[(indent + 1):].strip()) > 0 fixed_lines.append(line) return fixed_lines def _parse_dict_lines(lines: List[str]) -> Dict[str, any]: response = {} indent = 0 stack = [(None, indent, response)] # type: List[Tuple[str, int, Union[str, dict]]] stack_level = 0 for line in _fix_continuation_lines(lines): if len(line.strip()) == 0: continue _LOGGER.debug(line) # exploding the line colon_pos = line.index(':') comma_pos = line.index(',') if ',' in line[:colon_pos] else None key = line[:colon_pos].strip() value = line[(colon_pos + 1):].strip() new_indent = comma_pos if comma_pos is not None else colon_pos # assuming line is like 'mac-access, id = Bridge0: ...' if comma_pos is not None: key = line[:comma_pos].strip() value = {key: value} if value != '' else {} args = line[comma_pos + 1:colon_pos].split(',') for arg in args: sub_key, sub_value = [p.strip() for p in arg.split('=', 1)] value[sub_key] = sub_value # up and down the stack if new_indent > indent: # new line is a sub-value of parent stack_level += 1 indent = new_indent stack.append(None) else: while new_indent < indent and len(stack) > 0: # getting one level up stack_level -= 1 stack.pop() _, indent, _ = stack[stack_level] if stack_level < 1: break assert indent == new_indent, 'Irregular indentation detected' stack[stack_level] = key, indent, value # current containing object obj_key, obj_indent, obj = stack[stack_level - 1] # we are the first child of the containing object if not isinstance(obj, dict): # need to convert it from empty string to empty object assert obj == '', 'Unexpected nested object format' _, _, parent_obj = stack[stack_level - 2] obj = {} # containing object might be in a list also if isinstance(parent_obj[obj_key], list): parent_obj[obj_key].pop() parent_obj[obj_key].append(obj) else: parent_obj[obj_key] = obj stack[stack_level - 1] = obj_key, obj_indent, obj # current key is already in object means there should be an array of values if key in obj: if not isinstance(obj[key], list): obj[key] = [obj[key]] obj[key].append(value) else: obj[key] = value return response def _parse_collection_lines(lines: List[str]) -> List[Dict[str, any]]: _HEADER_REGEXP = re.compile(r'^(\w+),\s*name\s*=\s*\"([^"]+)\"') result = [] item_lines = [] # type: List[str] for line in lines: if len(line.strip()) == 0: continue match = _HEADER_REGEXP.match(line) if match: if len(item_lines) > 0: result.append(_parse_dict_lines(item_lines)) item_lines = [] else: item_lines.append(line) if len(item_lines) > 0: result.append(_parse_dict_lines(item_lines)) return result def _check_command_result(lines: List[str]) -> List[str]: for line in lines: match = _ERROR_REGEX.search(line) if match: raise Exception('Command failed with error {}: {}'.format(match.group('code'), match.group('message'))) return lines foxel-python_ndms2_client-f3b0cb1/ndms2_client/connection.py000066400000000000000000000117711453366454600244120ustar00rootroot00000000000000import logging import re from telnetlib import Telnet from typing import List, Union, Pattern, Match _LOGGER = logging.getLogger(__name__) class ConnectionException(Exception): pass class Connection(object): @property def connected(self) -> bool: raise NotImplementedError("Should have implemented this") def connect(self): raise NotImplementedError("Should have implemented this") def disconnect(self): raise NotImplementedError("Should have implemented this") def run_command(self, command: str) -> List[str]: raise NotImplementedError("Should have implemented this") class TelnetConnection(Connection): """Maintains a Telnet connection to a router.""" def __init__(self, host: str, port: int, username: str, password: str, *, timeout: int = 30): """Initialize the Telnet connection properties.""" self._telnet = None # type: Telnet self._host = host self._port = port self._username = username self._password = password self._timeout = timeout self._current_prompt_string = None # type: bytes @property def connected(self): return self._telnet is not None def run_command(self, command, *, group_change_expected=False) -> List[str]: """Run a command through a Telnet connection. Connect to the Telnet server if not currently connected, otherwise use the existing connection. """ if not self._telnet: self.connect() try: self._telnet.read_very_eager() # this is here to flush the read buffer self._telnet.write('{}\n'.format(command).encode('UTF-8')) response = self._read_response(group_change_expected) except Exception as e: message = "Error executing command: %s" % str(e) _LOGGER.error(message) self.disconnect() raise ConnectionException(message) from None else: _LOGGER.debug('Command %s: %s', command, '\n'.join(response)) return response def connect(self): """Connect to the Telnet server.""" try: self._telnet = Telnet() self._telnet.set_option_negotiation_callback(TelnetConnection.__negotiate_naws) self._telnet.open(self._host, self._port, self._timeout) self._read_until(b'Login: ') self._telnet.write((self._username + '\n').encode('UTF-8')) self._read_until(b'Password: ') self._telnet.write((self._password + '\n').encode('UTF-8')) self._read_response(True) self._set_max_window_size() except Exception as e: message = "Error connecting to telnet server: %s" % str(e) _LOGGER.error(message) self._telnet = None raise ConnectionException(message) from None def disconnect(self): """Disconnect the current Telnet connection.""" try: if self._telnet: self._telnet.write(b'exit\n') except Exception as e: _LOGGER.error("Telnet error on exit: %s" % str(e)) pass self._telnet = None def _read_response(self, detect_new_prompt_string=False) -> List[str]: needle = re.compile(br'\n\(\w+[-\w]+\)>') if detect_new_prompt_string else self._current_prompt_string (match, text) = self._read_until(needle) if detect_new_prompt_string: self._current_prompt_string = match[0] return text.decode('UTF-8').split('\n')[1:-1] def _read_until(self, needle: Union[bytes, Pattern]) -> (Match, bytes): matcher = needle if isinstance(needle, Pattern) else re.escape(needle) (i, match, text) = self._telnet.expect([matcher], self._timeout) assert i == 0, "No expected response from server" return match, text # noinspection PyProtectedMember def _set_max_window_size(self): """ --> inform the Telnet server of the window width and height. see __negotiate_naws """ from telnetlib import IAC, NAWS, SB, SE import struct width = struct.pack('H', 65000) height = struct.pack('H', 5000) self._telnet.get_socket().sendall(IAC + SB + NAWS + width + height + IAC + SE) # noinspection PyProtectedMember @staticmethod def __negotiate_naws(tsocket, command, option): """ --> inform the Telnet server we'll be using Window Size Option. Refer to https://www.ietf.org/rfc/rfc1073.txt :param tsocket: telnet socket object :param command: telnet Command :param option: telnet option :return: None """ from telnetlib import DO, DONT, IAC, WILL, WONT, NAWS if option == NAWS: tsocket.sendall(IAC + WILL + NAWS) # -- below code taken from telnetlib elif command in (DO, DONT): tsocket.sendall(IAC + WONT + option) elif command in (WILL, WONT): tsocket.sendall(IAC + DONT + option) foxel-python_ndms2_client-f3b0cb1/setup.py000066400000000000000000000014441453366454600210260ustar00rootroot00000000000000import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="ndms2_client", version="0.1.3", author="Andrey F. Kupreychik", author_email="foxel@quickfox.ru", description="Keenetic NDMS 2.x, 3.x, and 4.x client", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/foxel/python_ndms2_client", packages=setuptools.find_packages(exclude=['tests']), classifiers=( "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ), ) foxel-python_ndms2_client-f3b0cb1/tests/000077500000000000000000000000001453366454600204535ustar00rootroot00000000000000foxel-python_ndms2_client-f3b0cb1/tests/test_check_command_result.py000066400000000000000000000024221453366454600262350ustar00rootroot00000000000000import os import sys from typing import Tuple, List import pytest sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # noinspection PyProtectedMember def test_check_command_result_positive(positive_results: List[str]) -> None: from ndms2_client.client import _check_command_result assert _check_command_result(positive_results) is positive_results def test_check_command_result_error(error_results: List[str]) -> None: from ndms2_client.client import _check_command_result with pytest.raises(Exception): _check_command_result(error_results) @pytest.fixture(params=range(2)) def positive_results(request) -> List[str]: data = [ ['Network::Interface::Base: "WifiMaster0/AccessPoint1": interface is up.'], ['Core::System::StartupConfig: Saving (cli).'] ] return data[request.param] @pytest.fixture(params=range(3)) def error_results(request) -> List[str]: data = [ ['Command::Base error[7405602]: argument parse error.'], ['Core::Configurator error[1179653]: interface down: execute denied [cli].'], ['Network::Interface::Base error[6553609]: unable to find GuestWiF as "Network::Interface::Base".'] ] return data[request.param] foxel-python_ndms2_client-f3b0cb1/tests/test_compile.py000077500000000000000000000005001453366454600235120ustar00rootroot00000000000000#!/usr/bin/python3 import os import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # this is a dummy test showing that code compiles def test_compile(): from ndms2_client import TelnetConnection, Client assert TelnetConnection is not None assert Client is not None foxel-python_ndms2_client-f3b0cb1/tests/test_parse_dict_lines.py000066400000000000000000000302701453366454600253750ustar00rootroot00000000000000import os import sys from typing import Tuple import pytest sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # noinspection PyProtectedMember def test_parse_dict_lines(dict_text): from ndms2_client.client import _parse_dict_lines _parse_dict_lines(dict_text.split('\n')) def test_hotspot_data(hostpot_sample: Tuple[str, int]): from ndms2_client.client import _parse_dict_lines sample, expected_hosts = hostpot_sample parsed = _parse_dict_lines(sample.split('\n')) print(parsed['host']) if expected_hosts > 1: assert isinstance(parsed['host'], list) assert len(parsed['host']) == expected_hosts else: assert isinstance(parsed['host'], dict) @pytest.fixture(params=range(4)) def dict_text(request): data = [''' station: mac: 60:ff:ff:ff:ff:ff ap: WifiMaster0/AccessPoint0 authenticated: yes txrate: 65 rxrate: 54 uptime: 3558 txbytes: 56977 rxbytes: 21754 ht: 20 mode: 11n gi: 800 rssi: -46 mcs: 7 txss: 1 station: mac: b8:ff:ff:ff:ff:ff ap: WifiMaster0/AccessPoint0 authenticated: yes txrate: 65 rxrate: 24 uptime: 3557 txbytes: 9261871 rxbytes: 196082452 ht: 20 mode: 11n gi: 800 rssi: -54 mcs: 7 txss: 1 station: mac: 5c:ff:ff:ff:ff:ff ap: WifiMaster0/AccessPoint0 authenticated: yes txrate: 65 rxrate: 36 uptime: 3555 txbytes: 74073 rxbytes: 21007 ht: 20 mode: 11n gi: 800 rssi: -54 mcs: 7 txss: 1 station: mac: 60:ff:ff:ff:ff:ff ap: WifiMaster0/AccessPoint0 authenticated: yes txrate: 65 rxrate: 6 uptime: 3554 txbytes: 92221 rxbytes: 21475 ht: 20 mode: 11n gi: 800 rssi: -63 mcs: 7 txss: 1 station: mac: 48:ff:ff:ff:ff:ff ap: WifiMaster0/AccessPoint0 authenticated: yes txrate: 65 rxrate: 65 uptime: 3423 txbytes: 697534 rxbytes: 732250 ht: 20 mode: 11n gi: 800 rssi: -20 mcs: 7 txss: 1 station: mac: a4:ff:ff:ff:ff:ff ap: WifiMaster1/AccessPoint0 authenticated: yes txrate: 200 rxrate: 200 uptime: 1749 txbytes: 1012970 rxbytes: 1183758 ht: 40 mode: 11ac gi: 400 rssi: -63 mcs: 9 txss: 1 station: mac: a4:ff:ff:ff:ff:ff ap: WifiMaster1/AccessPoint0 authenticated: yes txrate: 200 rxrate: 180 uptime: 1741 txbytes: 586118 rxbytes: 658847 ht: 40 mode: 11ac gi: 400 rssi: -71 mcs: 9 txss: 1 ''', ''' buttons: button, name = RESET: is_switch: no position: 2 position_count: 2 clicks: 0 elapsed: 0 hold_delay: 10000 button, name = WLAN: is_switch: no position: 2 position_count: 2 clicks: 0 elapsed: 0 hold_delay: 3000 button, name = FN1: is_switch: no position: 2 position_count: 2 clicks: 0 elapsed: 0 hold_delay: 3000 button, name = FN2: is_switch: no position: 2 position_count: 2 clicks: 0 elapsed: 0 hold_delay: 3000 ''', ''' id: WifiMaster0/AccessPoint0 index: 0 type: AccessPoint description: Wi-Fi access point interface-name: AccessPoint link: up connected: yes state: up mtu: 1500 tx-queue: 1000 group: Home usedby: Bridge0 mac: 00:ff:00:00:00:00 auth-type: none ssid: home encryption: wpa2,wpa3 ''', ''' release: v2.08(AAUR.4)C2 arch: mips ndm: exact: 0-df82a04 cdate: 16 Oct 2017 bsp: exact: 0-02ec1b2 cdate: 16 Oct 2017 ndw: version: 4.2.0.166 features: wifi_button,single_usb_port,nopack, flexible_menu,emulate_firmware_progress components: ddns,dot1x,interface-extras,kabinet, miniupnpd,nathelper-ftp,nathelper-h323,nathelper-pptp, nathelper-rtsp,nathelper-sip,ppe,trafficcontrol,usblte, usbserial,base,cloud,cloudcontrol,components,config-ap, config-client,config-repeater,corewireless,dhcpd, easyconfig,igmp,ipsec,l2tp,madwimax,pingcheck,ppp,pptp, pppoe,skydns,usb,usbdsl,usbmodem,usbnet,ydns,vpnserver, base-l10n,sysmode,easyconfig-3.2,modems,theme-ZyXEL-Intl, base-theme,ispdb,base-ZyXEL-Intl manufacturer: ZyXEL vendor: ZyXEL series: Keenetic series model: Keenetic hw_version: 12131000-G hw_id: kn_rg device: Keenetic 4G III class: Internet Center '''] return data[request.param] @pytest.fixture(params=range(2)) def hostpot_sample(request) -> Tuple[str, int]: samples = [ (''' host: mac: dc:09:xx:xx:xx:xx via: dc:09:xx:xx:xx:xx ip: 192.xx.xx.xx hostname: xxxxxxxxxxxxx name: xxxxxxxxxxxxxxxxxx interface: id: Bridge0 name: Home description: Home VLAN expires: 181613 registered: yes access: permit schedule: active: yes rxbytes: 34442317 txbytes: 2176340 uptime: 59 first-seen: 157428 last-seen: 7 link: up auto-negotiation: yes speed: 1000 duplex: yes ever-seen: yes traffic-shape: rx: 0 tx: 0 mode: mac schedule: host: mac: 10:40:xx:xx:xx:xx via: 10:40:xx:xx:xx:xx ip: 192.xx.xx.xx hostname: xxxxxxxxxxxxx name: xxxxxxxxxxxxxx interface: id: Bridge0 name: Home description: Home VLAN expires: 0 registered: yes access: permit schedule: active: no rxbytes: 0 txbytes: 0 uptime: 0 link: down ever-seen: yes traffic-shape: rx: 0 tx: 0 mode: mac schedule: ''', 2), (''' host: mac: 74:ff:ff:ff:ff:ff via: 74:ff:ff:ff:ff:ff ip: 250:250:250:218 hostname: foxel-desktop name: foxel-desktop interface: id: Bridge0 name: Home description: Home network expires: 0 registered: yes access: permit schedule: active: yes rxbytes: 3664009359 txbytes: 280968424 uptime: 354617 first-seen: 656249 last-seen: 1 link: up auto-negotiation: yes speed: 1000 duplex: yes traffic-shape: rx: 0 tx: 0 mode: none schedule: mac-access, id = Bridge0: deny host: mac: 10:ff:ff:ff:ff:ff via: 10:ff:ff:ff:ff:ff ip: 250:250:250:200 hostname: foxhome-server name: foxhome, server interface: id: Bridge0 name: Home description: Home network expires: 0 registered: yes access: permit schedule: active: yes rxbytes: 2077239992 txbytes: 665641019 uptime: 614018 first-seen: 656255 last-seen: 1 link: up auto-negotiation: yes speed: 1000 duplex: yes traffic-shape: rx: 0 tx: 0 mode: none schedule: mac-access, id = Bridge0: deny host: mac: a4:ff:ff:ff:ff:ff via: a4:ff:ff:ff:ff:ff ip: 250:250:250:224 hostname: Chromecast-Audio name: foxcast-bedroom description: This is very long description with newline and colon: foo interface: id: Bridge0 name: Home description: Home network expires: 670979 registered: yes access: permit schedule: active: yes rxbytes: 48388740 txbytes: 10987076 uptime: 23108 first-seen: 656255 last-seen: 1 link: up auto-negotiation: yes speed: 1000 duplex: yes traffic-shape: rx: 0 tx: 0 mode: none schedule: mac-access, id = Bridge0: deny host: mac: a4:ff:ff:ff:ff:ff via: a4:ff:ff:ff:ff:ff ip: 250:250:250:220 hostname: Chromecast-Audio name: foxcast-kitchen interface: id: Bridge0 name: Home description: Home network expires: 679721 registered: yes access: permit schedule: active: yes rxbytes: 531016115 txbytes: 27461293 uptime: 23482 first-seen: 656238 last-seen: 1 link: up ssid: FOXHOME-5G ap: WifiMaster1/AccessPoint0 authenticated: yes txrate: 200 ht: 40 mode: 11ac gi: 400 rssi: -57 mcs: 9 txss: 1 traffic-shape: rx: 0 tx: 0 mode: none schedule: mac-access, id = Bridge0: permit ''', 4) ] return samples[request.param]