mirror of
https://github.com/craigerl/aprsd.git
synced 2026-04-06 23:25:34 -04:00
Merge pull request #204 from craigerl/serial-kiss
Added SerialKISSDriver
This commit is contained in:
commit
9c06957943
@ -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 │ │ │ │ │
|
||||
└──────────────────────────┴─────────────────────────────────────────────────────────────────────┴─────────┴─────────────────────┴────────────┘
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
184
aprsd/client/drivers/kiss_common.py
Normal file
184
aprsd/client/drivers/kiss_common.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# timeout in seconds
|
||||
select_timeout = 1
|
||||
|
||||
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)
|
||||
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')
|
||||
|
||||
@trace.no_trace
|
||||
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
|
||||
249
aprsd/client/drivers/serialkiss.py
Normal file
249
aprsd/client/drivers/serialkiss.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""
|
||||
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
|
||||
from aprsd.utils import trace
|
||||
|
||||
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
|
||||
if self.socket and self.socket.is_open:
|
||||
try:
|
||||
self.socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
# 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,
|
||||
timeout=1,
|
||||
baudrate=CONF.kiss_serial.baudrate,
|
||||
# bytesize=8,
|
||||
# parity='N',
|
||||
# stopbits=1,
|
||||
# xonxoff=False,
|
||||
# rtscts=False,
|
||||
# dsrdtr=False,
|
||||
)
|
||||
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.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.
|
||||
|
||||
Returns:
|
||||
Dict containing client statistics
|
||||
"""
|
||||
stats = super().stats(serializable=serializable)
|
||||
stats['device'] = CONF.kiss_serial.device
|
||||
stats['baudrate'] = CONF.kiss_serial.baudrate
|
||||
return stats
|
||||
@ -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,45 +23,22 @@ 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
|
||||
select_timeout = 1
|
||||
path = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
@ -107,15 +84,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 +168,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 +219,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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -143,12 +162,22 @@ 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):
|
||||
# replace it with a wrapped version
|
||||
attribute = functools.update_wrapper(
|
||||
trace_method(attribute),
|
||||
attribute,
|
||||
)
|
||||
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):
|
||||
# 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)
|
||||
|
||||
@ -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)
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@ -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]]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user