mirror of
https://github.com/craigerl/aprsd.git
synced 2026-08-18 17:44:02 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a286b196a8 | |||
| dcd1294b2f | |||
| 77201f1e3d | |||
| 4f6824f671 | |||
| d06390add8 | |||
| db3a0428c0 | |||
| b064ac97a4 | |||
| 5cc918e5c2 | |||
| 9146ff76c9 | |||
| 0c515d45fe | |||
| d3281cff0b | |||
| d8134c4531 | |||
| 3a47571b60 | |||
| 4ecdec0aa4 | |||
| 490ff41cdc | |||
| 27413ab8cf | |||
| 314f4da180 | |||
| f6eb383caf | |||
| 930339d4cf | |||
| a2e07a2279 | |||
| 3a12ccb842 | |||
| 5463b8d5b5 | |||
| f2526efe1d | |||
| 96b017d59e | |||
| 8d8648e9dd | |||
| bf258e4bcf | |||
| 4ab59c6cf3 | |||
| 505c0fa8a8 | |||
| 85ebf8a274 | |||
| bc9ce61e59 | |||
| 343ec3e81c | |||
| 44b8bc572d | |||
| 43ba69e352 | |||
| b7a37322e1 | |||
| bc9b15d47a | |||
| d8747317df | |||
| 4cc90a53ed | |||
| 425ad469b0 | |||
| bbc2ccd302 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -130,7 +130,7 @@ def send_message(
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
APRSDClient().client # noqa: B018
|
||||
APRSDClient() # noqa: B018
|
||||
except LoginError:
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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→...→qAO→KM6LYW-2→REPEAT "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→...→qAR→GTOWN→REPEAT "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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"',
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Executable
+18
@@ -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
|
||||
Reference in New Issue
Block a user