ka9q-python 3.20.0__tar.gz → 3.26.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 (148) hide show
  1. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/CHANGELOG.md +208 -0
  2. {ka9q_python-3.20.0/ka9q_python.egg-info → ka9q_python-3.26.0}/PKG-INFO +50 -6
  3. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/README.md +49 -5
  4. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/GETTING_STARTED.md +5 -5
  5. ka9q_python-3.26.0/docs/POLL-SUPPRESSION-DESIGN.md +136 -0
  6. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/RECIPES.md +4 -4
  7. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/REQUIREMENTS.md +6 -0
  8. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/README.md +52 -0
  9. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/clients.md +631 -0
  10. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/contract.md +434 -0
  11. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/control-surface.md +334 -0
  12. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/findings.md +628 -0
  13. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/idempotency.md +974 -0
  14. ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/upstream.md +328 -0
  15. ka9q_python-3.26.0/docs/superpowers/plans/2026-08-12-upstream-alignment-and-client-compat.md +937 -0
  16. ka9q_python-3.26.0/docs/superpowers/plans/2026-08-13-audit-remediation.md +1590 -0
  17. ka9q_python-3.26.0/docs/superpowers/plans/2026-08-13-client-side-fixes.md +1162 -0
  18. ka9q_python-3.26.0/docs/superpowers/specs/2026-08-12-upstream-alignment-and-client-compat-design.md +155 -0
  19. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/channel_cleanup_example.py +6 -6
  20. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/codar_oceanography.py +1 -1
  21. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/hf_band_scanner.py +1 -1
  22. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/simple_am_radio.py +1 -1
  23. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/superdarn_recorder.py +1 -1
  24. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/test_improvements.py +7 -7
  25. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/__init__.py +7 -1
  26. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/compat.py +1 -1
  27. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/control.py +627 -167
  28. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/discovery.py +28 -1
  29. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/managed_stream.py +7 -0
  30. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/multi_stream.py +169 -6
  31. ka9q_python-3.26.0/ka9q/recovery_ladder.py +139 -0
  32. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/resequencer.py +48 -1
  33. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/rtp_recorder.py +20 -4
  34. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/spectrum_stream.py +31 -23
  35. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/status.py +8 -1
  36. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/status_listener.py +65 -0
  37. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/stream.py +150 -24
  38. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/stream_quality.py +32 -21
  39. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/types.py +8 -5
  40. {ka9q_python-3.20.0 → ka9q_python-3.26.0/ka9q_python.egg-info}/PKG-INFO +50 -6
  41. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/SOURCES.txt +34 -1
  42. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_radio_compat +1 -1
  43. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/pyproject.toml +1 -1
  44. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/scripts/check_upstream_drift.py +221 -22
  45. ka9q_python-3.26.0/scripts/gen_client_manifest.py +140 -0
  46. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/scripts/sync_types.py +129 -7
  47. ka9q_python-3.26.0/tests/test_client_contract.py +56 -0
  48. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_client_id_destination.py +11 -6
  49. ka9q_python-3.26.0/tests/test_compat_pin_reachability.py +94 -0
  50. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_create_split_encoding.py +1 -1
  51. ka9q_python-3.26.0/tests/test_delivered_rtp_start.py +120 -0
  52. ka9q_python-3.26.0/tests/test_destination_required.py +133 -0
  53. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ensure_channel_encoding.py +4 -2
  54. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_filter_edges.py +6 -3
  55. ka9q_python-3.26.0/tests/test_gen_client_manifest.py +75 -0
  56. ka9q_python-3.26.0/tests/test_idempotency_integration.py +184 -0
  57. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_iq_20khz_f32.py +23 -1
  58. ka9q_python-3.26.0/tests/test_keepalive_encoding.py +208 -0
  59. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_lifetime.py +9 -1
  60. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multicast_helpers.py +9 -0
  61. ka9q_python-3.26.0/tests/test_multistream_completeness.py +85 -0
  62. ka9q_python-3.26.0/tests/test_multistream_restore.py +90 -0
  63. ka9q_python-3.26.0/tests/test_parameter_vector.py +133 -0
  64. ka9q_python-3.26.0/tests/test_poll_channel_idle.py +103 -0
  65. ka9q_python-3.26.0/tests/test_protocol_compat.py +156 -0
  66. ka9q_python-3.26.0/tests/test_recovery_ladder.py +166 -0
  67. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_remove_channel.py +3 -2
  68. ka9q_python-3.26.0/tests/test_rtp_to_wallclock_deprecated.py +83 -0
  69. ka9q_python-3.26.0/tests/test_set_demod_type.py +46 -0
  70. ka9q_python-3.26.0/tests/test_set_lock_warns.py +33 -0
  71. ka9q_python-3.26.0/tests/test_set_spectrum.py +128 -0
  72. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_status_decoder.py +64 -0
  73. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_status_listener.py +58 -1
  74. ka9q_python-3.26.0/tests/test_status_socket_drain.py +155 -0
  75. ka9q_python-3.26.0/tests/test_sync_types.py +40 -0
  76. ka9q_python-3.26.0/tests/test_upstream_drift.py +422 -0
  77. ka9q_python-3.26.0/tests/test_zero_block_detection.py +135 -0
  78. ka9q_python-3.20.0/tests/test_protocol_compat.py +0 -82
  79. ka9q_python-3.20.0/tests/test_upstream_drift.py +0 -176
  80. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/LICENSE +0 -0
  81. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/MANIFEST.in +0 -0
  82. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/API_REFERENCE.md +0 -0
  83. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/ARCHITECTURE.md +0 -0
  84. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/CLI_GUIDE.md +0 -0
  85. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/INSTALLATION.md +0 -0
  86. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/MULTI_STREAM.md +0 -0
  87. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  88. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/SECURITY.md +0 -0
  89. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/TESTING_GUIDE.md +0 -0
  90. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/TUI_GUIDE.md +0 -0
  91. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/advanced_features_demo.py +0 -0
  92. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/diagnostics/diagnose_packets.py +0 -0
  93. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  94. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/discover_example.py +0 -0
  95. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/grape_integration_example.py +0 -0
  96. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/multi_stream_smoke.py +0 -0
  97. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/rtp_recorder_example.py +0 -0
  98. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/spectrum_example.py +0 -0
  99. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/stream_example.py +0 -0
  100. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/test_channel_operations.py +0 -0
  101. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/test_timing_fields.py +0 -0
  102. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/tune.py +0 -0
  103. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/tune_example.py +0 -0
  104. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/_multicast.py +0 -0
  105. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/addressing.py +0 -0
  106. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/cli.py +0 -0
  107. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/exceptions.py +0 -0
  108. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/monitor.py +0 -0
  109. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/pps_calibrator.py +0 -0
  110. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/slot_clock.py +0 -0
  111. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/tui.py +0 -0
  112. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/utils.py +0 -0
  113. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  114. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/entry_points.txt +0 -0
  115. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/requires.txt +0 -0
  116. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/top_level.txt +0 -0
  117. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/setup.cfg +0 -0
  118. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/__init__.py +0 -0
  119. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/conftest.py +0 -0
  120. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_addressing.py +0 -0
  121. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_channel_verification.py +0 -0
  122. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_decode_description.py +0 -0
  123. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_decode_functions.py +0 -0
  124. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_encode_functions.py +0 -0
  125. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_encode_socket.py +0 -0
  126. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_integration.py +0 -0
  127. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_listen_multicast.py +0 -0
  128. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_managed_stream_recovery.py +0 -0
  129. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_monitor.py +0 -0
  130. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multihomed.py +0 -0
  131. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multistream_gap_storm.py +0 -0
  132. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multistream_prune.py +0 -0
  133. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_native_discovery.py +0 -0
  134. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_parse_rtp_samples_iq.py +0 -0
  135. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_performance_fixes.py +0 -0
  136. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_rtp_recorder.py +0 -0
  137. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_security_features.py +0 -0
  138. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_slot_clock.py +0 -0
  139. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_spectrum.py +0 -0
  140. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ssrc_dest_unit.py +0 -0
  141. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ssrc_encoding_unit.py +0 -0
  142. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  143. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ttl_warning.py +0 -0
  144. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune.py +0 -0
  145. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_cli.py +0 -0
  146. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_debug.py +0 -0
  147. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_live.py +0 -0
  148. {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_method.py +0 -0
@@ -1,5 +1,213 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.25.2 (2026-08-22)
4
+
5
+ ### Fixed
6
+
7
+ - **`poll_channel()` can now see a channel radiod holds at 0 Hz** (#6).
8
+ radiod answers a poll for a channel it is still reaping after
9
+ `remove_channel()` — or one parked at 0 Hz, or one a dynamic-create build
10
+ minted for the poll itself — with the same `frequency = 0` reply it gives
11
+ for an unknown SSRC. `poll_channel()` discarded those as "no such
12
+ channel", so a purging SSRC was indistinguishable from a free one and any
13
+ "is this SSRC free?" logic re-created the dying channel (answers status,
14
+ never emits RTP). New `allow_idle=True` returns that reply as a
15
+ `ChannelInfo` with `frequency == 0.0`, meaning "radiod currently answers
16
+ for this SSRC at 0 Hz — not free to reuse". Default behaviour is
17
+ unchanged; `expected_freq` still rejects 0 Hz. `remove_channel()`'s
18
+ docstring now states the purge window.
19
+
20
+ ## 3.25.1 (2026-08-18)
21
+
22
+ ### Fixed
23
+
24
+ - **`DEMOD_TYPE` no longer contradicts the preset it is sent with.**
25
+ `create_channel()` derived the demodulator from a five-name allowlist —
26
+ `demod_type = 0 if preset in ['iq','usb','lsb','cw','am'] else 1` — and sent
27
+ it immediately after `PRESET` in the same packet, so the later value won.
28
+ Every preset outside that list got `FM_DEMOD`, including `wfm`: radiod ran
29
+ the *narrowband* FM demodulator behind the wfm preset's ±110 kHz filter,
30
+ which emits nothing and reports `snr=-inf` indefinitely. Broadcast FM was
31
+ unusable through this library and the failure was silent — the channel
32
+ existed, verified, and simply never produced RTP.
33
+
34
+ `sam`, `ame`, `dsb`, `cwu`, `cwl`, `wspr`, `nam` and `amsq` were mislabelled
35
+ the same way (all are `demod = linear` in `presets.conf`), and `spectrum`
36
+ got FM rather than `SPECT_DEMOD`. Only the five allowlisted names were right.
37
+
38
+ `demod_type_for_preset()` now mirrors the `demod =` line of each stock
39
+ preset, and defaults to `LINEAR_DEMOD` — which `radio.h` defines as
40
+ "everything else" — rather than to FM.
41
+
42
+ Verified against radiod: a `wfm` channel now reports `demod_type=2` and,
43
+ with a squelch threshold below the signal, streams continuously (301
44
+ packets in 6 s where it previously produced none).
45
+
46
+ Note for callers: radiod's `wfm.c` sets `chan->squelch.snr_enable = true`
47
+ unconditionally when the demod thread starts, so `set_squelch(enable=False)`
48
+ is reverted on a wfm channel. Hold it open with a low `open_snr_db` instead.
49
+
50
+ ## 3.25.0 (2026-08-18)
51
+
52
+ ### Added
53
+
54
+ - **`raw_payloads=True` on `RadiodStream` and `ManagedStream`: deliver
55
+ undecoded RTP payloads for the framed encodings.** `parse_rtp_samples()`
56
+ deliberately returns `None` for OPUS/OPUS_VOIP/AX25 — they have no
57
+ raw-sample representation — and `_process_packet` drops a `None`, so a
58
+ stream on any of those encodings ran healthy and silent: packets arrived,
59
+ the callback never fired, and nothing logged. The only way to consume an
60
+ Opus channel was `OpusDecoder`, i.e. decoding to PCM.
61
+
62
+ That is the wrong shape for a consumer that wants the codec frames
63
+ themselves — forwarding Opus to a browser's WebCodecs decoder, or writing
64
+ an Ogg/AX25 file — where decoding to PCM and re-encoding is pure loss.
65
+ With `raw_payloads=True`, `on_samples` receives a `List[bytes]`, one
66
+ entry per RTP packet in arrival order, and with
67
+ `deliver_interval_packets=1` that is exactly one frame per call.
68
+
69
+ Two properties callers must respect:
70
+
71
+ - The resequencer is **bypassed**. A codec frame is opaque: it can be
72
+ neither concatenated nor zero-filled, and gap concealment belongs to
73
+ the decoder (Opus has built-in PLC). Loss and reorder therefore surface
74
+ to the caller, which for a sequenced transport like TCP/WebSocket is
75
+ exactly right, and for a lossy one means the caller owns concealment.
76
+ - Quality accounting switches to **frames, not samples**:
77
+ `batch_samples_delivered` and `total_samples_expected` count RTP
78
+ packets, and the resequencer statistics stay zero because it never ran.
79
+
80
+ `stop()` no longer flushes the resequencer in this mode — its ndarray
81
+ output would otherwise be appended to a buffer of `bytes` and blow up in
82
+ `np.concatenate` on the final delivery.
83
+
84
+ The parameter is additive and defaults to `False`; the sample path is
85
+ unchanged. `tests/client_usage_manifest.json` is updated for the new
86
+ signature.
87
+
88
+ - **`FILTER_DROPS` (tag 77) now decodes in `decode_status_dict()`, and is
89
+ carried on `ChannelInfo.filter_drops`.** radiod's per-channel count of
90
+ output blocks its demod thread lapped was decodable only in
91
+ `decode_status_packet()` → `ChannelStatus.filter_drops` — the typed
92
+ decoder nothing polls continuously. The lightweight decoder that
93
+ `StatusListener` runs on *every* broadcast silently skipped the tag, and
94
+ since listener callbacks receive a `ChannelInfo` rather than the status
95
+ dict, the counter never reached a long-running client at all. Measuring
96
+ radiod's own loss therefore meant either `metadump` (which follows one
97
+ stream, not the fleet) or hand-rolling a TLV walk.
98
+
99
+ This matters more since ka9q-radio `55d9048d`: a lapped block is now
100
+ counted *and* emitted as a block of zeros, so `PacketResequencer`'s
101
+ zero-block detection and this counter are two independent views of the
102
+ same event. Cross-checking them is what makes either trustworthy — the
103
+ same test that showed the pre-`55d9048d` counter accounted for only
104
+ 6–15% of real timeline loss.
105
+
106
+ Two properties callers must respect, both covered by tests:
107
+
108
+ - The counter is **cumulative since radiod started**. Difference it
109
+ across two reads; a level on its own says nothing about current loss.
110
+ - **Absent ≠ zero.** A status packet without the tag leaves
111
+ `filter_drops` as `None` and does not clear a previously known count
112
+ — clearing it would read as a counter reset and produce a spurious
113
+ negative delta.
114
+
115
+ `ChannelInfo.filter_drops` is appended as the last field, so the change
116
+ is purely additive for positional construction. `client_usage_manifest.json`
117
+ is updated accordingly; the two signature entries were edited by hand
118
+ because `scripts/gen_client_manifest.py` scans `/opt/git/sigmond`, which
119
+ is not present on a dev box — re-run it on an appliance to confirm.
120
+
121
+ ### Deprecated
122
+
123
+ - **`rtp_to_wallclock()` now emits `DeprecationWarning` on every call**
124
+ (audit F16, completing the 2026-06-27 rename). Signature and behavior
125
+ are unchanged — it delegates to `rtp_to_utc()`. The in-tree clients
126
+ (hf-timestd, wspr-recorder) migrated to `rtp_to_utc` before this
127
+ landed; internal callers (`RadiodStream`, `MultiStream`, `RTPRecorder`)
128
+ were migrated in the same commit so the library never warns about
129
+ itself on the per-packet path.
130
+
131
+ ## 3.22.0 (2026-08-13)
132
+
133
+ Audit remediation follow-up (findings F1, F3-F8, F10-F14 from the
134
+ 2026-08 upstream-alignment audit; see `docs/audit/` and
135
+ `.superpowers/sdd/2026-08-13-audit-remediation/`). Minor bump: adds
136
+ public capability and one deliberate, owner-ruled behavior change.
137
+
138
+ ### Changed (behavior)
139
+
140
+ - **`create_channel()` now derives a per-client multicast destination
141
+ from `client_id` when `destination=` is not given, and raises
142
+ `ValidationError` when *neither* is available** (F5). Previously a
143
+ bare `create_channel()` call with no `client_id` and no
144
+ `destination=` omitted the `OUTPUT_DATA_DEST_SOCKET` TLV entirely,
145
+ so radiod created no channel at all, rather than failing loudly on
146
+ the client side. Any caller that relied on that silent no-op must
147
+ now pass `client_id=` (recommended) or an explicit `destination=`.
148
+ One deliberate side effect: for direct `create_channel()` calls that
149
+ set `client_id`, omit `destination=`, and let the SSRC be
150
+ auto-allocated, the newly derived destination now participates in
151
+ the SSRC hash — SSRCs for that specific path differ from what they
152
+ were pre-3.22.
153
+
154
+ ### Added
155
+
156
+ - **`set_spectrum()`** gains `window_type`, `avg`, `overlap`, `base`,
157
+ `step` parameters (plus `demod_type`/`frequency_hz`); `SpectrumStream`
158
+ delegates to it (F11).
159
+ - **`ChannelStatus.options`** decodes the `SETOPTS` TLV (F12).
160
+ - **`DemodType.INVALID_DEMOD = -1`** now parses correctly from
161
+ upstream headers (negative enum literals were previously dropped by
162
+ `sync_types.py`) (F10); `set_demod_type()`'s bound now derives from
163
+ `DemodType.N_DEMOD` so `IDLE_DEMOD` is no longer wrongly rejected
164
+ (F13).
165
+ - **`resolve_multicast_address` re-exported from `ka9q`** (F14).
166
+
167
+ ### Fixed
168
+
169
+ - **`MultiStream` re-sends `agc`/`gain`/filter-edges/requested-encoding**
170
+ on channel re-creation after a radiod restart — these fields were
171
+ never stored on the `MultiStream` side, so re-creation could not
172
+ re-assert them; this was an origin bug, not a regression (F1, P0).
173
+ - **`tune()` records `_requested_encoding`** so keepalives re-assert
174
+ the caller's chosen encoding instead of drifting back to the
175
+ channel's current one (F4).
176
+ - **`set_lock()` warns** that radiod silently discards `LOCK` rather
177
+ than failing (F6).
178
+
179
+ ### Docs
180
+
181
+ - Corrected re-create/lifetime semantics (delta-update only; polls
182
+ don't extend lifetime; units are frames, not seconds) (F3, F7).
183
+ - Corrected a stale pre-3.14 fallback claim in the `client_id`
184
+ docstring (F5 follow-up).
185
+ - README Troubleshooting: a `lo`-route silently swallows control
186
+ commands; use `interface=` (F8).
187
+
188
+ ## 3.21.1 (2026-08-11)
189
+
190
+ - **Fix: `StreamQuality.copy()` dropped `delivered_rtp_start`** — the
191
+ hand-maintained field list predated the new field, so every
192
+ RadiodStream callback received `None` (MultiStream, which passes its
193
+ quality object directly, was unaffected). `copy()` now uses
194
+ `dataclasses.replace`, carrying every present and future field; a
195
+ fields-iteration regression test guards the class.
196
+
197
+ ## 3.21.0 (2026-08-11)
198
+
199
+ - **`StreamQuality.delivered_rtp_start`** — the true RTP timestamp of the
200
+ FIRST sample of each delivered batch, from the resequencer's emission
201
+ tracking (`PacketResequencer.last_chunk_rtp_start`; zero fills
202
+ included). Both `RadiodStream` and `MultiStream` populate it.
203
+ Consumers that label samples MUST prefer this over
204
+ `last_rtp_timestamp`: the latter is the last RECEIVED packet's header
205
+ (pre-resequencer) and desynchronizes from delivered samples under
206
+ loss — steady-state ±1-packet label wobble, and block-scale label
207
+ slips during stall-and-catchup (root cause of hf-timestd's T6 origin
208
+ instability; see hf-timestd docs/T6-BLOCK-SLIP-ROOT-CAUSE-2026-08-10).
209
+ `None` when nothing has been delivered yet.
210
+
3
211
  ## [3.20.0] - 2026-06-29
4
212
 
5
213
  ### Added — `SlotClock` ambiguous-unwrap guard (fail loud, never silent)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.20.0
3
+ Version: 3.26.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
@@ -53,6 +53,7 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
53
53
  - [Documentation](#documentation)
54
54
  - [Examples](#examples)
55
55
  - [Use Cases](#use-cases)
56
+ - [Troubleshooting](#troubleshooting)
56
57
  - [License](#license)
57
58
 
58
59
  ## Features
@@ -105,7 +106,7 @@ pip install -e .
105
106
  from ka9q import RadiodControl
106
107
 
107
108
  # Connect to radiod (default test host: bee1-hf-status.local)
108
- control = RadiodControl("bee1-hf-status.local")
109
+ control = RadiodControl("bee1-hf-status.local", client_id="am-demo")
109
110
 
110
111
  # Create AM channel on 10 MHz WWV
111
112
  control.create_channel(
@@ -123,7 +124,7 @@ control.create_channel(
123
124
  ```python
124
125
  from ka9q import RadiodControl, Encoding
125
126
 
126
- control = RadiodControl("bee1-hf-status.local")
127
+ control = RadiodControl("bee1-hf-status.local", client_id="hq-encoding-demo")
127
128
 
128
129
  # Create a channel with 32-bit float output (highest quality)
129
130
  control.ensure_channel(
@@ -139,7 +140,7 @@ control.ensure_channel(
139
140
  ```python
140
141
  from ka9q import RadiodControl
141
142
 
142
- control = RadiodControl("bee1-hf-status.local")
143
+ control = RadiodControl("bee1-hf-status.local", client_id="wspr-demo")
143
144
 
144
145
  wspr_bands = [
145
146
  (1.8366e6, "160m"),
@@ -216,7 +217,7 @@ ensure your channels survive radiod restarts:
216
217
  ```python
217
218
  from ka9q import RadiodControl, ChannelMonitor
218
219
 
219
- control = RadiodControl("bee1-hf-status.local")
220
+ control = RadiodControl("bee1-hf-status.local", client_id="monitor-demo")
220
221
  monitor = ChannelMonitor(control)
221
222
  monitor.start()
222
223
 
@@ -233,7 +234,7 @@ monitor.monitor_channel(
233
234
  `radiod` removes channels by polling for streams whose frequency is set to `0 Hz`. Always call `remove_channel(ssrc)` (or explicitly set `set_frequency(ssrc, 0.0)` if you build TLVs yourself) when tearing down a stream so the background poller can reclaim it:
234
235
 
235
236
  ```python
236
- with RadiodControl("bee1-hf-status.local") as control:
237
+ with RadiodControl("bee1-hf-status.local", client_id="cleanup-demo") as control:
237
238
  info = control.ensure_channel(
238
239
  frequency_hz=10e6,
239
240
  preset="iq",
@@ -339,6 +340,49 @@ See [docs/RECIPES.md](docs/RECIPES.md) for worked examples of:
339
340
  - **Nimble channel switching** — single-channel SWL-style retuning driven from the CLI or an app
340
341
  - **SDR portability** — ka9q-python talks to `radiod`, which talks to the SDR; reporting frontend capabilities via `FrontendStatus`. Primary tested frontend is the RX888; AirspyR2 and Airspy HF+ support is in development.
341
342
 
343
+ ## Troubleshooting
344
+
345
+ ### Created channels never appear (silent write-path failure)
346
+
347
+ **Symptom:** `discover_channels()` works — you can see the radiod's
348
+ existing channels — but every `create_channel()` / `ensure_channel()`
349
+ you issue "succeeds" (no exception from the send), and then the channel
350
+ never shows up. The only visible error is a later
351
+ `TimeoutError: Channel SSRC ... not verified within ...s` or a
352
+ `poll_channel()` that returns `None` — neither of which names the cause.
353
+
354
+ **Cause:** some hosts carry a kernel route that sends **all
355
+ locally-originated multicast to loopback** (e.g. a `239.0.0.0/8 dev lo`
356
+ entry). Inbound multicast still arrives on the real NIC — so discovery
357
+ (the read path) looks healthy — while every outbound control command is
358
+ routed to `lo` and never reaches radiod. There is no socket error: the
359
+ packet is delivered, just to the wrong interface. This was
360
+ tcpdump-confirmed during the 2026-08-12 audit: `tcpdump -i <nic>` saw
361
+ zero command packets while `tcpdump -i lo` captured all of them
362
+ (sourced from 127.0.0.1).
363
+
364
+ **Check:**
365
+
366
+ ```bash
367
+ # Substitute the group your radiod status DNS name resolves to:
368
+ ip route get 239.205.73.40
369
+ # BAD: ... dev lo src ... <- commands never leave this host
370
+ # GOOD: ... dev eth0 src ...
371
+ ```
372
+
373
+ **Remedy:** pass your NIC's IP as `interface=` so ka9q-python sets
374
+ `IP_MULTICAST_IF` on the send socket, overriding the kernel route:
375
+
376
+ ```python
377
+ control = RadiodControl("bee1-status.local", interface="192.168.1.176")
378
+ channels = discover_channels("bee1-status.local", interface="192.168.1.176")
379
+ ```
380
+
381
+ **Rule of thumb:** a working `discover_channels()` proves only the
382
+ *read* path. If reads work but writes vanish, check `ip route get`
383
+ first — send-side ACKs don't exist in this protocol, so routing
384
+ misconfiguration is otherwise invisible.
385
+
342
386
  ## License
343
387
 
344
388
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -19,6 +19,7 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
19
19
  - [Documentation](#documentation)
20
20
  - [Examples](#examples)
21
21
  - [Use Cases](#use-cases)
22
+ - [Troubleshooting](#troubleshooting)
22
23
  - [License](#license)
23
24
 
24
25
  ## Features
@@ -71,7 +72,7 @@ pip install -e .
71
72
  from ka9q import RadiodControl
72
73
 
73
74
  # Connect to radiod (default test host: bee1-hf-status.local)
74
- control = RadiodControl("bee1-hf-status.local")
75
+ control = RadiodControl("bee1-hf-status.local", client_id="am-demo")
75
76
 
76
77
  # Create AM channel on 10 MHz WWV
77
78
  control.create_channel(
@@ -89,7 +90,7 @@ control.create_channel(
89
90
  ```python
90
91
  from ka9q import RadiodControl, Encoding
91
92
 
92
- control = RadiodControl("bee1-hf-status.local")
93
+ control = RadiodControl("bee1-hf-status.local", client_id="hq-encoding-demo")
93
94
 
94
95
  # Create a channel with 32-bit float output (highest quality)
95
96
  control.ensure_channel(
@@ -105,7 +106,7 @@ control.ensure_channel(
105
106
  ```python
106
107
  from ka9q import RadiodControl
107
108
 
108
- control = RadiodControl("bee1-hf-status.local")
109
+ control = RadiodControl("bee1-hf-status.local", client_id="wspr-demo")
109
110
 
110
111
  wspr_bands = [
111
112
  (1.8366e6, "160m"),
@@ -182,7 +183,7 @@ ensure your channels survive radiod restarts:
182
183
  ```python
183
184
  from ka9q import RadiodControl, ChannelMonitor
184
185
 
185
- control = RadiodControl("bee1-hf-status.local")
186
+ control = RadiodControl("bee1-hf-status.local", client_id="monitor-demo")
186
187
  monitor = ChannelMonitor(control)
187
188
  monitor.start()
188
189
 
@@ -199,7 +200,7 @@ monitor.monitor_channel(
199
200
  `radiod` removes channels by polling for streams whose frequency is set to `0 Hz`. Always call `remove_channel(ssrc)` (or explicitly set `set_frequency(ssrc, 0.0)` if you build TLVs yourself) when tearing down a stream so the background poller can reclaim it:
200
201
 
201
202
  ```python
202
- with RadiodControl("bee1-hf-status.local") as control:
203
+ with RadiodControl("bee1-hf-status.local", client_id="cleanup-demo") as control:
203
204
  info = control.ensure_channel(
204
205
  frequency_hz=10e6,
205
206
  preset="iq",
@@ -305,6 +306,49 @@ See [docs/RECIPES.md](docs/RECIPES.md) for worked examples of:
305
306
  - **Nimble channel switching** — single-channel SWL-style retuning driven from the CLI or an app
306
307
  - **SDR portability** — ka9q-python talks to `radiod`, which talks to the SDR; reporting frontend capabilities via `FrontendStatus`. Primary tested frontend is the RX888; AirspyR2 and Airspy HF+ support is in development.
307
308
 
309
+ ## Troubleshooting
310
+
311
+ ### Created channels never appear (silent write-path failure)
312
+
313
+ **Symptom:** `discover_channels()` works — you can see the radiod's
314
+ existing channels — but every `create_channel()` / `ensure_channel()`
315
+ you issue "succeeds" (no exception from the send), and then the channel
316
+ never shows up. The only visible error is a later
317
+ `TimeoutError: Channel SSRC ... not verified within ...s` or a
318
+ `poll_channel()` that returns `None` — neither of which names the cause.
319
+
320
+ **Cause:** some hosts carry a kernel route that sends **all
321
+ locally-originated multicast to loopback** (e.g. a `239.0.0.0/8 dev lo`
322
+ entry). Inbound multicast still arrives on the real NIC — so discovery
323
+ (the read path) looks healthy — while every outbound control command is
324
+ routed to `lo` and never reaches radiod. There is no socket error: the
325
+ packet is delivered, just to the wrong interface. This was
326
+ tcpdump-confirmed during the 2026-08-12 audit: `tcpdump -i <nic>` saw
327
+ zero command packets while `tcpdump -i lo` captured all of them
328
+ (sourced from 127.0.0.1).
329
+
330
+ **Check:**
331
+
332
+ ```bash
333
+ # Substitute the group your radiod status DNS name resolves to:
334
+ ip route get 239.205.73.40
335
+ # BAD: ... dev lo src ... <- commands never leave this host
336
+ # GOOD: ... dev eth0 src ...
337
+ ```
338
+
339
+ **Remedy:** pass your NIC's IP as `interface=` so ka9q-python sets
340
+ `IP_MULTICAST_IF` on the send socket, overriding the kernel route:
341
+
342
+ ```python
343
+ control = RadiodControl("bee1-status.local", interface="192.168.1.176")
344
+ channels = discover_channels("bee1-status.local", interface="192.168.1.176")
345
+ ```
346
+
347
+ **Rule of thumb:** a working `discover_channels()` proves only the
348
+ *read* path. If reads work but writes vanish, check `ip route get`
349
+ first — send-side ACKs don't exist in this protocol, so routing
350
+ misconfiguration is otherwise invisible.
351
+
308
352
  ## License
309
353
 
310
354
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -47,13 +47,13 @@ The `RadiodControl` class is the primary interface for sending commands to `radi
47
47
  ### Step 2: Connect to radiod
48
48
 
49
49
  ```python
50
- control = RadiodControl("radiod.local")
50
+ control = RadiodControl("radiod.local", client_id="getting-started")
51
51
  ```
52
52
 
53
- Replace `"radiod.local"` with the mDNS name or IP address of your `radiod` instance. If you're on a multi-homed system (multiple network interfaces), you may need to specify which interface to use:
53
+ Replace `"radiod.local"` with the mDNS name or IP address of your `radiod` instance. `client_id` identifies this application so ka9q-python can derive a per-client multicast destination for the channels you create. If you're on a multi-homed system (multiple network interfaces), you may need to specify which interface to use:
54
54
 
55
55
  ```python
56
- control = RadiodControl("radiod.local", interface="192.168.1.100")
56
+ control = RadiodControl("radiod.local", client_id="getting-started", interface="192.168.1.100")
57
57
  ```
58
58
 
59
59
  ### Step 3: Create a Channel
@@ -89,7 +89,7 @@ Here's the full program:
89
89
  from ka9q import RadiodControl
90
90
 
91
91
  # Connect to radiod
92
- control = RadiodControl("radiod.local")
92
+ control = RadiodControl("radiod.local", client_id="getting-started")
93
93
 
94
94
  # Create AM channel for WWV 10 MHz
95
95
  control.create_channel(
@@ -194,7 +194,7 @@ def on_stream_restored(channel):
194
194
  print(f"✓ Stream restored: {channel.frequency/1e6:.3f} MHz")
195
195
 
196
196
  # Connect to radiod
197
- control = RadiodControl("radiod.local")
197
+ control = RadiodControl("radiod.local", client_id="getting-started")
198
198
 
199
199
  # Create a self-healing stream
200
200
  stream = ManagedStream(
@@ -0,0 +1,136 @@
1
+ # Keeping channels alive on a shared control plane
2
+
3
+ **Status:** design, not built. 2026-09-21.
4
+ **Supersedes in practice:** the 30-second `set_channel_lifetime` keepalive.
5
+
6
+ ## What Phil asks of a controller
7
+
8
+ Four rules, from ka9q's author on 2026-09-21:
9
+
10
+ 1. The control/status protocol stays **multicast, idempotent and shared**, so
11
+ several controllers can drive one radiod and all hold a coherent view.
12
+ 2. A controller sends configuration parameters autonomously **only** when
13
+ retransmitting a command whose tag has not yet been echoed. Otherwise it
14
+ sends an empty poll.
15
+ 3. Because every poll response dumps the whole channel state, a controller
16
+ should **share other controllers' responses** rather than ask again, and
17
+ should **randomize its poll timing**.
18
+ 4. A controller resets its poll timer when a status arrives that another
19
+ controller's poll triggered.
20
+
21
+ And one prohibition that shapes everything below: no controller should
22
+ automatically *correct* what it believes to be a wrong channel state. That
23
+ starts a tuning war. State changes follow the user.
24
+
25
+ ## What we do today, and why it has to change
26
+
27
+ `set_channel_lifetime(ssrc, 6000)` every 30 seconds per channel, carrying a
28
+ re-asserted output encoding alongside. On AC0G-B4 that is 120 autonomous
29
+ configuration broadcasts an hour, which every other controller on the plane
30
+ must receive and parse.
31
+
32
+ Rule 2 rules it out. Two measurements say we can drop it outright:
33
+
34
+ - **An empty poll refreshes the self-destruct timer.** `decode_radio_commands`
35
+ begins `chan->lifetime = chan->lifestart`, for any command of length ≥ 2,
36
+ and `poll_channel` sends `COMMAND_TAG + OUTPUT_SSRC + EOL` and nothing
37
+ else. Measured on B4 2026-09-21: an unpolled control channel died at
38
+ 37.7 s; a channel polled every 5 s outlived it and then died 43.7 s after
39
+ the polls stopped — one fresh lifetime from its last poll.
40
+ - **The re-asserted encoding defends against nothing.** `loadpreset` sits
41
+ inside `case PRESET:` at both our pin and `ka9q/main`, so a LIFETIME-only
42
+ command never reaches it. Our own issue (HamSCI/ka9q-python#3) records that
43
+ we never reproduced the downgrade it guards against.
44
+
45
+ ## The design
46
+
47
+ ### The fact that makes it simple
48
+
49
+ Every status packet carries the channel's **current remaining lifetime**
50
+ (`radio_status.c`: `encode_int32(&bp,LIFETIME,chan->lifetime)`), and
51
+ `status.py` already decodes it into `ChannelStatus.lifetime`.
52
+
53
+ So a controller never has to *infer* whether someone else's command refreshed
54
+ a timer. It reads the answer. When another controller polls a channel we own,
55
+ the next status we see carries a lifetime that has jumped back up, and our
56
+ own poll becomes unnecessary — rules 3 and 4 satisfied by observation rather
57
+ than by bookkeeping about tags.
58
+
59
+ That turns the keepalive from open loop (fire every 30 s regardless) into
60
+ closed loop (act when the channel actually needs it).
61
+
62
+ ### Shape
63
+
64
+ A `ChannelKeepalive` holding, per owned SSRC:
65
+
66
+ - `lifetime_frames` — the last value seen in a status
67
+ - `observed_at` — the monotonic time of that reading
68
+ - `block_time_s` — measured, not assumed (see below)
69
+
70
+ On each tick it projects the remaining lifetime forward from the last
71
+ reading, and polls a channel only when that projection falls under a
72
+ threshold, with jitter applied so two controllers do not synchronise.
73
+
74
+ ```
75
+ remaining_s ≈ (lifetime_frames × block_time_s) − (now − observed_at)
76
+ poll when remaining_s < threshold_s × uniform(0.85, 1.15)
77
+ ```
78
+
79
+ ### Three things that must not go wrong
80
+
81
+ **⛔ Absence of status must never read as health.** If no status has arrived
82
+ for a channel within some bound, the controller knows nothing about its
83
+ lifetime and must poll unconditionally. A suppression rule whose quiet state
84
+ is "do nothing" turns a dropped multicast group into a fleet of channels
85
+ quietly self-destructing. The fail-safe direction is to poll.
86
+
87
+ **⛔ Suppression is per-SSRC, never global.** `chan->lifetime` is refreshed by
88
+ a command addressed to *that channel*. Another controller polling a different
89
+ SSRC refreshes nothing of ours. Seeing traffic on the plane is not evidence
90
+ our channel was touched; seeing a status *for our SSRC* is.
91
+
92
+ **⚠ Block time is a measurement, not a constant.** `lifetime` counts radiod
93
+ main-loop blocks, and the conversion to seconds depends on the configured
94
+ block time. B4 measured ~18.9 ms on 2026-09-21 (2000 frames ≈ 37.7 s) with no
95
+ `blocktime` in its radiod config, so that is a default rather than a fleet
96
+ constant. Derive it per station by watching the lifetime count down between
97
+ two statuses, and fall back to polling on a fixed short period when it cannot
98
+ be derived.
99
+
100
+ ### Threshold
101
+
102
+ Margin has to cover the status cadence (`STATUS_INTERVAL`), the poll round
103
+ trip, and lost packets on a multicast group with no retransmission. A
104
+ threshold near a third of the configured lifetime leaves room for two missed
105
+ statuses before the channel is at risk. With our 6000 frames ≈ 113 s, that
106
+ puts a poll roughly every 35–40 s in the worst case — no worse than today —
107
+ while a plane with several active controllers should see us poll rarely.
108
+
109
+ ### What it needs from the library
110
+
111
+ `ChannelInfo` does not carry `lifetime`, though `ChannelStatus` does. Same
112
+ plumbing gap that `demod_type` and `output_channels` had, and the same fix:
113
+ decode it in `decode_status_dict` and pass it through in `discovery.py`.
114
+
115
+ ## What this design refuses to do
116
+
117
+ It does not reconcile channel state. If a status shows a frequency or a
118
+ filter we did not ask for, the keepalive records it and says nothing. Putting
119
+ it back is the tuning war Phil warns against, and a controller that "knows
120
+ better" than the operator is exactly the thing a shared plane cannot carry.
121
+
122
+ `ChannelManager.ensure_channels_exist(update_existing=...)` already defaults
123
+ to `False` and is reached only from operator-invoked CLI verbs. That default
124
+ is load-bearing on a shared plane and should be commented as such, rather
125
+ than left looking like an ordinary convenience.
126
+
127
+ ## Testing it
128
+
129
+ The arrangement that settled the empty-poll question generalises: run two
130
+ channels, drive one and leave the other, and let the untouched arm reveal the
131
+ timescale. Then stop driving the first and confirm it dies — without that
132
+ third arm, survival proves nothing about cause.
133
+
134
+ For suppression specifically, the case worth building is two controllers on
135
+ one SSRC: confirm the second suppresses while the first polls, and — the one
136
+ that matters — confirm it resumes promptly when the first goes away.
@@ -161,7 +161,7 @@ def make_callback(label):
161
161
  handle(label, samples, quality)
162
162
  return on_samples
163
163
 
164
- with RadiodControl("bee3-status.local") as control:
164
+ with RadiodControl("bee3-status.local", client_id="band-recorder") as control:
165
165
  multi = MultiStream(control=control)
166
166
  for label, fhz, preset, rate in BAND_PLAN:
167
167
  multi.add_channel(
@@ -241,7 +241,7 @@ retune it in place.
241
241
  ```python
242
242
  from ka9q import RadiodControl, ManagedStream
243
243
 
244
- with RadiodControl("bee1-hf-status.local") as control:
244
+ with RadiodControl("bee1-hf-status.local", client_id="swl-tuner") as control:
245
245
  stream = ManagedStream(
246
246
  control=control,
247
247
  frequency_hz=14.074e6,
@@ -323,7 +323,7 @@ For a desktop-style tuner, the usual shape is:
323
323
  ```python
324
324
  class Receiver:
325
325
  def __init__(self, host):
326
- self._control = RadiodControl(host)
326
+ self._control = RadiodControl(host, client_id="swl-tuner")
327
327
  self._stream = ManagedStream(
328
328
  control=self._control,
329
329
  frequency_hz=14.200e6,
@@ -653,7 +653,7 @@ frequency with `ManagedStream`. Both use the same `RadiodControl`:
653
653
  ```python
654
654
  from ka9q import RadiodControl, SpectrumStream, ManagedStream
655
655
 
656
- with RadiodControl("radiod.local") as ctl:
656
+ with RadiodControl("radiod.local", client_id="spectrogram-demo") as ctl:
657
657
  # Wideband spectrum display
658
658
  spectrum = SpectrumStream(
659
659
  control=ctl,
@@ -224,6 +224,12 @@ ka9q-python's "actors" are its consumers and the protocol it tracks:
224
224
  concurrent long-running use.
225
225
  - `KQP-Q-003` `[CODE]` ✅ Receive sockets SHALL set **`SO_RCVBUF` = 64 MB** on
226
226
  both `RadiodStream` and `MultiStream` to resist GIL-stall packet loss.
227
+ - `KQP-Q-010` `[CODE]` ✅ Every delivered sample batch SHALL carry the true
228
+ RTP timestamp of its FIRST sample (`StreamQuality.delivered_rtp_start`,
229
+ gap fills included), computed from the resequencer's emission tracking.
230
+ Consumers labelling samples SHALL prefer it over `last_rtp_timestamp`
231
+ (the last RECEIVED packet's header), which desynchronizes from the
232
+ delivered stream under loss. (2026-08-11; hf-timestd T6 origin slips.)
227
233
  - `KQP-Q-004` `[CODE]` ✅ `ManagedStream` SHALL recover automatically from
228
234
  radiod restart and network interruption without consumer intervention.
229
235
  - `KQP-Q-005` `[DOC]` ✅ Protocol definitions SHALL be **provably in sync** with