ka9q-python 3.26.1__tar.gz → 3.26.2__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 (146) hide show
  1. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/CHANGELOG.md +22 -0
  2. {ka9q_python-3.26.1/ka9q_python.egg-info → ka9q_python-3.26.2}/PKG-INFO +1 -1
  3. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/control.py +39 -4
  4. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/stream.py +6 -5
  5. {ka9q_python-3.26.1 → ka9q_python-3.26.2/ka9q_python.egg-info}/PKG-INFO +1 -1
  6. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/pyproject.toml +1 -1
  7. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_ensure_channel_encoding.py +63 -0
  8. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/LICENSE +0 -0
  9. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/MANIFEST.in +0 -0
  10. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/README.md +0 -0
  11. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/API_REFERENCE.md +0 -0
  12. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/ARCHITECTURE.md +0 -0
  13. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/CLI_GUIDE.md +0 -0
  14. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/GETTING_STARTED.md +0 -0
  15. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/INSTALLATION.md +0 -0
  16. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/MULTI_STREAM.md +0 -0
  17. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/POLL-SUPPRESSION-DESIGN.md +0 -0
  18. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/RECIPES.md +0 -0
  19. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/REQUIREMENTS.md +0 -0
  20. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/RTP_TIMING_SUPPORT.md +0 -0
  21. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/SECURITY.md +0 -0
  22. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/TESTING_GUIDE.md +0 -0
  23. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/TUI_GUIDE.md +0 -0
  24. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/README.md +0 -0
  25. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/clients.md +0 -0
  26. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/contract.md +0 -0
  27. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/control-surface.md +0 -0
  28. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/findings.md +0 -0
  29. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/idempotency.md +0 -0
  30. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/audit/2026-08-12-alignment/upstream.md +0 -0
  31. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/superpowers/plans/2026-08-12-upstream-alignment-and-client-compat.md +0 -0
  32. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/superpowers/plans/2026-08-13-audit-remediation.md +0 -0
  33. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/superpowers/plans/2026-08-13-client-side-fixes.md +0 -0
  34. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/docs/superpowers/specs/2026-08-12-upstream-alignment-and-client-compat-design.md +0 -0
  35. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/advanced_features_demo.py +0 -0
  36. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/channel_cleanup_example.py +0 -0
  37. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/codar_oceanography.py +0 -0
  38. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/diagnostics/diagnose_packets.py +0 -0
  39. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/diagnostics/repro_utc_bug.py +0 -0
  40. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/discover_example.py +0 -0
  41. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/grape_integration_example.py +0 -0
  42. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/hf_band_scanner.py +0 -0
  43. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/multi_stream_smoke.py +0 -0
  44. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/rtp_recorder_example.py +0 -0
  45. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/simple_am_radio.py +0 -0
  46. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/spectrum_example.py +0 -0
  47. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/stream_example.py +0 -0
  48. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/superdarn_recorder.py +0 -0
  49. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/test_channel_operations.py +0 -0
  50. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/test_improvements.py +0 -0
  51. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/test_timing_fields.py +0 -0
  52. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/tune.py +0 -0
  53. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/examples/tune_example.py +0 -0
  54. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/__init__.py +0 -0
  55. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/_multicast.py +0 -0
  56. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/addressing.py +0 -0
  57. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/cli.py +0 -0
  58. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/compat.py +0 -0
  59. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/discovery.py +0 -0
  60. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/exceptions.py +0 -0
  61. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/managed_stream.py +0 -0
  62. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/monitor.py +0 -0
  63. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/multi_stream.py +0 -0
  64. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/pps_calibrator.py +0 -0
  65. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/recovery_ladder.py +0 -0
  66. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/resequencer.py +0 -0
  67. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/rtp_recorder.py +0 -0
  68. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/slot_clock.py +0 -0
  69. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/spectrum_stream.py +0 -0
  70. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/status.py +0 -0
  71. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/status_listener.py +0 -0
  72. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/stream_quality.py +0 -0
  73. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/tui.py +0 -0
  74. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/types.py +0 -0
  75. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q/utils.py +0 -0
  76. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q_python.egg-info/SOURCES.txt +0 -0
  77. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q_python.egg-info/dependency_links.txt +0 -0
  78. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q_python.egg-info/entry_points.txt +0 -0
  79. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q_python.egg-info/requires.txt +0 -0
  80. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q_python.egg-info/top_level.txt +0 -0
  81. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/ka9q_radio_compat +0 -0
  82. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/scripts/check_upstream_drift.py +0 -0
  83. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/scripts/gen_client_manifest.py +0 -0
  84. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/scripts/sync_types.py +0 -0
  85. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/setup.cfg +0 -0
  86. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/__init__.py +0 -0
  87. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/conftest.py +0 -0
  88. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_addressing.py +0 -0
  89. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_channel_verification.py +0 -0
  90. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_client_contract.py +0 -0
  91. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_client_id_destination.py +0 -0
  92. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_compat_pin_reachability.py +0 -0
  93. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_create_split_encoding.py +0 -0
  94. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_decode_description.py +0 -0
  95. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_decode_functions.py +0 -0
  96. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_delivered_rtp_start.py +0 -0
  97. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_destination_required.py +0 -0
  98. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_encode_functions.py +0 -0
  99. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_encode_socket.py +0 -0
  100. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_filter_edges.py +0 -0
  101. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_gen_client_manifest.py +0 -0
  102. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_idempotency_integration.py +0 -0
  103. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_integration.py +0 -0
  104. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_iq_20khz_f32.py +0 -0
  105. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_keepalive_encoding.py +0 -0
  106. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_lifetime.py +0 -0
  107. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_listen_multicast.py +0 -0
  108. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_managed_stream_recovery.py +0 -0
  109. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_monitor.py +0 -0
  110. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_multicast_helpers.py +0 -0
  111. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_multihomed.py +0 -0
  112. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_multistream_completeness.py +0 -0
  113. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_multistream_gap_storm.py +0 -0
  114. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_multistream_prune.py +0 -0
  115. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_multistream_restore.py +0 -0
  116. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_native_discovery.py +0 -0
  117. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_parameter_vector.py +0 -0
  118. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_parse_rtp_samples_iq.py +0 -0
  119. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_performance_fixes.py +0 -0
  120. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_poll_channel_idle.py +0 -0
  121. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_protocol_compat.py +0 -0
  122. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_recovery_ladder.py +0 -0
  123. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_remove_channel.py +0 -0
  124. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_rtp_recorder.py +0 -0
  125. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_rtp_to_wallclock_deprecated.py +0 -0
  126. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_security_features.py +0 -0
  127. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_set_demod_type.py +0 -0
  128. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_set_lock_warns.py +0 -0
  129. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_set_spectrum.py +0 -0
  130. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_slot_clock.py +0 -0
  131. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_spectrum.py +0 -0
  132. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_ssrc_dest_unit.py +0 -0
  133. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_ssrc_encoding_unit.py +0 -0
  134. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_ssrc_radiod_host_unit.py +0 -0
  135. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_status_decoder.py +0 -0
  136. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_status_listener.py +0 -0
  137. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_status_socket_drain.py +0 -0
  138. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_sync_types.py +0 -0
  139. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_ttl_warning.py +0 -0
  140. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_tune.py +0 -0
  141. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_tune_cli.py +0 -0
  142. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_tune_debug.py +0 -0
  143. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_tune_live.py +0 -0
  144. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_tune_method.py +0 -0
  145. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_upstream_drift.py +0 -0
  146. {ka9q_python-3.26.1 → ka9q_python-3.26.2}/tests/test_zero_block_detection.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.26.2 (2026-11-01)
4
+
5
+ ### Fixed
6
+
7
+ - **`ensure_channel()` now waits for radiod to report the requested
8
+ encoding.** `create_channel()` sends `OUTPUT_ENCODING` as a separate
9
+ command after the create, and `ensure_channel()` returned the first status
10
+ it saw, which could predate that command. A fresh F32 channel then came
11
+ back labelled S16, and `RadiodStream` decoded 4-byte floats as 2-byte
12
+ ints: noise-like samples on the 1/32768 grid, rms about 0.58 of full
13
+ scale, with "completeness 100%". codar-sounder ran this way on AC0G-ND and
14
+ UACNJ and heard no CODAR all day; with the encoding settled, the same
15
+ channel showed the Rutgers 13 MHz radars at 32 dB. `ensure_channel()` now
16
+ re-polls until the reported encoding matches the request, within its
17
+ `timeout`. If radiod still reports another encoding at the deadline, it
18
+ logs an error and returns radiod's value, so a stream decodes what is on
19
+ the wire. Checked live on UACNJ's radiod: a new F32 channel first reported
20
+ encoding 2 and settled to 4.
21
+ - The comments that blamed radiod for "silently downgrading F32 to S16"
22
+ (`control.py`, `stream.py`) now name the stale status instead. The bee1
23
+ 2026-05-15 T6/TSL3 incident they cited was very likely the same race.
24
+
3
25
  ## 3.26.1 (2026-10-01)
4
26
 
5
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.26.1
3
+ Version: 3.26.2
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
@@ -910,7 +910,11 @@ class RadiodControl:
910
910
  Sends TLV-encoded commands to radiod's control socket to create
911
911
  and configure channels.
912
912
  """
913
-
913
+
914
+ # ensure_channel(): pause between status polls while waiting for radiod
915
+ # to report the requested OUTPUT_ENCODING.
916
+ _ENCODING_SETTLE_POLL_S = 0.1
917
+
914
918
  def __init__(self, status_address: str, max_commands_per_sec: int = 100,
915
919
  interface: Optional[str] = None,
916
920
  client_id: Optional[str] = None):
@@ -2072,9 +2076,40 @@ class RadiodControl:
2072
2076
  f"Channel SSRC {ssrc} not verified within {timeout}s. "
2073
2077
  f"Requested: {frequency_hz/1e6:.3f} MHz, {preset}, {sample_rate} Hz"
2074
2078
  )
2075
- # radiod may grant a different encoding (e.g. F32->S16 for some IQ
2076
- # configs); the returned ChannelInfo carries the granted value, which
2077
- # consumers use authoritatively. A rate/preset divergence is logged
2079
+ # create_channel() sends OUTPUT_ENCODING as a separate command after
2080
+ # the create, so the first status can predate it and report the
2081
+ # preset's encoding. Streams pick their decoder from
2082
+ # ChannelInfo.encoding; a stale S16 on an F32 stream decodes 4-byte
2083
+ # floats as 2-byte ints — noise-like garbage at "100% completeness".
2084
+ # Keep polling until radiod reports the requested encoding. If it
2085
+ # still differs at the deadline, return what radiod reports (the
2086
+ # stream must decode what is on the wire) and say so loudly.
2087
+ if encoding != 0 and channel.encoding != encoding:
2088
+ deadline = time.monotonic() + timeout
2089
+ stale = channel.encoding
2090
+ while time.monotonic() < deadline:
2091
+ time.sleep(self._ENCODING_SETTLE_POLL_S)
2092
+ fresh = self.poll_channel(
2093
+ ssrc, expected_freq=frequency_hz,
2094
+ timeout=max(0.1, min(1.0, deadline - time.monotonic())),
2095
+ frequency_tolerance=frequency_tolerance,
2096
+ )
2097
+ if fresh is not None:
2098
+ channel = fresh
2099
+ if channel.encoding == encoding:
2100
+ break
2101
+ if channel.encoding == encoding:
2102
+ logger.info(
2103
+ "ensure_channel: SSRC %s encoding settled %s -> %s",
2104
+ ssrc, stale, channel.encoding,
2105
+ )
2106
+ else:
2107
+ logger.error(
2108
+ "ensure_channel: SSRC %s still reports encoding %s after "
2109
+ "%.1fs (requested %s); returning radiod's value",
2110
+ ssrc, channel.encoding, timeout, encoding,
2111
+ )
2112
+ # A rate/preset divergence is logged
2078
2113
  # but not fatal — the channel exists at the requested frequency.
2079
2114
  # An ABSENT preset is not a divergence. radiod may decline to echo
2080
2115
  # PRESET at all (write-only from the nopreset branch on), and a label
@@ -129,11 +129,12 @@ def parse_rtp_samples(
129
129
  if floats is None:
130
130
  return None
131
131
  if is_iq:
132
- # radiod silently downgrades F32 → S16 for some IQ-channel
133
- # configurations (high sample rate + wide filter); confirmed on
134
- # bee1 2026-05-15 for T6/TSL3 BPSK PPS channel (encoding=4
135
- # requested, encoding=2 granted, decoded as F32LE → NaN-poisoned
136
- # input, TSL3 dark). Honor the granted encoding above.
132
+ # Decode with the encoding the channel reports. A wrong value
133
+ # here corrupts silently: the bee1 2026-05-15 T6/TSL3 "F32→S16
134
+ # downgrade" and codar-sounder's 2026-11-01 garbage IQ were both
135
+ # a stale encoding from ensure_channel (status read before the
136
+ # separate OUTPUT_ENCODING command landed), not radiod changing
137
+ # the encoding. ensure_channel now waits for it to settle.
137
138
  if len(floats) % 2 != 0:
138
139
  logger.warning(f"Odd number of samples in IQ payload: {len(floats)}")
139
140
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.26.1
3
+ Version: 3.26.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.26.1"
7
+ version = "3.26.2"
8
8
  description = "Python interface for ka9q-radio control and monitoring"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -75,5 +75,68 @@ class TestEnsureChannelEncoding(unittest.TestCase):
75
75
  _, kwargs = self.control.create_channel.call_args
76
76
  self.assertEqual(kwargs['encoding'], Encoding.F32)
77
77
 
78
+
79
+ def _info(ssrc, encoding):
80
+ return ChannelInfo(ssrc=ssrc, preset="iq", sample_rate=16000,
81
+ frequency=14074000.0, snr=0.0,
82
+ multicast_address="239.1.1.1", port=5004,
83
+ encoding=encoding)
84
+
85
+
86
+ class TestEnsureChannelEncodingSettles(unittest.TestCase):
87
+ """The first status after create can predate OUTPUT_ENCODING.
88
+
89
+ Found 2026-11-01: codar-sounder on AC0G-ND and UACNJ got encoding S16
90
+ back for an F32 channel and decoded 4-byte floats as 2-byte ints for a
91
+ whole day. ensure_channel must wait for the requested encoding.
92
+ """
93
+
94
+ SSRC = 12345
95
+
96
+ @patch('ka9q.control.RadiodControl._connect')
97
+ def setUp(self, mock_connect):
98
+ self.control = RadiodControl("radiod.local")
99
+ self.control.create_channel = MagicMock()
100
+ self.control._ENCODING_SETTLE_POLL_S = 0.0
101
+
102
+ def _ensure(self, encoding=Encoding.F32, timeout=0.5):
103
+ with patch('ka9q.control.allocate_ssrc', return_value=self.SSRC):
104
+ return self.control.ensure_channel(
105
+ frequency_hz=14.074e6, destination="239.1.1.1",
106
+ encoding=encoding, timeout=timeout)
107
+
108
+ def test_stale_first_status_is_replaced_by_the_settled_one(self):
109
+ # probe (no channel yet), first verify (stale S16), then F32
110
+ self.control.poll_channel = MagicMock(side_effect=[
111
+ None, _info(self.SSRC, Encoding.S16BE),
112
+ _info(self.SSRC, Encoding.S16BE), _info(self.SSRC, Encoding.F32)])
113
+ result = self._ensure()
114
+ self.assertEqual(result.encoding, Encoding.F32)
115
+ self.assertEqual(self.control.poll_channel.call_count, 4)
116
+
117
+ def test_encoding_that_never_settles_returns_radiods_value_and_logs(self):
118
+ def poll(*a, **kw):
119
+ return None if poll.n == 0 else _info(self.SSRC, Encoding.S16BE)
120
+ poll.n = 0
121
+ def counting(*a, **kw):
122
+ r = poll(*a, **kw); poll.n += 1; return r
123
+ self.control.poll_channel = MagicMock(side_effect=counting)
124
+ with self.assertLogs('ka9q.control', level='ERROR') as logs:
125
+ result = self._ensure(timeout=0.2)
126
+ self.assertEqual(result.encoding, Encoding.S16BE)
127
+ self.assertTrue(any("still reports encoding" in m for m in logs.output))
128
+
129
+ def test_matching_first_status_polls_no_more(self):
130
+ self.control.poll_channel = MagicMock(side_effect=[
131
+ None, _info(self.SSRC, Encoding.F32)])
132
+ self.assertEqual(self._ensure().encoding, Encoding.F32)
133
+ self.assertEqual(self.control.poll_channel.call_count, 2)
134
+
135
+ def test_no_encoding_requested_takes_the_first_status(self):
136
+ self.control.poll_channel = MagicMock(side_effect=[
137
+ None, _info(self.SSRC, Encoding.S16BE)])
138
+ self.assertEqual(self._ensure(encoding=0).encoding, Encoding.S16BE)
139
+ self.assertEqual(self.control.poll_channel.call_count, 2)
140
+
78
141
  if __name__ == '__main__':
79
142
  unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes