ka9q-python 3.22.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.
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/CHANGELOG.md +128 -0
- {ka9q_python-3.22.0/ka9q_python.egg-info → ka9q_python-3.26.0}/PKG-INFO +1 -1
- ka9q_python-3.26.0/docs/POLL-SUPPRESSION-DESIGN.md +136 -0
- ka9q_python-3.26.0/docs/superpowers/plans/2026-08-13-client-side-fixes.md +1162 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/__init__.py +4 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/compat.py +1 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/control.py +260 -79
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/discovery.py +28 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/managed_stream.py +7 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/multi_stream.py +31 -4
- ka9q_python-3.26.0/ka9q/recovery_ladder.py +139 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/resequencer.py +37 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/rtp_recorder.py +20 -4
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/status_listener.py +9 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/stream.py +124 -24
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/stream_quality.py +13 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/types.py +4 -3
- {ka9q_python-3.22.0 → ka9q_python-3.26.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/SOURCES.txt +12 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q_radio_compat +1 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/pyproject.toml +1 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/scripts/check_upstream_drift.py +221 -22
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/scripts/sync_types.py +128 -6
- ka9q_python-3.26.0/tests/test_compat_pin_reachability.py +94 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_iq_20khz_f32.py +23 -1
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_keepalive_encoding.py +46 -7
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_lifetime.py +7 -1
- ka9q_python-3.26.0/tests/test_multistream_completeness.py +85 -0
- ka9q_python-3.26.0/tests/test_parameter_vector.py +133 -0
- ka9q_python-3.26.0/tests/test_poll_channel_idle.py +103 -0
- ka9q_python-3.26.0/tests/test_protocol_compat.py +156 -0
- ka9q_python-3.26.0/tests/test_recovery_ladder.py +166 -0
- ka9q_python-3.26.0/tests/test_rtp_to_wallclock_deprecated.py +83 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_status_decoder.py +46 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_status_listener.py +58 -1
- ka9q_python-3.26.0/tests/test_status_socket_drain.py +155 -0
- ka9q_python-3.26.0/tests/test_upstream_drift.py +422 -0
- ka9q_python-3.26.0/tests/test_zero_block_detection.py +135 -0
- ka9q_python-3.22.0/tests/test_protocol_compat.py +0 -82
- ka9q_python-3.22.0/tests/test_upstream_drift.py +0 -176
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/LICENSE +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/MANIFEST.in +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/README.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/REQUIREMENTS.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/README.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/clients.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/contract.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/control-surface.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/findings.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/idempotency.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/audit/2026-08-12-alignment/upstream.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/superpowers/plans/2026-08-12-upstream-alignment-and-client-compat.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/superpowers/plans/2026-08-13-audit-remediation.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/docs/superpowers/specs/2026-08-12-upstream-alignment-and-client-compat-design.md +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/tune.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/slot_clock.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/status.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/scripts/gen_client_manifest.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/setup.cfg +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/__init__.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/conftest.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_client_contract.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_delivered_rtp_start.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_destination_required.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_gen_client_manifest.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_idempotency_integration.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_multicast_helpers.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_multistream_gap_storm.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_multistream_prune.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_multistream_restore.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_parse_rtp_samples_iq.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_set_demod_type.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_set_lock_warns.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_set_spectrum.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_slot_clock.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_sync_types.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.22.0 → ka9q_python-3.26.0}/tests/test_tune_method.py +0 -0
|
@@ -1,5 +1,133 @@
|
|
|
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
|
+
|
|
3
131
|
## 3.22.0 (2026-08-13)
|
|
4
132
|
|
|
5
133
|
Audit remediation follow-up (findings F1, F3-F8, F10-F14 from the
|
|
@@ -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.
|