ka9q-python 3.22.0__tar.gz → 3.26.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 (148) hide show
  1. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/CHANGELOG.md +140 -0
  2. {ka9q_python-3.22.0/ka9q_python.egg-info → ka9q_python-3.26.1}/PKG-INFO +1 -1
  3. ka9q_python-3.26.1/docs/POLL-SUPPRESSION-DESIGN.md +136 -0
  4. ka9q_python-3.26.1/docs/superpowers/plans/2026-08-13-client-side-fixes.md +1162 -0
  5. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/__init__.py +4 -0
  6. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/compat.py +1 -1
  7. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/control.py +260 -79
  8. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/discovery.py +28 -1
  9. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/managed_stream.py +7 -0
  10. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/multi_stream.py +31 -4
  11. ka9q_python-3.26.1/ka9q/recovery_ladder.py +139 -0
  12. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/resequencer.py +37 -1
  13. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/rtp_recorder.py +20 -4
  14. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/status.py +10 -6
  15. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/status_listener.py +9 -0
  16. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/stream.py +124 -24
  17. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/stream_quality.py +13 -0
  18. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/types.py +4 -3
  19. {ka9q_python-3.22.0 → ka9q_python-3.26.1/ka9q_python.egg-info}/PKG-INFO +1 -1
  20. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q_python.egg-info/SOURCES.txt +12 -1
  21. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q_radio_compat +1 -1
  22. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/pyproject.toml +1 -1
  23. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/scripts/check_upstream_drift.py +221 -22
  24. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/scripts/sync_types.py +128 -6
  25. ka9q_python-3.26.1/tests/test_compat_pin_reachability.py +94 -0
  26. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_iq_20khz_f32.py +23 -1
  27. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_keepalive_encoding.py +46 -7
  28. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_lifetime.py +7 -1
  29. ka9q_python-3.26.1/tests/test_multistream_completeness.py +85 -0
  30. ka9q_python-3.26.1/tests/test_parameter_vector.py +133 -0
  31. ka9q_python-3.26.1/tests/test_poll_channel_idle.py +103 -0
  32. ka9q_python-3.26.1/tests/test_protocol_compat.py +156 -0
  33. ka9q_python-3.26.1/tests/test_recovery_ladder.py +166 -0
  34. ka9q_python-3.26.1/tests/test_rtp_to_wallclock_deprecated.py +83 -0
  35. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_status_decoder.py +83 -2
  36. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_status_listener.py +58 -1
  37. ka9q_python-3.26.1/tests/test_status_socket_drain.py +155 -0
  38. ka9q_python-3.26.1/tests/test_upstream_drift.py +422 -0
  39. ka9q_python-3.26.1/tests/test_zero_block_detection.py +135 -0
  40. ka9q_python-3.22.0/tests/test_protocol_compat.py +0 -82
  41. ka9q_python-3.22.0/tests/test_upstream_drift.py +0 -176
  42. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/LICENSE +0 -0
  43. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/MANIFEST.in +0 -0
  44. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/README.md +0 -0
  45. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/API_REFERENCE.md +0 -0
  46. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/ARCHITECTURE.md +0 -0
  47. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/CLI_GUIDE.md +0 -0
  48. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/GETTING_STARTED.md +0 -0
  49. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/INSTALLATION.md +0 -0
  50. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/MULTI_STREAM.md +0 -0
  51. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/RECIPES.md +0 -0
  52. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/REQUIREMENTS.md +0 -0
  53. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
  54. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/SECURITY.md +0 -0
  55. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/TESTING_GUIDE.md +0 -0
  56. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/TUI_GUIDE.md +0 -0
  57. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/README.md +0 -0
  58. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/clients.md +0 -0
  59. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/contract.md +0 -0
  60. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/control-surface.md +0 -0
  61. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/findings.md +0 -0
  62. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/idempotency.md +0 -0
  63. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/audit/2026-08-12-alignment/upstream.md +0 -0
  64. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/superpowers/plans/2026-08-12-upstream-alignment-and-client-compat.md +0 -0
  65. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/superpowers/plans/2026-08-13-audit-remediation.md +0 -0
  66. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/docs/superpowers/specs/2026-08-12-upstream-alignment-and-client-compat-design.md +0 -0
  67. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/advanced_features_demo.py +0 -0
  68. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/channel_cleanup_example.py +0 -0
  69. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/codar_oceanography.py +0 -0
  70. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/diagnostics/diagnose_packets.py +0 -0
  71. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/diagnostics/repro_utc_bug.py +0 -0
  72. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/discover_example.py +0 -0
  73. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/grape_integration_example.py +0 -0
  74. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/hf_band_scanner.py +0 -0
  75. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/multi_stream_smoke.py +0 -0
  76. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/rtp_recorder_example.py +0 -0
  77. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/simple_am_radio.py +0 -0
  78. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/spectrum_example.py +0 -0
  79. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/stream_example.py +0 -0
  80. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/superdarn_recorder.py +0 -0
  81. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/test_channel_operations.py +0 -0
  82. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/test_improvements.py +0 -0
  83. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/test_timing_fields.py +0 -0
  84. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/tune.py +0 -0
  85. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/examples/tune_example.py +0 -0
  86. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/_multicast.py +0 -0
  87. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/addressing.py +0 -0
  88. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/cli.py +0 -0
  89. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/exceptions.py +0 -0
  90. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/monitor.py +0 -0
  91. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/pps_calibrator.py +0 -0
  92. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/slot_clock.py +0 -0
  93. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/spectrum_stream.py +0 -0
  94. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/tui.py +0 -0
  95. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q/utils.py +0 -0
  96. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
  97. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q_python.egg-info/entry_points.txt +0 -0
  98. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q_python.egg-info/requires.txt +0 -0
  99. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/ka9q_python.egg-info/top_level.txt +0 -0
  100. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/scripts/gen_client_manifest.py +0 -0
  101. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/setup.cfg +0 -0
  102. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/__init__.py +0 -0
  103. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/conftest.py +0 -0
  104. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_addressing.py +0 -0
  105. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_channel_verification.py +0 -0
  106. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_client_contract.py +0 -0
  107. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_client_id_destination.py +0 -0
  108. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_create_split_encoding.py +0 -0
  109. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_decode_description.py +0 -0
  110. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_decode_functions.py +0 -0
  111. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_delivered_rtp_start.py +0 -0
  112. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_destination_required.py +0 -0
  113. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_encode_functions.py +0 -0
  114. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_encode_socket.py +0 -0
  115. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_ensure_channel_encoding.py +0 -0
  116. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_filter_edges.py +0 -0
  117. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_gen_client_manifest.py +0 -0
  118. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_idempotency_integration.py +0 -0
  119. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_integration.py +0 -0
  120. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_listen_multicast.py +0 -0
  121. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_managed_stream_recovery.py +0 -0
  122. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_monitor.py +0 -0
  123. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_multicast_helpers.py +0 -0
  124. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_multihomed.py +0 -0
  125. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_multistream_gap_storm.py +0 -0
  126. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_multistream_prune.py +0 -0
  127. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_multistream_restore.py +0 -0
  128. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_native_discovery.py +0 -0
  129. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_parse_rtp_samples_iq.py +0 -0
  130. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_performance_fixes.py +0 -0
  131. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_remove_channel.py +0 -0
  132. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_rtp_recorder.py +0 -0
  133. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_security_features.py +0 -0
  134. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_set_demod_type.py +0 -0
  135. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_set_lock_warns.py +0 -0
  136. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_set_spectrum.py +0 -0
  137. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_slot_clock.py +0 -0
  138. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_spectrum.py +0 -0
  139. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_ssrc_dest_unit.py +0 -0
  140. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_ssrc_encoding_unit.py +0 -0
  141. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
  142. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_sync_types.py +0 -0
  143. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_ttl_warning.py +0 -0
  144. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_tune.py +0 -0
  145. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_tune_cli.py +0 -0
  146. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_tune_debug.py +0 -0
  147. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_tune_live.py +0 -0
  148. {ka9q_python-3.22.0 → ka9q_python-3.26.1}/tests/test_tune_method.py +0 -0
