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