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.
Files changed (109) hide show
  1. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/CHANGELOG.md +107 -0
  2. {ka9q_python-3.15.1/ka9q_python.egg-info → ka9q_python-3.16.1}/PKG-INFO +1 -1
  3. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/__init__.py +5 -0
  4. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/control.py +202 -117
  5. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/discovery.py +86 -1
  6. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/multi_stream.py +7 -2
  7. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/rtp_recorder.py +15 -3
  8. ka9q_python-3.16.1/ka9q/status_listener.py +439 -0
  9. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/stream.py +15 -2
  10. {ka9q_python-3.15.1 → ka9q_python-3.16.1/ka9q_python.egg-info}/PKG-INFO +1 -1
  11. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/SOURCES.txt +2 -0
  12. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/pyproject.toml +1 -1
  13. ka9q_python-3.16.1/tests/test_status_listener.py +347 -0
  14. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/LICENSE +0 -0
  15. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/MANIFEST.in +0 -0
  16. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/README.md +0 -0
  17. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/API_REFERENCE.md +0 -0
  18. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/ARCHITECTURE.md +0 -0
  19. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/CLI_GUIDE.md +0 -0
  20. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/GETTING_STARTED.md +0 -0
  21. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/INSTALLATION.md +0 -0
  22. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/MULTI_STREAM.md +0 -0
  23. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/RECIPES.md +0 -0
  24. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
  25. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/SECURITY.md +0 -0
  26. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/TESTING_GUIDE.md +0 -0
  27. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/docs/TUI_GUIDE.md +0 -0
  28. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/advanced_features_demo.py +0 -0
  29. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/channel_cleanup_example.py +0 -0
  30. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/codar_oceanography.py +0 -0
  31. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/diagnostics/diagnose_packets.py +0 -0
  32. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/diagnostics/repro_utc_bug.py +0 -0
  33. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/discover_example.py +0 -0
  34. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/grape_integration_example.py +0 -0
  35. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/hf_band_scanner.py +0 -0
  36. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/multi_stream_smoke.py +0 -0
  37. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/rtp_recorder_example.py +0 -0
  38. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/simple_am_radio.py +0 -0
  39. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/spectrum_example.py +0 -0
  40. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/stream_example.py +0 -0
  41. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/superdarn_recorder.py +0 -0
  42. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/test_channel_operations.py +0 -0
  43. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/test_improvements.py +0 -0
  44. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/test_timing_fields.py +0 -0
  45. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/tune.py +0 -0
  46. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/examples/tune_example.py +0 -0
  47. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/_multicast.py +0 -0
  48. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/addressing.py +0 -0
  49. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/cli.py +0 -0
  50. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/compat.py +0 -0
  51. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/exceptions.py +0 -0
  52. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/managed_stream.py +0 -0
  53. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/monitor.py +0 -0
  54. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/pps_calibrator.py +0 -0
  55. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/resequencer.py +0 -0
  56. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/spectrum_stream.py +0 -0
  57. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/status.py +0 -0
  58. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/stream_quality.py +0 -0
  59. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/tui.py +0 -0
  60. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/types.py +0 -0
  61. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q/utils.py +0 -0
  62. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
  63. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/entry_points.txt +0 -0
  64. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/requires.txt +0 -0
  65. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_python.egg-info/top_level.txt +0 -0
  66. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/ka9q_radio_compat +0 -0
  67. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/scripts/check_upstream_drift.py +0 -0
  68. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/scripts/sync_types.py +0 -0
  69. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/setup.cfg +0 -0
  70. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/setup.py +0 -0
  71. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/__init__.py +0 -0
  72. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/conftest.py +0 -0
  73. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_addressing.py +0 -0
  74. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_channel_verification.py +0 -0
  75. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_client_id_destination.py +0 -0
  76. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_create_split_encoding.py +0 -0
  77. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_decode_description.py +0 -0
  78. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_decode_functions.py +0 -0
  79. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_encode_functions.py +0 -0
  80. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_encode_socket.py +0 -0
  81. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ensure_channel_encoding.py +0 -0
  82. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_filter_edges.py +0 -0
  83. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_integration.py +0 -0
  84. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_iq_20khz_f32.py +0 -0
  85. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_lifetime.py +0 -0
  86. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_listen_multicast.py +0 -0
  87. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_managed_stream_recovery.py +0 -0
  88. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_monitor.py +0 -0
  89. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_multicast_helpers.py +0 -0
  90. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_multihomed.py +0 -0
  91. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_native_discovery.py +0 -0
  92. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_parse_rtp_samples_iq.py +0 -0
  93. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_performance_fixes.py +0 -0
  94. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_protocol_compat.py +0 -0
  95. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_remove_channel.py +0 -0
  96. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_rtp_recorder.py +0 -0
  97. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_security_features.py +0 -0
  98. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_spectrum.py +0 -0
  99. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ssrc_dest_unit.py +0 -0
  100. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ssrc_encoding_unit.py +0 -0
  101. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
  102. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_status_decoder.py +0 -0
  103. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_ttl_warning.py +0 -0
  104. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune.py +0 -0
  105. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_cli.py +0 -0
  106. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_debug.py +0 -0
  107. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_live.py +0 -0
  108. {ka9q_python-3.15.1 → ka9q_python-3.16.1}/tests/test_tune_method.py +0 -0
  109. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.15.1
3
+ Version: 3.16.1
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Home-page: https://github.com/mijahauan/ka9q-python
6
6
  Author: Michael Hauan AC0G
@@ -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
- if len(buffer) == 0 or buffer[0] != 0:
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, 8 * 1024 * 1024)
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
- if channel.gps_time is None or channel.rtp_timesnap is None:
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 = channel.gps_time + BILLION * (GPS_UTC_OFFSET - GPS_LEAP_SECONDS)
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 - channel.rtp_timesnap) & 0xFFFFFFFF)
197
+ rtp_delta = int((rtp_timestamp - rtp_timesnap) & 0xFFFFFFFF)
186
198
  if rtp_delta > 0x7FFFFFFF:
187
199
  rtp_delta -= 0x100000000
188
200