@@ -1,5 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.26.1 (2026-10-01)
4
+
5
+ ### Fixed
6
+
7
+ - **`FrontendStatus.input_power_dbm` now matches radiod's `control.c` term
8
+ for term:** `if_power + lna + mixer + if_gain + |rf_atten| - rf_gain +
9
+ rf_level_cal`. The property had every analog sign inverted. It added
10
+ `rf_gain`, subtracted `rf_atten` and subtracted the LNA/mixer/IF gains. On
11
+ B4's RX888 it read about -5 dBm where `control` shows about -34 dBm, and the
12
+ error swung with the AGC. It also returns `None` when radiod sends
13
+ `rf_level_cal = NaN` (uncalibrated), where `control` shows no dBm figure.
14
+
15
+ ## 3.25.2 (2026-08-22)
16
+
17
+ ### Fixed
18
+
19
+ - **`poll_channel()` can now see a channel radiod holds at 0 Hz** (#6).
20
+ radiod answers a poll for a channel it is still reaping after
21
+ `remove_channel()` — or one parked at 0 Hz, or one a dynamic-create build
22
+ minted for the poll itself — with the same `frequency = 0` reply it gives
23
+ for an unknown SSRC. `poll_channel()` discarded those as "no such
24
+ channel", so a purging SSRC was indistinguishable from a free one and any
25
+ "is this SSRC free?" logic re-created the dying channel (answers status,
26
+ never emits RTP). New `allow_idle=True` returns that reply as a
27
+ `ChannelInfo` with `frequency == 0.0`, meaning "radiod currently answers
28
+ for this SSRC at 0 Hz — not free to reuse". Default behaviour is
29
+ unchanged; `expected_freq` still rejects 0 Hz. `remove_channel()`'s
30
+ docstring now states the purge window.
31
+
32
+ ## 3.25.1 (2026-08-18)
33
+
34
+ ### Fixed
35
+
36
+ - **`DEMOD_TYPE` no longer contradicts the preset it is sent with.**
37
+ `create_channel()` derived the demodulator from a five-name allowlist —
38
+ `demod_type = 0 if preset in ['iq','usb','lsb','cw','am'] else 1` — and sent
39
+ it immediately after `PRESET` in the same packet, so the later value won.
40
+ Every preset outside that list got `FM_DEMOD`, including `wfm`: radiod ran
41
+ the *narrowband* FM demodulator behind the wfm preset's ±110 kHz filter,
42
+ which emits nothing and reports `snr=-inf` indefinitely. Broadcast FM was
43
+ unusable through this library and the failure was silent — the channel
44
+ existed, verified, and simply never produced RTP.
45
+
46
+ `sam`, `ame`, `dsb`, `cwu`, `cwl`, `wspr`, `nam` and `amsq` were mislabelled
47
+ the same way (all are `demod = linear` in `presets.conf`), and `spectrum`
48
+ got FM rather than `SPECT_DEMOD`. Only the five allowlisted names were right.
49
+
50
+ `demod_type_for_preset()` now mirrors the `demod =` line of each stock
51
+ preset, and defaults to `LINEAR_DEMOD` — which `radio.h` defines as
52
+ "everything else" — rather than to FM.
53
+
54
+ Verified against radiod: a `wfm` channel now reports `demod_type=2` and,
55
+ with a squelch threshold below the signal, streams continuously (301
56
+ packets in 6 s where it previously produced none).
57
+
58
+ Note for callers: radiod's `wfm.c` sets `chan->squelch.snr_enable = true`
59
+ unconditionally when the demod thread starts, so `set_squelch(enable=False)`
60
+ is reverted on a wfm channel. Hold it open with a low `open_snr_db` instead.
61
+
62
+ ## 3.25.0 (2026-08-18)
63
+
64
+ ### Added
65
+
66
+ - **`raw_payloads=True` on `RadiodStream` and `ManagedStream`: deliver
67
+ undecoded RTP payloads for the framed encodings.** `parse_rtp_samples()`
68
+ deliberately returns `None` for OPUS/OPUS_VOIP/AX25 — they have no
69
+ raw-sample representation — and `_process_packet` drops a `None`, so a
70
+ stream on any of those encodings ran healthy and silent: packets arrived,
71
+ the callback never fired, and nothing logged. The only way to consume an
72
+ Opus channel was `OpusDecoder`, i.e. decoding to PCM.
73
+
74
+ That is the wrong shape for a consumer that wants the codec frames
75
+ themselves — forwarding Opus to a browser's WebCodecs decoder, or writing
76
+ an Ogg/AX25 file — where decoding to PCM and re-encoding is pure loss.
77
+ With `raw_payloads=True`, `on_samples` receives a `List[bytes]`, one
78
+ entry per RTP packet in arrival order, and with
79
+ `deliver_interval_packets=1` that is exactly one frame per call.
80
+
81
+ Two properties callers must respect:
82
+
83
+ - The resequencer is **bypassed**. A codec frame is opaque: it can be
84
+ neither concatenated nor zero-filled, and gap concealment belongs to
85
+ the decoder (Opus has built-in PLC). Loss and reorder therefore surface
86
+ to the caller, which for a sequenced transport like TCP/WebSocket is
87
+ exactly right, and for a lossy one means the caller owns concealment.
88
+ - Quality accounting switches to **frames, not samples**:
89
+ `batch_samples_delivered` and `total_samples_expected` count RTP
90
+ packets, and the resequencer statistics stay zero because it never ran.
91
+
92
+ `stop()` no longer flushes the resequencer in this mode — its ndarray
93
+ output would otherwise be appended to a buffer of `bytes` and blow up in
94
+ `np.concatenate` on the final delivery.
95
+
96
+ The parameter is additive and defaults to `False`; the sample path is
97
+ unchanged. `tests/client_usage_manifest.json` is updated for the new
98
+ signature.
99
+
100
+ - **`FILTER_DROPS` (tag 77) now decodes in `decode_status_dict()`, and is
101
+ carried on `ChannelInfo.filter_drops`.** radiod's per-channel count of
102
+ output blocks its demod thread lapped was decodable only in
103
+ `decode_status_packet()` → `ChannelStatus.filter_drops` — the typed
104
+ decoder nothing polls continuously. The lightweight decoder that
105
+ `StatusListener` runs on *every* broadcast silently skipped the tag, and
106
+ since listener callbacks receive a `ChannelInfo` rather than the status
107
+ dict, the counter never reached a long-running client at all. Measuring
108
+ radiod's own loss therefore meant either `metadump` (which follows one
109
+ stream, not the fleet) or hand-rolling a TLV walk.
110
+
111
+ This matters more since ka9q-radio `55d9048d`: a lapped block is now
112
+ counted *and* emitted as a block of zeros, so `PacketResequencer`'s
113
+ zero-block detection and this counter are two independent views of the
114
+ same event. Cross-checking them is what makes either trustworthy — the
115
+ same test that showed the pre-`55d9048d` counter accounted for only
116
+ 6–15% of real timeline loss.
117
+
118
+ Two properties callers must respect, both covered by tests:
119
+
120
+ - The counter is **cumulative since radiod started**. Difference it
121
+ across two reads; a level on its own says nothing about current loss.
122
+ - **Absent ≠ zero.** A status packet without the tag leaves
123
+ `filter_drops` as `None` and does not clear a previously known count
124
+ — clearing it would read as a counter reset and produce a spurious
125
+ negative delta.
126
+
127
+ `ChannelInfo.filter_drops` is appended as the last field, so the change
128
+ is purely additive for positional construction. `client_usage_manifest.json`
129
+ is updated accordingly; the two signature entries were edited by hand
130
+ because `scripts/gen_client_manifest.py` scans `/opt/git/sigmond`, which
131
+ is not present on a dev box — re-run it on an appliance to confirm.
132
+
133
+ ### Deprecated
134
+
135
+ - **`rtp_to_wallclock()` now emits `DeprecationWarning` on every call**
136
+ (audit F16, completing the 2026-06-27 rename). Signature and behavior
137
+ are unchanged — it delegates to `rtp_to_utc()`. The in-tree clients
138
+ (hf-timestd, wspr-recorder) migrated to `rtp_to_utc` before this
139
+ landed; internal callers (`RadiodStream`, `MultiStream`, `RTPRecorder`)
140
+ were migrated in the same commit so the library never warns about
141
+ itself on the per-packet path.
142
+
3
143
  ## 3.22.0 (2026-08-13)
4
144
 
5
145
  Audit remediation follow-up (findings F1, F3-F8, F10-F14 from the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.22.0
3
+ Version: 3.26.1
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
@@ -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.