diff --git a/.github/workflows/docker-multiarch-tag.yml b/.github/workflows/docker-multiarch-tag.yml new file mode 100644 index 0000000..8cf5ffa --- /dev/null +++ b/.github/workflows/docker-multiarch-tag.yml @@ -0,0 +1,57 @@ +name: Build Multi-Arch Docker Image on Tag + +on: + push: + tags: + - "v*.*.*" + - "*.*.*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set version from tag + id: version + run: | + TAG="${{ github.ref_name }}" + # Strip leading 'v' if present (e.g. v3.4.0 -> 3.4.0) + VERSION="${TAG#v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: hemna6969/aprsd + tags: | + type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref_type == 'tag' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./docker + file: ./docker/Dockerfile + platforms: linux/amd64,linux/arm64 + build-args: | + INSTALL_TYPE=pypi + VERSION=${{ steps.version.outputs.version }} + BUILDX_QEMU_ENV=true + push: true + provenance: false + tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/master-build.yml b/.github/workflows/master-build.yml index bd2c134..3ce4b3e 100644 --- a/.github/workflows/master-build.yml +++ b/.github/workflows/master-build.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f874f72..63521cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,17 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + - id: check-json - id: detect-private-key - id: check-merge-conflict - id: check-case-conflict - - id: check-docstring-first - id: check-builtin-literals - id: check-illegal-windows-names + - id: double-quote-string-fixer - repo: https://github.com/asottile/setup-cfg-fmt rev: v2.7.0 @@ -18,18 +19,19 @@ repos: - id: setup-cfg-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.0 + rev: v0.14.10 hooks: - - id: ruff ###### Relevant part below ###### - - id: ruff + - id: ruff-check + types_or: [python, pyi] args: ["check", "--select", "I", "--fix"] ###### Relevant part above ###### - id: ruff-format + types_or: [python, pyi] - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.5.16 + rev: 0.9.22 hooks: # Compile requirements - id: pip-compile diff --git a/AUTHORS b/AUTHORS index e59aba4..fcfb6d3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1 +1 @@ -waboring@hemna.com : 1 \ No newline at end of file +waboring@hemna.com : 1 diff --git a/aprsd/__init__.py b/aprsd/__init__.py index b265032..7d8353c 100644 --- a/aprsd/__init__.py +++ b/aprsd/__init__.py @@ -12,8 +12,7 @@ from importlib.metadata import PackageNotFoundError, version - try: - __version__ = version("aprsd") + __version__ = version('aprsd') except PackageNotFoundError: pass diff --git a/aprsd/client/drivers/aprsis.py b/aprsd/client/drivers/aprsis.py index 8af3d5d..a62f072 100644 --- a/aprsd/client/drivers/aprsis.py +++ b/aprsd/client/drivers/aprsis.py @@ -3,6 +3,7 @@ import logging import time from typing import Callable +import aprslib from aprslib.exceptions import LoginError from loguru import logger from oslo_config import cfg @@ -49,11 +50,12 @@ class APRSISDriver: @staticmethod def is_configured(): if APRSISDriver.is_enabled(): - # Ensure that the config vars are correctly set - if not CONF.aprs_network.login: - LOG.error('Config aprs_network.login not set.') + # Ensure that the config vars are correctly set. + # The callsign in [DEFAULT] is used as the APRS-IS login. + if not CONF.callsign or CONF.callsign == 'NOCALL': + LOG.error('Config callsign (in [DEFAULT]) not set or is NOCALL.') raise exception.MissingConfigOptionException( - 'aprs_network.login is not set.', + 'callsign (in [DEFAULT]) is not set or is NOCALL.', ) if not CONF.aprs_network.password: LOG.error('Config aprs_network.password not set.') @@ -88,7 +90,7 @@ class APRSISDriver: def setup_connection(self): if self.connected: return - user = CONF.aprs_network.login + user = CONF.callsign password = CONF.aprs_network.password host = CONF.aprs_network.host port = CONF.aprs_network.port @@ -133,6 +135,7 @@ class APRSISDriver: continue def set_filter(self, filter): + LOG.info(f'Setting filter to {filter}') self._client.set_filter(filter) def login_success(self) -> bool: @@ -166,7 +169,13 @@ class APRSISDriver: def decode_packet(self, *args, **kwargs): """APRS lib already decodes this.""" - return core.factory(args[0]) + if not args: + LOG.warning('No frame received to decode?!?!') + return None + # If args[0] is already a dict (already parsed), pass it directly to factory + if isinstance(args[0], dict): + return core.factory(args[0]) + return core.factory(aprslib.parse(args[0])) def consumer(self, callback: Callable, raw: bool = False): if self._client and self.connected: diff --git a/aprsd/client/drivers/fake.py b/aprsd/client/drivers/fake.py index c203e46..e3cbc92 100644 --- a/aprsd/client/drivers/fake.py +++ b/aprsd/client/drivers/fake.py @@ -103,16 +103,20 @@ class APRSDFakeDriver(metaclass=trace.TraceWrapperMetaclass): def decode_packet(self, *args, **kwargs): """APRS lib already decodes this.""" - if not kwargs: + # If packet is provided in kwargs, return it directly + if 'packet' in kwargs: + return kwargs['packet'] + # If raw is provided in kwargs, use it + if 'raw' in kwargs: + return core.factory(aprslib.parse(kwargs['raw'])) + # Otherwise, use args[0] if available + if not args: + LOG.warning('No frame received to decode?!?!') return None - - if kwargs.get('packet'): - return kwargs.get('packet') - - if kwargs.get('raw'): - pkt_raw = aprslib.parse(kwargs.get('raw')) - pkt = core.factory(pkt_raw) - return pkt + # If args[0] is already a dict (already parsed), pass it directly to factory + if isinstance(args[0], dict): + return core.factory(args[0]) + return core.factory(aprslib.parse(args[0])) def stats(self, serializable: bool = False) -> dict: return { diff --git a/aprsd/client/drivers/kiss_common.py b/aprsd/client/drivers/kiss_common.py index 4a5308e..446b9d4 100644 --- a/aprsd/client/drivers/kiss_common.py +++ b/aprsd/client/drivers/kiss_common.py @@ -101,11 +101,12 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass): Args: frame: Received AX.25 frame """ - frame = kwargs.get('frame') - if not frame: + if not args: LOG.warning('No frame received to decode?!?!') return None + frame = args[0] + try: aprslib_frame = aprslib.parse(str(frame)) packet = core.factory(aprslib_frame) @@ -134,10 +135,7 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass): frame = self.read_frame() if frame: LOG.info(f'GOT FRAME: {frame} calling {callback}') - kwargs = { - 'frame': frame, - } - callback(**kwargs) + callback(frame) def read_frame(self): """Read a frame from the KISS interface. diff --git a/aprsd/cmds/completion.py b/aprsd/cmds/completion.py index 5f75ce7..c118817 100644 --- a/aprsd/cmds/completion.py +++ b/aprsd/cmds/completion.py @@ -3,12 +3,12 @@ import click.shell_completion from aprsd.main import cli -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @cli.command() @click.argument( - "shell", type=click.Choice(list(click.shell_completion._available_shells)) + 'shell', type=click.Choice(list(click.shell_completion._available_shells)) ) def completion(shell): """Show the shell completion code""" @@ -16,10 +16,10 @@ def completion(shell): cls = click.shell_completion.get_completion_class(shell) prog_name = _detect_program_name() - complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() + complete_var = f'_{prog_name}_COMPLETE'.replace('-', '_').upper() print(cls(cli, {}, prog_name, complete_var).source()) print( - "# Add the following line to your shell configuration file to have aprsd command line completion" + '# Add the following line to your shell configuration file to have aprsd command line completion' ) print("# but remove the leading '#' character.") print(f'# eval "$(aprsd completion {shell})"') diff --git a/aprsd/cmds/dev.py b/aprsd/cmds/dev.py index 09aae59..eeac74c 100644 --- a/aprsd/cmds/dev.py +++ b/aprsd/cmds/dev.py @@ -10,7 +10,7 @@ import click from oslo_config import cfg import aprsd -from aprsd import cli_helper, conf, packets, plugin, utils +from aprsd import cli_helper, packets, plugin, utils # local imports here from aprsd.main import cli @@ -79,12 +79,13 @@ def test_plugin( CONF.log_opt_values(LOG, logging.DEBUG) if not aprs_login: - if CONF.aprs_network.login == conf.client.DEFAULT_LOGIN: - click.echo('Must set --aprs_login or APRS_LOGIN') + if CONF.callsign == 'NOCALL': + click.echo( + 'Must set --aprs_login or APRS_LOGIN, or set callsign in config ([DEFAULT])' + ) ctx.exit(-1) return - else: - fromcall = CONF.aprs_network.login + fromcall = CONF.callsign else: fromcall = aprs_login @@ -129,6 +130,9 @@ def test_plugin( LOG.info(f"P'{plugin_path}' F'{fromcall}' C'{message}'") for _ in range(number): + # PluginManager.run() executes all plugins in parallel + # Results may be in a different order than plugin registration + # NULL_MESSAGE results are already filtered out replies = pm.run(packet) # Plugin might have threads, so lets stop them so we can exit. # obj.stop_threads() @@ -149,12 +153,15 @@ def test_plugin( elif isinstance(reply, packets.Packet): # We have a message based object. LOG.info(reply) - elif reply is not packets.NULL_MESSAGE: - LOG.info( - packets.MessagePacket( - from_call=CONF.callsign, - to_call=fromcall, - message_text=reply, - ), - ) + else: + # Note: NULL_MESSAGE results are already filtered out + # in PluginManager.run(), but keeping this check for safety + if reply is not packets.NULL_MESSAGE: + LOG.info( + packets.MessagePacket( + from_call=CONF.callsign, + to_call=fromcall, + message_text=reply, + ), + ) pm.stop() diff --git a/aprsd/cmds/fetch_stats.py b/aprsd/cmds/fetch_stats.py index 2deb083..25aa322 100644 --- a/aprsd/cmds/fetch_stats.py +++ b/aprsd/cmds/fetch_stats.py @@ -205,7 +205,8 @@ def dump_stats(ctx, raw, show_section): console.print(stats[section]) return - t = Table(title='APRSD Stats') + aprsd_stats_count = len(stats['APRSDStats']) + t = Table(title=f'APRSD Stats ({aprsd_stats_count})') t.add_column('Key') t.add_column('Value') for key, value in stats['APRSDStats'].items(): @@ -215,7 +216,8 @@ def dump_stats(ctx, raw, show_section): console.print(t) # Show the thread list - t = Table(title='Thread List') + thread_list_count = len(stats['APRSDThreadList']) + t = Table(title=f'Thread List ({thread_list_count})') t.add_column('Name') t.add_column('Class') t.add_column('Alive?') @@ -234,7 +236,8 @@ def dump_stats(ctx, raw, show_section): console.print(t) # Show the plugins - t = Table(title='Plugin List') + plugin_count = len(stats['PluginManager']) + t = Table(title=f'Plugin List ({plugin_count})') t.add_column('Name') t.add_column('Enabled') t.add_column('Version') @@ -253,7 +256,8 @@ def dump_stats(ctx, raw, show_section): console.print(t) # Now show the client stats - t = Table(title='Client Stats') + client_stats_count = len(stats['APRSClientStats']) + t = Table(title=f'Client Stats ({client_stats_count})') t.add_column('Key') t.add_column('Value') for key, value in stats['APRSClientStats'].items(): @@ -264,7 +268,12 @@ def dump_stats(ctx, raw, show_section): # now show the packet list packet_list = stats.get('PacketList') - t = Table(title='Packet List') + # Count packet types if 'packets' key exists, otherwise count top-level keys + if 'packets' in packet_list: + packet_count = len(packet_list['packets']) + else: + packet_count = len(packet_list) + t = Table(title=f'Packet List ({packet_count})') t.add_column('Key') t.add_column('Value') t.add_row('Total Received', str(packet_list['rx'])) @@ -275,10 +284,15 @@ def dump_stats(ctx, raw, show_section): # now show the seen list seen_list = stats.get('SeenList') - sorted_seen_list = sorted( - seen_list.items(), + seen_list_count = len(seen_list) if seen_list else 0 + sorted_seen_list = ( + sorted( + seen_list.items(), + ) + if seen_list + else [] ) - t = Table(title='Seen List') + t = Table(title=f'Seen List ({seen_list_count})') t.add_column('Callsign') t.add_column('Message Count') t.add_column('Last Heard') @@ -294,10 +308,15 @@ def dump_stats(ctx, raw, show_section): # now show the watch list watch_list = stats.get('WatchList') - sorted_watch_list = sorted( - watch_list.items(), + watch_list_count = len(watch_list) if watch_list else 0 + sorted_watch_list = ( + sorted( + watch_list.items(), + ) + if watch_list + else [] ) - t = Table(title='Watch List') + t = Table(title=f'Watch List ({watch_list_count})') t.add_column('Callsign') t.add_column('Last Heard') for key, value in sorted_watch_list: diff --git a/aprsd/cmds/healthcheck.py b/aprsd/cmds/healthcheck.py index 8820fc7..4bb340f 100644 --- a/aprsd/cmds/healthcheck.py +++ b/aprsd/cmds/healthcheck.py @@ -25,23 +25,23 @@ from aprsd.threads import stats as stats_threads # setup the global logger # log.basicConfig(level=log.DEBUG) # level=10 CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') console = Console() @cli.command() @cli_helper.add_options(cli_helper.common_options) @click.option( - "--timeout", + '--timeout', show_default=True, default=3, - help="How long to wait for healtcheck url to come back", + help='How long to wait for healtcheck url to come back', ) @click.pass_context @cli_helper.process_standard_options def healthcheck(ctx, timeout): """Check the health of the running aprsd server.""" - ver_str = f"APRSD HealthCheck version: {aprsd.__version__}" + ver_str = f'APRSD HealthCheck version: {aprsd.__version__}' console.log(ver_str) with console.status(ver_str): @@ -56,33 +56,33 @@ def healthcheck(ctx, timeout): else: now = datetime.datetime.now() if not stats: - console.log("No stats from aprsd") + console.log('No stats from aprsd') sys.exit(-1) - email_stats = stats.get("EmailStats") + email_stats = stats.get('EmailStats') if email_stats: - email_thread_last_update = email_stats["last_check_time"] + email_thread_last_update = email_stats['last_check_time'] - if email_thread_last_update != "never": + if email_thread_last_update != 'never': d = now - email_thread_last_update - max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 30} + 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}") + console.log(f'Email thread is very old! {d}') sys.exit(-1) - client_stats = stats.get("APRSClientStats") + client_stats = stats.get('APRSClientStats') if not client_stats: - console.log("No APRSClientStats") + console.log('No APRSClientStats') sys.exit(-1) else: - aprsis_last_update = client_stats["connection_keepalive"] + aprsis_last_update = client_stats['connection_keepalive'] d = now - aprsis_last_update - max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0} + max_timeout = {'hours': 0.0, 'minutes': 5, 'seconds': 0} max_delta = datetime.timedelta(**max_timeout) if d > max_delta: - LOG.error(f"APRS-IS last update is very old! {d}") + LOG.error(f'APRS-IS last update is very old! {d}') sys.exit(-1) - console.log("OK") + console.log('OK') sys.exit(0) diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index 1dedd76..7e4a9c5 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -3,13 +3,16 @@ # # python included libs +import cProfile import datetime import logging +import pstats import signal import sys import time import click +import requests from loguru import logger from oslo_config import cfg from rich.console import Console @@ -19,8 +22,7 @@ import aprsd from aprsd import cli_helper, packets, plugin, threads, utils from aprsd.client.client import APRSDClient from aprsd.main import cli -from aprsd.packets import collector as packet_collector -from aprsd.packets import core, seen_list +from aprsd.packets import core from aprsd.packets import log as packet_log from aprsd.packets.filter import PacketFilter from aprsd.packets.filters import dupe_filter, packet_type @@ -28,6 +30,7 @@ from aprsd.stats import collector from aprsd.threads import keepalive, rx from aprsd.threads import stats as stats_thread from aprsd.threads.aprsd import APRSDThread +from aprsd.threads.stats import StatsLogThread # setup the global logger # log.basicConfig(level=log.DEBUG) # level=10 @@ -68,87 +71,61 @@ class APRSDListenProcessThread(rx.APRSDFilterThread): def print_packet(self, packet): if self.log_packets: - packet_log.log(packet) + packet_log.log( + packet, + packet_count=self.packet_count, + force_log=True, + ) def process_packet(self, packet: type[core.Packet]): if self.plugin_manager: # Don't do anything with the reply. # This is the listen only command. + # PluginManager.run() executes all plugins in parallel + # Results may be in a different order than plugin registration self.plugin_manager.run(packet) -class ListenStatsThread(APRSDThread): - """Log the stats from the PacketList.""" +class StatsExportThread(APRSDThread): + """Export stats to remote aprsd-exporter API.""" - def __init__(self): - super().__init__('PacketStatsLog') - self._last_total_rx = 0 - self.period = 31 - self.start_time = time.time() + def __init__(self, exporter_url): + super().__init__('StatsExport') + self.exporter_url = exporter_url + self.period = 10 # Export stats every 60 seconds def loop(self): if self.loop_count % self.period == 0: - # log the stats every 10 seconds - stats_json = collector.Collector().collect() - stats = stats_json['PacketList'] - total_rx = stats['rx'] - packet_count = len(stats['packets']) - rx_delta = total_rx - self._last_total_rx - rate = rx_delta / self.period + try: + # Collect all stats + stats_json = collector.Collector().collect(serializable=True) + # Remove the PacketList section to reduce payload size + if 'PacketList' in stats_json: + del stats_json['PacketList']['packets'] - # Get unique callsigns count from packets' from_call field - unique_callsigns = set() - if 'packets' in stats and stats['packets']: - for packet in stats['packets']: - # Handle both Packet objects and dicts (if serializable) - if hasattr(packet, 'from_call'): - if packet.from_call: - unique_callsigns.add(packet.from_call) - elif isinstance(packet, dict) and 'from_call' in packet: - if packet['from_call']: - unique_callsigns.add(packet['from_call']) - unique_callsigns_count = len(unique_callsigns) + now = datetime.datetime.now() + time_format = '%m-%d-%Y %H:%M:%S' + stats = { + 'time': now.strftime(time_format), + 'stats': stats_json, + } - # Calculate uptime - elapsed = time.time() - self.start_time - elapsed_minutes = elapsed / 60 - elapsed_hours = elapsed / 3600 + # Send stats to exporter API + url = f'{self.exporter_url}/stats' + headers = {'Content-Type': 'application/json'} + response = requests.post(url, json=stats, headers=headers, timeout=10) - # Log summary stats - LOGU.opt(colors=True).info( - f'RX Rate: {rate:.2f} pps ' - f'Total RX: {total_rx} ' - f'RX Last {self.period} secs: {rx_delta} ' - f'Packets in PacketListStats: {packet_count}', - ) - LOGU.opt(colors=True).info( - f'Uptime: {elapsed:.0f}s ({elapsed_minutes:.1f}m / {elapsed_hours:.2f}h) ' - f'Unique Callsigns: {unique_callsigns_count}', - ) - self._last_total_rx = total_rx + if response.status_code == 200: + LOGU.info(f'Successfully exported stats to {self.exporter_url}') + else: + LOGU.warning( + f'Failed to export stats to {self.exporter_url}: HTTP {response.status_code}' + ) - # Log individual type stats, sorted by RX count (descending) - sorted_types = sorted( - stats['types'].items(), key=lambda x: x[1]['rx'], reverse=True - ) - for k, v in sorted_types: - # Calculate percentage of this packet type compared to total RX - percentage = (v['rx'] / total_rx * 100) if total_rx > 0 else 0.0 - # Format values first, then apply colors - packet_type_str = f'{k:<15}' - rx_count_str = f'{v["rx"]:6d}' - tx_count_str = f'{v["tx"]:6d}' - percentage_str = f'{percentage:5.1f}%' - # Use different colors for RX count based on threshold (matching mqtt_injest.py) - rx_color_tag = ( - 'green' if v['rx'] > 100 else 'yellow' if v['rx'] > 10 else 'red' - ) - LOGU.opt(colors=True).info( - f' {packet_type_str}: ' - f'<{rx_color_tag}>RX: {rx_count_str} ' - f'TX: {tx_count_str} ' - f'({percentage_str})', - ) + except requests.exceptions.RequestException as e: + LOGU.error(f'Error exporting stats to {self.exporter_url}: {e}') + except Exception as e: + LOGU.error(f'Unexpected error in stats export: {e}') time.sleep(1) return True @@ -218,6 +195,23 @@ class ListenStatsThread(APRSDThread): is_flag=True, help='Enable packet stats periodic logging.', ) +@click.option( + '--export-stats', + default=False, + is_flag=True, + help='Export stats to remote aprsd-exporter API.', +) +@click.option( + '--exporter-url', + default='http://localhost:8081', + help='URL of the aprsd-exporter API to send stats to.', +) +@click.option( + '--profile', + default=False, + is_flag=True, + help='Enable Python cProfile profiling to identify performance bottlenecks.', +) @click.pass_context @cli_helper.process_standard_options def listen( @@ -230,6 +224,9 @@ def listen( filter, log_packets, enable_packet_stats, + export_stats, + exporter_url, + profile, ): """Listen to packets on the APRS-IS Network based on FILTER. @@ -241,6 +238,13 @@ def listen( o/obj1/obj2... - Object Filter Pass all objects with the exact name of obj1, obj2, ... (* wild card allowed)\n """ + # Initialize profiler if enabled + profiler = None + if profile: + LOG.info('Starting Python cProfile profiling') + profiler = cProfile.Profile() + profiler.enable() + signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) @@ -256,9 +260,6 @@ def listen( ctx.fail('Must set --aprs-password or APRS_PASSWORD') ctx.exit() - # 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() @@ -292,10 +293,6 @@ def listen( keepalive_thread = keepalive.KeepAliveThread() - if not CONF.enable_seen_list: - # just deregister the class from the packet collector - packet_collector.PacketCollector().unregister(seen_list.SeenList) - # we don't want the dupe filter to run here. PacketFilter().unregister(dupe_filter.DupePacketFilter) if packet_filter: @@ -326,6 +323,11 @@ def listen( for p in pm.get_plugins(): LOG.info('Loaded plugin %s', p.__class__.__name__) + if log_packets: + LOG.info('Packet Logging is enabled') + else: + LOG.info('Packet Logging is disabled') + stats = stats_thread.APRSDStatsStoreThread() stats.start() @@ -346,13 +348,44 @@ def listen( LOG.debug(f'enable_packet_stats: {enable_packet_stats}') if enable_packet_stats: - LOG.debug('Start ListenStatsThread') - listen_stats = ListenStatsThread() + LOG.debug('Start StatsLogThread') + listen_stats = StatsLogThread() listen_stats.start() + LOG.debug(f'export_stats: {export_stats}') + stats_export = None + if export_stats: + LOG.debug('Start StatsExportThread') + stats_export = StatsExportThread(exporter_url) + stats_export.start() + keepalive_thread.start() LOG.debug('keepalive Join') keepalive_thread.join() rx_thread.join() listen_thread.join() stats.join() + if stats_export: + stats_export.join() + + # Save profiling results if enabled + if profiler: + profiler.disable() + profile_file = 'aprsd_listen_profile.prof' + profiler.dump_stats(profile_file) + LOG.info(f'Profile saved to {profile_file}') + + # Print profiling summary + LOG.info('Profile Summary (top 50 functions by cumulative time):') + stats = pstats.Stats(profiler) + stats.sort_stats('cumulative') + + # Log the top functions + LOG.info('-' * 80) + for item in stats.get_stats().items()[:50]: + func_info, stats_tuple = item + cumulative = stats_tuple[3] + total_calls = stats_tuple[0] + LOG.info( + f'{func_info} - Calls: {total_calls}, Cumulative: {cumulative:.4f}s' + ) diff --git a/aprsd/cmds/send_message.py b/aprsd/cmds/send_message.py index c1296c2..51ab90d 100644 --- a/aprsd/cmds/send_message.py +++ b/aprsd/cmds/send_message.py @@ -9,12 +9,7 @@ from oslo_config import cfg import aprsd import aprsd.packets # noqa : F401 -from aprsd import ( - cli_helper, - conf, # noqa : F401 - packets, - utils, -) +from aprsd import cli_helper, packets, utils from aprsd.client.client import APRSDClient from aprsd.main import cli from aprsd.packets import collector @@ -75,12 +70,13 @@ def send_message( quiet = ctx.obj['quiet'] if not aprs_login: - if CONF.aprs_network.login == conf.client.DEFAULT_LOGIN: - click.echo('Must set --aprs_login or APRS_LOGIN') + if CONF.callsign == 'NOCALL': + click.echo( + 'Must set --aprs_login or APRS_LOGIN, or set callsign in config ([DEFAULT])' + ) ctx.exit(-1) return - else: - aprs_login = CONF.aprs_network.login + aprs_login = CONF.callsign if not aprs_password: if not CONF.aprs_network.password: diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py index 0c34f6a..0da6a21 100644 --- a/aprsd/cmds/server.py +++ b/aprsd/cmds/server.py @@ -15,6 +15,7 @@ from aprsd.packets import collector as packet_collector from aprsd.packets import seen_list from aprsd.threads import keepalive, registry, rx, service, tx from aprsd.threads import stats as stats_thread +from aprsd.threads.stats import StatsLogThread CONF = cfg.CONF LOG = logging.getLogger('APRSD') @@ -42,9 +43,15 @@ def _is_aprsd_gps_extension_installed(): default=False, help='Flush out all old aged messages on disk.', ) +@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 server(ctx, flush): +def server(ctx, flush, enable_packet_stats): """Start the aprsd server gateway process.""" signal.signal(signal.SIGINT, aprsd_main.signal_handler) signal.signal(signal.SIGTERM, aprsd_main.signal_handler) @@ -165,6 +172,11 @@ def server(ctx, flush): LOG.info('Registry Enabled. Starting Registry thread.') service_threads.register(registry.APRSRegistryThread()) + if enable_packet_stats: + LOG.debug('Start StatsLogThread') + listen_stats = StatsLogThread() + listen_stats.start() + service_threads.start() service_threads.join() diff --git a/aprsd/conf/__init__.py b/aprsd/conf/__init__.py index df5008b..2b20e91 100644 --- a/aprsd/conf/__init__.py +++ b/aprsd/conf/__init__.py @@ -2,7 +2,6 @@ from oslo_config import cfg from aprsd.conf import client, common, log, plugin_common - CONF = cfg.CONF log.register_opts(CONF) @@ -37,19 +36,19 @@ def conf_to_dict(): def _sanitize(opt, value): """Obfuscate values of options declared secret.""" - return value if not opt.secret else "*" * 4 + return value if not opt.secret else '*' * 4 for opt_name in sorted(CONF._opts): - opt = CONF._get_opt_info(opt_name)["opt"] + opt = CONF._get_opt_info(opt_name)['opt'] val = str(_sanitize(opt, getattr(CONF, opt_name))) entries[str(opt)] = val for group_name in list(CONF._groups): group_attr = CONF.GroupAttr(CONF, CONF._get_group(group_name)) for opt_name in sorted(CONF._groups[group_name]._opts): - opt = CONF._get_opt_info(opt_name, group_name)["opt"] + opt = CONF._get_opt_info(opt_name, group_name)['opt'] val = str(_sanitize(opt, getattr(group_attr, opt_name))) - gname_opt_name = f"{group_name}.{opt_name}" + gname_opt_name = f'{group_name}.{opt_name}' entries[gname_opt_name] = val return entries diff --git a/aprsd/conf/client.py b/aprsd/conf/client.py index d7b394a..ac0e025 100644 --- a/aprsd/conf/client.py +++ b/aprsd/conf/client.py @@ -4,107 +4,100 @@ The options for log setup from oslo_config import cfg -DEFAULT_LOGIN = "NOCALL" - aprs_group = cfg.OptGroup( - name="aprs_network", - title="APRS-IS Network settings", + name='aprs_network', + title='APRS-IS Network settings', ) kiss_serial_group = cfg.OptGroup( - name="kiss_serial", - title="KISS Serial device connection", + name='kiss_serial', + title='KISS Serial device connection', ) kiss_tcp_group = cfg.OptGroup( - name="kiss_tcp", - title="KISS TCP/IP Device connection", + name='kiss_tcp', + title='KISS TCP/IP Device connection', ) fake_client_group = cfg.OptGroup( - name="fake_client", - title="Fake Client settings", + name='fake_client', + title='Fake Client settings', ) aprs_opts = [ cfg.BoolOpt( - "enabled", + 'enabled', default=True, - help="Set enabled to False if there is no internet connectivity." - "This is useful for a direwolf KISS aprs connection only.", + help='Set enabled to False if there is no internet connectivity.' + 'This is useful for a direwolf KISS aprs connection only.', ), cfg.StrOpt( - "login", - default=DEFAULT_LOGIN, - help="APRS Username", - ), - cfg.StrOpt( - "password", + 'password', secret=True, - help="APRS Password " - "Get the passcode for your callsign here: " - "https://apps.magicbug.co.uk/passcode", + help='APRS Password for the callsign in [DEFAULT]. ' + 'Get the passcode for your callsign here: ' + 'https://apps.magicbug.co.uk/passcode', ), cfg.HostAddressOpt( - "host", - default="noam.aprs2.net", - help="The APRS-IS hostname", + 'host', + default='noam.aprs2.net', + help='The APRS-IS hostname', ), cfg.PortOpt( - "port", + 'port', default=14580, - help="APRS-IS port", + help='APRS-IS port', ), ] kiss_serial_opts = [ cfg.BoolOpt( - "enabled", + 'enabled', default=False, - help="Enable Serial KISS interface connection.", + help='Enable Serial KISS interface connection.', ), cfg.StrOpt( - "device", - help="Serial Device file to use. /dev/ttyS0", + 'device', + help='Serial Device file to use. /dev/ttyS0', ), cfg.IntOpt( - "baudrate", + 'baudrate', default=9600, - help="The Serial device baud rate for communication", + help='The Serial device baud rate for communication', ), cfg.ListOpt( - "path", - default=["WIDE1-1", "WIDE2-1"], - help="The APRS path to use for wide area coverage.", + 'path', + default=['WIDE1-1', 'WIDE2-1'], + help='The APRS path to use for wide area coverage.', ), ] kiss_tcp_opts = [ cfg.BoolOpt( - "enabled", + 'enabled', default=False, - help="Enable Serial KISS interface connection.", + help='Enable Serial KISS interface connection.', ), cfg.HostAddressOpt( - "host", - help="The KISS TCP Host to connect to.", + 'host', + help='The KISS TCP Host to connect to.', ), cfg.PortOpt( - "port", + 'port', default=8001, - help="The KISS TCP/IP network port", + help='The KISS TCP/IP network port', ), cfg.ListOpt( - "path", - default=["WIDE1-1", "WIDE2-1"], - help="The APRS path to use for wide area coverage.", + 'path', + default=['WIDE1-1', 'WIDE2-1'], + help='The APRS path to use for wide area coverage.', ), ] fake_client_opts = [ cfg.BoolOpt( - "enabled", + 'enabled', default=False, - help="Enable fake client connection.", + help='Enable fake client connection.', ), ] diff --git a/aprsd/conf/common.py b/aprsd/conf/common.py index 5167527..ff0cf70 100644 --- a/aprsd/conf/common.py +++ b/aprsd/conf/common.py @@ -22,6 +22,11 @@ aprsd_opts = [ default='NOCALL', help='Callsign to use for messages sent by APRSD', ), + cfg.StrOpt( + 'owner_callsign', + default=None, + help='The ham radio license callsign that owns this APRSD instance.', + ), cfg.BoolOpt( 'enable_save', default=True, diff --git a/aprsd/conf/opts.py b/aprsd/conf/opts.py index 7dbd491..8062cc5 100644 --- a/aprsd/conf/opts.py +++ b/aprsd/conf/opts.py @@ -31,7 +31,7 @@ import importlib import os import pkgutil -LIST_OPTS_FUNC_NAME = "list_opts" +LIST_OPTS_FUNC_NAME = 'list_opts' def _tupleize(dct): @@ -51,7 +51,7 @@ def _list_module_names(): module_names = [] package_path = os.path.dirname(os.path.abspath(__file__)) for _, modname, ispkg in pkgutil.iter_modules(path=[package_path]): - if modname == "opts" or ispkg: + if modname == 'opts' or ispkg: continue else: module_names.append(modname) @@ -61,11 +61,11 @@ def _list_module_names(): def _import_modules(module_names): imported_modules = [] for modname in module_names: - mod = importlib.import_module("aprsd.conf." + modname) + mod = importlib.import_module('aprsd.conf.' + modname) if not hasattr(mod, LIST_OPTS_FUNC_NAME): msg = ( "The module 'aprsd.conf.%s' should have a '%s' " - "function which returns the config options." + 'function which returns the config options.' % (modname, LIST_OPTS_FUNC_NAME) ) raise Exception(msg) diff --git a/aprsd/conf/plugin_common.py b/aprsd/conf/plugin_common.py index b08487d..13a5ff1 100644 --- a/aprsd/conf/plugin_common.py +++ b/aprsd/conf/plugin_common.py @@ -1,55 +1,55 @@ from oslo_config import cfg aprsfi_group = cfg.OptGroup( - name="aprs_fi", - title="APRS.FI website settings", + name='aprs_fi', + title='APRS.FI website settings', ) query_group = cfg.OptGroup( - name="query_plugin", - title="Options for the Query Plugin", + name='query_plugin', + title='Options for the Query Plugin', ) avwx_group = cfg.OptGroup( - name="avwx_plugin", - title="Options for the AVWXWeatherPlugin", + name='avwx_plugin', + title='Options for the AVWXWeatherPlugin', ) owm_wx_group = cfg.OptGroup( - name="owm_weather_plugin", - title="Options for the OWMWeatherPlugin", + name='owm_weather_plugin', + title='Options for the OWMWeatherPlugin', ) aprsfi_opts = [ cfg.StrOpt( - "apiKey", - help="Get the apiKey from your aprs.fi account here:" "http://aprs.fi/account", + 'apiKey', + help='Get the apiKey from your aprs.fi account here:http://aprs.fi/account', ), ] owm_wx_opts = [ cfg.StrOpt( - "apiKey", + 'apiKey', help="OWMWeatherPlugin api key to OpenWeatherMap's API." - "This plugin uses the openweathermap API to fetch" - "location and weather information." - "To use this plugin you need to get an openweathermap" - "account and apikey." - "https://home.openweathermap.org/api_keys", + 'This plugin uses the openweathermap API to fetch' + 'location and weather information.' + 'To use this plugin you need to get an openweathermap' + 'account and apikey.' + 'https://home.openweathermap.org/api_keys', ), ] avwx_opts = [ cfg.StrOpt( - "apiKey", - help="avwx-api is an opensource project that has" - "a hosted service here: https://avwx.rest/" - "You can launch your own avwx-api in a container" - "by cloning the githug repo here:" - "https://github.com/avwx-rest/AVWX-API", + 'apiKey', + help='avwx-api is an opensource project that has' + 'a hosted service here: https://avwx.rest/' + 'You can launch your own avwx-api in a container' + 'by cloning the githug repo here:' + 'https://github.com/avwx-rest/AVWX-API', ), cfg.StrOpt( - "base_url", - default="https://avwx.rest", - help="The base url for the avwx API. If you are hosting your own" - "Here is where you change the url to point to yours.", + 'base_url', + default='https://avwx.rest', + help='The base url for the avwx API. If you are hosting your own' + 'Here is where you change the url to point to yours.', ), ] diff --git a/aprsd/packets/collector.py b/aprsd/packets/collector.py index 47c17a6..b79bfed 100644 --- a/aprsd/packets/collector.py +++ b/aprsd/packets/collector.py @@ -4,8 +4,7 @@ from typing import Callable, Protocol, runtime_checkable from aprsd.packets import core from aprsd.utils import singleton - -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') @runtime_checkable @@ -36,12 +35,12 @@ class PacketCollector: def register(self, monitor: Callable) -> None: if not isinstance(monitor, PacketMonitor): - raise TypeError(f"Monitor {monitor} is not a 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") + raise TypeError(f'Monitor {monitor} is not a PacketMonitor') self.monitors.remove(monitor) def rx(self, packet: type[core.Packet]) -> None: @@ -50,7 +49,7 @@ class PacketCollector: try: cls.rx(packet) except Exception as e: - LOG.error(f"Error in monitor {name} (rx): {e}") + LOG.error(f'Error in monitor {name} (rx): {e}') def tx(self, packet: type[core.Packet]) -> None: for name in self.monitors: @@ -58,7 +57,7 @@ class PacketCollector: try: cls.tx(packet) except Exception as e: - LOG.error(f"Error in monitor {name} (tx): {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.""" @@ -67,7 +66,7 @@ class PacketCollector: try: cls.flush() except Exception as e: - LOG.error(f"Error in monitor {name} (flush): {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.""" @@ -76,4 +75,4 @@ class PacketCollector: try: cls.load() except Exception as e: - LOG.error(f"Error in monitor {name} (load): {e}") + LOG.error(f'Error in monitor {name} (load): {e}') diff --git a/aprsd/packets/core.py b/aprsd/packets/core.py index 565ae77..5e988db 100644 --- a/aprsd/packets/core.py +++ b/aprsd/packets/core.py @@ -514,8 +514,13 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin): speed: Optional[float] = field(default=None) def _translate(self, raw: dict) -> dict: - for key in raw['weather']: - raw[key] = raw['weather'][key] + # aprslib returns the weather data in a 'weather' key + # We need to move the data out of the 'weather' key + # and into the root of the dictionary + if 'weather' in raw: + for key in raw['weather']: + raw[key] = raw['weather'][key] + del raw['weather'] # If we have the broken aprslib, then we need to # Convert the course and speed to wind_speed and wind_direction @@ -531,28 +536,27 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin): wind_speed = raw.get('speed') if wind_speed: raw['wind_speed'] = round(wind_speed / 1.852, 3) - raw['weather']['wind_speed'] = raw['wind_speed'] + # raw['weather']['wind_speed'] = raw['wind_speed'] if 'speed' in raw: del raw['speed'] # Let's adjust the rain numbers as well, since it's wrong raw['rain_1h'] = round((raw.get('rain_1h', 0) / 0.254) * 0.01, 3) - raw['weather']['rain_1h'] = raw['rain_1h'] + # raw['weather']['rain_1h'] = raw['rain_1h'] raw['rain_24h'] = round((raw.get('rain_24h', 0) / 0.254) * 0.01, 3) - raw['weather']['rain_24h'] = raw['rain_24h'] + # raw['weather']['rain_24h'] = raw['rain_24h'] raw['rain_since_midnight'] = round( (raw.get('rain_since_midnight', 0) / 0.254) * 0.01, 3 ) - raw['weather']['rain_since_midnight'] = raw['rain_since_midnight'] + # raw['weather']['rain_since_midnight'] = raw['rain_since_midnight'] if 'wind_direction' not in raw: wind_direction = raw.get('course') if wind_direction: raw['wind_direction'] = wind_direction - raw['weather']['wind_direction'] = raw['wind_direction'] + # raw['weather']['wind_direction'] = raw['wind_direction'] if 'course' in raw: del raw['course'] - del raw['weather'] return raw @classmethod diff --git a/aprsd/packets/filters/dupe_filter.py b/aprsd/packets/filters/dupe_filter.py index 0839fcf..d02cf8b 100644 --- a/aprsd/packets/filters/dupe_filter.py +++ b/aprsd/packets/filters/dupe_filter.py @@ -20,6 +20,9 @@ class DupePacketFilter: timeframe, then it's a dupe. """ + def __init__(self): + self.pl = packets.PacketList() + def filter(self, packet: type[core.Packet]) -> Union[type[core.Packet], None]: # LOG.debug(f"{self.__class__.__name__}.filter called for packet {packet}") """Filter a packet out if it's already been seen and processed.""" @@ -32,12 +35,11 @@ class DupePacketFilter: # Make sure we aren't re-processing the same packet # For RF based APRS Clients we can get duplicate packets # So we need to track them and not process the dupes. - pkt_list = packets.PacketList() found = False try: # Find the packet in the list of already seen packets # Based on the packet.key - found = pkt_list.find(packet) + found = self.pl.find(packet) if not packet.msgNo: # If the packet doesn't have a message id # then there is no reliable way to detect @@ -54,12 +56,13 @@ class DupePacketFilter: if not packet.processed: # We haven't processed this packet through the plugins. return packet - elif packet.timestamp - found.timestamp < CONF.packet_dupe_timeout: + elif abs(packet.timestamp - found.timestamp) < CONF.packet_dupe_timeout: # If the packet came in within N seconds of the # Last time seeing the packet, then we drop it as a dupe. LOG.warning( f'Packet {packet.from_call}:{packet.msgNo} already tracked, dropping.' ) + return None else: LOG.warning( f'Packet {packet.from_call}:{packet.msgNo} already tracked ' diff --git a/aprsd/packets/log.py b/aprsd/packets/log.py index bbee973..6f33b80 100644 --- a/aprsd/packets/log.py +++ b/aprsd/packets/log.py @@ -22,10 +22,15 @@ DEGREES_COLOR = 'fg #FFA900' def log_multiline( - packet, tx: Optional[bool] = False, header: Optional[bool] = True + packet, + tx: Optional[bool] = False, + header: Optional[bool] = True, + force_log: Optional[bool] = False, ) -> None: """LOG a packet to the logfile.""" - if not CONF.enable_packet_logging: + # If logging is disabled and we're not forcing log, return early + # However, if we're forcing log, we still proceed + if not CONF.enable_packet_logging and not force_log: return if CONF.log_packet_format == 'compact': return @@ -77,12 +82,15 @@ def log_multiline( if hasattr(packet, 'comment') and packet.comment: logit.append(f' Comment : {packet.comment}') - raw = packet.raw.replace('<', '\\<') + raw = packet.raw + if raw: + raw = raw.replace('<', '\\<') + else: + raw = '' logit.append(f' Raw : {raw}') logit.append(f'{header_str}________(<{PACKET_COLOR}>{name})') LOGU.opt(colors=True).info('\n'.join(logit)) - LOG.debug(repr(packet)) def log( @@ -90,13 +98,19 @@ def log( tx: Optional[bool] = False, header: Optional[bool] = True, packet_count: Optional[int] = None, + force_log: Optional[bool] = False, ) -> None: - if not CONF.enable_packet_logging: - return - if CONF.log_packet_format == 'multiline': - log_multiline(packet, tx, header) + # If logging is disabled and we're not forcing log, return early + if not CONF.enable_packet_logging and not force_log: return + # Handle multiline format + if CONF.log_packet_format == 'multiline': + log_multiline(packet, tx, header, force_log) + return + + # Handle compact format - this is the default case + # This is the compact format logging logic (which was unreachable before) if not packet_count: packet_count = '' else: @@ -168,4 +182,6 @@ def log( ) LOGU.opt(colors=True).info(' '.join(logit)) - log_multiline(packet, tx, header) + # Note: We don't call log_multiline again here for compact format since it's already handled above + if CONF.log_packet_format == 'both': + log_multiline(packet, tx, header, force_log) diff --git a/aprsd/packets/packet_list.py b/aprsd/packets/packet_list.py index de251d5..8bb9017 100644 --- a/aprsd/packets/packet_list.py +++ b/aprsd/packets/packet_list.py @@ -1,8 +1,10 @@ import logging +import threading from collections import OrderedDict from oslo_config import cfg +from aprsd import conf # noqa: F401 from aprsd.packets import core from aprsd.utils import objectstore @@ -21,6 +23,7 @@ class PacketList(objectstore.ObjectStoreMixin): def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) + cls.lock = threading.RLock() cls._instance.maxlen = CONF.packet_list_maxlen cls._instance._init_data() return cls._instance diff --git a/aprsd/packets/seen_list.py b/aprsd/packets/seen_list.py index 6f0da5c..775b62d 100644 --- a/aprsd/packets/seen_list.py +++ b/aprsd/packets/seen_list.py @@ -1,14 +1,14 @@ import datetime import logging +import threading from oslo_config import cfg from aprsd.packets import core from aprsd.utils import objectstore - CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class SeenList(objectstore.ObjectStoreMixin): @@ -20,13 +20,27 @@ class SeenList(objectstore.ObjectStoreMixin): def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) + cls._instance.lock = threading.RLock() cls._instance.data = {} return cls._instance def stats(self, serializable=False): """Return the stats for the PacketTrack class.""" with self.lock: - return self.data + if serializable: + # Convert datetime objects to strings for JSON serialization + serializable_data = {} + for callsign, data in self.data.items(): + serializable_data[callsign] = data.copy() + if 'last' in serializable_data[callsign] and isinstance( + serializable_data[callsign]['last'], datetime.datetime + ): + serializable_data[callsign]['last'] = serializable_data[ + callsign + ]['last'].isoformat() + return serializable_data + else: + return self.data def rx(self, packet: type[core.Packet]): """When we get a packet from the network, update the seen list.""" @@ -39,11 +53,11 @@ class SeenList(objectstore.ObjectStoreMixin): return if callsign not in self.data: self.data[callsign] = { - "last": None, - "count": 0, + 'last': None, + 'count': 0, } - self.data[callsign]["last"] = datetime.datetime.now() - self.data[callsign]["count"] += 1 + self.data[callsign]['last'] = datetime.datetime.now() + self.data[callsign]['count'] += 1 def tx(self, packet: type[core.Packet]): """We don't care about TX packets.""" diff --git a/aprsd/packets/tracker.py b/aprsd/packets/tracker.py index a751e15..fbd2605 100644 --- a/aprsd/packets/tracker.py +++ b/aprsd/packets/tracker.py @@ -1,14 +1,14 @@ import datetime import logging +import threading from oslo_config import cfg from aprsd.packets import core from aprsd.utils import objectstore - CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class PacketTrack(objectstore.ObjectStoreMixin): @@ -33,6 +33,7 @@ class PacketTrack(objectstore.ObjectStoreMixin): def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) + cls._instance.lock = threading.RLock() cls._instance._start_time = datetime.datetime.now() cls._instance._init_store() return cls._instance @@ -60,18 +61,20 @@ class PacketTrack(objectstore.ObjectStoreMixin): def stats(self, serializable=False): with self.lock: stats = { - "total_tracked": self.total_tracked, + 'total_tracked': self.total_tracked, } pkts = {} for key in self.data: last_send_time = self.data[key].last_send_time + if serializable and isinstance(last_send_time, datetime.datetime): + last_send_time = last_send_time.isoformat() pkts[key] = { - "last_send_time": last_send_time, - "send_count": self.data[key].send_count, - "retry_count": self.data[key].retry_count, - "message": self.data[key].raw, + 'last_send_time': last_send_time, + 'send_count': self.data[key].send_count, + 'retry_count': self.data[key].retry_count, + 'message': self.data[key].raw, } - stats["packets"] = pkts + stats['packets'] = pkts return stats def rx(self, packet: type[core.Packet]) -> None: @@ -80,7 +83,7 @@ class PacketTrack(objectstore.ObjectStoreMixin): self._remove(packet.msgNo) elif isinstance(packet, core.RejectPacket): self._remove(packet.msgNo) - elif hasattr(packet, "ackMsgNo"): + elif hasattr(packet, 'ackMsgNo'): # Got a piggyback ack, so remove the original message self._remove(packet.ackMsgNo) diff --git a/aprsd/packets/watch_list.py b/aprsd/packets/watch_list.py index d9a82d9..ec09128 100644 --- a/aprsd/packets/watch_list.py +++ b/aprsd/packets/watch_list.py @@ -1,5 +1,6 @@ import datetime import logging +import threading from oslo_config import cfg @@ -21,6 +22,7 @@ class WatchList(objectstore.ObjectStoreMixin): def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) + cls._instance.lock = threading.RLock() return cls._instance @trace.no_trace diff --git a/aprsd/plugin.py b/aprsd/plugin.py index c40dff3..6c9defe 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -7,6 +7,7 @@ import logging import re import textwrap import threading +from concurrent.futures import ThreadPoolExecutor, as_completed import pluggy from oslo_config import cfg @@ -49,6 +50,7 @@ class APRSDPluginSpec: class APRSDPluginBase(metaclass=abc.ABCMeta): """The base class for all APRSD Plugins.""" + _counter_lock = threading.Lock() config = None rx_count = 0 tx_count = 0 @@ -106,10 +108,12 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): return [] def rx_inc(self): - self.rx_count += 1 + with self._counter_lock: + self.rx_count += 1 def tx_inc(self): - self.tx_count += 1 + with self._counter_lock: + self.tx_count += 1 def stop_threads(self): """Stop any threads this plugin might have created.""" @@ -513,13 +517,90 @@ class PluginManager: LOG.info('Completed Plugin Loading.') def run(self, packet: packets.MessagePacket): - """Execute all the plugins run method.""" - with self.lock: - return self._pluggy_pm.hook.filter(packet=packet) + """Execute all plugins in parallel. + + Plugins are executed concurrently using ThreadPoolExecutor to improve + performance, especially when plugins perform I/O operations (API calls, + subprocess calls, etc.). Each plugin's filter() method is called in + parallel, and results are collected as they complete. + + Returns: + tuple: (results, handled) where: + - results: list of non-NULL plugin results + - handled: bool indicating if any plugin processed the message + (even if it returned NULL_MESSAGE) + """ + plugins = list(self._pluggy_pm.get_plugins()) + if not plugins: + return ([], False) + + results = [] + handled = False + + # Execute all plugins in parallel + with ThreadPoolExecutor(max_workers=len(plugins)) as executor: + future_to_plugin = { + executor.submit(plugin.filter, packet=packet): plugin + for plugin in plugins + } + + for future in as_completed(future_to_plugin): + plugin = future_to_plugin[future] + try: + result = future.result() + # Track if any plugin processed the message (even if NULL_MESSAGE) + if result is not None: + handled = True + # Only include non-NULL results + if result and result is not packets.NULL_MESSAGE: + results.append(result) + except Exception as ex: + LOG.error( + 'Plugin {} failed to process packet: {}'.format( + plugin.__class__.__name__, + ex, + ), + ) + LOG.exception(ex) + + return (results, handled) def run_watchlist(self, packet: packets.Packet): - with self.lock: - return self._watchlist_pm.hook.filter(packet=packet) + """Execute all watchlist plugins in parallel. + + Watchlist plugins are executed concurrently using ThreadPoolExecutor + to improve performance when multiple watchlist plugins are registered. + """ + plugins = list(self._watchlist_pm.get_plugins()) + if not plugins: + return [] + + results = [] + + # Execute all plugins in parallel + with ThreadPoolExecutor(max_workers=len(plugins)) as executor: + future_to_plugin = { + executor.submit(plugin.filter, packet=packet): plugin + for plugin in plugins + } + + for future in as_completed(future_to_plugin): + plugin = future_to_plugin[future] + try: + result = future.result() + # Only include non-NULL results + if result and result is not packets.NULL_MESSAGE: + results.append(result) + except Exception as ex: + LOG.error( + 'Watchlist plugin {} failed to process packet: {}'.format( + plugin.__class__.__name__, + ex, + ), + ) + LOG.exception(ex) + + return results def stop(self): """Stop all threads created by all plugins.""" diff --git a/aprsd/plugin_utils.py b/aprsd/plugin_utils.py index 11fb29a..b66181a 100644 --- a/aprsd/plugin_utils.py +++ b/aprsd/plugin_utils.py @@ -66,8 +66,8 @@ def fetch_openweathermap(api_key, lat, lon, units='metric', exclude=None): exclude = 'minutely,hourly,daily,alerts' try: url = ( - "https://api.openweathermap.org/data/3.0/onecall?" - "lat={}&lon={}&appid={}&units={}&exclude={}".format( + 'https://api.openweathermap.org/data/3.0/onecall?' + 'lat={}&lon={}&appid={}&units={}&exclude={}'.format( lat, lon, api_key, diff --git a/aprsd/plugins/ping.py b/aprsd/plugins/ping.py index ac0b015..1f85073 100644 --- a/aprsd/plugins/ping.py +++ b/aprsd/plugins/ping.py @@ -4,20 +4,19 @@ import time from aprsd import plugin from aprsd.utils import trace - -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class PingPlugin(plugin.APRSDRegexCommandPluginBase): """Ping.""" - command_regex = r"^([p]|[p]\s|ping)" - command_name = "ping" - short_description = "reply with a Pong!" + command_regex = r'^([p]|[p]\s|ping)' + command_name = 'ping' + short_description = 'reply with a Pong!' @trace.trace def process(self, packet): - LOG.info("PingPlugin") + LOG.info('PingPlugin') # fromcall = packet.get("from") # message = packet.get("message_text", None) # ack = packet.get("msgNo", "0") @@ -26,6 +25,6 @@ class PingPlugin(plugin.APRSDRegexCommandPluginBase): m = stm.tm_min s = stm.tm_sec reply = ( - "Pong! " + str(h).zfill(2) + ":" + str(m).zfill(2) + ":" + str(s).zfill(2) + 'Pong! ' + str(h).zfill(2) + ':' + str(m).zfill(2) + ':' + str(s).zfill(2) ) return reply.rstrip() diff --git a/aprsd/plugins/time.py b/aprsd/plugins/time.py index a6ded11..71adabd 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -1,25 +1,24 @@ import logging import re -from oslo_config import cfg import pytz +from oslo_config import cfg from tzlocal import get_localzone from aprsd import packets, plugin, plugin_utils from aprsd.utils import fuzzy, trace - CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class TimePlugin(plugin.APRSDRegexCommandPluginBase): """Time command.""" # Look for t or t or T or time - command_regex = r"^([t]|[t]\s|time)" - command_name = "time" - short_description = "What is the current local time." + command_regex = r'^([t]|[t]\s|time)' + command_name = 'time' + short_description = 'What is the current local time.' def _get_local_tz(self): lz = get_localzone() @@ -33,12 +32,12 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): gmt_t = pytz.utc.localize(utcnow) local_t = gmt_t.astimezone(localzone) - local_short_str = local_t.strftime("%H:%M %Z") - local_hour = local_t.strftime("%H") - local_min = local_t.strftime("%M") + local_short_str = local_t.strftime('%H:%M %Z') + local_hour = local_t.strftime('%H') + local_min = local_t.strftime('%M') cur_time = fuzzy(int(local_hour), int(local_min), 1) - reply = "{} ({})".format( + reply = '{} ({})'.format( cur_time, local_short_str, ) @@ -47,7 +46,7 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): @trace.trace def process(self, packet: packets.Packet): - LOG.info("TIME COMMAND") + LOG.info('TIME COMMAND') # So we can mock this in unit tests localzone = self._get_local_tz() return self.build_date_str(localzone) @@ -56,8 +55,8 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin): """OpenWeatherMap based timezone fetching.""" - command_regex = r"^([t]|[t]\s|time)" - command_name = "time" + command_regex = r'^([t]|[t]\s|time)' + command_name = 'time' short_description = "Current time of GPS beacon's timezone. Uses OpenWeatherMap" def setup(self): @@ -70,7 +69,7 @@ class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin): # ack = packet.get("msgNo", "0") # optional second argument is a callsign to search - a = re.search(r"^.*\s+(.*)", message) + a = re.search(r'^.*\s+(.*)', message) if a is not None: searchcall = a.group(1) searchcall = searchcall.upper() @@ -82,34 +81,34 @@ class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin): try: aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall) except Exception as ex: - LOG.error(f"Failed to fetch aprs.fi data {ex}") - return "Failed to fetch location" + LOG.error(f'Failed to fetch aprs.fi data {ex}') + return 'Failed to fetch location' - LOG.debug(f"LocationPlugin: aprs_data = {aprs_data}") - if not len(aprs_data["entries"]): + LOG.debug(f'LocationPlugin: aprs_data = {aprs_data}') + if not len(aprs_data['entries']): LOG.error("Didn't get any entries from aprs.fi") - return "Failed to fetch aprs.fi location" + return 'Failed to fetch aprs.fi location' - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] + lat = aprs_data['entries'][0]['lat'] + lon = aprs_data['entries'][0]['lng'] try: self.config.exists( - ["services", "openweathermap", "apiKey"], + ['services', 'openweathermap', 'apiKey'], ) except Exception as ex: - LOG.error(f"Failed to find config openweathermap:apiKey {ex}") - return "No openweathermap apiKey found" + LOG.error(f'Failed to find config openweathermap:apiKey {ex}') + return 'No openweathermap apiKey found' - api_key = self.config["services"]["openweathermap"]["apiKey"] + api_key = self.config['services']['openweathermap']['apiKey'] try: results = plugin_utils.fetch_openweathermap(api_key, lat, lon) except Exception as ex: LOG.error(f"Couldn't fetch openweathermap api '{ex}'") # default to UTC - localzone = pytz.timezone("UTC") + localzone = pytz.timezone('UTC') else: - tzone = results["timezone"] + tzone = results['timezone'] localzone = pytz.timezone(tzone) return self.build_date_str(localzone) diff --git a/aprsd/plugins/version.py b/aprsd/plugins/version.py index 7dce7cb..24f4b72 100644 --- a/aprsd/plugins/version.py +++ b/aprsd/plugins/version.py @@ -1,31 +1,32 @@ import logging import aprsd -from aprsd import plugin +from aprsd import conf, plugin from aprsd.stats import collector - -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class VersionPlugin(plugin.APRSDRegexCommandPluginBase): """Version of APRSD Plugin.""" - command_regex = r"^([v]|[v]\s|version)" - command_name = "version" - short_description = "What is the APRSD Version" + command_regex = r'^([v]|[v]\s|version)' + command_name = 'version' + short_description = 'What is the APRSD Version' # message_number:time combos so we don't resend the same email in # five mins {int:int} email_sent_dict = {} def process(self, packet): - LOG.info("Version COMMAND") + LOG.info('Version COMMAND') # fromcall = packet.get("from") # message = packet.get("message_text", None) # ack = packet.get("msgNo", "0") s = collector.Collector().collect() - return "APRSD ver:{} uptime:{}".format( + owner = conf.CONF.owner_callsign or '-' + return 'APRSD ver:{} uptime:{} owner:{}'.format( aprsd.__version__, - s["APRSDStats"]["uptime"], + s['APRSDStats']['uptime'], + owner, ) diff --git a/aprsd/stats/__init__.py b/aprsd/stats/__init__.py index 0e68df7..f786564 100644 --- a/aprsd/stats/__init__.py +++ b/aprsd/stats/__init__.py @@ -4,7 +4,6 @@ from aprsd.packets import packet_list, seen_list, tracker, watch_list from aprsd.stats import app, collector from aprsd.threads import aprsd - # Create the collector and register all the objects # that APRSD has that implement the stats protocol stats_collector = collector.Collector() diff --git a/aprsd/stats/app.py b/aprsd/stats/app.py index 0e64ff6..959522f 100644 --- a/aprsd/stats/app.py +++ b/aprsd/stats/app.py @@ -7,7 +7,6 @@ import aprsd from aprsd import utils from aprsd.log import log as aprsd_log - CONF = cfg.CONF @@ -37,13 +36,13 @@ class APRSDStats: if serializable: uptime = str(uptime) stats = { - "version": aprsd.__version__, - "uptime": uptime, - "callsign": CONF.callsign, - "memory_current": int(current), - "memory_current_str": utils.human_size(current), - "memory_peak": int(peak), - "memory_peak_str": utils.human_size(peak), - "loging_queue": qsize, + 'version': aprsd.__version__, + 'uptime': uptime, + 'callsign': CONF.callsign, + 'memory_current': int(current), + 'memory_current_str': utils.human_size(current), + 'memory_peak': int(peak), + 'memory_peak_str': utils.human_size(peak), + 'loging_queue': qsize, } return stats diff --git a/aprsd/threads/aprsd.py b/aprsd/threads/aprsd.py index 48a3621..77e60cf 100644 --- a/aprsd/threads/aprsd.py +++ b/aprsd/threads/aprsd.py @@ -107,7 +107,7 @@ class APRSDThreadList: 'name': th.name, 'class': th.__class__.__name__, 'alive': th.is_alive(), - 'age': th.loop_age(), + 'age': age, 'loop_count': th.loop_count, } return stats @@ -118,7 +118,9 @@ class APRSDThreadList: @wrapt.synchronized(lock) def remove(self, thread_obj): - self.threads_list.remove(thread_obj) + """Remove a thread from the list if it exists.""" + if thread_obj in self.threads_list: + self.threads_list.remove(thread_obj) @wrapt.synchronized(lock) def stop_all(self): diff --git a/aprsd/threads/keepalive.py b/aprsd/threads/keepalive.py index 5e259af..af552bd 100644 --- a/aprsd/threads/keepalive.py +++ b/aprsd/threads/keepalive.py @@ -13,7 +13,7 @@ from aprsd.threads import APRSDThread, APRSDThreadList from aprsd.utils import keepalive_collector CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') LOGU = logger @@ -23,8 +23,8 @@ class KeepAliveThread(APRSDThread): def __init__(self): tracemalloc.start() - super().__init__("KeepAlive") - max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0} + super().__init__('KeepAlive') + max_timeout = {'hours': 0.0, 'minutes': 2, 'seconds': 0} self.max_delta = datetime.timedelta(**max_timeout) def loop(self): @@ -35,58 +35,58 @@ class KeepAliveThread(APRSDThread): now = datetime.datetime.now() if ( - "APRSClientStats" in stats_json - and stats_json["APRSClientStats"].get("transport") == "aprsis" + 'APRSClientStats' in stats_json + and stats_json['APRSClientStats'].get('transport') == 'aprsis' ): - if stats_json["APRSClientStats"].get("server_keepalive"): + if stats_json['APRSClientStats'].get('server_keepalive'): last_msg_time = utils.strfdelta( - now - stats_json["APRSClientStats"]["server_keepalive"] + now - stats_json['APRSClientStats']['server_keepalive'] ) else: - last_msg_time = "N/A" + last_msg_time = 'N/A' else: - last_msg_time = "N/A" + last_msg_time = 'N/A' - tracked_packets = stats_json["PacketTrack"]["total_tracked"] + tracked_packets = stats_json['PacketTrack']['total_tracked'] tx_msg = 0 rx_msg = 0 - if "PacketList" in stats_json: - msg_packets = stats_json["PacketList"].get("MessagePacket") + if 'PacketList' in stats_json: + msg_packets = stats_json['PacketList'].get('MessagePacket') if msg_packets: - tx_msg = msg_packets.get("tx", 0) - rx_msg = msg_packets.get("rx", 0) + tx_msg = msg_packets.get('tx', 0) + rx_msg = msg_packets.get('rx', 0) keepalive = ( - "{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} " - "Last:{} - RAM Current:{} Peak:{} Threads:{} LoggingQueue:{}" + '{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} ' + 'Last:{} - RAM Current:{} Peak:{} Threads:{} LoggingQueue:{}' ).format( - stats_json["APRSDStats"]["callsign"], - stats_json["APRSDStats"]["uptime"], + stats_json['APRSDStats']['callsign'], + stats_json['APRSDStats']['uptime'], pl.total_rx(), pl.total_tx(), tracked_packets, tx_msg, rx_msg, last_msg_time, - stats_json["APRSDStats"]["memory_current_str"], - stats_json["APRSDStats"]["memory_peak_str"], + stats_json['APRSDStats']['memory_current_str'], + stats_json['APRSDStats']['memory_peak_str'], len(thread_list), aprsd_log.logging_queue.qsize(), ) LOG.info(keepalive) - if "APRSDThreadList" in stats_json: - thread_list = stats_json["APRSDThreadList"] + if 'APRSDThreadList' in stats_json: + thread_list = stats_json['APRSDThreadList'] for thread_name in thread_list: thread = thread_list[thread_name] - alive = thread["alive"] - age = thread["age"] - key = thread["name"] + alive = thread['alive'] + age = thread['age'] + key = thread['name'] if not alive: - LOG.error(f"Thread {thread}") + LOG.error(f'Thread {thread}') - thread_hex = f"fg {utils.hex_from_name(key)}" - t_name = f"<{thread_hex}>{key:<15}" - thread_msg = f"{t_name} Alive? {str(alive): <5} {str(age): <20}" + thread_hex = f'fg {utils.hex_from_name(key)}' + t_name = f'<{thread_hex}>{key:<15}' + 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}") diff --git a/aprsd/threads/registry.py b/aprsd/threads/registry.py index 97b4932..622bf42 100644 --- a/aprsd/threads/registry.py +++ b/aprsd/threads/registry.py @@ -8,7 +8,7 @@ import aprsd from aprsd import threads as aprsd_threads CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class APRSRegistryThread(aprsd_threads.APRSDThread): @@ -17,39 +17,39 @@ class APRSRegistryThread(aprsd_threads.APRSDThread): _loop_cnt: int = 1 def __init__(self): - super().__init__("APRSRegistryThread") + super().__init__('APRSRegistryThread') self._loop_cnt = 1 if not CONF.aprs_registry.enabled: LOG.error( - "APRS Registry is not enabled. ", + 'APRS Registry is not enabled. ', ) LOG.error( - "APRS Registry thread is STOPPING.", + 'APRS Registry thread is STOPPING.', ) self.stop() LOG.info( - "APRS Registry thread is running and will send " - f"info every {CONF.aprs_registry.frequency_seconds} seconds " - f"to {CONF.aprs_registry.registry_url}.", + 'APRS Registry thread is running and will send ' + f'info every {CONF.aprs_registry.frequency_seconds} seconds ' + f'to {CONF.aprs_registry.registry_url}.', ) def loop(self): # Only call the registry every N seconds if self._loop_cnt % CONF.aprs_registry.frequency_seconds == 0: info = { - "callsign": CONF.callsign, - "description": CONF.aprs_registry.description, - "service_website": CONF.aprs_registry.service_website, - "software": f"APRSD version {aprsd.__version__} " - "https://github.com/craigerl/aprsd", + 'callsign': CONF.callsign, + 'description': CONF.aprs_registry.description, + 'service_website': CONF.aprs_registry.service_website, + 'software': f'APRSD version {aprsd.__version__} ' + 'https://github.com/craigerl/aprsd', } try: requests.post( - f"{CONF.aprs_registry.registry_url}", + f'{CONF.aprs_registry.registry_url}', json=info, ) except Exception as e: - LOG.error(f"Failed to send registry info: {e}") + LOG.error(f'Failed to send registry info: {e}') time.sleep(1) self._loop_cnt += 1 diff --git a/aprsd/threads/rx.py b/aprsd/threads/rx.py index 9c46644..0878502 100644 --- a/aprsd/threads/rx.py +++ b/aprsd/threads/rx.py @@ -8,7 +8,7 @@ from oslo_config import cfg from aprsd import packets, plugin from aprsd.client.client import APRSDClient -from aprsd.packets import collector, filter +from aprsd.packets import collector, core, filter from aprsd.packets import log as packet_log from aprsd.threads import APRSDThread, tx @@ -17,12 +17,11 @@ LOG = logging.getLogger('APRSD') class APRSDRXThread(APRSDThread): - """Main Class to connect to an APRS Client and recieve packets. + """ + Thread to receive packets from the APRS Client and put them on the packet queue. - A packet is received in the main loop and then sent to the - process_packet method, which sends the packet through the collector - to track the packet for stats, and then put into the packet queue - for processing in a separate thread. + Args: + packet_queue: The queue to put the packets in. """ _client = None @@ -34,7 +33,12 @@ class APRSDRXThread(APRSDThread): pkt_count = 0 - def __init__(self, packet_queue): + def __init__(self, packet_queue: queue.Queue): + """Initialize the APRSDRXThread. + + Args: + packet_queue: The queue to put the packets in. + """ super().__init__('RX_PKT') self.packet_queue = packet_queue @@ -67,7 +71,7 @@ class APRSDRXThread(APRSDThread): # https://github.com/rossengeorgiev/aprs-python/pull/56 self._client.consumer( self.process_packet, - raw=False, + raw=True, ) except ( aprslib.exceptions.ConnectionDrop, @@ -87,63 +91,38 @@ class APRSDRXThread(APRSDThread): return True def process_packet(self, *args, **kwargs): - packet = self._client.decode_packet(*args, **kwargs) - if not packet: - LOG.error( - 'No packet received from decode_packet. Most likely a failure to parse' - ) + """Put the raw packet on the queue. + + The processing of the packet will happen in a separate thread. + """ + if not args: + LOG.warning('No frame received to process?!?!') return self.pkt_count += 1 - packet_log.log(packet, packet_count=self.pkt_count) - pkt_list = packets.PacketList() - - if isinstance(packet, packets.AckPacket): - # We don't need to drop AckPackets, those should be - # processed. - self.packet_queue.put(packet) - else: - # Make sure we aren't re-processing the same packet - # For RF based APRS Clients we can get duplicate packets - # So we need to track them and not process the dupes. - found = False - try: - # Find the packet in the list of already seen packets - # Based on the packet.key - found = pkt_list.find(packet) - if not packet.msgNo: - # If the packet doesn't have a message id - # then there is no reliable way to detect - # if it's a dupe, so we just pass it on. - # it shouldn't get acked either. - found = False - except KeyError: - found = False - - if not found: - # We haven't seen this packet before, so we process it. - collector.PacketCollector().rx(packet) - self.packet_queue.put(packet) - elif packet.timestamp - found.timestamp < CONF.packet_dupe_timeout: - # If the packet came in within N seconds of the - # Last time seeing the packet, then we drop it as a dupe. - LOG.warning( - f'Packet {packet.from_call}:{packet.msgNo} already tracked, dropping.' - ) - else: - LOG.warning( - f'Packet {packet.from_call}:{packet.msgNo} already tracked ' - f'but older than {CONF.packet_dupe_timeout} seconds. processing.', - ) - collector.PacketCollector().rx(packet) - self.packet_queue.put(packet) + self.packet_queue.put(args[0]) class APRSDFilterThread(APRSDThread): - def __init__(self, thread_name, packet_queue): + """ + Thread to filter packets on the packet queue. + Args: + thread_name: The name of the thread. + packet_queue: The queue to get the packets from. + """ + + def __init__(self, thread_name: str, packet_queue: queue.Queue): + """Initialize the APRSDFilterThread. + + Args: + thread_name: The name of the thread. + packet_queue: The queue to get the packets from. + """ super().__init__(thread_name) self.packet_queue = packet_queue + self.packet_count = 0 + self._client = APRSDClient() - def filter_packet(self, packet): + def filter_packet(self, packet: type[core.Packet]) -> type[core.Packet] | None: # Do any packet filtering prior to processing if not filter.PacketFilter().filter(packet): return None @@ -156,14 +135,27 @@ class APRSDFilterThread(APRSDThread): doesn't want to log packets. """ - packet_log.log(packet) + packet_log.log(packet, packet_count=self.packet_count) def loop(self): try: - packet = self.packet_queue.get(timeout=1) + pkt = self.packet_queue.get(timeout=1) + self.packet_count += 1 + # We use the client here, because the specific + # driver may need to decode the packet differently. + packet = self._client.decode_packet(pkt) + if not packet: + # We mark this as debug, since there are so many + # packets that are on the APRS network, and we don't + # want to spam the logs with this. + LOG.debug(f'Packet failed to parse. "{pkt}"') + return True self.print_packet(packet) if packet: if self.filter_packet(packet): + # The packet has passed all filters, so we collect it. + # and process it. + collector.PacketCollector().rx(packet) self.process_packet(packet) except queue.Empty: pass @@ -182,7 +174,7 @@ class APRSDProcessPacketThread(APRSDFilterThread): will ack a message before sending the packet to the subclass for processing.""" - def __init__(self, packet_queue): + def __init__(self, packet_queue: queue.Queue): super().__init__('ProcessPKT', packet_queue=packet_queue) if not CONF.enable_sending_ack_packets: LOG.warning( @@ -283,7 +275,10 @@ class APRSDProcessPacketThread(APRSDFilterThread): class APRSDPluginProcessPacketThread(APRSDProcessPacketThread): """Process the packet through the plugin manager. - This is the main aprsd server plugin processing thread.""" + This is the main aprsd server plugin processing thread. + Args: + packet_queue: The queue to get the packets from. + """ def process_other_packet(self, packet, for_us=False): pm = plugin.PluginManager() @@ -322,12 +317,16 @@ class APRSDPluginProcessPacketThread(APRSDProcessPacketThread): pm = plugin.PluginManager() try: - results = pm.run(packet) - replied = False + results, handled = pm.run(packet) + # Check if any plugin replied (results may be unordered due to parallel execution) + replied = any( + result and result is not packets.NULL_MESSAGE for result in results + ) + LOG.debug(f'Replied: {replied}, Handled: {handled}') for reply in results: + LOG.debug(f'Reply: {reply}') if isinstance(reply, list): # one of the plugins wants to send multiple messages - replied = True for subreply in reply: LOG.debug(f"Sending '{subreply}'") if isinstance(subreply, packets.Packet): @@ -343,13 +342,13 @@ class APRSDPluginProcessPacketThread(APRSDProcessPacketThread): elif isinstance(reply, packets.Packet): # We have a message based object. tx.send(reply) - replied = True else: - replied = True # A plugin can return a null message flag which signals # us that they processed the message correctly, but have # nothing to reply with, so we avoid replying with a # usage string + # Note: NULL_MESSAGE results are already filtered out + # in PluginManager.run(), so we can safely send this if reply is not packets.NULL_MESSAGE: LOG.debug(f"Sending '{reply}'") tx.send( @@ -362,7 +361,9 @@ class APRSDPluginProcessPacketThread(APRSDProcessPacketThread): # If the message was for us and we didn't have a # response, then we send a usage statement. - if to_call == CONF.callsign and not replied: + # Only send "Unknown command!" if no plugin handled the message. + # If a plugin returned NULL_MESSAGE, it handled it and we shouldn't reply. + if to_call == CONF.callsign and not replied and not handled: # Tailor the messages accordingly if CONF.load_help_plugin: LOG.warning('Sending help!') diff --git a/aprsd/threads/stats.py b/aprsd/threads/stats.py index 54d789a..290d3e3 100644 --- a/aprsd/threads/stats.py +++ b/aprsd/threads/stats.py @@ -1,19 +1,26 @@ import logging +import threading import time +from loguru import logger from oslo_config import cfg +from aprsd.packets import seen_list from aprsd.stats import collector from aprsd.threads import APRSDThread from aprsd.utils import objectstore CONF = cfg.CONF LOG = logging.getLogger('APRSD') +LOGU = logger class StatsStore(objectstore.ObjectStoreMixin): """Container to save the stats from the collector.""" + def __init__(self): + self.lock = threading.RLock() + def add(self, stats: dict): with self.lock: self.data = stats @@ -37,3 +44,109 @@ class APRSDStatsStoreThread(APRSDThread): time.sleep(1) return True + + +class StatsLogThread(APRSDThread): + """Log the stats from the PacketList.""" + + def __init__(self): + super().__init__('PacketStatsLog') + self._last_total_rx = 0 + self.period = 10 + self.start_time = time.time() + + def loop(self): + if self.loop_count % self.period == 0: + # log the stats every 10 seconds + stats_json = collector.Collector().collect(serializable=True) + stats = stats_json['PacketList'] + total_rx = stats['rx'] + rx_delta = total_rx - self._last_total_rx + rate = rx_delta / self.period + + # Get unique callsigns count from SeenList stats + seen_list_instance = seen_list.SeenList() + # stats() returns data while holding lock internally, so copy it immediately + seen_list_stats = seen_list_instance.stats() + seen_list_instance.save() + # Copy the stats to avoid holding references to locked data + seen_list_stats = seen_list_stats.copy() + unique_callsigns_count = len(seen_list_stats) + + # Calculate uptime + elapsed = time.time() - self.start_time + elapsed_minutes = elapsed / 60 + elapsed_hours = elapsed / 3600 + + # Log summary stats + LOGU.opt(colors=True).info( + f'RX Rate: {rate:.2f} pps ' + f'Total RX: {total_rx} ' + f'RX Last {self.period} secs: {rx_delta} ' + ) + LOGU.opt(colors=True).info( + f'Uptime: {elapsed:.0f}s ({elapsed_minutes:.1f}m / {elapsed_hours:.2f}h) ' + f'Unique Callsigns: {unique_callsigns_count}', + ) + self._last_total_rx = total_rx + + # Log individual type stats, sorted by RX count (descending) + sorted_types = sorted( + stats['types'].items(), key=lambda x: x[1]['rx'], reverse=True + ) + for k, v in sorted_types: + # Calculate percentage of this packet type compared to total RX + percentage = (v['rx'] / total_rx * 100) if total_rx > 0 else 0.0 + # Format values first, then apply colors + packet_type_str = f'{k:<15}' + rx_count_str = f'{v["rx"]:6d}' + tx_count_str = f'{v["tx"]:6d}' + percentage_str = f'{percentage:5.1f}%' + # Use different colors for RX count based on threshold (matching mqtt_injest.py) + rx_color_tag = ( + 'green' if v['rx'] > 100 else 'yellow' if v['rx'] > 10 else 'red' + ) + LOGU.opt(colors=True).info( + f' {packet_type_str}: ' + f'<{rx_color_tag}>RX: {rx_count_str} ' + f'TX: {tx_count_str} ' + f'({percentage_str})', + ) + + # Extract callsign counts from seen_list stats + callsign_counts = {} + for callsign, data in seen_list_stats.items(): + if isinstance(data, dict) and 'count' in data: + callsign_counts[callsign] = data['count'] + + # Sort callsigns by packet count (descending) and get top 10 + sorted_callsigns = sorted( + callsign_counts.items(), key=lambda x: x[1], reverse=True + )[:10] + + # Log top 10 callsigns + if sorted_callsigns: + LOGU.opt(colors=True).info( + 'Top 10 Callsigns by Packet Count:' + ) + total_ranks = len(sorted_callsigns) + for rank, (callsign, count) in enumerate(sorted_callsigns, 1): + # Calculate percentage of this callsign compared to total RX + percentage = (count / total_rx * 100) if total_rx > 0 else 0.0 + # Use different colors based on rank: most packets (rank 1) = red, + # least packets (last rank) = green, middle = yellow + if rank == 1: + count_color_tag = 'red' + elif rank == total_ranks: + count_color_tag = 'green' + else: + count_color_tag = 'yellow' + LOGU.opt(colors=True).info( + f' {rank:2d}. ' + f'{callsign:<12}: ' + f'<{count_color_tag}>{count:6d} packets ' + f'({percentage:5.1f}%)', + ) + + time.sleep(1) + return True diff --git a/aprsd/threads/tx.py b/aprsd/threads/tx.py index e3da259..e6d9ce6 100644 --- a/aprsd/threads/tx.py +++ b/aprsd/threads/tx.py @@ -1,6 +1,7 @@ import logging import threading import time +from concurrent.futures import ThreadPoolExecutor import wrapt from oslo_config import cfg @@ -39,6 +40,11 @@ msg_throttle_decorator = decorator.ThrottleDecorator(throttle=msg_t) ack_throttle_decorator = decorator.ThrottleDecorator(throttle=ack_t) s_lock = threading.Lock() +# Global scheduler instances (singletons) +_packet_scheduler = None +_ack_scheduler = None +_scheduler_lock = threading.Lock() + @wrapt.synchronized(s_lock) @msg_throttle_decorator.sleep_and_retry @@ -62,8 +68,15 @@ def send(packet: core.Packet, direct=False, aprs_client=None): @msg_throttle_decorator.sleep_and_retry def _send_packet(packet: core.Packet, direct=False, aprs_client=None): if not direct: - thread = SendPacketThread(packet=packet) - thread.start() + # Use threadpool scheduler instead of creating individual threads + scheduler = _get_packet_scheduler() + if scheduler and scheduler.is_alive(): + # Scheduler will handle the packet + pass + else: + # Fallback to old method if scheduler not available + thread = SendPacketThread(packet=packet) + thread.start() else: _send_direct(packet, aprs_client=aprs_client) @@ -71,12 +84,20 @@ def _send_packet(packet: core.Packet, direct=False, aprs_client=None): @ack_throttle_decorator.sleep_and_retry def _send_ack(packet: core.AckPacket, direct=False, aprs_client=None): if not direct: - thread = SendAckThread(packet=packet) - thread.start() + # Use threadpool scheduler instead of creating individual threads + scheduler = _get_ack_scheduler() + if scheduler and scheduler.is_alive(): + # Scheduler will handle the packet + pass + else: + # Fallback to old method if scheduler not available + thread = SendAckThread(packet=packet) + thread.start() else: _send_direct(packet, aprs_client=aprs_client) +@msg_throttle_decorator.sleep_and_retry def _send_direct(packet, aprs_client=None): if aprs_client: cl = aprs_client @@ -94,6 +115,220 @@ def _send_direct(packet, aprs_client=None): return True +def _get_packet_scheduler(): + """Get or create the packet send scheduler thread (singleton).""" + global _packet_scheduler + with _scheduler_lock: + if _packet_scheduler is None or not _packet_scheduler.is_alive(): + _packet_scheduler = PacketSendSchedulerThread() + _packet_scheduler.start() + return _packet_scheduler + + +def _get_ack_scheduler(): + """Get or create the ack send scheduler thread (singleton).""" + global _ack_scheduler + with _scheduler_lock: + if _ack_scheduler is None or not _ack_scheduler.is_alive(): + _ack_scheduler = AckSendSchedulerThread() + _ack_scheduler.start() + return _ack_scheduler + + +def _send_packet_worker(msg_no: str): + """Worker function for threadpool to send a packet. + + This function checks if the packet needs to be sent and sends it if conditions are met. + Returns True if packet should continue to be tracked, False if done. + """ + pkt_tracker = tracker.PacketTrack() + packet = pkt_tracker.get(msg_no) + + if not packet: + # Packet was acked and removed from tracker + return False + + if packet.send_count >= packet.retry_count: + # Reached max retry count + LOG.info( + f'{packet.__class__.__name__} ' + f'({packet.msgNo}) ' + 'Message Send Complete. Max attempts reached' + f' {packet.retry_count}', + ) + pkt_tracker.remove(packet.msgNo) + return False + + # Check if it's time to send + send_now = False + if packet.last_send_time: + now = int(round(time.time())) + sleeptime = (packet.send_count + 1) * 31 + delta = now - packet.last_send_time + if delta > sleeptime: + send_now = True + else: + send_now = True + + if send_now: + packet.last_send_time = int(round(time.time())) + sent = False + try: + sent = _send_direct(packet) + except Exception as ex: + LOG.error(f'Failed to send packet: {packet}') + LOG.error(ex) + else: + if sent: + packet.send_count += 1 + + return True + + +def _send_ack_worker(msg_no: str, max_retries: int): + """Worker function for threadpool to send an ack packet. + + This function checks if the ack needs to be sent and sends it if conditions are met. + Returns True if ack should continue to be tracked, False if done. + """ + pkt_tracker = tracker.PacketTrack() + packet = pkt_tracker.get(msg_no) + + if not packet: + # Packet was removed from tracker + return False + + if packet.send_count >= max_retries: + LOG.debug( + f'{packet.__class__.__name__}' + f'({packet.msgNo}) ' + 'Send Complete. Max attempts reached' + f' {max_retries}', + ) + return False + + # Check if it's time to send + send_now = False + if packet.last_send_time: + now = int(round(time.time())) + sleep_time = 31 + delta = now - packet.last_send_time + if delta > sleep_time: + send_now = True + else: + # No previous send time, send immediately + send_now = True + + if send_now: + sent = False + try: + sent = _send_direct(packet) + except Exception: + LOG.error(f'Failed to send packet: {packet}') + else: + if sent: + packet.send_count += 1 + packet.last_send_time = int(round(time.time())) + + return True + + +class PacketSendSchedulerThread(aprsd_threads.APRSDThread): + """Scheduler thread that uses a threadpool to send packets. + + This thread periodically checks all packets in PacketTrack and submits + send tasks to a threadpool executor, avoiding the need to create a + separate thread for each packet. + """ + + def __init__(self, max_workers=5): + super().__init__('PacketSendSchedulerThread') + self.executor = ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix='PacketSendWorker' + ) + self.max_workers = max_workers + + def loop(self): + """Check all tracked packets and submit send tasks to threadpool.""" + pkt_tracker = tracker.PacketTrack() + + # Check all packets in the tracker + for msg_no in list(pkt_tracker.keys()): + packet = pkt_tracker.get(msg_no) + if not packet: + # Packet was acked, skip it + continue + + # Skip AckPackets - they're handled by AckSendSchedulerThread + if isinstance(packet, core.AckPacket): + continue + + # Check if packet is still being tracked (not acked) + if packet.send_count >= packet.retry_count: + # Max retries reached, will be cleaned up by worker + continue + + # Submit send task to threadpool + # The worker will check timing and send if needed + self.executor.submit(_send_packet_worker, msg_no) + + time.sleep(1) # Check every second + return True + + def _cleanup(self): + """Cleanup threadpool executor on thread shutdown.""" + LOG.debug('Shutting down PacketSendSchedulerThread executor') + self.executor.shutdown(wait=True) + + +class AckSendSchedulerThread(aprsd_threads.APRSDThread): + """Scheduler thread that uses a threadpool to send ack packets. + + This thread periodically checks all ack packets in PacketTrack and submits + send tasks to a threadpool executor, avoiding the need to create a + separate thread for each ack. + """ + + def __init__(self, max_workers=3): + super().__init__('AckSendSchedulerThread') + self.executor = ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix='AckSendWorker' + ) + self.max_workers = max_workers + self.max_retries = CONF.default_ack_send_count + + def loop(self): + """Check all tracked ack packets and submit send tasks to threadpool.""" + pkt_tracker = tracker.PacketTrack() + + # Check all packets in the tracker that are acks + for msg_no in list(pkt_tracker.keys()): + packet = pkt_tracker.get(msg_no) + if not packet: + # Packet was removed, skip it + continue + + # Only process AckPackets + if not isinstance(packet, core.AckPacket): + continue + + # Check if ack is still being tracked + if packet.send_count >= self.max_retries: + # Max retries reached, will be cleaned up by worker + continue + + # Submit send task to threadpool + self.executor.submit(_send_ack_worker, msg_no, self.max_retries) + + time.sleep(1) # Check every second + return True + + def _cleanup(self): + """Cleanup threadpool executor on thread shutdown.""" + LOG.debug('Shutting down AckSendSchedulerThread executor') + self.executor.shutdown(wait=True) + + class SendPacketThread(aprsd_threads.APRSDThread): loop_count: int = 1 diff --git a/aprsd/utils/counter.py b/aprsd/utils/counter.py index 4bcc320..a29ab10 100644 --- a/aprsd/utils/counter.py +++ b/aprsd/utils/counter.py @@ -3,7 +3,6 @@ import threading import wrapt - MAX_PACKET_ID = 9999 diff --git a/aprsd/utils/fuzzyclock.py b/aprsd/utils/fuzzyclock.py index 19f105b..02bc9a2 100644 --- a/aprsd/utils/fuzzyclock.py +++ b/aprsd/utils/fuzzyclock.py @@ -26,42 +26,42 @@ def fuzzy(hour, minute, degree=1): When degree = 2, time is in quantum of 15 minutes.""" if degree <= 0 or degree > 2: - print("Please use a degree of 1 or 2. Using fuzziness degree=1") + print('Please use a degree of 1 or 2. Using fuzziness degree=1') degree = 1 begin = "It's " - f0 = "almost " - f1 = "exactly " - f2 = "around " + f0 = 'almost ' + f1 = 'exactly ' + f2 = 'around ' - b0 = " past " - b1 = " to " + b0 = ' past ' + b1 = ' to ' hourlist = ( - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven", - "Twelve", + 'One', + 'Two', + 'Three', + 'Four', + 'Five', + 'Six', + 'Seven', + 'Eight', + 'Nine', + 'Ten', + 'Eleven', + 'Twelve', ) - s1 = s2 = s3 = s4 = "" + s1 = s2 = s3 = s4 = '' base = 5 if degree == 1: base = 5 - val = ("Five", "Ten", "Quarter", "Twenty", "Twenty-Five", "Half") + val = ('Five', 'Ten', 'Quarter', 'Twenty', 'Twenty-Five', 'Half') elif degree == 2: base = 15 - val = ("Quarter", "Half") + val = ('Quarter', 'Half') # to find whether we have to use 'almost', 'exactly' or 'around' dmin = minute % base @@ -86,11 +86,11 @@ def fuzzy(hour, minute, degree=1): if minute <= base / 2: # Case like "It's around/exactly Ten" - s2 = s3 = "" + s2 = s3 = '' s4 = hourlist[hour - 12 - 1] elif minute >= 60 - base / 2: # Case like "It's almost Ten" - s2 = s3 = "" + s2 = s3 = '' s4 = hourlist[hour - 12] else: # Other cases with all words, like "It's around Quarter past One" @@ -114,22 +114,22 @@ def main(): try: deg = int(sys.argv[1]) except Exception: - print("Please use a degree of 1 or 2. Using fuzziness degree=1") + print('Please use a degree of 1 or 2. Using fuzziness degree=1') if len(sys.argv) >= 3: - tm = sys.argv[2].split(":") + tm = sys.argv[2].split(':') try: h = int(tm[0]) m = int(tm[1]) if h < 0 or h > 23 or m < 0 or m > 59: raise Exception except Exception: - print("Bad time entered. Using the system time.") + print('Bad time entered. Using the system time.') h = stm.tm_hour m = stm.tm_min print(fuzzy(h, m, deg)) return -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/aprsd/utils/json.py b/aprsd/utils/json.py index ebf5aca..f4ea480 100644 --- a/aprsd/utils/json.py +++ b/aprsd/utils/json.py @@ -10,40 +10,40 @@ class EnhancedJSONEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime.datetime): args = ( - "year", - "month", - "day", - "hour", - "minute", - "second", - "microsecond", + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'microsecond', ) return { - "__type__": "datetime.datetime", - "args": [getattr(obj, a) for a in args], + '__type__': 'datetime.datetime', + 'args': [getattr(obj, a) for a in args], } elif isinstance(obj, datetime.date): - args = ("year", "month", "day") + args = ('year', 'month', 'day') return { - "__type__": "datetime.date", - "args": [getattr(obj, a) for a in args], + '__type__': 'datetime.date', + 'args': [getattr(obj, a) for a in args], } elif isinstance(obj, datetime.time): - args = ("hour", "minute", "second", "microsecond") + args = ('hour', 'minute', 'second', 'microsecond') return { - "__type__": "datetime.time", - "args": [getattr(obj, a) for a in args], + '__type__': 'datetime.time', + 'args': [getattr(obj, a) for a in args], } elif isinstance(obj, datetime.timedelta): - args = ("days", "seconds", "microseconds") + args = ('days', 'seconds', 'microseconds') return { - "__type__": "datetime.timedelta", - "args": [getattr(obj, a) for a in args], + '__type__': 'datetime.timedelta', + 'args': [getattr(obj, a) for a in args], } elif isinstance(obj, decimal.Decimal): return { - "__type__": "decimal.Decimal", - "args": [str(obj)], + '__type__': 'decimal.Decimal', + 'args': [str(obj)], } else: return super().default(obj) @@ -76,10 +76,10 @@ class EnhancedJSONDecoder(json.JSONDecoder): ) def object_hook(self, d): - if "__type__" not in d: + if '__type__' not in d: return d o = sys.modules[__name__] - for e in d["__type__"].split("."): + for e in d['__type__'].split('.'): o = getattr(o, e) - args, kwargs = d.get("args", ()), d.get("kwargs", {}) + args, kwargs = d.get('args', ()), d.get('kwargs', {}) return o(*args, **kwargs) diff --git a/aprsd/utils/objectstore.py b/aprsd/utils/objectstore.py index be494f0..7c10cd4 100644 --- a/aprsd/utils/objectstore.py +++ b/aprsd/utils/objectstore.py @@ -2,7 +2,6 @@ import logging import os import pathlib import pickle -import threading from oslo_config import cfg @@ -25,8 +24,8 @@ class ObjectStoreMixin: aprsd server -f (flush) will wipe all saved objects. """ - def __init__(self): - self.lock = threading.RLock() + # Child class must create the lock. + lock = None def __len__(self): with self.lock: @@ -94,29 +93,31 @@ class ObjectStoreMixin: def load(self): if not CONF.enable_save: return - if os.path.exists(self._save_filename()): - try: - with open(self._save_filename(), 'rb') as fp: - raw = pickle.load(fp) - if raw: - self.data = raw - LOG.debug( - f'{self.__class__.__name__}::Loaded {len(self)} entries from disk.', - ) - else: - LOG.debug(f'{self.__class__.__name__}::No data to load.') - except (pickle.UnpicklingError, Exception) as ex: - LOG.error(f'Failed to UnPickle {self._save_filename()}') - LOG.error(ex) - self.data = {} - else: - LOG.debug(f'{self.__class__.__name__}::No save file found.') + with self.lock: + if os.path.exists(self._save_filename()): + try: + with open(self._save_filename(), 'rb') as fp: + raw = pickle.load(fp) + if raw: + self.data = raw + LOG.debug( + f'{self.__class__.__name__}::Loaded {len(self)} entries from disk.', + ) + else: + LOG.debug(f'{self.__class__.__name__}::No data to load.') + except (pickle.UnpicklingError, Exception) as ex: + LOG.error(f'Failed to UnPickle {self._save_filename()}') + LOG.error(ex) + self.data = {} + else: + LOG.debug(f'{self.__class__.__name__}::No save file found.') def flush(self): """Nuke the old pickle file that stored the old results from last aprsd run.""" if not CONF.enable_save: return - if os.path.exists(self._save_filename()): - pathlib.Path(self._save_filename()).unlink() with self.lock: - self.data = {} + if os.path.exists(self._save_filename()): + pathlib.Path(self._save_filename()).unlink() + with self.lock: + self.data = {} diff --git a/aprsd/utils/package.py b/aprsd/utils/package.py index b81b2ae..b2bf079 100644 --- a/aprsd/utils/package.py +++ b/aprsd/utils/package.py @@ -60,18 +60,27 @@ def get_module_info(package_name, module_name, module_path): 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__])}', - }, - ) + # Skip __init__.py files as they often have relative imports + # that don't work when imported directly via smuggle + if name == '__init__.py': + continue + try: + 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__])}', + }, + ) + except (ImportError, SyntaxError, AttributeError) as e: + # Skip files that can't be imported (relative imports, syntax errors, etc.) + LOG.debug(f'Could not import {path}/{name}: {e}') + continue return obj_list diff --git a/docker/bin/admin.sh b/docker/bin/admin.sh index fe87084..639ac9a 100755 --- a/docker/bin/admin.sh +++ b/docker/bin/admin.sh @@ -45,4 +45,4 @@ export COLUMNS=200 #exec uwsgi --http :8000 --gevent 1000 --http-websockets --master -w aprsd.wsgi --callable app #exec aprsd listen -c $APRSD_CONFIG --loglevel ${LOG_LEVEL} ${APRSD_LOAD_PLUGINS} ${APRSD_LISTEN_FILTER} # -uv run aprsd admin web -c $APRSD_CONFIG --loglevel ${LOG_LEVEL} +uv run aprsd admin web -c $APRSD_CONFIG --loglevel ${LOG_LEVEL} diff --git a/docs/source/configure.rst b/docs/source/configure.rst index 4dec077..658f1d8 100644 --- a/docs/source/configure.rst +++ b/docs/source/configure.rst @@ -157,10 +157,9 @@ Sample config file # useful for a direwolf KISS aprs connection only. (boolean value) #enabled = true - # APRS Username (string value) - #login = NOCALL + # The callsign in [DEFAULT] is used as the APRS-IS login. - # APRS Password Get the passcode for your callsign here: + # APRS Password for the callsign in [DEFAULT]. Get the passcode here: # https://apps.magicbug.co.uk/passcode (string value) #password = diff --git a/docs/source/server.rst b/docs/source/server.rst index e9659d0..1f42486 100644 --- a/docs/source/server.rst +++ b/docs/source/server.rst @@ -126,7 +126,6 @@ on creating your own plugins. 2025-12-10 14:30:05.259 | MainThread | DEBUG | aprs_registry.service_website = None | oslo_config.cfg:log_opt_values:2824 2025-12-10 14:30:05.259 | MainThread | DEBUG | aprs_network.enabled = True | oslo_config.cfg:log_opt_values:2824 2025-12-10 14:30:05.260 | MainThread | DEBUG | aprs_network.host = 155.138.131.1 | oslo_config.cfg:log_opt_values:2824 - 2025-12-10 14:30:05.260 | MainThread | DEBUG | aprs_network.login = WB4BOR-1 | oslo_config.cfg:log_opt_values:2824 2025-12-10 14:30:05.260 | MainThread | DEBUG | aprs_network.password = **** | oslo_config.cfg:log_opt_values:2824 2025-12-10 14:30:05.260 | MainThread | DEBUG | aprs_network.port = 14580 | oslo_config.cfg:log_opt_values:2824 2025-12-10 14:30:05.260 | MainThread | DEBUG | kiss_serial.baudrate = 9600 | oslo_config.cfg:log_opt_values:2824 diff --git a/examples/plugins/example_plugin.py b/examples/plugins/example_plugin.py index 8ca5e0a..3cf115d 100644 --- a/examples/plugins/example_plugin.py +++ b/examples/plugins/example_plugin.py @@ -2,18 +2,18 @@ import logging from aprsd import packets, plugin -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger('APRSD') class HelloPlugin(plugin.APRSDRegexCommandPluginBase): """Hello World.""" - version = "1.0" + version = '1.0' # matches any string starting with h or H - command_regex = "^[hH]" - command_name = "hello" + command_regex = '^[hH]' + command_name = 'hello' def process(self, packet: packets.MessagePacket): - LOG.info("HelloPlugin") + LOG.info('HelloPlugin') reply = f"Hello '{packet.from_call}'" return reply diff --git a/pyproject.toml b/pyproject.toml index 4690dba..b71dd0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,8 @@ classifiers = [ [tool.setuptools.dynamic] dependencies = {file = ["./requirements.txt"]} optional-dependencies.dev = {file = ["./requirements-dev.txt"]} +optional-dependencies.tests = {file = ["./requirements-tests.txt"]} +optional-dependencies.type = {file = ["./requirements-type.txt"]} # List additional groups of dependencies here (e.g. development # dependencies). Users will be able to install these using the "extras" diff --git a/requirements-dev.in b/requirements-dev.in index af0d94c..348da17 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -2,9 +2,24 @@ build pip pip-tools pre-commit +pre-commit-uv>=4.1.1 tox +tox-uv wheel +# Testing +pytest +pytest-cov + +# Linting and formatting +ruff + +# Type checking +mypy +types-pytz +types-requests +types-tzlocal + # Twine is used for uploading packages to pypi # but it induces an install of cryptography # This is sucky for rpi systems. diff --git a/requirements-dev.txt b/requirements-dev.txt index fa49431..9f54a27 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,22 +6,40 @@ cfgv==3.5.0 # via pre-commit chardet==5.2.0 # via tox click==8.3.1 # via pip-tools colorama==0.4.6 # via tox +coverage==7.13.1 # via pytest-cov distlib==0.4.0 # via virtualenv +exceptiongroup==1.3.1 # via pytest filelock==3.20.0 # via tox, virtualenv identify==2.6.15 # via pre-commit +iniconfig==2.3.0 # via pytest +librt==0.7.8 # via mypy +mypy==1.19.1 # via -r requirements-dev.in +mypy-extensions==1.1.0 # via mypy nodeenv==1.9.1 # via pre-commit -packaging==25.0 # via build, pyproject-api, tox +packaging==25.0 # via build, pyproject-api, pytest, tox, tox-uv +pathspec==1.0.3 # via mypy pip==25.3 # via pip-tools, -r requirements-dev.in pip-tools==7.5.2 # via -r requirements-dev.in platformdirs==4.5.1 # via tox, virtualenv -pluggy==1.6.0 # via tox -pre-commit==4.5.0 # via -r requirements-dev.in +pluggy==1.6.0 # via pytest, pytest-cov, tox +pre-commit==4.5.0 # via pre-commit-uv, -r requirements-dev.in +pre-commit-uv==4.2.0 # via -r requirements-dev.in +pygments==2.19.2 # via pytest pyproject-api==1.10.0 # via tox pyproject-hooks==1.2.0 # via build, pip-tools +pytest==9.0.2 # via pytest-cov, -r requirements-dev.in +pytest-cov==7.0.0 # via -r requirements-dev.in pyyaml==6.0.3 # via pre-commit +ruff==0.14.13 # via -r requirements-dev.in setuptools==80.9.0 # via pip-tools -tomli==2.3.0 # via build, pip-tools, pyproject-api, tox -tox==4.32.0 # via -r requirements-dev.in -typing-extensions==4.15.0 # via tox, virtualenv +tomli==2.4.0 # via build, coverage, mypy, pip-tools, pyproject-api, pytest, tox, tox-uv +tox==4.32.0 # via tox-uv, -r requirements-dev.in +tox-uv==1.29.0 # via -r requirements-dev.in +types-pytz==2025.2.0.20251108 # via types-tzlocal, -r requirements-dev.in +types-requests==2.32.4.20260107 # via -r requirements-dev.in +types-tzlocal==5.1.0.1 # via -r requirements-dev.in +typing-extensions==4.15.0 # via exceptiongroup, mypy, tox, virtualenv +urllib3==2.6.2 # via types-requests +uv==0.9.26 # via pre-commit-uv, tox-uv virtualenv==20.35.4 # via pre-commit, tox wheel==0.45.1 # via pip-tools, -r requirements-dev.in diff --git a/requirements.in b/requirements.in index 1357ea9..12f9e89 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,5 @@ -aprslib>=0.7.0 +#aprslib>=0.7.0 +git+https://github.com/hemna/aprs-python.git@telemetry click dataclasses-json haversine diff --git a/requirements.txt b/requirements.txt index 63f113e..3574e58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # uv pip compile --resolver backtracking --annotation-style=line requirements.in -o requirements.txt -aprslib==0.7.2 # via -r requirements.in +aprslib @ git+https://github.com/hemna/aprs-python.git@09cd7a2829a2e9d28ee1566881c843cc4769e590 # via -r requirements.in attrs==25.4.0 # via ax253, kiss3, rush ax253==0.1.5.post1 # via kiss3 bitarray==3.8.0 # via ax253, kiss3 diff --git a/setup.py b/setup.py index eaca044..e903d2e 100644 --- a/setup.py +++ b/setup.py @@ -14,5 +14,4 @@ # THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT import setuptools - setuptools.setup() diff --git a/tests/client/drivers/test_aprsis_driver.py b/tests/client/drivers/test_aprsis_driver.py index 42a5910..d024d4d 100644 --- a/tests/client/drivers/test_aprsis_driver.py +++ b/tests/client/drivers/test_aprsis_driver.py @@ -18,9 +18,9 @@ class TestAPRSISDriver(unittest.TestCase): self.conf_patcher = mock.patch('aprsd.client.drivers.aprsis.CONF') self.mock_conf = self.conf_patcher.start() - # Configure APRS-IS settings + # Configure APRS-IS settings. callsign in [DEFAULT] is used as APRS-IS login. self.mock_conf.aprs_network.enabled = True - self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.callsign = 'TEST' self.mock_conf.aprs_network.password = '12345' self.mock_conf.aprs_network.host = 'rotate.aprs.net' self.mock_conf.aprs_network.port = 14580 @@ -97,16 +97,26 @@ class TestAPRSISDriver(unittest.TestCase): def test_is_configured_true(self): """Test is_configured returns True when properly configured.""" with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): - self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.callsign = 'TEST' self.mock_conf.aprs_network.password = '12345' self.mock_conf.aprs_network.host = 'rotate.aprs.net' self.assertTrue(APRSISDriver.is_configured()) - def test_is_configured_no_login(self): - """Test is_configured raises exception when login not set.""" + def test_is_configured_no_callsign(self): + """Test is_configured raises exception when callsign not set or NOCALL.""" with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): - self.mock_conf.aprs_network.login = None + self.mock_conf.callsign = None + + with self.assertRaises(exception.MissingConfigOptionException): + APRSISDriver.is_configured() + + def test_is_configured_callsign_nocall(self): + """Test is_configured raises exception when callsign is NOCALL.""" + with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): + self.mock_conf.callsign = 'NOCALL' + self.mock_conf.aprs_network.password = '12345' + self.mock_conf.aprs_network.host = 'rotate.aprs.net' with self.assertRaises(exception.MissingConfigOptionException): APRSISDriver.is_configured() @@ -114,7 +124,7 @@ class TestAPRSISDriver(unittest.TestCase): def test_is_configured_no_password(self): """Test is_configured raises exception when password not set.""" with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): - self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.callsign = 'TEST' self.mock_conf.aprs_network.password = None with self.assertRaises(exception.MissingConfigOptionException): @@ -123,7 +133,7 @@ class TestAPRSISDriver(unittest.TestCase): def test_is_configured_no_host(self): """Test is_configured raises exception when host not set.""" with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True): - self.mock_conf.aprs_network.login = 'TEST' + self.mock_conf.callsign = 'TEST' self.mock_conf.aprs_network.password = '12345' self.mock_conf.aprs_network.host = None @@ -197,9 +207,9 @@ class TestAPRSISDriver(unittest.TestCase): self.driver.setup_connection() - # Check client created with correct parameters + # Check client created with correct parameters (callsign is APRS-IS login) self.mock_aprslib.assert_called_once_with( - self.mock_conf.aprs_network.login, + self.mock_conf.callsign, passwd=self.mock_conf.aprs_network.password, host=self.mock_conf.aprs_network.host, port=self.mock_conf.aprs_network.port, diff --git a/tests/client/drivers/test_kiss_common.py b/tests/client/drivers/test_kiss_common.py index f62ce4c..ca6b88f 100644 --- a/tests/client/drivers/test_kiss_common.py +++ b/tests/client/drivers/test_kiss_common.py @@ -112,7 +112,7 @@ class TestKISSDriver(unittest.TestCase): mock_parse.return_value = mock_aprs_data mock_factory.return_value = mock_packet - result = self.driver.decode_packet(frame=frame) + result = self.driver.decode_packet(frame) self.assertEqual(result, mock_packet) mock_parse.assert_called_with(str(frame)) @@ -131,7 +131,7 @@ class TestKISSDriver(unittest.TestCase): mock_parse.side_effect = Exception('Parse error') with mock.patch('aprsd.client.drivers.kiss_common.LOG') as mock_log: - result = self.driver.decode_packet(frame=frame) + result = self.driver.decode_packet(frame) self.assertIsNone(result) mock_log.error.assert_called() @@ -154,7 +154,7 @@ class TestKISSDriver(unittest.TestCase): mock_parse.return_value = mock_aprs_data mock_factory.return_value = third_party - result = self.driver.decode_packet(frame=frame) + result = self.driver.decode_packet(frame) self.assertEqual(result, third_party.subpacket) def test_consumer_not_connected(self): diff --git a/tests/client/drivers/test_tcpkiss_driver.py b/tests/client/drivers/test_tcpkiss_driver.py index bd91c3e..d040b77 100644 --- a/tests/client/drivers/test_tcpkiss_driver.py +++ b/tests/client/drivers/test_tcpkiss_driver.py @@ -339,7 +339,7 @@ class TestTCPKISSDriver(unittest.TestCase): with mock.patch( 'aprsd.client.drivers.tcpkiss.core.factory', return_value=mock_packet ) as mock_factory: - result = self.driver.decode_packet(frame=mock_frame) + result = self.driver.decode_packet(mock_frame) mock_parse.assert_called_once_with(str(mock_frame)) mock_factory.assert_called_once_with(mock_aprs_data) @@ -362,7 +362,7 @@ class TestTCPKISSDriver(unittest.TestCase): 'aprsd.client.drivers.kiss_common.aprslib.parse', side_effect=Exception('Test error'), ) as mock_parse: - result = self.driver.decode_packet(frame=mock_frame) + result = self.driver.decode_packet(mock_frame) mock_parse.assert_called_once() self.assertIsNone(result) @@ -389,7 +389,7 @@ class TestTCPKISSDriver(unittest.TestCase): self.driver.consumer(mock_callback) mock_read_frame.assert_called_once() - mock_callback.assert_called_once_with(frame=mock_frame) + mock_callback.assert_called_once_with(mock_frame) @mock.patch('aprsd.client.drivers.tcpkiss.LOG') def test_read_frame_success(self, mock_log): diff --git a/tests/client/test_registry.py b/tests/client/test_registry.py index 3b6450c..1efbd0e 100644 --- a/tests/client/test_registry.py +++ b/tests/client/test_registry.py @@ -26,11 +26,11 @@ class TestDriverRegistry(unittest.TestCase): mock_instance.is_enabled.return_value = False mock_instance.is_configured.return_value = False - # Mock CONF to prevent password check + # Mock CONF to prevent password/callsign check self.conf_patcher = mock.patch('aprsd.client.drivers.aprsis.CONF') mock_conf = self.conf_patcher.start() mock_conf.aprs_network.password = 'dummy' - mock_conf.aprs_network.login = 'dummy' + mock_conf.callsign = 'dummy' # Patch the register method to skip Protocol check for MockClientDriver self._original_register = self.registry.register diff --git a/tests/cmds/test_send_message.py b/tests/cmds/test_send_message.py index afda63d..749591f 100644 --- a/tests/cmds/test_send_message.py +++ b/tests/cmds/test_send_message.py @@ -5,14 +5,13 @@ from unittest import mock from click.testing import CliRunner from oslo_config import cfg -from aprsd import conf # noqa : F401 from aprsd.cmds import send_message # noqa from aprsd.main import cli from .. import fake CONF = cfg.CONF -F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +F = t.TypeVar('F', bound=t.Callable[..., t.Any]) class TestSendMessageCommand(unittest.TestCase): @@ -21,44 +20,44 @@ class TestSendMessageCommand(unittest.TestCase): CONF.trace_enabled = False CONF.watch_list.packet_keep_count = 1 if login: - CONF.aprs_network.login = login + CONF.callsign = login if password: CONF.aprs_network.password = password # CONF.aprsd_admin_extension.user = "admin" # CONF.aprsd_admin_extension.password = "password" - @mock.patch("aprsd.log.log.setup_logging") + @mock.patch('aprsd.log.log.setup_logging') def test_no_tocallsign(self, mock_logging): """Make sure we get an error if there is no tocallsign.""" self.config_and_init( - login="something", - password="another", + login='something', + password='another', ) runner = CliRunner() result = runner.invoke( cli, - ["send-message"], + ['send-message'], catch_exceptions=False, ) assert result.exit_code == 2 assert "Error: Missing argument 'TOCALLSIGN'" in result.output - @mock.patch("aprsd.log.log.setup_logging") + @mock.patch('aprsd.log.log.setup_logging') def test_no_command(self, mock_logging): """Make sure we get an error if there is no command.""" self.config_and_init( - login="something", - password="another", + login='something', + password='another', ) runner = CliRunner() result = runner.invoke( cli, - ["send-message", "WB4BOR"], + ['send-message', 'WB4BOR'], catch_exceptions=False, ) assert result.exit_code == 2 diff --git a/tests/fake.py b/tests/fake.py index fd3e592..6c78a82 100644 --- a/tests/fake.py +++ b/tests/fake.py @@ -1,9 +1,9 @@ from aprsd import plugin, threads from aprsd.packets import core -FAKE_MESSAGE_TEXT = "fake MeSSage" -FAKE_FROM_CALLSIGN = "KFAKE" -FAKE_TO_CALLSIGN = "KMINE" +FAKE_MESSAGE_TEXT = 'fake MeSSage' +FAKE_FROM_CALLSIGN = 'KFAKE' +FAKE_TO_CALLSIGN = 'KMINE' def fake_packet( @@ -15,22 +15,40 @@ def fake_packet( response=None, ): packet_dict = { - "from": fromcall, - "addresse": tocall, - "to": tocall, - "format": message_format, - "raw": "", + 'from': fromcall, + 'addresse': tocall, + 'to': tocall, + 'format': message_format, + 'raw': '', } if message: - packet_dict["message_text"] = message + packet_dict['message_text'] = message if msg_number: - packet_dict["msgNo"] = str(msg_number) + packet_dict['msgNo'] = str(msg_number) if response: - packet_dict["response"] = response + packet_dict['response'] = response - return core.factory(packet_dict) + packet = core.factory(packet_dict) + # Call prepare to build the raw data + packet.prepare() + return packet + + +def fake_gps_packet(): + """Create a properly prepared GPSPacket for testing.""" + packet = core.GPSPacket( + from_call=FAKE_FROM_CALLSIGN, + to_call=FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='>', + comment='Test GPS comment', + ) + # Call prepare to build the raw data + packet.prepare() + return packet def fake_ack_packet(): @@ -41,7 +59,7 @@ def fake_ack_packet(): class FakeBaseNoThreadsPlugin(plugin.APRSDPluginBase): - version = "1.0" + version = '1.0' def setup(self): self.enabled = True @@ -50,19 +68,19 @@ class FakeBaseNoThreadsPlugin(plugin.APRSDPluginBase): return None def process(self, packet): - return "process" + return 'process' class FakeThread(threads.APRSDThread): def __init__(self): - super().__init__("FakeThread") + super().__init__('FakeThread') def loop(self): return False class FakeBaseThreadsPlugin(plugin.APRSDPluginBase): - version = "1.0" + version = '1.0' def setup(self): self.enabled = True @@ -71,16 +89,16 @@ class FakeBaseThreadsPlugin(plugin.APRSDPluginBase): return None def process(self, packet): - return "process" + return 'process' def create_threads(self): return FakeThread() class FakeRegexCommandPlugin(plugin.APRSDRegexCommandPluginBase): - version = "1.0" - command_regex = "^[fF]" - command_name = "fake" + version = '1.0' + command_regex = '^[fF]' + command_name = 'fake' def process(self, packet): return FAKE_MESSAGE_TEXT diff --git a/tests/packets/filters/test_dupe_filter.py b/tests/packets/filters/test_dupe_filter.py index 7997319..54691d2 100644 --- a/tests/packets/filters/test_dupe_filter.py +++ b/tests/packets/filters/test_dupe_filter.py @@ -80,19 +80,16 @@ class TestDupePacketFilter(unittest.TestCase): packet.processed = True packet.timestamp = 1000 - with mock.patch( - 'aprsd.packets.filters.dupe_filter.packets.PacketList' - ) as mock_list: - mock_list_instance = mock.MagicMock() - found_packet = fake.fake_packet(msg_number='123') - found_packet.timestamp = 1050 # Within 60 second timeout - mock_list_instance.find.return_value = found_packet - mock_list.return_value = mock_list_instance + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet(msg_number='123') + found_packet.timestamp = 1050 # Within 60 second timeout + mock_list_instance.find.return_value = found_packet + self.filter.pl = mock_list_instance - with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log: - result = self.filter.filter(packet) - self.assertIsNone(result) # Should be dropped - mock_log.warning.assert_called() + with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log: + result = self.filter.filter(packet) + self.assertIsNone(result) # Should be dropped + mock_log.warning.assert_called() def test_filter_duplicate_after_timeout(self): """Test filter() with duplicate after timeout.""" @@ -105,16 +102,13 @@ class TestDupePacketFilter(unittest.TestCase): packet.processed = True packet.timestamp = 2000 - with mock.patch( - 'aprsd.packets.filters.dupe_filter.packets.PacketList' - ) as mock_list: - mock_list_instance = mock.MagicMock() - found_packet = fake.fake_packet(msg_number='123') - found_packet.timestamp = 1000 # More than 60 seconds ago - mock_list_instance.find.return_value = found_packet - mock_list.return_value = mock_list_instance + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet(msg_number='123') + found_packet.timestamp = 1000 # More than 60 seconds ago + mock_list_instance.find.return_value = found_packet + self.filter.pl = mock_list_instance - with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log: - result = self.filter.filter(packet) - self.assertEqual(result, packet) # Should pass - mock_log.warning.assert_called() + with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log: + result = self.filter.filter(packet) + self.assertEqual(result, packet) # Should pass + mock_log.warning.assert_called() diff --git a/tests/packets/test_ack_packet.py b/tests/packets/test_ack_packet.py new file mode 100644 index 0000000..e5f39f9 --- /dev/null +++ b/tests/packets/test_ack_packet.py @@ -0,0 +1,76 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestAckPacket(unittest.TestCase): + """Test AckPacket JSON serialization.""" + + def test_ack_packet_to_json(self): + """Test AckPacket.to_json() method.""" + packet = packets.AckPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo='123', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'AckPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['msgNo'], '123') + + def test_ack_packet_from_dict(self): + """Test AckPacket.from_dict() method.""" + packet_dict = { + '_type': 'AckPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'msgNo': '123', + } + packet = packets.AckPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.AckPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.msgNo, '123') + + def test_ack_packet_round_trip(self): + """Test AckPacket round-trip: to_json -> from_dict.""" + original = packets.AckPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo='123', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.AckPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.msgNo, original.msgNo) + self.assertEqual(restored._type, original._type) + + def test_ack_packet_from_raw_string(self): + """Test AckPacket creation from raw APRS string.""" + packet_raw = 'KFAKE>APZ100::KMINE :ack123' + packet_dict = aprslib.parse(packet_raw) + # aprslib might not set format/response correctly, so set them manually + packet_dict['format'] = 'message' + packet_dict['response'] = 'ack' + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.AckPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'AckPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.AckPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.to_call, packet.to_call) + self.assertEqual(restored.msgNo, packet.msgNo) diff --git a/tests/packets/test_beacon_packet.py b/tests/packets/test_beacon_packet.py new file mode 100644 index 0000000..e49c0d5 --- /dev/null +++ b/tests/packets/test_beacon_packet.py @@ -0,0 +1,98 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestBeaconPacket(unittest.TestCase): + """Test BeaconPacket JSON serialization.""" + + def test_beacon_packet_to_json(self): + """Test BeaconPacket.to_json() method.""" + packet = packets.BeaconPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='>', + symbol_table='/', + comment='Test beacon comment', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'BeaconPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['latitude'], 37.7749) + self.assertEqual(json_dict['longitude'], -122.4194) + self.assertEqual(json_dict['symbol'], '>') + self.assertEqual(json_dict['symbol_table'], '/') + self.assertEqual(json_dict['comment'], 'Test beacon comment') + + def test_beacon_packet_from_dict(self): + """Test BeaconPacket.from_dict() method.""" + packet_dict = { + '_type': 'BeaconPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'latitude': 37.7749, + 'longitude': -122.4194, + 'symbol': '>', + 'symbol_table': '/', + 'comment': 'Test beacon comment', + } + packet = packets.BeaconPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.BeaconPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.latitude, 37.7749) + self.assertEqual(packet.longitude, -122.4194) + self.assertEqual(packet.symbol, '>') + self.assertEqual(packet.symbol_table, '/') + self.assertEqual(packet.comment, 'Test beacon comment') + + def test_beacon_packet_round_trip(self): + """Test BeaconPacket round-trip: to_json -> from_dict.""" + original = packets.BeaconPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='>', + symbol_table='/', + comment='Test beacon comment', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.BeaconPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.latitude, original.latitude) + self.assertEqual(restored.longitude, original.longitude) + self.assertEqual(restored.symbol, original.symbol) + self.assertEqual(restored.symbol_table, original.symbol_table) + self.assertEqual(restored.comment, original.comment) + self.assertEqual(restored._type, original._type) + + def test_beacon_packet_from_raw_string(self): + """Test BeaconPacket creation from raw APRS string.""" + # Use a format that aprslib can parse correctly + packet_raw = 'kd8mey-10>APRS,TCPIP*,qAC,T2SYDNEY:=4247.80N/08539.00WrPHG1210/Making 220 Great Again Allstar# 552191' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.BeaconPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'BeaconPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.BeaconPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.latitude, packet.latitude) + self.assertEqual(restored.longitude, packet.longitude) diff --git a/tests/packets/test_bulletin_packet.py b/tests/packets/test_bulletin_packet.py new file mode 100644 index 0000000..bf8f893 --- /dev/null +++ b/tests/packets/test_bulletin_packet.py @@ -0,0 +1,75 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestBulletinPacket(unittest.TestCase): + """Test BulletinPacket JSON serialization.""" + + def test_bulletin_packet_to_json(self): + """Test BulletinPacket.to_json() method.""" + packet = packets.BulletinPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + message_text='Test bulletin message', + bid='1', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'BulletinPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['message_text'], 'Test bulletin message') + self.assertEqual(json_dict['bid'], '1') + + def test_bulletin_packet_from_dict(self): + """Test BulletinPacket.from_dict() method.""" + packet_dict = { + '_type': 'BulletinPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'message_text': 'Test bulletin message', + 'bid': '1', + } + packet = packets.BulletinPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.BulletinPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.message_text, 'Test bulletin message') + self.assertEqual(packet.bid, '1') + + def test_bulletin_packet_round_trip(self): + """Test BulletinPacket round-trip: to_json -> from_dict.""" + original = packets.BulletinPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + message_text='Test bulletin message', + bid='1', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.BulletinPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.message_text, original.message_text) + self.assertEqual(restored.bid, original.bid) + self.assertEqual(restored._type, original._type) + + def test_bulletin_packet_from_raw_string(self): + """Test BulletinPacket creation from raw APRS string.""" + packet_raw = 'KFAKE>APZ100::BLN1 :Test bulletin message' + packet_dict = aprslib.parse(packet_raw) + # aprslib might not set format correctly, so set it manually + packet_dict['format'] = 'bulletin' + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.BulletinPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'BulletinPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.BulletinPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.message_text, packet.message_text) + self.assertEqual(restored.bid, packet.bid) diff --git a/tests/packets/test_gps_packet.py b/tests/packets/test_gps_packet.py new file mode 100644 index 0000000..8af8bbb --- /dev/null +++ b/tests/packets/test_gps_packet.py @@ -0,0 +1,109 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestGPSPacket(unittest.TestCase): + """Test GPSPacket JSON serialization.""" + + def test_gps_packet_to_json(self): + """Test GPSPacket.to_json() method.""" + packet = packets.GPSPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + altitude=100.0, + symbol='>', + symbol_table='/', + comment='Test GPS comment', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'GPSPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['latitude'], 37.7749) + self.assertEqual(json_dict['longitude'], -122.4194) + self.assertEqual(json_dict['altitude'], 100.0) + self.assertEqual(json_dict['symbol'], '>') + self.assertEqual(json_dict['symbol_table'], '/') + self.assertEqual(json_dict['comment'], 'Test GPS comment') + + def test_gps_packet_from_dict(self): + """Test GPSPacket.from_dict() method.""" + packet_dict = { + '_type': 'GPSPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'latitude': 37.7749, + 'longitude': -122.4194, + 'altitude': 100.0, + 'symbol': '>', + 'symbol_table': '/', + 'comment': 'Test GPS comment', + } + packet = packets.GPSPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.GPSPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.latitude, 37.7749) + self.assertEqual(packet.longitude, -122.4194) + self.assertEqual(packet.altitude, 100.0) + self.assertEqual(packet.symbol, '>') + self.assertEqual(packet.symbol_table, '/') + self.assertEqual(packet.comment, 'Test GPS comment') + + def test_gps_packet_round_trip(self): + """Test GPSPacket round-trip: to_json -> from_dict.""" + original = packets.GPSPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + altitude=100.0, + symbol='>', + symbol_table='/', + comment='Test GPS comment', + speed=25.5, + course=180, + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.GPSPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.latitude, original.latitude) + self.assertEqual(restored.longitude, original.longitude) + self.assertEqual(restored.altitude, original.altitude) + self.assertEqual(restored.symbol, original.symbol) + self.assertEqual(restored.symbol_table, original.symbol_table) + self.assertEqual(restored.comment, original.comment) + self.assertEqual(restored.speed, original.speed) + self.assertEqual(restored.course, original.course) + self.assertEqual(restored._type, original._type) + + def test_gps_packet_from_raw_string(self): + """Test GPSPacket creation from raw APRS string.""" + packet_raw = 'KFAKE>APZ100,WIDE2-1:!3742.00N/12225.00W>Test GPS comment' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + # GPS packets are typically created as BeaconPacket or other types + # but we can test if it has GPS data + self.assertIsNotNone(packet) + if hasattr(packet, 'latitude') and hasattr(packet, 'longitude'): + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertIn('latitude', json_dict) + self.assertIn('longitude', json_dict) + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertEqual(restored.latitude, packet.latitude) + self.assertEqual(restored.longitude, packet.longitude) diff --git a/tests/packets/test_log.py b/tests/packets/test_log.py new file mode 100644 index 0000000..a5bc643 --- /dev/null +++ b/tests/packets/test_log.py @@ -0,0 +1,208 @@ +import unittest +from unittest import mock + +from aprsd import packets +from aprsd.packets import log +from tests import fake + + +class TestPacketLog(unittest.TestCase): + """Unit tests for the packet logging functions.""" + + def setUp(self): + """Set up test fixtures.""" + # Mock the logging to avoid actual log output during tests + self.loguru_opt_mock = mock.patch('aprsd.packets.log.LOGU.opt').start() + self.loguru_info_mock = self.loguru_opt_mock.return_value.info + self.logging_mock = mock.patch('aprsd.packets.log.LOG').start() + self.haversine_mock = mock.patch('aprsd.packets.log.haversine').start() + self.utils_mock = mock.patch('aprsd.packets.log.utils').start() + self.conf_mock = mock.patch('aprsd.packets.log.CONF').start() + + # Set default configuration values + self.conf_mock.enable_packet_logging = True + self.conf_mock.log_packet_format = ( + 'multiline' # Changed from 'compact' to 'multiline' + ) + self.conf_mock.default_ack_send_count = 3 + self.conf_mock.default_packet_send_count = 5 + self.conf_mock.latitude = 37.7749 + self.conf_mock.longitude = -122.4194 + + # Set up the utils mock methods + self.utils_mock.calculate_initial_compass_bearing.return_value = 45.0 + self.utils_mock.degrees_to_cardinal.return_value = 'NE' + self.haversine_mock.return_value = 10.5 + + # No need to mock packet.raw since we create real packets with raw data + # The packet objects created in tests will have their raw attribute set properly + + def tearDown(self): + """Clean up after tests.""" + # Stop all mocks + mock.patch.stopall() + + def test_log_multiline_with_ack_packet(self): + """Test log_multiline with an AckPacket.""" + # Create a fake AckPacket + packet = fake.fake_ack_packet() + packet.send_count = 1 + + # Call the function + log.log_multiline(packet, tx=True, header=True) + + # Verify that logging was called + self.loguru_opt_mock.assert_called_once() + self.loguru_info_mock.assert_called_once() + # LOG.debug is no longer called in log_multiline + + def test_log_multiline_with_gps_packet(self): + """Test log_multiline with a GPSPacket.""" + # Create a fake GPSPacket + packet = packets.GPSPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='>', + comment='Test GPS comment', + ) + packet.send_count = 2 + + # Call the function + log.log_multiline(packet, tx=False, header=True) + + # Verify that logging was called + self.loguru_opt_mock.assert_called_once() + self.loguru_info_mock.assert_called_once() + # LOG.debug is no longer called in log_multiline + + def test_log_multiline_disabled_logging(self): + """Test log_multiline when packet logging is disabled.""" + # Disable packet logging + self.conf_mock.enable_packet_logging = False + + # Create a fake packet + packet = fake.fake_packet() + packet.send_count = 0 + + # Call the function + log.log_multiline(packet, tx=False, header=True) + + # Verify that logging was NOT called + self.loguru_opt_mock.assert_not_called() + self.logging_mock.debug.assert_not_called() + + def test_log_multiline_compact_format(self): + """Test log_multiline when log format is compact.""" + # Set compact format + self.conf_mock.log_packet_format = 'compact' + + # Create a fake packet + packet = fake.fake_packet() + packet.send_count = 0 + + # Call the function + log.log_multiline(packet, tx=False, header=True) + + # Verify that logging was NOT called (because of compact format) + self.loguru_opt_mock.assert_not_called() + self.logging_mock.debug.assert_not_called() + + def test_log_with_compact_format(self): + """Test log function with compact format.""" + # Set compact format + self.conf_mock.log_packet_format = 'compact' + + # Create a fake packet + packet = fake.fake_packet() + packet.send_count = 1 + + # Call the function + log.log(packet, tx=True, header=True, packet_count=1) + + # Verify that logging was called (but may be different behavior) + self.loguru_opt_mock.assert_called_once() + + def test_log_with_multiline_format(self): + """Test log function with multiline format.""" + # Set multiline format + self.conf_mock.log_packet_format = 'multiline' + + # Create a fake packet + packet = fake.fake_packet() + packet.send_count = 1 + + # Call the function + log.log(packet, tx=True, header=True, packet_count=1) + + # Verify that logging was called + self.loguru_opt_mock.assert_called_once() + + def test_log_with_gps_packet_distance(self): + """Test log function with GPS packet that includes distance info.""" + # Create a GPSPacket + packet = packets.GPSPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='>', + comment='Test GPS comment', + ) + packet.send_count = 2 + + # Call the function + log.log(packet, tx=False, header=True) + + # Verify that logging was called + self.loguru_opt_mock.assert_called_once() + + def test_log_with_disabled_logging(self): + """Test log function when packet logging is disabled.""" + # Disable packet logging + self.conf_mock.enable_packet_logging = False + + # Create a fake packet + packet = fake.fake_packet() + packet.send_count = 0 + + # Call the function + log.log(packet, tx=False, header=True, force_log=False) + + # Verify that logging was NOT called + self.loguru_opt_mock.assert_not_called() + + def test_log_with_force_log(self): + """Test log function with force_log=True even when logging is disabled.""" + # Disable packet logging + self.conf_mock.enable_packet_logging = False + + # Create a fake packet + packet = fake.fake_packet() + packet.send_count = 0 + + # Call the function with force_log=True + log.log(packet, tx=False, header=True, force_log=True) + + # Verify that logging WAS called because of force_log=True + self.loguru_opt_mock.assert_called_once() + + def test_log_with_different_packet_types(self): + """Test log function with different packet types.""" + # Test with MessagePacket + packet = fake.fake_packet() + packet.send_count = 1 + + log.log(packet, tx=False, header=True) + self.loguru_opt_mock.assert_called_once() + + # Reset mocks + self.loguru_opt_mock.reset_mock() + + # Test with AckPacket + ack_packet = fake.fake_ack_packet() + ack_packet.send_count = 2 + + log.log(ack_packet, tx=True, header=True) + self.loguru_opt_mock.assert_called_once() diff --git a/tests/packets/test_message_packet.py b/tests/packets/test_message_packet.py new file mode 100644 index 0000000..3b7a90e --- /dev/null +++ b/tests/packets/test_message_packet.py @@ -0,0 +1,80 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestMessagePacket(unittest.TestCase): + """Test MessagePacket JSON serialization.""" + + def test_message_packet_to_json(self): + """Test MessagePacket.to_json() method.""" + packet = packets.MessagePacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + message_text='Test message', + msgNo='123', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'MessagePacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['message_text'], 'Test message') + self.assertEqual(json_dict['msgNo'], '123') + + def test_message_packet_from_dict(self): + """Test MessagePacket.from_dict() method.""" + packet_dict = { + '_type': 'MessagePacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'message_text': 'Test message', + 'msgNo': '123', + } + packet = packets.MessagePacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.MessagePacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.message_text, 'Test message') + self.assertEqual(packet.msgNo, '123') + + def test_message_packet_round_trip(self): + """Test MessagePacket round-trip: to_json -> from_dict.""" + original = packets.MessagePacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + message_text='Test message', + msgNo='123', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.MessagePacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.message_text, original.message_text) + self.assertEqual(restored.msgNo, original.msgNo) + self.assertEqual(restored._type, original._type) + + def test_message_packet_from_raw_string(self): + """Test MessagePacket creation from raw APRS string.""" + packet_raw = 'KM6LYW>APZ100::WB4BOR :Test message{123' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.MessagePacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'MessagePacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.MessagePacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.to_call, packet.to_call) + self.assertEqual(restored.message_text, packet.message_text) + self.assertEqual(restored.msgNo, packet.msgNo) diff --git a/tests/packets/test_mice_packet.py b/tests/packets/test_mice_packet.py new file mode 100644 index 0000000..a640a84 --- /dev/null +++ b/tests/packets/test_mice_packet.py @@ -0,0 +1,107 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestMicEPacket(unittest.TestCase): + """Test MicEPacket JSON serialization.""" + + def test_mice_packet_to_json(self): + """Test MicEPacket.to_json() method.""" + packet = packets.MicEPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + speed=25.5, + course=180, + mbits='test', + mtype='test_type', + telemetry={'key': 'value'}, + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'MicEPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['latitude'], 37.7749) + self.assertEqual(json_dict['longitude'], -122.4194) + self.assertEqual(json_dict['speed'], 25.5) + self.assertEqual(json_dict['course'], 180) + self.assertEqual(json_dict['mbits'], 'test') + self.assertEqual(json_dict['mtype'], 'test_type') + + def test_mice_packet_from_dict(self): + """Test MicEPacket.from_dict() method.""" + packet_dict = { + '_type': 'MicEPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'latitude': 37.7749, + 'longitude': -122.4194, + 'speed': 25.5, + 'course': 180, + 'mbits': 'test', + 'mtype': 'test_type', + 'telemetry': {'key': 'value'}, + } + packet = packets.MicEPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.MicEPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.latitude, 37.7749) + self.assertEqual(packet.longitude, -122.4194) + self.assertEqual(packet.speed, 25.5) + self.assertEqual(packet.course, 180) + self.assertEqual(packet.mbits, 'test') + self.assertEqual(packet.mtype, 'test_type') + + def test_mice_packet_round_trip(self): + """Test MicEPacket round-trip: to_json -> from_dict.""" + original = packets.MicEPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + speed=25.5, + course=180, + mbits='test', + mtype='test_type', + telemetry={'key': 'value'}, + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.MicEPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.latitude, original.latitude) + self.assertEqual(restored.longitude, original.longitude) + self.assertEqual(restored.speed, original.speed) + self.assertEqual(restored.course, original.course) + self.assertEqual(restored.mbits, original.mbits) + self.assertEqual(restored.mtype, original.mtype) + self.assertEqual(restored._type, original._type) + + def test_mice_packet_from_raw_string(self): + """Test MicEPacket creation from raw APRS string.""" + packet_raw = 'kh2sr-15>S7TSYR,WIDE1-1,WIDE2-1,qAO,KO6KL-1:`1`7\x1c\x1c.#/`"4,}QuirkyQRP 4.6V 35.3C S06' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.MicEPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'MicEPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.MicEPacket) + self.assertEqual(restored.from_call, packet.from_call) + if hasattr(packet, 'latitude') and packet.latitude: + self.assertEqual(restored.latitude, packet.latitude) + self.assertEqual(restored.longitude, packet.longitude) diff --git a/tests/packets/test_object_packet.py b/tests/packets/test_object_packet.py new file mode 100644 index 0000000..5e0db52 --- /dev/null +++ b/tests/packets/test_object_packet.py @@ -0,0 +1,122 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestObjectPacket(unittest.TestCase): + """Test ObjectPacket JSON serialization.""" + + def test_object_packet_to_json(self): + """Test ObjectPacket.to_json() method.""" + packet = packets.ObjectPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='r', + symbol_table='/', + comment='Test object comment', + alive=True, + speed=25.5, + course=180, + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'ObjectPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['latitude'], 37.7749) + self.assertEqual(json_dict['longitude'], -122.4194) + self.assertEqual(json_dict['symbol'], 'r') + self.assertEqual(json_dict['symbol_table'], '/') + self.assertEqual(json_dict['comment'], 'Test object comment') + self.assertEqual(json_dict['alive'], True) + self.assertEqual(json_dict['speed'], 25.5) + self.assertEqual(json_dict['course'], 180) + + def test_object_packet_from_dict(self): + """Test ObjectPacket.from_dict() method.""" + packet_dict = { + '_type': 'ObjectPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'latitude': 37.7749, + 'longitude': -122.4194, + 'symbol': 'r', + 'symbol_table': '/', + 'comment': 'Test object comment', + 'alive': True, + 'speed': 25.5, + 'course': 180, + } + packet = packets.ObjectPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.ObjectPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.latitude, 37.7749) + self.assertEqual(packet.longitude, -122.4194) + self.assertEqual(packet.symbol, 'r') + self.assertEqual(packet.symbol_table, '/') + self.assertEqual(packet.comment, 'Test object comment') + self.assertEqual(packet.alive, True) + self.assertEqual(packet.speed, 25.5) + self.assertEqual(packet.course, 180) + + def test_object_packet_round_trip(self): + """Test ObjectPacket round-trip: to_json -> from_dict.""" + original = packets.ObjectPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='r', + symbol_table='/', + comment='Test object comment', + alive=True, + speed=25.5, + course=180, + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.ObjectPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.latitude, original.latitude) + self.assertEqual(restored.longitude, original.longitude) + self.assertEqual(restored.symbol, original.symbol) + self.assertEqual(restored.symbol_table, original.symbol_table) + self.assertEqual(restored.comment, original.comment) + self.assertEqual(restored.alive, original.alive) + self.assertEqual(restored.speed, original.speed) + self.assertEqual(restored.course, original.course) + self.assertEqual(restored._type, original._type) + + def test_object_packet_from_raw_string(self): + """Test ObjectPacket creation from raw APRS string.""" + # Use a working object packet example from the codebase + packet_raw = ( + 'REPEAT>APZ100:;K4CQ *301301z3735.11N/07903.08Wr145.490MHz T136 -060' + ) + packet_dict = aprslib.parse(packet_raw) + # aprslib might not set format correctly, so set it manually + packet_dict['format'] = 'object' + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.ObjectPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'ObjectPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.ObjectPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.to_call, packet.to_call) + if hasattr(packet, 'latitude') and packet.latitude: + self.assertEqual(restored.latitude, packet.latitude) + self.assertEqual(restored.longitude, packet.longitude) diff --git a/tests/packets/test_packet.py b/tests/packets/test_packet.py new file mode 100644 index 0000000..f935899 --- /dev/null +++ b/tests/packets/test_packet.py @@ -0,0 +1,75 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestPacket(unittest.TestCase): + """Test Packet base class JSON serialization.""" + + def test_packet_to_json(self): + """Test Packet.to_json() method.""" + packet = packets.Packet( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo='123', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + # Verify it's valid JSON + json_dict = json.loads(json_str) + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['msgNo'], '123') + + def test_packet_from_dict(self): + """Test Packet.from_dict() method.""" + packet_dict = { + '_type': 'Packet', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'msgNo': '123', + } + packet = packets.Packet.from_dict(packet_dict) + self.assertIsInstance(packet, packets.Packet) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.msgNo, '123') + + def test_packet_round_trip(self): + """Test Packet round-trip: to_json -> from_dict.""" + original = packets.Packet( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo='123', + addresse=fake.FAKE_TO_CALLSIGN, + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.Packet.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.msgNo, original.msgNo) + self.assertEqual(restored.addresse, original.addresse) + + def test_packet_from_raw_string(self): + """Test Packet creation from raw APRS string.""" + # Note: Base Packet is rarely used directly, but we can test with a simple message + packet_raw = 'KFAKE>APZ100::KMINE :Test message{123' + packet_dict = aprslib.parse(packet_raw) + # aprslib might not set format correctly, so set it manually + packet_dict['format'] = 'message' + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.MessagePacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertIn('from_call', json_dict) + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.to_call, packet.to_call) diff --git a/tests/packets/test_reject_packet.py b/tests/packets/test_reject_packet.py new file mode 100644 index 0000000..8e699f5 --- /dev/null +++ b/tests/packets/test_reject_packet.py @@ -0,0 +1,76 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestRejectPacket(unittest.TestCase): + """Test RejectPacket JSON serialization.""" + + def test_reject_packet_to_json(self): + """Test RejectPacket.to_json() method.""" + packet = packets.RejectPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo='123', + response='rej', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'RejectPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['msgNo'], '123') + + def test_reject_packet_from_dict(self): + """Test RejectPacket.from_dict() method.""" + packet_dict = { + '_type': 'RejectPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'msgNo': '123', + 'response': 'rej', + } + packet = packets.RejectPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.RejectPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.msgNo, '123') + + def test_reject_packet_round_trip(self): + """Test RejectPacket round-trip: to_json -> from_dict.""" + original = packets.RejectPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo='123', + response='rej', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.RejectPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.msgNo, original.msgNo) + self.assertEqual(restored._type, original._type) + + def test_reject_packet_from_raw_string(self): + """Test RejectPacket creation from raw APRS string.""" + packet_raw = 'HB9FDL-1>APK102,HB9FM-4*,WIDE2,qAR,HB9FEF-11::REPEAT :rej4139' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.RejectPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'RejectPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.RejectPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.to_call, packet.to_call) + self.assertEqual(restored.msgNo, packet.msgNo) diff --git a/tests/packets/test_status_packet.py b/tests/packets/test_status_packet.py new file mode 100644 index 0000000..f548464 --- /dev/null +++ b/tests/packets/test_status_packet.py @@ -0,0 +1,93 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestStatusPacket(unittest.TestCase): + """Test StatusPacket JSON serialization.""" + + def test_status_packet_to_json(self): + """Test StatusPacket.to_json() method.""" + packet = packets.StatusPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + status='Test status message', + msgNo='123', + messagecapable=True, + comment='Test comment', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'StatusPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['status'], 'Test status message') + self.assertEqual(json_dict['msgNo'], '123') + self.assertEqual(json_dict['messagecapable'], True) + self.assertEqual(json_dict['comment'], 'Test comment') + + def test_status_packet_from_dict(self): + """Test StatusPacket.from_dict() method.""" + packet_dict = { + '_type': 'StatusPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'status': 'Test status message', + 'msgNo': '123', + 'messagecapable': True, + 'comment': 'Test comment', + } + packet = packets.StatusPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.StatusPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.status, 'Test status message') + self.assertEqual(packet.msgNo, '123') + self.assertEqual(packet.messagecapable, True) + self.assertEqual(packet.comment, 'Test comment') + + def test_status_packet_round_trip(self): + """Test StatusPacket round-trip: to_json -> from_dict.""" + original = packets.StatusPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + status='Test status message', + msgNo='123', + messagecapable=True, + comment='Test comment', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.StatusPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.status, original.status) + self.assertEqual(restored.msgNo, original.msgNo) + self.assertEqual(restored.messagecapable, original.messagecapable) + self.assertEqual(restored.comment, original.comment) + self.assertEqual(restored._type, original._type) + + def test_status_packet_from_raw_string(self): + """Test StatusPacket creation from raw APRS string.""" + packet_raw = 'KFAKE>APZ100::KMINE :Test status message{123' + packet_dict = aprslib.parse(packet_raw) + # aprslib might not set format correctly, so set it manually + packet_dict['format'] = 'status' + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.StatusPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'StatusPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.StatusPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.to_call, packet.to_call) + self.assertEqual(restored.status, packet.status) diff --git a/tests/packets/test_telemetry_packet.py b/tests/packets/test_telemetry_packet.py new file mode 100644 index 0000000..b9b0352 --- /dev/null +++ b/tests/packets/test_telemetry_packet.py @@ -0,0 +1,115 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from aprsd.packets.core import TelemetryPacket +from tests import fake + + +class TestTelemetryPacket(unittest.TestCase): + """Test TelemetryPacket JSON serialization.""" + + def test_telemetry_packet_to_json(self): + """Test TelemetryPacket.to_json() method.""" + packet = TelemetryPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + speed=25.5, + course=180, + mbits='test', + mtype='test_type', + telemetry={'key': 'value'}, + tPARM=['parm1', 'parm2'], + tUNIT=['unit1', 'unit2'], + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'TelemetryPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['latitude'], 37.7749) + self.assertEqual(json_dict['longitude'], -122.4194) + self.assertEqual(json_dict['speed'], 25.5) + self.assertEqual(json_dict['course'], 180) + self.assertEqual(json_dict['mbits'], 'test') + self.assertEqual(json_dict['mtype'], 'test_type') + + def test_telemetry_packet_from_dict(self): + """Test TelemetryPacket.from_dict() method.""" + packet_dict = { + '_type': 'TelemetryPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'latitude': 37.7749, + 'longitude': -122.4194, + 'speed': 25.5, + 'course': 180, + 'mbits': 'test', + 'mtype': 'test_type', + 'telemetry': {'key': 'value'}, + 'tPARM': ['parm1', 'parm2'], + 'tUNIT': ['unit1', 'unit2'], + } + packet = TelemetryPacket.from_dict(packet_dict) + self.assertIsInstance(packet, TelemetryPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.latitude, 37.7749) + self.assertEqual(packet.longitude, -122.4194) + self.assertEqual(packet.speed, 25.5) + self.assertEqual(packet.course, 180) + self.assertEqual(packet.mbits, 'test') + self.assertEqual(packet.mtype, 'test_type') + + def test_telemetry_packet_round_trip(self): + """Test TelemetryPacket round-trip: to_json -> from_dict.""" + original = TelemetryPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + speed=25.5, + course=180, + mbits='test', + mtype='test_type', + telemetry={'key': 'value'}, + tPARM=['parm1', 'parm2'], + tUNIT=['unit1', 'unit2'], + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = TelemetryPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.latitude, original.latitude) + self.assertEqual(restored.longitude, original.longitude) + self.assertEqual(restored.speed, original.speed) + self.assertEqual(restored.course, original.course) + self.assertEqual(restored.mbits, original.mbits) + self.assertEqual(restored.mtype, original.mtype) + self.assertEqual(restored._type, original._type) + + def test_telemetry_packet_from_raw_string(self): + """Test TelemetryPacket creation from raw APRS string.""" + # Telemetry packets are less common, using a Mic-E with telemetry as example + packet_raw = ( + "KD9YIL>T0PX9W,WIDE1-1,WIDE2-1,qAO,NU9R-10:`sB,l#P>/'\"6+}|#*%U'a|!whl!|3" + ) + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + # This might be MicEPacket or TelemetryPacket depending on content + self.assertIsNotNone(packet) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertEqual(restored.from_call, packet.from_call) + if hasattr(packet, 'telemetry') and packet.telemetry: + self.assertIsNotNone(restored.telemetry) diff --git a/tests/packets/test_thirdparty_packet.py b/tests/packets/test_thirdparty_packet.py new file mode 100644 index 0000000..0c1de26 --- /dev/null +++ b/tests/packets/test_thirdparty_packet.py @@ -0,0 +1,99 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestThirdPartyPacket(unittest.TestCase): + """Test ThirdPartyPacket JSON serialization.""" + + def test_thirdparty_packet_to_json(self): + """Test ThirdPartyPacket.to_json() method.""" + subpacket = packets.MessagePacket( + from_call='SUB', + to_call='TARGET', + message_text='Sub message', + ) + packet = packets.ThirdPartyPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + subpacket=subpacket, + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'ThirdPartyPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + # subpacket should be serialized as a dict + self.assertIn('subpacket', json_dict) + self.assertIsInstance(json_dict['subpacket'], dict) + + def test_thirdparty_packet_from_dict(self): + """Test ThirdPartyPacket.from_dict() method.""" + subpacket_dict = { + '_type': 'MessagePacket', + 'from_call': 'SUB', + 'to_call': 'TARGET', + 'message_text': 'Sub message', + } + packet_dict = { + '_type': 'ThirdPartyPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'subpacket': subpacket_dict, + } + packet = packets.ThirdPartyPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.ThirdPartyPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertIsNotNone(packet.subpacket) + self.assertIsInstance(packet.subpacket, packets.MessagePacket) + + def test_thirdparty_packet_round_trip(self): + """Test ThirdPartyPacket round-trip: to_json -> from_dict.""" + subpacket = packets.MessagePacket( + from_call='SUB', + to_call='TARGET', + message_text='Sub message', + ) + original = packets.ThirdPartyPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + subpacket=subpacket, + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.ThirdPartyPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored._type, original._type) + # Verify subpacket was restored + self.assertIsNotNone(restored.subpacket) + self.assertIsInstance(restored.subpacket, packets.MessagePacket) + self.assertEqual(restored.subpacket.from_call, original.subpacket.from_call) + self.assertEqual(restored.subpacket.to_call, original.subpacket.to_call) + self.assertEqual( + restored.subpacket.message_text, original.subpacket.message_text + ) + + def test_thirdparty_packet_from_raw_string(self): + """Test ThirdPartyPacket creation from raw APRS string.""" + packet_raw = 'GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.ThirdPartyPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'ThirdPartyPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.ThirdPartyPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertIsNotNone(restored.subpacket) + self.assertEqual(restored.subpacket.from_call, packet.subpacket.from_call) diff --git a/tests/packets/test_unknown_packet.py b/tests/packets/test_unknown_packet.py new file mode 100644 index 0000000..c425d8c --- /dev/null +++ b/tests/packets/test_unknown_packet.py @@ -0,0 +1,82 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestUnknownPacket(unittest.TestCase): + """Test UnknownPacket JSON serialization.""" + + def test_unknown_packet_to_json(self): + """Test UnknownPacket.to_json() method.""" + packet = packets.UnknownPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + format='unknown_format', + packet_type='unknown', + unknown_fields={'extra_field': 'extra_value'}, + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'UnknownPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['format'], 'unknown_format') + self.assertEqual(json_dict['packet_type'], 'unknown') + + def test_unknown_packet_from_dict(self): + """Test UnknownPacket.from_dict() method.""" + packet_dict = { + '_type': 'UnknownPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'format': 'unknown_format', + 'packet_type': 'unknown', + 'extra_field': 'extra_value', + } + packet = packets.UnknownPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.UnknownPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.format, 'unknown_format') + self.assertEqual(packet.packet_type, 'unknown') + + def test_unknown_packet_round_trip(self): + """Test UnknownPacket round-trip: to_json -> from_dict.""" + original = packets.UnknownPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + format='unknown_format', + packet_type='unknown', + unknown_fields={'extra_field': 'extra_value'}, + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.UnknownPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.format, original.format) + self.assertEqual(restored.packet_type, original.packet_type) + self.assertEqual(restored._type, original._type) + + def test_unknown_packet_from_raw_string(self): + """Test UnknownPacket creation from raw APRS string.""" + # Use a packet format that might not be recognized + packet_raw = 'KFAKE>APZ100:>Unknown format data' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + # This might be UnknownPacket or another type depending on parsing + self.assertIsNotNone(packet) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertEqual(restored.from_call, packet.from_call) + if isinstance(packet, packets.UnknownPacket): + self.assertIsInstance(restored, packets.UnknownPacket) diff --git a/tests/packets/test_weather_packet.py b/tests/packets/test_weather_packet.py new file mode 100644 index 0000000..6f1ec7d --- /dev/null +++ b/tests/packets/test_weather_packet.py @@ -0,0 +1,151 @@ +import json +import unittest + +import aprslib + +from aprsd import packets +from tests import fake + + +class TestWeatherPacket(unittest.TestCase): + """Test WeatherPacket JSON serialization.""" + + def test_weather_packet_to_json(self): + """Test WeatherPacket.to_json() method.""" + packet = packets.WeatherPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='_', + symbol_table='/', + wind_speed=10.5, + wind_direction=180, + wind_gust=15.0, + temperature=72.5, + rain_1h=0.1, + rain_24h=0.5, + rain_since_midnight=0.3, + humidity=65, + pressure=1013.25, + comment='Test weather comment', + ) + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'WeatherPacket') + self.assertEqual(json_dict['from_call'], fake.FAKE_FROM_CALLSIGN) + self.assertEqual(json_dict['to_call'], fake.FAKE_TO_CALLSIGN) + self.assertEqual(json_dict['latitude'], 37.7749) + self.assertEqual(json_dict['longitude'], -122.4194) + self.assertEqual(json_dict['symbol'], '_') + self.assertEqual(json_dict['wind_speed'], 10.5) + self.assertEqual(json_dict['wind_direction'], 180) + self.assertEqual(json_dict['wind_gust'], 15.0) + self.assertEqual(json_dict['temperature'], 72.5) + self.assertEqual(json_dict['rain_1h'], 0.1) + self.assertEqual(json_dict['rain_24h'], 0.5) + self.assertEqual(json_dict['rain_since_midnight'], 0.3) + self.assertEqual(json_dict['humidity'], 65) + self.assertEqual(json_dict['pressure'], 1013.25) + self.assertEqual(json_dict['comment'], 'Test weather comment') + + def test_weather_packet_from_dict(self): + """Test WeatherPacket.from_dict() method.""" + packet_dict = { + '_type': 'WeatherPacket', + 'from_call': fake.FAKE_FROM_CALLSIGN, + 'to_call': fake.FAKE_TO_CALLSIGN, + 'latitude': 37.7749, + 'longitude': -122.4194, + 'symbol': '_', + 'symbol_table': '/', + 'wind_speed': 10.5, + 'wind_direction': 180, + 'wind_gust': 15.0, + 'temperature': 72.5, + 'rain_1h': 0.1, + 'rain_24h': 0.5, + 'rain_since_midnight': 0.3, + 'humidity': 65, + 'pressure': 1013.25, + 'comment': 'Test weather comment', + } + packet = packets.WeatherPacket.from_dict(packet_dict) + self.assertIsInstance(packet, packets.WeatherPacket) + self.assertEqual(packet.from_call, fake.FAKE_FROM_CALLSIGN) + self.assertEqual(packet.to_call, fake.FAKE_TO_CALLSIGN) + self.assertEqual(packet.latitude, 37.7749) + self.assertEqual(packet.longitude, -122.4194) + self.assertEqual(packet.symbol, '_') + self.assertEqual(packet.wind_speed, 10.5) + self.assertEqual(packet.wind_direction, 180) + self.assertEqual(packet.wind_gust, 15.0) + self.assertEqual(packet.temperature, 72.5) + self.assertEqual(packet.rain_1h, 0.1) + self.assertEqual(packet.rain_24h, 0.5) + self.assertEqual(packet.rain_since_midnight, 0.3) + self.assertEqual(packet.humidity, 65) + self.assertEqual(packet.pressure, 1013.25) + self.assertEqual(packet.comment, 'Test weather comment') + + def test_weather_packet_round_trip(self): + """Test WeatherPacket round-trip: to_json -> from_dict.""" + original = packets.WeatherPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=37.7749, + longitude=-122.4194, + symbol='_', + symbol_table='/', + wind_speed=10.5, + wind_direction=180, + wind_gust=15.0, + temperature=72.5, + rain_1h=0.1, + rain_24h=0.5, + rain_since_midnight=0.3, + humidity=65, + pressure=1013.25, + comment='Test weather comment', + ) + json_str = original.to_json() + packet_dict = json.loads(json_str) + restored = packets.WeatherPacket.from_dict(packet_dict) + self.assertEqual(restored.from_call, original.from_call) + self.assertEqual(restored.to_call, original.to_call) + self.assertEqual(restored.latitude, original.latitude) + self.assertEqual(restored.longitude, original.longitude) + self.assertEqual(restored.symbol, original.symbol) + self.assertEqual(restored.wind_speed, original.wind_speed) + self.assertEqual(restored.wind_direction, original.wind_direction) + self.assertEqual(restored.wind_gust, original.wind_gust) + self.assertEqual(restored.temperature, original.temperature) + self.assertEqual(restored.rain_1h, original.rain_1h) + self.assertEqual(restored.rain_24h, original.rain_24h) + self.assertEqual(restored.rain_since_midnight, original.rain_since_midnight) + self.assertEqual(restored.humidity, original.humidity) + self.assertEqual(restored.pressure, original.pressure) + self.assertEqual(restored.comment, original.comment) + self.assertEqual(restored._type, original._type) + + def test_weather_packet_from_raw_string(self): + """Test WeatherPacket creation from raw APRS string.""" + packet_raw = 'FW9222>APRS,TCPXX*,qAX,CWOP-6:@122025z2953.94N/08423.77W_232/003g006t084r000p032P000h80b10157L745.DsWLL' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.WeatherPacket) + # Test to_json + json_str = packet.to_json() + self.assertIsInstance(json_str, str) + json_dict = json.loads(json_str) + self.assertEqual(json_dict['_type'], 'WeatherPacket') + # Test from_dict round trip + restored = packets.factory(json_dict) + self.assertIsInstance(restored, packets.WeatherPacket) + self.assertEqual(restored.from_call, packet.from_call) + self.assertEqual(restored.temperature, packet.temperature) + self.assertEqual(restored.humidity, packet.humidity) + self.assertEqual(restored.pressure, packet.pressure) + self.assertEqual(restored.wind_speed, packet.wind_speed) + self.assertEqual(restored.wind_direction, packet.wind_direction) diff --git a/tests/plugins/test_fortune.py b/tests/plugins/test_fortune.py index bf1c371..d8fd8e3 100644 --- a/tests/plugins/test_fortune.py +++ b/tests/plugins/test_fortune.py @@ -7,29 +7,28 @@ from aprsd.plugins import fortune as fortune_plugin from .. import fake, test_plugin - CONF = cfg.CONF class TestFortunePlugin(test_plugin.TestPlugin): - @mock.patch("shutil.which") + @mock.patch('shutil.which') def test_fortune_fail(self, mock_which): mock_which.return_value = None fortune = fortune_plugin.FortunePlugin() expected = "FortunePlugin isn't enabled" - packet = fake.fake_packet(message="fortune") + packet = fake.fake_packet(message='fortune') actual = fortune.filter(packet) self.assertEqual(expected, actual) - @mock.patch("subprocess.check_output") - @mock.patch("shutil.which") + @mock.patch('subprocess.check_output') + @mock.patch('shutil.which') def test_fortune_success(self, mock_which, mock_output): - mock_which.return_value = "/usr/bin/games/fortune" - mock_output.return_value = "Funny fortune" + mock_which.return_value = '/usr/bin/games/fortune' + mock_output.return_value = 'Funny fortune' CONF.callsign = fake.FAKE_TO_CALLSIGN fortune = fortune_plugin.FortunePlugin() - expected = "Funny fortune" - packet = fake.fake_packet(message="fortune") + expected = 'Funny fortune' + packet = fake.fake_packet(message='fortune') actual = fortune.filter(packet) self.assertEqual(expected, actual) diff --git a/tests/plugins/test_notify.py b/tests/plugins/test_notify.py index d15fc38..e5323fb 100644 --- a/tests/plugins/test_notify.py +++ b/tests/plugins/test_notify.py @@ -65,7 +65,6 @@ class TestWatchListPlugin(test_plugin.TestPlugin): watchlist_callsigns=DEFAULT_WATCHLIST_CALLSIGNS, ): CONF.callsign = self.fromcall - CONF.aprs_network.login = self.fromcall CONF.aprs_fi.apiKey = 'something' # Add mock password CONF.aprs_network.password = '12345' diff --git a/tests/plugins/test_package.py b/tests/plugins/test_package.py new file mode 100644 index 0000000..d0c715e --- /dev/null +++ b/tests/plugins/test_package.py @@ -0,0 +1,85 @@ +import os +import unittest + +from aprsd import plugin +from aprsd.utils import package + + +class TestPackage(unittest.TestCase): + def test_plugin_type(self): + self.assertEqual( + package.plugin_type(plugin.APRSDRegexCommandPluginBase), 'RegexCommand' + ) + self.assertEqual( + package.plugin_type(plugin.APRSDWatchListPluginBase), 'WatchList' + ) + self.assertEqual(package.plugin_type(plugin.APRSDPluginBase), 'APRSDPluginBase') + + def test_is_plugin(self): + class TestPlugin(plugin.APRSDPluginBase): + def setup(self): + pass + + def filter(self, packet): + pass + + def process(self, packet): + pass + + class NonPlugin: + pass + + self.assertTrue(package.is_plugin(TestPlugin)) + self.assertFalse(package.is_plugin(NonPlugin)) + + def test_walk_package(self): + import aprsd.utils + + result = package.walk_package(aprsd.utils) + # walk_package returns an iterator, so we just check it's not None + self.assertIsNotNone(result) + + def test_get_module_info(self): + # Test with a specific, limited directory to avoid hanging + # Use the aprsd/utils directory which is small and safe + import aprsd.utils + + package_name = 'aprsd.utils' + module_name = 'package' + # Get the actual path to aprsd/utils directory + module_path = os.path.dirname(aprsd.utils.__file__) + module_info = package.get_module_info(package_name, module_name, module_path) + # The result should be a list (even if empty) + self.assertIsInstance(module_info, list) + + def test_is_aprsd_package(self): + self.assertTrue(package.is_aprsd_package('aprsd_plugin')) + self.assertFalse(package.is_aprsd_package('other')) + + def test_is_aprsd_extension(self): + self.assertTrue(package.is_aprsd_extension('aprsd_extension_plugin')) + self.assertFalse(package.is_aprsd_extension('other')) + + def test_get_installed_aprsd_items(self): + plugins, extensions = package.get_installed_aprsd_items() + self.assertIsNotNone(plugins) + self.assertIsNotNone(extensions) + + def test_get_installed_plugins(self): + plugins = package.get_installed_plugins() + self.assertIsNotNone(plugins) + + def test_get_installed_extensions(self): + extensions = package.get_installed_extensions() + self.assertIsNotNone(extensions) + + def test_get_pypi_packages(self): + packages = package.get_pypi_packages() + self.assertIsNotNone(packages) + + def test_log_installed_extensions_and_plugins(self): + package.log_installed_extensions_and_plugins() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/plugins/test_ping.py b/tests/plugins/test_ping.py index 2539e4c..92ebda3 100644 --- a/tests/plugins/test_ping.py +++ b/tests/plugins/test_ping.py @@ -7,12 +7,11 @@ from aprsd.plugins import ping as ping_plugin from .. import fake, test_plugin - CONF = cfg.CONF class TestPingPlugin(test_plugin.TestPlugin): - @mock.patch("time.localtime") + @mock.patch('time.localtime') def test_ping(self, mock_time): fake_time = mock.MagicMock() h = fake_time.tm_hour = 16 @@ -24,7 +23,7 @@ class TestPingPlugin(test_plugin.TestPlugin): ping = ping_plugin.PingPlugin() packet = fake.fake_packet( - message="location", + message='location', msg_number=1, ) @@ -33,16 +32,16 @@ class TestPingPlugin(test_plugin.TestPlugin): def ping_str(h, m, s): return ( - "Pong! " + 'Pong! ' + str(h).zfill(2) - + ":" + + ':' + str(m).zfill(2) - + ":" + + ':' + str(s).zfill(2) ) packet = fake.fake_packet( - message="Ping", + message='Ping', msg_number=1, ) actual = ping.filter(packet) @@ -50,7 +49,7 @@ class TestPingPlugin(test_plugin.TestPlugin): self.assertEqual(expected, actual) packet = fake.fake_packet( - message="ping", + message='ping', msg_number=1, ) actual = ping.filter(packet) diff --git a/tests/plugins/test_time.py b/tests/plugins/test_time.py index 2adef59..feadb72 100644 --- a/tests/plugins/test_time.py +++ b/tests/plugins/test_time.py @@ -12,26 +12,26 @@ CONF = cfg.CONF class TestTimePlugins(test_plugin.TestPlugin): - @mock.patch("aprsd.plugins.time.TimePlugin._get_local_tz") - @mock.patch("aprsd.plugins.time.TimePlugin._get_utcnow") + @mock.patch('aprsd.plugins.time.TimePlugin._get_local_tz') + @mock.patch('aprsd.plugins.time.TimePlugin._get_utcnow') def test_time(self, mock_utcnow, mock_localtz): utcnow = pytz.datetime.datetime.utcnow() mock_utcnow.return_value = utcnow - tz = pytz.timezone("US/Pacific") + tz = pytz.timezone('US/Pacific') mock_localtz.return_value = tz gmt_t = pytz.utc.localize(utcnow) local_t = gmt_t.astimezone(tz) fake_time = mock.MagicMock() - h = int(local_t.strftime("%H")) - m = int(local_t.strftime("%M")) + h = int(local_t.strftime('%H')) + m = int(local_t.strftime('%M')) fake_time.tm_sec = 13 CONF.callsign = fake.FAKE_TO_CALLSIGN time = time_plugin.TimePlugin() packet = fake.fake_packet( - message="location", + message='location', msg_number=1, ) @@ -41,11 +41,11 @@ class TestTimePlugins(test_plugin.TestPlugin): cur_time = fuzzy(h, m, 1) packet = fake.fake_packet( - message="time", + message='time', msg_number=1, ) - local_short_str = local_t.strftime("%H:%M %Z") - expected = "{} ({})".format( + local_short_str = local_t.strftime('%H:%M %Z') + expected = '{} ({})'.format( cur_time, local_short_str, ) diff --git a/tests/plugins/test_version.py b/tests/plugins/test_version.py index 90f9102..3961094 100644 --- a/tests/plugins/test_version.py +++ b/tests/plugins/test_version.py @@ -40,8 +40,9 @@ class TestVersionPlugin(test_plugin.TestPlugin): } } - expected = f'APRSD ver:{aprsd.__version__} uptime:00:00:00' CONF.callsign = fake.FAKE_TO_CALLSIGN + CONF.owner_callsign = None + expected = f'APRSD ver:{aprsd.__version__} uptime:00:00:00 owner:-' version = version_plugin.VersionPlugin() version.enabled = True @@ -62,3 +63,22 @@ class TestVersionPlugin(test_plugin.TestPlugin): # Verify the mock was called exactly once mock_collector_instance.collect.assert_called_once() + + @mock.patch('aprsd.stats.collector.Collector') + def test_version_shows_owner_callsign_when_set(self, mock_collector_class): + mock_collector_instance = mock_collector_class.return_value + mock_collector_instance.collect.return_value = { + 'APRSDStats': {'uptime': '01:23:45'}, + } + + CONF.callsign = fake.FAKE_TO_CALLSIGN + CONF.owner_callsign = 'K0WN3R' + version = version_plugin.VersionPlugin() + version.enabled = True + + packet = fake.fake_packet(message='version', msg_number=1) + actual = version.filter(packet) + self.assertEqual( + actual, + f'APRSD ver:{aprsd.__version__} uptime:01:23:45 owner:K0WN3R', + ) diff --git a/tests/plugins/test_weather.py b/tests/plugins/test_weather.py index 76f98fb..539482c 100644 --- a/tests/plugins/test_weather.py +++ b/tests/plugins/test_weather.py @@ -18,89 +18,89 @@ class TestUSWeatherPlugin(test_plugin.TestPlugin): CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USWeatherPlugin() expected = "USWeatherPlugin isn't enabled" - packet = fake.fake_packet(message="weather") + packet = fake.fake_packet(message='weather') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_aprs_fi") + @mock.patch('aprsd.plugin_utils.get_aprs_fi') def test_failed_aprs_fi_location(self, mock_check): # When the aprs.fi api key isn't set, then # the Plugin will be disabled. mock_check.side_effect = Exception - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USWeatherPlugin() - expected = "Failed to fetch aprs.fi location" - packet = fake.fake_packet(message="weather") + expected = 'Failed to fetch aprs.fi location' + packet = fake.fake_packet(message='weather') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_aprs_fi") + @mock.patch('aprsd.plugin_utils.get_aprs_fi') def test_failed_aprs_fi_location_no_entries(self, mock_check): # When the aprs.fi api key isn't set, then # the Plugin will be disabled. - mock_check.return_value = {"entries": []} - CONF.aprs_fi.apiKey = "abc123" + mock_check.return_value = {'entries': []} + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USWeatherPlugin() wx.enabled = True - expected = "Failed to fetch aprs.fi location" - packet = fake.fake_packet(message="weather") + expected = 'Failed to fetch aprs.fi location' + packet = fake.fake_packet(message='weather') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_aprs_fi") - @mock.patch("aprsd.plugin_utils.get_weather_gov_for_gps") + @mock.patch('aprsd.plugin_utils.get_aprs_fi') + @mock.patch('aprsd.plugin_utils.get_weather_gov_for_gps') def test_unknown_gps(self, mock_weather, mock_check_aprs): # When the aprs.fi api key isn't set, then # the LocationPlugin will be disabled. mock_check_aprs.return_value = { - "entries": [ + 'entries': [ { - "lat": 10, - "lng": 11, - "lasttime": 10, + 'lat': 10, + 'lng': 11, + 'lasttime': 10, }, ], } mock_weather.side_effect = Exception - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USWeatherPlugin() wx.enabled = True - expected = "Unable to get weather" - packet = fake.fake_packet(message="weather") + expected = 'Unable to get weather' + packet = fake.fake_packet(message='weather') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_aprs_fi") - @mock.patch("aprsd.plugin_utils.get_weather_gov_for_gps") + @mock.patch('aprsd.plugin_utils.get_aprs_fi') + @mock.patch('aprsd.plugin_utils.get_weather_gov_for_gps') def test_working(self, mock_weather, mock_check_aprs): # When the aprs.fi api key isn't set, then # the LocationPlugin will be disabled. mock_check_aprs.return_value = { - "entries": [ + 'entries': [ { - "lat": 10, - "lng": 11, - "lasttime": 10, + 'lat': 10, + 'lng': 11, + 'lasttime': 10, }, ], } mock_weather.return_value = { - "currentobservation": {"Temp": "400"}, - "data": { - "temperature": ["10", "11"], - "weather": ["test", "another"], + 'currentobservation': {'Temp': '400'}, + 'data': { + 'temperature': ['10', '11'], + 'weather': ['test', 'another'], }, - "time": {"startPeriodName": ["ignored", "sometime"]}, + 'time': {'startPeriodName': ['ignored', 'sometime']}, } - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USWeatherPlugin() wx.enabled = True - expected = "400F(10F/11F) test. sometime, another." - packet = fake.fake_packet(message="weather") + expected = '400F(10F/11F) test. sometime, another.' + packet = fake.fake_packet(message='weather') actual = wx.filter(packet) self.assertEqual(expected, actual) @@ -112,93 +112,93 @@ class TestUSMetarPlugin(test_plugin.TestPlugin): CONF.aprs_fi.apiKey = None wx = weather_plugin.USMetarPlugin() expected = "USMetarPlugin isn't enabled" - packet = fake.fake_packet(message="metar") + packet = fake.fake_packet(message='metar') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_aprs_fi") + @mock.patch('aprsd.plugin_utils.get_aprs_fi') def test_failed_aprs_fi_location(self, mock_check): # When the aprs.fi api key isn't set, then # the Plugin will be disabled. mock_check.side_effect = Exception - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USMetarPlugin() wx.enabled = True - expected = "Failed to fetch aprs.fi location" - packet = fake.fake_packet(message="metar") + expected = 'Failed to fetch aprs.fi location' + packet = fake.fake_packet(message='metar') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_aprs_fi") + @mock.patch('aprsd.plugin_utils.get_aprs_fi') def test_failed_aprs_fi_location_no_entries(self, mock_check): # When the aprs.fi api key isn't set, then # the Plugin will be disabled. - mock_check.return_value = {"entries": []} - CONF.aprs_fi.apiKey = "abc123" + mock_check.return_value = {'entries': []} + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USMetarPlugin() wx.enabled = True - expected = "Failed to fetch aprs.fi location" - packet = fake.fake_packet(message="metar") + expected = 'Failed to fetch aprs.fi location' + packet = fake.fake_packet(message='metar') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_weather_gov_metar") + @mock.patch('aprsd.plugin_utils.get_weather_gov_metar') def test_gov_metar_fetch_fails(self, mock_metar): mock_metar.side_effect = Exception - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USMetarPlugin() wx.enabled = True - expected = "Unable to find station METAR" - packet = fake.fake_packet(message="metar KPAO") + expected = 'Unable to find station METAR' + packet = fake.fake_packet(message='metar KPAO') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_weather_gov_metar") + @mock.patch('aprsd.plugin_utils.get_weather_gov_metar') def test_airport_works(self, mock_metar): class Response: text = '{"properties": {"rawMessage": "BOGUSMETAR"}}' mock_metar.return_value = Response() - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USMetarPlugin() wx.enabled = True - expected = "BOGUSMETAR" - packet = fake.fake_packet(message="metar KPAO") + expected = 'BOGUSMETAR' + packet = fake.fake_packet(message='metar KPAO') actual = wx.filter(packet) self.assertEqual(expected, actual) - @mock.patch("aprsd.plugin_utils.get_weather_gov_metar") - @mock.patch("aprsd.plugin_utils.get_aprs_fi") - @mock.patch("aprsd.plugin_utils.get_weather_gov_for_gps") + @mock.patch('aprsd.plugin_utils.get_weather_gov_metar') + @mock.patch('aprsd.plugin_utils.get_aprs_fi') + @mock.patch('aprsd.plugin_utils.get_weather_gov_for_gps') def test_metar_works(self, mock_wx_for_gps, mock_check_aprs, mock_metar): mock_wx_for_gps.return_value = { - "location": {"metar": "BOGUSMETAR"}, + 'location': {'metar': 'BOGUSMETAR'}, } class Response: text = '{"properties": {"rawMessage": "BOGUSMETAR"}}' mock_check_aprs.return_value = { - "entries": [ + 'entries': [ { - "lat": 10, - "lng": 11, - "lasttime": 10, + 'lat': 10, + 'lng': 11, + 'lasttime': 10, }, ], } mock_metar.return_value = Response() - CONF.aprs_fi.apiKey = "abc123" + CONF.aprs_fi.apiKey = 'abc123' CONF.callsign = fake.FAKE_TO_CALLSIGN wx = weather_plugin.USMetarPlugin() wx.enabled = True - expected = "BOGUSMETAR" - packet = fake.fake_packet(message="metar") + expected = 'BOGUSMETAR' + packet = fake.fake_packet(message='metar') actual = wx.filter(packet) self.assertEqual(expected, actual) diff --git a/tests/test_packets.py b/tests/test_packets.py index 61d91da..49e101f 100644 --- a/tests/test_packets.py +++ b/tests/test_packets.py @@ -20,18 +20,18 @@ class TestPacketBase(unittest.TestCase): message_format=core.PACKET_TYPE_MESSAGE, ): packet_dict = { - "from": from_call, - "addresse": to_call, - "to": to_call, - "format": message_format, - "raw": "", + 'from': from_call, + 'addresse': to_call, + 'to': to_call, + 'format': message_format, + 'raw': '', } if message: - packet_dict["message_text"] = message + packet_dict['message_text'] = message if msg_number: - packet_dict["msgNo"] = str(msg_number) + packet_dict['msgNo'] = str(msg_number) return packet_dict @@ -52,7 +52,7 @@ class TestPacketBase(unittest.TestCase): self.assertEqual( fake.FAKE_FROM_CALLSIGN, - pkt.get("from_call"), + pkt.get('from_call'), ) def test_packet_factory(self): @@ -64,21 +64,21 @@ class TestPacketBase(unittest.TestCase): self.assertEqual(fake.FAKE_TO_CALLSIGN, pkt.to_call) self.assertEqual(fake.FAKE_TO_CALLSIGN, pkt.addresse) - pkt_dict["symbol"] = "_" - pkt_dict["weather"] = { - "wind_gust": 1.11, - "temperature": 32.01, - "humidity": 85, - "pressure": 1095.12, - "comment": "Home!", + pkt_dict['symbol'] = '_' + pkt_dict['weather'] = { + 'wind_gust': 1.11, + 'temperature': 32.01, + 'humidity': 85, + 'pressure': 1095.12, + 'comment': 'Home!', } - pkt_dict["format"] = core.PACKET_TYPE_UNCOMPRESSED + pkt_dict['format'] = core.PACKET_TYPE_UNCOMPRESSED pkt = packets.factory(pkt_dict) self.assertIsInstance(pkt, packets.WeatherPacket) - @mock.patch("aprsd.packets.core.GPSPacket._build_time_zulu") + @mock.patch('aprsd.packets.core.GPSPacket._build_time_zulu') def test_packet_format_rain_1h(self, mock_time_zulu): - mock_time_zulu.return_value = "221450" + mock_time_zulu.return_value = '221450' wx = packets.WeatherPacket( from_call=fake.FAKE_FROM_CALLSIGN, @@ -87,58 +87,58 @@ class TestPacketBase(unittest.TestCase): ) wx.prepare() - expected = "KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r000p000P000h00b00000" + expected = 'KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r000p000P000h00b00000' self.assertEqual(expected, wx.raw) rain_location = 59 - self.assertEqual(rain_location, wx.raw.find("r000")) + self.assertEqual(rain_location, wx.raw.find('r000')) wx.rain_1h = 1.11 wx.prepare() - expected = "KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r111p000P000h00b00000" + expected = 'KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r111p000P000h00b00000' self.assertEqual(expected, wx.raw) wx.rain_1h = 0.01 wx.prepare() - expected = "KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r001p000P000h00b00000" + expected = 'KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r001p000P000h00b00000' self.assertEqual(expected, wx.raw) def test_beacon_factory(self): """Test to ensure a beacon packet is created.""" packet_raw = ( - "WB4BOR-12>APZ100,WIDE2-1:@161647z3724.15N107847.58W$ APRSD WebChat" + 'WB4BOR-12>APZ100,WIDE2-1:@161647z3724.15N107847.58W$ APRSD WebChat' ) packet_dict = aprslib.parse(packet_raw) packet = packets.factory(packet_dict) self.assertIsInstance(packet, packets.BeaconPacket) - packet_raw = "kd8mey-10>APRS,TCPIP*,qAC,T2SYDNEY:=4247.80N/08539.00WrPHG1210/Making 220 Great Again Allstar# 552191" + packet_raw = 'kd8mey-10>APRS,TCPIP*,qAC,T2SYDNEY:=4247.80N/08539.00WrPHG1210/Making 220 Great Again Allstar# 552191' packet_dict = aprslib.parse(packet_raw) packet = packets.factory(packet_dict) self.assertIsInstance(packet, packets.BeaconPacket) def test_reject_factory(self): """Test to ensure a reject packet is created.""" - packet_raw = "HB9FDL-1>APK102,HB9FM-4*,WIDE2,qAR,HB9FEF-11::REPEAT :rej4139" + packet_raw = 'HB9FDL-1>APK102,HB9FM-4*,WIDE2,qAR,HB9FEF-11::REPEAT :rej4139' packet_dict = aprslib.parse(packet_raw) packet = packets.factory(packet_dict) self.assertIsInstance(packet, packets.RejectPacket) - self.assertEqual("4139", packet.msgNo) - self.assertEqual("HB9FDL-1", packet.from_call) - self.assertEqual("REPEAT", packet.to_call) - self.assertEqual("reject", packet.packet_type) + self.assertEqual('4139', packet.msgNo) + self.assertEqual('HB9FDL-1', packet.from_call) + self.assertEqual('REPEAT', packet.to_call) + self.assertEqual('reject', packet.packet_type) self.assertIsNone(packet.payload) def test_thirdparty_factory(self): """Test to ensure a third party packet is created.""" - packet_raw = "GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW" + packet_raw = 'GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW' packet_dict = aprslib.parse(packet_raw) packet = packets.factory(packet_dict) self.assertIsInstance(packet, packets.ThirdPartyPacket) def test_weather_factory(self): """Test to ensure a weather packet is created.""" - packet_raw = "FW9222>APRS,TCPXX*,qAX,CWOP-6:@122025z2953.94N/08423.77W_232/003g006t084r000p032P000h80b10157L745.DsWLL" + packet_raw = 'FW9222>APRS,TCPXX*,qAX,CWOP-6:@122025z2953.94N/08423.77W_232/003g006t084r000p032P000h80b10157L745.DsWLL' packet_dict = aprslib.parse(packet_raw) packet = packets.factory(packet_dict) self.assertIsInstance(packet, packets.WeatherPacket) @@ -178,7 +178,7 @@ class TestPacketBase(unittest.TestCase): ) expected = ( - f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:ack123" + f'{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:ack123' ) self.assertEqual(expected, str(ack)) @@ -191,7 +191,7 @@ class TestPacketBase(unittest.TestCase): ) expected = ( - f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:rej123" + f'{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:rej123' ) self.assertEqual(expected, str(reject)) @@ -200,20 +200,20 @@ class TestPacketBase(unittest.TestCase): lat = 28.123456 lon = -80.123456 ts = 1711219496.6426 - comment = "My Beacon Comment" + comment = 'My Beacon Comment' packet = packets.BeaconPacket( from_call=fake.FAKE_FROM_CALLSIGN, to_call=fake.FAKE_TO_CALLSIGN, latitude=lat, longitude=lon, timestamp=ts, - symbol=">", + symbol='>', comment=comment, ) expected_lat = aprslib_util.latitude_to_ddm(lat) expected_lon = aprslib_util.longitude_to_ddm(lon) - expected = f"KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{comment}" + expected = f'KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{comment}' self.assertEqual(expected, str(packet)) def test_beacon_format_no_comment(self): @@ -227,13 +227,13 @@ class TestPacketBase(unittest.TestCase): latitude=lat, longitude=lon, timestamp=ts, - symbol=">", + symbol='>', ) - empty_comment = "APRSD Beacon" + empty_comment = 'APRSD Beacon' expected_lat = aprslib_util.latitude_to_ddm(lat) expected_lon = aprslib_util.longitude_to_ddm(lon) - expected = f"KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{empty_comment}" + expected = f'KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{empty_comment}' self.assertEqual(expected, str(packet)) def test_bulletin_format(self): @@ -242,32 +242,32 @@ class TestPacketBase(unittest.TestCase): bid = 0 packet = packets.BulletinPacket( from_call=fake.FAKE_FROM_CALLSIGN, - message_text="My Bulletin Message", + message_text='My Bulletin Message', bid=0, ) expected = ( - f"{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{packet.message_text}" + f'{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{packet.message_text}' ) self.assertEqual(expected, str(packet)) # bulletin id = 1 bid = 1 - txt = "((((((( CX2SA - Salto Uruguay ))))))) http://www.cx2sa.org" + txt = '((((((( CX2SA - Salto Uruguay ))))))) http://www.cx2sa.org' packet = packets.BulletinPacket( from_call=fake.FAKE_FROM_CALLSIGN, message_text=txt, bid=1, ) - expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{txt}" + expected = f'{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{txt}' self.assertEqual(expected, str(packet)) def test_message_format(self): """Test the message packet format.""" - message = "My Message" - msgno = "ABX" + message = 'My Message' + msgno = 'ABX' packet = packets.MessagePacket( from_call=fake.FAKE_FROM_CALLSIGN, to_call=fake.FAKE_TO_CALLSIGN, @@ -275,19 +275,19 @@ class TestPacketBase(unittest.TestCase): msgNo=msgno, ) - expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{message}{{{msgno}" + expected = f'{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{message}{{{msgno}' self.assertEqual(expected, str(packet)) # test with bad words # Currently fails with mixed case - message = "My cunt piss fuck shIt text" - exp_msg = "My **** **** **** **** text" - msgno = "ABX" + message = 'My cunt piss fuck shIt text' + exp_msg = 'My **** **** **** **** text' + msgno = 'ABX' packet = packets.MessagePacket( from_call=fake.FAKE_FROM_CALLSIGN, to_call=fake.FAKE_TO_CALLSIGN, message_text=message, msgNo=msgno, ) - expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{exp_msg}{{{msgno}" + expected = f'{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{exp_msg}{{{msgno}' self.assertEqual(expected, str(packet)) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a4be3e1..428bb75 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -38,7 +38,6 @@ class TestPluginManager(unittest.TestCase): def config_and_init(self): CONF.callsign = self.fromcall - CONF.aprs_network.login = fake.FAKE_TO_CALLSIGN CONF.aprs_fi.apiKey = 'something' CONF.enabled_plugins = 'aprsd.plugins.ping.PingPlugin' CONF.enable_save = False @@ -115,7 +114,6 @@ class TestPlugin(unittest.TestCase): def config_and_init(self): CONF.callsign = self.fromcall - CONF.aprs_network.login = fake.FAKE_TO_CALLSIGN CONF.aprs_fi.apiKey = 'something' CONF.enabled_plugins = 'aprsd.plugins.ping.PingPlugin' CONF.enable_save = False diff --git a/tests/threads/test_rx.py b/tests/threads/test_rx.py index a928f0b..ed557bf 100644 --- a/tests/threads/test_rx.py +++ b/tests/threads/test_rx.py @@ -154,21 +154,26 @@ class TestAPRSDRXThread(unittest.TestCase): mock_list_instance.find.side_effect = KeyError('Not found') mock_pkt_list.return_value = mock_list_instance - self.rx_thread.process_packet() + # Pass raw packet string as args[0] + self.rx_thread.process_packet(packet.raw) self.assertEqual(self.rx_thread.pkt_count, 1) self.assertFalse(self.packet_queue.empty()) + # Verify the raw string is on the queue + queued_raw = self.packet_queue.get() + self.assertEqual(queued_raw, packet.raw) def test_process_packet_no_packet(self): - """Test process_packet() when decode returns None.""" + """Test process_packet() when no frame is received.""" mock_client = MockClientDriver() mock_client._decode_packet_return = None self.rx_thread._client = mock_client self.rx_thread.pkt_count = 0 with mock.patch('aprsd.threads.rx.LOG') as mock_log: + # Call without args to trigger warning self.rx_thread.process_packet() - mock_log.error.assert_called() + mock_log.warning.assert_called() self.assertEqual(self.rx_thread.pkt_count, 0) def test_process_packet_ack_packet(self): @@ -180,38 +185,39 @@ class TestAPRSDRXThread(unittest.TestCase): self.rx_thread.pkt_count = 0 with mock.patch('aprsd.threads.rx.packet_log'): - self.rx_thread.process_packet() + # Pass raw packet string as args[0] + self.rx_thread.process_packet(packet.raw) self.assertEqual(self.rx_thread.pkt_count, 1) self.assertFalse(self.packet_queue.empty()) + # Verify the raw string is on the queue + queued_raw = self.packet_queue.get() + self.assertEqual(queued_raw, packet.raw) def test_process_packet_duplicate(self): - """Test process_packet() with duplicate packet.""" - from oslo_config import cfg - - CONF = cfg.CONF - CONF.packet_dupe_timeout = 60 + """Test process_packet() with duplicate packet. + Note: The rx thread's process_packet() doesn't filter duplicates. + It puts all packets on the queue. Duplicate filtering happens + later in the filter thread. + """ mock_client = MockClientDriver() packet = fake.fake_packet(msg_number='123') + packet.processed = True packet.timestamp = 1000 mock_client._decode_packet_return = packet self.rx_thread._client = mock_client self.rx_thread.pkt_count = 0 with mock.patch('aprsd.threads.rx.packet_log'): - with mock.patch('aprsd.threads.rx.packets.PacketList') as mock_pkt_list: - mock_list_instance = mock.MagicMock() - found_packet = fake.fake_packet(msg_number='123') - found_packet.timestamp = 1050 # Within timeout - mock_list_instance.find.return_value = found_packet - mock_pkt_list.return_value = mock_list_instance - - with mock.patch('aprsd.threads.rx.LOG') as mock_log: - self.rx_thread.process_packet() - mock_log.warning.assert_called() - # Should not add to queue - self.assertTrue(self.packet_queue.empty()) + # Pass raw packet string as args[0] + self.rx_thread.process_packet(packet.raw) + # The rx thread puts all packets on the queue regardless of duplicates + # Duplicate filtering happens in the filter thread + self.assertFalse(self.packet_queue.empty()) + queued_raw = self.packet_queue.get() + # Verify the raw string is on the queue + self.assertEqual(queued_raw, packet.raw) class TestAPRSDFilterThread(unittest.TestCase): @@ -266,10 +272,11 @@ class TestAPRSDFilterThread(unittest.TestCase): def test_print_packet(self): """Test print_packet() method.""" packet = fake.fake_packet() + self.filter_thread.packet_count = 5 # Set a packet count with mock.patch('aprsd.threads.rx.packet_log') as mock_log: self.filter_thread.print_packet(packet) - mock_log.log.assert_called_with(packet) + mock_log.log.assert_called_with(packet, packet_count=5) def test_loop_with_packet(self): """Test loop() with packet in queue.""" diff --git a/tests/threads/test_stats.py b/tests/threads/test_stats.py new file mode 100644 index 0000000..7b3e097 --- /dev/null +++ b/tests/threads/test_stats.py @@ -0,0 +1,149 @@ +import unittest +from unittest import mock + +from aprsd.stats import collector +from aprsd.threads.stats import APRSDStatsStoreThread, StatsStore + + +class TestStatsStore(unittest.TestCase): + """Unit tests for the StatsStore class.""" + + def test_init(self): + """Test StatsStore initialization.""" + ss = StatsStore() + self.assertIsNotNone(ss.lock) + self.assertFalse(hasattr(ss, 'data')) + + def test_add(self): + """Test add method.""" + ss = StatsStore() + test_data = {'test': 'data'} + + ss.add(test_data) + self.assertEqual(ss.data, test_data) + + def test_add_concurrent(self): + """Test add method with concurrent access.""" + import threading + + ss = StatsStore() + test_data = {'test': 'data'} + results = [] + + def add_data(): + ss.add(test_data) + results.append(ss.data) + + # Create multiple threads to test thread safety + threads = [] + for _ in range(5): + t = threading.Thread(target=add_data) + threads.append(t) + t.start() + + for t in threads: + t.join() + + # All threads should have added the data + for result in results: + self.assertEqual(result, test_data) + + +class TestAPRSDStatsStoreThread(unittest.TestCase): + """Unit tests for the APRSDStatsStoreThread class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + collector.Collector._instance = None + # Clear producers to start fresh + c = collector.Collector() + c.producers = [] + + def tearDown(self): + """Clean up after tests.""" + collector.Collector._instance = None + + def test_init(self): + """Test APRSDStatsStoreThread initialization.""" + thread = APRSDStatsStoreThread() + self.assertEqual(thread.name, 'StatsStore') + self.assertEqual(thread.save_interval, 10) + self.assertTrue(hasattr(thread, 'loop_count')) + + def test_loop_with_save(self): + """Test loop method when save interval is reached.""" + thread = APRSDStatsStoreThread() + + # Mock the collector and save methods + with ( + mock.patch('aprsd.stats.collector.Collector') as mock_collector_class, + mock.patch('aprsd.utils.objectstore.ObjectStoreMixin.save') as mock_save, + ): + # Setup mock collector to return some stats + mock_collector_instance = mock.Mock() + mock_collector_instance.collect.return_value = {'test': 'data'} + mock_collector_class.return_value = mock_collector_instance + + # Set loop_count to match save interval + thread.loop_count = 10 + + # Call loop + result = thread.loop() + + # Should return True (continue looping) + self.assertTrue(result) + + # Should have called collect and save + mock_collector_instance.collect.assert_called_once() + mock_save.assert_called_once() + + def test_loop_without_save(self): + """Test loop method when save interval is not reached.""" + thread = APRSDStatsStoreThread() + + # Mock the collector and save methods + with ( + mock.patch('aprsd.stats.collector.Collector') as mock_collector_class, + mock.patch('aprsd.utils.objectstore.ObjectStoreMixin.save') as mock_save, + ): + # Setup mock collector to return some stats + mock_collector_instance = mock.Mock() + mock_collector_instance.collect.return_value = {'test': 'data'} + mock_collector_class.return_value = mock_collector_instance + + # Set loop_count to not match save interval + thread.loop_count = 1 + + # Call loop + result = thread.loop() + + # Should return True (continue looping) + self.assertTrue(result) + + # Should not have called save + mock_save.assert_not_called() + + def test_loop_with_exception(self): + """Test loop method when an exception occurs.""" + thread = APRSDStatsStoreThread() + + # Mock the collector to raise an exception + with mock.patch('aprsd.stats.collector.Collector') as mock_collector_class: + mock_collector_instance = mock.Mock() + mock_collector_instance.collect.side_effect = RuntimeError('Test exception') + mock_collector_class.return_value = mock_collector_instance + + # Set loop_count to match save interval + thread.loop_count = 10 + + # Should raise the exception + with self.assertRaises(RuntimeError): + thread.loop() + + # Removed test_loop_count_increment as it's not meaningful to test in isolation + # since the increment happens in the parent run() method, not in loop() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/threads/test_tx.py b/tests/threads/test_tx.py index 157b89a..8ff03a2 100644 --- a/tests/threads/test_tx.py +++ b/tests/threads/test_tx.py @@ -15,10 +15,24 @@ class TestSendFunctions(unittest.TestCase): """Set up test fixtures.""" # Reset singleton instances tracker.PacketTrack._instance = None + # Reset scheduler instances + tx._packet_scheduler = None + tx._ack_scheduler = None def tearDown(self): """Clean up after tests.""" tracker.PacketTrack._instance = None + # Clean up schedulers + if tx._packet_scheduler: + tx._packet_scheduler.stop() + if tx._packet_scheduler.is_alive(): + tx._packet_scheduler.join(timeout=1) + if tx._ack_scheduler: + tx._ack_scheduler.stop() + if tx._ack_scheduler.is_alive(): + tx._ack_scheduler.join(timeout=1) + tx._packet_scheduler = None + tx._ack_scheduler = None @mock.patch('aprsd.threads.tx.collector.PacketCollector') @mock.patch('aprsd.threads.tx._send_packet') @@ -66,10 +80,28 @@ class TestSendFunctions(unittest.TestCase): mock_log.info.assert_called() mock_send_ack.assert_not_called() - @mock.patch('aprsd.threads.tx.SendPacketThread') - def test_send_packet_threaded(self, mock_thread_class): - """Test _send_packet() with threading.""" + @mock.patch('aprsd.threads.tx._get_packet_scheduler') + def test_send_packet_threaded(self, mock_get_scheduler): + """Test _send_packet() uses scheduler.""" packet = fake.fake_packet() + mock_scheduler = mock.MagicMock() + mock_scheduler.is_alive.return_value = True + mock_get_scheduler.return_value = mock_scheduler + + tx._send_packet(packet, direct=False) + + mock_get_scheduler.assert_called() + # Scheduler should be alive and will handle the packet + self.assertTrue(mock_scheduler.is_alive()) + + @mock.patch('aprsd.threads.tx.SendPacketThread') + @mock.patch('aprsd.threads.tx._get_packet_scheduler') + def test_send_packet_fallback(self, mock_get_scheduler, mock_thread_class): + """Test _send_packet() falls back to old method if scheduler not available.""" + packet = fake.fake_packet() + mock_scheduler = mock.MagicMock() + mock_scheduler.is_alive.return_value = False + mock_get_scheduler.return_value = mock_scheduler mock_thread = mock.MagicMock() mock_thread_class.return_value = mock_thread @@ -85,10 +117,28 @@ class TestSendFunctions(unittest.TestCase): tx._send_packet(packet, direct=True) mock_send_direct.assert_called_with(packet, aprs_client=None) - @mock.patch('aprsd.threads.tx.SendAckThread') - def test_send_ack_threaded(self, mock_thread_class): - """Test _send_ack() with threading.""" + @mock.patch('aprsd.threads.tx._get_ack_scheduler') + def test_send_ack_threaded(self, mock_get_scheduler): + """Test _send_ack() uses scheduler.""" packet = fake.fake_ack_packet() + mock_scheduler = mock.MagicMock() + mock_scheduler.is_alive.return_value = True + mock_get_scheduler.return_value = mock_scheduler + + tx._send_ack(packet, direct=False) + + mock_get_scheduler.assert_called() + # Scheduler should be alive and will handle the packet + self.assertTrue(mock_scheduler.is_alive()) + + @mock.patch('aprsd.threads.tx.SendAckThread') + @mock.patch('aprsd.threads.tx._get_ack_scheduler') + def test_send_ack_fallback(self, mock_get_scheduler, mock_thread_class): + """Test _send_ack() falls back to old method if scheduler not available.""" + packet = fake.fake_ack_packet() + mock_scheduler = mock.MagicMock() + mock_scheduler.is_alive.return_value = False + mock_get_scheduler.return_value = mock_scheduler mock_thread = mock.MagicMock() mock_thread_class.return_value = mock_thread @@ -146,6 +196,397 @@ class TestSendFunctions(unittest.TestCase): self.assertFalse(result) mock_log_error.error.assert_called() + @mock.patch('aprsd.threads.tx.PacketSendSchedulerThread') + def test_get_packet_scheduler_creates_new(self, mock_scheduler_class): + """Test _get_packet_scheduler() creates new scheduler if none exists.""" + tx._packet_scheduler = None + mock_scheduler = mock.MagicMock() + mock_scheduler_class.return_value = mock_scheduler + + result = tx._get_packet_scheduler() + + mock_scheduler_class.assert_called_once() + mock_scheduler.start.assert_called_once() + self.assertEqual(result, mock_scheduler) + + @mock.patch('aprsd.threads.tx.PacketSendSchedulerThread') + def test_get_packet_scheduler_reuses_existing(self, mock_scheduler_class): + """Test _get_packet_scheduler() reuses existing scheduler if alive.""" + existing_scheduler = mock.MagicMock() + existing_scheduler.is_alive.return_value = True + tx._packet_scheduler = existing_scheduler + + result = tx._get_packet_scheduler() + + mock_scheduler_class.assert_not_called() + self.assertEqual(result, existing_scheduler) + + @mock.patch('aprsd.threads.tx.PacketSendSchedulerThread') + def test_get_packet_scheduler_recreates_if_dead(self, mock_scheduler_class): + """Test _get_packet_scheduler() recreates scheduler if dead.""" + dead_scheduler = mock.MagicMock() + dead_scheduler.is_alive.return_value = False + tx._packet_scheduler = dead_scheduler + new_scheduler = mock.MagicMock() + mock_scheduler_class.return_value = new_scheduler + + result = tx._get_packet_scheduler() + + mock_scheduler_class.assert_called_once() + new_scheduler.start.assert_called_once() + self.assertEqual(result, new_scheduler) + + @mock.patch('aprsd.threads.tx.AckSendSchedulerThread') + def test_get_ack_scheduler_creates_new(self, mock_scheduler_class): + """Test _get_ack_scheduler() creates new scheduler if none exists.""" + tx._ack_scheduler = None + mock_scheduler = mock.MagicMock() + mock_scheduler_class.return_value = mock_scheduler + + result = tx._get_ack_scheduler() + + mock_scheduler_class.assert_called_once() + mock_scheduler.start.assert_called_once() + self.assertEqual(result, mock_scheduler) + + +class TestPacketWorkers(unittest.TestCase): + """Unit tests for worker functions used by threadpool.""" + + def setUp(self): + """Set up test fixtures.""" + tracker.PacketTrack._instance = None + + def tearDown(self): + """Clean up after tests.""" + tracker.PacketTrack._instance = None + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_send_packet_worker_packet_acked(self, mock_tracker_class): + """Test _send_packet_worker() when packet is acked.""" + mock_tracker = mock.MagicMock() + mock_tracker.get.return_value = None # Packet removed = acked + mock_tracker_class.return_value = mock_tracker + + result = tx._send_packet_worker('123') + self.assertFalse(result) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_send_packet_worker_max_retries(self, mock_tracker_class): + """Test _send_packet_worker() when max retries reached.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_packet(msg_number='123') + tracked_packet.send_count = 3 + tracked_packet.retry_count = 3 + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + result = tx._send_packet_worker('123') + self.assertFalse(result) + mock_log.info.assert_called() + mock_tracker.remove.assert_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + @mock.patch('aprsd.threads.tx._send_direct') + def test_send_packet_worker_send_now(self, mock_send_direct, mock_tracker_class): + """Test _send_packet_worker() when it's time to send.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_packet(msg_number='123') + tracked_packet.send_count = 0 + tracked_packet.retry_count = 3 + tracked_packet.last_send_time = None + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + mock_send_direct.return_value = True + + result = tx._send_packet_worker('123') + + self.assertTrue(result) + mock_send_direct.assert_called() + self.assertEqual(tracked_packet.send_count, 1) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + @mock.patch('aprsd.threads.tx._send_direct') + def test_send_packet_worker_send_failed(self, mock_send_direct, mock_tracker_class): + """Test _send_packet_worker() when send fails.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_packet(msg_number='123') + tracked_packet.send_count = 0 + tracked_packet.retry_count = 3 + tracked_packet.last_send_time = None + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + mock_send_direct.return_value = False + + result = tx._send_packet_worker('123') + + self.assertTrue(result) + self.assertEqual( + tracked_packet.send_count, 0 + ) # Should not increment on failure + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_send_ack_worker_packet_removed(self, mock_tracker_class): + """Test _send_ack_worker() when packet is removed.""" + mock_tracker = mock.MagicMock() + mock_tracker.get.return_value = None + mock_tracker_class.return_value = mock_tracker + + result = tx._send_ack_worker('123', 3) + self.assertFalse(result) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_send_ack_worker_max_retries(self, mock_tracker_class): + """Test _send_ack_worker() when max retries reached.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_ack_packet() + tracked_packet.send_count = 3 + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + result = tx._send_ack_worker('123', 3) + self.assertFalse(result) + mock_log.debug.assert_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + @mock.patch('aprsd.threads.tx._send_direct') + def test_send_ack_worker_send_now(self, mock_send_direct, mock_tracker_class): + """Test _send_ack_worker() when it's time to send.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_ack_packet() + tracked_packet.send_count = 0 + tracked_packet.last_send_time = None + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + mock_send_direct.return_value = True + + result = tx._send_ack_worker('123', 3) + + self.assertTrue(result) + mock_send_direct.assert_called() + self.assertEqual(tracked_packet.send_count, 1) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + @mock.patch('aprsd.threads.tx._send_direct') + def test_send_ack_worker_waiting(self, mock_send_direct, mock_tracker_class): + """Test _send_ack_worker() when waiting for next send.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_ack_packet() + tracked_packet.send_count = 0 + tracked_packet.last_send_time = int(time.time()) - 10 # Too soon + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + mock_send_direct.return_value = True + + result = tx._send_ack_worker('123', 3) + + self.assertTrue(result) + mock_send_direct.assert_not_called() + + +class TestPacketSendSchedulerThread(unittest.TestCase): + """Unit tests for PacketSendSchedulerThread class.""" + + def setUp(self): + """Set up test fixtures.""" + tracker.PacketTrack._instance = None + self.scheduler = tx.PacketSendSchedulerThread(max_workers=2) + + def tearDown(self): + """Clean up after tests.""" + self.scheduler.stop() + if self.scheduler.is_alive(): + self.scheduler.join(timeout=1) + self.scheduler.executor.shutdown(wait=False) + tracker.PacketTrack._instance = None + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.scheduler.name, 'PacketSendSchedulerThread') + self.assertEqual(self.scheduler.max_workers, 2) + self.assertIsNotNone(self.scheduler.executor) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_submits_tasks(self, mock_tracker_class): + """Test loop() submits tasks to threadpool.""" + mock_tracker = mock.MagicMock() + packet1 = fake.fake_packet(msg_number='123') + packet1.send_count = 0 + packet1.retry_count = 3 + packet2 = fake.fake_packet(msg_number='456') + packet2.send_count = 0 + packet2.retry_count = 3 + mock_tracker.keys.return_value = ['123', '456'] + mock_tracker.get.side_effect = lambda x: packet1 if x == '123' else packet2 + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should submit tasks for both packets + self.assertEqual(mock_submit.call_count, 2) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_skips_acked_packets(self, mock_tracker_class): + """Test loop() skips packets that are acked.""" + mock_tracker = mock.MagicMock() + mock_tracker.keys.return_value = ['123'] + mock_tracker.get.return_value = None # Packet acked + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should not submit task for acked packet + mock_submit.assert_not_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_skips_ack_packets(self, mock_tracker_class): + """Test loop() skips AckPackets.""" + mock_tracker = mock.MagicMock() + ack_packet = fake.fake_ack_packet() + mock_tracker.keys.return_value = ['123'] + mock_tracker.get.return_value = ack_packet + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should not submit task for ack packet + mock_submit.assert_not_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_skips_max_retries(self, mock_tracker_class): + """Test loop() skips packets at max retries.""" + mock_tracker = mock.MagicMock() + packet = fake.fake_packet(msg_number='123') + packet.send_count = 3 + packet.retry_count = 3 + mock_tracker.keys.return_value = ['123'] + mock_tracker.get.return_value = packet + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should not submit task for packet at max retries + mock_submit.assert_not_called() + + def test_cleanup(self): + """Test _cleanup() shuts down executor.""" + with mock.patch.object(self.scheduler.executor, 'shutdown') as mock_shutdown: + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + self.scheduler._cleanup() + mock_shutdown.assert_called_once_with(wait=True) + mock_log.debug.assert_called() + + +class TestAckSendSchedulerThread(unittest.TestCase): + """Unit tests for AckSendSchedulerThread class.""" + + def setUp(self): + """Set up test fixtures.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.default_ack_send_count = 3 + tracker.PacketTrack._instance = None + self.scheduler = tx.AckSendSchedulerThread(max_workers=2) + + def tearDown(self): + """Clean up after tests.""" + self.scheduler.stop() + if self.scheduler.is_alive(): + self.scheduler.join(timeout=1) + self.scheduler.executor.shutdown(wait=False) + tracker.PacketTrack._instance = None + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.scheduler.name, 'AckSendSchedulerThread') + self.assertEqual(self.scheduler.max_workers, 2) + self.assertEqual(self.scheduler.max_retries, 3) + self.assertIsNotNone(self.scheduler.executor) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_submits_tasks(self, mock_tracker_class): + """Test loop() submits tasks to threadpool.""" + mock_tracker = mock.MagicMock() + ack_packet1 = fake.fake_ack_packet() + ack_packet1.send_count = 0 + ack_packet2 = fake.fake_ack_packet() + ack_packet2.send_count = 0 + mock_tracker.keys.return_value = ['123', '456'] + mock_tracker.get.side_effect = ( + lambda x: ack_packet1 if x == '123' else ack_packet2 + ) + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should submit tasks for both ack packets + self.assertEqual(mock_submit.call_count, 2) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_skips_non_ack_packets(self, mock_tracker_class): + """Test loop() skips non-AckPackets.""" + mock_tracker = mock.MagicMock() + regular_packet = fake.fake_packet() + mock_tracker.keys.return_value = ['123'] + mock_tracker.get.return_value = regular_packet + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should not submit task for non-ack packet + mock_submit.assert_not_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_skips_max_retries(self, mock_tracker_class): + """Test loop() skips acks at max retries.""" + mock_tracker = mock.MagicMock() + ack_packet = fake.fake_ack_packet() + ack_packet.send_count = 3 + mock_tracker.keys.return_value = ['123'] + mock_tracker.get.return_value = ack_packet + mock_tracker_class.return_value = mock_tracker + + # Mock the executor's submit method + with mock.patch.object(self.scheduler.executor, 'submit') as mock_submit: + result = self.scheduler.loop() + + self.assertTrue(result) + # Should not submit task for ack at max retries + mock_submit.assert_not_called() + + def test_cleanup(self): + """Test _cleanup() shuts down executor.""" + with mock.patch.object(self.scheduler.executor, 'shutdown') as mock_shutdown: + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + self.scheduler._cleanup() + mock_shutdown.assert_called_once_with(wait=True) + mock_log.debug.assert_called() + class TestSendPacketThread(unittest.TestCase): """Unit tests for the SendPacketThread class.""" diff --git a/tests/utils/test_objectstore.py b/tests/utils/test_objectstore.py index 5b2d9b7..56c71a5 100644 --- a/tests/utils/test_objectstore.py +++ b/tests/utils/test_objectstore.py @@ -2,6 +2,7 @@ import os import pickle import shutil import tempfile +import threading import unittest from unittest import mock @@ -17,6 +18,7 @@ class TestObjectStore(objectstore.ObjectStoreMixin): def __init__(self): super().__init__() + self.lock = threading.RLock() self.data = {} diff --git a/tests/utils/test_ring_buffer_additional.py b/tests/utils/test_ring_buffer_additional.py new file mode 100644 index 0000000..a84339f --- /dev/null +++ b/tests/utils/test_ring_buffer_additional.py @@ -0,0 +1,172 @@ +import unittest + +from aprsd.utils.ring_buffer import RingBuffer + + +class TestRingBufferAdditional(unittest.TestCase): + """Additional unit tests for the RingBuffer class to cover edge cases.""" + + def test_empty_buffer(self): + """Test behavior with empty buffer.""" + rb = RingBuffer(5) + self.assertEqual(len(rb), 0) + self.assertEqual(rb.get(), []) + + def test_buffer_with_zero_size(self): + """Test buffer with zero size.""" + rb = RingBuffer(0) + # Should not crash, but behavior might be different + # In this implementation, it will behave like a normal list + rb.append(1) + self.assertEqual(len(rb), 1) + self.assertEqual(rb.get(), [1]) + + def test_buffer_with_negative_size(self): + """Test buffer with negative size.""" + # This might not be a valid use case, but let's test it + rb = RingBuffer(-1) + rb.append(1) + self.assertEqual(len(rb), 1) + self.assertEqual(rb.get(), [1]) + + def test_append_none_value(self): + """Test appending None values.""" + rb = RingBuffer(3) + rb.append(None) + rb.append(1) + rb.append(2) + + result = rb.get() + self.assertEqual(len(result), 3) + self.assertIsNone(result[0]) + self.assertEqual(result[1], 1) + self.assertEqual(result[2], 2) + + def test_append_multiple_types(self): + """Test appending multiple different types of values.""" + rb = RingBuffer(4) + rb.append('string') + rb.append(42) + rb.append([1, 2, 3]) + rb.append({'key': 'value'}) + + result = rb.get() + self.assertEqual(len(result), 4) + self.assertEqual(result[0], 'string') + self.assertEqual(result[1], 42) + self.assertEqual(result[2], [1, 2, 3]) + self.assertEqual(result[3], {'key': 'value'}) + + def test_multiple_appends_then_get(self): + """Test multiple appends followed by get operations.""" + rb = RingBuffer(5) + + # Append multiple items + for i in range(10): + rb.append(i) + + # Get should return the last 5 items + result = rb.get() + self.assertEqual(len(result), 5) + self.assertEqual(result, [5, 6, 7, 8, 9]) + + def test_get_returns_copy(self): + """Test that get() returns a copy, not a reference.""" + rb = RingBuffer(3) + rb.append(1) + rb.append(2) + rb.append(3) + + result = rb.get() + # Modify the returned list + result.append(4) + + # Original buffer should not be affected + original = rb.get() + self.assertEqual(len(original), 3) + self.assertNotIn(4, original) + + def test_buffer_size_one(self): + """Test buffer with size 1.""" + rb = RingBuffer(1) + rb.append(1) + self.assertEqual(len(rb), 1) + self.assertEqual(rb.get(), [1]) + + rb.append(2) + self.assertEqual(len(rb), 1) + result = rb.get() + self.assertEqual(len(result), 1) + self.assertEqual(result[0], 2) + + def test_buffer_size_two(self): + """Test buffer with size 2.""" + rb = RingBuffer(2) + rb.append(1) + rb.append(2) + self.assertEqual(len(rb), 2) + self.assertEqual(rb.get(), [1, 2]) + + rb.append(3) + self.assertEqual(len(rb), 2) + result = rb.get() + self.assertEqual(len(result), 2) + self.assertEqual(result[0], 2) + self.assertEqual(result[1], 3) + + def test_large_buffer_size(self): + """Test with a large buffer size.""" + rb = RingBuffer(1000) + for i in range(1000): + rb.append(i) + + result = rb.get() + self.assertEqual(len(result), 1000) + self.assertEqual(result[0], 0) + self.assertEqual(result[-1], 999) + + def test_buffer_with_many_wraparounds(self): + """Test buffer with many wraparounds.""" + rb = RingBuffer(3) + # Fill and wrap multiple times + for i in range(100): + rb.append(i) + + result = rb.get() + self.assertEqual(len(result), 3) + # Should contain the last 3 elements + self.assertEqual(result[0], 97) + self.assertEqual(result[1], 98) + self.assertEqual(result[2], 99) + + def test_multiple_get_calls(self): + """Test multiple get() calls return consistent results.""" + rb = RingBuffer(3) + rb.append(1) + rb.append(2) + rb.append(3) + + result1 = rb.get() + result2 = rb.get() + result3 = rb.get() + + self.assertEqual(result1, result2) + self.assertEqual(result2, result3) + self.assertEqual(result1, [1, 2, 3]) + + def test_get_order_consistency(self): + """Test that get() maintains order consistency.""" + rb = RingBuffer(5) + # Add elements + elements = [1, 2, 3, 4, 5, 6, 7] + for elem in elements: + rb.append(elem) + + result = rb.get() + # Should contain the last 5 elements in correct order + self.assertEqual(len(result), 5) + self.assertEqual(result, [3, 4, 5, 6, 7]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 29ba6e3..6fe5ac4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,9 @@ [tox] -minversion = 2.9.0 +minversion = 4.30.0 skipdist = True skip_missing_interpreters = true -envlist = pep8,py{310,311} -#requires = tox-pipenv -# pip==22.0.4 -# pip-tools==5.4.0 +envlist = lint,py{311,312,313,314} +requires = tox-uv # Activate isolated build environment. tox will use a virtual environment # to build a source distribution from the source tree. For build tools and @@ -18,14 +16,12 @@ setenv = _PYTEST_SETUP_SKIP_APRSD_DEP=1 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -usedevelop = True -install_command = pip install {opts} {packages} -extras = tests +package = editable deps = - pytest-cov pytest + pytest-cov commands = - pytest -s -v --cov-report term-missing --cov=aprsd {posargs} + pytest -v --cov-report term-missing --cov=aprsd tests {posargs} coverage: coverage report -m coverage: coverage xml @@ -45,53 +41,38 @@ commands = #sphinx-build -a -W . _build sphinx-build -M html source build - -[testenv:pep8] -deps = - flake8 -commands = - flake8 {posargs} aprsd tests - -[testenv:fast8] -basepython = python3 -# Use same environment directory as pep8 env to save space and install time -envdir = {toxworkdir}/pep8 -commands = - {toxinidir}/tools/fast8.sh -passenv = FAST8_NUM_COMMITS - [testenv:lint] skip_install = true deps = - ruff + ruff commands = - ruff check aprsd tests + ruff check aprsd tests {posargs} + ruff format --check aprsd tests -[flake8] -max-line-length = 99 -show-source = True -ignore = E713,E501,W503,N818 -extend-ignore = E203,W503 -extend-exclude = venv -exclude = .venv,.git,.tox,dist,doc,.ropeproject +[testenv:fast8] +basepython = python3 +# Use same environment directory as lint env to save space and install time +envdir = {toxworkdir}/lint +commands = + {toxinidir}/tools/fast8.sh +passenv = FAST8_NUM_COMMITS # This is the configuration for the tox-gh-actions plugin for GitHub Actions # https://github.com/ymyzk/tox-gh-actions # This section is not needed if not using GitHub Actions for CI. [gh-actions] python = - 3.9: py39, pep8, type-check, docs - 3.10: py39, pep8, type-check, docs - 3.11: py311, pep8, type-check, docs + 3.10: py39, lint, type-check, docs + 3.11: py311, lint, type-check, docs [testenv:fmt] -# This will reformat your code to comply with pep8 -# and standard formatting +# This will reformat your code using ruff skip_install = true deps = ruff commands = ruff format aprsd tests + ruff check --fix aprsd tests [testenv:type-check] skip_install = true @@ -108,3 +89,27 @@ skip_install = true basepython = python3 deps = pre-commit commands = pre-commit run --all-files --show-diff-on-failure + +[testenv:fix] +description = run code formatter and linter (auto-fix) +skip_install = true +deps = + pre-commit-uv>=4.1.1 +commands = + pre-commit run --all-files --show-diff-on-failure + +[testenv:type] +runner = uv-venv-lock-runner +description = run type checker via mypy +commands = + mypy {posargs:aprsd} + +[testenv:dev] +runner = uv-venv-lock-runner +description = dev environment +extras = + dev + tests + type +commands = + uv pip tree diff --git a/uv.lock b/uv.lock index f8fa464..b6d3f83 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,7 @@ dependencies = [ { name = "rfc3986" }, { name = "rich" }, { name = "rush" }, + { name = "setuptools" }, { name = "stevedore" }, { name = "thesmuggler" }, { name = "timeago" }, @@ -63,6 +64,7 @@ dev = [ { name = "identify" }, { name = "nodeenv" }, { name = "packaging" }, + { name = "pip" }, { name = "pip-tools" }, { name = "platformdirs" }, { name = "pluggy" }, @@ -70,6 +72,7 @@ dev = [ { name = "pyproject-api" }, { name = "pyproject-hooks" }, { name = "pyyaml" }, + { name = "setuptools" }, { name = "tomli" }, { name = "tox" }, { name = "typing-extensions" }, @@ -112,6 +115,7 @@ requires-dist = [ { name = "packaging", specifier = "==25.0" }, { name = "packaging", marker = "extra == 'dev'", specifier = "==25.0" }, { name = "pbr", specifier = "==7.0.3" }, + { name = "pip", marker = "extra == 'dev'", specifier = "==25.3" }, { name = "pip-tools", marker = "extra == 'dev'", specifier = "==7.5.2" }, { name = "platformdirs", marker = "extra == 'dev'", specifier = "==4.5.1" }, { name = "pluggy", specifier = "==1.6.0" }, @@ -129,6 +133,8 @@ requires-dist = [ { name = "rfc3986", specifier = "==2.0.0" }, { name = "rich", specifier = "==14.2.0" }, { name = "rush", specifier = "==2021.4.0" }, + { name = "setuptools", specifier = "==80.9.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==80.9.0" }, { name = "stevedore", specifier = "==5.6.0" }, { name = "thesmuggler", specifier = "==1.0.1" }, { name = "timeago", specifier = "==1.0.16" },