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.
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/CHANGELOG.md +208 -0
- {ka9q_python-3.20.0/ka9q_python.egg-info → ka9q_python-3.26.0}/PKG-INFO +50 -6
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/README.md +49 -5
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/GETTING_STARTED.md +5 -5
- ka9q_python-3.26.0/docs/POLL-SUPPRESSION-DESIGN.md +136 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/RECIPES.md +4 -4
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/REQUIREMENTS.md +6 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/README.md +52 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/clients.md +631 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/contract.md +434 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/control-surface.md +334 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/findings.md +628 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/idempotency.md +974 -0
- ka9q_python-3.26.0/docs/audit/2026-08-12-alignment/upstream.md +328 -0
- ka9q_python-3.26.0/docs/superpowers/plans/2026-08-12-upstream-alignment-and-client-compat.md +937 -0
- ka9q_python-3.26.0/docs/superpowers/plans/2026-08-13-audit-remediation.md +1590 -0
- ka9q_python-3.26.0/docs/superpowers/plans/2026-08-13-client-side-fixes.md +1162 -0
- ka9q_python-3.26.0/docs/superpowers/specs/2026-08-12-upstream-alignment-and-client-compat-design.md +155 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/channel_cleanup_example.py +6 -6
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/codar_oceanography.py +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/hf_band_scanner.py +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/simple_am_radio.py +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/superdarn_recorder.py +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/test_improvements.py +7 -7
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/__init__.py +7 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/compat.py +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/control.py +627 -167
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/discovery.py +28 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/managed_stream.py +7 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/multi_stream.py +169 -6
- ka9q_python-3.26.0/ka9q/recovery_ladder.py +139 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/resequencer.py +48 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/rtp_recorder.py +20 -4
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/spectrum_stream.py +31 -23
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/status.py +8 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/status_listener.py +65 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/stream.py +150 -24
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/stream_quality.py +32 -21
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/types.py +8 -5
- {ka9q_python-3.20.0 → ka9q_python-3.26.0/ka9q_python.egg-info}/PKG-INFO +50 -6
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/SOURCES.txt +34 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_radio_compat +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/pyproject.toml +1 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/scripts/check_upstream_drift.py +221 -22
- ka9q_python-3.26.0/scripts/gen_client_manifest.py +140 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/scripts/sync_types.py +129 -7
- ka9q_python-3.26.0/tests/test_client_contract.py +56 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_client_id_destination.py +11 -6
- ka9q_python-3.26.0/tests/test_compat_pin_reachability.py +94 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_create_split_encoding.py +1 -1
- ka9q_python-3.26.0/tests/test_delivered_rtp_start.py +120 -0
- ka9q_python-3.26.0/tests/test_destination_required.py +133 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ensure_channel_encoding.py +4 -2
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_filter_edges.py +6 -3
- ka9q_python-3.26.0/tests/test_gen_client_manifest.py +75 -0
- ka9q_python-3.26.0/tests/test_idempotency_integration.py +184 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_iq_20khz_f32.py +23 -1
- ka9q_python-3.26.0/tests/test_keepalive_encoding.py +208 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_lifetime.py +9 -1
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multicast_helpers.py +9 -0
- ka9q_python-3.26.0/tests/test_multistream_completeness.py +85 -0
- ka9q_python-3.26.0/tests/test_multistream_restore.py +90 -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.20.0 → ka9q_python-3.26.0}/tests/test_remove_channel.py +3 -2
- ka9q_python-3.26.0/tests/test_rtp_to_wallclock_deprecated.py +83 -0
- ka9q_python-3.26.0/tests/test_set_demod_type.py +46 -0
- ka9q_python-3.26.0/tests/test_set_lock_warns.py +33 -0
- ka9q_python-3.26.0/tests/test_set_spectrum.py +128 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_status_decoder.py +64 -0
- {ka9q_python-3.20.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_sync_types.py +40 -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.20.0/tests/test_protocol_compat.py +0 -82
- ka9q_python-3.20.0/tests/test_upstream_drift.py +0 -176
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/LICENSE +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/MANIFEST.in +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/tune.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/slot_clock.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/setup.cfg +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/__init__.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/conftest.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multistream_gap_storm.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_multistream_prune.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_parse_rtp_samples_iq.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_slot_clock.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.20.0 → ka9q_python-3.26.0}/tests/test_tune_live.py +0 -0
- {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.
|
|
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
|