ka9q-python 3.18.0__tar.gz → 3.20.0__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 (113) hide show
  1. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/CHANGELOG.md +49 -0
  2. {ka9q_python-3.18.0/ka9q_python.egg-info → ka9q_python-3.20.0}/PKG-INFO +1 -1
  3. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/__init__.py +2 -1
  4. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/discovery.py +8 -33
  5. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/slot_clock.py +54 -32
  6. {ka9q_python-3.18.0 → ka9q_python-3.20.0/ka9q_python.egg-info}/PKG-INFO +1 -1
  7. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/pyproject.toml +1 -1
  8. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_slot_clock.py +45 -1
  9. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/LICENSE +0 -0
  10. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/MANIFEST.in +0 -0
  11. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/README.md +0 -0
  12. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/API_REFERENCE.md +0 -0
  13. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/ARCHITECTURE.md +0 -0
  14. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/CLI_GUIDE.md +0 -0
  15. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/GETTING_STARTED.md +0 -0
  16. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/INSTALLATION.md +0 -0
  17. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/MULTI_STREAM.md +0 -0
  18. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/RECIPES.md +0 -0
  19. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/REQUIREMENTS.md +0 -0
  20. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  21. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/SECURITY.md +0 -0
  22. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/TESTING_GUIDE.md +0 -0
  23. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/TUI_GUIDE.md +0 -0
  24. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/advanced_features_demo.py +0 -0
  25. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/channel_cleanup_example.py +0 -0
  26. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/codar_oceanography.py +0 -0
  27. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/diagnostics/diagnose_packets.py +0 -0
  28. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  29. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/discover_example.py +0 -0
  30. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/grape_integration_example.py +0 -0
  31. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/hf_band_scanner.py +0 -0
  32. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/multi_stream_smoke.py +0 -0
  33. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/rtp_recorder_example.py +0 -0
  34. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/simple_am_radio.py +0 -0
  35. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/spectrum_example.py +0 -0
  36. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/stream_example.py +0 -0
  37. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/superdarn_recorder.py +0 -0
  38. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/test_channel_operations.py +0 -0
  39. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/test_improvements.py +0 -0
  40. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/test_timing_fields.py +0 -0
  41. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/tune.py +0 -0
  42. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/tune_example.py +0 -0
  43. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/_multicast.py +0 -0
  44. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/addressing.py +0 -0
  45. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/cli.py +0 -0
  46. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/compat.py +0 -0
  47. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/control.py +0 -0
  48. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/exceptions.py +0 -0
  49. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/managed_stream.py +0 -0
  50. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/monitor.py +0 -0
  51. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/multi_stream.py +0 -0
  52. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/pps_calibrator.py +0 -0
  53. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/resequencer.py +0 -0
  54. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/rtp_recorder.py +0 -0
  55. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/spectrum_stream.py +0 -0
  56. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/status.py +0 -0
  57. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/status_listener.py +0 -0
  58. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/stream.py +0 -0
  59. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/stream_quality.py +0 -0
  60. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/tui.py +0 -0
  61. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/types.py +0 -0
  62. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/utils.py +0 -0
  63. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/SOURCES.txt +0 -0
  64. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  65. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/entry_points.txt +0 -0
  66. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/requires.txt +0 -0
  67. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/top_level.txt +0 -0
  68. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_radio_compat +0 -0
  69. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/scripts/check_upstream_drift.py +0 -0
  70. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/scripts/sync_types.py +0 -0
  71. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/setup.cfg +0 -0
  72. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/__init__.py +0 -0
  73. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/conftest.py +0 -0
  74. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_addressing.py +0 -0
  75. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_channel_verification.py +0 -0
  76. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_client_id_destination.py +0 -0
  77. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_create_split_encoding.py +0 -0
  78. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_decode_description.py +0 -0
  79. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_decode_functions.py +0 -0
  80. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_encode_functions.py +0 -0
  81. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_encode_socket.py +0 -0
  82. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ensure_channel_encoding.py +0 -0
  83. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_filter_edges.py +0 -0
  84. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_integration.py +0 -0
  85. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_iq_20khz_f32.py +0 -0
  86. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_lifetime.py +0 -0
  87. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_listen_multicast.py +0 -0
  88. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_managed_stream_recovery.py +0 -0
  89. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_monitor.py +0 -0
  90. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multicast_helpers.py +0 -0
  91. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multihomed.py +0 -0
  92. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multistream_gap_storm.py +0 -0
  93. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multistream_prune.py +0 -0
  94. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_native_discovery.py +0 -0
  95. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_parse_rtp_samples_iq.py +0 -0
  96. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_performance_fixes.py +0 -0
  97. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_protocol_compat.py +0 -0
  98. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_remove_channel.py +0 -0
  99. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_rtp_recorder.py +0 -0
  100. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_security_features.py +0 -0
  101. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_spectrum.py +0 -0
  102. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ssrc_dest_unit.py +0 -0
  103. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ssrc_encoding_unit.py +0 -0
  104. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  105. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_status_decoder.py +0 -0
  106. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_status_listener.py +0 -0
  107. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ttl_warning.py +0 -0
  108. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune.py +0 -0
  109. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_cli.py +0 -0
  110. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_debug.py +0 -0
  111. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_live.py +0 -0
  112. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_method.py +0 -0
  113. {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.20.0] - 2026-06-29
