1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-16 16:43:52 -04:00

Compare commits

...

10 Commits

Author SHA1 Message Date
hemna 9bf4bfd92c Update Changelog for 4.2.1 release 2025-10-07 14:22:59 -04:00
hemna af0feaf9c8 Fixed some unit tests
Fixed unit tests related to the updated static method signatures
of the client and drivers.
2025-10-07 14:18:50 -04:00
hemna 328c027ad3 Some client and driver cleanup.
Fixed the declarations of some of the client and driver methods
to be staticmethod.
2025-10-07 14:11:54 -04:00
hemna e15322ede3 Added package
refactored some package related code to the new
utils/package.py
2025-10-05 16:27:51 -04:00
hemna c7c9a92b15 refactored list-plugins
created new package module that gets information from installed
plugins and extensions so we can print it out to log at startup.

Updated commands to output the the installed extensions and plugins
2025-09-27 17:21:32 -04:00
hemna 3961e1d1ad Added ThirdPartyPacket decoding in tcpkiss driver
This patch adds a check in the tcpkiss driver to
check to see if the packet is a 3rd party packet
and automatically return the subpacket if it is.
2025-09-26 11:55:40 -04:00
hemna 8cd61a72c8 Fixed missing f string
This updates a string output in process_other_packet where
the string being output wasn't prefixed with the f character
to denote that python needed to format the contents with the object.
2025-09-26 11:27:03 -04:00
hemna 556554b1a7 Fixed stats issue with tcpkiss client.
The tcpkiss client's stats method wasn't serializing all of
the datetime objects.  This patch ensures that all the dates
in the stats are serializable when requested.
2025-09-26 10:57:23 -04:00
hemna f3039ebfa1 Added CONF.is_digipi
This will help Craig with custom code changes that might be
needed only on the digipi.  He likes to modify the UI for
webchat.  So in the new webchat extension simply add a call
if CONF.is_digipi:
  # Do your awesome changes here
else:
  # normal aprsd/webchat stuffs here.
