1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-18 17:44:02 -04:00

Compare commits

...

39 Commits

Author SHA1 Message Date
dependabot[bot] a286b196a8 chore(deps-dev): bump setuptools from 82.0.1 to 83.0.0
Bumps [setuptools](https://github.com/pypa/setuptools) from 82.0.1 to 83.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v82.0.1...v83.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 83.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 02:30:33 +00:00
hemna dcd1294b2f docs: add 5.0.0 changelog entry 2026-06-11 10:26:52 -04:00
hemna 77201f1e3d fix: upgrade uv.lock to resolve 9 Dependabot security alerts
Packages upgraded:
- filelock 3.20.0 -> 3.29.3 (TOCTOU symlink vulnerabilities)
- pip 25.3 -> 26.1.2 (path traversal, untrusted control sphere)
- pytest 9.0.2 -> 9.0.3 (vulnerable tmpdir handling)
- uv 0.11.6 -> 0.11.20 (arbitrary file write via entry points)
- virtualenv 20.35.4 -> 21.4.2 (TOCTOU in directory creation)
- wheel 0.45.1 -> 0.47.0 (arbitrary file permission modification)
2026-06-11 10:24:10 -04:00
hemna 4f6824f671 chore(deps): upgrade all pinned dependencies to latest (#229)
Key security upgrades:
- urllib3: 2.6.3 → 2.7.0 (fixes CVE-2026-decompression bypass)
- requests: 2.32.5 → 2.34.2 (fixes CVE-2026-25645 insecure temp file)
- idna: 3.11 → 3.18 (fixes CVE-2026-45409 crafted input DoS)
- pygments: 2.19.2 → 2.20.0 (fixes CVE-2026-4539 ReDoS)

Also bumps: attrs, bitarray, certifi, charset-normalizer, click,
importlib-metadata, markdown-it-py, oslo-config, oslo-i18n, packaging,
pytz, rich, stevedore, update-checker, wrapt, zipp
2026-06-10 15:58:05 -04:00
hemna d06390add8 Merge pull request #228 from craigerl/fix/dupe-filter-processed-flag
Fix DupePacketFilter checking wrong packet's processed flag
2026-06-02 15:47:38 -04:00
hemna db3a0428c0 Fix DupePacketFilter checking wrong packet's processed flag
DupePacketFilter.filter() was checking packet.processed (the newly
arrived duplicate) instead of found.processed (the previously stored
packet). Since all freshly decoded packets have processed=False by
default, the dupe detection branch was never reachable — every
duplicate message was passed through and re-processed.

This caused KM6LYW's retransmit of msg:9028 (via a different
digipeater path 63s after first receipt) to trigger NearestPlugin
a second time, sending 4 reply packets instead of 2 and resetting
the AckPacket retry counter back to (1 of 3).

Fix: check found.processed instead of packet.processed.
Update existing tests to reflect the correct variable under test.
Add regression test test_filter_aprs_retransmit_via_different_digi
that reproduces the exact production scenario.
2026-06-02 11:17:24 -04:00
hemna b064ac97a4 feat: add APRS Chat bulletin script 2026-05-21 14:52:03 -04:00
hemna 5cc918e5c2 fix: add socket lock to prevent TX/RX race causing stream corruption on retransmits
The RX reader thread sets setblocking(0) and the TX writer (via aprslib
sendall) sets setblocking(1) on the same socket without synchronization.
This race condition causes partial writes where other stations' APRS-IS
stream data gets concatenated onto retransmitted packets.

Add a shared _socket_lock between send() and _socket_readlines() so the
socket blocking mode is never changed by one thread while the other is
mid-operation.
2026-05-14 16:55:57 -04:00
hemna 9146ff76c9 fix: remove stale .client attribute access in send_message command
APRSDClient no longer has a .client property after the driver refactor
(commit 1c39546). Instantiating APRSDClient() is sufficient to trigger
connection via auto_connect=True.
2026-05-13 23:17:38 -04:00
hemna 0c515d45fe fix: filter stale BeaconPackets from PacketTrack on load from disk
Older versions persisted BeaconPackets to packettrack.json. On restart
these zombie beacons would be retransmitted by the scheduler. Now
PacketTrack.load() strips any BeaconPackets from the persisted data.

Workaround: delete ~/.config/aprsd/packettrack.json before restarting.
2026-05-13 12:14:21 -04:00
hemna d3281cff0b test: add tests for beacon/ack flood prevention and scheduler timing guards
Tests cover:
- BeaconPacket skipped in PacketTrack.tx() (fire-and-forget)
- AckPacket send_count not reset when same ack already tracked
- Heavy traffic scenario with 5 digi paths for same message
- Scheduler timing guards prevent threadpool race conditions
- Scheduler cleanup of max-retry packets
- MessagePacket still allows re-send (existing behavior preserved)
2026-05-13 11:44:49 -04:00
hemna d8134c4531 fix: prevent beacon and ack packet floods from PacketTrack retries
BeaconPackets are now skipped in PacketTrack — they are fire-and-forget
and never receive an ack, so tracking them caused the scheduler to
re-transmit them as unwanted duplicates.

AckPackets already being tracked are no longer reset when the same
message arrives via multiple digipeater paths, which was restarting
the retry counter and flooding RF with duplicate acks.

Added timing guards in both scheduler loops to prevent threadpool race
conditions where multiple workers could fire before send_count was
incremented.
2026-05-13 11:36:25 -04:00
hemna 3a47571b60 Merge pull request #224 from craigerl/dependabot/uv/uv-0.11.6
Bump uv from 0.9.26 to 0.11.6
2026-04-29 09:16:53 -04:00
dependabot[bot] 4ecdec0aa4 Bump uv from 0.9.26 to 0.11.6
Bumps [uv](https://github.com/astral-sh/uv) from 0.9.26 to 0.11.6.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.26...0.11.6)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.11.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 19:49:05 +00:00
hemna 490ff41cdc feat: Add configurable stats_store_interval for stats file saves
Add stats_store_interval config option to control how frequently
the statsstore.json file is written to disk. Default remains 10
seconds for backward compatibility.

This allows reducing disk I/O in production deployments and
can help avoid potential file corruption issues when external
processes read the stats file.
2026-03-27 17:37:16 -04:00
hemna 27413ab8cf Merge pull request #223 from craigerl/feature/configurable-stale-timeout
feat: Add configurable stale_timeout for APRS-IS connections
2026-03-27 10:27:09 -04:00
hemna 314f4da180 fix: Restore max_delta after custom stale_timeout test
The singleton's max_delta was being modified by test_init_custom_stale_timeout
and not restored, causing test_is_stale_connection_false to fail because
it expected 2 minutes but got 60 seconds.
2026-03-27 10:18:07 -04:00
hemna f6eb383caf fix: Update test to work with singleton pattern
The APRSISDriver uses @singleton decorator which transforms the class
into a function. The test was incorrectly trying to use __new__ which
doesn't work with decorated singletons. Instead, re-initialize the
existing instance after changing the config.
2026-03-27 10:13:55 -04:00
hemna 930339d4cf feat: Add configurable stale_timeout for APRS-IS connections
Add a new 'stale_timeout' configuration option to the aprs_network config
group that allows users to customize how long to wait before considering
an APRS-IS connection stale.

Problem:
The stale connection threshold was hardcoded to 2 minutes. In environments
with frequent network hiccups or when using certain APRS-IS servers that
may drop connections silently, 2 minutes can be too long to wait before
reconnecting, resulting in significant data loss.

Solution:
- Add 'stale_timeout' option to aprsd/conf/client.py with default of 120s
- Update APRSISDriver.__init__ to use the config value
- Maintain backward compatibility by defaulting to 120s if not configured
- Update tests to handle the new configuration option

Usage:
  [aprs_network]
  stale_timeout = 60  # Reconnect after 60 seconds without data

The default remains 120 seconds (2 minutes) for backward compatibility.
2026-03-27 10:05:44 -04:00
hemna a2e07a2279 Merge pull request #221 from craigerl/fix-urllib3-security
security: bump urllib3 from 2.6.2 to 2.6.3
2026-03-24 13:49:17 -04:00
hemna 3a12ccb842 security: bump urllib3 from 2.6.2 to 2.6.3
Fixes CVE-2026-21441 (8.9 High severity) - decompression-bomb safeguards
of the streaming API were bypassed when HTTP redirects were followed.

Closes #210
2026-03-24 13:43:44 -04:00
github-actions[bot] 5463b8d5b5 chore: update AUTHORS [skip ci] 2026-03-24 17:32:35 +00:00
hemna f2526efe1d Merge pull request #219 from craigerl/feature-daemon-threads-event-refactor
Refactor threads to use daemon threads and Event-based timing
2026-03-24 13:32:26 -04:00
hemna 96b017d59e chore: update uv.lock for uv 0.11.0 compatibility 2026-03-24 13:26:33 -04:00
hemna 8d8648e9dd style(threads): add return type to loop() and use modern type hints
- Add -> bool return type annotation to abstract loop() method
- Replace 'from typing import List' with built-in list[] (Python 3.9+)
2026-03-24 13:22:37 -04:00
hemna bf258e4bcf chore(tests): fix unused variable linter warning in test_stats.py 2026-03-24 13:22:37 -04:00
hemna 4ab59c6cf3 refactor(main): update signal handler for Event-based thread shutdown
- Replace time.sleep(1.5) with thread_list.join_non_daemon(timeout=5.0)
- Remove unused import time since time.sleep is no longer used
- Remove outdated commented-out code
- Improve log message (removed '10 seconds' reference)
2026-03-24 13:22:37 -04:00
hemna 505c0fa8a8 refactor(threads): migrate APRSRegistryThread to Event-based timing
- Set self.period=CONF.aprs_registry.frequency_seconds in __init__
- Remove counter-based conditional (loop every N seconds pattern)
- Replace time.sleep(1) with self.wait()
- Remove _loop_cnt tracking (use inherited loop_count from base)
- Remove unused time import
2026-03-24 13:22:37 -04:00
hemna 85ebf8a274 refactor(threads): migrate TX threads to Event-based timing
- PacketSendSchedulerThread: Add daemon=False, replace time.sleep with self.wait
- AckSendSchedulerThread: Add daemon=False, replace time.sleep with self.wait
- SendPacketThread: Replace time.sleep with self.wait, remove manual loop_count
- SendAckThread: Replace time.sleep with self.wait, remove manual loop_count
- BeaconSendThread: Set self.period=CONF.beacon_interval, remove counter-based
  conditional, replace time.sleep with self.wait, remove _loop_cnt tracking
- Update tests to use new Event-based API
2026-03-24 13:22:37 -04:00
hemna bc9ce61e59 refactor(threads): migrate RX threads to Event-based timing
- APRSDRXThread: Replace time.sleep with self.wait for interruptible waits
- APRSDRXThread.stop(): Use _shutdown_event.set() instead of thread_stop
- APRSDRXThread: Error recovery waits check for shutdown signal
- APRSDFilterThread: Use queue timeout with self.period for interruptible wait
- Remove unused time import
- Update tests to use new Event-based API
2026-03-24 13:22:37 -04:00
hemna 343ec3e81c refactor(threads): migrate stats threads to Event-based timing 2026-03-24 13:22:37 -04:00
hemna 44b8bc572d refactor(threads): migrate KeepAliveThread to Event-based timing 2026-03-24 13:22:36 -04:00
hemna 43ba69e352 feat(threads): add join_non_daemon() to APRSDThreadList
Allows graceful shutdown by waiting for non-daemon threads to complete
while allowing daemon threads to be terminated immediately.
2026-03-24 13:22:36 -04:00
hemna b7a37322e1 refactor(threads): add daemon, period, Event-based shutdown to APRSDThread
- Add daemon=True class attribute (subclasses override to False)
- Add period=1 class attribute for wait interval
- Replace thread_stop boolean with _shutdown_event (threading.Event)
- Add wait() method for interruptible sleeps
- Update tests for new Event-based API

BREAKING: thread_stop boolean replaced with _shutdown_event.
Code checking thread.thread_stop directly must use thread._shutdown_event.is_set()
2026-03-24 13:22:36 -04:00
hemna bc9b15d47a Add implementation plan for daemon threads and Event-based timing refactor 2026-03-24 13:22:36 -04:00
hemna d8747317df Add design spec for daemon threads and Event-based timing refactor 2026-03-24 13:22:36 -04:00
github-actions[bot] 4cc90a53ed chore: update AUTHORS [skip ci] 2026-03-24 17:12:56 +00:00
dependabot[bot] 425ad469b0 Bump marshmallow from 3.26.1 to 3.26.2 (#207)
Bumps [marshmallow](https://github.com/marshmallow-code/marshmallow) from 3.26.1 to 3.26.2.
- [Changelog](https://github.com/marshmallow-code/marshmallow/blob/3.26.2/CHANGELOG.rst)
- [Commits](https://github.com/marshmallow-code/marshmallow/compare/3.26.1...3.26.2)

---
updated-dependencies:
- dependency-name: marshmallow
  dependency-version: 3.26.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 13:12:45 -04:00
hemna bbc2ccd302 Merge pull request #214 from craigerl/fix/cli-command-issues
Fix CLI command inconsistencies
2026-02-28 09:33:31 -05:00
30 changed files with 3744 additions and 1048 deletions
+55
View File
@@ -4,6 +4,61 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [5.0.0](https://github.com/craigerl/aprsd/compare/4.3.0...5.0.0)
> 11 June 2026
##### Breaking Changes
- Upgrade to Python 3.11+ minimum [`da3ef77`](https://github.com/craigerl/aprsd/commit/da3ef77)
- Remove openweathermap plugin [`e3fda75`](https://github.com/craigerl/aprsd/commit/e3fda75)
- Removed AVWX plugin [`cefe3e3`](https://github.com/craigerl/aprsd/commit/cefe3e3)
- Replace insecure pickle serialization with JSON [`202c689`](https://github.com/craigerl/aprsd/commit/202c689)
##### Features
- Parallel processing of plugins [`6dcacb5`](https://github.com/craigerl/aprsd/commit/6dcacb5)
- Added new TX Scheduler and pool [`2a8b700`](https://github.com/craigerl/aprsd/commit/2a8b700)
- Refactor threads: add daemon, period, Event-based shutdown to APRSDThread [`b7a3732`](https://github.com/craigerl/aprsd/commit/b7a3732)
- Add join_non_daemon() to APRSDThreadList [`43ba69e`](https://github.com/craigerl/aprsd/commit/43ba69e)
- Migrate all threads to Event-based timing [`44b8bc5`](https://github.com/craigerl/aprsd/commit/44b8bc5)
- Add configurable stale_timeout for APRS-IS connections [`930339d`](https://github.com/craigerl/aprsd/commit/930339d)
- Add configurable stats_store_interval for stats file saves [`490ff41`](https://github.com/craigerl/aprsd/commit/490ff41)
- Added new owner_callsign [`d2cb208`](https://github.com/craigerl/aprsd/commit/d2cb208)
- Added new export-plugins command [`0b01881`](https://github.com/craigerl/aprsd/commit/0b01881)
- Added export-config [`7f7d03e`](https://github.com/craigerl/aprsd/commit/7f7d03e)
- Added --output-json for aprsd sample-config [`24bc864`](https://github.com/craigerl/aprsd/commit/24bc864)
- Added new APRSDPushStatsThread [`c5ca4f1`](https://github.com/craigerl/aprsd/commit/c5ca4f1)
- Add APRS Chat bulletin script [`b064ac9`](https://github.com/craigerl/aprsd/commit/b064ac9)
##### Bug Fixes
- Fix DupePacketFilter checking wrong packet's processed flag [`db3a042`](https://github.com/craigerl/aprsd/commit/db3a042)
- Add socket lock to prevent TX/RX race causing stream corruption on retransmits [`5cc918e`](https://github.com/craigerl/aprsd/commit/5cc918e)
- Remove stale .client attribute access in send_message command [`9146ff7`](https://github.com/craigerl/aprsd/commit/9146ff7)
- Filter stale BeaconPackets from PacketTrack on load from disk [`0c515d4`](https://github.com/craigerl/aprsd/commit/0c515d4)
- Prevent beacon and ack packet floods from PacketTrack retries [`d8134c4`](https://github.com/craigerl/aprsd/commit/d8134c4)
- Fix CLI command inconsistencies [`fcfb349`](https://github.com/craigerl/aprsd/commit/fcfb349)
- Fix JSON serialization of UnknownPacket in stats [`698d218`](https://github.com/craigerl/aprsd/commit/698d218)
- Fix client / driver inconsistencies from protocol [`3bcd03a`](https://github.com/craigerl/aprsd/commit/3bcd03a)
- Fixed inconsistent driver send() declaration [`c99a9c9`](https://github.com/craigerl/aprsd/commit/c99a9c9)
- Fix issue with getting the right plugin version [`6f9e6b2`](https://github.com/craigerl/aprsd/commit/6f9e6b2)
- Fixed an issue with dev command [`008fe3c`](https://github.com/craigerl/aprsd/commit/008fe3c)
##### Refactoring
- Moved optional deps into pyproject.toml [`d783a01`](https://github.com/craigerl/aprsd/commit/d783a01)
- Updates for plugins to make them more consistent [`ee61bf5`](https://github.com/craigerl/aprsd/commit/ee61bf5)
- Make consumer call signature consistent [`6ea9889`](https://github.com/craigerl/aprsd/commit/6ea9889)
- Added ruff to tox [`8b500ac`](https://github.com/craigerl/aprsd/commit/8b500ac)
- Reverse the threaded plugin processing [`3128f24`](https://github.com/craigerl/aprsd/commit/3128f24)
##### Security
- Upgrade uv.lock to resolve 9 Dependabot security alerts [`77201f1`](https://github.com/craigerl/aprsd/commit/77201f1)
- Bump urllib3 from 2.6.2 to 2.6.3 [`3a12ccb`](https://github.com/craigerl/aprsd/commit/3a12ccb)
- Upgrade all pinned dependencies to latest [`#229`](https://github.com/craigerl/aprsd/pull/229)
#### [4.3.0](https://github.com/craigerl/aprsd/compare/4.2.4...4.3.0)
> 11 December 2025
+6 -2
View File
@@ -32,8 +32,12 @@ class APRSISDriver:
connected = False
def __init__(self):
max_timeout = {'hours': 0.0, 'minutes': 2, 'seconds': 0}
self.max_delta = datetime.timedelta(**max_timeout)
# Use configurable stale_timeout, defaulting to 120 seconds if not set
try:
stale_timeout = CONF.aprs_network.stale_timeout
except (cfg.NoSuchOptError, cfg.NoSuchGroupError):
stale_timeout = 120 # Default to 2 minutes for backward compatibility
self.max_delta = datetime.timedelta(seconds=stale_timeout)
self.login_status = {
'success': False,
'message': None,
+23 -3
View File
@@ -44,6 +44,13 @@ class APRSLibClient(aprslib.IS):
select_timeout = 1
lock = threading.Lock()
# Shared lock between RX (reader) and TX (writer) to prevent
# socket state races. The reader sets setblocking(0) and the
# writer's sendall (in aprslib) sets setblocking(1). Without
# synchronization, these can race causing partial writes and
# stream corruption on retransmits.
_socket_lock = threading.Lock()
def stop(self):
self.thread_stop = True
LOG.warning('Shutdown Aprsdis client.')
@@ -54,8 +61,15 @@ class APRSLibClient(aprslib.IS):
@wrapt.synchronized(lock)
def send(self, packet: core.Packet):
"""Send an APRS Message object."""
self.sendall(packet.raw)
"""Send an APRS Message object.
Uses _socket_lock to prevent racing with the reader thread's
setblocking(0) call. The upstream aprslib sendall() sets
setblocking(1) before writing, which can corrupt in-progress
recv() calls if unsynchronized.
"""
with self._socket_lock:
self.sendall(packet.raw)
def is_alive(self):
"""If the connection is alive or not."""
@@ -141,7 +155,13 @@ class APRSLibClient(aprslib.IS):
continue
try:
short_buf = self.sock.recv(4096)
with self._socket_lock:
# Re-ensure non-blocking mode inside the lock.
# The send() path (via aprslib sendall) sets
# setblocking(1), so we must restore non-blocking
# before recv() to avoid blocking indefinitely.
self.sock.setblocking(0)
short_buf = self.sock.recv(4096)
# sock.recv returns empty if the connection drops
if not short_buf:
+1 -1
View File
@@ -130,7 +130,7 @@ def send_message(
sys.exit(0)
try:
APRSDClient().client # noqa: B018
APRSDClient() # noqa: B018
except LoginError:
sys.exit(-1)
+8
View File
@@ -47,6 +47,14 @@ aprs_opts = [
default=14580,
help='APRS-IS port',
),
cfg.IntOpt(
'stale_timeout',
default=120,
help='Seconds without receiving data before connection is considered stale. '
'When a connection goes stale, it will be automatically reconnected. '
'Lower values detect dead connections faster but may cause unnecessary '
'reconnects during brief network hiccups. Default is 120 seconds (2 minutes).',
),
]
kiss_serial_opts = [
+6
View File
@@ -133,6 +133,12 @@ aprsd_opts = [
help='Enable the Callsign seen list tracking feature. This allows aprsd to keep track of '
'callsigns that have been seen and when they were last seen.',
),
cfg.IntOpt(
'stats_store_interval',
default=10,
help='Interval in seconds between stats file saves to disk. '
'Lower values provide more frequent updates but increase disk I/O.',
),
cfg.BoolOpt(
'enable_packet_logging',
default=True,
+6 -6
View File
@@ -24,7 +24,6 @@ import datetime
import importlib.metadata as imp
import logging
import sys
import time
from importlib.metadata import version as metadata_version
import click
@@ -76,14 +75,17 @@ def main():
def signal_handler(sig, frame):
click.echo('signal_handler: called')
collector.Collector().stop_all()
threads.APRSDThreadList().stop_all()
thread_list = threads.APRSDThreadList()
thread_list.stop_all()
if 'subprocess' not in str(frame):
LOG.info(
'Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}'.format(
'Ctrl+C, Sending all threads exit! {}'.format(
datetime.datetime.now(),
),
)
time.sleep(1.5)
# Wait for non-daemon threads to finish gracefully
thread_list.join_non_daemon(timeout=5.0)
try:
packets.PacketTrack().save()
packets.WatchList().save()
@@ -93,8 +95,6 @@ def signal_handler(sig, frame):
except Exception as e:
LOG.error(f'Failed to save data: {e}')
sys.exit(0)
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
@cli.command()
+3 -2
View File
@@ -53,8 +53,9 @@ class DupePacketFilter:
# We haven't seen this packet before, so we process it.
return packet
if not packet.processed:
# We haven't processed this packet through the plugins.
if not found.processed:
# The previously seen packet hasn't been processed yet,
# so let this one through too.
return packet
elif abs(packet.timestamp - found.timestamp) < CONF.packet_dupe_timeout:
# If the packet came in within N seconds of the
+40 -1
View File
@@ -88,9 +88,25 @@ class PacketTrack(objectstore.ObjectStoreMixin):
self._remove(packet.ackMsgNo)
def tx(self, packet: type[core.Packet]) -> None:
"""Add a packet that was sent."""
"""Add a packet that was sent.
BeaconPackets are skipped they are fire-and-forget and never
receive an ack, so tracking them only causes the scheduler to
re-transmit them as unwanted duplicates.
AckPackets that are already being tracked are NOT reset this
prevents digipeated duplicates of the same message from restarting
the ack retry counter, which caused ack floods on RF.
"""
if isinstance(packet, core.BeaconPacket):
return
with self.lock:
key = packet.msgNo
if key in self.data and isinstance(packet, core.AckPacket):
# Already tracking this ack — don't reset send_count.
# This happens when the same message arrives via multiple
# digipeater paths and each copy triggers an ack send.
return
packet.send_count = 0
self.data[key] = packet
self.total_tracked += 1
@@ -98,6 +114,29 @@ class PacketTrack(objectstore.ObjectStoreMixin):
def remove(self, key):
self._remove(key)
def load(self):
"""Load tracked packets from disk, filtering out stale BeaconPackets.
BeaconPackets should never be retried (they are fire-and-forget),
but older versions persisted them to disk. Strip them on load so
they don't get retransmitted after a restart.
"""
super().load()
with self.lock:
stale = [
key
for key, pkt in self.data.items()
if isinstance(pkt, core.BeaconPacket)
or (isinstance(pkt, dict) and pkt.get('_type') == 'BeaconPacket')
]
for key in stale:
del self.data[key]
if stale:
LOG.info(
f'PacketTrack: removed {len(stale)} stale BeaconPacket(s) '
f'from persisted data.',
)
def _remove(self, key):
with self.lock:
try:
+40 -12
View File
@@ -2,8 +2,6 @@ import abc
import datetime
import logging
import threading
import time
from typing import List
import wrapt
@@ -13,21 +11,26 @@ LOG = logging.getLogger('APRSD')
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
"""Base class for all threads in APRSD."""
# Class attributes - subclasses override as needed
daemon = True # Most threads are daemon threads
period = 1 # Default wait period in seconds
loop_count = 1
_pause = False
thread_stop = False
def __init__(self, name):
super().__init__(name=name)
self.thread_stop = False
# Set daemon from class attribute
self.daemon = self.__class__.daemon
# Set period from class attribute (can be overridden in __init__)
self.period = self.__class__.period
self._shutdown_event = threading.Event()
self.loop_count = 0
APRSDThreadList().add(self)
self._last_loop = datetime.datetime.now()
def _should_quit(self):
"""see if we have a quit message from the global queue."""
if self.thread_stop:
return True
return False
"""Check if thread should exit."""
return self._shutdown_event.is_set()
def pause(self):
"""Logically pause the processing of the main loop."""
@@ -40,11 +43,24 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
self._pause = False
def stop(self):
"""Signal thread to stop. Returns immediately."""
LOG.debug(f"Stopping thread '{self.name}'")
self.thread_stop = True
self._shutdown_event.set()
def wait(self, timeout: float | None = None) -> bool:
"""Wait for shutdown signal or timeout.
Args:
timeout: Seconds to wait. Defaults to self.period.
Returns:
True if shutdown was signaled, False if timeout expired.
"""
wait_time = timeout if timeout is not None else self.period
return self._shutdown_event.wait(timeout=wait_time)
@abc.abstractmethod
def loop(self):
def loop(self) -> bool:
pass
def _cleanup(self):
@@ -64,7 +80,7 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
LOG.debug('Starting')
while not self._should_quit():
if self._pause:
time.sleep(1)
self.wait(timeout=1)
else:
self.loop_count += 1
can_loop = self.loop()
@@ -81,7 +97,7 @@ class APRSDThreadList:
_instance = None
threads_list: List[APRSDThread] = []
threads_list: list[APRSDThread] = []
lock = threading.Lock()
def __new__(cls, *args, **kwargs):
@@ -167,3 +183,15 @@ class APRSDThreadList:
@wrapt.synchronized(lock)
def __len__(self):
return len(self.threads_list)
@wrapt.synchronized(lock)
def join_non_daemon(self, timeout: float = 5.0):
"""Wait for non-daemon threads to complete gracefully.
Args:
timeout: Maximum seconds to wait per thread.
"""
for th in self.threads_list:
if not th.daemon and th.is_alive():
LOG.info(f'Waiting for non-daemon thread {th.name} to finish')
th.join(timeout=timeout)
+69 -70
View File
@@ -1,6 +1,5 @@
import datetime
import logging
import time
import tracemalloc
from loguru import logger
@@ -20,6 +19,7 @@ LOGU = logger
class KeepAliveThread(APRSDThread):
cntr = 0
checker_time = datetime.datetime.now()
period = 60
def __init__(self):
tracemalloc.start()
@@ -28,81 +28,80 @@ class KeepAliveThread(APRSDThread):
self.max_delta = datetime.timedelta(**max_timeout)
def loop(self):
if self.loop_count % 60 == 0:
stats_json = collector.Collector().collect()
pl = packets.PacketList()
thread_list = APRSDThreadList()
now = datetime.datetime.now()
stats_json = collector.Collector().collect()
pl = packets.PacketList()
thread_list = APRSDThreadList()
now = datetime.datetime.now()
if (
'APRSClientStats' in stats_json
and stats_json['APRSClientStats'].get('transport') == 'aprsis'
):
if stats_json['APRSClientStats'].get('server_keepalive'):
last_msg_time = utils.strfdelta(
now - stats_json['APRSClientStats']['server_keepalive']
)
else:
last_msg_time = 'N/A'
if (
'APRSClientStats' in stats_json
and stats_json['APRSClientStats'].get('transport') == 'aprsis'
):
if stats_json['APRSClientStats'].get('server_keepalive'):
last_msg_time = utils.strfdelta(
now - stats_json['APRSClientStats']['server_keepalive']
)
else:
last_msg_time = 'N/A'
else:
last_msg_time = 'N/A'
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 msg_packets:
tx_msg = msg_packets.get('tx', 0)
rx_msg = msg_packets.get('rx', 0)
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 msg_packets:
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:{}'
).format(
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'],
len(thread_list),
aprsd_log.logging_queue.qsize(),
)
LOG.info(keepalive)
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']
if not alive:
LOG.error(f'Thread {thread}')
keepalive = (
'{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} '
'Last:{} - RAM Current:{} Peak:{} Threads:{} LoggingQueue:{}'
).format(
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'],
len(thread_list),
aprsd_log.logging_queue.qsize(),
)
LOG.info(keepalive)
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']
if not alive:
LOG.error(f'Thread {thread}')
thread_hex = f'fg {utils.hex_from_name(key)}'
t_name = f'<{thread_hex}>{key:<15}</{thread_hex}>'
thread_msg = f'{t_name} Alive? {str(alive): <5} {str(age): <20}'
LOGU.opt(colors=True).info(thread_msg)
# LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
thread_hex = f'fg {utils.hex_from_name(key)}'
t_name = f'<{thread_hex}>{key:<15}</{thread_hex}>'
thread_msg = f'{t_name} Alive? {str(alive): <5} {str(age): <20}'
LOGU.opt(colors=True).info(thread_msg)
# LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
# Go through the registered keepalive collectors
# and check them as well as call log.
collect = keepalive_collector.KeepAliveCollector()
collect.check()
collect.log()
# Go through the registered keepalive collectors
# and check them as well as call log.
collect = keepalive_collector.KeepAliveCollector()
collect.check()
collect.log()
# Check version every day
delta = now - self.checker_time
if delta > datetime.timedelta(hours=24):
self.checker_time = now
level, msg = utils._check_version()
if level:
LOG.warning(msg)
self.cntr += 1
time.sleep(1)
# Check version every day
delta = now - self.checker_time
if delta > datetime.timedelta(hours=24):
self.checker_time = now
level, msg = utils._check_version()
if level:
LOG.warning(msg)
self.cntr += 1
self.wait()
return True
+17 -23
View File
@@ -1,5 +1,4 @@
import logging
import time
import requests
from oslo_config import cfg
@@ -14,11 +13,9 @@ LOG = logging.getLogger('APRSD')
class APRSRegistryThread(aprsd_threads.APRSDThread):
"""This sends service information to the configured APRS Registry."""
_loop_cnt: int = 1
def __init__(self):
super().__init__('APRSRegistryThread')
self._loop_cnt = 1
self.period = CONF.aprs_registry.frequency_seconds
if not CONF.aprs_registry.enabled:
LOG.error(
'APRS Registry is not enabled. ',
@@ -34,24 +31,21 @@ class APRSRegistryThread(aprsd_threads.APRSDThread):
)
def loop(self):
# Only call the registry every N seconds
if self._loop_cnt % CONF.aprs_registry.frequency_seconds == 0:
info = {
'callsign': CONF.callsign,
'owner_callsign': CONF.owner_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}',
json=info,
)
except Exception as e:
LOG.error(f'Failed to send registry info: {e}')
info = {
'callsign': CONF.callsign,
'owner_callsign': CONF.owner_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}',
json=info,
)
except Exception as e:
LOG.error(f'Failed to send registry info: {e}')
time.sleep(1)
self._loop_cnt += 1
self.wait()
return True
+8 -7
View File
@@ -1,7 +1,6 @@
import abc
import logging
import queue
import time
import aprslib
from oslo_config import cfg
@@ -43,19 +42,19 @@ class APRSDRXThread(APRSDThread):
self.packet_queue = packet_queue
def stop(self):
self.thread_stop = True
self._shutdown_event.set()
if self._client:
self._client.close()
def loop(self):
if not self._client:
self._client = APRSDClient()
time.sleep(1)
self.wait(timeout=1)
return True
if not self._client.is_alive:
self._client = APRSDClient()
time.sleep(1)
self.wait(timeout=1)
return True
# setup the consumer of messages and block until a messages
@@ -82,12 +81,14 @@ class APRSDRXThread(APRSDThread):
# This will cause a reconnect, next time client.get_client()
# is called
self._client.reset()
time.sleep(5)
if self.wait(timeout=5):
return False
except Exception as ex:
LOG.exception(ex)
LOG.error('Resetting connection and trying again.')
self._client.reset()
time.sleep(5)
if self.wait(timeout=5):
return False
return True
def process_packet(self, *args, **kwargs):
@@ -153,7 +154,7 @@ class APRSDFilterThread(APRSDThread):
def loop(self):
try:
pkt = self.packet_queue.get(timeout=1)
pkt = self.packet_queue.get(timeout=self.period)
self.packet_count += 1
# We use the client here, because the specific
# driver may need to decode the packet differently.
+117 -120
View File
@@ -31,20 +31,20 @@ class StatsStore(objectstore.ObjectStoreMixin):
class APRSDStatsStoreThread(APRSDThread):
"""Save APRSD Stats to disk periodically."""
# how often in seconds to write the file
save_interval = 10
daemon = False
def __init__(self):
super().__init__('StatsStore')
# Use config value for period, default to 10 seconds
self.period = CONF.stats_store_interval
def loop(self):
if self.loop_count % self.save_interval == 0:
stats = collector.Collector().collect()
ss = StatsStore()
ss.add(stats)
ss.save()
stats = collector.Collector().collect()
ss = StatsStore()
ss.add(stats)
ss.save()
time.sleep(1)
self.wait()
return True
@@ -64,143 +64,140 @@ class APRSDPushStatsThread(APRSDThread):
self.send_packetlist = send_packetlist
def loop(self):
if self.loop_count % self.period == 0:
stats_json = collector.Collector().collect(serializable=True)
url = f'{self.push_url}/stats'
headers = {'Content-Type': 'application/json'}
# Remove the PacketList section to reduce payload size
if not self.send_packetlist:
if 'PacketList' in stats_json:
del stats_json['PacketList']['packets']
stats_json = collector.Collector().collect(serializable=True)
url = f'{self.push_url}/stats'
headers = {'Content-Type': 'application/json'}
# Remove the PacketList section to reduce payload size
if not self.send_packetlist:
if 'PacketList' in stats_json:
del stats_json['PacketList']['packets']
now = datetime.datetime.now()
time_format = '%m-%d-%Y %H:%M:%S'
stats = {
'time': now.strftime(time_format),
'stats': stats_json,
}
now = datetime.datetime.now()
time_format = '%m-%d-%Y %H:%M:%S'
stats = {
'time': now.strftime(time_format),
'stats': stats_json,
}
try:
response = requests.post(url, json=stats, headers=headers, timeout=5)
response.raise_for_status()
try:
response = requests.post(url, json=stats, headers=headers, timeout=5)
response.raise_for_status()
if response.status_code == 200:
LOGU.info(f'Successfully pushed stats to {self.push_url}')
else:
LOGU.warning(
f'Failed to push stats to {self.push_url}: HTTP {response.status_code}'
)
if response.status_code == 200:
LOGU.info(f'Successfully pushed stats to {self.push_url}')
else:
LOGU.warning(
f'Failed to push stats to {self.push_url}: HTTP {response.status_code}'
)
except requests.exceptions.RequestException as e:
LOGU.error(f'Error pushing stats to {self.push_url}: {e}')
except Exception as e:
LOGU.error(f'Unexpected error in stats push: {e}')
except requests.exceptions.RequestException as e:
LOGU.error(f'Error pushing stats to {self.push_url}: {e}')
except Exception as e:
LOGU.error(f'Unexpected error in stats push: {e}')
time.sleep(1)
self.wait()
return True
class StatsLogThread(APRSDThread):
"""Log the stats from the PacketList."""
period = 10
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
# 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)
# 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
# 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'<green>RX Rate: {rate:.2f} pps</green> '
f'<yellow>Total RX: {total_rx}</yellow> '
f'<red>RX Last {self.period} secs: {rx_delta}</red> '
# Log summary stats
LOGU.opt(colors=True).info(
f'<green>RX Rate: {rate:.2f} pps</green> '
f'<yellow>Total RX: {total_rx}</yellow> '
f'<red>RX Last {self.period} secs: {rx_delta}</red> '
)
LOGU.opt(colors=True).info(
f'<cyan>Uptime: {elapsed:.0f}s ({elapsed_minutes:.1f}m / {elapsed_hours:.2f}h)</cyan> '
f'<magenta>Unique Callsigns: {unique_callsigns_count}</magenta>',
)
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'<cyan>Uptime: {elapsed:.0f}s ({elapsed_minutes:.1f}m / {elapsed_hours:.2f}h)</cyan> '
f'<magenta>Unique Callsigns: {unique_callsigns_count}</magenta>',
f' <cyan>{packet_type_str}</cyan>: '
f'<{rx_color_tag}>RX: {rx_count_str}</{rx_color_tag}> '
f'<red>TX: {tx_count_str}</red> '
f'<magenta>({percentage_str})</magenta>',
)
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'
)
# 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('<cyan>Top 10 Callsigns by Packet Count:</cyan>')
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' <cyan>{packet_type_str}</cyan>: '
f'<{rx_color_tag}>RX: {rx_count_str}</{rx_color_tag}> '
f'<red>TX: {tx_count_str}</red> '
f'<magenta>({percentage_str})</magenta>',
f' <cyan>{rank:2d}.</cyan> '
f'<white>{callsign:<12}</white>: '
f'<{count_color_tag}>{count:6d} packets</{count_color_tag}> '
f'<magenta>({percentage:5.1f}%)</magenta>',
)
# 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(
'<cyan>Top 10 Callsigns by Packet Count:</cyan>'
)
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' <cyan>{rank:2d}.</cyan> '
f'<white>{callsign:<12}</white>: '
f'<{count_color_tag}>{count:6d} packets</{count_color_tag}> '
f'<magenta>({percentage:5.1f}%)</magenta>',
)
time.sleep(1)
self.wait()
return True
+46 -34
View File
@@ -241,6 +241,8 @@ class PacketSendSchedulerThread(aprsd_threads.APRSDThread):
separate thread for each packet.
"""
daemon = False # Non-daemon for graceful packet handling
def __init__(self, max_workers=5):
super().__init__('PacketSendSchedulerThread')
self.executor = ThreadPoolExecutor(
@@ -265,14 +267,23 @@ class PacketSendSchedulerThread(aprsd_threads.APRSDThread):
# 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
# Max retries reached, clean up
pkt_tracker.remove(msg_no)
continue
# Don't submit if we sent recently (prevents threadpool race
# where multiple workers fire before send_count is incremented)
if packet.last_send_time:
now = int(round(time.time()))
sleeptime = (packet.send_count + 1) * 31
if now - packet.last_send_time < sleeptime:
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
self.wait() # Check every period (default 1 second)
return True
def _cleanup(self):
@@ -289,6 +300,8 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
separate thread for each ack.
"""
daemon = False # Non-daemon for graceful ACK handling
def __init__(self, max_workers=3):
super().__init__('AckSendSchedulerThread')
self.executor = ThreadPoolExecutor(
@@ -314,13 +327,21 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
# Check if ack is still being tracked
if packet.send_count >= self.max_retries:
# Max retries reached, will be cleaned up by worker
# Max retries reached, clean up
pkt_tracker.remove(msg_no)
continue
# Don't submit if we sent recently (prevents threadpool race
# where multiple workers fire before send_count is incremented)
if packet.last_send_time:
now = int(round(time.time()))
if now - packet.last_send_time < 31:
continue
# Submit send task to threadpool
self.executor.submit(_send_ack_worker, msg_no, self.max_retries)
time.sleep(1) # Check every second
self.wait() # Check every period (default 1 second)
return True
def _cleanup(self):
@@ -330,8 +351,6 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
class SendPacketThread(aprsd_threads.APRSDThread):
loop_count: int = 1
def __init__(self, packet):
self.packet = packet
super().__init__(f'TX-{packet.to_call}-{self.packet.msgNo}')
@@ -401,14 +420,12 @@ class SendPacketThread(aprsd_threads.APRSDThread):
if sent:
packet.send_count += 1
time.sleep(1)
self.wait()
# Make sure we get called again.
self.loop_count += 1
return True
class SendAckThread(aprsd_threads.APRSDThread):
loop_count: int = 1
max_retries = 3
def __init__(self, packet):
@@ -462,8 +479,7 @@ class SendAckThread(aprsd_threads.APRSDThread):
self.packet.last_send_time = int(round(time.time()))
time.sleep(1)
self.loop_count += 1
self.wait()
return True
@@ -473,11 +489,9 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
Settings are in the [DEFAULT] section of the config file.
"""
_loop_cnt: int = 1
def __init__(self):
super().__init__('BeaconSendThread')
self._loop_cnt = 1
self.period = CONF.beacon_interval
# Make sure Latitude and Longitude are set.
if not CONF.latitude or not CONF.longitude:
LOG.error(
@@ -491,25 +505,23 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
)
def loop(self):
# Only dump out the stats every N seconds
if self._loop_cnt % CONF.beacon_interval == 0:
pkt = core.BeaconPacket(
from_call=CONF.callsign,
to_call='APRS',
latitude=float(CONF.latitude),
longitude=float(CONF.longitude),
comment='APRSD GPS Beacon',
symbol=CONF.beacon_symbol,
)
try:
# Only send it once
pkt.retry_count = 1
send(pkt, direct=True)
except Exception as e:
LOG.error(f'Failed to send beacon: {e}')
APRSDClient().reset()
time.sleep(5)
pkt = core.BeaconPacket(
from_call=CONF.callsign,
to_call='APRS',
latitude=float(CONF.latitude),
longitude=float(CONF.longitude),
comment='APRSD GPS Beacon',
symbol=CONF.beacon_symbol,
)
try:
# Only send it once
pkt.retry_count = 1
send(pkt, direct=True)
except Exception as e:
LOG.error(f'Failed to send beacon: {e}')
APRSDClient().reset()
if self.wait(timeout=5):
return False
self._loop_cnt += 1
time.sleep(1)
self.wait()
return True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
# APRS Chat Bulletin Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a standalone bulletin script that announces APRS Chat on Google Play and includes short follow-up bulletin lines.
**Architecture:** Add one new shell script in `tools/` that matches the existing bulletin-script pattern already used in this repo, with a minimal `set -e` safety guard so failures are not masked by later `sleep` commands. Protect the behavior with one focused Python regression test that checks the script path, executable bit, and exact bulletin command lines.
**Tech Stack:** Bash, pytest, Python `pathlib`
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `tools/bulletin-aprschat.sh` | Create | Standalone APRS Chat Google Play bulletin sender |
| `tests/test_bulletin_scripts.py` | Create | Regression test for bulletin script presence and content |
---
## Chunk 1: APRS Chat Bulletin Script
### Task 1: Add the bulletin script with a focused regression test
**Files:**
- Create: `tests/test_bulletin_scripts.py`
- Create: `tools/bulletin-aprschat.sh`
- [ ] **Step 1: Write the failing test**
Create `tests/test_bulletin_scripts.py` with:
```python
import stat
from pathlib import Path
def test_bulletin_aprschat_script_contents():
repo_root = Path(__file__).resolve().parents[1]
script = repo_root / 'tools' / 'bulletin-aprschat.sh'
assert script.exists()
assert script.stat().st_mode & stat.S_IXUSR
lines = script.read_text().splitlines()
send_lines = [line for line in lines if line.startswith('aprsd send-message -n')]
assert send_lines == [
'aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"',
'aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"',
'aprsd send-message -n BLN2 "Android app for APRS chat and messaging"',
'aprsd send-message -n BLN3 "Search Google Play for APRS Chat"',
]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_bulletin_scripts.py -v`
Expected: FAIL because `tools/bulletin-aprschat.sh` does not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Create `tools/bulletin-aprschat.sh` with:
```bash
#!/bin/bash
set -e
# Send APRS bulletins announcing APRS Chat on Google Play
source ~/devel/mine/hamradio/aprsd/.venv/bin/activate
export APRS_LOGIN=WB4BOR
export APRS_PASSWORD=24496
aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"
sleep 2
aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"
sleep 2
aprsd send-message -n BLN2 "Android app for APRS chat and messaging"
sleep 2
aprsd send-message -n BLN3 "Search Google Play for APRS Chat"
sleep 2
```
- [ ] **Step 4: Make the script executable**
Run: `chmod +x tools/bulletin-aprschat.sh`
- [ ] **Step 5: Run test to verify it passes**
Run: `pytest tests/test_bulletin_scripts.py -v`
Expected: PASS
- [ ] **Step 6: Run a second verification pass**
Run: `bash -n tools/bulletin-aprschat.sh && pytest tests/test_bulletin_scripts.py -v`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add tests/test_bulletin_scripts.py tools/bulletin-aprschat.sh
git commit -m "Add APRS Chat bulletin script"
```
@@ -0,0 +1,259 @@
# Daemon Threads and Event-Based Timing Refactor
**Date:** 2026-03-24
**Status:** Draft
**Scope:** All APRSD thread classes
## Overview
Refactor all APRSD thread classes to use daemon threads and replace counter-based sleep patterns with `threading.Event()` for interruptible waits and cleaner periodic timing.
## Goals
1. **Faster shutdown** — threads respond to shutdown signals immediately instead of waiting for `time.sleep()` to complete
2. **Cleaner periodic timing** — replace counter-based timing (`loop_count % 60`) with explicit period-based waits
3. **Proper daemon semantics** — most threads become daemon threads; critical I/O threads remain non-daemon for graceful shutdown
## Current State
- **14 thread classes** extend `APRSDThread` base class
- All use `time.sleep(1)` with counter-based conditionals for periodic work
- Shutdown via boolean `thread_stop` flag, polled in while loop
- No threads set as daemon — all block program exit
- No `threading.Event()` usage anywhere
### Current Problems
1. Shutdown delay: up to 1-5 seconds waiting for sleep to finish
2. Counter math is fragile and obscures intent
3. Non-daemon threads prevent clean program exit
## Design
### Base Class Changes
File: `aprsd/threads/aprsd.py`
```python
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
# Class attributes (subclasses override as needed)
daemon = True # Most threads are daemon
period = 1 # Default wait period in seconds
def __init__(self, name):
super().__init__(name=name)
self.daemon = self.__class__.daemon
self._shutdown_event = threading.Event()
self.loop_count = 0 # Retained for debugging
self._last_loop = datetime.datetime.now()
APRSDThreadList().add(self)
def _should_quit(self) -> bool:
"""Check if thread should exit."""
return self._shutdown_event.is_set()
def stop(self):
"""Signal thread to stop. Returns immediately."""
self._shutdown_event.set()
def wait(self, timeout: float | None = None) -> bool:
"""Wait for shutdown signal or timeout.
Args:
timeout: Seconds to wait. Defaults to self.period.
Returns:
True if shutdown was signaled, False if timeout expired.
"""
wait_time = timeout if timeout is not None else self.period
return self._shutdown_event.wait(timeout=wait_time)
def run(self):
while not self._should_quit():
self.loop_count += 1
self._last_loop = datetime.datetime.now()
if not self.loop():
break
APRSDThreadList().remove(self)
```
### Daemon vs Non-Daemon Threads
**Non-daemon threads (3)** — require graceful shutdown for I/O operations:
| Thread | File | Reason |
|--------|------|--------|
| `PacketSendSchedulerThread` | tx.py | Manages packet send queue |
| `AckSendSchedulerThread` | tx.py | Manages ACK send queue |
| `APRSDStatsStoreThread` | stats.py | Writes stats to disk |
**Daemon threads (12)** — can be terminated immediately:
- `APRSDRXThread`, `APRSDFilterThread`, `APRSDProcessPacketThread`, `APRSDPluginProcessPacketThread`
- `APRSDPushStatsThread`, `StatsLogThread`
- `KeepAliveThread`, `APRSRegistryThread`
- `SendPacketThread`, `SendAckThread`, `BeaconSendThread`
- `APRSDListenProcessThread` (listen command)
### Subclass Migration Pattern
**Before:**
```python
class KeepAliveThread(APRSDThread):
def loop(self):
if self.loop_count % 60 == 0:
self._do_keepalive_work()
time.sleep(1)
return True
```
**After:**
```python
class KeepAliveThread(APRSDThread):
period = 60
def loop(self):
self._do_keepalive_work()
self.wait()
return True
```
### Thread Periods
| Thread | New `period` | Source |
|--------|--------------|--------|
| `KeepAliveThread` | 60 | Fixed |
| `APRSDStatsStoreThread` | 10 | Fixed |
| `APRSDPushStatsThread` | config | `CONF.push_stats.period` |
| `StatsLogThread` | 10 | Fixed |
| `APRSRegistryThread` | config | `CONF.aprs_registry.frequency_seconds` |
| `BeaconSendThread` | config | `CONF.beacon_interval` |
| `APRSDRXThread` | 1 | Default |
| `APRSDFilterThread` | 1 | Default |
| `APRSDProcessPacketThread` | 1 | Default |
| `APRSDPluginProcessPacketThread` | 1 | Default |
| `SendPacketThread` | 1 | Default |
| `SendAckThread` | 1 | Default |
| `PacketSendSchedulerThread` | 1 | Default |
| `AckSendSchedulerThread` | 1 | Default |
| `APRSDListenProcessThread` | 1 | Default |
Config-based periods are set in `__init__` or `setup()`. Note: `setup()` is called by subclasses in their `__init__` before the thread starts; the base class does not call it automatically.
```python
class APRSRegistryThread(APRSDThread):
period = 1 # Default
def setup(self):
self.period = CONF.aprs_registry.frequency_seconds
```
### ThreadList Changes
File: `aprsd/threads/aprsd.py`
```python
class APRSDThreadList:
def stop_all(self):
"""Signal all threads to stop."""
with self.lock:
for th in self.threads_list:
th.stop()
def join_non_daemon(self, timeout: float = 5.0):
"""Wait for non-daemon threads to complete gracefully."""
with self.lock:
for th in self.threads_list:
if not th.daemon and th.is_alive():
th.join(timeout=timeout)
```
### Shutdown Handler Changes
File: `aprsd/main.py`
```python
def signal_handler(sig, frame):
LOG.info("Shutdown signal received")
thread_list = threads.APRSDThreadList()
thread_list.stop_all()
thread_list.join_non_daemon(timeout=5.0)
# Daemon threads killed automatically on exit
```
### Queue-Based Threads
Threads that block on queues use queue timeout as interruptible wait:
```python
class APRSDFilterThread(APRSDThread):
period = 1
def loop(self):
try:
packet = self.queue.get(timeout=self.period)
self._process(packet)
except queue.Empty:
pass # Timeout, loop checks _should_quit
return True
```
### Error Recovery Waits
Threads needing longer waits for error recovery use explicit timeout:
```python
class APRSDRXThread(APRSDThread):
period = 1
def loop(self):
try:
self._process_packets()
except ConnectionError:
LOG.error("Connection lost, retrying in 5s")
if self.wait(timeout=5):
return False # Shutdown signaled
self.wait()
return True
```
## Files to Modify
1. `aprsd/threads/aprsd.py` — Base class and ThreadList
2. `aprsd/threads/rx.py` — RX thread classes (4)
3. `aprsd/threads/tx.py` — TX thread classes (5)
4. `aprsd/threads/stats.py` — Stats thread classes (3)
5. `aprsd/threads/keepalive.py` — KeepAliveThread
6. `aprsd/threads/registry.py` — APRSRegistryThread
7. `aprsd/main.py` — Signal handler
8. `aprsd/cmds/listen.py` — APRSDListenProcessThread
## Testing Strategy
1. **Unit tests for base class:**
- `wait()` returns `True` immediately when event is already set
- `wait(timeout=5)` returns `False` after 5 seconds if event not set
- `stop()` causes `_should_quit()` to return `True`
- `daemon` attribute is set correctly from class attribute
2. **Integration tests:**
- Shutdown completes in <1s for daemon-only scenarios
- Non-daemon threads get up to 5s grace period
- `join_non_daemon()` respects timeout parameter
3. **Manual testing:**
- Send SIGINT during operation, verify clean exit
- Verify no "thread still running" warnings on shutdown
4. **Existing test updates:**
- Update any tests that mock `thread_stop` to mock `_shutdown_event` instead
- Update tests that check `time.sleep` calls to check `wait()` calls
## Backwards Compatibility
- `loop_count` retained for debugging/logging
- `_should_quit()` method signature unchanged
- Default `period=1` matches current 1-second sleep behavior
## Rollout
Single PR with all changes — the refactor is atomic and affects thread behavior globally.
@@ -0,0 +1,66 @@
# APRS Chat Google Play Bulletin Script
**Date:** 2026-05-21
**Status:** Draft
**Scope:** `tools/` bulletin helper scripts
## Overview
Add a new standalone bulletin script that announces APRS Chat on the Google Play Store and follows with a few short explanatory bulletin lines.
## Goals
1. Add a dedicated script for the APRS Chat Play Store announcement.
2. Match the existing `tools/bulletin-*.sh` style and operational pattern.
3. Keep bulletin text short, direct, and suitable for APRS bulletin usage.
## Current State
- Existing bulletin scripts in `tools/` are small standalone shell wrappers.
- They activate the local virtual environment, export APRS credentials, send a few `BLN` messages, and pause with `sleep 2` between lines.
- There is no dedicated APRS Chat Google Play bulletin script today.
## Design
### Script Shape
Create `tools/bulletin-aprschat.sh` using the same pattern as `tools/bulletin-aprsthursday.sh`, with one small safety improvement so the script exits immediately if activation or any bulletin send fails:
- `#!/bin/bash`
- `set -e`
- brief header comment describing purpose
- `source ~/devel/mine/hamradio/aprsd/.venv/bin/activate`
- export `APRS_LOGIN=WB4BOR` and `APRS_PASSWORD=24496`
- send four numbered bulletin messages with `aprsd send-message -n BLN* ...`
- pause with `sleep 2` between each message
### Bulletin Content
The script will send these lines:
1. `BLN0 APRS Chat now on Google Play Store!`
2. `BLN1 Install: https://tinyurl.com/APRSChat`
3. `BLN2 Android app for APRS chat and messaging`
4. `BLN3 Search Google Play for APRS Chat`
This keeps the first line focused on the announcement, the second on the install URL, and the remaining lines as short follow-up guidance.
## Files to Modify
1. `tools/bulletin-aprschat.sh` - new standalone bulletin script
2. `tests/test_bulletin_scripts.py` - regression test for script content and expected bulletin lines
## Testing Strategy
1. Write a failing test first asserting:
- the new script exists
- it is executable
- it contains the expected ordered `BLN0`-`BLN3` send lines
2. Add the script with the approved content.
3. Re-run the focused test to confirm it passes.
## Non-Goals
- Refactoring existing bulletin scripts into a shared helper
- Folding the APRS Chat announcement into `tools/bulletin.sh`
- Adding scheduling or automation around bulletin execution
+95 -43
View File
@@ -1,45 +1,97 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --resolver backtracking --annotation-style=line requirements.in -o requirements.txt
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
certifi==2025.11.12 # via requests
charset-normalizer==3.4.4 # via requests
click==8.3.1 # via -r requirements.in
dataclasses-json==0.6.7 # via -r requirements.in
haversine==2.9.0 # via -r requirements.in
idna==3.11 # via requests
importlib-metadata==8.7.0 # via ax253, kiss3
kiss3==8.0.0 # via -r requirements.in
loguru==0.7.3 # via -r requirements.in
markdown-it-py==4.0.0 # via rich
marshmallow==3.26.1 # via dataclasses-json
mdurl==0.1.2 # via markdown-it-py
mypy-extensions==1.1.0 # via typing-inspect
netaddr==1.3.0 # via oslo-config
oslo-config==10.1.0 # via -r requirements.in
oslo-i18n==6.7.1 # via oslo-config
packaging==25.0 # via marshmallow
pbr==7.0.3 # via oslo-i18n
pluggy==1.6.0 # via -r requirements.in
pygments==2.19.2 # via rich
pyserial==3.5 # via pyserial-asyncio
pyserial-asyncio==0.6 # via kiss3
pytz==2025.2 # via -r requirements.in
pyyaml==6.0.3 # via oslo-config
requests==2.32.5 # via oslo-config, update-checker, -r requirements.in
rfc3986==2.0.0 # via oslo-config
rich==14.2.0 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
setuptools==80.9.0 # via pbr
stevedore==5.6.0 # via oslo-config
thesmuggler==1.0.1 # via -r requirements.in
timeago==1.0.16 # via -r requirements.in
typing-extensions==4.15.0 # via typing-inspect
typing-inspect==0.9.0 # via dataclasses-json
tzlocal==5.3.1 # via -r requirements.in
update-checker==0.18.0 # via -r requirements.in
urllib3==2.6.2 # via requests
wrapt==2.0.1 # via -r requirements.in
zipp==3.23.0 # via importlib-metadata
aprslib @ git+https://github.com/hemna/aprs-python.git@09cd7a2829a2e9d28ee1566881c843cc4769e590
# via -r requirements.in
attrs==26.1.0
# via
# ax253
# kiss3
# rush
ax253==0.1.5.post1
# via kiss3
bitarray==3.8.1
# via
# ax253
# kiss3
certifi==2026.5.20
# via requests
charset-normalizer==3.4.7
# via requests
click==8.4.1
# via -r requirements.in
dataclasses-json==0.6.7
# via -r requirements.in
haversine==2.9.0
# via -r requirements.in
idna==3.18
# via requests
importlib-metadata==9.0.0
# via
# ax253
# kiss3
kiss3==8.0.0
# via -r requirements.in
loguru==0.7.3
# via -r requirements.in
markdown-it-py==4.2.0
# via rich
marshmallow==3.26.2
# via dataclasses-json
mdurl==0.1.2
# via markdown-it-py
mypy-extensions==1.1.0
# via typing-inspect
netaddr==1.3.0
# via oslo-config
oslo-config==10.4.0
# via -r requirements.in
oslo-i18n==6.8.0
# via oslo-config
packaging==26.2
# via marshmallow
pbr==7.0.3
# via oslo-i18n
pluggy==1.6.0
# via -r requirements.in
pygments==2.20.0
# via rich
pyserial==3.5
# via pyserial-asyncio
pyserial-asyncio==0.6
# via kiss3
pytz==2026.2
# via -r requirements.in
pyyaml==6.0.3
# via oslo-config
requests==2.34.2
# via
# -r requirements.in
# oslo-config
rfc3986==2.0.0
# via oslo-config
rich==15.0.0
# via -r requirements.in
rush==2021.4.0
# via -r requirements.in
setuptools==83.0.0
# via pbr
stevedore==5.8.0
# via oslo-config
thesmuggler==1.0.1
# via -r requirements.in
timeago==1.0.16
# via -r requirements.in
typing-extensions==4.15.0
# via typing-inspect
typing-inspect==0.9.0
# via dataclasses-json
tzlocal==5.3.1
# via -r requirements.in
update-checker==1.0.0
# via -r requirements.in
urllib3==2.7.0
# via requests
wrapt==2.2.1
# via -r requirements.in
zipp==4.1.0
# via importlib-metadata
+22 -1
View File
@@ -24,6 +24,7 @@ class TestAPRSISDriver(unittest.TestCase):
self.mock_conf.aprs_network.password = '12345'
self.mock_conf.aprs_network.host = 'rotate.aprs.net'
self.mock_conf.aprs_network.port = 14580
self.mock_conf.aprs_network.stale_timeout = 120 # Default 2 minutes
# Mock APRS Lib Client
self.aprslib_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSLibClient')
@@ -71,11 +72,31 @@ class TestAPRSISDriver(unittest.TestCase):
def test_init(self):
"""Test initialization sets default values."""
self.assertIsInstance(self.driver.max_delta, datetime.timedelta)
self.assertEqual(self.driver.max_delta, datetime.timedelta(minutes=2))
# Default stale_timeout is 120 seconds (2 minutes)
self.assertEqual(self.driver.max_delta, datetime.timedelta(seconds=120))
self.assertFalse(self.driver.login_status['success'])
self.assertIsNone(self.driver.login_status['message'])
self.assertIsNone(self.driver._client)
def test_init_custom_stale_timeout(self):
"""Test initialization with custom stale_timeout."""
# Save original max_delta
original_max_delta = self.driver.max_delta
try:
# Set a custom stale_timeout
self.mock_conf.aprs_network.stale_timeout = 60 # 1 minute
# Re-initialize the existing driver with new config
# (singleton pattern means we can't create a new instance)
self.driver.__init__()
self.assertEqual(self.driver.max_delta, datetime.timedelta(seconds=60))
finally:
# Restore original max_delta so other tests aren't affected
self.driver.max_delta = original_max_delta
self.mock_conf.aprs_network.stale_timeout = 120
def test_is_enabled_true(self):
"""Test is_enabled returns True when APRS-IS is enabled."""
self.mock_conf.aprs_network.enabled = True
+59 -4
View File
@@ -70,18 +70,22 @@ class TestDupePacketFilter(unittest.TestCase):
self.assertEqual(result, packet)
def test_filter_duplicate_within_timeout(self):
"""Test filter() with duplicate within timeout."""
"""Test filter() with duplicate within timeout.
The found (previously stored) packet is already processed.
The new incoming duplicate should be dropped.
"""
from oslo_config import cfg
CONF = cfg.CONF
CONF.packet_dupe_timeout = 60
packet = fake.fake_packet(msg_number='123')
packet.processed = True
packet.timestamp = 1000
mock_list_instance = mock.MagicMock()
found_packet = fake.fake_packet(msg_number='123')
found_packet.processed = True # the stored packet was already processed
found_packet.timestamp = 1050 # Within 60 second timeout
mock_list_instance.find.return_value = found_packet
self.filter.pl = mock_list_instance
@@ -92,18 +96,23 @@ class TestDupePacketFilter(unittest.TestCase):
mock_log.warning.assert_called()
def test_filter_duplicate_after_timeout(self):
"""Test filter() with duplicate after timeout."""
"""Test filter() with duplicate after timeout.
The found (previously stored) packet is already processed,
but it arrived long ago (outside the dupe timeout window).
The new incoming duplicate should be re-processed with a warning.
"""
from oslo_config import cfg
CONF = cfg.CONF
CONF.packet_dupe_timeout = 60
packet = fake.fake_packet(msg_number='123')
packet.processed = True
packet.timestamp = 2000
mock_list_instance = mock.MagicMock()
found_packet = fake.fake_packet(msg_number='123')
found_packet.processed = True # the stored packet was already processed
found_packet.timestamp = 1000 # More than 60 seconds ago
mock_list_instance.find.return_value = found_packet
self.filter.pl = mock_list_instance
@@ -112,3 +121,49 @@ class TestDupePacketFilter(unittest.TestCase):
result = self.filter.filter(packet)
self.assertEqual(result, packet) # Should pass
mock_log.warning.assert_called()
def test_filter_aprs_retransmit_via_different_digi(self):
"""Regression test for the production duplicate-reply bug.
Scenario (observed 2026-06-02 in aprsd-REPEAT logs):
09:49:14 RX MessagePacket:9028 KM6LYW...qAOKM6LYW-2REPEAT "N 2"
processed, NearestPlugin replies sent (msg 2385, 2386)
09:49:47 TX AckPacket:9028 (2 of 3) KM6LYW never got the ack
09:50:17 RX MessagePacket:9028 KM6LYW...qARGTOWNREPEAT "N 2"
same msgNo, different digipeater path, 63 seconds later
BUG: DupePacketFilter passed it through because it was
checking packet.processed (new packet, always False)
instead of found.processed (stored packet, True)
NearestPlugin ran again 4 replies sent instead of 2
The fix: check found.processed, not packet.processed.
"""
from oslo_config import cfg
CONF = cfg.CONF
CONF.packet_dupe_timeout = 300 # 5 minute default
# The retransmit arrives 63 seconds after the first receipt.
first_timestamp = 1000.0
retransmit_timestamp = first_timestamp + 63
# Incoming duplicate — freshly decoded, processed=False (always)
retransmit = fake.fake_packet(msg_number='9028')
retransmit.timestamp = retransmit_timestamp
# What PacketList holds from the first receipt — already processed
first_receipt = fake.fake_packet(msg_number='9028')
first_receipt.processed = True
first_receipt.timestamp = first_timestamp
mock_list_instance = mock.MagicMock()
mock_list_instance.find.return_value = first_receipt
self.filter.pl = mock_list_instance
with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log:
result = self.filter.filter(retransmit)
self.assertIsNone(result) # Must be dropped — no duplicate reply
mock_log.warning.assert_called_once()
warning_msg = mock_log.warning.call_args[0][0]
self.assertIn('9028', warning_msg)
self.assertIn('already tracked', warning_msg)
+135 -1
View File
@@ -1,6 +1,6 @@
import unittest
from aprsd.packets import tracker
from aprsd.packets import core, tracker
from tests import fake
@@ -218,3 +218,137 @@ class TestPacketTrack(unittest.TestCase):
pt.tx(fake.fake_packet(msg_number='456'))
self.assertEqual(len(pt), 2)
def test_tx_skips_beacon_packet(self):
"""Test tx() does not track BeaconPackets.
BeaconPackets are fire-and-forget they never receive an ack,
so tracking them would cause the scheduler to retransmit them
as unwanted duplicates flooding RF.
"""
pt = tracker.PacketTrack()
beacon = core.BeaconPacket(
from_call='KFAKE',
to_call='APRS',
latitude=38.0,
longitude=-121.0,
comment='Test Beacon',
)
beacon.prepare(create_msg_number=True)
initial_total = pt.total_tracked
pt.tx(beacon)
# Beacon should NOT be tracked
self.assertEqual(len(pt), 0)
self.assertEqual(pt.total_tracked, initial_total)
def test_tx_beacon_not_tracked_even_with_retry_count(self):
"""Test tx() skips BeaconPacket regardless of retry_count setting."""
pt = tracker.PacketTrack()
beacon = core.BeaconPacket(
from_call='KFAKE',
to_call='APDW16',
latitude=38.0,
longitude=-121.0,
comment='WebChat Beacon',
)
beacon.retry_count = 3 # Even with retries set, don't track
beacon.prepare(create_msg_number=True)
pt.tx(beacon)
self.assertEqual(len(pt), 0)
def test_tx_ack_not_reset_when_already_tracked(self):
"""Test tx() does not reset send_count for an ack already being tracked.
When the same message arrives via multiple digipeater paths, each
copy triggers an ack send. The tracker must NOT reset send_count
on the existing ack, otherwise the retry counter restarts and
floods RF with duplicate acks.
"""
pt = tracker.PacketTrack()
# First ack for msgNo '8817'
ack1 = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='8817',
)
pt.tx(ack1)
self.assertIn('8817', pt.data)
self.assertEqual(pt.data['8817'].send_count, 0)
# Simulate ack being partially sent (scheduler incremented send_count)
pt.data['8817'].send_count = 2
# Second ack for the same msgNo (from a digi copy of the message)
ack2 = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='8817',
)
pt.tx(ack2)
# send_count must NOT be reset to 0
self.assertEqual(pt.data['8817'].send_count, 2)
# total_tracked should not have incremented again
self.assertEqual(pt.total_tracked, 1)
def test_tx_ack_tracked_on_first_occurrence(self):
"""Test tx() properly tracks an ack on first occurrence."""
pt = tracker.PacketTrack()
ack = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='100',
)
pt.tx(ack)
self.assertIn('100', pt.data)
self.assertEqual(pt.data['100'].send_count, 0)
self.assertEqual(pt.total_tracked, 1)
def test_tx_message_packet_still_resets_on_duplicate(self):
"""Test that non-ack packets still get reset if sent again.
MessagePackets may legitimately need to be re-sent with fresh
retry state (e.g., user re-sends a message).
"""
pt = tracker.PacketTrack()
pkt = fake.fake_packet(msg_number='999')
pt.tx(pkt)
pt.data['999'].send_count = 2
# Re-sending the same message should reset
pkt2 = fake.fake_packet(msg_number='999')
pt.tx(pkt2)
self.assertEqual(pt.data['999'].send_count, 0)
def test_heavy_traffic_multiple_digi_paths(self):
"""Simulate heavy traffic: same message arrives via 5 digipeater paths.
Each arrival triggers an ack. Only the first ack should be tracked.
Subsequent ack sends for the same msgNo must not reset the tracker,
preventing an ack flood on RF.
"""
pt = tracker.PacketTrack()
# Simulate 5 copies of the same message arriving via different paths
for i in range(5):
ack = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='8817',
)
pt.tx(ack)
# After first add, simulate partial sending
if i == 0:
pt.data['8817'].send_count = 1
# Only tracked once, send_count preserved from after first send
self.assertEqual(pt.total_tracked, 1)
self.assertEqual(pt.data['8817'].send_count, 1)
+20
View File
@@ -0,0 +1,20 @@
import stat
from pathlib import Path
def test_bulletin_aprschat_script_contents():
repo_root = Path(__file__).resolve().parents[1]
script = repo_root / 'tools' / 'bulletin-aprschat.sh'
assert script.exists()
assert script.stat().st_mode & stat.S_IXUSR
lines = script.read_text().splitlines()
send_lines = [line for line in lines if line.startswith('aprsd send-message -n')]
assert send_lines == [
'aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"',
'aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"',
'aprsd send-message -n BLN2 "Android app for APRS chat and messaging"',
'aprsd send-message -n BLN3 "Search Google Play for APRS Chat"',
]
+132 -14
View File
@@ -1,3 +1,4 @@
import datetime
import threading
import time
import unittest
@@ -42,11 +43,9 @@ class TestAPRSDThread(unittest.TestCase):
"""Test thread initialization."""
thread = TestThread('TestThread1')
self.assertEqual(thread.name, 'TestThread1')
self.assertFalse(thread.thread_stop)
self.assertFalse(thread._shutdown_event.is_set())
self.assertFalse(thread._pause)
self.assertEqual(thread.loop_count, 1)
# Should be registered in thread list
self.assertEqual(thread.loop_count, 0) # Was 1, now starts at 0
thread_list = APRSDThreadList()
self.assertIn(thread, thread_list.threads_list)
@@ -54,8 +53,7 @@ class TestAPRSDThread(unittest.TestCase):
"""Test _should_quit() method."""
thread = TestThread('TestThread2')
self.assertFalse(thread._should_quit())
thread.thread_stop = True
thread._shutdown_event.set()
self.assertTrue(thread._should_quit())
def test_pause_unpause(self):
@@ -72,20 +70,93 @@ class TestAPRSDThread(unittest.TestCase):
def test_stop(self):
"""Test stop() method."""
thread = TestThread('TestThread4')
self.assertFalse(thread.thread_stop)
self.assertFalse(thread._shutdown_event.is_set())
thread.stop()
self.assertTrue(thread.thread_stop)
self.assertTrue(thread._shutdown_event.is_set())
def test_loop_age(self):
"""Test loop_age() method."""
import datetime
thread = TestThread('TestThread5')
age = thread.loop_age()
self.assertIsInstance(age, datetime.timedelta)
self.assertGreaterEqual(age.total_seconds(), 0)
def test_daemon_attribute_default(self):
"""Test that daemon attribute defaults to True."""
thread = TestThread('DaemonTest')
self.assertTrue(thread.daemon)
def test_daemon_attribute_override(self):
"""Test that daemon attribute can be overridden via class attribute."""
class NonDaemonThread(APRSDThread):
daemon = False
def loop(self):
return False
thread = NonDaemonThread('NonDaemonTest')
self.assertFalse(thread.daemon)
def test_period_attribute_default(self):
"""Test that period attribute defaults to 1."""
thread = TestThread('PeriodTest')
self.assertEqual(thread.period, 1)
def test_period_attribute_override(self):
"""Test that period attribute can be overridden via class attribute."""
class LongPeriodThread(APRSDThread):
period = 60
def loop(self):
return False
thread = LongPeriodThread('LongPeriodTest')
self.assertEqual(thread.period, 60)
def test_shutdown_event_exists(self):
"""Test that _shutdown_event is created."""
thread = TestThread('EventTest')
self.assertIsInstance(thread._shutdown_event, threading.Event)
self.assertFalse(thread._shutdown_event.is_set())
def test_wait_returns_false_on_timeout(self):
"""Test that wait() returns False when timeout expires."""
thread = TestThread('WaitTimeoutTest')
start = time.time()
result = thread.wait(timeout=0.1)
elapsed = time.time() - start
self.assertFalse(result)
self.assertGreaterEqual(elapsed, 0.1)
def test_wait_returns_true_when_stopped(self):
"""Test that wait() returns True immediately when stop() was called."""
thread = TestThread('WaitStopTest')
thread.stop()
start = time.time()
result = thread.wait(timeout=10)
elapsed = time.time() - start
self.assertTrue(result)
self.assertLess(elapsed, 1)
def test_wait_uses_period_by_default(self):
"""Test that wait() uses self.period when no timeout specified."""
class ShortPeriodThread(APRSDThread):
period = 0.1
def loop(self):
return False
thread = ShortPeriodThread('ShortPeriodTest')
start = time.time()
result = thread.wait()
elapsed = time.time() - start
self.assertFalse(result)
self.assertGreaterEqual(elapsed, 0.1)
self.assertLess(elapsed, 0.5)
def test_str(self):
"""Test __str__() method."""
thread = TestThread('TestThread6')
@@ -253,10 +324,9 @@ class TestAPRSDThreadList(unittest.TestCase):
thread2 = TestThread('TestThread9')
thread_list.add(thread1)
thread_list.add(thread2)
thread_list.stop_all()
self.assertTrue(thread1.thread_stop)
self.assertTrue(thread2.thread_stop)
self.assertTrue(thread1._shutdown_event.is_set())
self.assertTrue(thread2._shutdown_event.is_set())
def test_pause_all(self):
"""Test pause_all() method."""
@@ -334,3 +404,51 @@ class TestAPRSDThreadList(unittest.TestCase):
# Should handle concurrent access without errors
self.assertGreaterEqual(len(thread_list), 0)
def test_join_non_daemon(self):
"""Test join_non_daemon() waits for non-daemon threads."""
class NonDaemonTestThread(APRSDThread):
daemon = False
def __init__(self, name):
super().__init__(name)
self.finished = False
def loop(self):
time.sleep(0.2)
self.finished = True
return False
thread_list = APRSDThreadList()
thread = NonDaemonTestThread('NonDaemonJoinTest')
thread_list.add(thread)
thread.start()
# Stop triggers the event, thread should finish its loop then exit
thread.stop()
thread_list.join_non_daemon(timeout=5.0)
self.assertTrue(thread.finished or not thread.is_alive())
def test_join_non_daemon_skips_daemon_threads(self):
"""Test join_non_daemon() does not wait for daemon threads."""
thread_list = APRSDThreadList()
# Clear existing threads
thread_list.threads_list = []
# Create a daemon thread that loops forever
thread = TestThread('DaemonSkipTest', should_loop=True)
thread_list.add(thread)
thread.start()
# This should return quickly since it's a daemon thread
start = time.time()
thread_list.join_non_daemon(timeout=0.1)
elapsed = time.time() - start
self.assertLess(elapsed, 0.5) # Should not wait for daemon
# Cleanup
thread.stop()
thread.join(timeout=1)
+18 -17
View File
@@ -15,17 +15,18 @@ class TestAPRSDRXThread(unittest.TestCase):
self.packet_queue = queue.Queue()
self.rx_thread = rx.APRSDRXThread(self.packet_queue)
self.rx_thread.pkt_count = 0 # Reset packet count
# Mock time.sleep to speed up tests
self.sleep_patcher = mock.patch('aprsd.threads.rx.time.sleep')
self.mock_sleep = self.sleep_patcher.start()
# Mock self.wait to speed up tests
self.wait_patcher = mock.patch.object(
self.rx_thread, 'wait', return_value=False
)
self.mock_wait = self.wait_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.wait_patcher.stop()
self.rx_thread.stop()
if self.rx_thread.is_alive():
self.rx_thread.join(timeout=1)
# Stop the sleep patcher
self.sleep_patcher.stop()
def test_init(self):
"""Test initialization."""
@@ -39,13 +40,13 @@ class TestAPRSDRXThread(unittest.TestCase):
self.rx_thread._client = mock.MagicMock()
self.rx_thread.stop()
self.assertTrue(self.rx_thread.thread_stop)
self.assertTrue(self.rx_thread._shutdown_event.is_set())
self.rx_thread._client.close.assert_called()
def test_stop_no_client(self):
"""Test stop() when client is None."""
self.rx_thread.stop()
self.assertTrue(self.rx_thread.thread_stop)
self.assertTrue(self.rx_thread._shutdown_event.is_set())
def test_loop_no_client(self):
"""Test loop() when client is None."""
@@ -237,18 +238,18 @@ class TestAPRSDFilterThread(unittest.TestCase):
"""Process packet - required by base class."""
pass
# Mock APRSDClient to avoid config requirements
self.client_patcher = mock.patch('aprsd.threads.rx.APRSDClient')
self.mock_client = self.client_patcher.start()
self.filter_thread = TestFilterThread('TestFilterThread', self.packet_queue)
# Mock time.sleep to speed up tests
self.sleep_patcher = mock.patch('aprsd.threads.rx.time.sleep')
self.mock_sleep = self.sleep_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.client_patcher.stop()
self.filter_thread.stop()
if self.filter_thread.is_alive():
self.filter_thread.join(timeout=1)
# Stop the sleep patcher
self.sleep_patcher.stop()
def test_init(self):
"""Test initialization."""
@@ -330,18 +331,18 @@ class TestAPRSDProcessPacketThread(unittest.TestCase):
def process_our_message_packet(self, packet):
pass
# Mock APRSDClient to avoid config requirements
self.client_patcher = mock.patch('aprsd.threads.rx.APRSDClient')
self.mock_client = self.client_patcher.start()
self.process_thread = ConcreteProcessThread(self.packet_queue)
# Mock time.sleep to speed up tests
self.sleep_patcher = mock.patch('aprsd.threads.rx.time.sleep')
self.mock_sleep = self.sleep_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.client_patcher.stop()
self.process_thread.stop()
if self.process_thread.is_alive():
self.process_thread.join(timeout=1)
# Stop the sleep patcher
self.sleep_patcher.stop()
def test_init(self):
"""Test initialization."""
+46 -40
View File
@@ -72,28 +72,36 @@ class TestAPRSDStatsStoreThread(unittest.TestCase):
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'))
with mock.patch('aprsd.threads.stats.CONF') as mock_conf:
mock_conf.stats_store_interval = 10
thread = APRSDStatsStoreThread()
self.assertEqual(thread.name, 'StatsStore')
self.assertEqual(thread.period, 10)
self.assertFalse(thread.daemon)
self.assertTrue(hasattr(thread, 'loop_count'))
def test_init_with_custom_interval(self):
"""Test APRSDStatsStoreThread uses stats_store_interval from config."""
with mock.patch('aprsd.threads.stats.CONF') as mock_conf:
mock_conf.stats_store_interval = 60
thread = APRSDStatsStoreThread()
self.assertEqual(thread.period, 60)
def test_loop_with_save(self):
"""Test loop method when save interval is reached."""
"""Test loop method saves stats every call."""
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,
mock.patch.object(thread, 'wait'),
):
# 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()
@@ -104,45 +112,43 @@ class TestAPRSDStatsStoreThread(unittest.TestCase):
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."""
def test_loop_calls_wait(self):
"""Test loop method calls wait() at the end."""
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,
mock.patch('aprsd.utils.objectstore.ObjectStoreMixin.save'),
mock.patch.object(thread, 'wait') as mock_wait,
):
# 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()
# Should have called wait
mock_wait.assert_called_once()
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:
with (
mock.patch('aprsd.stats.collector.Collector') as mock_collector_class,
mock.patch.object(thread, 'wait'),
):
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()
@@ -177,24 +183,31 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
self.assertEqual(thread.period, 15)
self.assertFalse(thread.send_packetlist)
def test_loop_skips_push_when_period_not_reached(self):
"""Test loop does not POST when loop_count not divisible by period."""
def test_loop_pushes_stats_every_call(self):
"""Test loop POSTs stats on every call (timing controlled by wait)."""
thread = APRSDPushStatsThread(
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 3 # 3 % 10 != 0
with (
mock.patch('aprsd.threads.stats.collector.Collector') as mock_collector,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector.return_value.collect.return_value = {}
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
mock_post.return_value.status_code = 200
mock_post.return_value.raise_for_status = mock.Mock()
result = thread.loop()
self.assertTrue(result)
mock_collector.return_value.collect.assert_not_called()
mock_post.assert_not_called()
mock_collector.return_value.collect.assert_called_once_with(serializable=True)
mock_post.assert_called_once()
def test_loop_pushes_stats_and_removes_packetlist_by_default(self):
"""Test loop collects stats, POSTs to url/stats, and strips PacketList.packets."""
@@ -203,7 +216,6 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
frequency_seconds=10,
send_packetlist=False,
)
thread.loop_count = 10
collected = {
'PacketList': {'packets': [1, 2, 3], 'rx': 5, 'tx': 1},
@@ -215,7 +227,7 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
@@ -247,7 +259,6 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
frequency_seconds=10,
send_packetlist=True,
)
thread.loop_count = 10
collected = {'PacketList': {'packets': [1, 2, 3], 'rx': 5}}
@@ -256,7 +267,7 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
@@ -276,14 +287,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -306,14 +316,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -337,14 +346,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -368,14 +376,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -398,7 +405,6 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
frequency_seconds=10,
send_packetlist=False,
)
thread.loop_count = 10
collected = {'Only': 'data', 'No': 'PacketList'}
@@ -407,7 +413,7 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
+233 -30
View File
@@ -596,9 +596,13 @@ class TestSendPacketThread(unittest.TestCase):
tracker.PacketTrack._instance = None
self.packet = fake.fake_packet(msg_number='123')
self.thread = tx.SendPacketThread(self.packet)
# Mock wait to speed up tests
self.wait_patcher = mock.patch.object(self.thread, 'wait', return_value=False)
self.mock_wait = self.wait_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.wait_patcher.stop()
self.thread.stop()
if self.thread.is_alive():
self.thread.join(timeout=1)
@@ -608,7 +612,8 @@ class TestSendPacketThread(unittest.TestCase):
"""Test initialization."""
self.assertEqual(self.thread.packet, self.packet)
self.assertIn('TX-', self.thread.name)
self.assertEqual(self.thread.loop_count, 1)
# loop_count starts at 0 from base class, incremented in run()
self.assertEqual(self.thread.loop_count, 0)
@mock.patch('aprsd.threads.tx.tracker.PacketTrack')
def test_loop_packet_acked(self, mock_tracker_class):
@@ -761,7 +766,8 @@ class TestBeaconSendThread(unittest.TestCase):
"""Test initialization."""
thread = tx.BeaconSendThread()
self.assertEqual(thread.name, 'BeaconSendThread')
self.assertEqual(thread._loop_cnt, 1)
self.assertEqual(thread.period, 10) # Uses CONF.beacon_interval
thread.stop()
def test_init_no_coordinates(self):
"""Test initialization without coordinates."""
@@ -772,39 +778,27 @@ class TestBeaconSendThread(unittest.TestCase):
CONF.longitude = None
thread = tx.BeaconSendThread()
self.assertTrue(thread.thread_stop)
self.assertTrue(thread._shutdown_event.is_set())
thread.stop()
@mock.patch('aprsd.threads.tx.send')
def test_loop_send_beacon(self, mock_send):
"""Test loop() sends beacon at interval."""
"""Test loop() sends beacon."""
from oslo_config import cfg
CONF = cfg.CONF
CONF.beacon_interval = 1
CONF.latitude = 40.7128
CONF.longitude = -74.0060
thread = tx.BeaconSendThread()
thread._loop_cnt = 1
# Mock wait to return False (no shutdown)
with mock.patch.object(thread, 'wait', return_value=False):
result = thread.loop()
result = thread.loop()
self.assertTrue(result)
mock_send.assert_called()
@mock.patch('aprsd.threads.tx.send')
def test_loop_not_time(self, mock_send):
"""Test loop() doesn't send before interval."""
from oslo_config import cfg
CONF = cfg.CONF
CONF.beacon_interval = 10
thread = tx.BeaconSendThread()
thread._loop_cnt = 5
result = thread.loop()
self.assertTrue(result)
mock_send.assert_not_called()
self.assertTrue(result)
mock_send.assert_called()
thread.stop()
@mock.patch('aprsd.threads.tx.send')
@mock.patch('aprsd.threads.tx.APRSDClient')
@@ -814,13 +808,222 @@ class TestBeaconSendThread(unittest.TestCase):
CONF = cfg.CONF
CONF.beacon_interval = 1
CONF.latitude = 40.7128
CONF.longitude = -74.0060
thread = tx.BeaconSendThread()
thread._loop_cnt = 1
mock_send.side_effect = Exception('Send error')
with mock.patch('aprsd.threads.tx.LOG') as mock_log:
result = thread.loop()
self.assertTrue(result)
mock_log.error.assert_called()
mock_client_class.return_value.reset.assert_called()
# Mock wait to return False (no shutdown signaled during error wait)
with mock.patch.object(thread, 'wait', return_value=False):
result = thread.loop()
self.assertTrue(result)
mock_log.error.assert_called()
mock_client_class.return_value.reset.assert_called()
thread.stop()
class TestSchedulerTimingGuards(unittest.TestCase):
"""Tests for scheduler timing guards that prevent threadpool race conditions.
These tests verify that the scheduler does NOT submit workers to the
threadpool when a packet was recently sent, preventing the race where
multiple workers fire before send_count is incremented.
"""
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
tracker.PacketTrack.data = {}
tracker.PacketTrack.total_tracked = 0
def tearDown(self):
"""Clean up after tests."""
tracker.PacketTrack._instance = None
tracker.PacketTrack.data = {}
tracker.PacketTrack.total_tracked = 0
def test_ack_scheduler_skips_recently_sent(self):
"""AckSendSchedulerThread must not re-submit if sent < 31 seconds ago.
This prevents the threadpool race where multiple workers fire for
the same ack before send_count is incremented, causing rapid-fire
duplicate acks on RF.
"""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 1
ack_packet.last_send_time = int(round(time.time())) # Just sent
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should NOT submit — sent too recently
mock_submit.assert_not_called()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_ack_scheduler_submits_after_31_seconds(self):
"""AckSendSchedulerThread submits worker after 31 second cooldown."""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 1
ack_packet.last_send_time = int(round(time.time())) - 32 # 32 seconds ago
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should submit — enough time has passed
mock_submit.assert_called_once()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_ack_scheduler_submits_first_send(self):
"""AckSendSchedulerThread submits worker on first send (no last_send_time)."""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 0
ack_packet.last_send_time = None # Never sent
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should submit — first time, no last_send_time
mock_submit.assert_called_once()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_packet_scheduler_skips_recently_sent(self):
"""PacketSendSchedulerThread must not re-submit if sent recently.
Similar to ack scheduler, prevents threadpool race for message
packets where multiple workers could fire before send_count updates.
"""
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
try:
packet = fake.fake_packet(msg_number='123')
packet.send_count = 0
packet.retry_count = 3
packet.last_send_time = int(round(time.time())) # Just sent
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['123']
mock_tracker.get.return_value = packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should NOT submit — sent too recently
mock_submit.assert_not_called()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_packet_scheduler_submits_after_backoff(self):
"""PacketSendSchedulerThread submits after exponential backoff elapses."""
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
try:
packet = fake.fake_packet(msg_number='123')
packet.send_count = 1 # Second send: backoff = (1+1)*31 = 62s
packet.retry_count = 3
packet.last_send_time = int(round(time.time())) - 63 # 63 seconds ago
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['123']
mock_tracker.get.return_value = packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should submit — backoff period has elapsed
mock_submit.assert_called_once()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_ack_scheduler_cleans_up_max_retries(self):
"""AckSendSchedulerThread removes packets that hit max retries."""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 3 # At max
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
mock_submit.assert_not_called()
# Should have called remove to clean up
mock_tracker.remove.assert_called_once_with('12')
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_packet_scheduler_cleans_up_max_retries(self):
"""PacketSendSchedulerThread removes packets that hit max retries."""
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
try:
packet = fake.fake_packet(msg_number='123')
packet.send_count = 3
packet.retry_count = 3
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['123']
mock_tracker.get.return_value = packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
mock_submit.assert_not_called()
# Should have called remove to clean up
mock_tracker.remove.assert_called_once_with('123')
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Send APRS bulletins announcing APRS Chat on Google Play
set -e
source ~/devel/mine/hamradio/aprsd/.venv/bin/activate
export APRS_LOGIN=WB4BOR
export APRS_PASSWORD=24496
aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"
sleep 2
aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"
sleep 2
aprsd send-message -n BLN2 "Android app for APRS chat and messaging"
sleep 2
aprsd send-message -n BLN3 "Search Google Play for APRS Chat"
sleep 2
Generated
+735 -617
View File
File diff suppressed because it is too large Load Diff