* docs: update changelog for atomic object store writes
* docs: generate changelog in docs source directory
* build: install development dependencies from pyproject
Replace misleading bare 'pass' with explicit comment explaining that
PacketTrack polling is handled by PacketSendSchedulerThread /
AckSendSchedulerThread. Restructure condition to
'if not (scheduler and scheduler.is_alive()):' so the fallback
thread is only started when the scheduler is genuinely unavailable.
Closes#244
@wrapt.synchronized without an argument acquires the lock on the
wrapped object itself, not the class-level lock. For APRSDThreadList
the intent is always to hold the shared class-level lock.
Pass the class-level lock to both decorators:
@wrapt.synchronized -> @wrapt.synchronized(lock)
Closes#248
The key property had no fallback return when neither raw_timestamp
nor wx_raw_timestamp was set, silently returning None. This causes
KeyErrors when callers use the key as a dict key.
Add an explicit fallback: return self.from_call.
Closes#246
The method was silently ignored because __post__init__ (double underscores
in the middle) is not a dataclass lifecycle hook. Rename to __post_init__
so the warning is emitted when response is set.
Closes#245
Replace misleading bare 'pass' with explicit comment explaining that
PacketTrack polling is handled by PacketSendSchedulerThread /
AckSendSchedulerThread. Restructure condition to
'if not (scheduler and scheduler.is_alive()):' so the fallback
thread is only started when the scheduler is genuinely unavailable.
Closes#244
Both APIs are deprecated since Python 3.12 and scheduled for removal.
- aprsd/packets/core.py: datetime.utcfromtimestamp(ts)
-> datetime.fromtimestamp(ts, tz=timezone.utc)
- aprsd/plugins/time.py: pytz.datetime.datetime.utcnow()
-> datetime.now(timezone.utc).replace(tzinfo=None)
(naive UTC returned to preserve pytz.utc.localize() contract)
- tests/plugins/test_time.py: same fix in test setup
Closes#249
The class-level annotation 'data: list = []' creates a single list shared
among all instances. While __init__ already rebinds self.data = [], the
class-level default is confusing and fragile.
Remove the class-level default; move the annotation to __init__.
Closes#250
The guard:
if sys.version_info.major == 3 and sys.version_info.minor >= 3:
from collections.abc import MutableMapping
else:
from collections.abc import MutableMapping
has identical branches — both import from collections.abc (the correct
Python 3.3+ location). Remove the guard; keep the bare import.
Closes#251
The 'I' (isort) ruleset was commented out. The codebase now has zero
isort violations so the ruleset can be enabled to enforce import order
going forward.
Closes#252
In the else-branch of USMetarPlugin.process(), 'fromcall = fromcall'
is a self-assignment that does nothing. It was likely a leftover from
an edit that intended to reassign fromcall but forgot to write the RHS.
Remove the dead line. The variable is still used correctly on the next
line (get_aprs_fi call).
Closes#253
The @singleton decorator stored its instance in wrapper_singleton.instance
inside a closure. Tests could reset __new__-based singletons via
ClassName._instance = None, but @singleton classes had no equivalent reset
mechanism, causing state to leak between tests.
Add wrapper_singleton.reset() to every @singleton-decorated class. The
method clears wrapper_singleton.instance so the next call creates a fresh
instance, matching the __new__ singleton pattern.
Update all tests to call ClassName.reset() instead of manually setting
ClassName.instance = None (tests/client/test_client.py x5,
tests/client/test_registry.py x1).
Tests added (tests/utils/test_utils.py):
- test_singleton_has_reset: asserts reset is callable on the wrapper
- test_singleton_reset_clears_instance: verifies a new instance is created
after reset(), not the cached one
- test_singleton_instance_is_none_before_first_call: verifies the instance
lifecycle — None → populated → None after reset
Closes#240
ObjectStoreMixin.save() calls len(self) -> len(self.data) immediately.
StatsStore never set self.data in __init__, so any call to save() before
add() raised AttributeError: 'StatsStore' object has no attribute 'data',
crashing APRSDStatsStoreThread on the first tick if the path where
enable_save is True is hit.
Tests updated/added (tests/threads/test_stats.py):
- test_init: now asserts data exists and equals {} (was asserting absence)
- test_save_before_add_does_not_raise: regression guard — save() must not
raise AttributeError when called before add()
Closes#241
keys(), items(), values() each acquired self.lock then returned a live
dict view (dict_keys, dict_items, dict_values). The lock was released
on return, leaving callers with an unsynchronised view that races with
concurrent tx()/rx()/remove() calls on other threads.
Wrap each return in list() so a snapshot is taken while the lock is held.
Tests added (tests/packets/test_tracker.py):
- test_keys_returns_snapshot_not_view: asserts isinstance list and that
a subsequent mutation does not appear in the returned value
- test_items_returns_snapshot_not_view: same for items()
- test_values_returns_snapshot_not_view: same for values()
Closes#242
factory() was calling globals()[raw['_type']] on a value read from a
persisted JSON file on disk without validation, allowing an attacker who
can write to ~/.config/aprsd/ to reference arbitrary names in the module
global namespace.
Add an allowlist (_known_packet_type_names) derived lazily from
TYPE_LOOKUP. Any _type value not in the set raises ValueError before
globals() is ever called.
Tests added (tests/packets/test_packet.py):
- test_factory_known_type_roundtrip: valid known _type still deserialises
- test_factory_unknown_type_raises: module-global name ('os') is rejected
- test_factory_arbitrary_string_raises: arbitrary strings are rejected
- test_factory_empty_type_raises: empty string is rejected
- test_factory_allowlist_covers_all_type_lookup_classes: allowlist stays
in sync with TYPE_LOOKUP automatically
Closes#239
stats() had a bare 'return self.data' on the first line that:
- returned the raw internal dict without holding self.lock (race condition)
- made the locked loop below unreachable dead code
- returned the wrong shape (callers expect age/old/packet/last keys, not
the raw was_old_before_update internal key)
Remove the early return so the existing with self.lock: loop executes.
Tests added (tests/packets/test_watch_list.py):
- test_stats_empty: empty watch list returns {}
- test_stats_returns_enriched_shape: verifies the four expected keys are
present and the raw-internal 'was_old_before_update' key is absent
- test_stats_not_raw_internal_dict: returned dict must not be wl.data itself
- setUp/tearDown: reset class-level data and initialized to prevent leakage
Closes#237
- test_checks_initialised_to_false: asserts _checks is False right after
__init__, catching any regression that removes the initialisation
- test_keepalive_check_first_call_no_reset: verifies no AttributeError and
no spurious reset on the very first keepalive_check() call even when the
driver is already dead
- test_keepalive_check: remove the manual _checks = False setup that masked
the original bug; rely on the __init__ value instead
keepalive_check() reads self._checks on its first call but the
attribute was never set in __init__, causing AttributeError which
kills the KeepAliveThread on first invocation.
Closes#238
The final `return True` in is_configured() should be `return False`.
When APRS-IS is not enabled, the method always returned True, so the
startup guard in server.py never fired for misconfigured instances.
Closes#2
The aprsd/~/ directory (unexpanded tilde) contained a stale pickle
save file at aprsd/~/.config/aprsd/listen/statsstore.p. Runtime data
should never be committed. Added explicit .gitignore entry to prevent
accidental future commits.
Closes#17
* fix: rename 'list' variable in HelpPlugin to avoid shadowing Python builtin
Closes#15
aprsd/plugin.py HelpPlugin.process() used 'list' as a local variable name,
shadowing the Python built-in list type. Renamed to 'plugin_names'.
* ci: fix CI workflow for Forgejo self-hosted runners
- Switch runs-on from ubuntu-latest to docker (node:20-bookworm)
ubuntu-latest runner has no Node.js so actions/checkout@v4 fails
- Replace actions/setup-python@v5 (broken on self-hosted) with uv
- Use tox-uv instead of pip-installed tox for faster installs
* ci: use catthehacker/ubuntu:act-latest container (matches haminfo working CI)
* ci: fix master-build.yml for Forgejo self-hosted runners
Use catthehacker/ubuntu:act-latest container + uv for tox,
matching the working pattern from haminfo. Removes broken
actions/setup-python@v2 + ubuntu-latest bare runner combo.
* ci: revert workflow changes - Forgejo-specific, not for GitHub Actions
* ci: restrict master-build to master branch and tags only
The Docker build job clones from GitHub by branch name, which fails
for feature branches (sanitized slash → name mismatch). This workflow
should only run on master pushes and version tags, not on PRs.
* docs: add unreleased changelog entries for PR #233
* docs: update changelog for 5.0.1 release
DupePacketFilter.filter() was checking packet.processed (the newly
arrived duplicate) instead of found.processed (the previously stored
packet). Since all freshly decoded packets have processed=False by
default, the dupe detection branch was never reachable — every
duplicate message was passed through and re-processed.
This caused KM6LYW's retransmit of msg:9028 (via a different
digipeater path 63s after first receipt) to trigger NearestPlugin
a second time, sending 4 reply packets instead of 2 and resetting
the AckPacket retry counter back to (1 of 3).
Fix: check found.processed instead of packet.processed.
Update existing tests to reflect the correct variable under test.
Add regression test test_filter_aprs_retransmit_via_different_digi
that reproduces the exact production scenario.
The RX reader thread sets setblocking(0) and the TX writer (via aprslib
sendall) sets setblocking(1) on the same socket without synchronization.
This race condition causes partial writes where other stations' APRS-IS
stream data gets concatenated onto retransmitted packets.
Add a shared _socket_lock between send() and _socket_readlines() so the
socket blocking mode is never changed by one thread while the other is
mid-operation.
APRSDClient no longer has a .client property after the driver refactor
(commit 1c39546). Instantiating APRSDClient() is sufficient to trigger
connection via auto_connect=True.
Older versions persisted BeaconPackets to packettrack.json. On restart
these zombie beacons would be retransmitted by the scheduler. Now
PacketTrack.load() strips any BeaconPackets from the persisted data.
Workaround: delete ~/.config/aprsd/packettrack.json before restarting.
BeaconPackets are now skipped in PacketTrack — they are fire-and-forget
and never receive an ack, so tracking them caused the scheduler to
re-transmit them as unwanted duplicates.
AckPackets already being tracked are no longer reset when the same
message arrives via multiple digipeater paths, which was restarting
the retry counter and flooding RF with duplicate acks.
Added timing guards in both scheduler loops to prevent threadpool race
conditions where multiple workers could fire before send_count was
incremented.
Add stats_store_interval config option to control how frequently
the statsstore.json file is written to disk. Default remains 10
seconds for backward compatibility.
This allows reducing disk I/O in production deployments and
can help avoid potential file corruption issues when external
processes read the stats file.