2025-09-22 10:52:28 -04:00
hemna 58cb046b31 Sanity check around decoding packet
This adds another sanity try, except block at the
APRSDClient level call for decode_packet, just in
case the driver itself fails in it's decode_packet()
call.
2025-09-22 10:25:31 -04:00
15 changed files with 285 additions and 164 deletions
+15
View File
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [4.2.1](https://github.com/craigerl/aprsd/compare/4.2.0...4.2.1)
> 7 October 2025
- Sanity check around decoding packet [`58cb046`](https://github.com/craigerl/aprsd/commit/58cb046b3131f4300255c527dee4c7291a4aeed2)
- Added CONF.is_digipi [`f3039eb`](https://github.com/craigerl/aprsd/commit/f3039ebfa153d0fbad4a390089fce360e6148854)
- Fixed stats issue with tcpkiss client. [`556554b`](https://github.com/craigerl/aprsd/commit/556554b1a76743989fa88877e7f694b918f770cf)
- Fixed missing f string [`8cd61a7`](https://github.com/craigerl/aprsd/commit/8cd61a72c8f16e8651533baf2d677d3345bd3712)
- Added ThirdPartyPacket decoding in tcpkiss driver [`3961e1d`](https://github.com/craigerl/aprsd/commit/3961e1d1adf428ff0fa66ed5fe0d6192adf164bf)
- refactored list-plugins [`c7c9a92`](https://github.com/craigerl/aprsd/commit/c7c9a92b153ccc673275dca628d485d1df88db12)
- Added package [`e15322e`](https://github.com/craigerl/aprsd/commit/e15322ede384d6e463c42e9496502e2c6ea0886c)
- Some client and driver cleanup. [`328c027`](https://github.com/craigerl/aprsd/commit/328c027ad3db594f3d5622a2cab7cafb21d1cfd6)
- Fixed some unit tests [`af0feaf`](https://github.com/craigerl/aprsd/commit/af0feaf9c81b8b9f443e58fbe7d23f1ff7ba63d2)
#### [4.2.0](https://github.com/craigerl/aprsd/compare/4.1.2...4.2.0)
> 12 August 2025
@@ -25,6 +39,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Fix tox failures [`74887af`](https://github.com/craigerl/aprsd/commit/74887af507755cd7ee27f13129b177f4d58ca8e0)
- log the exception when tx fails. [`fa5d0c6`](https://github.com/craigerl/aprsd/commit/fa5d0c643ae85fc9ad3f615f9d0afc590f422283)
- Updated requirements for 4.2.0 [`2c476d8`](https://github.com/craigerl/aprsd/commit/2c476d8a04df8cebbaa50780688e58d8b9df2bc9)
- Updated Changelog for 4.2.0 [`b9fea98`](https://github.com/craigerl/aprsd/commit/b9fea982f977e9cf60c4695ca1892adaeee64298)
#### [4.1.2](https://github.com/craigerl/aprsd/compare/4.1.1...4.1.2)
+31 -16
View File
@@ -38,15 +38,16 @@ class APRSDClient:
keepalive_collector.KeepAliveCollector().register(cls)
return cls._instance
def __init__(self):
def __init__(self, auto_connect: bool = True):
self.auto_connect = auto_connect
self.connected = False
self.login_status = {
'success': False,
'message': None,
}
if not self.driver:
self.driver = DriverRegistry().get_driver()
self.driver.setup_connection()
self.driver = DriverRegistry().get_driver()
if self.auto_connect:
self.connect()
def stats(self, serializable=False) -> dict:
stats = {}
@@ -54,17 +55,20 @@ class APRSDClient:
stats = self.driver.stats(serializable=serializable)
return stats
@property
def is_enabled(self):
if not self.driver:
return False
return self.driver.is_enabled()
@staticmethod
def is_enabled():
for driver in DriverRegistry().drivers:
if driver.is_enabled():
return True
return False
@property
def is_configured(self):
if not self.driver:
return False
return self.driver.is_configured()
@staticmethod
def is_configured():
"""Check if ANY driver is configured."""
for driver in DriverRegistry().drivers:
if driver.is_configured():
return True
return False
# @property
# def is_connected(self):
@@ -98,6 +102,11 @@ class APRSDClient:
def is_alive(self):
return self.driver.is_alive()
def connect(self):
if not self.driver:
self.driver = DriverRegistry().get_driver()
self.driver.setup_connection()
def close(self):
if not self.driver:
return
@@ -109,7 +118,8 @@ class APRSDClient:
LOG.info('Resetting client connection.')
if self.driver:
self.driver.close()
self.driver.setup_connection()
if not self.delay_connect:
self.driver.setup_connection()
if self.filter:
self.driver.set_filter(self.filter)
else:
@@ -138,4 +148,9 @@ class APRSDClient:
return self.driver.consumer(callback=callback, raw=raw)
def decode_packet(self, *args, **kwargs) -> core.Packet:
return self.driver.decode_packet(*args, **kwargs)
try:
packet = self.driver.decode_packet(*args, **kwargs)
except Exception as e:
LOG.error(f'Error decoding packet: {e}')
return None
return packet
+3 -3
View File
@@ -26,6 +26,7 @@ class APRSISDriver:
_client = None
_checks = False
connected = False
def __init__(self):
max_timeout = {'hours': 0.0, 'minutes': 2, 'seconds': 0}
@@ -164,7 +165,7 @@ class APRSISDriver:
return core.factory(args[0])
def consumer(self, callback: Callable, raw: bool = False):
if self._client:
if self._client and self.connected:
try:
self._client.consumer(
callback,
@@ -177,10 +178,9 @@ class APRSISDriver:
LOG.info(e.__cause__)
raise e
else:
LOG.warning('client is None, might be resetting.')
self.connected = False
def stats(self, serializable=False) -> dict:
def stats(self, serializable: bool = False) -> dict:
stats = {}
if self.is_configured():
if self._client:
+20 -5
View File
@@ -79,8 +79,8 @@ class TCPKISSDriver:
def transport(self) -> str:
return client.TRANSPORT_TCPKISS
@classmethod
def is_enabled(cls) -> bool:
@staticmethod
def is_enabled() -> bool:
"""Check if KISS is enabled in configuration.
Returns:
@@ -248,7 +248,11 @@ class TCPKISSDriver:
LOG.warning(f'FRAME: {str(frame)}')
try:
aprslib_frame = aprslib.parse(str(frame))
return core.factory(aprslib_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
@@ -271,8 +275,19 @@ class TCPKISSDriver:
"""
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,
@@ -280,8 +295,8 @@ class TCPKISSDriver:
'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,
'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,
+6 -1
View File
@@ -4,11 +4,13 @@
#
# python included libs
import logging
import sys
import click
from oslo_config import cfg
from aprsd import cli_helper, conf, packets, plugin
import aprsd
from aprsd import cli_helper, conf, packets, plugin, utils
# local imports here
from aprsd.main import cli
@@ -71,6 +73,9 @@ def test_plugin(
):
"""Test an individual APRSD plugin given a python path."""
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD DEV Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
CONF.log_opt_values(LOG, logging.DEBUG)
if not aprs_login:
+2
View File
@@ -1,5 +1,6 @@
# Fetch active stats from a remote running instance of aprsd admin web interface.
import logging
import sys
import click
import requests
@@ -38,6 +39,7 @@ CONF = cfg.CONF
def fetch_stats(ctx, host, port):
"""Fetch stats from a APRSD admin web interface."""
console = Console()
console.print(f'Python version: {sys.version}')
console.print(f'APRSD Fetch-Stats started version: {aprsd.__version__}')
CONF.log_opt_values(LOG, logging.DEBUG)
+6 -133
View File
@@ -1,119 +1,18 @@
import fnmatch
import importlib
import inspect
import logging
import os
import pkgutil
import sys
from traceback import print_tb
import click
import requests
from rich.console import Console
from rich.table import Table
from rich.text import Text
from thesmuggler import smuggle
from aprsd import cli_helper
from aprsd import plugin as aprsd_plugin
from aprsd.main import cli
from aprsd.plugins import fortune, notify, ping, time, version, weather
from aprsd.utils import package as aprsd_package
LOG = logging.getLogger('APRSD')
PYPI_URL = 'https://pypi.org/search/'
def onerror(name):
print(f'Error importing module {name}')
type, value, traceback = sys.exc_info()
print_tb(traceback)
def is_plugin(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return True
return False
def plugin_type(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDRegexCommandPluginBase):
return 'RegexCommand'
if issubclass(c, aprsd_plugin.APRSDWatchListPluginBase):
return 'WatchList'
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return 'APRSDPluginBase'
return 'Unknown'
def walk_package(package):
return pkgutil.walk_packages(
package.__path__,
package.__name__ + '.',
onerror=onerror,
)
def get_module_info(package_name, module_name, module_path):
if not os.path.exists(module_path):
return None
dir_path = os.path.realpath(module_path)
pattern = '*.py'
obj_list = []
for path, _subdirs, files in os.walk(dir_path):
for name in files:
if fnmatch.fnmatch(name, pattern):
module = smuggle(f'{path}/{name}')
for mem_name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and is_plugin(obj):
obj_list.append(
{
'package': package_name,
'name': mem_name,
'obj': obj,
'version': obj.version,
'path': f'{".".join([module_name, obj.__name__])}',
},
)
return obj_list
def _get_installed_aprsd_items():
# installed plugins
plugins = {}
extensions = {}
for _finder, name, ispkg in pkgutil.iter_modules():
if ispkg and name.startswith('aprsd_'):
module = importlib.import_module(name)
pkgs = walk_package(module)
for pkg in pkgs:
pkg_info = get_module_info(
module.__name__, pkg.name, module.__path__[0]
)
if 'plugin' in name:
plugins[name] = pkg_info
elif 'extension' in name:
extensions[name] = pkg_info
return plugins, extensions
def get_installed_plugins():
# installed plugins
plugins, extensions = _get_installed_aprsd_items()
return plugins
def get_installed_extensions():
# installed plugins
plugins, extensions = _get_installed_aprsd_items()
return extensions
def show_built_in_plugins(console):
@@ -157,34 +56,8 @@ def show_built_in_plugins(console):
console.print(table)
def _get_pypi_packages():
if simple_r := requests.get(
'https://pypi.org/simple',
headers={'Accept': 'application/vnd.pypi.simple.v1+json'},
):
simple_response = simple_r.json()
else:
simple_response = {}
key = 'aprsd'
matches = [
p['name'] for p in simple_response['projects'] if p['name'].startswith(key)
]
packages = []
for pkg in matches:
# Get info for first match
if r := requests.get(
f'https://pypi.org/pypi/{pkg}/json',
headers={'Accept': 'application/json'},
):
packages.append(r.json())
return packages
def show_pypi_plugins(installed_plugins, console):
packages = _get_pypi_packages()
packages = aprsd_package.get_pypi_packages()
title = Text.assemble(
('Pypi.org APRSD Installable Plugin Packages\n\n', 'bold magenta'),
@@ -225,7 +98,7 @@ def show_pypi_plugins(installed_plugins, console):
def show_pypi_extensions(installed_extensions, console):
packages = _get_pypi_packages()
packages = aprsd_package.get_pypi_packages()
title = Text.assemble(
('Pypi.org APRSD Installable Extension Packages\n\n', 'bold magenta'),
@@ -282,7 +155,7 @@ def show_installed_plugins(installed_plugins, console):
name.replace('_', '-'),
plugin['name'],
plugin['version'],
plugin_type(plugin['obj']),
aprsd_package.plugin_type(plugin['obj']),
plugin['path'],
)
@@ -302,7 +175,7 @@ def list_plugins(ctx):
show_built_in_plugins(console)
status.update('Fetching pypi.org plugins')
installed_plugins = get_installed_plugins()
installed_plugins = aprsd_package.get_installed_plugins()
show_pypi_plugins(installed_plugins, console)
status.update('Looking for installed APRSD plugins')
@@ -321,5 +194,5 @@ def list_extensions(ctx):
status.update('Fetching pypi.org APRSD Extensions')
status.update('Looking for installed APRSD Extensions')
installed_extensions = get_installed_extensions()
installed_extensions = aprsd_package.get_installed_extensions()
show_pypi_extensions(installed_extensions, console)
+2
View File
@@ -221,7 +221,9 @@ def listen(
# CONF.aprs_network.login = aprs_login
# config["aprs"]["password"] = aprs_password
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD Listen Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
CONF.log_opt_values(LOG, logging.DEBUG)
collector.Collector()
+4 -1
View File
@@ -13,6 +13,7 @@ from aprsd import (
cli_helper,
conf, # noqa : F401
packets,
utils,
)
from aprsd.client.client import APRSDClient
from aprsd.main import cli
@@ -89,7 +90,9 @@ def send_message(
else:
aprs_password = CONF.aprs_network.password
LOG.info(f'APRSD LISTEN Started version: {aprsd.__version__}')
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD SEND_MESSAGE Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
if type(command) is tuple:
command = ' '.join(command)
if not quiet:
+3 -1
View File
@@ -40,12 +40,14 @@ def server(ctx, flush):
service_threads = service.ServiceThreads()
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD Started version: {aprsd.__version__}')
level, msg = utils._check_version()
if level:
LOG.warning(msg)
else:
LOG.info(msg)
LOG.info(f'APRSD Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
# Make sure we have 1 client transport enabled
if not APRSDClient().is_enabled:
+7 -1
View File
@@ -19,7 +19,7 @@ registry_group = cfg.OptGroup(
aprsd_opts = [
cfg.StrOpt(
'callsign',
required=True,
default='NOCALL',
help='Callsign to use for messages sent by APRSD',
),
cfg.BoolOpt(
@@ -137,6 +137,12 @@ aprsd_opts = [
help='Set this to False, to disable sending of ack packets. This will entirely stop'
'APRSD from sending ack packets.',
),
cfg.BoolOpt(
'is_digipi',
default=False,
help='Set this to True, if APRSD is running on a Digipi.'
'This is useful for changing the behavior of APRSD to work with Digipi.',
),
]
watch_list_opts = [
+7
View File
@@ -13,3 +13,10 @@ class ConfigOptionBogusDefaultException(Exception):
f"Config file option '{config_option}' needs to be "
f"changed from provided default of '{default_fail}'"
)
class APRSClientNotConfiguredException(Exception):
"""APRS client is not configured."""
def __init__(self):
self.message = 'APRS client is not configured.'
+1 -1
View File
@@ -275,7 +275,7 @@ class APRSDProcessPacketThread(APRSDFilterThread):
def process_other_packet(self, packet, for_us=False):
"""Process an APRS Packet that isn't a message or ack"""
if not for_us:
LOG.info("Got a packet meant for someone else '{packet.to_call}'")
LOG.info(f"Got a packet meant for someone else '{packet.to_call}'")
else:
LOG.info('Got a non AckPacket/MessagePacket')
+176
View File
@@ -0,0 +1,176 @@
import fnmatch
import importlib
import inspect
import logging
import os
import pkgutil
import sys
from traceback import print_tb
import requests
from thesmuggler import smuggle
from aprsd import plugin as aprsd_plugin
LOG = logging.getLogger()
def onerror(name):
type, value, traceback = sys.exc_info()
print_tb(traceback)
def plugin_type(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDRegexCommandPluginBase):
return 'RegexCommand'
if issubclass(c, aprsd_plugin.APRSDWatchListPluginBase):
return 'WatchList'
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return 'APRSDPluginBase'
return 'Unknown'
def is_plugin(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return True
return False
def walk_package(package):
return pkgutil.walk_packages(
package.__path__,
package.__name__ + '.',
onerror=onerror,
)
def get_module_info(package_name, module_name, module_path):
if not os.path.exists(module_path):
return None
dir_path = os.path.realpath(module_path)
pattern = '*.py'
obj_list = []
for path, _subdirs, files in os.walk(dir_path):
for name in files:
if fnmatch.fnmatch(name, pattern):
module = smuggle(f'{path}/{name}')
for mem_name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and is_plugin(obj):
obj_list.append(
{
'package': package_name,
'name': mem_name,
'obj': obj,
'version': obj.version,
'path': f'{".".join([module_name, obj.__name__])}',
},
)
return obj_list
def is_aprsd_package(name):
if name.startswith('aprsd_'):
return True
def is_aprsd_extension(name):
if name.startswith('aprsd_') and 'extension' in name:
# This is an installed package that is an extension of
# APRSD
return True
else:
# We might have an editable install of an extension
# of APRSD.
return '__editable__' in name and 'aprsd_' in name and 'extension' in name
def get_installed_aprsd_items():
# installed plugins
plugins = {}
extensions = {}
for _finder, name, ispkg in pkgutil.iter_modules():
if ispkg and is_aprsd_package(name):
module = importlib.import_module(name)
pkgs = walk_package(module)
for pkg in pkgs:
pkg_info = get_module_info(
module.__name__, pkg.name, module.__path__[0]
)
if 'plugin' in name:
plugins[name] = pkg_info
elif 'extension' in name:
mod = importlib.import_module(name)
extensions[name] = mod
elif is_aprsd_extension(name):
# This isn't a package, so it could be an editable install
module = importlib.import_module(name)
key_name = next(iter(module.MAPPING.keys()))
module = importlib.import_module(key_name)
pkg_info = get_module_info(module.__name__, key_name, module.__path__[0])
extensions[key_name] = module
return plugins, extensions
def get_installed_plugins():
# installed plugins
plugins, _ = get_installed_aprsd_items()
return plugins
def get_installed_extensions():
# installed plugins
_, extensions = get_installed_aprsd_items()
return extensions
def get_pypi_packages():
if simple_r := requests.get(
'https://pypi.org/simple',
headers={'Accept': 'application/vnd.pypi.simple.v1+json'},
):
simple_response = simple_r.json()
else:
simple_response = {}
key = 'aprsd'
matches = [
p['name'] for p in simple_response['projects'] if p['name'].startswith(key)
]
packages = []
for pkg in matches:
# Get info for first match
if r := requests.get(
f'https://pypi.org/pypi/{pkg}/json',
headers={'Accept': 'application/json'},
):
packages.append(r.json())
return packages
def log_installed_extensions_and_plugins():
plugins, extensions = get_installed_aprsd_items()
for name in extensions:
ext = extensions[name]
# print(f"Extension: {ext}")
# print(f"Extension: {ext.__dict__}")
if hasattr(ext, '__version__'):
version = ext.__version__
elif hasattr(ext, 'version'):
version = ext.version
else:
version = ext['version']
LOG.info(f'Extension: {name} version: {version}')
for plugin in plugins:
LOG.info(f'Plugin: {plugin} version: {plugins[plugin][0]["version"]}')
+2 -2
View File
@@ -353,6 +353,7 @@ class TestAPRSISDriver(unittest.TestCase):
def test_consumer_success(self, mock_log):
"""Test consumer forwards callback to client."""
self.driver._client = self.mock_client
self.driver.connected = True
mock_callback = mock.MagicMock()
self.driver.consumer(mock_callback, raw=True)
@@ -365,6 +366,7 @@ class TestAPRSISDriver(unittest.TestCase):
def test_consumer_exception(self, mock_log):
"""Test consumer handles exceptions."""
self.driver._client = self.mock_client
self.driver.connected = True
mock_callback = mock.MagicMock()
test_error = Exception('Test error')
self.mock_client.consumer.side_effect = test_error
@@ -381,8 +383,6 @@ class TestAPRSISDriver(unittest.TestCase):
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):