From a8822672d8d7c46a9e44dead931a443469de9015 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Wed, 26 Nov 2025 13:49:21 -0500 Subject: [PATCH 1/5] Added SerialKISSDriver This refactors the tcp kiss driver into the base class KISSDdriver and implements the serial kiss driver. --- README.md | 3 + aprsd/client/drivers/kiss_common.py | 181 ++++++++++++++++++++++ aprsd/client/drivers/serialkiss.py | 226 ++++++++++++++++++++++++++++ aprsd/client/drivers/tcpkiss.py | 144 +----------------- aprsd/utils/trace.py | 5 +- 5 files changed, 420 insertions(+), 139 deletions(-) create mode 100644 aprsd/client/drivers/kiss_common.py create mode 100644 aprsd/client/drivers/serialkiss.py diff --git a/README.md b/README.md index 71aac95..772bfb5 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ You can see the [available plugins/extensions on pypi here:](https://pypi.org/se - [aprsd-admin-extension](https://github.com/hemna/aprsd-admin-extension) - Web Administration page for APRSD - [aprsd-webchat-extension](https://github.com/hemna/aprsd-webchat-extension) - Web page for APRS Messaging + - [aprsd-rich-cli-extension](https://github.com/hemna/aprsd-rich-cli-extension) - Textual rich CLI versions of aprsd commands - [aprsd-irc-extension](https://github.com/hemna/aprsd-irc-extension) - an IRC like server command for APRS ### APRSD Overview Diagram @@ -299,6 +300,8 @@ file │ 📂 aprsd-admin-extension │ Administration extension for the Ham radio APRSD Server │ 1.0.1 │ 2025-01-06T21:57:24 │ Yes │ │ 📂 aprsd-irc-extension │ An Extension to Ham radio APRSD Daemon to act like an irc server │ 0.0.5 │ 2024-04-09T11:28:47 │ No │ │ │ for APRS │ │ │ │ + │ 📂 aprsd-rich-cli-extens │ APRSD Extension to create textual rich CLI versions of aprsd │ 0.1.1 │ 2024-12-01T00:00:00 │ No │ + │ ion │ commands │ │ │ │ │ 📂 aprsd-webchat-extens │ Web page for APRS Messaging │ 1.2.3 │ 2024-10-01T00:00:00 │ No │ │ ion │ │ │ │ │ └──────────────────────────┴─────────────────────────────────────────────────────────────────────┴─────────┴─────────────────────┴────────────┘ diff --git a/aprsd/client/drivers/kiss_common.py b/aprsd/client/drivers/kiss_common.py new file mode 100644 index 0000000..6fe9069 --- /dev/null +++ b/aprsd/client/drivers/kiss_common.py @@ -0,0 +1,181 @@ +""" +APRSD KISS Client Driver Base classusing native KISS implementation. + +This module provides a KISS client driver for APRSD using the new +non-asyncio KISSInterface implementation. +""" + +import datetime +import logging +from typing import Any, Callable, Dict + +import aprslib +from kiss import util as kissutil + +from aprsd.packets import core +from aprsd.utils import trace + +LOG = logging.getLogger('APRSD') + + +class KISSDriver(metaclass=trace.TraceWrapperMetaclass): + """APRSD KISS Client Driver Base class.""" + + packets_received = 0 + packets_sent = 0 + last_packet_sent = None + last_packet_received = None + keepalive = 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() + + 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 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 '' + + @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 _handle_fend(self, 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) + + 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 self._handle_fend(ax25_data) + + 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 + + try: + aprslib_frame = aprslib.parse(str(frame)) + packet = core.factory(aprslib_frame) + if isinstance(packet, core.ThirdPartyPacket): + return packet.subpacket + else: + return packet + except Exception as e: + LOG.error(f'Error decoding packet: {e}') + return None + + 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 + """ + # Ensure connection + if not self._connected: + return + + # Read frame + frame = self.read_frame() + if frame: + LOG.info(f'GOT FRAME: {frame} calling {callback}') + kwargs = { + 'frame': frame, + } + callback(**kwargs) + + def read_frame(self): + """Read a frame from the KISS interface. + + This is implemented in the subclass. + + """ + raise NotImplementedError('read_frame is not implemented for KISS') + + def stats(self, serializable: bool = False) -> Dict[str, Any]: + """Get client statistics. + + Returns: + Dict containing client statistics + """ + if serializable: + keepalive = self.keepalive.isoformat() + if self.last_packet_sent: + last_packet_sent = self.last_packet_sent.isoformat() + else: + last_packet_sent = 'None' + if self.last_packet_received: + last_packet_received = self.last_packet_received.isoformat() + else: + last_packet_received = 'None' + else: + keepalive = self.keepalive + last_packet_sent = self.last_packet_sent + last_packet_received = self.last_packet_received + + 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': last_packet_sent, + 'last_packet_received': last_packet_received, + 'connection_keepalive': keepalive, + } + + return stats diff --git a/aprsd/client/drivers/serialkiss.py b/aprsd/client/drivers/serialkiss.py new file mode 100644 index 0000000..0855f34 --- /dev/null +++ b/aprsd/client/drivers/serialkiss.py @@ -0,0 +1,226 @@ +""" +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 +from typing import Any, Dict + +import serial +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.client.drivers.kiss_common import KISSDriver +from aprsd.packets import core + +CONF = cfg.CONF +LOG = logging.getLogger('APRSD') + + +class SerialKISSDriver(KISSDriver): + """APRSD client driver for Serial KISS connections.""" + + _instance = None + + def __new__(cls, *args, **kwargs): + """This magic turns this into a singleton.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + 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() + # This is initialized in setup_connection() + self.socket = None + + @property + def transport(self) -> str: + return client.TRANSPORT_SERIALKISS + + @staticmethod + def is_enabled() -> bool: + """Check if KISS is enabled in configuration. + + Returns: + bool: True if either Serial is enabled + """ + return CONF.kiss_serial.enabled + + @staticmethod + def is_configured(): + # Ensure that the config vars are correctly set + if SerialKISSDriver.is_enabled(): + 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.', + ) + return True + return False + + def close(self): + """Close the connection.""" + self._connected = False + + def setup_connection(self): + """Set up the KISS interface.""" + if not self.is_enabled(): + LOG.error('KISS is not enabled in configuration') + return + + if self._connected: + LOG.warning('KISS interface already connected') + return + + try: + # Configure for TCP KISS + if self.is_enabled(): + LOG.info( + f'Serial KISS Connection to {CONF.kiss_serial.device}:{CONF.kiss_serial.baudrate}' + ) + self.path = CONF.kiss_serial.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 connect(self): + """Connect to the KISS interface.""" + if not self.is_enabled(): + LOG.error('KISS is not enabled in configuration') + return + + if self._connected: + LOG.warning('KISS interface already connected') + return + + try: + self.socket = serial.Serial( + CONF.kiss_serial.device, CONF.kiss_serial.baudrate + ) + self.socket.open() + self._connected = True + except serial.SerialException as e: + LOG.error(f'Failed to connect to KISS interface: {e}') + self._connected = False + return False + except Exception as ex: + LOG.error('Failed to connect to KISS interface') + LOG.exception(ex) + self._connected = False + + def read_frame(self): + """Read a frame from the KISS interface.""" + if not self.socket: + return None + + if not self._connected: + return None + + while self._connected: + try: + readable, _, _ = select.select( + [self.socket], + [], + [], + self.select_timeout, + ) + if not readable: + continue + except Exception as e: + # No need to log if we are not running. + # this happens when the client is stopped/closed. + LOG.error(f'Error in read loop: {e}') + self._connected = False + break + + try: + short_buf = self.socket.read(1024) + if not short_buf: + continue + + raw_frame = self.fix_raw_frame(short_buf) + return ax25frame.Frame.from_bytes(raw_frame) + except Exception as e: + LOG.error(f'Error in read loop: {e}') + self._connected = False + break + + 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 stats(self, serializable: bool = False) -> Dict[str, Any]: + """Get client statistics. + + Returns: + Dict containing client statistics + """ + stats = super().stats(serializable=serializable) + stats['device'] = CONF.kiss_serial.device + stats['baudrate'] = CONF.kiss_serial.baudrate + return stats diff --git a/aprsd/client/drivers/tcpkiss.py b/aprsd/client/drivers/tcpkiss.py index 03acb1a..8434426 100644 --- a/aprsd/client/drivers/tcpkiss.py +++ b/aprsd/client/drivers/tcpkiss.py @@ -9,7 +9,7 @@ import datetime import logging import select import socket -from typing import Any, Callable, Dict +from typing import Any, Dict import aprslib from ax253 import frame as ax25frame @@ -23,41 +23,20 @@ from aprsd import ( # noqa conf, # noqa exception, ) +from aprsd.client.drivers.kiss_common import KISSDriver from aprsd.packets import core -from aprsd.utils import trace 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(KISSDriver): # class TCPKISSDriver: """APRSD client driver for TCP KISS connections.""" _instance = None # 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 @@ -107,15 +86,6 @@ class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass): 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._connected = False @@ -200,112 +170,15 @@ class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass): 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 - """ - # Ensure connection - if not self._connected: - return - - # Read frame - frame = self.read_frame() - if frame: - LOG.info(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 - - try: - aprslib_frame = aprslib.parse(str(frame)) - packet = core.factory(aprslib_frame) - if isinstance(packet, core.ThirdPartyPacket): - return packet.subpacket - else: - return packet - except Exception as e: - LOG.error(f'Error decoding packet: {e}') - return None - def stats(self, serializable: bool = False) -> Dict[str, Any]: """Get client statistics. Returns: Dict containing client statistics """ - if serializable: - keepalive = self.keepalive.isoformat() - if self.last_packet_sent: - last_packet_sent = self.last_packet_sent.isoformat() - else: - last_packet_sent = 'None' - if self.last_packet_received: - last_packet_received = self.last_packet_received.isoformat() - else: - last_packet_received = 'None' - else: - keepalive = self.keepalive - last_packet_sent = self.last_packet_sent - last_packet_received = self.last_packet_received - - 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': last_packet_sent, - 'last_packet_received': last_packet_received, - 'connection_keepalive': keepalive, - 'host': CONF.kiss_tcp.host, - 'port': CONF.kiss_tcp.port, - } - + stats = super().stats(serializable=serializable) + stats['host'] = CONF.kiss_tcp.host + stats['port'] = CONF.kiss_tcp.port return stats def connect(self) -> bool: @@ -348,11 +221,6 @@ class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass): 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 diff --git a/aprsd/utils/trace.py b/aprsd/utils/trace.py index c90dc84..a96210f 100644 --- a/aprsd/utils/trace.py +++ b/aprsd/utils/trace.py @@ -143,7 +143,10 @@ class TraceWrapperMetaclass(type): def __new__(cls, classname, bases, class_dict): new_class_dict = {} for attribute_name, attribute in class_dict.items(): - if isinstance(attribute, types.FunctionType): + if attribute_name == '__new__' or attribute_name == '__init__': + # Don't trace the __new__ or __init__ methods + pass + elif isinstance(attribute, types.FunctionType): # replace it with a wrapped version attribute = functools.update_wrapper( trace_method(attribute), From d28ed86f31a974bf7b8ef3b6a9f3f5839d74d9cb Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Wed, 26 Nov 2025 16:15:02 -0500 Subject: [PATCH 2/5] Added @trace.no_trace this adds the new decorator to stop tracing specific methods when tracing is enabled. this is useful when debugging. --- aprsd/utils/trace.py | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/aprsd/utils/trace.py b/aprsd/utils/trace.py index a96210f..b7d8e87 100644 --- a/aprsd/utils/trace.py +++ b/aprsd/utils/trace.py @@ -130,6 +130,25 @@ def trace_method(f): return trace_method_logging_wrapper +def no_trace(f): + """Decorator to exclude a method from TraceWrapperMetaclass wrapping. + + Use this decorator on methods that should not be wrapped with trace_method + by the TraceWrapperMetaclass. + + Example: + class MyClass(metaclass=TraceWrapperMetaclass): + def traced_method(self): + pass # This will be wrapped + + @no_trace + def not_traced_method(self): + pass # This will NOT be wrapped + """ + f.__no_trace__ = True + return f + + class TraceWrapperMetaclass(type): """Metaclass that wraps all methods of a class with trace_method. @@ -146,12 +165,19 @@ class TraceWrapperMetaclass(type): if attribute_name == '__new__' or attribute_name == '__init__': # Don't trace the __new__ or __init__ methods pass + elif attribute_name == 'consumer': + pass elif isinstance(attribute, types.FunctionType): - # replace it with a wrapped version - attribute = functools.update_wrapper( - trace_method(attribute), - attribute, - ) + # Check if method is marked with @no_trace decorator + if getattr(attribute, '__no_trace__', False): + # Don't wrap methods marked with @no_trace + pass + else: + # replace it with a wrapped version + attribute = functools.update_wrapper( + trace_method(attribute), + attribute, + ) new_class_dict[attribute_name] = attribute return type.__new__(cls, classname, bases, new_class_dict) From 6bd0f50dc01323c0bed16c485ddb712dc19303d4 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Wed, 26 Nov 2025 16:16:08 -0500 Subject: [PATCH 3/5] Move some class vars out of __init__ This ensures that connected doesn't change between calls to the constructor. --- aprsd/client/client.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/aprsd/client/client.py b/aprsd/client/client.py index ce6d93d..f877811 100644 --- a/aprsd/client/client.py +++ b/aprsd/client/client.py @@ -30,6 +30,13 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass): driver = None lock = threading.Lock() filter = None + connected = False + running = False + auto_connect = True + login_status = { + 'success': False, + 'message': None, + } def __new__(cls, *args, **kwargs): """This magic turns this into a singleton.""" @@ -40,12 +47,6 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass): def __init__(self, auto_connect: bool = True): self.auto_connect = auto_connect - self.connected = False - self.running = False - self.login_status = { - 'success': False, - 'message': None, - } if not self.driver: self.driver = DriverRegistry().get_driver() if self.auto_connect: @@ -72,12 +73,6 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass): return True return False - # @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: @@ -106,6 +101,7 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass): @wrapt.synchronized(lock) def connect(self): + # log a stack trace here to find out who is calling us. if not self.driver: self.driver = DriverRegistry().get_driver() if not self.connected: From 50022e1e9eb863f5b53c4d8067baff9a94e631ee Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Wed, 26 Nov 2025 16:19:37 -0500 Subject: [PATCH 4/5] Got the serial KISS driver working --- aprsd/client/drivers/__init__.py | 2 + aprsd/client/drivers/kiss_common.py | 5 ++- aprsd/client/drivers/serialkiss.py | 61 ++++++++++++++++++++--------- aprsd/client/drivers/tcpkiss.py | 2 - 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/aprsd/client/drivers/__init__.py b/aprsd/client/drivers/__init__.py index 8da8326..92cc247 100644 --- a/aprsd/client/drivers/__init__.py +++ b/aprsd/client/drivers/__init__.py @@ -2,9 +2,11 @@ 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.serialkiss import SerialKISSDriver from aprsd.client.drivers.tcpkiss import TCPKISSDriver driver_registry = DriverRegistry() driver_registry.register(APRSDFakeDriver) driver_registry.register(APRSISDriver) driver_registry.register(TCPKISSDriver) +driver_registry.register(SerialKISSDriver) diff --git a/aprsd/client/drivers/kiss_common.py b/aprsd/client/drivers/kiss_common.py index 6fe9069..4a5308e 100644 --- a/aprsd/client/drivers/kiss_common.py +++ b/aprsd/client/drivers/kiss_common.py @@ -27,6 +27,9 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass): last_packet_received = None keepalive = None + # timeout in seconds + select_timeout = 1 + def __init__(self): """Initialize the KISS client. @@ -85,7 +88,6 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass): 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) def fix_raw_frame(self, raw_frame: bytes) -> bytes: @@ -145,6 +147,7 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass): """ raise NotImplementedError('read_frame is not implemented for KISS') + @trace.no_trace def stats(self, serializable: bool = False) -> Dict[str, Any]: """Get client statistics. diff --git a/aprsd/client/drivers/serialkiss.py b/aprsd/client/drivers/serialkiss.py index 0855f34..5fe1aee 100644 --- a/aprsd/client/drivers/serialkiss.py +++ b/aprsd/client/drivers/serialkiss.py @@ -7,7 +7,8 @@ non-asyncio KISSInterface implementation. import datetime import logging -import select + +# import select from typing import Any, Dict import serial @@ -24,6 +25,7 @@ from aprsd import ( # noqa ) from aprsd.client.drivers.kiss_common import KISSDriver from aprsd.packets import core +from aprsd.utils import trace CONF = cfg.CONF LOG = logging.getLogger('APRSD') @@ -80,6 +82,11 @@ class SerialKISSDriver(KISSDriver): def close(self): """Close the connection.""" self._connected = False + if self.socket and self.socket.is_open: + try: + self.socket.close() + except Exception: + pass def setup_connection(self): """Set up the KISS interface.""" @@ -118,11 +125,26 @@ class SerialKISSDriver(KISSDriver): LOG.warning('KISS interface already connected') return + # Close existing socket if it exists + if self.socket and self.socket.is_open: + try: + self.socket.close() + except Exception: + pass + try: + # serial.Serial() automatically opens the port, so we don't need to call open() self.socket = serial.Serial( - CONF.kiss_serial.device, CONF.kiss_serial.baudrate + CONF.kiss_serial.device, + timeout=1, + baudrate=CONF.kiss_serial.baudrate, + # bytesize=8, + # parity='N', + # stopbits=1, + # xonxoff=False, + # rtscts=False, + # dsrdtr=False, ) - self.socket.open() self._connected = True except serial.SerialException as e: LOG.error(f'Failed to connect to KISS interface: {e}') @@ -142,21 +164,21 @@ class SerialKISSDriver(KISSDriver): return None while self._connected: - try: - readable, _, _ = select.select( - [self.socket], - [], - [], - self.select_timeout, - ) - if not readable: - continue - except Exception as e: - # No need to log if we are not running. - # this happens when the client is stopped/closed. - LOG.error(f'Error in read loop: {e}') - self._connected = False - break + # try: + # readable, _, _ = select.select( + # [self.socket], + ## [], + # [], + # self.select_timeout, + # ) + # if not readable: + # continue + # except Exception as e: + # # No need to log if we are not running. + # # this happens when the client is stopped/closed. + # LOG.error(f'Error in read loop: {e}') + # self._connected = False + # break try: short_buf = self.socket.read(1024) @@ -208,12 +230,13 @@ class SerialKISSDriver(KISSDriver): frame_kiss = b''.join( [kiss_constants.FEND, command.value, frame_escaped, kiss_constants.FEND] ) - self.socket.send(frame_kiss) + self.socket.write(frame_kiss) # Update last packet sent time self.last_packet_sent = datetime.datetime.now() # Increment packets sent counter self.packets_sent += 1 + @trace.no_trace def stats(self, serializable: bool = False) -> Dict[str, Any]: """Get client statistics. diff --git a/aprsd/client/drivers/tcpkiss.py b/aprsd/client/drivers/tcpkiss.py index 8434426..ddc2f90 100644 --- a/aprsd/client/drivers/tcpkiss.py +++ b/aprsd/client/drivers/tcpkiss.py @@ -39,8 +39,6 @@ class TCPKISSDriver(KISSDriver): # Class level attributes required by Client protocol client_name = None socket = None - # timeout in seconds - select_timeout = 1 path = None def __new__(cls, *args, **kwargs): From c34a82108bc00fb540385ccbb185d5ef2f862479 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Wed, 26 Nov 2025 20:28:25 -0500 Subject: [PATCH 5/5] fixed tox failures --- aprsd/client/drivers/serialkiss.py | 2 +- tests/client/drivers/test_tcpkiss_driver.py | 9 +++++---- uv.lock | 10 +++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/aprsd/client/drivers/serialkiss.py b/aprsd/client/drivers/serialkiss.py index 5fe1aee..453ee7d 100644 --- a/aprsd/client/drivers/serialkiss.py +++ b/aprsd/client/drivers/serialkiss.py @@ -167,7 +167,7 @@ class SerialKISSDriver(KISSDriver): # try: # readable, _, _ = select.select( # [self.socket], - ## [], + # [], # [], # self.select_timeout, # ) diff --git a/tests/client/drivers/test_tcpkiss_driver.py b/tests/client/drivers/test_tcpkiss_driver.py index 4cbcb96..bd91c3e 100644 --- a/tests/client/drivers/test_tcpkiss_driver.py +++ b/tests/client/drivers/test_tcpkiss_driver.py @@ -316,7 +316,8 @@ class TestTCPKISSDriver(unittest.TestCase): """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' + 'aprsd.client.drivers.kiss_common.KISSDriver._handle_fend', + return_value=b'fixed_frame', ) as mock_handle_fend: raw_frame = b'\xc0\x00some_frame_data\xc0' # \xc0 is FEND @@ -344,7 +345,7 @@ class TestTCPKISSDriver(unittest.TestCase): mock_factory.assert_called_once_with(mock_aprs_data) self.assertEqual(result, mock_packet) - @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + @mock.patch('aprsd.client.drivers.kiss_common.LOG') def test_decode_packet_no_frame(self, mock_log): """Test decode_packet with no frame returns None.""" result = self.driver.decode_packet() @@ -352,13 +353,13 @@ class TestTCPKISSDriver(unittest.TestCase): self.assertIsNone(result) mock_log.warning.assert_called_once() - @mock.patch('aprsd.client.drivers.tcpkiss.LOG') + @mock.patch('aprsd.client.drivers.kiss_common.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', + 'aprsd.client.drivers.kiss_common.aprslib.parse', side_effect=Exception('Test error'), ) as mock_parse: result = self.driver.decode_packet(frame=mock_frame) diff --git a/uv.lock b/uv.lock index a047ebc..356361f 100644 --- a/uv.lock +++ b/uv.lock @@ -84,6 +84,7 @@ dev = [ { name = "mistune" }, { name = "nodeenv" }, { name = "packaging" }, + { name = "pip" }, { name = "pip-tools" }, { name = "platformdirs" }, { name = "pluggy" }, @@ -93,6 +94,7 @@ dev = [ { name = "pyproject-hooks" }, { name = "pyyaml" }, { name = "requests" }, + { name = "setuptools" }, { name = "snowballstemmer" }, { name = "sphinx" }, { name = "sphinxcontrib-applehelp" }, @@ -155,6 +157,7 @@ requires-dist = [ { name = "packaging", specifier = "==25.0" }, { name = "packaging", marker = "extra == 'dev'", specifier = "==25.0" }, { name = "pbr", specifier = "==6.1.1" }, + { name = "pip", marker = "extra == 'dev'", specifier = "==25.2" }, { name = "pip-tools", marker = "extra == 'dev'", specifier = "==7.5.0" }, { name = "platformdirs", marker = "extra == 'dev'", specifier = "==4.3.8" }, { name = "pluggy", specifier = "==1.6.0" }, @@ -175,6 +178,7 @@ requires-dist = [ { name = "rich", specifier = "==14.1.0" }, { name = "rush", specifier = "==2021.4.0" }, { name = "setuptools", specifier = "==80.9.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==80.9.0" }, { name = "snowballstemmer", marker = "extra == 'dev'", specifier = "==3.0.1" }, { name = "sphinx", marker = "extra == 'dev'", specifier = "==8.1.3" }, { name = "sphinxcontrib-applehelp", marker = "extra == 'dev'", specifier = "==2.0.0" }, @@ -768,11 +772,11 @@ wheels = [ [[package]] name = "pip" -version = "24.3.1" +version = "25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/b422acd212ad7eedddaf7981eee6e5de085154ff726459cf2da7c5a184c1/pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99", size = 1931073, upload-time = "2024-10-27T18:35:56.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021, upload-time = "2025-07-30T21:50:15.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/7d/500c9ad20238fcfcb4cb9243eede163594d7020ce87bd9610c9e02771876/pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed", size = 1822182, upload-time = "2024-10-27T18:35:53.067Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557, upload-time = "2025-07-30T21:50:13.323Z" }, ] [[package]]