diff --git a/aprsd/client/__init__.py b/aprsd/client/__init__.py index 28dd803..76c328e 100644 --- a/aprsd/client/__init__.py +++ b/aprsd/client/__init__.py @@ -1,13 +1,5 @@ -from aprsd.client import aprsis, factory, fake, kiss - - -TRANSPORT_APRSIS = "aprsis" -TRANSPORT_TCPKISS = "tcpkiss" -TRANSPORT_SERIALKISS = "serialkiss" -TRANSPORT_FAKE = "fake" - - -client_factory = factory.ClientFactory() -client_factory.register(aprsis.APRSISClient) -client_factory.register(kiss.KISSClient) -client_factory.register(fake.APRSDFakeClient) +# define the client transports here +TRANSPORT_APRSIS = 'aprsis' +TRANSPORT_TCPKISS = 'tcpkiss' +TRANSPORT_SERIALKISS = 'serialkiss' +TRANSPORT_FAKE = 'fake' diff --git a/aprsd/client/aprsis.py b/aprsd/client/aprsis.py deleted file mode 100644 index e2ff163..0000000 --- a/aprsd/client/aprsis.py +++ /dev/null @@ -1,183 +0,0 @@ -import datetime -import logging -import time - -import timeago -from aprslib.exceptions import LoginError -from loguru import logger -from oslo_config import cfg - -from aprsd import client, exception -from aprsd.client import base -from aprsd.client.drivers import aprsis -from aprsd.packets import core - -CONF = cfg.CONF -LOG = logging.getLogger("APRSD") -LOGU = logger - - -class APRSISClient(base.APRSClient): - _client = None - _checks = False - - def __init__(self): - max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0} - self.max_delta = datetime.timedelta(**max_timeout) - - def stats(self, serializable=False) -> dict: - stats = {} - if self.is_configured(): - if self._client: - keepalive = self._client.aprsd_keepalive - server_string = self._client.server_string - if serializable: - keepalive = keepalive.isoformat() - else: - keepalive = "None" - server_string = "None" - stats = { - "connected": self.is_connected, - "filter": self.filter, - "login_status": self.login_status, - "connection_keepalive": keepalive, - "server_string": server_string, - "transport": self.transport(), - } - - return stats - - def keepalive_check(self): - # Don't check the first time through. - if not self.is_alive() and self._checks: - LOG.warning("Resetting client. It's not alive.") - self.reset() - self._checks = True - - def keepalive_log(self): - if ka := self._client.aprsd_keepalive: - keepalive = timeago.format(ka) - else: - keepalive = "N/A" - LOGU.opt(colors=True).info(f"Client keepalive {keepalive}") - - @staticmethod - def is_enabled(): - # Defaults to True if the enabled flag is non existent - try: - return CONF.aprs_network.enabled - except KeyError: - return False - - @staticmethod - def is_configured(): - if APRSISClient.is_enabled(): - # Ensure that the config vars are correctly set - if not CONF.aprs_network.login: - LOG.error("Config aprs_network.login not set.") - raise exception.MissingConfigOptionException( - "aprs_network.login is not set.", - ) - if not CONF.aprs_network.password: - LOG.error("Config aprs_network.password not set.") - raise exception.MissingConfigOptionException( - "aprs_network.password is not set.", - ) - if not CONF.aprs_network.host: - LOG.error("Config aprs_network.host not set.") - raise exception.MissingConfigOptionException( - "aprs_network.host is not set.", - ) - - return True - return True - - def _is_stale_connection(self): - delta = datetime.datetime.now() - self._client.aprsd_keepalive - if delta > self.max_delta: - LOG.error(f"Connection is stale, last heard {delta} ago.") - return True - return False - - def is_alive(self): - if not self._client: - LOG.warning(f"APRS_CLIENT {self._client} alive? NO!!!") - return False - return self._client.is_alive() and not self._is_stale_connection() - - def close(self): - if self._client: - self._client.stop() - self._client.close() - - @staticmethod - def transport(): - return client.TRANSPORT_APRSIS - - def decode_packet(self, *args, **kwargs): - """APRS lib already decodes this.""" - return core.factory(args[0]) - - def setup_connection(self): - user = CONF.aprs_network.login - password = CONF.aprs_network.password - host = CONF.aprs_network.host - port = CONF.aprs_network.port - self.connected = False - backoff = 1 - aprs_client = None - retries = 3 - retry_count = 0 - while not self.connected: - retry_count += 1 - if retry_count >= retries: - break - try: - LOG.info( - f"Creating aprslib client({host}:{port}) and logging in {user}." - ) - aprs_client = aprsis.Aprsdis( - user, passwd=password, host=host, port=port - ) - # Force the log to be the same - aprs_client.logger = LOG - aprs_client.connect() - self.connected = self.login_status["success"] = True - self.login_status["message"] = aprs_client.server_string - backoff = 1 - except LoginError as e: - LOG.error(f"Failed to login to APRS-IS Server '{e}'") - self.connected = self.login_status["success"] = False - self.login_status["message"] = e.message - LOG.error(e.message) - time.sleep(backoff) - except Exception as e: - LOG.error(f"Unable to connect to APRS-IS server. '{e}' ") - self.connected = self.login_status["success"] = False - self.login_status["message"] = e.message - time.sleep(backoff) - # Don't allow the backoff to go to inifinity. - if backoff > 5: - backoff = 5 - else: - backoff += 1 - continue - self._client = aprs_client - return aprs_client - - def consumer(self, callback, blocking=False, immortal=False, raw=False): - if self._client: - try: - self._client.consumer( - callback, - blocking=blocking, - immortal=immortal, - raw=raw, - ) - except Exception as e: - LOG.error(e) - LOG.info(e.__cause__) - raise e - else: - LOG.warning("client is None, might be resetting.") - self.connected = False diff --git a/aprsd/client/base.py b/aprsd/client/base.py deleted file mode 100644 index d4d9b5a..0000000 --- a/aprsd/client/base.py +++ /dev/null @@ -1,156 +0,0 @@ -import abc -import logging -import threading - -import wrapt -from oslo_config import cfg - -from aprsd.packets import core -from aprsd.utils import keepalive_collector - -CONF = cfg.CONF -LOG = logging.getLogger('APRSD') - - -class APRSClient: - """Singleton client class that constructs the aprslib connection.""" - - _instance = None - _client = None - - connected = False - login_status = { - 'success': False, - 'message': None, - } - filter = None - lock = threading.Lock() - - def __new__(cls, *args, **kwargs): - """This magic turns this into a singleton.""" - if cls._instance is None: - cls._instance = super().__new__(cls) - keepalive_collector.KeepAliveCollector().register(cls) - # Put any initialization here. - cls._instance._create_client() - return cls._instance - - @abc.abstractmethod - def stats(self) -> dict: - """Return statistics about the client connection. - - Returns: - dict: Statistics about the connection and packet handling - """ - - @abc.abstractmethod - def keepalive_check(self) -> None: - """Called during keepalive run to check status.""" - ... - - @abc.abstractmethod - def keepalive_log(self) -> None: - """Log any keepalive information.""" - ... - - @property - def is_connected(self): - return self.connected - - @property - def login_success(self): - return self.login_status.get('success', False) - - @property - def login_failure(self): - return self.login_status['message'] - - def set_filter(self, filter): - self.filter = filter - if self._client: - self._client.set_filter(filter) - - def get_filter(self): - return self.filter - - @property - def client(self): - if not self._client: - self._create_client() - return self._client - - def _create_client(self): - try: - self._client = self.setup_connection() - if self.filter: - LOG.info('Creating APRS client filter') - self._client.set_filter(self.filter) - except Exception as e: - LOG.error(f'Failed to create APRS client: {e}') - self._client = None - raise - - def stop(self): - if self._client: - LOG.info('Stopping client connection.') - self._client.stop() - - def send(self, packet: core.Packet) -> None: - """Send a packet to the network. - - Args: - packet: The APRS packet to send - """ - self.client.send(packet) - - @wrapt.synchronized(lock) - def reset(self) -> None: - """Call this to force a rebuild/reconnect.""" - LOG.info('Resetting client connection.') - if self._client: - self._client.close() - del self._client - self._create_client() - else: - LOG.warning('Client not initialized, nothing to reset.') - - # Recreate the client - LOG.info(f'Creating new client {self.client}') - - @abc.abstractmethod - def setup_connection(self): - """Initialize and return the underlying APRS connection. - - Returns: - object: The initialized connection object - """ - - @staticmethod - @abc.abstractmethod - def is_enabled(): - pass - - @staticmethod - @abc.abstractmethod - def transport(): - pass - - @abc.abstractmethod - def decode_packet(self, *args, **kwargs): - """Decode raw APRS packet data into a Packet object. - - Returns: - Packet: Decoded APRS packet - """ - - @abc.abstractmethod - def consumer(self, callback, blocking=False, immortal=False, raw=False): - pass - - @abc.abstractmethod - def is_alive(self): - pass - - @abc.abstractmethod - def close(self): - pass diff --git a/aprsd/client/client.py b/aprsd/client/client.py new file mode 100644 index 0000000..942ad5d --- /dev/null +++ b/aprsd/client/client.py @@ -0,0 +1,141 @@ +import logging +import threading +from typing import Callable + +import timeago +import wrapt +from loguru import logger +from oslo_config import cfg + +from aprsd.client import drivers # noqa - ensure drivers are registered +from aprsd.client.drivers.registry import DriverRegistry +from aprsd.packets import core +from aprsd.utils import keepalive_collector + +CONF = cfg.CONF +LOG = logging.getLogger('APRSD') +LOGU = logger + + +class APRSDClient: + """APRSD client class. + + This is a singleton class that provides a single instance of the APRSD client. + It is responsible for connecting to the appropriate APRSD client driver based on + the configuration. + + """ + + _instance = None + driver = None + lock = threading.Lock() + filter = None + + def __new__(cls, *args, **kwargs): + """This magic turns this into a singleton.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + keepalive_collector.KeepAliveCollector().register(cls) + return cls._instance + + def __init__(self): + self.connected = False + self.login_status = { + 'success': False, + 'message': None, + } + if not self.driver: + self.driver = DriverRegistry().get_driver() + self.driver.setup_connection() + + def stats(self, serializable=False) -> dict: + stats = {} + if self.driver: + stats = self.driver.stats(serializable=serializable) + return stats + + @property + def is_enabled(self): + if not self.driver: + return False + return self.driver.is_enabled() + + @property + def is_configured(self): + if not self.driver: + return False + return self.driver.is_configured() + + # @property + # def is_connected(self): + # if not self.driver: + # return False + # return self.driver.is_connected() + + @property + def login_success(self): + if not self.driver: + return False + return self.driver.login_success + + @property + def login_failure(self): + if not self.driver: + return None + return self.driver.login_failure + + def set_filter(self, filter): + self.filter = filter + if not self.driver: + return + self.driver.set_filter(filter) + + def get_filter(self): + if not self.driver: + return None + return self.driver.filter + + def is_alive(self): + return self.driver.is_alive() + + def close(self): + if not self.driver: + return + self.driver.close() + + @wrapt.synchronized(lock) + def reset(self): + """Call this to force a rebuild/reconnect.""" + LOG.info('Resetting client connection.') + if self.driver: + self.driver.close() + self.driver.setup_connection() + if self.filter: + self.driver.set_filter(self.filter) + else: + LOG.warning('Client not initialized, nothing to reset.') + + def send(self, packet: core.Packet) -> bool: + return self.driver.send(packet) + + # For the keepalive collector + def keepalive_check(self): + # Don't check the first time through. + if not self.driver.is_alive and self._checks: + LOG.warning("Resetting client. It's not alive.") + self.reset() + self._checks = True + + # For the keepalive collector + def keepalive_log(self): + if ka := self.driver.keepalive: + keepalive = timeago.format(ka) + else: + keepalive = 'N/A' + LOGU.opt(colors=True).info(f'Client keepalive {keepalive}') + + def consumer(self, callback: Callable, raw: bool = False): + return self.driver.consumer(callback=callback, raw=raw) + + def decode_packet(self, *args, **kwargs) -> core.Packet: + return self.driver.decode_packet(*args, **kwargs) diff --git a/aprsd/client/drivers/__init__.py b/aprsd/client/drivers/__init__.py index e69de29..8da8326 100644 --- a/aprsd/client/drivers/__init__.py +++ b/aprsd/client/drivers/__init__.py @@ -0,0 +1,10 @@ +# All client drivers must be registered here +from aprsd.client.drivers.aprsis import APRSISDriver +from aprsd.client.drivers.fake import APRSDFakeDriver +from aprsd.client.drivers.registry import DriverRegistry +from aprsd.client.drivers.tcpkiss import TCPKISSDriver + +driver_registry = DriverRegistry() +driver_registry.register(APRSDFakeDriver) +driver_registry.register(APRSISDriver) +driver_registry.register(TCPKISSDriver) diff --git a/aprsd/client/drivers/aprsis.py b/aprsd/client/drivers/aprsis.py index 95a403d..c7013d6 100644 --- a/aprsd/client/drivers/aprsis.py +++ b/aprsd/client/drivers/aprsis.py @@ -1,286 +1,205 @@ import datetime import logging -import select -import socket -import threading +import time +from typing import Callable -import aprslib -import wrapt -from aprslib import is_py3 -from aprslib.exceptions import ( - ConnectionDrop, - ConnectionError, - GenericError, - LoginError, - ParseError, - UnknownFormat, -) +from aprslib.exceptions import LoginError +from loguru import logger +from oslo_config import cfg -import aprsd +from aprsd import client, exception +from aprsd.client.drivers.lib.aprslib import APRSLibClient from aprsd.packets import core +CONF = cfg.CONF LOG = logging.getLogger('APRSD') +LOGU = logger -class Aprsdis(aprslib.IS): - """Extend the aprslib class so we can exit properly.""" +# class APRSISDriver(metaclass=trace.TraceWrapperMetaclass): +class APRSISDriver: + """This is the APRS-IS driver for the APRSD client. - # flag to tell us to stop - thread_stop = False + This driver uses our modified aprslib.IS class to connect to the APRS-IS server. - # date for last time we heard from the server - aprsd_keepalive = datetime.datetime.now() + """ - # Which server we are connected to? - server_string = 'None' + _client = None + _checks = False - # timeout in seconds - select_timeout = 1 - lock = threading.Lock() + def __init__(self): + max_timeout = {'hours': 0.0, 'minutes': 2, 'seconds': 0} + self.max_delta = datetime.timedelta(**max_timeout) + self.login_status = { + 'success': False, + 'message': None, + } - def stop(self): - self.thread_stop = True - LOG.warning('Shutdown Aprsdis client.') + @staticmethod + def is_enabled(): + # Defaults to True if the enabled flag is non existent + try: + return CONF.aprs_network.enabled + except KeyError: + return False + + @staticmethod + def is_configured(): + if APRSISDriver.is_enabled(): + # Ensure that the config vars are correctly set + if not CONF.aprs_network.login: + LOG.error('Config aprs_network.login not set.') + raise exception.MissingConfigOptionException( + 'aprs_network.login is not set.', + ) + if not CONF.aprs_network.password: + LOG.error('Config aprs_network.password not set.') + raise exception.MissingConfigOptionException( + 'aprs_network.password is not set.', + ) + if not CONF.aprs_network.host: + LOG.error('Config aprs_network.host not set.') + raise exception.MissingConfigOptionException( + 'aprs_network.host is not set.', + ) + + return True + return True + + @property + def is_alive(self): + if not self._client: + LOG.warning(f'APRS_CLIENT {self._client} alive? NO!!!') + return False + return self._client.is_alive() and not self._is_stale_connection() def close(self): - LOG.warning('Closing Aprsdis client.') - super().close() + if self._client: + self._client.stop() + self._client.close() - @wrapt.synchronized(lock) - def send(self, packet: core.Packet): - """Send an APRS Message object.""" - self.sendall(packet.raw) + def send(self, packet: core.Packet) -> bool: + return self._client.send(packet) - def is_alive(self): - """If the connection is alive or not.""" - return self._connected - - def _connect(self): - """ - Attemps connection to the server - """ - - self.logger.info( - 'Attempting connection to %s:%s', self.server[0], self.server[1] - ) - - try: - self._open_socket() - - peer = self.sock.getpeername() - - self.logger.info('Connected to %s', str(peer)) - - # 5 second timeout to receive server banner - self.sock.setblocking(1) - self.sock.settimeout(5) - - self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - # MACOS doesn't have TCP_KEEPIDLE - if hasattr(socket, 'TCP_KEEPIDLE'): - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1) - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3) - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) - - banner = self.sock.recv(512) - if is_py3: - banner = banner.decode('latin-1') - - if banner[0] == '#': - self.logger.debug('Banner: %s', banner.rstrip()) - else: - raise ConnectionError('invalid banner from server') - - except ConnectionError as e: - self.logger.error(str(e)) - self.close() - raise - except (socket.error, socket.timeout) as e: - self.close() - - self.logger.error('Socket error: %s' % str(e)) - if str(e) == 'timed out': - raise ConnectionError('no banner from server') from e - else: - raise ConnectionError(e) from e - - self._connected = True - - def _socket_readlines(self, blocking=False): - """ - Generator for complete lines, received from the server - """ - try: - self.sock.setblocking(0) - except OSError as e: - self.logger.error(f'socket error when setblocking(0): {str(e)}') - raise aprslib.ConnectionDrop('connection dropped') from e - - while not self.thread_stop: - short_buf = b'' - newline = b'\r\n' - - # set a select timeout, so we get a chance to exit - # when user hits CTRL-C - readable, writable, exceptional = select.select( - [self.sock], - [], - [], - self.select_timeout, - ) - if not readable: - if not blocking: - break - else: - continue - - try: - short_buf = self.sock.recv(4096) - - # sock.recv returns empty if the connection drops - if not short_buf: - if not blocking: - # We could just not be blocking, so empty is expected - continue - else: - self.logger.error('socket.recv(): returned empty') - raise aprslib.ConnectionDrop('connection dropped') - except OSError as e: - # self.logger.error("socket error on recv(): %s" % str(e)) - if 'Resource temporarily unavailable' in str(e): - if not blocking: - if len(self.buf) == 0: - break - - self.buf += short_buf - - while newline in self.buf: - line, self.buf = self.buf.split(newline, 1) - - yield line - - def _send_login(self): - """ - Sends login string to server - """ - login_str = 'user {0} pass {1} vers Python-APRSD {3}{2}\r\n' - login_str = login_str.format( - self.callsign, - self.passwd, - (' filter ' + self.filter) if self.filter != '' else '', - aprsd.__version__, - ) - - self.logger.debug('Sending login information') - - try: - self._sendall(login_str) - self.sock.settimeout(5) - test = self.sock.recv(len(login_str) + 100) - if is_py3: - test = test.decode('latin-1') - test = test.rstrip() - - self.logger.debug("Server: '%s'", test) - - if not test: - raise LoginError(f"Server Response Empty: '{test}'") - - _, _, callsign, status, e = test.split(' ', 4) - s = e.split(',') - if len(s): - server_string = s[0].replace('server ', '') - else: - server_string = e.replace('server ', '') - - if callsign == '': - raise LoginError('Server responded with empty callsign???') - if callsign != self.callsign: - raise LoginError(f'Server: {test}') - if status != 'verified,' and self.passwd != '-1': - raise LoginError('Password is incorrect') - - if self.passwd == '-1': - self.logger.info('Login successful (receive only)') - else: - self.logger.info('Login successful') - - self.logger.info(f'Connected to {server_string}') - self.server_string = server_string - - except LoginError as e: - self.logger.error(str(e)) - self.close() - raise - except Exception as e: - self.close() - self.logger.error(f"Failed to login '{e}'") - self.logger.exception(e) - raise LoginError('Failed to login') from e - - def consumer(self, callback, blocking=True, immortal=False, raw=False): - """ - When a position sentence is received, it will be passed to the callback function - - blocking: if true (default), runs forever, otherwise will return after one sentence - You can still exit the loop, by raising StopIteration in the callback function - - immortal: When true, consumer will try to reconnect and stop propagation of Parse exceptions - if false (default), consumer will return - - raw: when true, raw packet is passed to callback, otherwise the result from aprs.parse() - """ - - if not self._connected: - raise ConnectionError('not connected to a server') - - line = b'' - - while True and not self.thread_stop: - try: - for line in self._socket_readlines(blocking): - if line[0:1] != b'#': - self.aprsd_keepalive = datetime.datetime.now() - if raw: - callback(line) - else: - callback(self._parse(line)) - else: - self.logger.debug('Server: %s', line.decode('utf8')) - self.aprsd_keepalive = datetime.datetime.now() - except ParseError as exp: - self.logger.log( - 11, - "%s Packet: '%s'", - exp, - exp.packet, - ) - except UnknownFormat as exp: - self.logger.log( - 9, - "%s Packet: '%s'", - exp, - exp.packet, - ) - except LoginError as exp: - self.logger.error('%s: %s', exp.__class__.__name__, exp) - except (KeyboardInterrupt, SystemExit): - raise - except (ConnectionDrop, ConnectionError): - self.close() - - if not immortal: - raise - else: - self.connect(blocking=blocking) - continue - except GenericError: - pass - except StopIteration: + def setup_connection(self): + user = CONF.aprs_network.login + password = CONF.aprs_network.password + host = CONF.aprs_network.host + port = CONF.aprs_network.port + self.connected = False + backoff = 1 + retries = 3 + retry_count = 0 + while not self.connected: + retry_count += 1 + if retry_count >= retries: break - except Exception: - self.logger.error('APRS Packet: %s', line) - raise + try: + LOG.info( + f'Creating aprslib client({host}:{port}) and logging in {user}.' + ) + self._client = APRSLibClient( + user, passwd=password, host=host, port=port + ) + # Force the log to be the same + self._client.logger = LOG + self._client.connect() + self.connected = self.login_status['success'] = True + self.login_status['message'] = self._client.server_string + backoff = 1 + except LoginError as e: + LOG.error(f"Failed to login to APRS-IS Server '{e}'") + self.connected = self.login_status['success'] = False + self.login_status['message'] = ( + e.message if hasattr(e, 'message') else str(e) + ) + LOG.error(self.login_status['message']) + time.sleep(backoff) + except Exception as e: + LOG.error(f"Unable to connect to APRS-IS server. '{e}' ") + self.connected = self.login_status['success'] = False + self.login_status['message'] = getattr(e, 'message', str(e)) + time.sleep(backoff) + # Don't allow the backoff to go to inifinity. + if backoff > 5: + backoff = 5 + else: + backoff += 1 + continue - if not blocking: - break + def set_filter(self, filter): + self._client.set_filter(filter) + + def login_success(self) -> bool: + return self.login_status.get('success', False) + + def login_failure(self) -> str: + return self.login_status.get('message', None) + + @property + def filter(self): + return self._client.filter + + @property + def server_string(self): + return self._client.server_string + + @property + def keepalive(self): + return self._client.aprsd_keepalive + + def _is_stale_connection(self): + delta = datetime.datetime.now() - self._client.aprsd_keepalive + if delta > self.max_delta: + LOG.error(f'Connection is stale, last heard {delta} ago.') + return True + return False + + @staticmethod + def transport(): + return client.TRANSPORT_APRSIS + + def decode_packet(self, *args, **kwargs): + """APRS lib already decodes this.""" + return core.factory(args[0]) + + def consumer(self, callback: Callable, raw: bool = False): + if self._client: + try: + self._client.consumer( + callback, + blocking=False, + immortal=False, + raw=raw, + ) + except Exception as e: + LOG.error(e) + LOG.info(e.__cause__) + raise e + else: + LOG.warning('client is None, might be resetting.') + self.connected = False + + def stats(self, serializable=False) -> dict: + stats = {} + if self.is_configured(): + if self._client: + keepalive = self._client.aprsd_keepalive + server_string = self._client.server_string + if serializable: + keepalive = keepalive.isoformat() + filter = self.filter + else: + keepalive = 'None' + server_string = 'None' + filter = 'None' + stats = { + 'connected': self.is_alive, + 'filter': filter, + 'login_status': self.login_status, + 'connection_keepalive': keepalive, + 'server_string': server_string, + 'transport': self.transport(), + } + + return stats diff --git a/aprsd/client/drivers/fake.py b/aprsd/client/drivers/fake.py index 3229f88..a2466d4 100644 --- a/aprsd/client/drivers/fake.py +++ b/aprsd/client/drivers/fake.py @@ -2,6 +2,7 @@ import datetime import logging import threading import time +from typing import Callable import aprslib import wrapt @@ -15,7 +16,7 @@ CONF = cfg.CONF LOG = logging.getLogger('APRSD') -class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass): +class APRSDFakeDriver(metaclass=trace.TraceWrapperMetaclass): """Fake client for testing.""" # flag to tell us to stop @@ -28,17 +29,40 @@ class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass): path = [] def __init__(self): - LOG.info('Starting APRSDFakeClient client.') + LOG.info('Starting APRSDFakeDriver driver.') self.path = ['WIDE1-1', 'WIDE2-1'] - def stop(self): - self.thread_stop = True - LOG.info('Shutdown APRSDFakeClient client.') + @staticmethod + def is_enabled(): + if CONF.fake_client.enabled: + return True + return False + + @staticmethod + def is_configured(): + return APRSDFakeDriver.is_enabled def is_alive(self): """If the connection is alive or not.""" return not self.thread_stop + def close(self): + self.thread_stop = True + LOG.info('Shutdown APRSDFakeDriver driver.') + + def setup_connection(self): + # It's fake.... + pass + + def set_filter(self, filter: str) -> None: + pass + + def login_success(self) -> bool: + return True + + def login_failure(self) -> str: + return None + @wrapt.synchronized(lock) def send(self, packet: core.Packet): """Send an APRS Message object.""" @@ -61,13 +85,37 @@ class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass): f'\'{packet.from_call}\' with PATH "{self.path}"', ) - def consumer(self, callback, blocking=False, immortal=False, raw=False): + def consumer(self, callback: Callable, raw: bool = False): LOG.debug('Start non blocking FAKE consumer') # Generate packets here? - raw = 'GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW' - pkt_raw = aprslib.parse(raw) - pkt = core.factory(pkt_raw) + raw_str = 'GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW' self.aprsd_keepalive = datetime.datetime.now() - callback(packet=pkt) + if raw: + callback(raw=raw_str) + else: + pkt_raw = aprslib.parse(raw_str) + pkt = core.factory(pkt_raw) + callback(packet=pkt) + LOG.debug(f'END blocking FAKE consumer {self}') - time.sleep(8) + time.sleep(1) + + def decode_packet(self, *args, **kwargs): + """APRS lib already decodes this.""" + if not kwargs: + return None + + if kwargs.get('packet'): + return kwargs.get('packet') + + if kwargs.get('raw'): + pkt_raw = aprslib.parse(kwargs.get('raw')) + pkt = core.factory(pkt_raw) + return pkt + + def stats(self, serializable: bool = False) -> dict: + return { + 'driver': self.__class__.__name__, + 'is_alive': self.is_alive(), + 'transport': 'fake', + } diff --git a/aprsd/client/drivers/kiss.py b/aprsd/client/drivers/kiss.py deleted file mode 100644 index 944a44e..0000000 --- a/aprsd/client/drivers/kiss.py +++ /dev/null @@ -1,144 +0,0 @@ -import datetime -import logging - -import kiss -from ax253 import Frame -from oslo_config import cfg - -from aprsd import conf # noqa -from aprsd.packets import core -from aprsd.utils import trace - -CONF = cfg.CONF -LOG = logging.getLogger('APRSD') - - -class KISS3Client: - path = [] - - # date for last time we heard from the server - aprsd_keepalive = datetime.datetime.now() - _connected = False - - def __init__(self): - self.setup() - - def is_alive(self): - return self._connected - - def setup(self): - # we can be TCP kiss or Serial kiss - if CONF.kiss_serial.enabled: - LOG.debug( - 'KISS({}) Serial connection to {}'.format( - kiss.__version__, - CONF.kiss_serial.device, - ), - ) - self.kiss = kiss.SerialKISS( - port=CONF.kiss_serial.device, - speed=CONF.kiss_serial.baudrate, - strip_df_start=True, - ) - self.path = CONF.kiss_serial.path - elif CONF.kiss_tcp.enabled: - LOG.debug( - 'KISS({}) TCP Connection to {}:{}'.format( - kiss.__version__, - CONF.kiss_tcp.host, - CONF.kiss_tcp.port, - ), - ) - self.kiss = kiss.TCPKISS( - host=CONF.kiss_tcp.host, - port=CONF.kiss_tcp.port, - strip_df_start=True, - ) - self.path = CONF.kiss_tcp.path - - LOG.debug('Starting KISS interface connection') - try: - self.kiss.start() - if self.kiss.protocol.transport.is_closing(): - LOG.warning('KISS transport is closing, not setting consumer callback') - self._connected = False - else: - self._connected = True - except Exception: - LOG.error('Failed to start KISS interface.') - self._connected = False - - @trace.trace - def stop(self): - if not self._connected: - # do nothing since we aren't connected - return - - try: - self.kiss.stop() - self.kiss.loop.call_soon_threadsafe( - self.kiss.protocol.transport.close, - ) - except Exception: - LOG.error('Failed to stop KISS interface.') - - def close(self): - self.stop() - - def set_filter(self, filter): - # This does nothing right now. - pass - - def parse_frame(self, frame_bytes): - try: - frame = Frame.from_bytes(frame_bytes) - # Now parse it with aprslib - kwargs = { - 'frame': frame, - } - self._parse_callback(**kwargs) - self.aprsd_keepalive = datetime.datetime.now() - except Exception as ex: - LOG.error('Failed to parse bytes received from KISS interface.') - LOG.exception(ex) - - def consumer(self, callback): - if not self._connected: - raise Exception('KISS transport is not connected') - - self._parse_callback = callback - if not self.kiss.protocol.transport.is_closing(): - self.kiss.read(callback=self.parse_frame, min_frames=1) - else: - self._connected = False - - def send(self, packet): - """Send an APRS Message object.""" - - payload = None - path = self.path - if isinstance(packet, core.Packet): - packet.prepare() - payload = packet.payload.encode('US-ASCII') - if packet.path: - path = packet.path - else: - msg_payload = f'{packet.raw}{{{str(packet.msgNo)}' - payload = ( - ':{:<9}:{}'.format( - packet.to_call, - msg_payload, - ) - ).encode('US-ASCII') - - LOG.debug( - f"KISS Send '{payload}' TO '{packet.to_call}' From " - f"'{packet.from_call}' with PATH '{path}'", - ) - frame = Frame.ui( - destination='APZ100', - source=packet.from_call, - path=path, - info=payload, - ) - self.kiss.write(frame) diff --git a/aprsd/client/drivers/lib/__init__.py b/aprsd/client/drivers/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/client/drivers/lib/aprslib.py b/aprsd/client/drivers/lib/aprslib.py new file mode 100644 index 0000000..4ed3504 --- /dev/null +++ b/aprsd/client/drivers/lib/aprslib.py @@ -0,0 +1,295 @@ +import datetime +import logging +import select +import socket +import threading + +import aprslib +import wrapt +from aprslib import is_py3 +from aprslib.exceptions import ( + ConnectionDrop, + ConnectionError, + GenericError, + LoginError, + ParseError, + UnknownFormat, +) + +import aprsd +from aprsd.packets import core + +LOG = logging.getLogger('APRSD') + + +class APRSLibClient(aprslib.IS): + """Extend the aprslib class so we can exit properly. + + This is a modified version of the aprslib.IS class that adds a stop method to + allow the client to exit cleanly. + + The aprsis driver uses this class to connect to the APRS-IS server. + """ + + # flag to tell us to stop + thread_stop = False + + # date for last time we heard from the server + aprsd_keepalive = datetime.datetime.now() + + # Which server we are connected to? + server_string = 'None' + + # timeout in seconds + select_timeout = 1 + lock = threading.Lock() + + def stop(self): + self.thread_stop = True + LOG.warning('Shutdown Aprsdis client.') + + def close(self): + LOG.warning('Closing Aprsdis client.') + super().close() + + @wrapt.synchronized(lock) + def send(self, packet: core.Packet): + """Send an APRS Message object.""" + self.sendall(packet.raw) + + def is_alive(self): + """If the connection is alive or not.""" + return self._connected + + def _connect(self): + """ + Attemps connection to the server + """ + + self.logger.info( + 'Attempting connection to %s:%s', self.server[0], self.server[1] + ) + + try: + self._open_socket() + + peer = self.sock.getpeername() + + self.logger.info('Connected to %s', str(peer)) + + # 5 second timeout to receive server banner + self.sock.setblocking(1) + self.sock.settimeout(5) + + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + # MACOS doesn't have TCP_KEEPIDLE + if hasattr(socket, 'TCP_KEEPIDLE'): + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1) + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3) + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) + + banner = self.sock.recv(512) + if is_py3: + banner = banner.decode('latin-1') + + if banner[0] == '#': + self.logger.debug('Banner: %s', banner.rstrip()) + else: + raise ConnectionError('invalid banner from server') + + except ConnectionError as e: + self.logger.error(str(e)) + self.close() + raise + except (socket.error, socket.timeout) as e: + self.close() + + self.logger.error('Socket error: %s' % str(e)) + if str(e) == 'timed out': + raise ConnectionError('no banner from server') from e + else: + raise ConnectionError(e) from e + + self._connected = True + + def _socket_readlines(self, blocking=False): + """ + Generator for complete lines, received from the server + """ + try: + self.sock.setblocking(0) + except OSError as e: + self.logger.error(f'socket error when setblocking(0): {str(e)}') + raise aprslib.ConnectionDrop('connection dropped') from e + + while not self.thread_stop: + short_buf = b'' + newline = b'\r\n' + + # set a select timeout, so we get a chance to exit + # when user hits CTRL-C + readable, writable, exceptional = select.select( + [self.sock], + [], + [], + self.select_timeout, + ) + if not readable: + if not blocking: + break + else: + continue + + try: + short_buf = self.sock.recv(4096) + + # sock.recv returns empty if the connection drops + if not short_buf: + if not blocking: + # We could just not be blocking, so empty is expected + continue + else: + self.logger.error('socket.recv(): returned empty') + raise aprslib.ConnectionDrop('connection dropped') + except OSError as e: + # self.logger.error("socket error on recv(): %s" % str(e)) + if 'Resource temporarily unavailable' in str(e): + if not blocking: + if len(self.buf) == 0: + break + + self.buf += short_buf + + while newline in self.buf: + line, self.buf = self.buf.split(newline, 1) + + yield line + + def _send_login(self): + """ + Sends login string to server + """ + login_str = 'user {0} pass {1} vers Python-APRSD {3}{2}\r\n' + login_str = login_str.format( + self.callsign, + self.passwd, + (' filter ' + self.filter) if self.filter != '' else '', + aprsd.__version__, + ) + + self.logger.debug('Sending login information') + + try: + self._sendall(login_str) + self.sock.settimeout(5) + test = self.sock.recv(len(login_str) + 100) + if is_py3: + test = test.decode('latin-1') + test = test.rstrip() + + self.logger.debug("Server: '%s'", test) + + if not test: + raise LoginError(f"Server Response Empty: '{test}'") + + _, _, callsign, status, e = test.split(' ', 4) + s = e.split(',') + if len(s): + server_string = s[0].replace('server ', '') + else: + server_string = e.replace('server ', '') + + if callsign == '': + raise LoginError('Server responded with empty callsign???') + if callsign != self.callsign: + raise LoginError(f'Server: {test}') + if status != 'verified,' and self.passwd != '-1': + raise LoginError('Password is incorrect') + + if self.passwd == '-1': + self.logger.info('Login successful (receive only)') + else: + self.logger.info('Login successful') + + self.logger.info(f'Connected to {server_string}') + self.server_string = server_string + + except LoginError as e: + self.logger.error(str(e)) + self.close() + raise + except Exception as e: + self.close() + self.logger.error(f"Failed to login '{e}'") + self.logger.exception(e) + raise LoginError('Failed to login') from e + + def consumer(self, callback, blocking=True, immortal=False, raw=False): + """ + When a position sentence is received, it will be passed to the callback function + + blocking: if true (default), runs forever, otherwise will return after one sentence + You can still exit the loop, by raising StopIteration in the callback function + + immortal: When true, consumer will try to reconnect and stop propagation of Parse exceptions + if false (default), consumer will return + + raw: when true, raw packet is passed to callback, otherwise the result from aprs.parse() + """ + + if not self._connected: + raise ConnectionError('not connected to a server') + + line = b'' + + while True and not self.thread_stop: + try: + for line in self._socket_readlines(blocking): + if line[0:1] != b'#': + self.aprsd_keepalive = datetime.datetime.now() + if raw: + callback(line) + else: + callback(self._parse(line)) + else: + self.logger.debug('Server: %s', line.decode('utf8')) + self.aprsd_keepalive = datetime.datetime.now() + except ParseError as exp: + self.logger.log( + 11, + "%s Packet: '%s'", + exp, + exp.packet, + ) + except UnknownFormat as exp: + self.logger.log( + 9, + "%s Packet: '%s'", + exp, + exp.packet, + ) + except LoginError as exp: + self.logger.error('%s: %s', exp.__class__.__name__, exp) + except (KeyboardInterrupt, SystemExit): + raise + except (ConnectionDrop, ConnectionError): + self.close() + + if not immortal: + raise + else: + self.connect(blocking=blocking) + continue + except GenericError: + pass + except StopIteration: + break + except IOError: + self.logger.error('IOError') + break + except Exception: + self.logger.error('APRS Packet: %s', line) + raise + + if not blocking: + break diff --git a/aprsd/client/drivers/registry.py b/aprsd/client/drivers/registry.py new file mode 100644 index 0000000..f295952 --- /dev/null +++ b/aprsd/client/drivers/registry.py @@ -0,0 +1,86 @@ +from typing import Callable, Protocol, runtime_checkable + +from aprsd.packets import core +from aprsd.utils import singleton, trace + + +@runtime_checkable +class ClientDriver(Protocol): + """Protocol for APRSD client drivers. + + This protocol defines the methods that must be + implemented by APRSD client drivers. + """ + + @staticmethod + def is_enabled(self) -> bool: + pass + + @staticmethod + def is_configured(self) -> bool: + pass + + def is_alive(self) -> bool: + pass + + def close(self) -> None: + pass + + def send(self, packet: core.Packet) -> bool: + pass + + def setup_connection(self) -> None: + pass + + def set_filter(self, filter: str) -> None: + pass + + def login_success(self) -> bool: + pass + + def login_failure(self) -> str: + pass + + def consumer(self, callback: Callable, raw: bool = False) -> None: + pass + + def decode_packet(self, *args, **kwargs) -> core.Packet: + pass + + def stats(self, serializable: bool = False) -> dict: + pass + + +@singleton +class DriverRegistry(metaclass=trace.TraceWrapperMetaclass): + """Registry for APRSD client drivers. + + This registry is used to register and unregister APRSD client drivers. + + This allows us to dynamically load the configured driver at runtime. + + All drivers are registered, then when aprsd needs the client, the + registry provides the configured driver for the single instance of the + single APRSD client. + """ + + def __init__(self): + self.drivers = [] + + def register(self, driver: Callable): + if not isinstance(driver, ClientDriver): + raise ValueError('Driver must be of ClientDriver type') + self.drivers.append(driver) + + def unregister(self, driver: Callable): + if driver in self.drivers: + self.drivers.remove(driver) + else: + raise ValueError(f'Driver {driver} not found') + + def get_driver(self) -> ClientDriver: + """Get the first enabled driver.""" + for driver in self.drivers: + if driver.is_enabled() and driver.is_configured(): + return driver() + raise ValueError('No enabled driver found') diff --git a/aprsd/client/drivers/tcpkiss.py b/aprsd/client/drivers/tcpkiss.py new file mode 100644 index 0000000..3d58da3 --- /dev/null +++ b/aprsd/client/drivers/tcpkiss.py @@ -0,0 +1,408 @@ +""" +APRSD KISS Client Driver using native KISS implementation. + +This module provides a KISS client driver for APRSD using the new +non-asyncio KISSInterface implementation. +""" + +import datetime +import logging +import select +import socket +import time +from typing import Any, Callable, Dict + +import aprslib +from ax253 import frame as ax25frame +from kiss import constants as kiss_constants +from kiss import util as kissutil +from kiss.kiss import Command +from oslo_config import cfg + +from aprsd import ( # noqa + client, + conf, # noqa + exception, +) +from aprsd.packets import core + +CONF = cfg.CONF +LOG = logging.getLogger('APRSD') + + +def handle_fend(buffer: bytes, strip_df_start: bool = True) -> bytes: + """ + Handle FEND (end of frame) encountered in a KISS data stream. + + :param buffer: the buffer containing the frame + :param strip_df_start: remove leading null byte (DATA_FRAME opcode) + :return: the bytes of the frame without escape characters or frame + end markers (FEND) + """ + frame = kissutil.recover_special_codes(kissutil.strip_nmea(bytes(buffer))) + if strip_df_start: + frame = kissutil.strip_df_start(frame) + LOG.warning(f'handle_fend {" ".join(f"{b:02X}" for b in bytes(frame))}') + return bytes(frame) + + +# class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass): +class TCPKISSDriver: + """APRSD client driver for TCP KISS connections.""" + + # Class level attributes required by Client protocol + packets_received = 0 + packets_sent = 0 + last_packet_sent = None + last_packet_received = None + keepalive = None + client_name = None + socket = None + # timeout in seconds + select_timeout = 1 + path = None + + def __init__(self): + """Initialize the KISS client. + + Args: + client_name: Name of the client instance + """ + super().__init__() + self._connected = False + self.keepalive = datetime.datetime.now() + self._running = False + # This is initialized in setup_connection() + self.socket = None + + @property + def transport(self) -> str: + return client.TRANSPORT_TCPKISS + + @classmethod + def is_enabled(cls) -> bool: + """Check if KISS is enabled in configuration. + + Returns: + bool: True if either TCP is enabled + """ + return CONF.kiss_tcp.enabled + + @staticmethod + def is_configured(): + # Ensure that the config vars are correctly set + if TCPKISSDriver.is_enabled(): + if not CONF.kiss_tcp.host: + LOG.error('KISS TCP enabled, but no host is set.') + raise exception.MissingConfigOptionException( + 'kiss_tcp.host is not set.', + ) + return True + return False + + @property + def is_alive(self) -> bool: + """Check if the client is connected. + + Returns: + bool: True if connected to KISS TNC, False otherwise + """ + return self._connected + + def close(self): + """Close the connection.""" + self.stop() + + def send(self, packet: core.Packet): + """Send an APRS packet. + + Args: + packet: APRS packet to send (Packet or Message object) + + Raises: + Exception: If not connected or send fails + """ + if not self.socket: + raise Exception('KISS interface not initialized') + + payload = None + path = self.path + packet.prepare() + payload = packet.payload.encode('US-ASCII') + if packet.path: + path = packet.path + + LOG.debug( + f"KISS Send '{payload}' TO '{packet.to_call}' From " + f"'{packet.from_call}' with PATH '{path}'", + ) + frame = ax25frame.Frame.ui( + destination='APZ100', + # destination=packet.to_call, + source=packet.from_call, + path=path, + info=payload, + ) + + # now escape the frame special characters + frame_escaped = kissutil.escape_special_codes(bytes(frame)) + # and finally wrap the frame in KISS protocol + command = Command.DATA_FRAME + frame_kiss = b''.join( + [kiss_constants.FEND, command.value, frame_escaped, kiss_constants.FEND] + ) + self.socket.send(frame_kiss) + # Update last packet sent time + self.last_packet_sent = datetime.datetime.now() + # Increment packets sent counter + self.packets_sent += 1 + + def setup_connection(self): + """Set up the KISS interface.""" + if not self.is_enabled(): + LOG.error('KISS is not enabled in configuration') + return + + try: + # Configure for TCP KISS + if self.is_enabled(): + LOG.info( + f'KISS TCP Connection to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port}' + ) + self.path = CONF.kiss_tcp.path + self.connect() + if self._connected: + LOG.info('KISS interface initialized') + else: + LOG.error('Failed to connect to KISS interface') + + except Exception as ex: + LOG.error('Failed to initialize KISS interface') + LOG.exception(ex) + self._connected = False + + def set_filter(self, filter_text: str): + """Set packet filter (not implemented for KISS). + + Args: + filter_text: Filter specification (ignored for KISS) + """ + # KISS doesn't support filtering at the TNC level + pass + + @property + def filter(self) -> str: + """Get packet filter (not implemented for KISS). + Returns: + str: Empty string (not implemented for KISS) + """ + return '' + + def login_success(self) -> bool: + """There is no login for KISS.""" + if not self._connected: + return False + return True + + def login_failure(self) -> str: + """There is no login for KISS.""" + return 'Login successful' + + def consumer(self, callback: Callable, raw: bool = False): + """Start consuming frames with the given callback. + + Args: + callback: Function to call with received packets + + Raises: + Exception: If not connected to KISS TNC + """ + self._running = True + while self._running: + # Ensure connection + if not self._connected: + if not self.connect(): + time.sleep(1) + continue + + # Read frame + frame = self.read_frame() + if frame: + LOG.warning(f'GOT FRAME: {frame} calling {callback}') + kwargs = { + 'frame': frame, + } + callback(**kwargs) + + def decode_packet(self, *args, **kwargs) -> core.Packet: + """Decode a packet from an AX.25 frame. + + Args: + frame: Received AX.25 frame + """ + frame = kwargs.get('frame') + if not frame: + LOG.warning('No frame received to decode?!?!') + return None + + LOG.warning(f'FRAME: {str(frame)}') + try: + aprslib_frame = aprslib.parse(str(frame)) + return core.factory(aprslib_frame) + except Exception as e: + LOG.error(f'Error decoding packet: {e}') + return None + + def stop(self): + """Stop the KISS interface.""" + self._running = False + self._connected = False + if self.socket: + try: + self.socket.close() + except Exception: + pass + + def stats(self, serializable: bool = False) -> Dict[str, Any]: + """Get client statistics. + + Returns: + Dict containing client statistics + """ + if serializable: + keepalive = self.keepalive.isoformat() + else: + keepalive = self.keepalive + stats = { + 'client': self.__class__.__name__, + 'transport': self.transport, + 'connected': self._connected, + 'path': self.path, + 'packets_sent': self.packets_sent, + 'packets_received': self.packets_received, + 'last_packet_sent': self.last_packet_sent, + 'last_packet_received': self.last_packet_received, + 'connection_keepalive': keepalive, + 'host': CONF.kiss_tcp.host, + 'port': CONF.kiss_tcp.port, + } + + return stats + + def connect(self) -> bool: + """Establish TCP connection to the KISS host. + + Returns: + bool: True if connection successful, False otherwise + """ + try: + if self.socket: + try: + self.socket.close() + except Exception: + pass + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(5.0) # 5 second timeout for connection + self.socket.connect((CONF.kiss_tcp.host, CONF.kiss_tcp.port)) + self.socket.settimeout(0.1) # Reset to shorter timeout for reads + self._connected = True + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + # MACOS doesn't have TCP_KEEPIDLE + if hasattr(socket, 'TCP_KEEPIDLE'): + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) + return True + + except ConnectionError as e: + LOG.error( + f'Failed to connect to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port} - {str(e)}' + ) + self._connected = False + return False + + except Exception as e: + LOG.error( + f'Failed to connect to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port} - {str(e)}' + ) + self._connected = False + return False + + def fix_raw_frame(self, raw_frame: bytes) -> bytes: + """Fix the raw frame by recalculating the FCS.""" + ax25_data = raw_frame[2:-1] # Remove KISS markers + return handle_fend(ax25_data) + + def read_frame(self, blocking=False): + """ + Generator for complete lines, received from the server + """ + try: + self.socket.setblocking(0) + except OSError as e: + LOG.error(f'socket error when setblocking(0): {str(e)}') + raise aprslib.ConnectionDrop('connection dropped') from e + + while self._running: + short_buf = b'' + + try: + readable, _, _ = select.select( + [self.socket], + [], + [], + self.select_timeout, + ) + if not readable: + if not blocking: + break + else: + continue + except Exception as e: + LOG.error(f'Error in read loop: {e}') + self._connected = False + break + + try: + print('reading from socket') + short_buf = self.socket.recv(1024) + print(f'short_buf: {short_buf}') + # sock.recv returns empty if the connection drops + if not short_buf: + if not blocking: + # We could just not be blocking, so empty is expected + continue + else: + self.logger.error('socket.recv(): returned empty') + raise aprslib.ConnectionDrop('connection dropped') + + raw_frame = self.fix_raw_frame(short_buf) + return ax25frame.Frame.from_bytes(raw_frame) + except OSError as e: + # self.logger.error("socket error on recv(): %s" % str(e)) + if 'Resource temporarily unavailable' in str(e): + if not blocking: + if len(short_buf) == 0: + break + except socket.timeout: + continue + except (KeyboardInterrupt, SystemExit): + raise + except ConnectionError: + self.close() + if not self.auto_reconnect: + raise + else: + self.connect() + continue + except StopIteration: + break + except IOError: + LOG.error('IOError') + break + except Exception as e: + LOG.error(f'Error in read loop: {e}') + self._connected = False + if not self.auto_reconnect: + break diff --git a/aprsd/client/factory.py b/aprsd/client/factory.py deleted file mode 100644 index e312344..0000000 --- a/aprsd/client/factory.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging -from typing import Callable, Protocol, runtime_checkable - -from aprsd import exception -from aprsd.packets import core - -LOG = logging.getLogger("APRSD") - - -@runtime_checkable -class Client(Protocol): - def __init__(self): - pass - - def connect(self) -> bool: - pass - - def disconnect(self) -> bool: - pass - - def decode_packet(self, *args, **kwargs) -> type[core.Packet]: - pass - - def is_enabled(self) -> bool: - pass - - def is_configured(self) -> bool: - pass - - def transport(self) -> str: - pass - - def send(self, message: str) -> bool: - pass - - def setup_connection(self) -> None: - pass - - -class ClientFactory: - _instance = None - clients = [] - client = None - - def __new__(cls, *args, **kwargs): - """This magic turns this into a singleton.""" - if cls._instance is None: - cls._instance = super().__new__(cls) - # Put any initialization here. - return cls._instance - - def __init__(self): - self.clients: list[Callable] = [] - - def register(self, aprsd_client: Callable): - if isinstance(aprsd_client, Client): - raise ValueError("Client must be a subclass of Client protocol") - - self.clients.append(aprsd_client) - - def create(self, key=None): - for client in self.clients: - if client.is_enabled(): - self.client = client() - return self.client - raise Exception("No client is configured!!") - - def client_exists(self): - return bool(self.client) - - def is_client_enabled(self): - """Make sure at least one client is enabled.""" - enabled = False - for client in self.clients: - if client.is_enabled(): - enabled = True - return enabled - - def is_client_configured(self): - enabled = False - for client in self.clients: - try: - if client.is_configured(): - enabled = True - except exception.MissingConfigOptionException as ex: - LOG.error(ex.message) - return False - except exception.ConfigOptionBogusDefaultException as ex: - LOG.error(ex.message) - return False - return enabled diff --git a/aprsd/client/fake.py b/aprsd/client/fake.py deleted file mode 100644 index 384468e..0000000 --- a/aprsd/client/fake.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging - -from oslo_config import cfg - -from aprsd import client -from aprsd.client import base -from aprsd.client.drivers import fake as fake_driver -from aprsd.utils import trace - -CONF = cfg.CONF -LOG = logging.getLogger("APRSD") - - -class APRSDFakeClient(base.APRSClient, metaclass=trace.TraceWrapperMetaclass): - def stats(self, serializable=False) -> dict: - return { - "transport": "Fake", - "connected": True, - } - - @staticmethod - def is_enabled(): - if CONF.fake_client.enabled: - return True - return False - - @staticmethod - def is_configured(): - return APRSDFakeClient.is_enabled() - - def is_alive(self): - return True - - def close(self): - pass - - def setup_connection(self): - self.connected = True - return fake_driver.APRSDFakeClient() - - @staticmethod - def transport(): - return client.TRANSPORT_FAKE - - def decode_packet(self, *args, **kwargs): - LOG.debug(f"kwargs {kwargs}") - pkt = kwargs["packet"] - LOG.debug(f"Got an APRS Fake Packet '{pkt}'") - return pkt diff --git a/aprsd/client/kiss.py b/aprsd/client/kiss.py deleted file mode 100644 index cb282a0..0000000 --- a/aprsd/client/kiss.py +++ /dev/null @@ -1,143 +0,0 @@ -import datetime -import logging - -import aprslib -import timeago -from loguru import logger -from oslo_config import cfg - -from aprsd import client, exception -from aprsd.client import base -from aprsd.client.drivers import kiss -from aprsd.packets import core - -CONF = cfg.CONF -LOG = logging.getLogger('APRSD') -LOGU = logger - - -class KISSClient(base.APRSClient): - _client = None - keepalive = datetime.datetime.now() - - def stats(self, serializable=False) -> dict: - stats = {} - if self.is_configured(): - keepalive = self.keepalive - if serializable: - keepalive = keepalive.isoformat() - stats = { - 'connected': self.is_connected, - 'connection_keepalive': keepalive, - 'transport': self.transport(), - } - if self.transport() == client.TRANSPORT_TCPKISS: - stats['host'] = CONF.kiss_tcp.host - stats['port'] = CONF.kiss_tcp.port - elif self.transport() == client.TRANSPORT_SERIALKISS: - stats['device'] = CONF.kiss_serial.device - return stats - - @staticmethod - def is_enabled(): - """Return if tcp or serial KISS is enabled.""" - if CONF.kiss_serial.enabled: - return True - - if CONF.kiss_tcp.enabled: - return True - - return False - - @staticmethod - def is_configured(): - # Ensure that the config vars are correctly set - if KISSClient.is_enabled(): - transport = KISSClient.transport() - if transport == client.TRANSPORT_SERIALKISS: - if not CONF.kiss_serial.device: - LOG.error('KISS serial enabled, but no device is set.') - raise exception.MissingConfigOptionException( - 'kiss_serial.device is not set.', - ) - elif transport == client.TRANSPORT_TCPKISS: - if not CONF.kiss_tcp.host: - LOG.error('KISS TCP enabled, but no host is set.') - raise exception.MissingConfigOptionException( - 'kiss_tcp.host is not set.', - ) - - return True - return False - - def is_alive(self): - if self._client: - return self._client.is_alive() - else: - return False - - def close(self): - if self._client: - self._client.stop() - - def keepalive_check(self): - # Don't check the first time through. - if not self.is_alive() and self._checks: - LOG.warning("Resetting client. It's not alive.") - self.reset() - self._checks = True - - def keepalive_log(self): - if ka := self._client.aprsd_keepalive: - keepalive = timeago.format(ka) - else: - keepalive = 'N/A' - LOGU.opt(colors=True).info(f'Client keepalive {keepalive}') - - @staticmethod - def transport(): - if CONF.kiss_serial.enabled: - return client.TRANSPORT_SERIALKISS - - if CONF.kiss_tcp.enabled: - return client.TRANSPORT_TCPKISS - - def decode_packet(self, *args, **kwargs): - """We get a frame, which has to be decoded.""" - LOG.debug(f'kwargs {kwargs}') - frame = kwargs['frame'] - LOG.debug(f"Got an APRS Frame '{frame}'") - # try and nuke the * from the fromcall sign. - # frame.header._source._ch = False - # payload = str(frame.payload.decode()) - # msg = f"{str(frame.header)}:{payload}" - # msg = frame.tnc2 - # LOG.debug(f"Decoding {msg}") - - try: - raw = aprslib.parse(str(frame)) - packet = core.factory(raw) - if isinstance(packet, core.ThirdPartyPacket): - return packet.subpacket - else: - return packet - except Exception as ex: - LOG.error(f'Error decoding packet: {ex}') - - def setup_connection(self): - try: - self._client = kiss.KISS3Client() - self.connected = self.login_status['success'] = True - except Exception as ex: - self.connected = self.login_status['success'] = False - self.login_status['message'] = str(ex) - return self._client - - def consumer(self, callback, blocking=False, immortal=False, raw=False): - try: - self._client.consumer(callback) - self.keepalive = datetime.datetime.now() - except Exception as ex: - LOG.error(f'Consumer failed {ex}') - LOG.error(ex) - raise ex diff --git a/aprsd/client/stats.py b/aprsd/client/stats.py index 0097224..a41d9bf 100644 --- a/aprsd/client/stats.py +++ b/aprsd/client/stats.py @@ -3,7 +3,7 @@ import threading import wrapt from oslo_config import cfg -from aprsd import client +from aprsd.client.client import APRSDClient from aprsd.utils import singleton CONF = cfg.CONF @@ -15,4 +15,4 @@ class APRSClientStats: @wrapt.synchronized(lock) def stats(self, serializable=False): - return client.client_factory.create().stats(serializable=serializable) + return APRSDClient().stats(serializable=serializable) diff --git a/aprsd/cmds/dev.py b/aprsd/cmds/dev.py index 428c4d5..c1d4891 100644 --- a/aprsd/cmds/dev.py +++ b/aprsd/cmds/dev.py @@ -11,7 +11,6 @@ from oslo_config import cfg from aprsd import cli_helper, conf, packets, plugin # local imports here -from aprsd.client import base from aprsd.main import cli from aprsd.utils import trace @@ -97,8 +96,6 @@ def test_plugin( if CONF.trace_enabled: trace.setup_tracing(['method', 'api']) - base.APRSClient() - pm = plugin.PluginManager() if load_all: pm.setup_plugins(load_help_plugin=CONF.load_help_plugin) diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index 4784e3a..5064b39 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -17,7 +17,7 @@ from rich.console import Console # local imports here import aprsd from aprsd import cli_helper, packets, plugin, threads, utils -from aprsd.client import client_factory +from aprsd.client.client import APRSDClient from aprsd.main import cli from aprsd.packets import collector as packet_collector from aprsd.packets import core, seen_list @@ -232,13 +232,13 @@ def listen( # Initialize the client factory and create # The correct client object ready for use # Make sure we have 1 client transport enabled - if not client_factory.is_client_enabled(): + if not APRSDClient().is_enabled: LOG.error('No Clients are enabled in config.') sys.exit(-1) # Creates the client object LOG.info('Creating client connection') - aprs_client = client_factory.create() + aprs_client = APRSDClient() LOG.info(aprs_client) if not aprs_client.login_success: # We failed to login, will just quit! diff --git a/aprsd/cmds/send_message.py b/aprsd/cmds/send_message.py index 904263b..3c17bb3 100644 --- a/aprsd/cmds/send_message.py +++ b/aprsd/cmds/send_message.py @@ -14,7 +14,7 @@ from aprsd import ( conf, # noqa : F401 packets, ) -from aprsd.client import client_factory +from aprsd.client.client import APRSDClient from aprsd.main import cli from aprsd.packets import collector from aprsd.packets import log as packet_log @@ -103,7 +103,7 @@ def send_message( def rx_packet(packet): global got_ack, got_response - cl = client_factory.create() + cl = APRSDClient() packet = cl.decode_packet(packet) collector.PacketCollector().rx(packet) packet_log.log(packet, tx=False) @@ -131,7 +131,7 @@ def send_message( sys.exit(0) try: - client_factory.create().client # noqa: B018 + APRSDClient().client # noqa: B018 except LoginError: sys.exit(-1) @@ -163,7 +163,7 @@ def send_message( # This will register a packet consumer with aprslib # When new packets come in the consumer will process # the packet - aprs_client = client_factory.create().client + aprs_client = APRSDClient() aprs_client.consumer(rx_packet, raw=False) except aprslib.exceptions.ConnectionDrop: LOG.error('Connection dropped, reconnecting') diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py index 058fe27..c92aa31 100644 --- a/aprsd/cmds/server.py +++ b/aprsd/cmds/server.py @@ -8,7 +8,7 @@ from oslo_config import cfg import aprsd from aprsd import cli_helper, plugin, threads, utils from aprsd import main as aprsd_main -from aprsd.client import client_factory +from aprsd.client.client import APRSDClient from aprsd.main import cli from aprsd.packets import collector as packet_collector from aprsd.packets import seen_list @@ -47,24 +47,18 @@ def server(ctx, flush): LOG.info(msg) LOG.info(f'APRSD Started version: {aprsd.__version__}') - # Initialize the client factory and create - # The correct client object ready for use - if not client_factory.is_client_enabled(): - LOG.error('No Clients are enabled in config.') - sys.exit(-1) - # Make sure we have 1 client transport enabled - if not client_factory.is_client_enabled(): + if not APRSDClient().is_enabled: LOG.error('No Clients are enabled in config.') sys.exit(-1) - if not client_factory.is_client_configured(): + if not APRSDClient().is_configured: LOG.error('APRS client is not properly configured in config file.') sys.exit(-1) # Creates the client object LOG.info('Creating client connection') - aprs_client = client_factory.create() + aprs_client = APRSDClient() LOG.info(aprs_client) if not aprs_client.login_success: # We failed to login, will just quit! diff --git a/aprsd/main.py b/aprsd/main.py index 2764bdf..6e33bf6 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -41,7 +41,6 @@ from aprsd.stats import collector CONF = cfg.CONF LOG = logging.getLogger('APRSD') CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) -flask_enabled = False @click.group(cls=cli_helper.AliasedGroup, context_settings=CONTEXT_SETTINGS) @@ -73,8 +72,6 @@ def main(): def signal_handler(sig, frame): - global flask_enabled - click.echo('signal_handler: called') threads.APRSDThreadList().stop_all() if 'subprocess' not in str(frame): @@ -96,8 +93,7 @@ def signal_handler(sig, frame): # signal.signal(signal.SIGTERM, sys.exit(0)) # sys.exit(0) - if flask_enabled: - signal.signal(signal.SIGTERM, sys.exit(0)) + signal.signal(signal.SIGTERM, sys.exit(0)) @cli.command() diff --git a/aprsd/packets/core.py b/aprsd/packets/core.py index 804db00..b5cda0c 100644 --- a/aprsd/packets/core.py +++ b/aprsd/packets/core.py @@ -19,26 +19,26 @@ from loguru import logger from aprsd.utils import counter # For mypy to be happy -A = TypeVar("A", bound="DataClassJsonMixin") +A = TypeVar('A', bound='DataClassJsonMixin') Json = Union[dict, list, str, int, float, bool, None] LOG = logging.getLogger() LOGU = logger -PACKET_TYPE_BULLETIN = "bulletin" -PACKET_TYPE_MESSAGE = "message" -PACKET_TYPE_ACK = "ack" -PACKET_TYPE_REJECT = "reject" -PACKET_TYPE_MICE = "mic-e" -PACKET_TYPE_WX = "wx" -PACKET_TYPE_WEATHER = "weather" -PACKET_TYPE_OBJECT = "object" -PACKET_TYPE_UNKNOWN = "unknown" -PACKET_TYPE_STATUS = "status" -PACKET_TYPE_BEACON = "beacon" -PACKET_TYPE_THIRDPARTY = "thirdparty" -PACKET_TYPE_TELEMETRY = "telemetry-message" -PACKET_TYPE_UNCOMPRESSED = "uncompressed" +PACKET_TYPE_BULLETIN = 'bulletin' +PACKET_TYPE_MESSAGE = 'message' +PACKET_TYPE_ACK = 'ack' +PACKET_TYPE_REJECT = 'reject' +PACKET_TYPE_MICE = 'mic-e' +PACKET_TYPE_WX = 'wx' +PACKET_TYPE_WEATHER = 'weather' +PACKET_TYPE_OBJECT = 'object' +PACKET_TYPE_UNKNOWN = 'unknown' +PACKET_TYPE_STATUS = 'status' +PACKET_TYPE_BEACON = 'beacon' +PACKET_TYPE_THIRDPARTY = 'thirdparty' +PACKET_TYPE_TELEMETRY = 'telemetry-message' +PACKET_TYPE_UNCOMPRESSED = 'uncompressed' NO_DATE = datetime(1900, 10, 24) @@ -67,14 +67,14 @@ def _init_msgNo(): # noqa: N802 def _translate_fields(raw: dict) -> dict: # Direct key checks instead of iteration - if "from" in raw: - raw["from_call"] = raw.pop("from") - if "to" in raw: - raw["to_call"] = raw.pop("to") + if 'from' in raw: + raw['from_call'] = raw.pop('from') + if 'to' in raw: + raw['to_call'] = raw.pop('to') # addresse overrides to_call - if "addresse" in raw: - raw["to_call"] = raw["addresse"] + if 'addresse' in raw: + raw['to_call'] = raw['addresse'] return raw @@ -82,7 +82,7 @@ def _translate_fields(raw: dict) -> dict: @dataclass_json @dataclass(unsafe_hash=True) class Packet: - _type: str = field(default="Packet", hash=False) + _type: str = field(default='Packet', hash=False) from_call: Optional[str] = field(default=None) to_call: Optional[str] = field(default=None) addresse: Optional[str] = field(default=None) @@ -120,7 +120,7 @@ class Packet: @property def key(self) -> str: """Build a key for finding this packet in a dict.""" - return f"{self.from_call}:{self.addresse}:{self.msgNo}" + return f'{self.from_call}:{self.addresse}:{self.msgNo}' def update_timestamp(self) -> None: self.timestamp = _init_timestamp() @@ -133,7 +133,7 @@ class Packet: the human readable payload. """ self.prepare() - msg = self._filter_for_send(self.raw).rstrip("\n") + msg = self._filter_for_send(self.raw).rstrip('\n') return msg def prepare(self, create_msg_number=False) -> None: @@ -152,11 +152,11 @@ class Packet: ) # The base packet class has no real payload - self.payload = f":{self.to_call.ljust(9)}" + self.payload = f':{self.to_call.ljust(9)}' def _build_raw(self) -> None: """Build the self.raw which is what is sent over the air.""" - self.raw = "{}>APZ100:{}".format( + self.raw = '{}>APZ100:{}'.format( self.from_call, self.payload, ) @@ -168,13 +168,13 @@ class Packet: # 67 displays 64 on the ftm400. (+3 {01 suffix) # feature req: break long ones into two msgs if not msg: - return "" + return '' message = msg[:67] # We all miss George Carlin return re.sub( - "fuck|shit|cunt|piss|cock|bitch", - "****", + 'fuck|shit|cunt|piss|cock|bitch', + '****', message, flags=re.IGNORECASE, ) @@ -183,100 +183,98 @@ class Packet: """Show the raw version of the packet""" self.prepare() if not self.raw: - raise ValueError("self.raw is unset") + raise ValueError('self.raw is unset') return self.raw def __repr__(self) -> str: """Build the repr version of the packet.""" return ( - f"{self.__class__.__name__}:" - f" From: {self.from_call} " - f" To: {self.to_call}" + f'{self.__class__.__name__}: From: {self.from_call} To: {self.to_call}' ) @dataclass_json @dataclass(unsafe_hash=True) class AckPacket(Packet): - _type: str = field(default="AckPacket", hash=False) + _type: str = field(default='AckPacket', hash=False) def _build_payload(self): - self.payload = f":{self.to_call: <9}:ack{self.msgNo}" + self.payload = f':{self.to_call: <9}:ack{self.msgNo}' @dataclass_json @dataclass(unsafe_hash=True) class BulletinPacket(Packet): - _type: str = "BulletinPacket" + _type: str = 'BulletinPacket' # Holds the encapsulated packet - bid: Optional[str] = field(default="1") + bid: Optional[str] = field(default='1') message_text: Optional[str] = field(default=None) @property def key(self) -> str: """Build a key for finding this packet in a dict.""" - return f"{self.from_call}:BLN{self.bid}" + return f'{self.from_call}:BLN{self.bid}' @property def human_info(self) -> str: - return f"BLN{self.bid} {self.message_text}" + return f'BLN{self.bid} {self.message_text}' def _build_payload(self) -> None: - self.payload = f":BLN{self.bid:<9}" f":{self.message_text}" + self.payload = f':BLN{self.bid:<9}:{self.message_text}' @dataclass_json @dataclass(unsafe_hash=True) class RejectPacket(Packet): - _type: str = field(default="RejectPacket", hash=False) + _type: str = field(default='RejectPacket', hash=False) response: Optional[str] = field(default=None) def __post__init__(self): if self.response: - LOG.warning("Response set!") + LOG.warning('Response set!') def _build_payload(self): - self.payload = f":{self.to_call: <9}:rej{self.msgNo}" + self.payload = f':{self.to_call: <9}:rej{self.msgNo}' @dataclass_json @dataclass(unsafe_hash=True) class MessagePacket(Packet): - _type: str = field(default="MessagePacket", hash=False) + _type: str = field(default='MessagePacket', hash=False) message_text: Optional[str] = field(default=None) @property def human_info(self) -> str: self.prepare() - return self._filter_for_send(self.message_text).rstrip("\n") + return self._filter_for_send(self.message_text).rstrip('\n') def _build_payload(self): if self.msgNo: - self.payload = ":{}:{}{{{}".format( + self.payload = ':{}:{}{{{}'.format( self.to_call.ljust(9), - self._filter_for_send(self.message_text).rstrip("\n"), + self._filter_for_send(self.message_text).rstrip('\n'), str(self.msgNo), ) else: - self.payload = ":{}:{}".format( + self.payload = ':{}:{}'.format( self.to_call.ljust(9), - self._filter_for_send(self.message_text).rstrip("\n"), + self._filter_for_send(self.message_text).rstrip('\n'), ) @dataclass_json @dataclass(unsafe_hash=True) class StatusPacket(Packet): - _type: str = field(default="StatusPacket", hash=False) + _type: str = field(default='StatusPacket', hash=False) status: Optional[str] = field(default=None) messagecapable: bool = field(default=False) comment: Optional[str] = field(default=None) raw_timestamp: Optional[str] = field(default=None) def _build_payload(self): - self.payload = ":{}:{}{{{}".format( + self.payload = ':{}:{}{{{}'.format( self.to_call.ljust(9), - self._filter_for_send(self.status).rstrip("\n"), + self._filter_for_send(self.status).rstrip('\n'), str(self.msgNo), ) @@ -289,7 +287,7 @@ class StatusPacket(Packet): @dataclass_json @dataclass(unsafe_hash=True) class GPSPacket(Packet): - _type: str = field(default="GPSPacket", hash=False) + _type: str = field(default='GPSPacket', hash=False) latitude: float = field(default=0.00) longitude: float = field(default=0.00) altitude: float = field(default=0.00) @@ -297,8 +295,8 @@ class GPSPacket(Packet): posambiguity: int = field(default=0) messagecapable: bool = field(default=False) comment: Optional[str] = field(default=None) - symbol: str = field(default="l") - symbol_table: str = field(default="/") + symbol: str = field(default='l') + symbol_table: str = field(default='/') raw_timestamp: Optional[str] = field(default=None) object_name: Optional[str] = field(default=None) object_format: Optional[str] = field(default=None) @@ -318,7 +316,7 @@ class GPSPacket(Packet): def _build_time_zulu(self): """Build the timestamp in UTC/zulu.""" if self.timestamp: - return datetime.utcfromtimestamp(self.timestamp).strftime("%d%H%M") + return datetime.utcfromtimestamp(self.timestamp).strftime('%d%H%M') def _build_payload(self): """The payload is the non headers portion of the packet.""" @@ -326,7 +324,7 @@ class GPSPacket(Packet): lat = aprslib_util.latitude_to_ddm(self.latitude) long = aprslib_util.longitude_to_ddm(self.longitude) payload = [ - "@" if self.timestamp else "!", + '@' if self.timestamp else '!', time_zulu, lat, self.symbol_table, @@ -337,34 +335,34 @@ class GPSPacket(Packet): if self.comment: payload.append(self._filter_for_send(self.comment)) - self.payload = "".join(payload) + self.payload = ''.join(payload) def _build_raw(self): - self.raw = f"{self.from_call}>{self.to_call},WIDE2-1:" f"{self.payload}" + self.raw = f'{self.from_call}>{self.to_call},WIDE2-1:{self.payload}' @property def human_info(self) -> str: h_str = [] - h_str.append(f"Lat:{self.latitude:03.3f}") - h_str.append(f"Lon:{self.longitude:03.3f}") + h_str.append(f'Lat:{self.latitude:03.3f}') + h_str.append(f'Lon:{self.longitude:03.3f}') if self.altitude: - h_str.append(f"Altitude {self.altitude:03.0f}") + h_str.append(f'Altitude {self.altitude:03.0f}') if self.speed: - h_str.append(f"Speed {self.speed:03.0f}MPH") + h_str.append(f'Speed {self.speed:03.0f}MPH') if self.course: - h_str.append(f"Course {self.course:03.0f}") + h_str.append(f'Course {self.course:03.0f}') if self.rng: - h_str.append(f"RNG {self.rng:03.0f}") + h_str.append(f'RNG {self.rng:03.0f}') if self.phg: - h_str.append(f"PHG {self.phg}") + h_str.append(f'PHG {self.phg}') - return " ".join(h_str) + return ' '.join(h_str) @dataclass_json @dataclass(unsafe_hash=True) class BeaconPacket(GPSPacket): - _type: str = field(default="BeaconPacket", hash=False) + _type: str = field(default='BeaconPacket', hash=False) def _build_payload(self): """The payload is the non headers portion of the packet.""" @@ -372,38 +370,38 @@ class BeaconPacket(GPSPacket): lat = aprslib_util.latitude_to_ddm(self.latitude) lon = aprslib_util.longitude_to_ddm(self.longitude) - self.payload = f"@{time_zulu}z{lat}{self.symbol_table}" f"{lon}" + self.payload = f'@{time_zulu}z{lat}{self.symbol_table}{lon}' if self.comment: comment = self._filter_for_send(self.comment) - self.payload = f"{self.payload}{self.symbol}{comment}" + self.payload = f'{self.payload}{self.symbol}{comment}' else: - self.payload = f"{self.payload}{self.symbol}APRSD Beacon" + self.payload = f'{self.payload}{self.symbol}APRSD Beacon' def _build_raw(self): - self.raw = f"{self.from_call}>APZ100:" f"{self.payload}" + self.raw = f'{self.from_call}>APZ100:{self.payload}' @property def key(self) -> str: """Build a key for finding this packet in a dict.""" if self.raw_timestamp: - return f"{self.from_call}:{self.raw_timestamp}" + return f'{self.from_call}:{self.raw_timestamp}' else: - return f"{self.from_call}:{self.human_info.replace(' ', '')}" + return f'{self.from_call}:{self.human_info.replace(" ", "")}' @property def human_info(self) -> str: h_str = [] - h_str.append(f"Lat:{self.latitude:03.3f}") - h_str.append(f"Lon:{self.longitude:03.3f}") - h_str.append(f"{self.comment}") - return " ".join(h_str) + h_str.append(f'Lat:{self.latitude:03.3f}') + h_str.append(f'Lon:{self.longitude:03.3f}') + h_str.append(f'{self.comment}') + return ' '.join(h_str) @dataclass_json @dataclass(unsafe_hash=True) class MicEPacket(GPSPacket): - _type: str = field(default="MicEPacket", hash=False) + _type: str = field(default='MicEPacket', hash=False) messagecapable: bool = False mbits: Optional[str] = None mtype: Optional[str] = None @@ -416,18 +414,18 @@ class MicEPacket(GPSPacket): @property def key(self) -> str: """Build a key for finding this packet in a dict.""" - return f"{self.from_call}:{self.human_info.replace(' ', '')}" + return f'{self.from_call}:{self.human_info.replace(" ", "")}' @property def human_info(self) -> str: h_info = super().human_info - return f"{h_info} {self.mbits} mbits" + return f'{h_info} {self.mbits} mbits' @dataclass_json @dataclass(unsafe_hash=True) class TelemetryPacket(GPSPacket): - _type: str = field(default="TelemetryPacket", hash=False) + _type: str = field(default='TelemetryPacket', hash=False) messagecapable: bool = False mbits: Optional[str] = None mtype: Optional[str] = None @@ -443,23 +441,23 @@ class TelemetryPacket(GPSPacket): def key(self) -> str: """Build a key for finding this packet in a dict.""" if self.raw_timestamp: - return f"{self.from_call}:{self.raw_timestamp}" + return f'{self.from_call}:{self.raw_timestamp}' else: - return f"{self.from_call}:{self.human_info.replace(' ', '')}" + return f'{self.from_call}:{self.human_info.replace(" ", "")}' @property def human_info(self) -> str: h_info = super().human_info - return f"{h_info} {self.telemetry}" + return f'{h_info} {self.telemetry}' @dataclass_json @dataclass(unsafe_hash=True) class ObjectPacket(GPSPacket): - _type: str = field(default="ObjectPacket", hash=False) + _type: str = field(default='ObjectPacket', hash=False) alive: bool = True raw_timestamp: Optional[str] = None - symbol: str = field(default="r") + symbol: str = field(default='r') # in MPH speed: float = 0.00 # 0 to 360 @@ -470,11 +468,11 @@ class ObjectPacket(GPSPacket): lat = aprslib_util.latitude_to_ddm(self.latitude) long = aprslib_util.longitude_to_ddm(self.longitude) - self.payload = f"*{time_zulu}z{lat}{self.symbol_table}" f"{long}{self.symbol}" + self.payload = f'*{time_zulu}z{lat}{self.symbol_table}{long}{self.symbol}' if self.comment: comment = self._filter_for_send(self.comment) - self.payload = f"{self.payload}{comment}" + self.payload = f'{self.payload}{comment}' def _build_raw(self): """ @@ -487,18 +485,18 @@ class ObjectPacket(GPSPacket): The frequency, uplink_tone, offset is part of the comment """ - self.raw = f"{self.from_call}>APZ100:;{self.to_call:9s}" f"{self.payload}" + self.raw = f'{self.from_call}>APZ100:;{self.to_call:9s}{self.payload}' @property def human_info(self) -> str: h_info = super().human_info - return f"{h_info} {self.comment}" + return f'{h_info} {self.comment}' @dataclass(unsafe_hash=True) class WeatherPacket(GPSPacket, DataClassJsonMixin): - _type: str = field(default="WeatherPacket", hash=False) - symbol: str = "_" + _type: str = field(default='WeatherPacket', hash=False) + symbol: str = '_' wind_speed: float = 0.00 wind_direction: int = 0 wind_gust: float = 0.00 @@ -516,8 +514,8 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin): speed: Optional[float] = field(default=None) def _translate(self, raw: dict) -> dict: - for key in raw["weather"]: - raw[key] = raw["weather"][key] + for key in raw['weather']: + raw[key] = raw['weather'][key] # If we have the broken aprslib, then we need to # Convert the course and speed to wind_speed and wind_direction @@ -525,36 +523,36 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin): # https://github.com/rossengeorgiev/aprs-python/issues/80 # Wind speed and course is option in the SPEC. # For some reason aprslib multiplies the speed by 1.852. - if "wind_speed" not in raw and "wind_direction" not in raw: + if 'wind_speed' not in raw and 'wind_direction' not in raw: # Most likely this is the broken aprslib # So we need to convert the wind_gust speed - raw["wind_gust"] = round(raw.get("wind_gust", 0) / 0.44704, 3) - if "wind_speed" not in raw: - wind_speed = raw.get("speed") + raw['wind_gust'] = round(raw.get('wind_gust', 0) / 0.44704, 3) + if 'wind_speed' not in raw: + wind_speed = raw.get('speed') if wind_speed: - raw["wind_speed"] = round(wind_speed / 1.852, 3) - raw["weather"]["wind_speed"] = raw["wind_speed"] - if "speed" in raw: - del raw["speed"] + raw['wind_speed'] = round(wind_speed / 1.852, 3) + raw['weather']['wind_speed'] = raw['wind_speed'] + if 'speed' in raw: + del raw['speed'] # Let's adjust the rain numbers as well, since it's wrong - raw["rain_1h"] = round((raw.get("rain_1h", 0) / 0.254) * 0.01, 3) - raw["weather"]["rain_1h"] = raw["rain_1h"] - raw["rain_24h"] = round((raw.get("rain_24h", 0) / 0.254) * 0.01, 3) - raw["weather"]["rain_24h"] = raw["rain_24h"] - raw["rain_since_midnight"] = round( - (raw.get("rain_since_midnight", 0) / 0.254) * 0.01, 3 + raw['rain_1h'] = round((raw.get('rain_1h', 0) / 0.254) * 0.01, 3) + raw['weather']['rain_1h'] = raw['rain_1h'] + raw['rain_24h'] = round((raw.get('rain_24h', 0) / 0.254) * 0.01, 3) + raw['weather']['rain_24h'] = raw['rain_24h'] + raw['rain_since_midnight'] = round( + (raw.get('rain_since_midnight', 0) / 0.254) * 0.01, 3 ) - raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"] + raw['weather']['rain_since_midnight'] = raw['rain_since_midnight'] - if "wind_direction" not in raw: - wind_direction = raw.get("course") + if 'wind_direction' not in raw: + wind_direction = raw.get('course') if wind_direction: - raw["wind_direction"] = wind_direction - raw["weather"]["wind_direction"] = raw["wind_direction"] - if "course" in raw: - del raw["course"] + raw['wind_direction'] = wind_direction + raw['weather']['wind_direction'] = raw['wind_direction'] + if 'course' in raw: + del raw['course'] - del raw["weather"] + del raw['weather'] return raw @classmethod @@ -567,20 +565,20 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin): def key(self) -> str: """Build a key for finding this packet in a dict.""" if self.raw_timestamp: - return f"{self.from_call}:{self.raw_timestamp}" + return f'{self.from_call}:{self.raw_timestamp}' elif self.wx_raw_timestamp: - return f"{self.from_call}:{self.wx_raw_timestamp}" + return f'{self.from_call}:{self.wx_raw_timestamp}' @property def human_info(self) -> str: h_str = [] - h_str.append(f"Temp {self.temperature:03.0f}F") - h_str.append(f"Humidity {self.humidity}%") - h_str.append(f"Wind {self.wind_speed:03.0f}MPH@{self.wind_direction}") - h_str.append(f"Pressure {self.pressure}mb") - h_str.append(f"Rain {self.rain_24h}in/24hr") + h_str.append(f'Temp {self.temperature:03.0f}F') + h_str.append(f'Humidity {self.humidity}%') + h_str.append(f'Wind {self.wind_speed:03.0f}MPH@{self.wind_direction}') + h_str.append(f'Pressure {self.pressure}mb') + h_str.append(f'Rain {self.rain_24h}in/24hr') - return " ".join(h_str) + return ' '.join(h_str) def _build_payload(self): """Build an uncompressed weather packet @@ -610,49 +608,49 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin): time_zulu = self._build_time_zulu() contents = [ - f"@{time_zulu}z{self.latitude}{self.symbol_table}", - f"{self.longitude}{self.symbol}", - f"{self.wind_direction:03d}", + f'@{time_zulu}z{self.latitude}{self.symbol_table}', + f'{self.longitude}{self.symbol}', + f'{self.wind_direction:03d}', # Speed = sustained 1 minute wind speed in mph - f"{self.symbol_table}", - f"{self.wind_speed:03.0f}", + f'{self.symbol_table}', + f'{self.wind_speed:03.0f}', # wind gust (peak wind speed in mph in the last 5 minutes) - f"g{self.wind_gust:03.0f}", + f'g{self.wind_gust:03.0f}', # Temperature in degrees F - f"t{self.temperature:03.0f}", + f't{self.temperature:03.0f}', # Rainfall (in hundredths of an inch) in the last hour - f"r{self.rain_1h * 100:03.0f}", + f'r{self.rain_1h * 100:03.0f}', # Rainfall (in hundredths of an inch) in last 24 hours - f"p{self.rain_24h * 100:03.0f}", + f'p{self.rain_24h * 100:03.0f}', # Rainfall (in hundredths of an inch) since midnigt - f"P{self.rain_since_midnight * 100:03.0f}", + f'P{self.rain_since_midnight * 100:03.0f}', # Humidity - f"h{self.humidity:02d}", + f'h{self.humidity:02d}', # Barometric pressure (in tenths of millibars/tenths of hPascal) - f"b{self.pressure:05.0f}", + f'b{self.pressure:05.0f}', ] if self.comment: - comment = self.filter_for_send(self.comment) + comment = self._filter_for_send(self.comment) contents.append(comment) - self.payload = "".join(contents) + self.payload = ''.join(contents) def _build_raw(self): - self.raw = f"{self.from_call}>{self.to_call},WIDE1-1,WIDE2-1:" f"{self.payload}" + self.raw = f'{self.from_call}>{self.to_call},WIDE1-1,WIDE2-1:{self.payload}' @dataclass(unsafe_hash=True) class ThirdPartyPacket(Packet, DataClassJsonMixin): - _type: str = "ThirdPartyPacket" + _type: str = 'ThirdPartyPacket' # Holds the encapsulated packet subpacket: Optional[type[Packet]] = field(default=None, compare=True, hash=False) def __repr__(self): """Build the repr version of the packet.""" repr_str = ( - f"{self.__class__.__name__}:" - f" From: {self.from_call} " - f" To: {self.to_call} " - f" Subpacket: {repr(self.subpacket)}" + f'{self.__class__.__name__}:' + f' From: {self.from_call} ' + f' To: {self.to_call} ' + f' Subpacket: {repr(self.subpacket)}' ) return repr_str @@ -666,12 +664,12 @@ class ThirdPartyPacket(Packet, DataClassJsonMixin): @property def key(self) -> str: """Build a key for finding this packet in a dict.""" - return f"{self.from_call}:{self.subpacket.key}" + return f'{self.from_call}:{self.subpacket.key}' @property def human_info(self) -> str: sub_info = self.subpacket.human_info - return f"{self.from_call}->{self.to_call} {sub_info}" + return f'{self.from_call}->{self.to_call} {sub_info}' @dataclass_json(undefined=Undefined.INCLUDE) @@ -683,7 +681,7 @@ class UnknownPacket: """ unknown_fields: CatchAll - _type: str = "UnknownPacket" + _type: str = 'UnknownPacket' from_call: Optional[str] = field(default=None) to_call: Optional[str] = field(default=None) msgNo: str = field(default_factory=_init_msgNo) # noqa: N815 @@ -701,7 +699,7 @@ class UnknownPacket: @property def key(self) -> str: """Build a key for finding this packet in a dict.""" - return f"{self.from_call}:{self.packet_type}:{self.to_call}" + return f'{self.from_call}:{self.packet_type}:{self.to_call}' @property def human_info(self) -> str: @@ -728,20 +726,20 @@ TYPE_LOOKUP: dict[str, type[Packet]] = { def get_packet_type(packet: dict) -> str: """Decode the packet type from the packet.""" - pkt_format = packet.get("format") - msg_response = packet.get("response") + pkt_format = packet.get('format') + msg_response = packet.get('response') packet_type = PACKET_TYPE_UNKNOWN - if pkt_format == "message" and msg_response == "ack": + if pkt_format == 'message' and msg_response == 'ack': packet_type = PACKET_TYPE_ACK - elif pkt_format == "message" and msg_response == "rej": + elif pkt_format == 'message' and msg_response == 'rej': packet_type = PACKET_TYPE_REJECT - elif pkt_format == "message": + elif pkt_format == 'message': packet_type = PACKET_TYPE_MESSAGE - elif pkt_format == "mic-e": + elif pkt_format == 'mic-e': packet_type = PACKET_TYPE_MICE - elif pkt_format == "object": + elif pkt_format == 'object': packet_type = PACKET_TYPE_OBJECT - elif pkt_format == "status": + elif pkt_format == 'status': packet_type = PACKET_TYPE_STATUS elif pkt_format == PACKET_TYPE_BULLETIN: packet_type = PACKET_TYPE_BULLETIN @@ -752,13 +750,13 @@ def get_packet_type(packet: dict) -> str: elif pkt_format == PACKET_TYPE_WX: packet_type = PACKET_TYPE_WEATHER elif pkt_format == PACKET_TYPE_UNCOMPRESSED: - if packet.get("symbol") == "_": + if packet.get('symbol') == '_': packet_type = PACKET_TYPE_WEATHER elif pkt_format == PACKET_TYPE_THIRDPARTY: packet_type = PACKET_TYPE_THIRDPARTY if packet_type == PACKET_TYPE_UNKNOWN: - if "latitude" in packet: + if 'latitude' in packet: packet_type = PACKET_TYPE_BEACON else: packet_type = PACKET_TYPE_UNKNOWN @@ -780,32 +778,32 @@ def is_mice_packet(packet: dict[Any, Any]) -> bool: def factory(raw_packet: dict[Any, Any]) -> type[Packet]: """Factory method to create a packet from a raw packet string.""" raw = raw_packet - if "_type" in raw: - cls = globals()[raw["_type"]] + if '_type' in raw: + cls = globals()[raw['_type']] return cls.from_dict(raw) - raw["raw_dict"] = raw.copy() + raw['raw_dict'] = raw.copy() raw = _translate_fields(raw) packet_type = get_packet_type(raw) - raw["packet_type"] = packet_type + raw['packet_type'] = packet_type packet_class = TYPE_LOOKUP[packet_type] if packet_type == PACKET_TYPE_WX: # the weather information is in a dict # this brings those values out to the outer dict packet_class = WeatherPacket - elif packet_type == PACKET_TYPE_OBJECT and "weather" in raw: + elif packet_type == PACKET_TYPE_OBJECT and 'weather' in raw: packet_class = WeatherPacket elif packet_type == PACKET_TYPE_UNKNOWN: # Try and figure it out here - if "latitude" in raw: + if 'latitude' in raw: packet_class = GPSPacket else: # LOG.warning(raw) packet_class = UnknownPacket - raw.get("addresse", raw.get("to_call")) + raw.get('addresse', raw.get('to_call')) # TODO: Find a global way to enable/disable this # LOGU.opt(colors=True).info( diff --git a/aprsd/plugin.py b/aprsd/plugin.py index b3ede28..c40dff3 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -12,7 +12,8 @@ import pluggy from oslo_config import cfg import aprsd -from aprsd import client, packets, threads +from aprsd import packets, threads +from aprsd.client.client import APRSDClient from aprsd.packets import watch_list # setup the global logger @@ -146,7 +147,7 @@ class APRSDWatchListPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta): watch_list = CONF.watch_list.callsigns # make sure the timeout is set or this doesn't work if watch_list: - aprs_client = client.client_factory.create().client + aprs_client = APRSDClient() filter_str = 'b/{}'.format('/'.join(watch_list)) aprs_client.set_filter(filter_str) else: diff --git a/aprsd/stats/collector.py b/aprsd/stats/collector.py index 10d0b81..39423e4 100644 --- a/aprsd/stats/collector.py +++ b/aprsd/stats/collector.py @@ -3,7 +3,7 @@ from typing import Callable, Protocol, runtime_checkable from aprsd.utils import singleton -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') @runtime_checkable @@ -31,15 +31,16 @@ class Collector: serializable=serializable ).copy() except Exception as e: - LOG.error(f"Error in producer {name} (stats): {e}") + LOG.error(f'Error in producer {name} (stats): {e}') + raise e return stats def register_producer(self, producer_name: Callable): if not isinstance(producer_name, StatsProducer): - raise TypeError(f"Producer {producer_name} is not a StatsProducer") + raise TypeError(f'Producer {producer_name} is not a StatsProducer') self.producers.append(producer_name) def unregister_producer(self, producer_name: Callable): if not isinstance(producer_name, StatsProducer): - raise TypeError(f"Producer {producer_name} is not a StatsProducer") + raise TypeError(f'Producer {producer_name} is not a StatsProducer') self.producers.remove(producer_name) diff --git a/aprsd/threads/rx.py b/aprsd/threads/rx.py index b995f8c..bf5f40e 100644 --- a/aprsd/threads/rx.py +++ b/aprsd/threads/rx.py @@ -7,7 +7,7 @@ import aprslib from oslo_config import cfg from aprsd import packets, plugin -from aprsd.client import client_factory +from aprsd.client.client import APRSDClient from aprsd.packets import collector, filter from aprsd.packets import log as packet_log from aprsd.threads import APRSDThread, tx @@ -39,16 +39,16 @@ class APRSDRXThread(APRSDThread): def stop(self): self.thread_stop = True if self._client: - self._client.stop() + self._client.close() def loop(self): if not self._client: - self._client = client_factory.create() + self._client = APRSDClient() time.sleep(1) return True - if not self._client.is_connected: - self._client = client_factory.create() + if not self._client.is_alive: + self._client = APRSDClient() time.sleep(1) return True @@ -66,7 +66,6 @@ class APRSDRXThread(APRSDThread): self._client.consumer( self.process_packet, raw=False, - blocking=False, ) except ( aprslib.exceptions.ConnectionDrop, @@ -78,8 +77,8 @@ class APRSDRXThread(APRSDThread): # is called self._client.reset() time.sleep(5) - except Exception: - # LOG.exception(ex) + except Exception as ex: + LOG.exception(ex) LOG.error('Resetting connection and trying again.') self._client.reset() time.sleep(5) diff --git a/aprsd/threads/tx.py b/aprsd/threads/tx.py index 72f33b6..0421d76 100644 --- a/aprsd/threads/tx.py +++ b/aprsd/threads/tx.py @@ -11,12 +11,12 @@ from rush.stores import dictionary from aprsd import conf # noqa from aprsd import threads as aprsd_threads -from aprsd.client import client_factory +from aprsd.client.client import APRSDClient from aprsd.packets import collector, core, tracker from aprsd.packets import log as packet_log CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') msg_t = throttle.Throttle( limiter=periodic.PeriodicLimiter( @@ -54,7 +54,7 @@ def send(packet: core.Packet, direct=False, aprs_client=None): if CONF.enable_sending_ack_packets: _send_ack(packet, direct=direct, aprs_client=aprs_client) else: - LOG.info("Sending ack packets is disabled. Not sending AckPacket.") + LOG.info('Sending ack packets is disabled. Not sending AckPacket.') else: _send_packet(packet, direct=direct, aprs_client=aprs_client) @@ -81,14 +81,14 @@ def _send_direct(packet, aprs_client=None): if aprs_client: cl = aprs_client else: - cl = client_factory.create() + cl = APRSDClient() packet.update_timestamp() packet_log.log(packet, tx=True) try: cl.send(packet) except Exception as e: - LOG.error(f"Failed to send packet: {packet}") + LOG.error(f'Failed to send packet: {packet}') LOG.error(e) return False else: @@ -100,7 +100,7 @@ class SendPacketThread(aprsd_threads.APRSDThread): def __init__(self, packet): self.packet = packet - super().__init__(f"TX-{packet.to_call}-{self.packet.msgNo}") + super().__init__(f'TX-{packet.to_call}-{self.packet.msgNo}') def loop(self): """Loop until a message is acked or it gets delayed. @@ -119,9 +119,9 @@ class SendPacketThread(aprsd_threads.APRSDThread): # The message has been removed from the tracking queue # So it got acked and we are done. LOG.info( - f"{self.packet.__class__.__name__}" - f"({self.packet.msgNo}) " - "Message Send Complete via Ack.", + f'{self.packet.__class__.__name__}' + f'({self.packet.msgNo}) ' + 'Message Send Complete via Ack.', ) return False else: @@ -130,10 +130,10 @@ class SendPacketThread(aprsd_threads.APRSDThread): # we reached the send limit, don't send again # TODO(hemna) - Need to put this in a delayed queue? LOG.info( - f"{packet.__class__.__name__} " - f"({packet.msgNo}) " - "Message Send Complete. Max attempts reached" - f" {packet.retry_count}", + f'{packet.__class__.__name__} ' + f'({packet.msgNo}) ' + 'Message Send Complete. Max attempts reached' + f' {packet.retry_count}', ) pkt_tracker.remove(packet.msgNo) return False @@ -158,7 +158,7 @@ class SendPacketThread(aprsd_threads.APRSDThread): try: sent = _send_direct(packet) except Exception: - LOG.error(f"Failed to send packet: {packet}") + LOG.error(f'Failed to send packet: {packet}') else: # If an exception happens while sending # we don't want this attempt to count @@ -178,7 +178,7 @@ class SendAckThread(aprsd_threads.APRSDThread): def __init__(self, packet): self.packet = packet - super().__init__(f"TXAck-{packet.to_call}-{self.packet.msgNo}") + super().__init__(f'TXAck-{packet.to_call}-{self.packet.msgNo}') self.max_retries = CONF.default_ack_send_count def loop(self): @@ -188,10 +188,10 @@ class SendAckThread(aprsd_threads.APRSDThread): # we reached the send limit, don't send again # TODO(hemna) - Need to put this in a delayed queue? LOG.debug( - f"{self.packet.__class__.__name__}" - f"({self.packet.msgNo}) " - "Send Complete. Max attempts reached" - f" {self.max_retries}", + f'{self.packet.__class__.__name__}' + f'({self.packet.msgNo}) ' + 'Send Complete. Max attempts reached' + f' {self.max_retries}', ) return False @@ -207,7 +207,7 @@ class SendAckThread(aprsd_threads.APRSDThread): # It's time to try to send it again send_now = True elif self.loop_count % 10 == 0: - LOG.debug(f"Still wating. {delta}") + LOG.debug(f'Still wating. {delta}') else: send_now = True @@ -216,7 +216,7 @@ class SendAckThread(aprsd_threads.APRSDThread): try: sent = _send_direct(self.packet) except Exception: - LOG.error(f"Failed to send packet: {self.packet}") + LOG.error(f'Failed to send packet: {self.packet}') else: # If an exception happens while sending # we don't want this attempt to count @@ -240,18 +240,18 @@ class BeaconSendThread(aprsd_threads.APRSDThread): _loop_cnt: int = 1 def __init__(self): - super().__init__("BeaconSendThread") + super().__init__('BeaconSendThread') self._loop_cnt = 1 # Make sure Latitude and Longitude are set. if not CONF.latitude or not CONF.longitude: LOG.error( - "Latitude and Longitude are not set in the config file." - "Beacon will not be sent and thread is STOPPED.", + 'Latitude and Longitude are not set in the config file.' + 'Beacon will not be sent and thread is STOPPED.', ) self.stop() LOG.info( - "Beacon thread is running and will send " - f"beacons every {CONF.beacon_interval} seconds.", + 'Beacon thread is running and will send ' + f'beacons every {CONF.beacon_interval} seconds.', ) def loop(self): @@ -259,10 +259,10 @@ class BeaconSendThread(aprsd_threads.APRSDThread): if self._loop_cnt % CONF.beacon_interval == 0: pkt = core.BeaconPacket( from_call=CONF.callsign, - to_call="APRS", + to_call='APRS', latitude=float(CONF.latitude), longitude=float(CONF.longitude), - comment="APRSD GPS Beacon", + comment='APRSD GPS Beacon', symbol=CONF.beacon_symbol, ) try: @@ -270,8 +270,8 @@ class BeaconSendThread(aprsd_threads.APRSDThread): pkt.retry_count = 1 send(pkt, direct=True) except Exception as e: - LOG.error(f"Failed to send beacon: {e}") - client_factory.create().reset() + LOG.error(f'Failed to send beacon: {e}') + APRSDClient().reset() time.sleep(5) self._loop_cnt += 1 diff --git a/aprsd/utils/keepalive_collector.py b/aprsd/utils/keepalive_collector.py index 9c53b06..1bf6dfe 100644 --- a/aprsd/utils/keepalive_collector.py +++ b/aprsd/utils/keepalive_collector.py @@ -3,7 +3,7 @@ from typing import Callable, Protocol, runtime_checkable from aprsd.utils import singleton -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') @runtime_checkable @@ -33,7 +33,8 @@ class KeepAliveCollector: try: cls.keepalive_check() except Exception as e: - LOG.error(f"Error in producer {name} (check): {e}") + LOG.error(f'Error in producer {name} (check): {e}') + raise e def log(self) -> None: """Log any relevant information during a KeepAlive check""" @@ -42,14 +43,15 @@ class KeepAliveCollector: try: cls.keepalive_log() except Exception as e: - LOG.error(f"Error in producer {name} (check): {e}") + LOG.error(f'Error in producer {name} (check): {e}') + raise e def register(self, producer_name: Callable): if not isinstance(producer_name, KeepAliveProducer): - raise TypeError(f"Producer {producer_name} is not a KeepAliveProducer") + raise TypeError(f'Producer {producer_name} is not a KeepAliveProducer') self.producers.append(producer_name) def unregister(self, producer_name: Callable): if not isinstance(producer_name, KeepAliveProducer): - raise TypeError(f"Producer {producer_name} is not a KeepAliveProducer") + raise TypeError(f'Producer {producer_name} is not a KeepAliveProducer') self.producers.remove(producer_name) diff --git a/tests/client/__init__.py b/tests/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/client/drivers/__init__.py b/tests/client/drivers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/client/drivers/test_aprsis_driver.py b/tests/client/drivers/test_aprsis_driver.py new file mode 100644 index 0000000..4ab1d6c --- /dev/null +++ b/tests/client/drivers/test_aprsis_driver.py @@ -0,0 +1,440 @@ +import datetime +import unittest +from unittest import mock + +from aprslib.exceptions import LoginError + +from aprsd import exception +from aprsd.client.drivers.aprsis import APRSISDriver +from aprsd.client.drivers.registry import ClientDriver +from aprsd.packets import core + + +class TestAPRSISDriver(unittest.TestCase): + """Unit tests for the APRSISDriver class.""" + + def setUp(self): + # Mock configuration + self.conf_patcher = mock.patch('aprsd.client.drivers.aprsis.CONF') + self.mock_conf = self.conf_patcher.start() + + # Configure APRS-IS settings + self.mock_conf.aprs_network.enabled = True + self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.aprs_network.password = '12345' + self.mock_conf.aprs_network.host = 'rotate.aprs.net' + self.mock_conf.aprs_network.port = 14580 + + # Mock APRS Lib Client + self.aprslib_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSLibClient') + self.mock_aprslib = self.aprslib_patcher.start() + self.mock_client = mock.MagicMock() + self.mock_aprslib.return_value = self.mock_client + + # Create an instance of the driver + self.driver = APRSISDriver() + + def tearDown(self): + self.conf_patcher.stop() + self.aprslib_patcher.stop() + + def test_implements_client_driver_protocol(self): + """Test that APRSISDriver implements the ClientDriver Protocol.""" + # Verify the instance is recognized as implementing the Protocol + self.assertIsInstance(self.driver, ClientDriver) + + # Verify all required methods are present with correct signatures + required_methods = [ + 'is_enabled', + 'is_configured', + 'is_alive', + 'close', + 'send', + 'setup_connection', + 'set_filter', + 'login_success', + 'login_failure', + 'consumer', + 'decode_packet', + 'stats', + ] + + for method_name in required_methods: + self.assertTrue( + hasattr(self.driver, method_name), + f'Missing required method: {method_name}', + ) + + def test_init(self): + """Test initialization sets default values.""" + self.assertIsInstance(self.driver.max_delta, datetime.timedelta) + self.assertEqual(self.driver.max_delta, datetime.timedelta(minutes=2)) + self.assertFalse(self.driver.login_status['success']) + self.assertIsNone(self.driver.login_status['message']) + self.assertIsNone(self.driver._client) + + def test_is_enabled_true(self): + """Test is_enabled returns True when APRS-IS is enabled.""" + self.mock_conf.aprs_network.enabled = True + self.assertTrue(APRSISDriver.is_enabled()) + + def test_is_enabled_false(self): + """Test is_enabled returns False when APRS-IS is disabled.""" + self.mock_conf.aprs_network.enabled = False + self.assertFalse(APRSISDriver.is_enabled()) + + def test_is_enabled_key_error(self): + """Test is_enabled returns False when enabled flag doesn't exist.""" + self.mock_conf.aprs_network = mock.MagicMock() + type(self.mock_conf.aprs_network).enabled = mock.PropertyMock( + side_effect=KeyError + ) + self.assertFalse(APRSISDriver.is_enabled()) + + def test_is_configured_true(self): + """Test is_configured returns True when properly configured.""" + with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): + self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.aprs_network.password = '12345' + self.mock_conf.aprs_network.host = 'rotate.aprs.net' + + self.assertTrue(APRSISDriver.is_configured()) + + def test_is_configured_no_login(self): + """Test is_configured raises exception when login not set.""" + with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): + self.mock_conf.aprs_network.login = None + + with self.assertRaises(exception.MissingConfigOptionException): + APRSISDriver.is_configured() + + def test_is_configured_no_password(self): + """Test is_configured raises exception when password not set.""" + with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): + self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.aprs_network.password = None + + with self.assertRaises(exception.MissingConfigOptionException): + APRSISDriver.is_configured() + + def test_is_configured_no_host(self): + """Test is_configured raises exception when host not set.""" + with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): + self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.aprs_network.password = '12345' + self.mock_conf.aprs_network.host = None + + with self.assertRaises(exception.MissingConfigOptionException): + APRSISDriver.is_configured() + + def test_is_configured_disabled(self): + """Test is_configured returns True when not enabled.""" + with mock.patch.object(APRSISDriver, 'is_enabled', return_value=False): + self.assertTrue(APRSISDriver.is_configured()) + + def test_is_alive_no_client(self): + """Test is_alive returns False when no client.""" + self.driver._client = None + self.assertFalse(self.driver.is_alive) + + def test_is_alive_true(self): + """Test is_alive returns True when client is alive and connection is not stale.""" + self.driver._client = self.mock_client + self.mock_client.is_alive.return_value = True + + with mock.patch.object(self.driver, '_is_stale_connection', return_value=False): + self.assertTrue(self.driver.is_alive) + + def test_is_alive_client_not_alive(self): + """Test is_alive returns False when client is not alive.""" + self.driver._client = self.mock_client + self.mock_client.is_alive.return_value = False + + with mock.patch.object(self.driver, '_is_stale_connection', return_value=False): + self.assertFalse(self.driver.is_alive) + + def test_is_alive_stale_connection(self): + """Test is_alive returns False when connection is stale.""" + self.driver._client = self.mock_client + self.mock_client.is_alive.return_value = True + + with mock.patch.object(self.driver, '_is_stale_connection', return_value=True): + self.assertFalse(self.driver.is_alive) + + def test_close(self): + """Test close method stops and closes the client.""" + self.driver._client = self.mock_client + + self.driver.close() + + self.mock_client.stop.assert_called_once() + self.mock_client.close.assert_called_once() + + def test_close_no_client(self): + """Test close method handles no client gracefully.""" + self.driver._client = None + + # Should not raise exception + self.driver.close() + + def test_send(self): + """Test send passes packet to client.""" + self.driver._client = self.mock_client + mock_packet = mock.MagicMock(spec=core.Packet) + + self.driver.send(mock_packet) + + self.mock_client.send.assert_called_once_with(mock_packet) + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + def test_setup_connection_success(self, mock_log): + """Test setup_connection successfully connects.""" + # Configure successful connection + self.mock_client.server_string = 'Test APRS-IS Server' + + self.driver.setup_connection() + + # Check client created with correct parameters + self.mock_aprslib.assert_called_once_with( + self.mock_conf.aprs_network.login, + passwd=self.mock_conf.aprs_network.password, + host=self.mock_conf.aprs_network.host, + port=self.mock_conf.aprs_network.port, + ) + + # Check logger set and connection initialized + self.assertEqual(self.mock_client.logger, mock_log) + self.mock_client.connect.assert_called_once() + + # Check status updated + self.assertTrue(self.driver.connected) + self.assertTrue(self.driver.login_status['success']) + self.assertEqual(self.driver.login_status['message'], 'Test APRS-IS Server') + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + @mock.patch('aprsd.client.drivers.aprsis.time.sleep') + def test_setup_connection_login_error(self, mock_sleep, mock_log): + """Test setup_connection handles login error.""" + # Configure login error + login_error = LoginError('Bad login') + login_error.message = 'Invalid login credentials' + self.mock_client.connect.side_effect = login_error + + self.driver.setup_connection() + + # Check error logged + mock_log.error.assert_any_call("Failed to login to APRS-IS Server 'Bad login'") + mock_log.error.assert_any_call('Invalid login credentials') + + # Check status updated + self.assertFalse(self.driver.connected) + self.assertFalse(self.driver.login_status['success']) + self.assertEqual( + self.driver.login_status['message'], 'Invalid login credentials' + ) + + # Check backoff used + mock_sleep.assert_called() + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + @mock.patch('aprsd.client.drivers.aprsis.time.sleep') + def test_setup_connection_general_error(self, mock_sleep, mock_log): + """Test setup_connection handles general error.""" + # Configure general exception + error_message = 'Connection error' + error = Exception(error_message) + # Standard exceptions don't have a message attribute + self.mock_client.connect.side_effect = error + + self.driver.setup_connection() + + # Check error logged + mock_log.error.assert_any_call( + f"Unable to connect to APRS-IS server. '{error_message}' " + ) + + # Check status updated + self.assertFalse(self.driver.connected) + self.assertFalse(self.driver.login_status['success']) + + # Check login message contains the error message (more flexible than exact equality) + self.assertIn(error_message, self.driver.login_status['message']) + + # Check backoff used + mock_sleep.assert_called() + + def test_set_filter(self): + """Test set_filter passes filter to client.""" + self.driver._client = self.mock_client + test_filter = 'm/50' + + self.driver.set_filter(test_filter) + + self.mock_client.set_filter.assert_called_once_with(test_filter) + + def test_login_success(self): + """Test login_success returns login status.""" + self.driver.login_status['success'] = True + self.assertTrue(self.driver.login_success()) + + self.driver.login_status['success'] = False + self.assertFalse(self.driver.login_success()) + + def test_login_failure(self): + """Test login_failure returns error message.""" + self.driver.login_status['message'] = None + self.assertIsNone(self.driver.login_failure()) + + self.driver.login_status['message'] = 'Test error' + self.assertEqual(self.driver.login_failure(), 'Test error') + + def test_filter_property(self): + """Test filter property returns client filter.""" + self.driver._client = self.mock_client + test_filter = 'm/50' + self.mock_client.filter = test_filter + + self.assertEqual(self.driver.filter, test_filter) + + def test_server_string_property(self): + """Test server_string property returns client server string.""" + self.driver._client = self.mock_client + test_string = 'Test APRS-IS Server' + self.mock_client.server_string = test_string + + self.assertEqual(self.driver.server_string, test_string) + + def test_keepalive_property(self): + """Test keepalive property returns client keepalive.""" + self.driver._client = self.mock_client + test_time = datetime.datetime.now() + self.mock_client.aprsd_keepalive = test_time + + self.assertEqual(self.driver.keepalive, test_time) + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + def test_is_stale_connection_true(self, mock_log): + """Test _is_stale_connection returns True when connection is stale.""" + self.driver._client = self.mock_client + # Set keepalive to 3 minutes ago (exceeds max_delta of 2 minutes) + self.mock_client.aprsd_keepalive = datetime.datetime.now() - datetime.timedelta( + minutes=3 + ) + + result = self.driver._is_stale_connection() + + self.assertTrue(result) + mock_log.error.assert_called_once() + + def test_is_stale_connection_false(self): + """Test _is_stale_connection returns False when connection is not stale.""" + self.driver._client = self.mock_client + # Set keepalive to 1 minute ago (within max_delta of 2 minutes) + self.mock_client.aprsd_keepalive = datetime.datetime.now() - datetime.timedelta( + minutes=1 + ) + + result = self.driver._is_stale_connection() + + self.assertFalse(result) + + def test_transport(self): + """Test transport returns appropriate transport type.""" + self.assertEqual(APRSISDriver.transport(), 'aprsis') + + def test_decode_packet(self): + """Test decode_packet uses core.factory.""" + with mock.patch('aprsd.client.drivers.aprsis.core.factory') as mock_factory: + raw_packet = {'from': 'TEST', 'to': 'APRS'} + self.driver.decode_packet(raw_packet) + mock_factory.assert_called_once_with(raw_packet) + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + def test_consumer_success(self, mock_log): + """Test consumer forwards callback to client.""" + self.driver._client = self.mock_client + mock_callback = mock.MagicMock() + + self.driver.consumer(mock_callback, raw=True) + + self.mock_client.consumer.assert_called_once_with( + mock_callback, blocking=False, immortal=False, raw=True + ) + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + def test_consumer_exception(self, mock_log): + """Test consumer handles exceptions.""" + self.driver._client = self.mock_client + mock_callback = mock.MagicMock() + test_error = Exception('Test error') + self.mock_client.consumer.side_effect = test_error + + with self.assertRaises(Exception): # noqa: B017 + self.driver.consumer(mock_callback) + + mock_log.error.assert_called_with(test_error) + + @mock.patch('aprsd.client.drivers.aprsis.LOG') + def test_consumer_no_client(self, mock_log): + """Test consumer handles no client gracefully.""" + self.driver._client = None + mock_callback = mock.MagicMock() + + self.driver.consumer(mock_callback) + + mock_log.warning.assert_called_once() + self.assertFalse(self.driver.connected) + + def test_stats_configured_with_client(self): + """Test stats returns correct data when configured with client.""" + # Configure driver + with mock.patch.object(self.driver, 'is_configured', return_value=True): + self.driver._client = self.mock_client + self.mock_client.aprsd_keepalive = datetime.datetime.now() + self.mock_client.server_string = 'Test Server' + self.mock_client.filter = 'm/50' + + stats = self.driver.stats() + + self.assertEqual(stats['connected'], True) + self.assertEqual(stats['filter'], 'm/50') + self.assertEqual(stats['server_string'], 'Test Server') + self.assertEqual(stats['transport'], 'aprsis') + + def test_stats_serializable(self): + """Test stats with serializable=True converts datetime to ISO format.""" + # Configure driver + with mock.patch.object(self.driver, 'is_configured', return_value=True): + self.driver._client = self.mock_client + test_time = datetime.datetime.now() + self.mock_client.aprsd_keepalive = test_time + + stats = self.driver.stats(serializable=True) + + # Check keepalive is a string in ISO format + self.assertIsInstance(stats['connection_keepalive'], str) + # Try parsing it to verify it's a valid ISO format + try: + datetime.datetime.fromisoformat(stats['connection_keepalive']) + except ValueError: + self.fail('keepalive is not in valid ISO format') + + def test_stats_no_client(self): + """Test stats with no client.""" + with mock.patch.object(self.driver, 'is_configured', return_value=True): + self.driver._client = None + + stats = self.driver.stats() + + self.assertEqual(stats['connection_keepalive'], 'None') + self.assertEqual(stats['server_string'], 'None') + + def test_stats_not_configured(self): + """Test stats when not configured returns empty dict.""" + with mock.patch.object(self.driver, 'is_configured', return_value=False): + stats = self.driver.stats() + self.assertEqual(stats, {}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/client/drivers/test_fake_driver.py b/tests/client/drivers/test_fake_driver.py new file mode 100644 index 0000000..b595b35 --- /dev/null +++ b/tests/client/drivers/test_fake_driver.py @@ -0,0 +1,191 @@ +import unittest +from unittest import mock + +from aprsd.client.drivers.fake import APRSDFakeDriver +from aprsd.packets import core + + +class TestAPRSDFakeDriver(unittest.TestCase): + """Unit tests for the APRSDFakeDriver class.""" + + def setUp(self): + # Mock CONF for testing + self.conf_patcher = mock.patch('aprsd.client.drivers.fake.CONF') + self.mock_conf = self.conf_patcher.start() + + # Configure fake_client.enabled + self.mock_conf.fake_client.enabled = True + + # Create an instance of the driver + self.driver = APRSDFakeDriver() + + def tearDown(self): + self.conf_patcher.stop() + + def test_init(self): + """Test initialization sets default values.""" + self.assertEqual(self.driver.path, ['WIDE1-1', 'WIDE2-1']) + self.assertFalse(self.driver.thread_stop) + + def test_is_enabled_true(self): + """Test is_enabled returns True when configured.""" + self.mock_conf.fake_client.enabled = True + self.assertTrue(APRSDFakeDriver.is_enabled()) + + def test_is_enabled_false(self): + """Test is_enabled returns False when not configured.""" + self.mock_conf.fake_client.enabled = False + self.assertFalse(APRSDFakeDriver.is_enabled()) + + def test_is_alive(self): + """Test is_alive returns True when thread_stop is False.""" + self.driver.thread_stop = False + self.assertTrue(self.driver.is_alive()) + + self.driver.thread_stop = True + self.assertFalse(self.driver.is_alive()) + + def test_close(self): + """Test close sets thread_stop to True.""" + self.driver.thread_stop = False + self.driver.close() + self.assertTrue(self.driver.thread_stop) + + @mock.patch('aprsd.client.drivers.fake.LOG') + def test_setup_connection(self, mock_log): + """Test setup_connection does nothing (it's fake).""" + self.driver.setup_connection() + # Method doesn't do anything, so just verify it doesn't crash + + def test_set_filter(self): + """Test set_filter method does nothing (it's fake).""" + # Just test it doesn't fail + self.driver.set_filter('test/filter') + + def test_login_success(self): + """Test login_success always returns True.""" + self.assertTrue(self.driver.login_success()) + + def test_login_failure(self): + """Test login_failure always returns None.""" + self.assertIsNone(self.driver.login_failure()) + + @mock.patch('aprsd.client.drivers.fake.LOG') + def test_send_with_packet_object(self, mock_log): + """Test send with a Packet object.""" + mock_packet = mock.MagicMock(spec=core.Packet) + mock_packet.payload = 'Test payload' + mock_packet.to_call = 'TEST' + mock_packet.from_call = 'FAKE' + + self.driver.send(mock_packet) + + mock_log.info.assert_called_once() + mock_packet.prepare.assert_called_once() + + @mock.patch('aprsd.client.drivers.fake.LOG') + def test_send_with_non_packet_object(self, mock_log): + """Test send with a non-Packet object.""" + # Create a mock message-like object + mock_msg = mock.MagicMock() + mock_msg.raw = 'Test' + mock_msg.msgNo = '123' + mock_msg.to_call = 'TEST' + mock_msg.from_call = 'FAKE' + + self.driver.send(mock_msg) + + mock_log.info.assert_called_once() + mock_log.debug.assert_called_once() + + @mock.patch('aprsd.client.drivers.fake.LOG') + @mock.patch('aprsd.client.drivers.fake.time.sleep') + def test_consumer_with_raw_true(self, mock_sleep, mock_log): + """Test consumer with raw=True.""" + mock_callback = mock.MagicMock() + + self.driver.consumer(mock_callback, raw=True) + + # Verify callback was called with raw data + mock_callback.assert_called_once() + call_args = mock_callback.call_args[1] + self.assertIn('raw', call_args) + mock_sleep.assert_called_once_with(1) + + @mock.patch('aprsd.client.drivers.fake.LOG') + @mock.patch('aprsd.client.drivers.fake.aprslib.parse') + @mock.patch('aprsd.client.drivers.fake.core.factory') + @mock.patch('aprsd.client.drivers.fake.time.sleep') + def test_consumer_with_raw_false( + self, mock_sleep, mock_factory, mock_parse, mock_log + ): + """Test consumer with raw=False.""" + mock_callback = mock.MagicMock() + mock_packet = mock.MagicMock(spec=core.Packet) + mock_factory.return_value = mock_packet + + self.driver.consumer(mock_callback, raw=False) + + # Verify the packet was created and passed to callback + mock_parse.assert_called_once() + mock_factory.assert_called_once() + mock_callback.assert_called_once_with(packet=mock_packet) + mock_sleep.assert_called_once_with(1) + + def test_consumer_updates_keepalive(self): + """Test consumer updates keepalive timestamp.""" + mock_callback = mock.MagicMock() + old_keepalive = self.driver.aprsd_keepalive + + # Force a small delay to ensure timestamp changes + import time + + time.sleep(0.01) + + with mock.patch('aprsd.client.drivers.fake.time.sleep'): + self.driver.consumer(mock_callback) + + self.assertNotEqual(old_keepalive, self.driver.aprsd_keepalive) + self.assertGreater(self.driver.aprsd_keepalive, old_keepalive) + + def test_decode_packet_with_empty_kwargs(self): + """Test decode_packet with empty kwargs.""" + result = self.driver.decode_packet() + self.assertIsNone(result) + + def test_decode_packet_with_packet(self): + """Test decode_packet with packet in kwargs.""" + mock_packet = mock.MagicMock(spec=core.Packet) + result = self.driver.decode_packet(packet=mock_packet) + self.assertEqual(result, mock_packet) + + @mock.patch('aprsd.client.drivers.fake.aprslib.parse') + @mock.patch('aprsd.client.drivers.fake.core.factory') + def test_decode_packet_with_raw(self, mock_factory, mock_parse): + """Test decode_packet with raw in kwargs.""" + mock_packet = mock.MagicMock(spec=core.Packet) + mock_factory.return_value = mock_packet + raw_data = 'raw packet data' + + result = self.driver.decode_packet(raw=raw_data) + + mock_parse.assert_called_once_with(raw_data) + mock_factory.assert_called_once_with(mock_parse.return_value) + self.assertEqual(result, mock_packet) + + def test_stats(self): + """Test stats returns correct information.""" + self.driver.thread_stop = False + result = self.driver.stats() + + self.assertEqual(result['driver'], 'APRSDFakeDriver') + self.assertTrue(result['is_alive']) + + # Test with serializable parameter + result_serializable = self.driver.stats(serializable=True) + self.assertEqual(result_serializable['driver'], 'APRSDFakeDriver') + self.assertTrue(result_serializable['is_alive']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/client/drivers/test_tcpkiss_driver.py b/tests/client/drivers/test_tcpkiss_driver.py new file mode 100644 index 0000000..cec2ce2 --- /dev/null +++ b/tests/client/drivers/test_tcpkiss_driver.py @@ -0,0 +1,498 @@ +import datetime +import socket +import unittest +from unittest import mock + +import aprslib + +from aprsd import exception +from aprsd.client.drivers.registry import ClientDriver +from aprsd.client.drivers.tcpkiss import TCPKISSDriver +from aprsd.packets import core + + +class TestTCPKISSDriver(unittest.TestCase): + """Unit tests for the TCPKISSDriver class.""" + + def setUp(self): + # Mock configuration + self.conf_patcher = mock.patch('aprsd.client.drivers.tcpkiss.CONF') + self.mock_conf = self.conf_patcher.start() + + # Configure KISS settings + self.mock_conf.kiss_tcp.enabled = True + self.mock_conf.kiss_tcp.host = '127.0.0.1' + self.mock_conf.kiss_tcp.port = 8001 + self.mock_conf.kiss_tcp.path = ['WIDE1-1', 'WIDE2-1'] + + # Mock socket + self.socket_patcher = mock.patch('aprsd.client.drivers.tcpkiss.socket') + self.mock_socket_module = self.socket_patcher.start() + self.mock_socket = mock.MagicMock() + self.mock_socket_module.socket.return_value = self.mock_socket + + # Mock select + self.select_patcher = mock.patch('aprsd.client.drivers.tcpkiss.select') + self.mock_select = self.select_patcher.start() + + # Create an instance of the driver + self.driver = TCPKISSDriver() + + def tearDown(self): + self.conf_patcher.stop() + self.socket_patcher.stop() + self.select_patcher.stop() + + def test_implements_client_driver_protocol(self): + """Test that TCPKISSDriver implements the ClientDriver Protocol.""" + # Verify the instance is recognized as implementing the Protocol + self.assertIsInstance(self.driver, ClientDriver) + + # Verify all required methods are present with correct signatures + required_methods = [ + 'is_enabled', + 'is_configured', + 'is_alive', + 'close', + 'send', + 'setup_connection', + 'set_filter', + 'login_success', + 'login_failure', + 'consumer', + 'decode_packet', + 'stats', + ] + + for method_name in required_methods: + self.assertTrue( + hasattr(self.driver, method_name), + f'Missing required method: {method_name}', + ) + + def test_init(self): + """Test initialization sets default values.""" + self.assertFalse(self.driver._connected) + self.assertIsInstance(self.driver.keepalive, datetime.datetime) + self.assertFalse(self.driver._running) + + def test_transport_property(self): + """Test transport property returns correct value.""" + self.assertEqual(self.driver.transport, 'tcpkiss') + + def test_is_enabled_true(self): + """Test is_enabled returns True when KISS TCP is enabled.""" + self.mock_conf.kiss_tcp.enabled = True + self.assertTrue(TCPKISSDriver.is_enabled()) + + def test_is_enabled_false(self): + """Test is_enabled returns False when KISS TCP is disabled.""" + self.mock_conf.kiss_tcp.enabled = False + self.assertFalse(TCPKISSDriver.is_enabled()) + + def test_is_configured_true(self): + """Test is_configured returns True when properly configured.""" + with mock.patch.object(TCPKISSDriver, 'is_enabled', return_value=True): + self.mock_conf.kiss_tcp.host = '127.0.0.1' + self.assertTrue(TCPKISSDriver.is_configured()) + + def test_is_configured_false_no_host(self): + """Test is_configured returns False when host not set.""" + with mock.patch.object(TCPKISSDriver, 'is_enabled', return_value=True): + self.mock_conf.kiss_tcp.host = None + with self.assertRaises(exception.MissingConfigOptionException): + TCPKISSDriver.is_configured() + + def test_is_configured_false_not_enabled(self): + """Test is_configured returns False when not enabled.""" + with mock.patch.object(TCPKISSDriver, 'is_enabled', return_value=False): + self.assertFalse(TCPKISSDriver.is_configured()) + + def test_is_alive(self): + """Test is_alive property returns connection state.""" + self.driver._connected = True + self.assertTrue(self.driver.is_alive) + + self.driver._connected = False + self.assertFalse(self.driver.is_alive) + + def test_close(self): + """Test close method calls stop.""" + with mock.patch.object(self.driver, 'stop') as mock_stop: + self.driver.close() + mock_stop.assert_called_once() + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_setup_connection_success(self, mock_log): + """Test setup_connection successfully connects.""" + # Mock the connect method to succeed + is_en = self.driver.is_enabled + is_con = self.driver.is_configured + self.driver.is_enabled = mock.MagicMock(return_value=True) + self.driver.is_configured = mock.MagicMock(return_value=True) + with mock.patch.object( + self.driver, 'connect', return_value=True + ) as mock_connect: + self.driver.setup_connection() + mock_connect.assert_called_once() + mock_log.info.assert_called_with('KISS TCP Connection to 127.0.0.1:8001') + + self.driver.is_enabled = is_en + self.driver.is_configured = is_con + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_setup_connection_failure(self, mock_log): + """Test setup_connection handles connection failure.""" + # Mock the connect method to fail + with mock.patch.object( + self.driver, 'connect', return_value=False + ) as mock_connect: + self.driver.setup_connection() + mock_connect.assert_called_once() + mock_log.error.assert_called_with('Failed to connect to KISS interface') + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_setup_connection_exception(self, mock_log): + """Test setup_connection handles exceptions.""" + # Mock the connect method to raise an exception + with mock.patch.object( + self.driver, 'connect', side_effect=Exception('Test error') + ) as mock_connect: + self.driver.setup_connection() + mock_connect.assert_called_once() + mock_log.error.assert_any_call('Failed to initialize KISS interface') + mock_log.exception.assert_called_once() + self.assertFalse(self.driver._connected) + + def test_set_filter(self): + """Test set_filter does nothing for KISS.""" + # Just ensure it doesn't fail + self.driver.set_filter('test/filter') + + def test_login_success_when_connected(self): + """Test login_success returns True when connected.""" + self.driver._connected = True + self.assertTrue(self.driver.login_success()) + + def test_login_success_when_not_connected(self): + """Test login_success returns False when not connected.""" + self.driver._connected = False + self.assertFalse(self.driver.login_success()) + + def test_login_failure(self): + """Test login_failure returns success message.""" + self.assertEqual(self.driver.login_failure(), 'Login successful') + + @mock.patch('aprsd.client.drivers.tcpkiss.ax25frame.Frame.ui') + def test_send_packet(self, mock_frame_ui): + """Test sending an APRS packet.""" + # Create a mock frame + mock_frame = mock.MagicMock() + mock_frame_bytes = b'mock_frame_data' + mock_frame.__bytes__ = mock.MagicMock(return_value=mock_frame_bytes) + mock_frame_ui.return_value = mock_frame + + # Set up the driver + self.driver.socket = self.mock_socket + self.driver.path = ['WIDE1-1', 'WIDE2-1'] + + # Create a mock packet + mock_packet = mock.MagicMock(spec=core.Packet) + mock_bytes = b'Test packet data' + mock_packet.__bytes__ = mock.MagicMock(return_value=mock_bytes) + # Add path attribute to the mock packet + mock_packet.path = None + + # Send the packet + self.driver.send(mock_packet) + + # Check that frame was created correctly + mock_frame_ui.assert_called_once_with( + destination='APZ100', + source=mock_packet.from_call, + path=self.driver.path, + info=mock_packet.payload.encode('US-ASCII'), + ) + + # Check that socket send was called + self.mock_socket.send.assert_called_once() + + # Verify packet counters updated + self.assertEqual(self.driver.packets_sent, 1) + self.assertIsNotNone(self.driver.last_packet_sent) + + def test_send_with_no_socket(self): + """Test send raises exception when socket not initialized.""" + self.driver.socket = None + mock_packet = mock.MagicMock(spec=core.Packet) + + with self.assertRaises(Exception) as context: + self.driver.send(mock_packet) + self.assertIn('KISS interface not initialized', str(context.exception)) + + def test_stop(self): + """Test stop method cleans up properly.""" + self.driver._running = True + self.driver._connected = True + self.driver.socket = self.mock_socket + + self.driver.stop() + + self.assertFalse(self.driver._running) + self.assertFalse(self.driver._connected) + self.mock_socket.close.assert_called_once() + + def test_stats(self): + """Test stats method returns correct data.""" + # Set up test data + self.driver._connected = True + self.driver.path = ['WIDE1-1', 'WIDE2-1'] + self.driver.packets_sent = 5 + self.driver.packets_received = 3 + self.driver.last_packet_sent = datetime.datetime.now() + self.driver.last_packet_received = datetime.datetime.now() + + # Get stats + stats = self.driver.stats() + + # Check stats contains expected keys + expected_keys = [ + 'client', + 'transport', + 'connected', + 'path', + 'packets_sent', + 'packets_received', + 'last_packet_sent', + 'last_packet_received', + 'connection_keepalive', + 'host', + 'port', + ] + for key in expected_keys: + self.assertIn(key, stats) + + # Check some specific values + self.assertEqual(stats['client'], 'TCPKISSDriver') + self.assertEqual(stats['transport'], 'tcpkiss') + self.assertEqual(stats['connected'], True) + self.assertEqual(stats['packets_sent'], 5) + self.assertEqual(stats['packets_received'], 3) + + def test_stats_serializable(self): + """Test stats with serializable=True converts datetime to ISO format.""" + self.driver.keepalive = datetime.datetime.now() + + stats = self.driver.stats(serializable=True) + + # Check keepalive is a string in ISO format + self.assertIsInstance(stats['connection_keepalive'], str) + # Try parsing it to verify it's a valid ISO format + try: + datetime.datetime.fromisoformat(stats['connection_keepalive']) + except ValueError: + self.fail('keepalive is not in valid ISO format') + + def test_connect_success(self): + """Test successful connection.""" + result = self.driver.connect() + + self.assertTrue(result) + self.assertTrue(self.driver._connected) + self.mock_socket.connect.assert_called_once_with( + (self.mock_conf.kiss_tcp.host, self.mock_conf.kiss_tcp.port) + ) + self.mock_socket.settimeout.assert_any_call(5.0) + self.mock_socket.settimeout.assert_any_call(0.1) + + def test_connect_failure_socket_error(self): + """Test connection failure due to socket error.""" + self.mock_socket.connect.side_effect = socket.error('Test socket error') + + result = self.driver.connect() + + self.assertFalse(result) + self.assertFalse(self.driver._connected) + + def test_connect_failure_timeout(self): + """Test connection failure due to timeout.""" + self.mock_socket.connect.side_effect = socket.timeout('Test timeout') + + result = self.driver.connect() + + self.assertFalse(result) + self.assertFalse(self.driver._connected) + + def test_fix_raw_frame(self): + """Test fix_raw_frame removes KISS markers and handles FEND.""" + # Create a test frame with KISS markers + with mock.patch( + 'aprsd.client.drivers.tcpkiss.handle_fend', return_value=b'fixed_frame' + ) as mock_handle_fend: + raw_frame = b'\xc0\x00some_frame_data\xc0' # \xc0 is FEND + + result = self.driver.fix_raw_frame(raw_frame) + + mock_handle_fend.assert_called_once_with(b'some_frame_data') + self.assertEqual(result, b'fixed_frame') + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_decode_packet_success(self, mock_log): + """Test successful packet decoding.""" + mock_frame = 'test frame data' + mock_aprs_data = {'from': 'TEST-1', 'to': 'APRS'} + mock_packet = mock.MagicMock(spec=core.Packet) + + with mock.patch( + 'aprsd.client.drivers.tcpkiss.aprslib.parse', return_value=mock_aprs_data + ) as mock_parse: + with mock.patch( + 'aprsd.client.drivers.tcpkiss.core.factory', return_value=mock_packet + ) as mock_factory: + result = self.driver.decode_packet(frame=mock_frame) + + mock_parse.assert_called_once_with(str(mock_frame)) + mock_factory.assert_called_once_with(mock_aprs_data) + self.assertEqual(result, mock_packet) + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_decode_packet_no_frame(self, mock_log): + """Test decode_packet with no frame returns None.""" + result = self.driver.decode_packet() + + self.assertIsNone(result) + mock_log.warning.assert_called_once() + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_decode_packet_exception(self, mock_log): + """Test decode_packet handles exceptions.""" + mock_frame = 'invalid frame' + + with mock.patch( + 'aprsd.client.drivers.tcpkiss.aprslib.parse', + side_effect=Exception('Test error'), + ) as mock_parse: + result = self.driver.decode_packet(frame=mock_frame) + + mock_parse.assert_called_once() + self.assertIsNone(result) + mock_log.error.assert_called_once() + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_consumer_with_frame(self, mock_log): + """Test consumer processes frames and calls callback.""" + mock_callback = mock.MagicMock() + mock_frame = mock.MagicMock() + + # Configure driver for test + self.driver._connected = True + self.driver._running = True + + # Set up read_frame to return one frame then stop + def side_effect(): + self.driver._running = False + return mock_frame + + with mock.patch.object( + self.driver, 'read_frame', side_effect=side_effect + ) as mock_read_frame: + self.driver.consumer(mock_callback) + + mock_read_frame.assert_called_once() + mock_callback.assert_called_once_with(frame=mock_frame) + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_consumer_with_connect_reconnect(self, mock_log): + """Test consumer tries to reconnect when not connected.""" + mock_callback = mock.MagicMock() + + # Configure driver for test + self.driver._connected = False + + # Setup to run once then stop + call_count = 0 + + def connect_side_effect(): + nonlocal call_count + call_count += 1 + # On second call, connect successfully + if call_count == 2: + self.driver._running = False + self.driver.socket = self.mock_socket + return True + return False + + with mock.patch.object( + self.driver, 'connect', side_effect=connect_side_effect + ) as mock_connect: + with mock.patch('aprsd.client.drivers.tcpkiss.time.sleep') as mock_sleep: + self.driver.consumer(mock_callback) + + self.assertEqual(mock_connect.call_count, 2) + mock_sleep.assert_called_once_with(1) + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_read_frame_success(self, mock_log): + """Test read_frame successfully reads a frame.""" + # Set up driver + self.driver.socket = self.mock_socket + self.driver._running = True + + # Mock socket recv to return data + raw_data = b'\xc0\x00test_frame\xc0' + self.mock_socket.recv.return_value = raw_data + + # Mock select to indicate socket is readable + self.mock_select.select.return_value = ([self.mock_socket], [], []) + + # Mock fix_raw_frame and Frame.from_bytes + mock_fixed_frame = b'fixed_frame' + mock_ax25_frame = mock.MagicMock() + + with mock.patch.object( + self.driver, 'fix_raw_frame', return_value=mock_fixed_frame + ) as mock_fix: + with mock.patch( + 'aprsd.client.drivers.tcpkiss.ax25frame.Frame.from_bytes', + return_value=mock_ax25_frame, + ) as mock_from_bytes: + result = self.driver.read_frame() + + self.mock_socket.setblocking.assert_called_once_with(0) + self.mock_select.select.assert_called_once() + self.mock_socket.recv.assert_called_once() + mock_fix.assert_called_once_with(raw_data) + mock_from_bytes.assert_called_once_with(mock_fixed_frame) + self.assertEqual(result, mock_ax25_frame) + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_read_frame_select_timeout(self, mock_log): + """Test read_frame handles select timeout.""" + # Set up driver + self.driver.socket = self.mock_socket + self.driver._running = True + + # Mock select to indicate no readable sockets + self.mock_select.select.return_value = ([], [], []) + + result = self.driver.read_frame() + + self.assertIsNone(result) + + @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + def test_read_frame_socket_error(self, mock_log): + """Test read_frame handles socket error.""" + # Set up driver + self.driver.socket = self.mock_socket + self.driver._running = True + + # Mock setblocking to raise OSError + self.mock_socket.setblocking.side_effect = OSError('Test error') + + with self.assertRaises(aprslib.ConnectionDrop): + self.driver.read_frame() + mock_log.error.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/client/test_aprsis.py b/tests/client/test_aprsis.py deleted file mode 100644 index a5bf542..0000000 --- a/tests/client/test_aprsis.py +++ /dev/null @@ -1,89 +0,0 @@ -import datetime -import unittest -from unittest import mock - -from aprsd import exception -from aprsd.client.aprsis import APRSISClient - - -class TestAPRSISClient(unittest.TestCase): - """Test cases for APRSISClient.""" - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - - # Mock the config - self.mock_conf = mock.MagicMock() - self.mock_conf.aprs_network.enabled = True - self.mock_conf.aprs_network.login = "TEST" - self.mock_conf.aprs_network.password = "12345" - self.mock_conf.aprs_network.host = "localhost" - self.mock_conf.aprs_network.port = 14580 - - @mock.patch("aprsd.client.base.APRSClient") - @mock.patch("aprsd.client.drivers.aprsis.Aprsdis") - def test_stats_not_configured(self, mock_aprsdis, mock_base): - """Test stats when client is not configured.""" - mock_client = mock.MagicMock() - mock_aprsdis.return_value = mock_client - - with mock.patch("aprsd.client.aprsis.cfg.CONF", self.mock_conf): - self.client = APRSISClient() - - with mock.patch.object(APRSISClient, "is_configured", return_value=False): - stats = self.client.stats() - self.assertEqual({}, stats) - - @mock.patch("aprsd.client.base.APRSClient") - @mock.patch("aprsd.client.drivers.aprsis.Aprsdis") - def test_stats_configured(self, mock_aprsdis, mock_base): - """Test stats when client is configured.""" - mock_client = mock.MagicMock() - mock_aprsdis.return_value = mock_client - - with mock.patch("aprsd.client.aprsis.cfg.CONF", self.mock_conf): - self.client = APRSISClient() - - mock_client = mock.MagicMock() - mock_client.server_string = "test.server:14580" - mock_client.aprsd_keepalive = datetime.datetime.now() - self.client._client = mock_client - self.client.filter = "m/50" - - with mock.patch.object(APRSISClient, "is_configured", return_value=True): - stats = self.client.stats() - from rich.console import Console - - c = Console() - c.print(stats) - self.assertEqual( - { - "connected": True, - "filter": "m/50", - "login_status": {"message": mock.ANY, "success": True}, - "connection_keepalive": mock_client.aprsd_keepalive, - "server_string": mock_client.server_string, - "transport": "aprsis", - }, - stats, - ) - - def test_is_configured_missing_login(self): - """Test is_configured with missing login.""" - self.mock_conf.aprs_network.login = None - with self.assertRaises(exception.MissingConfigOptionException): - APRSISClient.is_configured() - - def test_is_configured_missing_password(self): - """Test is_configured with missing password.""" - self.mock_conf.aprs_network.password = None - with self.assertRaises(exception.MissingConfigOptionException): - APRSISClient.is_configured() - - def test_is_configured_missing_host(self): - """Test is_configured with missing host.""" - self.mock_conf.aprs_network.host = None - with mock.patch("aprsd.client.aprsis.cfg.CONF", self.mock_conf): - with self.assertRaises(exception.MissingConfigOptionException): - APRSISClient.is_configured() diff --git a/tests/client/test_client_base.py b/tests/client/test_client_base.py deleted file mode 100644 index 08667cb..0000000 --- a/tests/client/test_client_base.py +++ /dev/null @@ -1,141 +0,0 @@ -import unittest -from unittest import mock - -from aprsd.client.base import APRSClient -from aprsd.packets import core - - -class MockAPRSClient(APRSClient): - """Concrete implementation of APRSClient for testing.""" - - def stats(self): - return {"packets_received": 0, "packets_sent": 0} - - def setup_connection(self): - mock_connection = mock.MagicMock() - # Configure the mock with required methods - mock_connection.close = mock.MagicMock() - mock_connection.stop = mock.MagicMock() - mock_connection.set_filter = mock.MagicMock() - mock_connection.send = mock.MagicMock() - self._client = mock_connection - return mock_connection - - def decode_packet(self, *args, **kwargs): - return mock.MagicMock() - - def consumer(self, callback, blocking=False, immortal=False, raw=False): - pass - - def is_alive(self): - return True - - def close(self): - pass - - @staticmethod - def is_enabled(): - return True - - @staticmethod - def transport(): - return "mock" - - def reset(self): - """Mock implementation of reset.""" - if self._client: - self._client.close() - self._client = self.setup_connection() - if self.filter: - self._client.set_filter(self.filter) - - -class TestAPRSClient(unittest.TestCase): - def setUp(self): - # Reset the singleton instance before each test - APRSClient._instance = None - APRSClient._client = None - self.client = MockAPRSClient() - - def test_singleton_pattern(self): - """Test that multiple instantiations return the same instance.""" - client1 = MockAPRSClient() - client2 = MockAPRSClient() - self.assertIs(client1, client2) - - def test_set_filter(self): - """Test setting APRS filter.""" - # Get the existing mock client that was created in __init__ - mock_client = self.client._client - - test_filter = "m/50" - self.client.set_filter(test_filter) - self.assertEqual(self.client.filter, test_filter) - # The filter is set once during set_filter() and once during reset() - mock_client.set_filter.assert_called_with(test_filter) - - @mock.patch("aprsd.client.base.LOG") - def test_reset(self, mock_log): - """Test client reset functionality.""" - # Create a new mock client with the necessary methods - old_client = mock.MagicMock() - self.client._client = old_client - - self.client.reset() - - # Verify the old client was closed - old_client.close.assert_called_once() - - # Verify a new client was created - self.assertIsNotNone(self.client._client) - self.assertNotEqual(old_client, self.client._client) - - def test_send_packet(self): - """Test sending an APRS packet.""" - mock_packet = mock.Mock(spec=core.Packet) - self.client.send(mock_packet) - self.client._client.send.assert_called_once_with(mock_packet) - - def test_stop(self): - """Test stopping the client.""" - # Ensure client is created first - self.client._create_client() - - self.client.stop() - self.client._client.stop.assert_called_once() - - @mock.patch("aprsd.client.base.LOG") - def test_create_client_failure(self, mock_log): - """Test handling of client creation failure.""" - # Make setup_connection raise an exception - with mock.patch.object( - self.client, - "setup_connection", - side_effect=Exception("Connection failed"), - ): - with self.assertRaises(Exception): - self.client._create_client() - - self.assertIsNone(self.client._client) - mock_log.error.assert_called_once() - - def test_client_property(self): - """Test the client property creates client if none exists.""" - self.client._client = None - client = self.client.client - self.assertIsNotNone(client) - - def test_filter_applied_on_creation(self): - """Test that filter is applied when creating new client.""" - test_filter = "m/50" - self.client.set_filter(test_filter) - - # Force client recreation - self.client.reset() - - # Verify filter was applied to new client - self.client._client.set_filter.assert_called_with(test_filter) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/client/test_factory.py b/tests/client/test_factory.py deleted file mode 100644 index 4c2257e..0000000 --- a/tests/client/test_factory.py +++ /dev/null @@ -1,75 +0,0 @@ -import unittest -from unittest import mock - -from aprsd.client.factory import Client, ClientFactory - - -class MockClient: - """Mock client for testing.""" - - @classmethod - def is_enabled(cls): - return True - - @classmethod - def is_configured(cls): - return True - - -class TestClientFactory(unittest.TestCase): - """Test cases for ClientFactory.""" - - def setUp(self): - """Set up test fixtures.""" - self.factory = ClientFactory() - # Clear any registered clients from previous tests - self.factory.clients = [] - - def test_singleton(self): - """Test that ClientFactory is a singleton.""" - factory2 = ClientFactory() - self.assertEqual(self.factory, factory2) - - def test_register_client(self): - """Test registering a client.""" - self.factory.register(MockClient) - self.assertIn(MockClient, self.factory.clients) - - def test_register_invalid_client(self): - """Test registering an invalid client raises error.""" - invalid_client = mock.MagicMock(spec=Client) - with self.assertRaises(ValueError): - self.factory.register(invalid_client) - - def test_create_client(self): - """Test creating a client.""" - self.factory.register(MockClient) - client = self.factory.create() - self.assertIsInstance(client, MockClient) - - def test_create_no_clients(self): - """Test creating a client with no registered clients.""" - with self.assertRaises(Exception): - self.factory.create() - - def test_is_client_enabled(self): - """Test checking if any client is enabled.""" - self.factory.register(MockClient) - self.assertTrue(self.factory.is_client_enabled()) - - def test_is_client_enabled_none(self): - """Test checking if any client is enabled when none are.""" - MockClient.is_enabled = classmethod(lambda cls: False) - self.factory.register(MockClient) - self.assertFalse(self.factory.is_client_enabled()) - - def test_is_client_configured(self): - """Test checking if any client is configured.""" - self.factory.register(MockClient) - self.assertTrue(self.factory.is_client_configured()) - - def test_is_client_configured_none(self): - """Test checking if any client is configured when none are.""" - MockClient.is_configured = classmethod(lambda cls: False) - self.factory.register(MockClient) - self.assertFalse(self.factory.is_client_configured()) diff --git a/tests/client/test_registry.py b/tests/client/test_registry.py new file mode 100644 index 0000000..7eeb3e4 --- /dev/null +++ b/tests/client/test_registry.py @@ -0,0 +1,100 @@ +import unittest +from unittest import mock + +from aprsd.client.drivers.registry import DriverRegistry + +from ..mock_client_driver import MockClientDriver + + +class TestDriverRegistry(unittest.TestCase): + """Unit tests for the DriverRegistry class.""" + + def setUp(self): + # Reset the singleton instance before each test + DriverRegistry._singleton_instances = {} + self.registry = DriverRegistry() + self.registry.drivers = [] + + # Mock APRSISDriver completely + self.aprsis_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSISDriver') + mock_aprsis_class = self.aprsis_patcher.start() + mock_aprsis_class.is_enabled.return_value = False + mock_aprsis_class.is_configured.return_value = False + + # Mock the instance methods as well + mock_instance = mock_aprsis_class.return_value + mock_instance.is_enabled.return_value = False + mock_instance.is_configured.return_value = False + + # Mock CONF to prevent password check + self.conf_patcher = mock.patch('aprsd.client.drivers.aprsis.CONF') + mock_conf = self.conf_patcher.start() + mock_conf.aprs_network.password = 'dummy' + mock_conf.aprs_network.login = 'dummy' + + def tearDown(self): + # Reset the singleton instance after each test + DriverRegistry().drivers = [] + self.aprsis_patcher.stop() + self.conf_patcher.stop() + + def test_get_driver_with_valid_driver(self): + """Test getting an enabled and configured driver.""" + # Add an enabled and configured driver + driver = MockClientDriver + driver.is_enabled = mock.MagicMock(return_value=True) + driver.is_configured = mock.MagicMock(return_value=True) + self.registry.register(MockClientDriver) + + # Get the driver + result = self.registry.get_driver() + print(result) + self.assertTrue(isinstance(result, MockClientDriver)) + + def test_get_driver_with_disabled_driver(self): + """Test getting a driver when only disabled drivers exist.""" + driver = MockClientDriver + driver.is_enabled = mock.MagicMock(return_value=False) + driver.is_configured = mock.MagicMock(return_value=False) + self.registry.register(driver) + + with self.assertRaises(ValueError) as context: + self.registry.get_driver() + self.assertIn('No enabled driver found', str(context.exception)) + + def test_get_driver_with_unconfigured_driver(self): + """Test getting a driver when only unconfigured drivers exist.""" + driver = MockClientDriver + driver.is_enabled = mock.MagicMock(return_value=True) + driver.is_configured = mock.MagicMock(return_value=False) + self.registry.register(driver) + + with self.assertRaises(ValueError) as context: + self.registry.get_driver() + self.assertIn('No enabled driver found', str(context.exception)) + + def test_get_driver_with_no_drivers(self): + """Test getting a driver when no drivers exist.""" + # Try to get a driver + with self.assertRaises(ValueError) as context: + self.registry.get_driver() + self.assertIn('No enabled driver found', str(context.exception)) + + def test_get_driver_with_multiple_drivers(self): + """Test getting a driver when multiple valid drivers exist.""" + # Add multiple drivers + driver1 = MockClientDriver + driver1.is_enabled = mock.MagicMock(return_value=True) + driver1.is_configured = mock.MagicMock(return_value=True) + driver2 = MockClientDriver + self.registry.register(driver1) + self.registry.register(driver2) + + # Get the driver - should return the first one + result = self.registry.get_driver() + # We can only check that it's a MockDriver instance + self.assertTrue(isinstance(result, MockClientDriver)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/mock_client_driver.py b/tests/mock_client_driver.py new file mode 100644 index 0000000..8317d6f --- /dev/null +++ b/tests/mock_client_driver.py @@ -0,0 +1,76 @@ +from unittest import mock + +from aprsd.packets import core + + +class MockClientDriver: + """Mock implementation of ClientDriver for testing.""" + + def __init__(self, enabled=True, configured=True): + self.connected = False + self._alive = True + self._keepalive = None + self.filter = None + self._enabled = enabled + self._configured = configured + self.path = '/dev/ttyUSB0' + self.login_status = { + 'success': True, + 'message': None, + } + + @staticmethod + def is_enabled(): + """Static method to check if driver is enabled.""" + return True + + @staticmethod + def is_configured(): + """Static method to check if driver is configured.""" + return True + + def is_alive(self): + """Instance method to check if driver is alive.""" + return self._alive + + def stats(self, serializable=False): + """Return mock stats.""" + stats = {'packets_received': 0, 'packets_sent': 0} + if serializable: + stats['path'] = self.path + return stats + + @property + def login_success(self): + """Property to get login success status.""" + return self.login_status['success'] + + @property + def login_failure(self): + """Property to get login failure message.""" + return self.login_status['message'] + + def decode_packet(self, *args, **kwargs): + """Mock packet decoding.""" + packet = mock.MagicMock(spec=core.Packet) + packet.raw = 'test packet' + return packet + + def close(self): + self.connected = False + + def setup_connection(self): + self.connected = True + + def send(self, packet): + return True + + def set_filter(self, filter_str): + self.filter = filter_str + + @property + def keepalive(self): + return self._keepalive + + def consumer(self, callback, raw=False): + pass diff --git a/tests/plugins/test_notify.py b/tests/plugins/test_notify.py index 4cb2638..9839499 100644 --- a/tests/plugins/test_notify.py +++ b/tests/plugins/test_notify.py @@ -7,9 +7,11 @@ from aprsd import ( # noqa: F401 conf, packets, ) +from aprsd.client.drivers.registry import DriverRegistry from aprsd.plugins import notify as notify_plugin from .. import fake, test_plugin +from ..mock_client_driver import MockClientDriver CONF = cfg.CONF DEFAULT_WATCHLIST_CALLSIGNS = fake.FAKE_FROM_CALLSIGN @@ -17,9 +19,24 @@ DEFAULT_WATCHLIST_CALLSIGNS = fake.FAKE_FROM_CALLSIGN class TestWatchListPlugin(test_plugin.TestPlugin): def setUp(self): + super().setUp() self.fromcall = fake.FAKE_FROM_CALLSIGN self.ack = 1 + # Mock APRSISDriver + self.aprsis_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSISDriver') + self.mock_aprsis = self.aprsis_patcher.start() + self.mock_aprsis.is_enabled.return_value = False + self.mock_aprsis.is_configured.return_value = False + + # Register the mock driver + DriverRegistry().register(MockClientDriver) + + def tearDown(self): + super().tearDown() + if hasattr(self, 'aprsis_patcher'): + self.aprsis_patcher.stop() + def config_and_init( self, watchlist_enabled=True, @@ -30,7 +47,9 @@ class TestWatchListPlugin(test_plugin.TestPlugin): ): CONF.callsign = self.fromcall CONF.aprs_network.login = self.fromcall - CONF.aprs_fi.apiKey = "something" + CONF.aprs_fi.apiKey = 'something' + # Add mock password + CONF.aprs_network.password = '12345' # Set the watchlist specific config options CONF.watch_list.enabled = watchlist_enabled @@ -56,22 +75,20 @@ class TestAPRSDWatchListPluginBase(TestWatchListPlugin): plugin = fake.FakeWatchListPlugin() packet = fake.fake_packet( - message="version", + message='version', msg_number=1, ) actual = plugin.filter(packet) expected = packets.NULL_MESSAGE self.assertEqual(expected, actual) - @mock.patch("aprsd.client.factory.ClientFactory", autospec=True) - def test_watchlist_not_in_watchlist(self, mock_factory): - client.client_factory = mock_factory + def test_watchlist_not_in_watchlist(self): self.config_and_init() plugin = fake.FakeWatchListPlugin() packet = fake.fake_packet( - fromcall="FAKE", - message="version", + fromcall='FAKE', + message='version', msg_number=1, ) actual = plugin.filter(packet) @@ -85,87 +102,77 @@ class TestNotifySeenPlugin(TestWatchListPlugin): plugin = notify_plugin.NotifySeenPlugin() packet = fake.fake_packet( - message="version", + message='version', msg_number=1, ) actual = plugin.filter(packet) expected = packets.NULL_MESSAGE self.assertEqual(expected, actual) - @mock.patch("aprsd.client.factory.ClientFactory", autospec=True) - def test_callsign_not_in_watchlist(self, mock_factory): - client.client_factory = mock_factory + def test_callsign_not_in_watchlist(self): self.config_and_init(watchlist_enabled=False) plugin = notify_plugin.NotifySeenPlugin() packet = fake.fake_packet( - message="version", + message='version', msg_number=1, ) actual = plugin.filter(packet) expected = packets.NULL_MESSAGE self.assertEqual(expected, actual) - @mock.patch("aprsd.client.factory.ClientFactory", autospec=True) - @mock.patch("aprsd.packets.WatchList.is_old") - def test_callsign_in_watchlist_not_old(self, mock_is_old, mock_factory): - client.client_factory = mock_factory + @mock.patch('aprsd.packets.WatchList.is_old') + def test_callsign_in_watchlist_not_old(self, mock_is_old): mock_is_old.return_value = False self.config_and_init( watchlist_enabled=True, - watchlist_callsigns=["WB4BOR"], + watchlist_callsigns=['WB4BOR'], ) plugin = notify_plugin.NotifySeenPlugin() packet = fake.fake_packet( - fromcall="WB4BOR", - message="ping", + fromcall='WB4BOR', + message='ping', msg_number=1, ) actual = plugin.filter(packet) expected = packets.NULL_MESSAGE self.assertEqual(expected, actual) - @mock.patch("aprsd.client.factory.ClientFactory", autospec=True) - @mock.patch("aprsd.packets.WatchList.is_old") - def test_callsign_in_watchlist_old_same_alert_callsign( - self, mock_is_old, mock_factory - ): - client.client_factory = mock_factory + @mock.patch('aprsd.packets.WatchList.is_old') + def test_callsign_in_watchlist_old_same_alert_callsign(self, mock_is_old): mock_is_old.return_value = True self.config_and_init( watchlist_enabled=True, - watchlist_alert_callsign="WB4BOR", - watchlist_callsigns=["WB4BOR"], + watchlist_alert_callsign='WB4BOR', + watchlist_callsigns=['WB4BOR'], ) plugin = notify_plugin.NotifySeenPlugin() packet = fake.fake_packet( - fromcall="WB4BOR", - message="ping", + fromcall='WB4BOR', + message='ping', msg_number=1, ) actual = plugin.filter(packet) expected = packets.NULL_MESSAGE self.assertEqual(expected, actual) - @mock.patch("aprsd.client.factory.ClientFactory", autospec=True) - @mock.patch("aprsd.packets.WatchList.is_old") - def test_callsign_in_watchlist_old_send_alert(self, mock_is_old, mock_factory): - client.client_factory = mock_factory + @mock.patch('aprsd.packets.WatchList.is_old') + def test_callsign_in_watchlist_old_send_alert(self, mock_is_old): mock_is_old.return_value = True notify_callsign = fake.FAKE_TO_CALLSIGN - fromcall = "WB4BOR" + fromcall = 'WB4BOR' self.config_and_init( watchlist_enabled=True, watchlist_alert_callsign=notify_callsign, - watchlist_callsigns=["WB4BOR"], + watchlist_callsigns=['WB4BOR'], ) plugin = notify_plugin.NotifySeenPlugin() packet = fake.fake_packet( fromcall=fromcall, - message="ping", + message='ping', msg_number=1, ) packet_type = packet.__class__.__name__ diff --git a/tests/plugins/test_version.py b/tests/plugins/test_version.py index 14d2af0..90f9102 100644 --- a/tests/plugins/test_version.py +++ b/tests/plugins/test_version.py @@ -3,6 +3,7 @@ from unittest import mock from oslo_config import cfg import aprsd +from aprsd.client.drivers.fake import APRSDFakeDriver from aprsd.plugins import version as version_plugin from .. import fake, test_plugin @@ -11,16 +12,41 @@ CONF = cfg.CONF class TestVersionPlugin(test_plugin.TestPlugin): - @mock.patch("aprsd.stats.app.APRSDStats.uptime") - def test_version(self, mock_stats): - mock_stats.return_value = "00:00:00" - expected = f"APRSD ver:{aprsd.__version__} uptime:00:00:00" + def setUp(self): + # make sure the fake client driver is enabled + # Mock CONF for testing + super().setUp() + self.conf_patcher = mock.patch('aprsd.client.drivers.fake.CONF') + self.mock_conf = self.conf_patcher.start() + + # Configure fake_client.enabled + self.mock_conf.fake_client.enabled = True + + # Create an instance of the driver + self.driver = APRSDFakeDriver() + self.fromcall = fake.FAKE_FROM_CALLSIGN + + def tearDown(self): + self.conf_patcher.stop() + super().tearDown() + + @mock.patch('aprsd.stats.collector.Collector') + def test_version(self, mock_collector_class): + # Set up the mock collector instance + mock_collector_instance = mock_collector_class.return_value + mock_collector_instance.collect.return_value = { + 'APRSDStats': { + 'uptime': '00:00:00', + } + } + + expected = f'APRSD ver:{aprsd.__version__} uptime:00:00:00' CONF.callsign = fake.FAKE_TO_CALLSIGN version = version_plugin.VersionPlugin() version.enabled = True packet = fake.fake_packet( - message="No", + message='No', msg_number=1, ) @@ -28,8 +54,11 @@ class TestVersionPlugin(test_plugin.TestPlugin): self.assertEqual(None, actual) packet = fake.fake_packet( - message="version", + message='version', msg_number=1, ) actual = version.filter(packet) self.assertEqual(expected, actual) + + # Verify the mock was called exactly once + mock_collector_instance.collect.assert_called_once() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 636a245..a4be3e1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -9,9 +9,11 @@ from aprsd import ( # noqa: F401 plugins, ) from aprsd import plugin as aprsd_plugin +from aprsd.client.drivers.registry import DriverRegistry from aprsd.packets import core from . import fake +from .mock_client_driver import MockClientDriver CONF = cfg.CONF @@ -21,15 +23,24 @@ class TestPluginManager(unittest.TestCase): self.fromcall = fake.FAKE_FROM_CALLSIGN self.config_and_init() + self.mock_driver = MockClientDriver() + # Mock the DriverRegistry to return our mock driver + self.registry_patcher = mock.patch.object( + DriverRegistry, 'get_driver', return_value=self.mock_driver + ) + self.mock_registry = self.registry_patcher.start() + def tearDown(self) -> None: self.config = None aprsd_plugin.PluginManager._instance = None + self.registry_patcher.stop() + self.mock_registry.stop() def config_and_init(self): CONF.callsign = self.fromcall CONF.aprs_network.login = fake.FAKE_TO_CALLSIGN - CONF.aprs_fi.apiKey = "something" - CONF.enabled_plugins = "aprsd.plugins.ping.PingPlugin" + CONF.aprs_fi.apiKey = 'something' + CONF.enabled_plugins = 'aprsd.plugins.ping.PingPlugin' CONF.enable_save = False def test_get_plugins_no_plugins(self): @@ -39,7 +50,7 @@ class TestPluginManager(unittest.TestCase): self.assertEqual([], plugin_list) def test_get_plugins_with_plugins(self): - CONF.enabled_plugins = ["aprsd.plugins.ping.PingPlugin"] + CONF.enabled_plugins = ['aprsd.plugins.ping.PingPlugin'] pm = aprsd_plugin.PluginManager() plugin_list = pm.get_plugins() self.assertEqual([], plugin_list) @@ -64,7 +75,7 @@ class TestPluginManager(unittest.TestCase): self.assertEqual(0, len(plugin_list)) def test_get_message_plugins(self): - CONF.enabled_plugins = ["aprsd.plugins.ping.PingPlugin"] + CONF.enabled_plugins = ['aprsd.plugins.ping.PingPlugin'] pm = aprsd_plugin.PluginManager() plugin_list = pm.get_plugins() self.assertEqual([], plugin_list) @@ -87,22 +98,31 @@ class TestPlugin(unittest.TestCase): self.ack = 1 self.config_and_init() + self.mock_driver = MockClientDriver() + # Mock the DriverRegistry to return our mock driver + self.registry_patcher = mock.patch.object( + DriverRegistry, 'get_driver', return_value=self.mock_driver + ) + self.mock_registry = self.registry_patcher.start() + def tearDown(self) -> None: packets.WatchList._instance = None packets.SeenList._instance = None packets.PacketTrack._instance = None self.config = None + self.registry_patcher.stop() + self.mock_registry.stop() def config_and_init(self): CONF.callsign = self.fromcall CONF.aprs_network.login = fake.FAKE_TO_CALLSIGN - CONF.aprs_fi.apiKey = "something" - CONF.enabled_plugins = "aprsd.plugins.ping.PingPlugin" + CONF.aprs_fi.apiKey = 'something' + CONF.enabled_plugins = 'aprsd.plugins.ping.PingPlugin' CONF.enable_save = False class TestPluginBase(TestPlugin): - @mock.patch.object(fake.FakeBaseNoThreadsPlugin, "process") + @mock.patch.object(fake.FakeBaseNoThreadsPlugin, 'process') def test_base_plugin_no_threads(self, mock_process): p = fake.FakeBaseNoThreadsPlugin() @@ -110,7 +130,7 @@ class TestPluginBase(TestPlugin): actual = p.create_threads() self.assertEqual(expected, actual) - expected = "1.0" + expected = '1.0' actual = p.version self.assertEqual(expected, actual) @@ -123,7 +143,7 @@ class TestPluginBase(TestPlugin): self.assertEqual(expected, actual) mock_process.assert_not_called() - @mock.patch.object(fake.FakeBaseThreadsPlugin, "create_threads") + @mock.patch.object(fake.FakeBaseThreadsPlugin, 'create_threads') def test_base_plugin_threads_created(self, mock_create): p = fake.FakeBaseThreadsPlugin() mock_create.assert_called_once() @@ -135,17 +155,17 @@ class TestPluginBase(TestPlugin): self.assertTrue(isinstance(actual, fake.FakeThread)) p.stop_threads() - @mock.patch.object(fake.FakeRegexCommandPlugin, "process") + @mock.patch.object(fake.FakeRegexCommandPlugin, 'process') def test_regex_base_not_called(self, mock_process): CONF.callsign = fake.FAKE_TO_CALLSIGN p = fake.FakeRegexCommandPlugin() - packet = fake.fake_packet(message="a") + packet = fake.fake_packet(message='a') expected = None actual = p.filter(packet) self.assertEqual(expected, actual) mock_process.assert_not_called() - packet = fake.fake_packet(tocall="notMe", message="f") + packet = fake.fake_packet(tocall='notMe', message='f') expected = None actual = p.filter(packet) self.assertEqual(expected, actual) @@ -165,11 +185,11 @@ class TestPluginBase(TestPlugin): self.assertEqual(expected, actual) mock_process.assert_not_called() - @mock.patch.object(fake.FakeRegexCommandPlugin, "process") + @mock.patch.object(fake.FakeRegexCommandPlugin, 'process') def test_regex_base_assert_called(self, mock_process): CONF.callsign = fake.FAKE_TO_CALLSIGN p = fake.FakeRegexCommandPlugin() - packet = fake.fake_packet(message="f") + packet = fake.fake_packet(message='f') p.filter(packet) mock_process.assert_called_once() @@ -177,22 +197,22 @@ class TestPluginBase(TestPlugin): CONF.callsign = fake.FAKE_TO_CALLSIGN p = fake.FakeRegexCommandPlugin() - packet = fake.fake_packet(message="f") + packet = fake.fake_packet(message='f') expected = fake.FAKE_MESSAGE_TEXT actual = p.filter(packet) self.assertEqual(expected, actual) - packet = fake.fake_packet(message="F") + packet = fake.fake_packet(message='F') expected = fake.FAKE_MESSAGE_TEXT actual = p.filter(packet) self.assertEqual(expected, actual) - packet = fake.fake_packet(message="fake") + packet = fake.fake_packet(message='fake') expected = fake.FAKE_MESSAGE_TEXT actual = p.filter(packet) self.assertEqual(expected, actual) - packet = fake.fake_packet(message="FAKE") + packet = fake.fake_packet(message='FAKE') expected = fake.FAKE_MESSAGE_TEXT actual = p.filter(packet) self.assertEqual(expected, actual) diff --git a/tox.ini b/tox.ini index dec5424..7f1cdbf 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ deps = pytest-cov pytest commands = - pytest -v --cov-report term-missing --cov=aprsd {posargs} + pytest -s -v --cov-report term-missing --cov=aprsd {posargs} coverage: coverage report -m coverage: coverage xml