4
+
5
+ ### Added — `SlotClock` ambiguous-unwrap guard (fail loud, never silent)
6
+
7
+ - **`SlotClock.offset_of_rtp` now raises `SlotClockDesyncError`** when a wrapped
8
+ RTP timestamp is more than `2**30` samples (half the signed-32 window, ~24.8 h
9
+ @ 12 kHz / ~4.7 h @ 64 kHz IQ) from the unwrap high-water in either direction.
10
+ The high-water unwrap (3.18.0) keeps continuous streams correct past the
11
+ `2**31` window, but it only disambiguates while each step stays within
12
+ `±2**31` of the high-water. A *single* forward jump `>= 2**31` samples (a
13
+ `>49.7 h` stall-then-resume, a missed re-anchor, or a discontinuous timestamp)
14
+ aliased into a backward step — `advance()` then harvested **0 slots forever on
15
+ live RF**, the exact silent failure `SlotClock` exists to prevent.
16
+ - **`SlotClock.advance` catches the desync, logs an ERROR, drops the anchor, and
17
+ returns `[]`**, so the caller's normal `if not clk.anchored: clk.anchor(...)`
18
+ path re-establishes the grid on a fresh RTP reference instead of stalling. The
19
+ `2**30` margin never false-fires: continuous advancing steps a few hundred
20
+ samples per call.
21
+ - **`SlotClockDesyncError` is exported** from the package.
22
+
23
+ ## [3.19.0] - 2026-06-28
24
+
25
+ ### Removed / Changed — simplify how we look at radiod (defer to RTP)
26
+
27
+ - **Removed `SlotClock.divergence_sec`** (added in 3.18.0). It recomputed a
28
+ boundary's UTC from the live, StatusListener-refreshed `channel_info` and
29
+ compared it to the grid — inviting consumers to re-anchor when the two
30
+ disagree. In practice a busy radiod's status pair (`gps_time`/`rtp_timesnap`)
31
+ jitters ~0.45 s and occasionally tears between ~450 ms snapshots, so the check
32
+ reported large spurious divergences and drove downstream recorders into a
33
+ re-anchor storm. The `SlotClock` grid is RTP-driven and drift-immune by
34
+ design; nothing should second-guess it against the noisy status feed.
35
+
36
+ - **Removed the per-update RTP↔GPS offset-step detection in
37
+ `ChannelInfo.update_anchor`** (`discovery.py`). It bumped `anchor_epoch` and
38
+ logged a loud `radiod RTP↔GPS offset STEPPED … (consumers will re-anchor)`
39
+ WARNING whenever consecutive status snapshots implied a stepped offset — but
40
+ that "step" was almost always status-feed jitter/tearing, not a real radiod
41
+ slide (radiod's RTP data path stayed consistent; decodes never stopped).
42
+ `update_anchor` now simply adopts the latest anchor pair. The `anchor_epoch`,
43
+ `last_offset_step_sec`, and `anchor_step_threshold_sec` fields remain
44
+ (vestigial, at their defaults) for backward compatibility.
45
+
46
+ The principle: **anchor once off radiod's RTP timestamp and defer to it.** A
47
+ genuine radiod restart is still handled downstream via the stream-restored path
48
+ (MultiStream's drop/restore callback), not by polling the status feed for
49
+ divergence. The sigmond recorders (psk/meteor/wspr) drop their matching
50
+ re-anchor machinery in lockstep.
51
+
3
52
  ## [3.18.0] - 2026-06-28
4
53
 
5
54
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.18.0
3
+ Version: 3.20.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Author-email: Michael Hauan AC0G <ac0g@hauan.org>
6
6
  License: MIT
@@ -115,7 +115,7 @@ from .managed_stream import (
115
115
  StreamState,
116
116
  )
117
117
  from .multi_stream import MultiStream
118
- from .slot_clock import SlotClock, Slot, rtp_diff
118
+ from .slot_clock import SlotClock, Slot, SlotClockDesyncError, rtp_diff
119
119
  from .spectrum_stream import SpectrumStream
120
120
  from .status_listener import StatusListener, StatusListenerStats
121
121
 
@@ -165,6 +165,7 @@ __all__ = [
165
165
  # Slot timing (epoch-aligned, RTP-referenced)
166
166
  'SlotClock',
167
167
  'Slot',
168
+ 'SlotClockDesyncError',
168
169
  'rtp_diff',
169
170
 
170
171
  # Stream API (sample-oriented)
@@ -132,9 +132,6 @@ class ChannelInfo:
132
132
  ``_anchor_pair``. Tuple is written first so the atomic snapshot
133
133
  is the leading edge of any state change.
134
134
  """
135
- # Snapshot the previous pair BEFORE overwriting, for step detection.
136
- prev = getattr(self, '_anchor_pair', None)
137
-
138
135
  # Atomic pair first — this is the source of truth for
139
136
  # transactional readers.
140
137
  self._anchor_pair = (gps_time, rtp_timesnap)
@@ -144,36 +141,14 @@ class ChannelInfo:
144
141
  # ``.gps_time`` directly sees the current value.
145
142
  self.gps_time = gps_time
146
143
  self.rtp_timesnap = rtp_timesnap
147
-
148
- # ── Offset-step detection ──
149
- # Consecutive snapshots are ~450 ms apart, so the signed 32-bit RTP
150
- # delta is tiny (no wrap ambiguity, unlike the projection path in
151
- # rtp_to_wallclock). ``move_sec`` is the change in the RTP→GPS
152
- # mapping: ~0 when radiod is consistent, a gross jump when it steps.
153
- # Only the StatusListener thread calls update_anchor (single writer),
154
- # so the ``anchor_epoch += 1`` read-modify-write is race-free; the
155
- # epoch is bumped AFTER ``_anchor_pair`` is published so any reader
156
- # that observes the new epoch also reads the new (or newer) anchor.
157
- if (
158
- prev is not None
159
- and prev[0] is not None
160
- and prev[1] is not None
161
- and self.sample_rate
162
- ):
163
- d_rtp = int((rtp_timesnap - prev[1]) & 0xFFFFFFFF)
164
- if d_rtp > 0x7FFFFFFF:
165
- d_rtp -= 0x100000000
166
- move_sec = (
167
- (gps_time - prev[0]) - d_rtp * 1_000_000_000 / self.sample_rate
168
- ) / 1e9
169
- if abs(move_sec) > self.anchor_step_threshold_sec:
170
- self.last_offset_step_sec = move_sec
171
- self.anchor_epoch += 1
172
- logger.warning(
173
- "ChannelInfo ssrc=%s: radiod RTP↔GPS offset STEPPED "
174
- "%+.3fs — anchor_epoch=%d (consumers will re-anchor)",
175
- self.ssrc, move_sec, self.anchor_epoch,
176
- )
144
+ # NOTE (2026-06-28): the per-update RTP↔GPS offset-step detection was
145
+ # removed. It compared consecutive ~450 ms status snapshots and bumped
146
+ # ``anchor_epoch`` (with a loud WARNING) on any jump — but a busy
147
+ # radiod's status pair jitters/tears at that cadence, so it fired
148
+ # constantly and drove downstream recorders into a re-anchor storm.
149
+ # We now simply adopt the latest pair and defer to radiod's RTP; the
150
+ # ``anchor_epoch`` / ``last_offset_step_sec`` fields remain (vestigial,
151
+ # at their defaults) for backward compatibility.
177
152
 
178
153
 
179
154
  def _create_status_listener_socket(multicast_addr: str, interface: Optional[str] = None) -> socket.socket:
@@ -68,6 +68,28 @@ def rtp_diff(a: int, b: int) -> int:
68
68
  return d
69
69
 
70
70
 
71
+ # Largest single unwrap step (in samples) we trust from the high-water. The
72
+ # signed-32 unwrap is only unambiguous within +/-2**31 of the high-water; we
73
+ # refuse anything past the half-window (2**30 ~= 24.8 h @ 12 kHz, ~4.7 h @
74
+ # 64 kHz IQ) so the unwrap fails LOUD with a wide margin instead of silently
75
+ # aliasing a huge forward jump into a backward step (-> advance() harvests 0
76
+ # slots on live RF: the exact silent failure SlotClock exists to prevent).
77
+ # Continuous advancing steps by a few hundred samples per call, so this never
78
+ # false-fires in normal operation — only a >24.8 h stall, a missed re-anchor,
79
+ # or a bogus/discontinuous timestamp reaches it.
80
+ _SAFE_UNWRAP_SAMPLES = 2 ** 30
81
+
82
+
83
+ class SlotClockDesyncError(RuntimeError):
84
+ """A wrapped RTP timestamp is too far from the unwrap high-water to be
85
+ disambiguated (a >24.8 h gap, a missed re-anchor, or a discontinuous/bogus
86
+ timestamp). The grid can no longer be trusted from this timestamp alone;
87
+ the caller must re-anchor against a fresh RTP reference. ``advance``
88
+ catches this, drops the anchor, and returns no slots, so the caller's
89
+ normal ``if not clk.anchored: clk.anchor(...)`` path re-establishes the
90
+ grid rather than silently stalling at zero decodes."""
91
+
92
+
71
93
  @dataclass(frozen=True)
72
94
  class Slot:
73
95
  """One completed, epoch-aligned slot.
@@ -189,10 +211,26 @@ class SlotClock:
189
211
  within the last slot), which are always within one signed-32 window of
190
212
  the high-water — so the unwrap is unambiguous. Advances the high-water
191
213
  when a query is ahead of it; never rewinds it on a stale/past query.
214
+
215
+ Raises ``SlotClockDesyncError`` if the timestamp is more than
216
+ ``_SAFE_UNWRAP_SAMPLES`` (half the signed-32 window) from the
217
+ high-water in either direction: at that distance a wrapped 32-bit
218
+ value can no longer be told apart from a ~2**32-aliased one, so any
219
+ unwrap would be a guess. This converts the old silent alias (huge
220
+ forward jump read as a backward step -> 0 slots forever) into a loud,
221
+ recoverable signal.
192
222
  """
193
223
  assert self._anchor_rtp is not None
194
224
  ref_rtp = (self._anchor_rtp + self._latest_off) & 0xFFFFFFFF
195
- off = self._latest_off + rtp_diff(rtp_timestamp, ref_rtp)
225
+ delta = rtp_diff(rtp_timestamp, ref_rtp)
226
+ if abs(delta) > _SAFE_UNWRAP_SAMPLES:
227
+ raise SlotClockDesyncError(
228
+ f"rtp={rtp_timestamp} is {delta} samples from the unwrap "
229
+ f"high-water (offset {self._latest_off}); |delta| exceeds the "
230
+ f"safe window {_SAFE_UNWRAP_SAMPLES} — cannot unwrap "
231
+ f"unambiguously, re-anchor required"
232
+ )
233
+ off = self._latest_off + delta
196
234
  if off > self._latest_off:
197
235
  self._latest_off = off
198
236
  return off
@@ -209,7 +247,21 @@ class SlotClock:
209
247
  """
210
248
  if self._anchor_rtp is None or self._next_boundary_off is None:
211
249
  return []
212
- latest_off = self.offset_of_rtp(latest_rtp_timestamp)
250
+ try:
251
+ latest_off = self.offset_of_rtp(latest_rtp_timestamp)
252
+ except SlotClockDesyncError as exc:
253
+ # A wrapped timestamp this far from the high-water can't be
254
+ # unwrapped safely. Fail LOUD and drop the anchor so the caller's
255
+ # ``if not anchored: anchor()`` path re-establishes the grid on a
256
+ # fresh RTP reference — instead of silently harvesting 0 slots on
257
+ # live RF (the failure mode this whole class exists to prevent).
258
+ logger.error(
259
+ "SlotClock(cadence=%.3fs, sr=%d): %s; dropping anchor to force "
260
+ "a clean re-anchor (was at slot index %d)",
261
+ self.cadence_sec, self.sample_rate, exc, self._next_index,
262
+ )
263
+ self.reset()
264
+ return []
213
265
  out: List[Slot] = []
214
266
  while latest_off >= (
215
267
  self._next_boundary_off + self.cadence_samples + self.settle_samples
@@ -226,33 +278,3 @@ class SlotClock:
226
278
  self._next_boundary_off = start_off + self.cadence_samples
227
279
  self._next_index += 1
228
280
  return out
229
-
230
- # ── RTP-reference re-validation ──────────────────────────────────
231
-
232
- def divergence_sec(self, channel_info, rtp_to_utc) -> Optional[float]:
233
- """Grid-vs-GPS divergence at the next boundary, in seconds.
234
-
235
- Recomputes the next boundary's true UTC straight from radiod's
236
- (StatusListener-refreshed) ``channel_info`` via ``rtp_to_utc``
237
- and compares it to the grid projection. A sustained nonzero result
238
- means the anchor is stale/wrong — the caller should ``anchor()``
239
- again off the fresh reference. Returns None if it can't be computed.
240
-
241
- ``rtp_to_utc`` is passed in (rather than imported) so the caller binds
242
- the exact projector it anchored with; ``ka9q.rtp_to_wallclock`` works
243
- too as a deprecated alias of the same function.
244
- """
245
- if self._anchor_rtp is None or self._next_boundary_off is None:
246
- return None
247
- boundary_rtp = self.rtp_of_offset(self._next_boundary_off)
248
- projected = self.utc_of_offset(self._next_boundary_off)
249
- try:
250
- ref = rtp_to_utc(
251
- boundary_rtp, channel_info, wallclock_hint_sec=projected,
252
- )
253
- except Exception as exc: # noqa: BLE001 — detection must not crash audio
254
- logger.debug("SlotClock divergence check raised: %s", exc)
255
- return None
256
- if ref is None:
257
- return None
258
- return projected - ref
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.18.0
3
+ Version: 3.20.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Author-email: Michael Hauan AC0G <ac0g@hauan.org>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.18.0"
7
+ version = "3.20.0"
8
8
  description = "Python interface for ka9q-radio control and monitoring"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -3,7 +3,13 @@ import math
3
3
 
4
4
  import pytest
5
5
 
6
- from ka9q.slot_clock import SlotClock, Slot, rtp_diff
6
+ from ka9q.slot_clock import (
7
+ SlotClock,
8
+ Slot,
9
+ SlotClockDesyncError,
10
+ rtp_diff,
11
+ _SAFE_UNWRAP_SAMPLES,
12
+ )
7
13
 
8
14
 
9
15
  SR = 12000
@@ -136,6 +142,44 @@ def test_offset_of_rtp_unwraps_past_window():
136
142
  assert off > 2 ** 31 # we genuinely crossed the signed-32 boundary
137
143
 
138
144
 
145
+ def test_safe_unwrap_limit_allows_full_half_window_step():
146
+ """A single step of exactly the safe limit (half the signed-32 window) is
147
+ still unambiguous and must be accepted — the guard rejects only *past* it."""
148
+ clk = SlotClock(15.0, SR)
149
+ clk.anchor(rtp_timestamp=0, utc=900_000_000.0)
150
+ off = _SAFE_UNWRAP_SAMPLES
151
+ assert clk.offset_of_rtp(off & 0xFFFFFFFF) == off
152
+
153
+
154
+ def test_offset_of_rtp_raises_on_ambiguous_jump():
155
+ """A single unwrap step past the safe half-window can alias a huge forward
156
+ jump into a backward step; offset_of_rtp must raise rather than guess."""
157
+ clk = SlotClock(15.0, SR)
158
+ clk.anchor(rtp_timestamp=0, utc=900_000_000.0)
159
+ with pytest.raises(SlotClockDesyncError):
160
+ clk.offset_of_rtp((_SAFE_UNWRAP_SAMPLES + 1) & 0xFFFFFFFF)
161
+
162
+
163
+ def test_advance_big_jump_fails_loud_and_drops_anchor(caplog):
164
+ """Regression: a single advance() forward jump > 2**31 from the anchor used
165
+ to alias to a backward step and silently harvest 0 slots forever (live RF,
166
+ zero decodes). The guard must instead log loudly, drop the anchor, and
167
+ return [] so the caller re-anchors — turning a silent stall into recovery."""
168
+ clk = SlotClock(15.0, SR, settle_sec=1.5)
169
+ clk.anchor(rtp_timestamp=0, utc=900_000_000.0)
170
+ # 50 h in samples: 2,160,000,000 > 2**31 (2,147,483,648) in ONE call.
171
+ big = 50 * 3600 * SR & 0xFFFFFFFF
172
+ with caplog.at_level("ERROR"):
173
+ slots = clk.advance(big)
174
+ assert slots == [] # no garbage slots
175
+ assert not clk.anchored # anchor dropped -> caller must re-anchor
176
+ assert any("SlotClock" in r.message for r in caplog.records)
177
+ # and recovery works: re-anchor near the real leading edge, harvest normally
178
+ clk.anchor(rtp_timestamp=big, utc=900_180_000.0) # multiple of 15
179
+ out = clk.advance((big + 180000 + 24000 + 1) & 0xFFFFFFFF)
180
+ assert len(out) == 1 and out[0].index == 0
181
+
182
+
139
183
  def test_settle_delays_completion():
140
184
  clk = SlotClock(15.0, SR, settle_sec=2.0)
141
185
  clk.anchor(rtp_timestamp=0, utc=900_000_000.0) # 900000000 % 15 == 0 -> boundary0 at offset 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes