ka9q-python 3.19.0__tar.gz → 3.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/CHANGELOG.md +100 -0
  2. {ka9q_python-3.19.0/ka9q_python.egg-info → ka9q_python-3.22.0}/PKG-INFO +50 -6
  3. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/README.md +49 -5
  4. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/GETTING_STARTED.md +5 -5
  5. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/RECIPES.md +4 -4
  6. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/REQUIREMENTS.md +6 -0
  7. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/README.md +52 -0
  8. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/clients.md +631 -0
  9. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/contract.md +434 -0
  10. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/control-surface.md +334 -0
  11. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/findings.md +628 -0
  12. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/idempotency.md +974 -0
  13. ka9q_python-3.22.0/docs/audit/2026-08-12-alignment/upstream.md +328 -0
  14. ka9q_python-3.22.0/docs/superpowers/plans/2026-08-12-upstream-alignment-and-client-compat.md +937 -0
  15. ka9q_python-3.22.0/docs/superpowers/plans/2026-08-13-audit-remediation.md +1590 -0
  16. ka9q_python-3.22.0/docs/superpowers/specs/2026-08-12-upstream-alignment-and-client-compat-design.md +155 -0
  17. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/channel_cleanup_example.py +6 -6
  18. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/codar_oceanography.py +1 -1
  19. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/hf_band_scanner.py +1 -1
  20. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/simple_am_radio.py +1 -1
  21. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/superdarn_recorder.py +1 -1
  22. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/test_improvements.py +7 -7
  23. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/__init__.py +5 -2
  24. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/compat.py +1 -1
  25. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/control.py +367 -88
  26. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/multi_stream.py +138 -2
  27. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/resequencer.py +11 -0
  28. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/slot_clock.py +54 -2
  29. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/spectrum_stream.py +31 -23
  30. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/status.py +8 -1
  31. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/status_listener.py +56 -0
  32. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/stream.py +26 -0
  33. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/stream_quality.py +19 -21
  34. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/types.py +5 -3
  35. {ka9q_python-3.19.0 → ka9q_python-3.22.0/ka9q_python.egg-info}/PKG-INFO +50 -6
  36. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q_python.egg-info/SOURCES.txt +22 -0
  37. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q_radio_compat +1 -1
  38. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/pyproject.toml +1 -1
  39. ka9q_python-3.22.0/scripts/gen_client_manifest.py +140 -0
  40. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/scripts/sync_types.py +1 -1
  41. ka9q_python-3.22.0/tests/test_client_contract.py +56 -0
  42. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_client_id_destination.py +11 -6
  43. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_create_split_encoding.py +1 -1
  44. ka9q_python-3.22.0/tests/test_delivered_rtp_start.py +120 -0
  45. ka9q_python-3.22.0/tests/test_destination_required.py +133 -0
  46. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_ensure_channel_encoding.py +4 -2
  47. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_filter_edges.py +6 -3
  48. ka9q_python-3.22.0/tests/test_gen_client_manifest.py +75 -0
  49. ka9q_python-3.22.0/tests/test_idempotency_integration.py +184 -0
  50. ka9q_python-3.22.0/tests/test_keepalive_encoding.py +169 -0
  51. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_lifetime.py +2 -0
  52. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_multicast_helpers.py +9 -0
  53. ka9q_python-3.22.0/tests/test_multistream_restore.py +90 -0
  54. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_remove_channel.py +3 -2
  55. ka9q_python-3.22.0/tests/test_set_demod_type.py +46 -0
  56. ka9q_python-3.22.0/tests/test_set_lock_warns.py +33 -0
  57. ka9q_python-3.22.0/tests/test_set_spectrum.py +128 -0
  58. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_slot_clock.py +45 -1
  59. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_status_decoder.py +18 -0
  60. ka9q_python-3.22.0/tests/test_sync_types.py +40 -0
  61. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/LICENSE +0 -0
  62. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/MANIFEST.in +0 -0
  63. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/API_REFERENCE.md +0 -0
  64. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/ARCHITECTURE.md +0 -0
  65. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/CLI_GUIDE.md +0 -0
  66. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/INSTALLATION.md +0 -0
  67. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/MULTI_STREAM.md +0 -0
  68. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  69. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/SECURITY.md +0 -0
  70. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/TESTING_GUIDE.md +0 -0
  71. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/docs/TUI_GUIDE.md +0 -0
  72. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/advanced_features_demo.py +0 -0
  73. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/diagnostics/diagnose_packets.py +0 -0
  74. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  75. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/discover_example.py +0 -0
  76. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/grape_integration_example.py +0 -0
  77. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/multi_stream_smoke.py +0 -0
  78. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/rtp_recorder_example.py +0 -0
  79. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/spectrum_example.py +0 -0
  80. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/stream_example.py +0 -0
  81. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/test_channel_operations.py +0 -0
  82. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/test_timing_fields.py +0 -0
  83. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/tune.py +0 -0
  84. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/examples/tune_example.py +0 -0
  85. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/_multicast.py +0 -0
  86. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/addressing.py +0 -0
  87. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/cli.py +0 -0
  88. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/discovery.py +0 -0
  89. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/exceptions.py +0 -0
  90. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/managed_stream.py +0 -0
  91. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/monitor.py +0 -0
  92. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/pps_calibrator.py +0 -0
  93. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/rtp_recorder.py +0 -0
  94. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/tui.py +0 -0
  95. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q/utils.py +0 -0
  96. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  97. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q_python.egg-info/entry_points.txt +0 -0
  98. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q_python.egg-info/requires.txt +0 -0
  99. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/ka9q_python.egg-info/top_level.txt +0 -0
  100. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/scripts/check_upstream_drift.py +0 -0
  101. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/setup.cfg +0 -0
  102. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/__init__.py +0 -0
  103. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/conftest.py +0 -0
  104. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_addressing.py +0 -0
  105. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_channel_verification.py +0 -0
  106. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_decode_description.py +0 -0
  107. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_decode_functions.py +0 -0
  108. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_encode_functions.py +0 -0
  109. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_encode_socket.py +0 -0
  110. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_integration.py +0 -0
  111. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_iq_20khz_f32.py +0 -0
  112. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_listen_multicast.py +0 -0
  113. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_managed_stream_recovery.py +0 -0
  114. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_monitor.py +0 -0
  115. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_multihomed.py +0 -0
  116. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_multistream_gap_storm.py +0 -0
  117. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_multistream_prune.py +0 -0
  118. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_native_discovery.py +0 -0
  119. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_parse_rtp_samples_iq.py +0 -0
  120. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_performance_fixes.py +0 -0
  121. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_protocol_compat.py +0 -0
  122. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_rtp_recorder.py +0 -0
  123. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_security_features.py +0 -0
  124. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_spectrum.py +0 -0
  125. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_ssrc_dest_unit.py +0 -0
  126. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_ssrc_encoding_unit.py +0 -0
  127. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  128. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_status_listener.py +0 -0
  129. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_ttl_warning.py +0 -0
  130. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_tune.py +0 -0
  131. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_tune_cli.py +0 -0
  132. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_tune_debug.py +0 -0
  133. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_tune_live.py +0 -0
  134. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_tune_method.py +0 -0
  135. {ka9q_python-3.19.0 → ka9q_python-3.22.0}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,105 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.22.0 (2026-08-13)
4
+
5
+ Audit remediation follow-up (findings F1, F3-F8, F10-F14 from the
6
+ 2026-08 upstream-alignment audit; see `docs/audit/` and
7
+ `.superpowers/sdd/2026-08-13-audit-remediation/`). Minor bump: adds
8
+ public capability and one deliberate, owner-ruled behavior change.
9
+
10
+ ### Changed (behavior)
11
+
12
+ - **`create_channel()` now derives a per-client multicast destination
13
+ from `client_id` when `destination=` is not given, and raises
14
+ `ValidationError` when *neither* is available** (F5). Previously a
15
+ bare `create_channel()` call with no `client_id` and no
16
+ `destination=` omitted the `OUTPUT_DATA_DEST_SOCKET` TLV entirely,
17
+ so radiod created no channel at all, rather than failing loudly on
18
+ the client side. Any caller that relied on that silent no-op must
19
+ now pass `client_id=` (recommended) or an explicit `destination=`.
20
+ One deliberate side effect: for direct `create_channel()` calls that
21
+ set `client_id`, omit `destination=`, and let the SSRC be
22
+ auto-allocated, the newly derived destination now participates in
23
+ the SSRC hash — SSRCs for that specific path differ from what they
24
+ were pre-3.22.
25
+
26
+ ### Added
27
+
28
+ - **`set_spectrum()`** gains `window_type`, `avg`, `overlap`, `base`,
29
+ `step` parameters (plus `demod_type`/`frequency_hz`); `SpectrumStream`
30
+ delegates to it (F11).
31
+ - **`ChannelStatus.options`** decodes the `SETOPTS` TLV (F12).
32
+ - **`DemodType.INVALID_DEMOD = -1`** now parses correctly from
33
+ upstream headers (negative enum literals were previously dropped by
34
+ `sync_types.py`) (F10); `set_demod_type()`'s bound now derives from
35
+ `DemodType.N_DEMOD` so `IDLE_DEMOD` is no longer wrongly rejected
36
+ (F13).
37
+ - **`resolve_multicast_address` re-exported from `ka9q`** (F14).
38
+
39
+ ### Fixed
40
+
41
+ - **`MultiStream` re-sends `agc`/`gain`/filter-edges/requested-encoding**
42
+ on channel re-creation after a radiod restart — these fields were
43
+ never stored on the `MultiStream` side, so re-creation could not
44
+ re-assert them; this was an origin bug, not a regression (F1, P0).
45
+ - **`tune()` records `_requested_encoding`** so keepalives re-assert
46
+ the caller's chosen encoding instead of drifting back to the
47
+ channel's current one (F4).
48
+ - **`set_lock()` warns** that radiod silently discards `LOCK` rather
49
+ than failing (F6).
50
+
51
+ ### Docs
52
+
53
+ - Corrected re-create/lifetime semantics (delta-update only; polls
54
+ don't extend lifetime; units are frames, not seconds) (F3, F7).
55
+ - Corrected a stale pre-3.14 fallback claim in the `client_id`
56
+ docstring (F5 follow-up).
57
+ - README Troubleshooting: a `lo`-route silently swallows control
58
+ commands; use `interface=` (F8).
59
+
60
+ ## 3.21.1 (2026-08-11)
61
+
62
+ - **Fix: `StreamQuality.copy()` dropped `delivered_rtp_start`** — the
63
+ hand-maintained field list predated the new field, so every
64
+ RadiodStream callback received `None` (MultiStream, which passes its
65
+ quality object directly, was unaffected). `copy()` now uses
66
+ `dataclasses.replace`, carrying every present and future field; a
67
+ fields-iteration regression test guards the class.
68
+
69
+ ## 3.21.0 (2026-08-11)
70
+
71
+ - **`StreamQuality.delivered_rtp_start`** — the true RTP timestamp of the
72
+ FIRST sample of each delivered batch, from the resequencer's emission
73
+ tracking (`PacketResequencer.last_chunk_rtp_start`; zero fills
74
+ included). Both `RadiodStream` and `MultiStream` populate it.
75
+ Consumers that label samples MUST prefer this over
76
+ `last_rtp_timestamp`: the latter is the last RECEIVED packet's header
77
+ (pre-resequencer) and desynchronizes from delivered samples under
78
+ loss — steady-state ±1-packet label wobble, and block-scale label
79
+ slips during stall-and-catchup (root cause of hf-timestd's T6 origin
80
+ instability; see hf-timestd docs/T6-BLOCK-SLIP-ROOT-CAUSE-2026-08-10).
81
+ `None` when nothing has been delivered yet.
82
+
83
+ ## [3.20.0] - 2026-06-29
84
+
85
+ ### Added — `SlotClock` ambiguous-unwrap guard (fail loud, never silent)
86
+
87
+ - **`SlotClock.offset_of_rtp` now raises `SlotClockDesyncError`** when a wrapped
88
+ RTP timestamp is more than `2**30` samples (half the signed-32 window, ~24.8 h
89
+ @ 12 kHz / ~4.7 h @ 64 kHz IQ) from the unwrap high-water in either direction.
90
+ The high-water unwrap (3.18.0) keeps continuous streams correct past the
91
+ `2**31` window, but it only disambiguates while each step stays within
92
+ `±2**31` of the high-water. A *single* forward jump `>= 2**31` samples (a
93
+ `>49.7 h` stall-then-resume, a missed re-anchor, or a discontinuous timestamp)
94
+ aliased into a backward step — `advance()` then harvested **0 slots forever on
95
+ live RF**, the exact silent failure `SlotClock` exists to prevent.
96
+ - **`SlotClock.advance` catches the desync, logs an ERROR, drops the anchor, and
97
+ returns `[]`**, so the caller's normal `if not clk.anchored: clk.anchor(...)`
98
+ path re-establishes the grid on a fresh RTP reference instead of stalling. The
99
+ `2**30` margin never false-fires: continuous advancing steps a few hundred
100
+ samples per call.
101
+ - **`SlotClockDesyncError` is exported** from the package.
102
+
3
103
  ## [3.19.0] - 2026-06-28
4
104
 
5
105
  ### Removed / Changed — simplify how we look at radiod (defer to RTP)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.19.0
3
+ Version: 3.22.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(
@@ -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
@@ -0,0 +1,52 @@
1
+ # Upstream Alignment & Client-Compat Audit — 2026-08-12
2
+
3
+ Audit of ka9q-python's alignment with `ka9q-radio` upstream
4
+ (`14d780af624e821941708bd0d64fd895a0c80a2a` →
5
+ `cedec349f7b4212078de3e007d142b4c64d36546`, 149 commits) and its
6
+ sigmond-suite client contracts. Read `findings.md` first — it is the
7
+ document Checkpoint A reviews.
8
+
9
+ ## Reports
10
+
11
+ | File | Task | Contents |
12
+ |---|---|---|
13
+ | [`upstream.md`](./upstream.md) | 2 | Drift-watcher verdict, full 149-commit classification |
14
+ | [`contract.md`](./contract.md) | 3 | Enum-level header diff, `sync_types.py` reconciliation, pin-advance verdict |
15
+ | [`control-surface.md`](./control-surface.md) | 4 | Every writable/readable radiod TLV mapped against `RadiodControl`/`status.py` |
16
+ | [`clients.md`](./clients.md) | 5 | Per-client symbol contract matrix + no-bypass sweep across all `/opt/git/sigmond` repos |
17
+ | [`idempotency.md`](./idempotency.md) | 6–7 | Five idempotency spec questions, code-level verdicts, and live probes against `bee1`/`bee2` radiod |
18
+ | [`findings.md`](./findings.md) | 8 | **This audit's output** — every finding above, ranked P0–P3 with evidence links and remediation |
19
+
20
+ ## Executive summary
21
+
22
+ **Drift verdict:** `CONTRACT: CRITICAL-CHANGE — DO NOT ADVANCE` (Task 3).
23
+ The only client-visible enum change in range is `DemodType.N_DEMOD`
24
+ shifting 5→6 (upstream inserted `IDLE_DEMOD`); `check_upstream_drift.py`'s
25
+ `STREAM_CRITICAL_ENUMS` policy classifies this `fail` regardless of usage.
26
+ Zero live code anywhere references `N_DEMOD` by value, so nothing decodes
27
+ wrong today — the block is a mechanical policy trigger, not a demonstrated
28
+ break (see `findings.md` F2).
29
+
30
+ **Is the pin safe to advance?** Not yet, per the mechanical gate — but the
31
+ remediation is small and well-scoped: regenerate `types.py`
32
+ (`sync_types.py --apply`, plus a manual add for the negative-literal
33
+ `INVALID_DEMOD` the parser misses — F10), widen `control.py`'s hardcoded
34
+ demod-range check (F13), then re-run the watcher. None of this is an
35
+ emergency fix for a live bug; it's the normal Task 4/9 capability-exposure
36
+ step this project's process expects before a pin with a flagged enum
37
+ change advances.
38
+
39
+ **Findings:** 23 total — **1 P0, 9 P1, 4 P2, 9 P3**. The P0 (`F1`) is
40
+ `MultiStream`'s restore path silently changing a channel's SSRC identity
41
+ on recovery when a channel has non-default gain/AGC — the shared
42
+ recovery substrate every major sigmond client depends on. Several P1s are
43
+ environment/deployment findings from the live Task 7 probes (silent
44
+ `destination=` failure, a host-routing trap that swallows outbound control
45
+ commands with zero error, and a keepalive docstring claim that doesn't
46
+ hold empirically) — each is explicitly flagged with its own ambiguity
47
+ note rather than forced into a clean bucket, per the audit's ranking
48
+ rubric. Zero confirmed no-bypass violations exist among sigmond-authored
49
+ Python clients (`clients.md`); the one bypass found (`ka9q-web`, vendored
50
+ third-party C) is a policy-scope question, not a ka9q-python code fix.
51
+
52
+ **Artifact:** https://claude.ai/code/artifact/13cb509f-9248-49ed-bb05-0be0354d4361