1
0
mirror of https://github.com/craigerl/aprsd.git synced 2025-06-16 13:22:27 -04:00

Merge branch 'master' into option-to-disable-help

This commit is contained in:
afourney 2024-11-11 20:40:33 -08:00 committed by GitHub
commit c460cefc1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1856 additions and 977 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
WORKDIR?=.
VENVDIR ?= $(WORKDIR)/.aprsd-venv
VENVDIR ?= $(WORKDIR)/.venv
.DEFAULT_GOAL := help
@ -24,7 +24,7 @@ run: venv ## Create a virtual environment for running aprsd commands
changelog: dev
npm i -g auto-changelog
auto-changelog -l false -o ChangeLog.md
auto-changelog -l false --sort-commits date -o ChangeLog.md
docs: changelog
m2r --overwrite ChangeLog.md

View File

@ -11,6 +11,37 @@ ____________________
`APRSD <http://github.com/craigerl/aprsd>`_ is a Ham radio `APRS <http://aprs.org>`_ message command gateway built on python.
Table of Contents
=================
1. `What is APRSD <#what-is-aprsd>`_
2. `APRSD Overview Diagram <#aprsd-overview-diagram>`_
3. `Typical Use Case <#typical-use-case>`_
4. `Installation <#installation>`_
5. `Example Usage <#example-usage>`_
6. `Help <#help>`_
7. `Commands <#commands>`_
- `Configuration <#configuration>`_
- `Server <#server>`_
- `Current List of Built-in Plugins <#current-list-of-built-in-plugins>`_
- `Pypi.org APRSD Installable Plugin Packages <#pypiorg-aprsd-installable-plugin-packages>`_
- `🐍 APRSD Installed 3rd Party Plugins <#aprsd-installed-3rd-party-plugins>`_
- `Send Message <#send-message>`_
- `Send Email (Radio to SMTP Server) <#send-email-radio-to-smtp-server>`_
- `Receive Email (IMAP Server to Radio) <#receive-email-imap-server-to-radio>`_
- `Location <#location>`_
- `Web Admin Interface <#web-admin-interface>`_
8. `Development <#development>`_
- `Building Your Own APRSD Plugins <#building-your-own-aprsd-plugins>`_
9. `Workflow <#workflow>`_
10. `Release <#release>`_
11. `Docker Container <#docker-container>`_
- `Building <#building-1>`_
- `Official Build <#official-build>`_
- `Development Build <#development-build>`_
- `Running the Container <#running-the-container>`_
What is APRSD
=============
APRSD is a python application for interacting with the APRS network and providing
@ -147,8 +178,7 @@ look for incomming commands to the callsign configured in the config file
Current list of built-in plugins
======================================
--------------------------------
::
└─> aprsd list-plugins
@ -300,18 +330,21 @@ AND... ping, fortune, time.....
Web Admin Interface
===================
APRSD has a web admin interface that allows you to view the status of the running APRSD server instance.
The web admin interface shows graphs of packet counts, packet types, number of threads running, the latest
packets sent and received, and the status of each of the plugins that are loaded. You can also view the logfile
and view the raw APRSD configuration file.
To start the web admin interface, You have to install gunicorn in your virtualenv that already has aprsd installed.
::
source <path to APRSD's virtualenv>/bin/activate
pip install gunicorn
gunicorn --bind 0.0.0.0:8080 "aprsd.wsgi:app"
aprsd admin --loglevel INFO
The web admin interface will be running on port 8080 on the local machine. http://localhost:8080
Development
===========
@ -320,7 +353,7 @@ Development
* ``make``
Workflow
========
--------
While working aprsd, The workflow is as follows:
@ -349,7 +382,7 @@ While working aprsd, The workflow is as follows:
Release
=======
-------
To do release to pypi:
@ -370,6 +403,29 @@ To do release to pypi:
``make upload``
Building your own APRSD plugins
-------------------------------
APRSD plugins are the mechanism by which APRSD can respond to APRS Messages. The plugins are loaded at server startup
and can also be loaded at listen startup. When a packet is received by APRSD, it is passed to each of the plugins
in the order they were registered in the config file. The plugins can then decide what to do with the packet.
When a plugin is called, it is passed a APRSD Packet object. The plugin can then do something with the packet and
return a reply message if desired. If a plugin does not want to reply to the packet, it can just return None.
When a plugin does return a reply message, APRSD will send the reply message to the appropriate destination.
For example, when a 'ping' message is received, the PingPlugin will return a reply message of 'pong'. When APRSD
receives the 'pong' message, it will be sent back to the original caller of the ping message.
APRSD plugins are simply python packages that can be installed from pypi.org. They are installed into the
aprsd virtualenv and can be imported by APRSD at runtime. The plugins are registered in the config file and loaded
at startup of the aprsd server command or the aprsd listen command.
Overview
--------
You can build your own plugins by following the instructions in the `Building your own APRSD plugins`_ section.
Plugins are called by APRSD when packe
Docker Container
================

View File

@ -126,7 +126,10 @@ class APRSISClient(base.APRSClient):
return aprs_client
def consumer(self, callback, blocking=False, immortal=False, raw=False):
self._client.consumer(
callback, blocking=blocking,
immortal=immortal, raw=raw,
)
try:
self._client.consumer(
callback, blocking=blocking,
immortal=immortal, raw=raw,
)
except Exception as e:
LOG.error(f"Exception in consumer: {e}")

View File

@ -32,7 +32,11 @@ class APRSClient:
@abc.abstractmethod
def stats(self) -> dict:
pass
"""Return statistics about the client connection.
Returns:
dict: Statistics about the connection and packet handling
"""
def set_filter(self, filter):
self.filter = filter
@ -46,22 +50,31 @@ class APRSClient:
return self._client
def _create_client(self):
self._client = self.setup_connection()
if self.filter:
LOG.info("Creating APRS client filter")
self._client.set_filter(self.filter)
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):
"""Send a packet to the network."""
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):
def reset(self) -> None:
"""Call this to force a rebuild/reconnect."""
LOG.info("Resetting client connection.")
if self._client:
@ -76,7 +89,11 @@ class APRSClient:
@abc.abstractmethod
def setup_connection(self):
pass
"""Initialize and return the underlying APRS connection.
Returns:
object: The initialized connection object
"""
@staticmethod
@abc.abstractmethod
@ -90,7 +107,11 @@ class APRSClient:
@abc.abstractmethod
def decode_packet(self, *args, **kwargs):
pass
"""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):

View File

@ -33,7 +33,11 @@ class Aprsdis(aprslib.IS):
def stop(self):
self.thread_stop = True
LOG.info("Shutdown Aprsdis client.")
LOG.warning("Shutdown Aprsdis client.")
def close(self):
LOG.warning("Closing Aprsdis client.")
super().close()
@wrapt.synchronized(lock)
def send(self, packet: core.Packet):
@ -189,14 +193,14 @@ class Aprsdis(aprslib.IS):
except ParseError as exp:
self.logger.log(
11,
"%s\n Packet: %s",
"%s Packet: '%s'",
exp,
exp.packet,
)
except UnknownFormat as exp:
self.logger.log(
9,
"%s\n Packet: %s",
"%s Packet: '%s'",
exp,
exp.packet,
)

57
aprsd/cmds/admin.py Normal file
View File

@ -0,0 +1,57 @@
import logging
import os
import signal
import click
from oslo_config import cfg
import socketio
import aprsd
from aprsd import cli_helper
from aprsd import main as aprsd_main
from aprsd import utils
from aprsd.main import cli
os.environ["APRSD_ADMIN_COMMAND"] = "1"
# this import has to happen AFTER we set the
# above environment variable, so that the code
# inside the wsgi.py has the value
from aprsd import wsgi as aprsd_wsgi # noqa
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
# main() ###
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.pass_context
@cli_helper.process_standard_options
def admin(ctx):
"""Start the aprsd admin interface."""
signal.signal(signal.SIGINT, aprsd_main.signal_handler)
signal.signal(signal.SIGTERM, aprsd_main.signal_handler)
level, msg = utils._check_version()
if level:
LOG.warning(msg)
else:
LOG.info(msg)
LOG.info(f"APRSD Started version: {aprsd.__version__}")
# Dump all the config options now.
CONF.log_opt_values(LOG, logging.DEBUG)
async_mode = "threading"
sio = socketio.Server(logger=True, async_mode=async_mode)
aprsd_wsgi.app.wsgi_app = socketio.WSGIApp(sio, aprsd_wsgi.app.wsgi_app)
aprsd_wsgi.init_app()
sio.register_namespace(aprsd_wsgi.LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)
aprsd_wsgi.app.run(
threaded=True,
debug=False,
port=CONF.admin.web_port,
host=CONF.admin.web_ip,
)

View File

@ -11,6 +11,7 @@ from rich.table import Table
import aprsd
from aprsd import cli_helper
from aprsd.main import cli
from aprsd.threads.stats import StatsStore
# setup the global logger
@ -154,3 +155,157 @@ def fetch_stats(ctx, host, port):
watch_table.add_row(key, value["last"])
console.print(watch_table)
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.option(
"--raw",
is_flag=True,
default=False,
help="Dump raw stats instead of formatted output.",
)
@click.option(
"--show-section",
default=["All"],
help="Show specific sections of the stats. "
" Choices: All, APRSDStats, APRSDThreadList, APRSClientStats,"
" PacketList, SeenList, WatchList",
multiple=True,
type=click.Choice(
[
"All",
"APRSDStats",
"APRSDThreadList",
"APRSClientStats",
"PacketList",
"SeenList",
"WatchList",
],
case_sensitive=False,
),
)
@click.pass_context
@cli_helper.process_standard_options
def dump_stats(ctx, raw, show_section):
"""Dump the current stats from the running APRSD instance."""
console = Console()
console.print(f"APRSD Dump-Stats started version: {aprsd.__version__}")
with console.status("Dumping stats"):
ss = StatsStore()
ss.load()
stats = ss.data
if raw:
if "All" in show_section:
console.print(stats)
return
else:
for section in show_section:
console.print(f"Dumping {section} section:")
console.print(stats[section])
return
t = Table(title="APRSD Stats")
t.add_column("Key")
t.add_column("Value")
for key, value in stats["APRSDStats"].items():
t.add_row(key, str(value))
if "All" in show_section or "APRSDStats" in show_section:
console.print(t)
# Show the thread list
t = Table(title="Thread List")
t.add_column("Name")
t.add_column("Class")
t.add_column("Alive?")
t.add_column("Loop Count")
t.add_column("Age")
for name, value in stats["APRSDThreadList"].items():
t.add_row(
name,
value["class"],
str(value["alive"]),
str(value["loop_count"]),
str(value["age"]),
)
if "All" in show_section or "APRSDThreadList" in show_section:
console.print(t)
# Show the plugins
t = Table(title="Plugin List")
t.add_column("Name")
t.add_column("Enabled")
t.add_column("Version")
t.add_column("TX")
t.add_column("RX")
for name, value in stats["PluginManager"].items():
t.add_row(
name,
str(value["enabled"]),
value["version"],
str(value["tx"]),
str(value["rx"]),
)
if "All" in show_section or "PluginManager" in show_section:
console.print(t)
# Now show the client stats
t = Table(title="Client Stats")
t.add_column("Key")
t.add_column("Value")
for key, value in stats["APRSClientStats"].items():
t.add_row(key, str(value))
if "All" in show_section or "APRSClientStats" in show_section:
console.print(t)
# now show the packet list
packet_list = stats.get("PacketList")
t = Table(title="Packet List")
t.add_column("Key")
t.add_column("Value")
t.add_row("Total Received", str(packet_list["rx"]))
t.add_row("Total Sent", str(packet_list["tx"]))
if "All" in show_section or "PacketList" in show_section:
console.print(t)
# now show the seen list
seen_list = stats.get("SeenList")
sorted_seen_list = sorted(
seen_list.items(),
)
t = Table(title="Seen List")
t.add_column("Callsign")
t.add_column("Message Count")
t.add_column("Last Heard")
for key, value in sorted_seen_list:
t.add_row(
key,
str(value["count"]),
str(value["last"]),
)
if "All" in show_section or "SeenList" in show_section:
console.print(t)
# now show the watch list
watch_list = stats.get("WatchList")
sorted_watch_list = sorted(
watch_list.items(),
)
t = Table(title="Watch List")
t.add_column("Callsign")
t.add_column("Last Heard")
for key, value in sorted_watch_list:
t.add_row(
key,
str(value["last"]),
)
if "All" in show_section or "WatchList" in show_section:
console.print(t)

View File

@ -63,7 +63,7 @@ def healthcheck(ctx, timeout):
if email_thread_last_update != "never":
d = now - email_thread_last_update
max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0}
max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 30}
max_delta = datetime.timedelta(**max_timeout)
if d > max_delta:
console.log(f"Email thread is very old! {d}")

View File

@ -10,12 +10,13 @@ import sys
import time
import click
from loguru import logger
from oslo_config import cfg
from rich.console import Console
# local imports here
import aprsd
from aprsd import cli_helper, packets, plugin, threads
from aprsd import cli_helper, packets, plugin, threads, utils
from aprsd.client import client_factory
from aprsd.main import cli
from aprsd.packets import collector as packet_collector
@ -24,12 +25,14 @@ from aprsd.packets import seen_list
from aprsd.stats import collector
from aprsd.threads import keep_alive, rx
from aprsd.threads import stats as stats_thread
from aprsd.threads.aprsd import APRSDThread
# setup the global logger
# log.basicConfig(level=log.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
CONF = cfg.CONF
LOGU = logger
console = Console()
@ -42,16 +45,21 @@ def signal_handler(sig, frame):
),
)
time.sleep(5)
LOG.info(collector.Collector().collect())
# Last save to disk
collector.Collector().collect()
class APRSDListenThread(rx.APRSDRXThread):
def __init__(self, packet_queue, packet_filter=None, plugin_manager=None):
def __init__(
self, packet_queue, packet_filter=None, plugin_manager=None,
enabled_plugins=[], log_packets=False,
):
super().__init__(packet_queue)
self.packet_filter = packet_filter
self.plugin_manager = plugin_manager
if self.plugin_manager:
LOG.info(f"Plugins {self.plugin_manager.get_message_plugins()}")
self.log_packets = log_packets
def process_packet(self, *args, **kwargs):
packet = self._client.decode_packet(*args, **kwargs)
@ -72,13 +80,16 @@ class APRSDListenThread(rx.APRSDRXThread):
if self.packet_filter:
filter_class = filters[self.packet_filter]
if isinstance(packet, filter_class):
packet_log.log(packet)
if self.log_packets:
packet_log.log(packet)
if self.plugin_manager:
# Don't do anything with the reply
# This is the listen only command.
self.plugin_manager.run(packet)
else:
packet_log.log(packet)
if self.log_packets:
LOG.error("PISS")
packet_log.log(packet)
if self.plugin_manager:
# Don't do anything with the reply.
# This is the listen only command.
@ -87,6 +98,42 @@ class APRSDListenThread(rx.APRSDRXThread):
packet_collector.PacketCollector().rx(packet)
class ListenStatsThread(APRSDThread):
"""Log the stats from the PacketList."""
def __init__(self):
super().__init__("PacketStatsLog")
self._last_total_rx = 0
def loop(self):
if self.loop_count % 10 == 0:
# log the stats every 10 seconds
stats_json = collector.Collector().collect()
stats = stats_json["PacketList"]
total_rx = stats["rx"]
rx_delta = total_rx - self._last_total_rx
rate = rx_delta / 10
# Log summary stats
LOGU.opt(colors=True).info(
f"<green>RX Rate: {rate} pps</green> "
f"<yellow>Total RX: {total_rx}</yellow> "
f"<red>RX Last 10 secs: {rx_delta}</red>",
)
self._last_total_rx = total_rx
# Log individual type stats
for k, v in stats["types"].items():
thread_hex = f"fg {utils.hex_from_name(k)}"
LOGU.opt(colors=True).info(
f"<{thread_hex}>{k:<15}</{thread_hex}> "
f"<blue>RX: {v['rx']}</blue> <red>TX: {v['tx']}</red>",
)
time.sleep(1)
return True
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.option(
@ -121,6 +168,11 @@ class APRSDListenThread(rx.APRSDRXThread):
),
help="Filter by packet type",
)
@click.option(
"--enable-plugin",
multiple=True,
help="Enable a plugin. This is the name of the file in the plugins directory.",
)
@click.option(
"--load-plugins",
default=False,
@ -132,6 +184,18 @@ class APRSDListenThread(rx.APRSDRXThread):
nargs=-1,
required=True,
)
@click.option(
"--log-packets",
default=False,
is_flag=True,
help="Log incoming packets.",
)
@click.option(
"--enable-packet-stats",
default=False,
is_flag=True,
help="Enable packet stats periodic logging.",
)
@click.pass_context
@cli_helper.process_standard_options
def listen(
@ -139,8 +203,11 @@ def listen(
aprs_login,
aprs_password,
packet_filter,
enable_plugin,
load_plugins,
filter,
log_packets,
enable_packet_stats,
):
"""Listen to packets on the APRS-IS Network based on FILTER.
@ -194,22 +261,32 @@ def listen(
aprs_client.set_filter(filter)
keepalive = keep_alive.KeepAliveThread()
# keepalive.start()
if not CONF.enable_seen_list:
# just deregister the class from the packet collector
packet_collector.PacketCollector().unregister(seen_list.SeenList)
pm = None
pm = plugin.PluginManager()
if load_plugins:
pm = plugin.PluginManager()
LOG.info("Loading plugins")
pm.setup_plugins(load_help_plugin=False)
elif enable_plugin:
pm = plugin.PluginManager()
pm.setup_plugins(
load_help_plugin=False,
plugin_list=enable_plugin,
)
else:
LOG.warning(
"Not Loading any plugins use --load-plugins to load what's "
"defined in the config file.",
)
if pm:
for p in pm.get_plugins():
LOG.info("Loaded plugin %s", p.__class__.__name__)
stats = stats_thread.APRSDStatsStoreThread()
stats.start()
@ -218,9 +295,14 @@ def listen(
packet_queue=threads.packet_queue,
packet_filter=packet_filter,
plugin_manager=pm,
enabled_plugins=enable_plugin,
log_packets=log_packets,
)
LOG.debug("Start APRSDListenThread")
listen_thread.start()
if enable_packet_stats:
listen_stats = ListenStatsThread()
listen_stats.start()
keepalive.start()
LOG.debug("keepalive Join")

View File

@ -12,7 +12,9 @@ from aprsd import cli_helper, packets
from aprsd import conf # noqa : F401
from aprsd.client import client_factory
from aprsd.main import cli
import aprsd.packets # noqa : F401
from aprsd.packets import collector
from aprsd.packets import log as packet_log
from aprsd.threads import tx
@ -94,10 +96,6 @@ def send_message(
else:
LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{command}'")
packets.PacketList()
packets.WatchList()
packets.SeenList()
got_ack = False
got_response = False
@ -106,7 +104,7 @@ def send_message(
cl = client_factory.create()
packet = cl.decode_packet(packet)
collector.PacketCollector().rx(packet)
packet.log("RX")
packet_log.log(packet, tx=False)
# LOG.debug("Got packet back {}".format(packet))
if isinstance(packet, packets.AckPacket):
got_ack = True

View File

@ -8,7 +8,7 @@ from oslo_config import cfg
import aprsd
from aprsd import cli_helper
from aprsd import main as aprsd_main
from aprsd import packets, plugin, threads, utils
from aprsd import plugin, threads, utils
from aprsd.client import client_factory
from aprsd.main import cli
from aprsd.packets import collector as packet_collector
@ -87,29 +87,24 @@ def server(ctx, flush):
LOG.error("APRS client is not properly configured in config file.")
sys.exit(-1)
# Now load the msgTrack from disk if any
packets.PacketList()
if flush:
LOG.debug("Deleting saved MsgTrack.")
packets.PacketTrack().flush()
packets.WatchList().flush()
packets.SeenList().flush()
packets.PacketList().flush()
else:
# Try and load saved MsgTrack list
LOG.debug("Loading saved MsgTrack object.")
packets.PacketTrack().load()
packets.WatchList().load()
packets.SeenList().load()
packets.PacketList().load()
keepalive = keep_alive.KeepAliveThread()
keepalive.start()
if not CONF.enable_seen_list:
# just deregister the class from the packet collector
packet_collector.PacketCollector().unregister(seen_list.SeenList)
# Now load the msgTrack from disk if any
if flush:
LOG.debug("Flushing All packet tracking objects.")
packet_collector.PacketCollector().flush()
else:
# Try and load saved MsgTrack list
LOG.debug("Loading saved packet tracking data.")
packet_collector.PacketCollector().load()
# Now start all the main processing threads.
keepalive = keep_alive.KeepAliveThread()
keepalive.start()
stats_store_thread = stats_thread.APRSDStatsStoreThread()
stats_store_thread.start()

View File

@ -62,9 +62,7 @@ def signal_handler(sig, frame):
threads.APRSDThreadList().stop_all()
if "subprocess" not in str(frame):
time.sleep(1.5)
# packets.WatchList().save()
# packets.SeenList().save()
LOG.info(stats.stats_collector.collect())
stats.stats_collector.collect()
LOG.info("Telling flask to bail.")
signal.signal(signal.SIGTERM, sys.exit(0))
@ -647,11 +645,6 @@ def webchat(ctx, flush, port):
LOG.error("APRS client is not properly configured in config file.")
sys.exit(-1)
packets.PacketList()
packets.PacketTrack()
packets.WatchList()
packets.SeenList()
keepalive = keep_alive.KeepAliveThread()
LOG.info("Start KeepAliveThread")
keepalive.start()

View File

@ -141,6 +141,12 @@ aprsd_opts = [
default=True,
help="Set this to False to disable the help plugin.",
),
cfg.BoolOpt(
"enable_sending_ack_packets",
default=True,
help="Set this to False, to disable sending of ack packets. This will entirely stop"
"APRSD from sending ack packets.",
),
]
watch_list_opts = [
@ -210,7 +216,6 @@ enabled_plugins_opts = [
"aprsd.plugins.fortune.FortunePlugin",
"aprsd.plugins.location.LocationPlugin",
"aprsd.plugins.ping.PingPlugin",
"aprsd.plugins.query.QueryPlugin",
"aprsd.plugins.time.TimePlugin",
"aprsd.plugins.weather.OWMWeatherPlugin",
"aprsd.plugins.version.VersionPlugin",

View File

@ -31,13 +31,6 @@ aprsfi_opts = [
),
]
query_plugin_opts = [
cfg.StrOpt(
"callsign",
help="The Ham callsign to allow access to the query plugin from RF.",
),
]
owm_wx_opts = [
cfg.StrOpt(
"apiKey",
@ -172,7 +165,6 @@ def register_opts(config):
config.register_group(aprsfi_group)
config.register_opts(aprsfi_opts, group=aprsfi_group)
config.register_group(query_group)
config.register_opts(query_plugin_opts, group=query_group)
config.register_group(owm_wx_group)
config.register_opts(owm_wx_opts, group=owm_wx_group)
config.register_group(avwx_group)
@ -184,7 +176,6 @@ def register_opts(config):
def list_opts():
return {
aprsfi_group.name: aprsfi_opts,
query_group.name: query_plugin_opts,
owm_wx_group.name: owm_wx_opts,
avwx_group.name: avwx_opts,
location_group.name: location_opts,

View File

@ -54,7 +54,7 @@ def cli(ctx):
def load_commands():
from .cmds import ( # noqa
completion, dev, fetch_stats, healthcheck, list_plugins, listen,
admin, completion, dev, fetch_stats, healthcheck, list_plugins, listen,
send_message, server, webchat,
)
@ -79,11 +79,15 @@ def signal_handler(sig, frame):
),
)
time.sleep(1.5)
packets.PacketTrack().save()
packets.WatchList().save()
packets.SeenList().save()
packets.PacketList().save()
LOG.info(collector.Collector().collect())
try:
packets.PacketTrack().save()
packets.WatchList().save()
packets.SeenList().save()
packets.PacketList().save()
collector.Collector().collect()
except Exception as e:
LOG.error(f"Failed to save data: {e}")
sys.exit(0)
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)

View File

@ -1,4 +0,0 @@
# What to return from a plugin if we have processed the message
# and it's ok, but don't send a usage string back
# REMOVE THIS FILE

View File

@ -1,3 +1,4 @@
from aprsd.packets import collector
from aprsd.packets.core import ( # noqa: F401
AckPacket, BeaconPacket, BulletinPacket, GPSPacket, MessagePacket,
MicEPacket, ObjectPacket, Packet, RejectPacket, StatusPacket,
@ -9,4 +10,11 @@ from aprsd.packets.tracker import PacketTrack # noqa: F401
from aprsd.packets.watch_list import WatchList # noqa: F401
# Register all the packet tracking objects.
collector.PacketCollector().register(PacketList)
collector.PacketCollector().register(SeenList)
collector.PacketCollector().register(PacketTrack)
collector.PacketCollector().register(WatchList)
NULL_MESSAGE = -1

View File

@ -20,6 +20,14 @@ class PacketMonitor(Protocol):
"""When we send a packet out the network."""
...
def flush(self) -> None:
"""Flush out any data."""
...
def load(self) -> None:
"""Load any data."""
...
@singleton
class PacketCollector:
@ -27,30 +35,45 @@ class PacketCollector:
self.monitors: list[Callable] = []
def register(self, monitor: Callable) -> None:
if not isinstance(monitor, PacketMonitor):
raise TypeError(f"Monitor {monitor} is not a PacketMonitor")
self.monitors.append(monitor)
def unregister(self, monitor: Callable) -> None:
if not isinstance(monitor, PacketMonitor):
raise TypeError(f"Monitor {monitor} is not a PacketMonitor")
self.monitors.remove(monitor)
def rx(self, packet: type[core.Packet]) -> None:
for name in self.monitors:
cls = name()
if isinstance(cls, PacketMonitor):
try:
cls.rx(packet)
except Exception as e:
LOG.error(f"Error in monitor {name} (rx): {e}")
else:
raise TypeError(f"Monitor {name} is not a PacketMonitor")
try:
cls.rx(packet)
except Exception as e:
LOG.error(f"Error in monitor {name} (rx): {e}")
def tx(self, packet: type[core.Packet]) -> None:
for name in self.monitors:
cls = name()
if isinstance(cls, PacketMonitor):
try:
cls.tx(packet)
except Exception as e:
LOG.error(f"Error in monitor {name} (tx): {e}")
else:
raise TypeError(f"Monitor {name} is not a PacketMonitor")
try:
cls.tx(packet)
except Exception as e:
LOG.error(f"Error in monitor {name} (tx): {e}")
def flush(self):
"""Call flush on the objects. This is used to flush out any data."""
for name in self.monitors:
cls = name()
try:
cls.flush()
except Exception as e:
LOG.error(f"Error in monitor {name} (flush): {e}")
def load(self):
"""Call load on the objects. This is used to load any data."""
for name in self.monitors:
cls = name()
try:
cls.load()
except Exception as e:
LOG.error(f"Error in monitor {name} (load): {e}")

View File

@ -63,15 +63,11 @@ def _init_msgNo(): # noqa: N802
def _translate_fields(raw: dict) -> dict:
translate_fields = {
"from": "from_call",
"to": "to_call",
}
# First translate some fields
for key in translate_fields:
if key in raw:
raw[translate_fields[key]] = raw[key]
del raw[key]
# 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")
# addresse overrides to_call
if "addresse" in raw:
@ -110,11 +106,7 @@ class Packet:
via: Optional[str] = field(default=None, compare=False, hash=False)
def get(self, key: str, default: Optional[str] = None):
"""Emulate a getter on a dict."""
if hasattr(self, key):
return getattr(self, key)
else:
return default
return getattr(self, key, default)
@property
def key(self) -> str:

View File

@ -1,10 +1,12 @@
import logging
from typing import Optional
from geopy.distance import geodesic
from loguru import logger
from oslo_config import cfg
from aprsd.packets.core import AckPacket, RejectPacket
from aprsd import utils
from aprsd.packets.core import AckPacket, GPSPacket, RejectPacket
LOG = logging.getLogger()
@ -16,6 +18,8 @@ TO_COLOR = "fg #D033FF"
TX_COLOR = "red"
RX_COLOR = "green"
PACKET_COLOR = "cyan"
DISTANCE_COLOR = "fg #FF5733"
DEGREES_COLOR = "fg #FFA900"
def log_multiline(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> None:
@ -97,19 +101,19 @@ def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> No
if header:
if tx:
via_color = "red"
arrow = f"<{via_color}>-></{via_color}>"
arrow = f"<{via_color}>\u2192</{via_color}>"
logit.append(
f"<red>TX {arrow}</red> "
f"<red>TX\u2191</red> "
f"<cyan>{name}</cyan>"
f":{packet.msgNo}"
f" ({packet.send_count + 1} of {pkt_max_send_count})",
)
else:
via_color = "fg #828282"
arrow = f"<{via_color}>-></{via_color}>"
left_arrow = f"<{via_color}><-</{via_color}>"
via_color = "fg #1AA730"
arrow = f"<{via_color}>\u2192</{via_color}>"
f"<{via_color}><-</{via_color}>"
logit.append(
f"<fg #1AA730>RX</fg #1AA730> {left_arrow} "
f"<fg #1AA730>RX\u2193</fg #1AA730> "
f"<cyan>{name}</cyan>"
f":{packet.msgNo}",
)
@ -139,5 +143,19 @@ def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> No
msg = msg.replace("<", "\\<")
logit.append(f"<light-yellow><b>{msg}</b></light-yellow>")
# is there distance information?
if isinstance(packet, GPSPacket) and CONF.latitude and CONF.longitude:
my_coords = (CONF.latitude, CONF.longitude)
packet_coords = (packet.latitude, packet.longitude)
try:
bearing = utils.calculate_initial_compass_bearing(my_coords, packet_coords)
except Exception as e:
LOG.error(f"Failed to calculate bearing: {e}")
bearing = 0
logit.append(
f" : <{DEGREES_COLOR}>{utils.degrees_to_cardinal(bearing, full_string=True)}</{DEGREES_COLOR}>"
f"<{DISTANCE_COLOR}>@{geodesic(my_coords, packet_coords).miles:.2f}miles</{DISTANCE_COLOR}>",
)
LOGU.opt(colors=True).info(" ".join(logit))
log_multiline(packet, tx, header)

View File

@ -3,7 +3,7 @@ import logging
from oslo_config import cfg
from aprsd.packets import collector, core
from aprsd.packets import core
from aprsd.utils import objectstore
@ -37,9 +37,10 @@ class PacketList(objectstore.ObjectStoreMixin):
self._total_rx += 1
self._add(packet)
ptype = packet.__class__.__name__
if ptype not in self.data["types"]:
self.data["types"][ptype] = {"tx": 0, "rx": 0}
self.data["types"][ptype]["rx"] += 1
type_stats = self.data["types"].setdefault(
ptype, {"tx": 0, "rx": 0},
)
type_stats["rx"] += 1
def tx(self, packet: type[core.Packet]):
"""Add a packet that was received."""
@ -47,9 +48,10 @@ class PacketList(objectstore.ObjectStoreMixin):
self._total_tx += 1
self._add(packet)
ptype = packet.__class__.__name__
if ptype not in self.data["types"]:
self.data["types"][ptype] = {"tx": 0, "rx": 0}
self.data["types"][ptype]["tx"] += 1
type_stats = self.data["types"].setdefault(
ptype, {"tx": 0, "rx": 0},
)
type_stats["tx"] += 1
def add(self, packet):
with self.lock:
@ -81,36 +83,18 @@ class PacketList(objectstore.ObjectStoreMixin):
return self._total_tx
def stats(self, serializable=False) -> dict:
# limit the number of packets to return to 50
with self.lock:
tmp = OrderedDict(
reversed(
list(
self.data.get("packets", OrderedDict()).items(),
),
),
)
pkts = []
count = 1
for packet in tmp:
pkts.append(tmp[packet])
count += 1
if count > CONF.packet_list_stats_maxlen:
break
# Get last N packets directly using list slicing
packets_list = list(self.data.get("packets", {}).values())
pkts = packets_list[-CONF.packet_list_stats_maxlen:][::-1]
stats = {
"total_tracked": self._total_rx + self._total_rx,
"total_tracked": self._total_rx + self._total_tx, # Fixed typo: was rx + rx
"rx": self._total_rx,
"tx": self._total_tx,
"types": self.data.get("types", []),
"types": self.data.get("types", {}), # Changed default from [] to {}
"packet_count": len(self.data.get("packets", [])),
"maxlen": self.maxlen,
"packets": pkts,
}
return stats
# Now register the PacketList with the collector
# every packet we RX and TX goes through the collector
# for processing for whatever reason is needed.
collector.PacketCollector().register(PacketList)

View File

@ -3,7 +3,7 @@ import logging
from oslo_config import cfg
from aprsd.packets import collector, core
from aprsd.packets import core
from aprsd.utils import objectstore
@ -47,8 +47,3 @@ class SeenList(objectstore.ObjectStoreMixin):
def tx(self, packet: type[core.Packet]):
"""We don't care about TX packets."""
# Register with the packet collector so we can process the packet
# when we get it off the client (network)
collector.PacketCollector().register(SeenList)

View File

@ -3,7 +3,7 @@ import logging
from oslo_config import cfg
from aprsd.packets import collector, core
from aprsd.packets import core
from aprsd.utils import objectstore
@ -101,9 +101,3 @@ class PacketTrack(objectstore.ObjectStoreMixin):
del self.data[key]
except KeyError:
pass
# Now register the PacketList with the collector
# every packet we RX and TX goes through the collector
# for processing for whatever reason is needed.
collector.PacketCollector().register(PacketTrack)

View File

@ -4,7 +4,7 @@ import logging
from oslo_config import cfg
from aprsd import utils
from aprsd.packets import collector, core
from aprsd.packets import core
from aprsd.utils import objectstore
@ -117,6 +117,3 @@ class WatchList(objectstore.ObjectStoreMixin):
return False
else:
return False
collector.PacketCollector().register(WatchList)

View File

@ -25,7 +25,6 @@ CORE_MESSAGE_PLUGINS = [
"aprsd.plugins.fortune.FortunePlugin",
"aprsd.plugins.location.LocationPlugin",
"aprsd.plugins.ping.PingPlugin",
"aprsd.plugins.query.QueryPlugin",
"aprsd.plugins.time.TimePlugin",
"aprsd.plugins.weather.USWeatherPlugin",
"aprsd.plugins.version.VersionPlugin",
@ -473,22 +472,25 @@ class PluginManager:
del self._pluggy_pm
self.setup_plugins()
def setup_plugins(self, load_help_plugin=None):
def setup_plugins(
self, load_help_plugin=True,
plugin_list=[],
):
"""Create the plugin manager and register plugins."""
# If load_help_plugin is not specified, load it from the config
if load_help_plugin is None:
load_help_plugin = CONF.load_help_plugin
LOG.info("Loading APRSD Plugins")
# Help plugin is always enabled.
if load_help_plugin:
_help = HelpPlugin()
self._pluggy_pm.register(_help)
enabled_plugins = CONF.enabled_plugins
if enabled_plugins:
for p_name in enabled_plugins:
# if plugins_list is passed in, only load
# those plugins.
if plugin_list:
for plugin_name in plugin_list:
self._load_plugin(plugin_name)
elif CONF.enabled_plugins:
for p_name in CONF.enabled_plugins:
self._load_plugin(p_name)
else:
# Enabled plugins isn't set, so we default to loading all of

View File

@ -12,6 +12,7 @@ import imapclient
from oslo_config import cfg
from aprsd import packets, plugin, threads, utils
from aprsd.stats import collector
from aprsd.threads import tx
from aprsd.utils import trace
@ -126,6 +127,11 @@ class EmailPlugin(plugin.APRSDRegexCommandPluginBase):
shortcuts = _build_shortcuts_dict()
LOG.info(f"Email shortcuts {shortcuts}")
# Register the EmailStats producer with the stats collector
# We do this here to prevent EmailStats from being registered
# when email is not enabled in the config file.
collector.Collector().register_producer(EmailStats)
else:
LOG.info("Email services not enabled.")
self.enabled = False

View File

@ -8,7 +8,7 @@ from aprsd.utils import trace
LOG = logging.getLogger("APRSD")
DEFAULT_FORTUNE_PATH = '/usr/games/fortune'
DEFAULT_FORTUNE_PATH = "/usr/games/fortune"
class FortunePlugin(plugin.APRSDRegexCommandPluginBase):
@ -45,7 +45,7 @@ class FortunePlugin(plugin.APRSDRegexCommandPluginBase):
command,
shell=True,
timeout=3,
universal_newlines=True,
text=True,
)
output = (
output.replace("\r", "")

View File

@ -2,8 +2,10 @@ import logging
import re
import time
from geopy.geocoders import ArcGIS, AzureMaps, Baidu, Bing, GoogleV3
from geopy.geocoders import HereV7, Nominatim, OpenCage, TomTom, What3WordsV3, Woosmap
from geopy.geocoders import (
ArcGIS, AzureMaps, Baidu, Bing, GoogleV3, HereV7, Nominatim, OpenCage,
TomTom, What3WordsV3, Woosmap,
)
from oslo_config import cfg
from aprsd import packets, plugin, plugin_utils
@ -39,8 +41,8 @@ class USGov:
result = plugin_utils.get_weather_gov_for_gps(lat, lon)
# LOG.info(f"WEATHER: {result}")
# LOG.info(f"area description {result['location']['areaDescription']}")
if 'location' in result:
loc = UsLocation(result['location']['areaDescription'])
if "location" in result:
loc = UsLocation(result["location"]["areaDescription"])
else:
loc = UsLocation("Unknown Location")

View File

@ -1,7 +1,6 @@
from aprsd import plugin
from aprsd.client import stats as client_stats
from aprsd.packets import packet_list, seen_list, tracker, watch_list
from aprsd.plugins import email
from aprsd.stats import app, collector
from aprsd.threads import aprsd
@ -15,6 +14,5 @@ stats_collector.register_producer(watch_list.WatchList)
stats_collector.register_producer(tracker.PacketTrack)
stats_collector.register_producer(plugin.PluginManager)
stats_collector.register_producer(aprsd.APRSDThreadList)
stats_collector.register_producer(email.EmailStats)
stats_collector.register_producer(client_stats.APRSClientStats)
stats_collector.register_producer(seen_list.SeenList)

View File

@ -10,7 +10,7 @@ LOG = logging.getLogger("APRSD")
@runtime_checkable
class StatsProducer(Protocol):
"""The StatsProducer protocol is used to define the interface for collecting stats."""
def stats(self, serializeable=False) -> dict:
def stats(self, serializable=False) -> dict:
"""provide stats in a dictionary format."""
...
@ -25,14 +25,18 @@ class Collector:
stats = {}
for name in self.producers:
cls = name()
if isinstance(cls, StatsProducer):
try:
stats[cls.__class__.__name__] = cls.stats(serializable=serializable).copy()
except Exception as e:
LOG.error(f"Error in producer {name} (stats): {e}")
else:
raise TypeError(f"{cls} is not an instance of StatsProducer")
try:
stats[cls.__class__.__name__] = cls.stats(serializable=serializable).copy()
except Exception as e:
LOG.error(f"Error in producer {name} (stats): {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")
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")
self.producers.remove(producer_name)

View File

@ -3,6 +3,7 @@ import logging
import time
import tracemalloc
from loguru import logger
from oslo_config import cfg
from aprsd import packets, utils
@ -14,6 +15,7 @@ from aprsd.threads import APRSDThread, APRSDThreadList
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
LOGU = logger
class KeepAliveThread(APRSDThread):
@ -87,7 +89,12 @@ class KeepAliveThread(APRSDThread):
key = thread["name"]
if not alive:
LOG.error(f"Thread {thread}")
LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
thread_hex = f"fg {utils.hex_from_name(key)}"
t_name = f"<{thread_hex}>{key:<15}</{thread_hex}>"
thread_msg = f"{t_name} Alive? {str(alive): <5} {str(age): <20}"
LOGU.opt(colors=True).info(thread_msg)
# LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
# check the APRS connection
cl = client_factory.create()

View File

@ -151,6 +151,11 @@ class APRSDProcessPacketThread(APRSDThread):
def __init__(self, packet_queue):
self.packet_queue = packet_queue
super().__init__("ProcessPKT")
if not CONF.enable_sending_ack_packets:
LOG.warning(
"Sending ack packets is disabled, messages "
"will not be acknowledged.",
)
def process_ack_packet(self, packet):
"""We got an ack for a message, no need to resend it."""

View File

@ -53,7 +53,10 @@ def send(packet: core.Packet, direct=False, aprs_client=None):
# After prepare, as prepare assigns the msgNo
collector.PacketCollector().tx(packet)
if isinstance(packet, core.AckPacket):
_send_ack(packet, direct=direct, aprs_client=aprs_client)
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.")
else:
_send_packet(packet, direct=direct, aprs_client=aprs_client)
@ -89,6 +92,9 @@ def _send_direct(packet, aprs_client=None):
except Exception as e:
LOG.error(f"Failed to send packet: {packet}")
LOG.error(e)
return False
else:
return True
class SendPacketThread(aprsd_threads.APRSDThread):
@ -150,8 +156,17 @@ class SendPacketThread(aprsd_threads.APRSDThread):
# no attempt time, so lets send it, and start
# tracking the time.
packet.last_send_time = int(round(time.time()))
_send_direct(packet)
packet.send_count += 1
sent = False
try:
sent = _send_direct(packet)
except Exception:
LOG.error(f"Failed to send packet: {packet}")
else:
# If an exception happens while sending
# we don't want this attempt to count
# against the packet
if sent:
packet.send_count += 1
time.sleep(1)
# Make sure we get called again.
@ -199,8 +214,18 @@ class SendAckThread(aprsd_threads.APRSDThread):
send_now = True
if send_now:
_send_direct(self.packet)
self.packet.send_count += 1
sent = False
try:
sent = _send_direct(self.packet)
except Exception:
LOG.error(f"Failed to send packet: {self.packet}")
else:
# If an exception happens while sending
# we don't want this attempt to count
# against the packet
if sent:
self.packet.send_count += 1
self.packet.last_send_time = int(round(time.time()))
time.sleep(1)

View File

@ -2,6 +2,7 @@
import errno
import functools
import math
import os
import re
import sys
@ -82,6 +83,16 @@ def rgb_from_name(name):
return red, green, blue
def hextriplet(colortuple):
"""Convert a color tuple to a hex triplet."""
return "#" + "".join(f"{i:02X}" for i in colortuple)
def hex_from_name(name):
"""Create a hex color from a string."""
return hextriplet(rgb_from_name(name))
def human_size(bytes, units=None):
"""Returns a human readable string representation of bytes"""
if not units:
@ -161,3 +172,47 @@ def load_entry_points(group):
except Exception as e:
print(f"Extension {ep.name} of group {group} failed to load with {e}", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
def calculate_initial_compass_bearing(start, end):
if (type(start) != tuple) or (type(end) != tuple): # noqa: E721
raise TypeError("Only tuples are supported as arguments")
lat1 = math.radians(float(start[0]))
lat2 = math.radians(float(end[0]))
diff_long = math.radians(float(end[1]) - float(start[1]))
x = math.sin(diff_long) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - (
math.sin(lat1)
* math.cos(lat2) * math.cos(diff_long)
)
initial_bearing = math.atan2(x, y)
# Now we have the initial bearing but math.atan2 return values
# from -180° to + 180° which is not what we want for a compass bearing
# The solution is to normalize the initial bearing as shown below
initial_bearing = math.degrees(initial_bearing)
compass_bearing = (initial_bearing + 360) % 360
return compass_bearing
def degrees_to_cardinal(bearing, full_string=False):
if full_string:
directions = [
"North", "North-Northeast", "Northeast", "East-Northeast", "East", "East-Southeast",
"Southeast", "South-Southeast", "South", "South-Southwest", "Southwest", "West-Southwest",
"West", "West-Northwest", "Northwest", "North-Northwest", "North",
]
else:
directions = [
"N", "NNE", "NE", "ENE", "E", "ESE",
"SE", "SSE", "S", "SSW", "SW", "WSW",
"W", "WNW", "NW", "NNW", "N",
]
cardinal = directions[round(bearing / 22.5)]
return cardinal

View File

@ -3,6 +3,7 @@ import importlib.metadata as imp
import io
import json
import logging
import os
import queue
import flask
@ -23,6 +24,12 @@ CONF = cfg.CONF
LOG = logging.getLogger("gunicorn.access")
logging_queue = queue.Queue()
# ADMIN_COMMAND True means we are running from `aprsd admin`
# the `aprsd admin` command will import this file after setting
# the APRSD_ADMIN_COMMAND environment variable.
ADMIN_COMMAND = os.environ.get("APRSD_ADMIN_COMMAND", False)
auth = HTTPBasicAuth()
users: dict[str, str] = {}
app = Flask(
@ -297,7 +304,7 @@ if __name__ == "uwsgi_file_aprsd_wsgi":
CONF.log_opt_values(LOG, logging.DEBUG)
if __name__ == "aprsd.wsgi":
if __name__ == "aprsd.wsgi" and not ADMIN_COMMAND:
# set async_mode to 'threading', 'eventlet', 'gevent' or 'gevent_uwsgi' to
# force a mode else, the best mode is selected automatically from what's
# installed

View File

@ -1,4 +1,4 @@
FROM python:3.11-slim as build
FROM python:3.11-slim AS build
ARG VERSION=3.4.0
# pass this in as 'dev' if you want to install from github repo vs pypi
@ -40,7 +40,7 @@ RUN set -ex \
### Final stage
FROM build as final
FROM build AS install
WORKDIR /app
RUN pip3 install -U pip
@ -64,6 +64,8 @@ RUN aprsd --version
ADD bin/setup.sh /app
ADD bin/admin.sh /app
FROM install AS final
# For the web admin interface
EXPOSE 8001

View File

@ -8,23 +8,23 @@ add-trailing-comma==3.1.0 # via gray
alabaster==1.0.0 # via sphinx
autoflake==1.5.3 # via gray
babel==2.16.0 # via sphinx
black==24.8.0 # via gray
build==1.2.2 # via -r requirements-dev.in, check-manifest, pip-tools
black==24.10.0 # via gray
build==1.2.2.post1 # via -r requirements-dev.in, check-manifest, pip-tools
cachetools==5.5.0 # via tox
certifi==2024.8.30 # via requests
cfgv==3.4.0 # via pre-commit
chardet==5.2.0 # via tox
charset-normalizer==3.3.2 # via requests
check-manifest==0.49 # via -r requirements-dev.in
charset-normalizer==3.4.0 # via requests
check-manifest==0.50 # via -r requirements-dev.in
click==8.1.7 # via black, fixit, moreorless, pip-tools
colorama==0.4.6 # via tox
commonmark==0.9.1 # via rich
configargparse==1.7 # via gray
coverage[toml]==7.6.1 # via pytest-cov
distlib==0.3.8 # via virtualenv
coverage[toml]==7.6.3 # via pytest-cov
distlib==0.3.9 # via virtualenv
docutils==0.21.2 # via m2r, sphinx
exceptiongroup==1.2.2 # via pytest
filelock==3.16.0 # via tox, virtualenv
filelock==3.16.1 # via tox, virtualenv
fixit==2.1.0 # via gray
flake8==7.1.1 # via -r requirements-dev.in, pep8-naming
gray==0.15.0 # via -r requirements-dev.in
@ -34,35 +34,35 @@ imagesize==1.4.1 # via sphinx
iniconfig==2.0.0 # via pytest
isort==5.13.2 # via -r requirements-dev.in, gray
jinja2==3.1.4 # via sphinx
libcst==1.4.0 # via fixit
libcst==1.5.0 # via fixit
m2r==0.3.1 # via -r requirements-dev.in
markupsafe==2.1.5 # via jinja2
markupsafe==3.0.2 # via jinja2
mccabe==0.7.0 # via flake8
mistune==0.8.4 # via m2r
moreorless==0.4.0 # via fixit
mypy==1.11.2 # via -r requirements-dev.in
mypy==1.12.0 # via -r requirements-dev.in
mypy-extensions==1.0.0 # via black, mypy
nodeenv==1.9.1 # via pre-commit
packaging==24.1 # via black, build, fixit, pyproject-api, pytest, sphinx, tox
pathspec==0.12.1 # via black, trailrunner
pep8-naming==0.14.1 # via -r requirements-dev.in
pip-tools==7.4.1 # via -r requirements-dev.in
platformdirs==4.3.3 # via black, tox, virtualenv
platformdirs==4.3.6 # via black, tox, virtualenv
pluggy==1.5.0 # via pytest, tox
pre-commit==3.8.0 # via -r requirements-dev.in
pre-commit==4.0.1 # via -r requirements-dev.in
pycodestyle==2.12.1 # via flake8
pyflakes==3.2.0 # via autoflake, flake8
pygments==2.18.0 # via rich, sphinx
pyproject-api==1.7.1 # via tox
pyproject-hooks==1.1.0 # via build, pip-tools
pyproject-api==1.8.0 # via tox
pyproject-hooks==1.2.0 # via build, pip-tools
pytest==8.3.3 # via -r requirements-dev.in, pytest-cov
pytest-cov==5.0.0 # via -r requirements-dev.in
pyupgrade==3.17.0 # via gray
pyupgrade==3.18.0 # via gray
pyyaml==6.0.2 # via libcst, pre-commit
requests==2.32.3 # via sphinx
rich==12.6.0 # via gray
snowballstemmer==2.2.0 # via sphinx
sphinx==8.0.2 # via -r requirements-dev.in
sphinx==8.1.3 # via -r requirements-dev.in
sphinxcontrib-applehelp==2.0.0 # via sphinx
sphinxcontrib-devhelp==2.0.0 # via sphinx
sphinxcontrib-htmlhelp==2.1.0 # via sphinx
@ -71,14 +71,14 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx
sphinxcontrib-serializinghtml==2.0.0 # via sphinx
tokenize-rt==6.0.0 # via add-trailing-comma, pyupgrade
toml==0.10.2 # via autoflake
tomli==2.0.1 # via black, build, check-manifest, coverage, fixit, mypy, pip-tools, pyproject-api, pytest, sphinx, tox
tox==4.18.1 # via -r requirements-dev.in
tomli==2.0.2 # via black, build, check-manifest, coverage, fixit, mypy, pip-tools, pyproject-api, pytest, sphinx, tox
tox==4.23.0 # via -r requirements-dev.in
trailrunner==1.4.0 # via fixit
typing-extensions==4.12.2 # via black, mypy
typing-extensions==4.12.2 # via black, mypy, tox
unify==0.5 # via gray
untokenize==0.1.1 # via unify
urllib3==2.2.3 # via requests
virtualenv==20.26.4 # via pre-commit, tox
virtualenv==20.27.0 # via pre-commit, tox
wheel==0.44.0 # via -r requirements-dev.in, pip-tools
# The following packages are considered to be unsafe in a requirements file:

View File

@ -5,12 +5,10 @@ click
click-params
dataclasses
dataclasses-json
eventlet
flask
flask-httpauth
flask-socketio
geopy
gevent
imapclient
kiss3
loguru
@ -18,7 +16,6 @@ oslo.config
pluggy
python-socketio
pyyaml
pytz
requests
# Pinned due to gray needing 12.6.0
rich~=12.6.0
@ -30,3 +27,4 @@ thesmuggler
tzlocal
update_checker
wrapt
pytz

View File

@ -4,78 +4,162 @@
#
# pip-compile --annotation-style=line requirements.in
#
aprslib==0.7.2 # via -r requirements.in
attrs==24.2.0 # via ax253, kiss3, rush
ax253==0.1.5.post1 # via kiss3
beautifulsoup4==4.12.3 # via -r requirements.in
bidict==0.23.1 # via python-socketio
bitarray==2.9.2 # via ax253, kiss3
blinker==1.8.2 # via flask
certifi==2024.8.30 # via requests
charset-normalizer==3.3.2 # via requests
click==8.1.7 # via -r requirements.in, click-params, flask
click-params==0.5.0 # via -r requirements.in
commonmark==0.9.1 # via rich
dataclasses==0.6 # via -r requirements.in
dataclasses-json==0.6.7 # via -r requirements.in
debtcollector==3.0.0 # via oslo-config
deprecated==1.2.14 # via click-params
dnspython==2.6.1 # via eventlet
eventlet==0.37.0 # via -r requirements.in
flask==3.0.3 # via -r requirements.in, flask-httpauth, flask-socketio
flask-httpauth==4.8.0 # via -r requirements.in
flask-socketio==5.3.7 # via -r requirements.in
geographiclib==2.0 # via geopy
geopy==2.4.1 # via -r requirements.in
gevent==24.2.1 # via -r requirements.in
greenlet==3.1.0 # via eventlet, gevent
h11==0.14.0 # via wsproto
idna==3.10 # via requests
imapclient==3.0.1 # via -r requirements.in
importlib-metadata==8.5.0 # via ax253, kiss3
itsdangerous==2.2.0 # via flask
jinja2==3.1.4 # via flask
kiss3==8.0.0 # via -r requirements.in
loguru==0.7.2 # via -r requirements.in
markupsafe==2.1.5 # via jinja2, werkzeug
marshmallow==3.22.0 # via dataclasses-json
mypy-extensions==1.0.0 # via typing-inspect
netaddr==1.3.0 # via oslo-config
oslo-config==9.6.0 # via -r requirements.in
oslo-i18n==6.4.0 # via oslo-config
packaging==24.1 # via marshmallow
pbr==6.1.0 # via oslo-i18n, stevedore
pluggy==1.5.0 # via -r requirements.in
pygments==2.18.0 # via rich
pyserial==3.5 # via pyserial-asyncio
pyserial-asyncio==0.6 # via kiss3
python-engineio==4.9.1 # via python-socketio
python-socketio==5.11.4 # via -r requirements.in, flask-socketio
pytz==2024.2 # via -r requirements.in
pyyaml==6.0.2 # via -r requirements.in, oslo-config
requests==2.32.3 # via -r requirements.in, oslo-config, update-checker
rfc3986==2.0.0 # via oslo-config
rich==12.6.0 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
shellingham==1.5.4 # via -r requirements.in
simple-websocket==1.0.0 # via python-engineio
six==1.16.0 # via -r requirements.in
soupsieve==2.6 # via beautifulsoup4
stevedore==5.3.0 # via oslo-config
tabulate==0.9.0 # via -r requirements.in
thesmuggler==1.0.1 # via -r requirements.in
typing-extensions==4.12.2 # via typing-inspect
typing-inspect==0.9.0 # via dataclasses-json
tzlocal==5.2 # via -r requirements.in
update-checker==0.18.0 # via -r requirements.in
urllib3==2.2.3 # via requests
validators==0.22.0 # via click-params
werkzeug==3.0.4 # via flask
wrapt==1.16.0 # via -r requirements.in, debtcollector, deprecated
wsproto==1.2.0 # via simple-websocket
zipp==3.20.2 # via importlib-metadata
zope-event==5.0 # via gevent
zope-interface==7.0.3 # via gevent
# The following packages are considered to be unsafe in a requirements file:
# setuptools
aprslib==0.7.2
# via -r requirements.in
attrs==24.2.0
# via
# ax253
# kiss3
# rush
ax253==0.1.5.post1
# via kiss3
beautifulsoup4==4.12.3
# via -r requirements.in
bidict==0.23.1
# via python-socketio
bitarray==3.0.0
# via
# ax253
# kiss3
blinker==1.8.2
# via flask
certifi==2024.8.30
# via requests
charset-normalizer==3.4.0
# via requests
click==8.1.7
# via
# -r requirements.in
# click-params
# flask
click-params==0.5.0
# via -r requirements.in
commonmark==0.9.1
# via rich
dataclasses==0.6
# via -r requirements.in
dataclasses-json==0.6.7
# via -r requirements.in
debtcollector==3.0.0
# via oslo-config
deprecated==1.2.14
# via click-params
flask==3.0.3
# via
# -r requirements.in
# flask-httpauth
# flask-socketio
flask-httpauth==4.8.0
# via -r requirements.in
flask-socketio==5.4.1
# via -r requirements.in
geographiclib==2.0
# via geopy
geopy==2.4.1
# via -r requirements.in
h11==0.14.0
# via wsproto
idna==3.10
# via requests
imapclient==3.0.1
# via -r requirements.in
importlib-metadata==8.5.0
# via
# ax253
# kiss3
itsdangerous==2.2.0
# via flask
jinja2==3.1.4
# via flask
kiss3==8.0.0
# via -r requirements.in
loguru==0.7.2
# via -r requirements.in
markupsafe==3.0.2
# via
# jinja2
# werkzeug
marshmallow==3.23.0
# via dataclasses-json
mypy-extensions==1.0.0
# via typing-inspect
netaddr==1.3.0
# via oslo-config
oslo-config==9.6.0
# via -r requirements.in
oslo-i18n==6.4.0
# via oslo-config
packaging==24.1
# via marshmallow
pbr==6.1.0
# via
# oslo-i18n
# stevedore
pluggy==1.5.0
# via -r requirements.in
pygments==2.18.0
# via rich
pyserial==3.5
# via pyserial-asyncio
pyserial-asyncio==0.6
# via kiss3
python-engineio==4.10.1
# via python-socketio
python-socketio==5.11.4
# via
# -r requirements.in
# flask-socketio
pytz==2024.2
# via -r requirements.in
pyyaml==6.0.2
# via
# -r requirements.in
# oslo-config
requests==2.32.3
# via
# -r requirements.in
# oslo-config
# update-checker
rfc3986==2.0.0
# via oslo-config
rich==12.6.0
# via -r requirements.in
rush==2021.4.0
# via -r requirements.in
shellingham==1.5.4
# via -r requirements.in
simple-websocket==1.1.0
# via python-engineio
six==1.16.0
# via -r requirements.in
soupsieve==2.6
# via beautifulsoup4
stevedore==5.3.0
# via oslo-config
tabulate==0.9.0
# via -r requirements.in
thesmuggler==1.0.1
# via -r requirements.in
typing-extensions==4.12.2
# via typing-inspect
typing-inspect==0.9.0
# via dataclasses-json
tzlocal==5.2
# via -r requirements.in
update-checker==0.18.0
# via -r requirements.in
urllib3==2.2.3
# via requests
validators==0.22.0
# via click-params
werkzeug==3.0.6
# via flask
wrapt==1.16.0
# via
# -r requirements.in
# debtcollector
# deprecated
wsproto==1.2.0
# via simple-websocket
zipp==3.20.2
# via importlib-metadata

View File

@ -0,0 +1,81 @@
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()
self.assertEqual(
{
"server_string": mock_client.server_string,
"sever_keepalive": mock_client.aprsd_keepalive,
"filter": "m/50",
}, 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()

View File

@ -0,0 +1,140 @@
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()

View File

@ -0,0 +1,75 @@
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())

View File

@ -1,15 +1,9 @@
import sys
import unittest
from unittest import mock
from aprsd.plugins import email
if sys.version_info >= (3, 2):
from unittest import mock
else:
from unittest import mock
class TestMain(unittest.TestCase):
@mock.patch("aprsd.plugins.email._imap_connect")
@mock.patch("aprsd.plugins.email._smtp_connect")