ka9q-python 3.15.1__tar.gz → 3.16.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/CHANGELOG.md +107 -0
- {ka9q_python-3.15.1/ka9q_python.egg-info → ka9q_python-3.16.1}/PKG-INFO +1 -1
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/__init__.py +5 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/control.py +202 -117
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/discovery.py +86 -1
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/multi_stream.py +7 -2
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/rtp_recorder.py +15 -3
- ka9q_python-3.16.1/ka9q/status_listener.py +439 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/stream.py +15 -2
- {ka9q_python-3.15.1 → ka9q_python-3.16.1/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/SOURCES.txt +2 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/pyproject.toml +1 -1
- ka9q_python-3.16.1/tests/test_status_listener.py +347 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/LICENSE +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/MANIFEST.in +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/README.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/RECIPES.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/SECURITY.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/discover_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/stream_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/test_improvements.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/tune.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/tune_example.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/addressing.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/cli.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/compat.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/monitor.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/status.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/tui.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/types.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/utils.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_radio_compat +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/scripts/sync_types.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/setup.cfg +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/setup.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/__init__.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/conftest.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_addressing.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_integration.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_monitor.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_multicast_helpers.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_parse_rtp_samples_iq.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_security_features.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,112 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.16.1] - 2026-05-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`ChannelInfo` anchor pair is now atomic** (`channel-info`). Adds
|
|
8
|
+
`ChannelInfo.get_anchor()` / `update_anchor()` — a tuple-based
|
|
9
|
+
atomic snapshot of `(gps_time, rtp_timesnap)`. `rtp_to_wallclock`
|
|
10
|
+
now reads the pair via `get_anchor` (single GIL-atomic attribute
|
|
11
|
+
access) instead of two separate reads; `StatusListener` writes via
|
|
12
|
+
`update_anchor` (single tuple assignment).
|
|
13
|
+
|
|
14
|
+
Why: the `StatusListener` introduced in 3.16.0 refreshes the anchor
|
|
15
|
+
in place at sub-second cadence (~450 ms on a busy host). Direct
|
|
16
|
+
sequential reads of `channel.gps_time` followed by
|
|
17
|
+
`channel.rtp_timesnap` could land between the listener's two writes
|
|
18
|
+
and yield a torn pair off by one listener-cadence interval.
|
|
19
|
+
`rtp_to_wallclock` then returned a wall-time off by that much —
|
|
20
|
+
usually harmless, but consumers comparing against an external time
|
|
21
|
+
reference with a tight gate (e.g. hf-timestd's T5 LB-1421 NMEA
|
|
22
|
+
disambig at ±0.5 s) could be pushed across the threshold and fall
|
|
23
|
+
back to a chrony walk that itself fails during a post-restart
|
|
24
|
+
cascade.
|
|
25
|
+
|
|
26
|
+
Backward compatible: constructor kwargs (`gps_time=`,
|
|
27
|
+
`rtp_timesnap=`) and direct field reads are unchanged; consumers
|
|
28
|
+
that need the pair transactionally must call `get_anchor`. Adds
|
|
29
|
+
5 new tests (atomic update, construction-time seed, mixed-None
|
|
30
|
+
handling, listener path, 50-iteration consistency smoke test).
|
|
31
|
+
|
|
32
|
+
### Performance
|
|
33
|
+
|
|
34
|
+
- **`RadiodStream`: `SO_RCVBUF` raised 0 → 64 MB** (`stream.py`).
|
|
35
|
+
Mirrors the 3.16.0-cycle `multi_stream.py` change on the
|
|
36
|
+
single-channel path. Previously `RadiodStream` sockets fell back
|
|
37
|
+
to the kernel default (`rmem_default`, typically 16 MB on hosts
|
|
38
|
+
with sigmond's `rule_kernel_rcvbuf_adequate` provisioning) and were
|
|
39
|
+
vulnerable to GIL-stall packet loss with no other consumer competing
|
|
40
|
+
to drain the buffer. 64 MB matches the `MultiStream` cap; sigmond
|
|
41
|
+
provisions `net.core.rmem_max=128 MB` (after kernel doubling), so
|
|
42
|
+
the request is honored. Observed on bee1 2026-05-23 closing a
|
|
43
|
+
140 ms-stall-induced `gap=13440` resequencer event on hf-timestd's
|
|
44
|
+
T6 dedicated stream.
|
|
45
|
+
|
|
46
|
+
- **`MultiStream`: `SO_RCVBUF` raised 8 MB → 64 MB** (`multi_stream.py`).
|
|
47
|
+
Observed 412 M UDP `RcvbufErrors` on B4-100 since boot, driven by
|
|
48
|
+
GIL contention preventing Python receiver threads from draining the
|
|
49
|
+
kernel-doubled 16 MB sockets. Bigger absorber → more headroom
|
|
50
|
+
across GIL stalls before packets are dropped. After applying:
|
|
51
|
+
socket `rb` shows 134217728 (128 MB visible after kernel doubling)
|
|
52
|
+
and recv-Q sits at ~50 KB in steady state (was hitting 14 MB / 16
|
|
53
|
+
MB before). Requires `net.core.rmem_max >= 64 MB` to be honored —
|
|
54
|
+
provisioned by sigmond in
|
|
55
|
+
`/etc/sysctl.d/99-wspr-recorder.conf` alongside this change.
|
|
56
|
+
|
|
57
|
+
## [3.16.0] - 2026-05-23
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- **`StatusListener` — continuous STATUS multicast listener.** New
|
|
62
|
+
`ka9q.status_listener.StatusListener` class subscribes to radiod's
|
|
63
|
+
STATUS multicast (port 5006) in a background thread and refreshes
|
|
64
|
+
`ChannelInfo.gps_time` / `.rtp_timesnap` on every broadcast. Replaces
|
|
65
|
+
the previous one-shot `discover_channels` anchor capture, which
|
|
66
|
+
froze the timing anchor at SSRC discovery — leaving `rtp_to_wallclock`
|
|
67
|
+
to project forward from a host-clock value that drifts at the
|
|
68
|
+
chrony slew rate (~3.8 µs/s on a typical disciplined host).
|
|
69
|
+
|
|
70
|
+
Mutates the registered `ChannelInfo` in place so callers holding a
|
|
71
|
+
reference (e.g. hf-timestd's cached `_t6_channel_info`) see fresh
|
|
72
|
+
values immediately. Supports per-SSRC and wildcard callbacks for
|
|
73
|
+
explicit notification. Uses `SO_REUSEPORT` so it can coexist with
|
|
74
|
+
`RadiodControl`'s own status socket without stealing tune/discover
|
|
75
|
+
responses — multicast packets are delivered to every joined socket.
|
|
76
|
+
|
|
77
|
+
Opt-in via `RadiodControl.start_status_listener()`; closing the
|
|
78
|
+
control object stops the listener. See class docstring for usage.
|
|
79
|
+
|
|
80
|
+
- **`RadiodControl.start_status_listener(...)` / `.stop_status_listener(...)`
|
|
81
|
+
/ `.status_listener` property** — convenience wiring to attach a
|
|
82
|
+
`StatusListener` to an existing control session. Listener is
|
|
83
|
+
stopped automatically by `RadiodControl.close()`.
|
|
84
|
+
|
|
85
|
+
### Why this exists
|
|
86
|
+
|
|
87
|
+
Before this release, every ka9q-python consumer (hf-timestd, codar,
|
|
88
|
+
psk, hfdl, wspr, wsprdaemon-client, gpsdo-monitor) labeled data via
|
|
89
|
+
`rtp_to_wallclock(rtp, channel)` with a ChannelInfo whose
|
|
90
|
+
`gps_time`/`rtp_timesnap` were captured once at SSRC discovery.
|
|
91
|
+
For long-running services this meant labels drifted from GPSDO truth
|
|
92
|
+
at the host-clock slew rate — on bee1 (`chrony` slewing at
|
|
93
|
+
~3.8 µs/s), labels accumulated ~330 ms of drift per day.
|
|
94
|
+
|
|
95
|
+
The chrony SHM-push side of hf-timestd's BPSK PPS path (HPPS / HFPS)
|
|
96
|
+
manifested this most visibly: TS-1 source reported tracking with
|
|
97
|
+
1 ns standard deviation but drifted at the host slew rate, blocking
|
|
98
|
+
DASI2 grant deployment. Faster anchor refresh closes the drift.
|
|
99
|
+
|
|
100
|
+
### Backwards compatibility
|
|
101
|
+
|
|
102
|
+
- All existing tests pass unchanged. The listener is **opt-in** —
|
|
103
|
+
consumers that don't call `start_status_listener()` see no behavior
|
|
104
|
+
change.
|
|
105
|
+
- `ChannelInfo` is unchanged structurally; only its mutability
|
|
106
|
+
contract is clarified (the timing fields were always documented as
|
|
107
|
+
"the latest snapshot", which is now true continuously rather than
|
|
108
|
+
once).
|
|
109
|
+
|
|
3
110
|
## [3.15.1] - 2026-05-21
|
|
4
111
|
|
|
5
112
|
### Fixed
|
|
@@ -115,6 +115,7 @@ from .managed_stream import (
|
|
|
115
115
|
)
|
|
116
116
|
from .multi_stream import MultiStream
|
|
117
117
|
from .spectrum_stream import SpectrumStream
|
|
118
|
+
from .status_listener import StatusListener, StatusListenerStats
|
|
118
119
|
|
|
119
120
|
__all__ = [
|
|
120
121
|
# Control
|
|
@@ -178,6 +179,10 @@ __all__ = [
|
|
|
178
179
|
# Spectrum Stream (FFT bin data receiver)
|
|
179
180
|
'SpectrumStream',
|
|
180
181
|
|
|
182
|
+
# Continuous STATUS listener (live timing anchor refresh)
|
|
183
|
+
'StatusListener',
|
|
184
|
+
'StatusListenerStats',
|
|
185
|
+
|
|
181
186
|
# Utilities
|
|
182
187
|
'generate_multicast_ip',
|
|
183
188
|
'ChannelMonitor',
|
|
@@ -682,6 +682,136 @@ def decode_socket(data: bytes, length: int) -> dict:
|
|
|
682
682
|
return {'family': 'unknown', 'address': '', 'port': 0}
|
|
683
683
|
|
|
684
684
|
|
|
685
|
+
def decode_status_dict(buffer: bytes) -> dict:
|
|
686
|
+
"""Decode a radiod STATUS packet into a flat dictionary.
|
|
687
|
+
|
|
688
|
+
Standalone module-level decoder — does NOT require a RadiodControl
|
|
689
|
+
instance, so it's safe to call from contexts that should not open
|
|
690
|
+
a control socket (notably :class:`ka9q.status_listener.StatusListener`,
|
|
691
|
+
which would otherwise drag in a full control session just to parse
|
|
692
|
+
bytes).
|
|
693
|
+
|
|
694
|
+
Returns an empty dict for non-STATUS packets (first byte != 0).
|
|
695
|
+
Unknown TLV tags are silently skipped.
|
|
696
|
+
|
|
697
|
+
Used by :meth:`RadiodControl._decode_status_response` (which adds a
|
|
698
|
+
metric bump on top of the same parse) and by the status listener.
|
|
699
|
+
"""
|
|
700
|
+
status: dict = {}
|
|
701
|
+
|
|
702
|
+
if len(buffer) == 0 or buffer[0] != 0:
|
|
703
|
+
return status # Not a status response
|
|
704
|
+
|
|
705
|
+
cp = 1 # Skip packet type byte
|
|
706
|
+
|
|
707
|
+
while cp < len(buffer):
|
|
708
|
+
if cp >= len(buffer):
|
|
709
|
+
break
|
|
710
|
+
|
|
711
|
+
type_val = buffer[cp]
|
|
712
|
+
cp += 1
|
|
713
|
+
|
|
714
|
+
if type_val == StatusType.EOL:
|
|
715
|
+
break
|
|
716
|
+
|
|
717
|
+
if cp >= len(buffer):
|
|
718
|
+
break
|
|
719
|
+
|
|
720
|
+
optlen = buffer[cp]
|
|
721
|
+
cp += 1
|
|
722
|
+
|
|
723
|
+
# Handle extended length encoding
|
|
724
|
+
if optlen & 0x80:
|
|
725
|
+
length_of_length = optlen & 0x7f
|
|
726
|
+
optlen = 0
|
|
727
|
+
for _ in range(length_of_length):
|
|
728
|
+
if cp >= len(buffer):
|
|
729
|
+
break
|
|
730
|
+
optlen = (optlen << 8) | buffer[cp]
|
|
731
|
+
cp += 1
|
|
732
|
+
|
|
733
|
+
if cp + optlen > len(buffer):
|
|
734
|
+
break
|
|
735
|
+
|
|
736
|
+
data = buffer[cp:cp + optlen]
|
|
737
|
+
|
|
738
|
+
# Decode based on type
|
|
739
|
+
if type_val == StatusType.COMMAND_TAG:
|
|
740
|
+
status['command_tag'] = decode_int32(data, optlen)
|
|
741
|
+
elif type_val == StatusType.GPS_TIME:
|
|
742
|
+
status['gps_time'] = decode_int64(data, optlen)
|
|
743
|
+
elif type_val == StatusType.RTP_TIMESNAP:
|
|
744
|
+
status['rtp_timesnap'] = decode_int32(data, optlen)
|
|
745
|
+
elif type_val == StatusType.RADIO_FREQUENCY:
|
|
746
|
+
status['frequency'] = decode_double(data, optlen)
|
|
747
|
+
elif type_val == StatusType.OUTPUT_SSRC:
|
|
748
|
+
status['ssrc'] = decode_int32(data, optlen)
|
|
749
|
+
elif type_val == StatusType.AGC_ENABLE:
|
|
750
|
+
status['agc_enable'] = decode_bool(data, optlen)
|
|
751
|
+
elif type_val == StatusType.GAIN:
|
|
752
|
+
status['gain'] = decode_float(data, optlen)
|
|
753
|
+
elif type_val == StatusType.RF_GAIN:
|
|
754
|
+
status['rf_gain'] = decode_float(data, optlen)
|
|
755
|
+
elif type_val == StatusType.RF_ATTEN:
|
|
756
|
+
status['rf_atten'] = decode_float(data, optlen)
|
|
757
|
+
elif type_val == StatusType.RF_AGC:
|
|
758
|
+
status['rf_agc'] = decode_int(data, optlen)
|
|
759
|
+
elif type_val == StatusType.PRESET:
|
|
760
|
+
status['preset'] = decode_string(data, optlen)
|
|
761
|
+
elif type_val == StatusType.LOW_EDGE:
|
|
762
|
+
status['low_edge'] = decode_float(data, optlen)
|
|
763
|
+
elif type_val == StatusType.HIGH_EDGE:
|
|
764
|
+
status['high_edge'] = decode_float(data, optlen)
|
|
765
|
+
elif type_val == StatusType.NOISE_DENSITY:
|
|
766
|
+
status['noise_density'] = decode_float(data, optlen)
|
|
767
|
+
elif type_val == StatusType.BASEBAND_POWER:
|
|
768
|
+
status['baseband_power'] = decode_float(data, optlen)
|
|
769
|
+
elif type_val == StatusType.OUTPUT_SAMPRATE:
|
|
770
|
+
status['sample_rate'] = decode_int(data, optlen)
|
|
771
|
+
elif type_val == StatusType.OUTPUT_ENCODING:
|
|
772
|
+
status['encoding'] = decode_int(data, optlen)
|
|
773
|
+
elif type_val == StatusType.OUTPUT_DATA_DEST_SOCKET:
|
|
774
|
+
status['destination'] = decode_socket(data, optlen)
|
|
775
|
+
elif type_val == StatusType.OUTPUT_TTL:
|
|
776
|
+
status['ttl'] = decode_int(data, optlen)
|
|
777
|
+
if status['ttl'] == 0:
|
|
778
|
+
logger.warning(
|
|
779
|
+
f"Radiod reporting TTL=0 for SSRC "
|
|
780
|
+
f"{status.get('ssrc', 'unknown')}: Multicast data "
|
|
781
|
+
f"restricted to localhost loopback only!"
|
|
782
|
+
)
|
|
783
|
+
elif type_val == StatusType.LIFETIME:
|
|
784
|
+
status['lifetime'] = decode_int(data, optlen)
|
|
785
|
+
elif type_val == StatusType.DESCRIPTION:
|
|
786
|
+
status['description'] = decode_string(data, optlen)
|
|
787
|
+
|
|
788
|
+
cp += optlen
|
|
789
|
+
|
|
790
|
+
# Calculate SNR if we have the necessary data
|
|
791
|
+
if all(k in status for k in
|
|
792
|
+
['baseband_power', 'low_edge', 'high_edge', 'noise_density']):
|
|
793
|
+
import math
|
|
794
|
+
bandwidth = abs(status['high_edge'] - status['low_edge'])
|
|
795
|
+
|
|
796
|
+
if bandwidth > 0:
|
|
797
|
+
try:
|
|
798
|
+
noise_power_db = (
|
|
799
|
+
status['noise_density'] + 10 * math.log10(bandwidth)
|
|
800
|
+
)
|
|
801
|
+
signal_plus_noise_db = status['baseband_power']
|
|
802
|
+
noise_power = 10 ** (noise_power_db / 10)
|
|
803
|
+
signal_plus_noise = 10 ** (signal_plus_noise_db / 10)
|
|
804
|
+
|
|
805
|
+
if noise_power > 0:
|
|
806
|
+
snr_linear = signal_plus_noise / noise_power - 1
|
|
807
|
+
if snr_linear > 0:
|
|
808
|
+
status['snr'] = 10 * math.log10(snr_linear)
|
|
809
|
+
except (ValueError, ZeroDivisionError, OverflowError):
|
|
810
|
+
pass
|
|
811
|
+
|
|
812
|
+
return status
|
|
813
|
+
|
|
814
|
+
|
|
685
815
|
class RadiodControl:
|
|
686
816
|
"""
|
|
687
817
|
Control interface for radiod
|
|
@@ -724,6 +854,10 @@ class RadiodControl:
|
|
|
724
854
|
self._status_sock = None # Cached status listener socket for tune()
|
|
725
855
|
self._status_sock_lock = None # Will be initialized when needed
|
|
726
856
|
self._socket_lock = threading.RLock() # Protect control socket operations
|
|
857
|
+
|
|
858
|
+
# Optional continuous STATUS listener (live timing anchor refresh).
|
|
859
|
+
# Lazily created by start_status_listener(); see ka9q.status_listener.
|
|
860
|
+
self._status_listener = None
|
|
727
861
|
|
|
728
862
|
# Rate limiting
|
|
729
863
|
self.max_commands_per_sec = max_commands_per_sec
|
|
@@ -1978,127 +2112,25 @@ class RadiodControl:
|
|
|
1978
2112
|
|
|
1979
2113
|
def _decode_status_response(self, buffer: bytes) -> dict:
|
|
1980
2114
|
"""
|
|
1981
|
-
Decode a status response packet from radiod
|
|
1982
|
-
|
|
2115
|
+
Decode a status response packet from radiod (thin wrapper).
|
|
2116
|
+
|
|
2117
|
+
Delegates the actual parse to the module-level
|
|
2118
|
+
:func:`decode_status_dict` and bumps the per-instance metric.
|
|
2119
|
+
Kept as a method for backward compatibility with callers that
|
|
2120
|
+
already use ``self._decode_status_response`` (e.g.
|
|
2121
|
+
``listen_status``, ``tune``).
|
|
2122
|
+
|
|
1983
2123
|
Args:
|
|
1984
2124
|
buffer: Raw response bytes
|
|
1985
|
-
|
|
2125
|
+
|
|
1986
2126
|
Returns:
|
|
1987
2127
|
Dictionary containing decoded status fields
|
|
1988
2128
|
"""
|
|
1989
|
-
status =
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
return status # Not a status response
|
|
1993
|
-
|
|
1994
|
-
cp = 1 # Skip packet type byte
|
|
1995
|
-
|
|
1996
|
-
while cp < len(buffer):
|
|
1997
|
-
if cp >= len(buffer):
|
|
1998
|
-
break
|
|
1999
|
-
|
|
2000
|
-
type_val = buffer[cp]
|
|
2001
|
-
cp += 1
|
|
2002
|
-
|
|
2003
|
-
if type_val == StatusType.EOL:
|
|
2004
|
-
break
|
|
2005
|
-
|
|
2006
|
-
if cp >= len(buffer):
|
|
2007
|
-
break
|
|
2008
|
-
|
|
2009
|
-
optlen = buffer[cp]
|
|
2010
|
-
cp += 1
|
|
2011
|
-
|
|
2012
|
-
# Handle extended length encoding
|
|
2013
|
-
if optlen & 0x80:
|
|
2014
|
-
length_of_length = optlen & 0x7f
|
|
2015
|
-
optlen = 0
|
|
2016
|
-
for _ in range(length_of_length):
|
|
2017
|
-
if cp >= len(buffer):
|
|
2018
|
-
break
|
|
2019
|
-
optlen = (optlen << 8) | buffer[cp]
|
|
2020
|
-
cp += 1
|
|
2021
|
-
|
|
2022
|
-
if cp + optlen > len(buffer):
|
|
2023
|
-
break
|
|
2024
|
-
|
|
2025
|
-
data = buffer[cp:cp + optlen]
|
|
2026
|
-
|
|
2027
|
-
# Decode based on type
|
|
2028
|
-
if type_val == StatusType.COMMAND_TAG:
|
|
2029
|
-
status['command_tag'] = decode_int32(data, optlen)
|
|
2030
|
-
elif type_val == StatusType.GPS_TIME:
|
|
2031
|
-
status['gps_time'] = decode_int64(data, optlen)
|
|
2032
|
-
elif type_val == StatusType.RTP_TIMESNAP:
|
|
2033
|
-
status['rtp_timesnap'] = decode_int32(data, optlen)
|
|
2034
|
-
elif type_val == StatusType.RADIO_FREQUENCY:
|
|
2035
|
-
status['frequency'] = decode_double(data, optlen)
|
|
2036
|
-
elif type_val == StatusType.OUTPUT_SSRC:
|
|
2037
|
-
status['ssrc'] = decode_int32(data, optlen)
|
|
2038
|
-
elif type_val == StatusType.AGC_ENABLE:
|
|
2039
|
-
status['agc_enable'] = decode_bool(data, optlen)
|
|
2040
|
-
elif type_val == StatusType.GAIN:
|
|
2041
|
-
status['gain'] = decode_float(data, optlen)
|
|
2042
|
-
elif type_val == StatusType.RF_GAIN:
|
|
2043
|
-
status['rf_gain'] = decode_float(data, optlen)
|
|
2044
|
-
elif type_val == StatusType.RF_ATTEN:
|
|
2045
|
-
status['rf_atten'] = decode_float(data, optlen)
|
|
2046
|
-
elif type_val == StatusType.RF_AGC:
|
|
2047
|
-
status['rf_agc'] = decode_int(data, optlen)
|
|
2048
|
-
elif type_val == StatusType.PRESET:
|
|
2049
|
-
status['preset'] = decode_string(data, optlen)
|
|
2050
|
-
elif type_val == StatusType.LOW_EDGE:
|
|
2051
|
-
status['low_edge'] = decode_float(data, optlen)
|
|
2052
|
-
elif type_val == StatusType.HIGH_EDGE:
|
|
2053
|
-
status['high_edge'] = decode_float(data, optlen)
|
|
2054
|
-
elif type_val == StatusType.NOISE_DENSITY:
|
|
2055
|
-
status['noise_density'] = decode_float(data, optlen)
|
|
2056
|
-
elif type_val == StatusType.BASEBAND_POWER:
|
|
2057
|
-
status['baseband_power'] = decode_float(data, optlen)
|
|
2058
|
-
elif type_val == StatusType.OUTPUT_SAMPRATE:
|
|
2059
|
-
status['sample_rate'] = decode_int(data, optlen)
|
|
2060
|
-
elif type_val == StatusType.OUTPUT_ENCODING:
|
|
2061
|
-
status['encoding'] = decode_int(data, optlen)
|
|
2062
|
-
elif type_val == StatusType.OUTPUT_DATA_DEST_SOCKET:
|
|
2063
|
-
status['destination'] = decode_socket(data, optlen)
|
|
2064
|
-
elif type_val == StatusType.OUTPUT_TTL:
|
|
2065
|
-
status['ttl'] = decode_int(data, optlen)
|
|
2066
|
-
if status['ttl'] == 0:
|
|
2067
|
-
logger.warning(f"Radiod reporting TTL=0 for SSRC {status.get('ssrc', 'unknown')}: Multicast data restricted to localhost loopback only!")
|
|
2068
|
-
elif type_val == StatusType.LIFETIME:
|
|
2069
|
-
status['lifetime'] = decode_int(data, optlen)
|
|
2070
|
-
elif type_val == StatusType.DESCRIPTION:
|
|
2071
|
-
status['description'] = decode_string(data, optlen)
|
|
2072
|
-
|
|
2073
|
-
cp += optlen
|
|
2074
|
-
|
|
2075
|
-
# Calculate SNR if we have the necessary data
|
|
2076
|
-
if all(k in status for k in ['baseband_power', 'low_edge', 'high_edge', 'noise_density']):
|
|
2077
|
-
import math
|
|
2078
|
-
bandwidth = abs(status['high_edge'] - status['low_edge'])
|
|
2079
|
-
|
|
2080
|
-
# Guard against invalid bandwidth
|
|
2081
|
-
if bandwidth > 0:
|
|
2082
|
-
try:
|
|
2083
|
-
noise_power_db = status['noise_density'] + 10 * math.log10(bandwidth)
|
|
2084
|
-
signal_plus_noise_db = status['baseband_power']
|
|
2085
|
-
# Convert to linear, calculate SNR, convert back to dB
|
|
2086
|
-
noise_power = 10 ** (noise_power_db / 10)
|
|
2087
|
-
signal_plus_noise = 10 ** (signal_plus_noise_db / 10)
|
|
2088
|
-
|
|
2089
|
-
# Guard against division by zero
|
|
2090
|
-
if noise_power > 0:
|
|
2091
|
-
snr_linear = signal_plus_noise / noise_power - 1
|
|
2092
|
-
if snr_linear > 0:
|
|
2093
|
-
status['snr'] = 10 * math.log10(snr_linear)
|
|
2094
|
-
except (ValueError, ZeroDivisionError, OverflowError):
|
|
2095
|
-
# SNR calculation failed, skip it
|
|
2096
|
-
pass
|
|
2097
|
-
|
|
2098
|
-
# Track status received
|
|
2099
|
-
self.metrics.status_received += 1
|
|
2129
|
+
status = decode_status_dict(buffer)
|
|
2130
|
+
if status:
|
|
2131
|
+
self.metrics.status_received += 1
|
|
2100
2132
|
return status
|
|
2101
|
-
|
|
2133
|
+
|
|
2102
2134
|
def get_metrics(self) -> dict:
|
|
2103
2135
|
"""
|
|
2104
2136
|
Get current metrics as a dictionary
|
|
@@ -2982,14 +3014,67 @@ class RadiodControl:
|
|
|
2982
3014
|
except Exception as exc:
|
|
2983
3015
|
logger.warning(f"listen_status callback raised: {exc}")
|
|
2984
3016
|
|
|
3017
|
+
# ── Continuous STATUS listener (v3.16.0+) ──────────────────────
|
|
3018
|
+
|
|
3019
|
+
@property
|
|
3020
|
+
def status_listener(self):
|
|
3021
|
+
"""The continuous STATUS listener, or ``None`` if not started.
|
|
3022
|
+
|
|
3023
|
+
Created lazily by :py:meth:`start_status_listener`. See
|
|
3024
|
+
:py:class:`ka9q.status_listener.StatusListener` for usage —
|
|
3025
|
+
register channels and callbacks via that object directly.
|
|
3026
|
+
"""
|
|
3027
|
+
return self._status_listener
|
|
3028
|
+
|
|
3029
|
+
def start_status_listener(self, socket_timeout: float = 0.5):
|
|
3030
|
+
"""Start a continuous STATUS multicast listener (idempotent).
|
|
3031
|
+
|
|
3032
|
+
Spawns a background thread that subscribes to radiod's STATUS
|
|
3033
|
+
multicast and refreshes the ``gps_time``/``rtp_timesnap`` anchor
|
|
3034
|
+
on every broadcast for all channels registered via
|
|
3035
|
+
:py:meth:`StatusListener.register_channel`.
|
|
3036
|
+
|
|
3037
|
+
Returns the :py:class:`StatusListener` instance.
|
|
3038
|
+
|
|
3039
|
+
See :py:class:`ka9q.status_listener.StatusListener` for full
|
|
3040
|
+
usage, including per-SSRC callbacks.
|
|
3041
|
+
|
|
3042
|
+
Closing the :py:class:`RadiodControl` (via :py:meth:`close` or
|
|
3043
|
+
the context manager) stops the listener.
|
|
3044
|
+
"""
|
|
3045
|
+
from .status_listener import StatusListener
|
|
3046
|
+
if self._status_listener is None:
|
|
3047
|
+
self._status_listener = StatusListener(
|
|
3048
|
+
status_address=self.status_address,
|
|
3049
|
+
interface=self.interface,
|
|
3050
|
+
socket_timeout=socket_timeout,
|
|
3051
|
+
)
|
|
3052
|
+
if not self._status_listener.running:
|
|
3053
|
+
self._status_listener.start()
|
|
3054
|
+
return self._status_listener
|
|
3055
|
+
|
|
3056
|
+
def stop_status_listener(self, timeout: float = 2.0):
|
|
3057
|
+
"""Stop the continuous STATUS listener, if running."""
|
|
3058
|
+
if self._status_listener is not None:
|
|
3059
|
+
self._status_listener.stop(timeout=timeout)
|
|
3060
|
+
|
|
2985
3061
|
def close(self):
|
|
2986
3062
|
"""
|
|
2987
3063
|
Close all sockets with proper error handling
|
|
2988
|
-
|
|
3064
|
+
|
|
2989
3065
|
This method is safe to call multiple times and handles errors gracefully.
|
|
2990
3066
|
"""
|
|
2991
3067
|
errors = []
|
|
2992
|
-
|
|
3068
|
+
|
|
3069
|
+
# Stop continuous STATUS listener if running
|
|
3070
|
+
if self._status_listener is not None:
|
|
3071
|
+
try:
|
|
3072
|
+
self._status_listener.stop()
|
|
3073
|
+
except Exception as e:
|
|
3074
|
+
errors.append(f"status listener: {e}")
|
|
3075
|
+
finally:
|
|
3076
|
+
self._status_listener = None
|
|
3077
|
+
|
|
2993
3078
|
# Close control socket
|
|
2994
3079
|
if self.socket:
|
|
2995
3080
|
try:
|
|
@@ -22,7 +22,32 @@ logger = logging.getLogger(__name__)
|
|
|
22
22
|
|
|
23
23
|
@dataclass
|
|
24
24
|
class ChannelInfo:
|
|
25
|
-
"""Information about a ka9q-radio channel
|
|
25
|
+
"""Information about a ka9q-radio channel.
|
|
26
|
+
|
|
27
|
+
Timing anchor atomicity
|
|
28
|
+
-----------------------
|
|
29
|
+
``gps_time`` and ``rtp_timesnap`` form a paired anchor — they were
|
|
30
|
+
captured at the same instant by radiod and only make sense together.
|
|
31
|
+
Reading them as separate attributes is **not atomic**: under
|
|
32
|
+
concurrent modification (e.g. the
|
|
33
|
+
:py:class:`ka9q.status_listener.StatusListener` background thread
|
|
34
|
+
updating both fields on every STATUS broadcast), a consumer that
|
|
35
|
+
reads ``gps_time`` then ``rtp_timesnap`` can land between the two
|
|
36
|
+
writes and get a torn pair.
|
|
37
|
+
|
|
38
|
+
For ``rtp_to_wallclock`` and other consumers that compare absolute
|
|
39
|
+
time against an external reference (where torn-pair errors of up to
|
|
40
|
+
the listener cadence — typically ~450 ms — exceed downstream gates),
|
|
41
|
+
use :py:meth:`get_anchor` to obtain a consistent snapshot, or have
|
|
42
|
+
the listener call :py:meth:`update_anchor` instead of mutating the
|
|
43
|
+
individual fields. Both rely on Python's GIL making single attribute
|
|
44
|
+
access atomic — the snapshot is stored in ``_anchor_pair`` as a
|
|
45
|
+
single tuple, written and read in one bytecode each.
|
|
46
|
+
|
|
47
|
+
Direct reads of ``ci.gps_time`` and ``ci.rtp_timesnap`` remain valid
|
|
48
|
+
for non-paired uses (diagnostic logging, schema introspection); the
|
|
49
|
+
tear risk only matters when both are consumed transactionally.
|
|
50
|
+
"""
|
|
26
51
|
ssrc: int
|
|
27
52
|
preset: str
|
|
28
53
|
sample_rate: int
|
|
@@ -35,6 +60,66 @@ class ChannelInfo:
|
|
|
35
60
|
encoding: int = 0 # stream encoding (0=none, 4=F32, etc)
|
|
36
61
|
chain_delay_correction_ns: Optional[int] = None # L6 BPSK PPS chain-delay calibration (nanoseconds)
|
|
37
62
|
|
|
63
|
+
def __post_init__(self):
|
|
64
|
+
# Seed the atomic-pair snapshot from the constructor args if both
|
|
65
|
+
# were provided. Lets ``get_anchor`` return the construction-time
|
|
66
|
+
# pair before any ``update_anchor`` call has happened.
|
|
67
|
+
if self.gps_time is not None and self.rtp_timesnap is not None:
|
|
68
|
+
self._anchor_pair = (self.gps_time, self.rtp_timesnap)
|
|
69
|
+
else:
|
|
70
|
+
self._anchor_pair = None
|
|
71
|
+
|
|
72
|
+
def get_anchor(self) -> Optional[Tuple[Optional[int], Optional[int]]]:
|
|
73
|
+
"""Return the ``(gps_time, rtp_timesnap)`` anchor as a single
|
|
74
|
+
atomic snapshot.
|
|
75
|
+
|
|
76
|
+
Reads a single attribute (``_anchor_pair``) — GIL-atomic, so the
|
|
77
|
+
returned tuple is always internally consistent, never torn.
|
|
78
|
+
Returns ``None`` if the anchor has not been set.
|
|
79
|
+
|
|
80
|
+
Use this from :py:func:`rtp_to_wallclock` and any other code
|
|
81
|
+
path that consumes both fields transactionally. Direct reads
|
|
82
|
+
of ``self.gps_time`` / ``self.rtp_timesnap`` are still permitted
|
|
83
|
+
for non-paired uses but should not be combined when consistency
|
|
84
|
+
matters.
|
|
85
|
+
"""
|
|
86
|
+
# Single attribute read — atomic under the GIL.
|
|
87
|
+
pair = getattr(self, '_anchor_pair', None)
|
|
88
|
+
if pair is not None:
|
|
89
|
+
return pair
|
|
90
|
+
# Backward compat fallback: synthesize from the legacy fields.
|
|
91
|
+
# Only happens when ``_anchor_pair`` was never initialised (e.g.
|
|
92
|
+
# for ChannelInfo objects constructed via __new__ in test setups).
|
|
93
|
+
gps = self.gps_time
|
|
94
|
+
rtp = self.rtp_timesnap
|
|
95
|
+
if gps is None or rtp is None:
|
|
96
|
+
return None
|
|
97
|
+
return (gps, rtp)
|
|
98
|
+
|
|
99
|
+
def update_anchor(self, gps_time: int, rtp_timesnap: int) -> None:
|
|
100
|
+
"""Atomically update the ``(gps_time, rtp_timesnap)`` anchor pair.
|
|
101
|
+
|
|
102
|
+
Writes a single tuple attribute (``_anchor_pair``) — GIL-atomic.
|
|
103
|
+
Readers using :py:meth:`get_anchor` see either the old pair or
|
|
104
|
+
the new pair, never a torn combination.
|
|
105
|
+
|
|
106
|
+
Also updates the legacy ``gps_time`` and ``rtp_timesnap`` fields
|
|
107
|
+
for backward compatibility with code that reads them directly.
|
|
108
|
+
Those updates can still tear if read as a pair without
|
|
109
|
+
``get_anchor`` — that's why the pair lives in
|
|
110
|
+
``_anchor_pair``. Tuple is written first so the atomic snapshot
|
|
111
|
+
is the leading edge of any state change.
|
|
112
|
+
"""
|
|
113
|
+
# Atomic pair first — this is the source of truth for
|
|
114
|
+
# transactional readers.
|
|
115
|
+
self._anchor_pair = (gps_time, rtp_timesnap)
|
|
116
|
+
# Legacy field mirrors for backward compat. Order doesn't
|
|
117
|
+
# matter for the atomic story (consumers that need both should
|
|
118
|
+
# use get_anchor); we update them so diagnostic code that reads
|
|
119
|
+
# ``.gps_time`` directly sees the current value.
|
|
120
|
+
self.gps_time = gps_time
|
|
121
|
+
self.rtp_timesnap = rtp_timesnap
|
|
122
|
+
|
|
38
123
|
|
|
39
124
|
def _create_status_listener_socket(multicast_addr: str, interface: Optional[str] = None) -> socket.socket:
|
|
40
125
|
"""
|
|
@@ -326,9 +326,14 @@ class MultiStream:
|
|
|
326
326
|
except OSError:
|
|
327
327
|
pass
|
|
328
328
|
|
|
329
|
-
# Large receive buffer for multi-channel throughput
|
|
329
|
+
# Large receive buffer for multi-channel throughput.
|
|
330
|
+
# Bumped from 8 MB → 64 MB on 2026-05-23 after observing 412M
|
|
331
|
+
# UDP RcvbufErrors on B4-100 with sustained GIL stalls. Kernel
|
|
332
|
+
# doubles for bookkeeping → 128 MB visible in ``ss -m``. Honored
|
|
333
|
+
# only if ``net.core.rmem_max >= 64 MB``; sigmond provisions
|
|
334
|
+
# 128 MB in /etc/sysctl.d/99-wspr-recorder.conf.
|
|
330
335
|
try:
|
|
331
|
-
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF,
|
|
336
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 * 1024 * 1024)
|
|
332
337
|
except OSError:
|
|
333
338
|
pass
|
|
334
339
|
|
|
@@ -171,18 +171,30 @@ def rtp_to_wallclock(
|
|
|
171
171
|
SHM samples stuck at the snapshot's wall-clock time, ~12.4 h
|
|
172
172
|
behind. Chrony filtered every sample and reach fell to 0.
|
|
173
173
|
"""
|
|
174
|
-
|
|
174
|
+
# Read the (gps_time, rtp_timesnap) anchor as a single atomic
|
|
175
|
+
# snapshot. This matters when a background StatusListener (v3.16.0+)
|
|
176
|
+
# is mutating the anchor concurrently: separate reads of
|
|
177
|
+
# ``channel.gps_time`` and ``channel.rtp_timesnap`` could land
|
|
178
|
+
# either side of an update and yield a torn pair off by up to one
|
|
179
|
+
# listener-cadence interval (typically ~450 ms on bee1). The
|
|
180
|
+
# tuple lives in ``_anchor_pair``, a single GIL-atomic attribute.
|
|
181
|
+
# See ChannelInfo.get_anchor() docstring for the full story.
|
|
182
|
+
anchor = channel.get_anchor()
|
|
183
|
+
if anchor is None:
|
|
184
|
+
return None
|
|
185
|
+
gps_time, rtp_timesnap = anchor
|
|
186
|
+
if gps_time is None or rtp_timesnap is None:
|
|
175
187
|
return None
|
|
176
188
|
|
|
177
189
|
# Convert GPS nanoseconds to Unix time
|
|
178
190
|
# GPS epoch is Jan 6, 1980; Unix epoch is Jan 1, 1970
|
|
179
191
|
# gps_time is nanoseconds since GPS epoch, so add GPS_UTC_OFFSET (in ns)
|
|
180
192
|
# AND subtract current GPS_LEAP_SECONDS (18s) to align with UTC
|
|
181
|
-
sender_time =
|
|
193
|
+
sender_time = gps_time + BILLION * (GPS_UTC_OFFSET - GPS_LEAP_SECONDS)
|
|
182
194
|
|
|
183
195
|
# Signed 32-bit RTP delta — correct within ±2**31 samples of
|
|
184
196
|
# the snapshot.
|
|
185
|
-
rtp_delta = int((rtp_timestamp -
|
|
197
|
+
rtp_delta = int((rtp_timestamp - rtp_timesnap) & 0xFFFFFFFF)
|
|
186
198
|
if rtp_delta > 0x7FFFFFFF:
|
|
187
199
|
rtp_delta -= 0x100000000
|
|
188
200
|
|