ka9q-python 3.8.0__tar.gz → 3.9.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 (162) hide show
  1. {ka9q_python-3.8.0/ka9q_python.egg-info → ka9q_python-3.9.0}/PKG-INFO +4 -1
  2. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/README.md +1 -0
  3. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/CHANGELOG.md +24 -0
  4. ka9q_python-3.9.0/docs/TUI_GUIDE.md +148 -0
  5. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/__init__.py +21 -1
  6. ka9q_python-3.9.0/ka9q/cli.py +338 -0
  7. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/control.py +163 -0
  8. ka9q_python-3.9.0/ka9q/status.py +648 -0
  9. ka9q_python-3.9.0/ka9q/tui.py +516 -0
  10. {ka9q_python-3.8.0 → ka9q_python-3.9.0/ka9q_python.egg-info}/PKG-INFO +4 -1
  11. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/SOURCES.txt +6 -0
  12. ka9q_python-3.9.0/ka9q_python.egg-info/entry_points.txt +2 -0
  13. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/requires.txt +3 -0
  14. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/pyproject.toml +7 -1
  15. ka9q_python-3.9.0/tests/test_status_decoder.py +161 -0
  16. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/CHANGELOG.md +0 -0
  17. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/LICENSE +0 -0
  18. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/MANIFEST.in +0 -0
  19. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/RELEASE_NOTES_v3.5.0.md +0 -0
  20. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/API_REFERENCE.md +0 -0
  21. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/ARCHITECTURE.md +0 -0
  22. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/CROSS_PLATFORM_SUPPORT.md +0 -0
  23. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/DISTRIBUTION_RECOMMENDATION.md +0 -0
  24. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/GETTING_STARTED.md +0 -0
  25. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/INSTALLATION.md +0 -0
  26. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/MULTI_HOMED_QUICK_REF.md +0 -0
  27. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/NATIVE_DISCOVERY.md +0 -0
  28. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/PYPI_PUBLICATION_GUIDE.md +0 -0
  29. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/QUICK_REFERENCE.md +0 -0
  30. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/RTP_RECORDER_PASS_ALL_PACKETS.md +0 -0
  31. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/RTP_TIMING_IMPLEMENTATION.md +0 -0
  32. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  33. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/SECURITY.md +0 -0
  34. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/SSRC_COLLISION_PREVENTION.md +0 -0
  35. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/TESTING_GUIDE.md +0 -0
  36. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/TESTING_SUMMARY.md +0 -0
  37. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/TEST_RESULTS.md +0 -0
  38. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_ENHANCEMENT_GUIDE.md +0 -0
  39. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_ENHANCEMENT_IMPLEMENTED.md +0 -0
  40. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_ESCAPE_SEQUENCE_FIX.md +0 -0
  41. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_FUNCTIONALITY_REVIEW.md +0 -0
  42. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_IMPLEMENTATION_STATUS.md +0 -0
  43. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_INTERACTIVE_COMPLETE.md +0 -0
  44. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANGES_SUMMARY.md +0 -0
  45. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANNEL_CLEANUP_ADDITION.md +0 -0
  46. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANNEL_CLEANUP_COMPLETE.md +0 -0
  47. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANNEL_TUNING_DIAGNOSTICS.md +0 -0
  48. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CODE_REVIEW_RECOMMENDATIONS.md +0 -0
  49. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CODE_REVIEW_SUMMARY.md +0 -0
  50. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/COMMIT_SUMMARY.md +0 -0
  51. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CRITICAL_FIXES_CHECKLIST.md +0 -0
  52. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/FINAL_SUMMARY.md +0 -0
  53. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/FIXES_SUMMARY.md +0 -0
  54. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/GIT_STATUS_SUMMARY.md +0 -0
  55. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_COMPLETE.md +0 -0
  56. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_STATUS.md +0 -0
  57. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_SUMMARY.md +0 -0
  58. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_SUMMARY_v2.2.0.md +0 -0
  59. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPROVEMENTS_IMPLEMENTED.md +0 -0
  60. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/MULTI_HOMED_ACTION_PLAN.md +0 -0
  61. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/MULTI_HOMED_IMPLEMENTATION_COMPLETE.md +0 -0
  62. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/MULTI_HOMED_SUPPORT_REVIEW.md +0 -0
  63. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PACKAGE_STATUS.md +0 -0
  64. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PACKAGE_VERIFICATION.md +0 -0
  65. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_FIXES_APPLIED.md +0 -0
  66. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_REVIEW.md +0 -0
  67. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_REVIEW_V2.md +0 -0
  68. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_SUMMARY.md +0 -0
  69. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/QUICK_ACTION_ITEMS.md +0 -0
  70. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/QUICK_START_DIAGNOSIS.md +0 -0
  71. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/RELEASE_CHECKLIST_v3.0.0.md +0 -0
  72. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/SUMMARY.md +0 -0
  73. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/SUMMARY_WEB_UI_FIXES.md +0 -0
  74. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/TUNE_IMPLEMENTATION.md +0 -0
  75. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/WEBUI_SUMMARY.md +0 -0
  76. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/WEB_UI_ENHANCEMENTS_COMPLETE.md +0 -0
  77. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/CONTROL_COMPARISON.md +0 -0
  78. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/DESTINATION_AWARE_CHANNELS.md +0 -0
  79. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/NEW_FEATURES.md +0 -0
  80. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/RADIOD_FEATURES_SUMMARY.md +0 -0
  81. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/RTP_DESTINATION_FEATURE.md +0 -0
  82. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_INSTRUCTIONS.md +0 -0
  83. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v2.2.0.md +0 -0
  84. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v2.4.0.md +0 -0
  85. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v2.5.0.md +0 -0
  86. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v3.0.0.md +0 -0
  87. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v3.1.0.md +0 -0
  88. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v3.2.0.md +0 -0
  89. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES.md +0 -0
  90. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.1.0.md +0 -0
  91. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.2.0.md +0 -0
  92. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.3.0.md +0 -0
  93. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.4.0.md +0 -0
  94. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RTP_TIMING_RELEASE_NOTES.md +0 -0
  95. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/advanced_features_demo.py +0 -0
  96. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/channel_cleanup_example.py +0 -0
  97. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/codar_oceanography.py +0 -0
  98. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/diagnostics/diagnose_packets.py +0 -0
  99. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  100. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/discover_example.py +0 -0
  101. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/grape_integration_example.py +0 -0
  102. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/hf_band_scanner.py +0 -0
  103. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/multi_stream_smoke.py +0 -0
  104. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/rtp_recorder_example.py +0 -0
  105. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/simple_am_radio.py +0 -0
  106. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/stream_example.py +0 -0
  107. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/superdarn_recorder.py +0 -0
  108. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/test_channel_operations.py +0 -0
  109. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/test_improvements.py +0 -0
  110. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/test_timing_fields.py +0 -0
  111. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/tune.py +0 -0
  112. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/tune_example.py +0 -0
  113. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/addressing.py +0 -0
  114. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/compat.py +0 -0
  115. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/discovery.py +0 -0
  116. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/exceptions.py +0 -0
  117. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/managed_stream.py +0 -0
  118. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/monitor.py +0 -0
  119. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/multi_stream.py +0 -0
  120. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/pps_calibrator.py +0 -0
  121. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/resequencer.py +0 -0
  122. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/rtp_recorder.py +0 -0
  123. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/stream.py +0 -0
  124. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/stream_quality.py +0 -0
  125. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/types.py +0 -0
  126. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/utils.py +0 -0
  127. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  128. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/top_level.txt +0 -0
  129. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_radio_compat +0 -0
  130. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/scripts/sync_types.py +0 -0
  131. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/setup.cfg +0 -0
  132. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/setup.py +0 -0
  133. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/__init__.py +0 -0
  134. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/conftest.py +0 -0
  135. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_addressing.py +0 -0
  136. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_channel_verification.py +0 -0
  137. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_create_split_encoding.py +0 -0
  138. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_decode_functions.py +0 -0
  139. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_encode_functions.py +0 -0
  140. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_encode_socket.py +0 -0
  141. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ensure_channel_encoding.py +0 -0
  142. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_integration.py +0 -0
  143. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_iq_20khz_f32.py +0 -0
  144. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_listen_multicast.py +0 -0
  145. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_managed_stream_recovery.py +0 -0
  146. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_monitor.py +0 -0
  147. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_multihomed.py +0 -0
  148. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_native_discovery.py +0 -0
  149. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_performance_fixes.py +0 -0
  150. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_protocol_compat.py +0 -0
  151. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_remove_channel.py +0 -0
  152. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_rtp_recorder.py +0 -0
  153. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_security_features.py +0 -0
  154. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ssrc_dest_unit.py +0 -0
  155. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ssrc_encoding_unit.py +0 -0
  156. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  157. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ttl_warning.py +0 -0
  158. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune.py +0 -0
  159. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_cli.py +0 -0
  160. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_debug.py +0 -0
  161. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_live.py +0 -0
  162. {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_method.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.8.0
3
+ Version: 3.9.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Home-page: https://github.com/mijahauan/ka9q-python
6
6
  Author: Michael Hauan AC0G
@@ -28,6 +28,8 @@ Requires-Dist: numpy>=1.24.0
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0.0; extra == "dev"
30
30
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
31
+ Provides-Extra: tui
32
+ Requires-Dist: textual>=0.50; extra == "tui"
31
33
  Dynamic: author
32
34
  Dynamic: home-page
33
35
  Dynamic: license-file
@@ -294,6 +296,7 @@ It auto-skips if `../ka9q-radio` is not present, so CI environments without the
294
296
  For detailed information, please refer to the documentation in the `docs/` directory:
295
297
 
296
298
  - **[API Reference](docs/API_REFERENCE.md)**: Full details on all classes, methods, and functions.
299
+ - **[TUI Guide](docs/TUI_GUIDE.md)**: Launching and using the `ka9q tui` Textual terminal UI.
297
300
  - **[RTP Timing Support](docs/RTP_TIMING_SUPPORT.md)**: Guide to RTP timing and synchronization.
298
301
  - **[Architecture](docs/ARCHITECTURE.md)**: Overview of the library's design and structure.
299
302
  - **[Installation Guide](docs/INSTALLATION.md)**: Detailed installation instructions.
@@ -259,6 +259,7 @@ It auto-skips if `../ka9q-radio` is not present, so CI environments without the
259
259
  For detailed information, please refer to the documentation in the `docs/` directory:
260
260
 
261
261
  - **[API Reference](docs/API_REFERENCE.md)**: Full details on all classes, methods, and functions.
262
+ - **[TUI Guide](docs/TUI_GUIDE.md)**: Launching and using the `ka9q tui` Textual terminal UI.
262
263
  - **[RTP Timing Support](docs/RTP_TIMING_SUPPORT.md)**: Guide to RTP timing and synchronization.
263
264
  - **[Architecture](docs/ARCHITECTURE.md)**: Overview of the library's design and structure.
264
265
  - **[Installation Guide](docs/INSTALLATION.md)**: Detailed installation instructions.
@@ -5,6 +5,30 @@ All notable changes to ka9q-python will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.9.0] - 2026-04-14
9
+
10
+ ### Added - `ka9q` CLI and Textual TUI
11
+
12
+ - **`ka9q` console script** (`ka9q.cli`) with `list` / `query` / `set` / `tui`
13
+ subcommands and a shared `SET_VERBS` vocabulary so the CLI and TUI accept
14
+ identical parameter names and values.
15
+ - **Textual TUI** (`ka9q.tui`, opt-in via `pip install ka9q-python[tui]`):
16
+ 8-panel live view (Tuning, Frontend/GPSDO, Signal/Levels, Filter/FFT,
17
+ Demod, Options/Squelch, Input/Status, Output/RTP) with `control`-style
18
+ one-letter keybindings. Updates come from a passive `listen_status()`
19
+ worker plus a 1 Hz `poll_status()` tick so values refresh even when the
20
+ channel is idle.
21
+ - **Typed status decoder** (`ka9q.status`): `ChannelStatus`,
22
+ `FrontendStatus`, `PllStatus`, `FmStatus`, `SpectrumStatus`,
23
+ `Filter2Status`, `OpusStatus` dataclasses plus `decode_status_packet()`,
24
+ all re-exported from `ka9q`.
25
+ - **New `RadiodControl` methods**: `poll_status(ssrc, timeout)` for active
26
+ SSRC-tagged polls, and `listen_status(callback, duration, ssrcs)` for
27
+ passive multicast receive.
28
+ - **Docs**: [TUI Guide](TUI_GUIDE.md) covering launch, update cadence,
29
+ per-panel field reference (including where GPSDO discipline and ADC
30
+ overrange counters surface), keybinding table, and troubleshooting.
31
+
8
32
  ## [3.4.0] - 2024-12-30
9
33
 
10
34
  ### Added - Web UI Interactive Control 🎛️
@@ -0,0 +1,148 @@
1
+ # ka9q TUI Guide
2
+
3
+ A Textual-based terminal UI for monitoring and controlling a live
4
+ `radiod` channel. Modeled on ka9q-radio's ncurses `control` program:
5
+ eight panels of channel state and one-letter keybindings that drive the
6
+ same setters as `ka9q set`.
7
+
8
+ ## Install
9
+
10
+ The TUI dependency (`textual`) ships as an optional extra:
11
+
12
+ ```bash
13
+ pip install -e ".[tui]"
14
+ # or, for development:
15
+ pip install -e ".[dev,tui]"
16
+ ```
17
+
18
+ ## Launch
19
+
20
+ ```bash
21
+ # Watch a specific SSRC (recommended)
22
+ ka9q tui bee1-hf-status.local --ssrc 14095000
23
+
24
+ # Or discover interactively — the TUI latches onto the first SSRC
25
+ # seen on the status multicast
26
+ ka9q tui bee1-hf-status.local
27
+
28
+ # Pin a network interface when the host is multi-homed
29
+ ka9q tui bee1-hf-status.local --ssrc 14095000 --interface eth0
30
+ ```
31
+
32
+ Find SSRCs first with `ka9q list HOST`.
33
+
34
+ ## Update cadence
35
+
36
+ radiod emits per-channel status packets in two situations:
37
+
38
+ 1. **On change** — when any setter modifies the channel.
39
+ 2. **On poll** — in response to a status-request command.
40
+
41
+ The TUI uses both:
42
+
43
+ - A background thread runs `RadiodControl.listen_status()` and pushes
44
+ any status packet for the focused SSRC onto an internal queue
45
+ ([tui.py:38-62](../ka9q/tui.py#L38-L62)). This captures change-driven
46
+ updates and anything triggered by other clients (e.g. a running
47
+ `control` instance).
48
+ - A 1 Hz timer calls `poll_status(ssrc, timeout=0.8)` in a worker
49
+ thread ([tui.py:412-421](../ka9q/tui.py#L412-L421)), so values
50
+ refresh every second even when the channel is idle.
51
+ - The UI drains the queue and repaints every 200 ms
52
+ ([tui.py:399](../ka9q/tui.py#L399)).
53
+
54
+ A one-shot `poll_status(timeout=2.0)` fires at mount to populate the
55
+ panels immediately.
56
+
57
+ ## Panels
58
+
59
+ Grid layout (3×3, last cell blank):
60
+
61
+ | Panel | Source fields |
62
+ |---|---|
63
+ | **Tuning** | Carrier, first/second LO, shift, channel filter edges, frontend filter edges, Doppler |
64
+ | **Frontend / GPSDO** | Description, input sample rate, AD bits, real/complex, **`calibrate_ppm`**, **`gpsdo_reference_hz`** (10 MHz reference), `ad_over`, `samples_since_over`, LNA/MIX/IF gains, RF gain/atten/AGC |
65
+ | **Signal / Levels** | IF power (dBFS), RF level cal, input dBm, baseband power, noise density, S/N₀, S/N + bandwidth, output level |
66
+ | **Filter / FFT** | Kaiser β, blocksize, FIR length, drops, noise BW, optional Filter2 block |
67
+ | **Demod** | Mode + demod name, plus mode-specific block (FM SNR / peak dev / PL tone / de-emphasis; linear AGC / ISB / envelope / PLL; spectrum RBW / bins / window / FFT N) |
68
+ | **Options / Squelch** | Lock, SNR squelch enable, open/close thresholds, ISB, envelope, AGC, FM threshold-extend |
69
+ | **Input / Status** | GPS time, cmd count, input sample rate, samples in, ADC overrange counters, status destination + interval |
70
+ | **Output / RTP** | SSRC, output rate, channels, encoding, TTL, destination, sample count, packet counters, errors, max delay, Opus parameters when present |
71
+
72
+ A "—" means the field is absent from the latest status packet — many
73
+ fields depend on frontend/demod type (RX888 vs Airspy; linear vs FM vs
74
+ spectrum), so not every cell is populated for every channel.
75
+
76
+ ### Where GPSDO and ADC governance show up
77
+
78
+ - **GPSDO discipline**: `Frontend / GPSDO` panel — `Calibrate: ±x.xxx
79
+ ppm` is the frontend's fractional-frequency error vs. its 10 MHz
80
+ reference, and `Ref (10M): …` is the measured reference frequency.
81
+ - **ADC overranges**: both `Frontend / GPSDO` and `Input / Status` show
82
+ `AD over` (total clip count) and `samples_since_over` (how long ago
83
+ the last clip occurred, in samples at the input sample rate).
84
+
85
+ ## Keybindings
86
+
87
+ Modeled after `control`. Parameter keys open a single-line modal
88
+ prompt; toggle keys act immediately.
89
+
90
+ | Key | Action | Setter |
91
+ |---|---|---|
92
+ | `f` | Carrier frequency (Hz) | `set_frequency` |
93
+ | `p` | Preset / mode | `set_preset` |
94
+ | `S` | Sample rate (Hz) | `set_sample_rate` |
95
+ | `s` | Squelch open (dB) | `set_squelch` |
96
+ | `G` | RF gain (dB) | `set_rf_gain` |
97
+ | `A` | RF attenuation (dB) | `set_rf_attenuation` |
98
+ | `g` | Linear gain (dB) | `set_gain` |
99
+ | `H` | Headroom (dB) | `set_headroom` |
100
+ | `L` | AGC threshold (dB) | `set_agc_threshold` |
101
+ | `R` | AGC recovery (dB/s) | `set_agc_recovery_rate` |
102
+ | `T` | AGC hang time (s) | `set_agc_hangtime` |
103
+ | `P` | PLL bandwidth (Hz) | `set_pll` |
104
+ | `K` | Kaiser β | `set_kaiser_beta` |
105
+ | `e` | Output encoding (S16LE, F32LE, OPUS…) | `set_output_encoding` |
106
+ | `b` | Opus bitrate (bps) | `set_opus_bitrate` |
107
+ | `t` | PL tone (Hz, 0 off) | `set_pl_tone` |
108
+ | `D` | Demod type (linear/fm/wfm/spect) | `set_demod_type` |
109
+ | `l` | Toggle channel lock | `set_lock` |
110
+ | `i` | Toggle independent sideband | `set_independent_sideband` |
111
+ | `v` | Toggle envelope detection | `set_envelope_detection` |
112
+ | `x` | Toggle FM threshold-extend | `set_fm_threshold_extension` |
113
+ | `?` / `h` | Show help overlay | — |
114
+ | `q` | Quit | — |
115
+
116
+ Prompt keys dispatch through `SET_VERBS` in
117
+ [cli.py:163](../ka9q/cli.py#L163), so any value accepted by `ka9q set`
118
+ is also accepted in the modal — the TUI and CLI share one vocabulary.
119
+
120
+ If a setter raises, the header line shows `ERR: …` instead of dimming
121
+ the panels, so you can see the failure without losing context.
122
+
123
+ ## Under the hood
124
+
125
+ - [tui.py](../ka9q/tui.py) — app, panels, keybindings, status worker.
126
+ - [control.py `listen_status`](../ka9q/control.py) — passive receive on
127
+ the status multicast.
128
+ - [control.py `poll_status`](../ka9q/control.py) — active poll (sends a
129
+ command tagged with the target SSRC and waits for the matching
130
+ status reply).
131
+ - [status.py `ChannelStatus`](../ka9q/status.py) — the typed dataclass
132
+ every panel reads from.
133
+
134
+ ## Troubleshooting
135
+
136
+ **Nothing updates / panels stay blank.** Confirm the SSRC exists:
137
+ `ka9q query HOST --ssrc N` should print a status. If `query` works but
138
+ the TUI doesn't, the status multicast may be reaching the control
139
+ socket but not the passive listener — check `--interface` on multi-homed
140
+ hosts.
141
+
142
+ **All values are "—".** The SSRC is receiving packets but they lack
143
+ populated TLVs. Usually this means the channel is in an unusual demod
144
+ state; pressing `p` and re-setting the preset forces radiod to emit a
145
+ full status.
146
+
147
+ **Key does nothing.** Parameter keys require a focused SSRC. Pass
148
+ `--ssrc` explicitly or wait for the listener to latch onto one.
@@ -56,7 +56,7 @@ Lower-level usage (explicit control):
56
56
  )
57
57
  print(f"Created channel with SSRC: {ssrc}")
58
58
  """
59
- __version__ = '3.8.0'
59
+ __version__ = '3.9.0'
60
60
  __author__ = 'Michael Hauan AC0G'
61
61
 
62
62
  from .control import RadiodControl, allocate_ssrc
@@ -68,6 +68,16 @@ from .discovery import (
68
68
  ChannelInfo
69
69
  )
70
70
  from .types import StatusType, Encoding, DemodType, WindowType
71
+ from .status import (
72
+ FrontendStatus,
73
+ ChannelStatus,
74
+ PllStatus,
75
+ FmStatus,
76
+ SpectrumStatus,
77
+ Filter2Status,
78
+ OpusStatus,
79
+ decode_status_packet,
80
+ )
71
81
  from .exceptions import Ka9qError, ConnectionError, CommandError, ValidationError
72
82
  from .rtp_recorder import (
73
83
  RTPRecorder,
@@ -114,6 +124,16 @@ __all__ = [
114
124
  'Encoding',
115
125
  'DemodType',
116
126
  'WindowType',
127
+
128
+ # Typed status
129
+ 'FrontendStatus',
130
+ 'ChannelStatus',
131
+ 'PllStatus',
132
+ 'FmStatus',
133
+ 'SpectrumStatus',
134
+ 'Filter2Status',
135
+ 'OpusStatus',
136
+ 'decode_status_packet',
117
137
 
118
138
  # Exceptions
119
139
  'Ka9qError',
@@ -0,0 +1,338 @@
1
+ """
2
+ ``ka9q`` command-line tool.
3
+
4
+ Subcommands mirror the things the Textual TUI does, so the CLI and TUI
5
+ share one vocabulary:
6
+
7
+ ka9q list HOST List channels (via discovery)
8
+ ka9q query HOST --ssrc N [--field path] [--json] [--watch]
9
+ ka9q set HOST --ssrc N PARAM VALUE Change a parameter
10
+ ka9q tui HOST Launch the Textual TUI
11
+
12
+ Every ``set`` verb maps to a :class:`RadiodControl` setter, and every
13
+ ``query --field`` path is one of the dotted paths produced by
14
+ :meth:`ChannelStatus.field_names`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ import time
22
+ from dataclasses import is_dataclass, asdict
23
+ from typing import Any, Optional
24
+
25
+ from .control import RadiodControl
26
+ from .discovery import discover_channels
27
+ from .status import ChannelStatus
28
+ from .types import DemodType, Encoding, WindowType
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Rendering helpers
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _fmt_hz(v: Optional[float]) -> str:
36
+ if v is None:
37
+ return "—"
38
+ if abs(v) >= 1e6:
39
+ return f"{v/1e6:.6f} MHz"
40
+ if abs(v) >= 1e3:
41
+ return f"{v/1e3:.3f} kHz"
42
+ return f"{v:+.1f} Hz"
43
+
44
+
45
+ def _render_status_text(st: ChannelStatus) -> str:
46
+ fe = st.frontend
47
+ L = []
48
+ L.append(f"SSRC: {st.ssrc}")
49
+ L.append(f"Description: {st.description or '—'}")
50
+ L.append(f"Preset / Mode: {st.preset or '—'} ({st.demod_name})")
51
+ L.append("")
52
+ L.append("[Tuning]")
53
+ L.append(f" Carrier: {_fmt_hz(st.frequency)}")
54
+ L.append(f" First LO: {_fmt_hz(st.first_lo)} lock={fe.lock}")
55
+ L.append(f" Second LO: {_fmt_hz(st.second_lo)}")
56
+ L.append(f" Shift: {_fmt_hz(st.shift)}")
57
+ if st.doppler:
58
+ L.append(f" Doppler: {_fmt_hz(st.doppler)} @ {st.doppler_rate} Hz/s")
59
+ L.append(f" Filter: {_fmt_hz(st.low_edge)} .. {_fmt_hz(st.high_edge)} (β={st.kaiser_beta})")
60
+ L.append(f" FE filter: {_fmt_hz(fe.fe_low_edge)} .. {_fmt_hz(fe.fe_high_edge)}")
61
+ L.append("")
62
+ L.append("[Frontend / GPSDO]")
63
+ L.append(f" Input rate: {fe.input_samprate} Hz ({fe.ad_bits_per_sample} bits, real={fe.isreal})")
64
+ if fe.calibrate is not None:
65
+ L.append(f" Calibrate: {fe.calibrate:+.3e} ({fe.calibrate_ppm:+.3f} ppm)")
66
+ L.append(f" Implied 10 MHz ref: {fe.gpsdo_reference_hz:.3f} Hz")
67
+ L.append(f" Overranges: {fe.ad_over} samples since: {fe.samples_since_over}")
68
+ L.append(f" Gains: LNA={fe.lna_gain} MIX={fe.mixer_gain} IF={fe.if_gain} "
69
+ f"RFgain={fe.rf_gain} RFatten={fe.rf_atten} RFAGC={fe.rf_agc}")
70
+ L.append(f" IF power: {fe.if_power} dBFS cal={fe.rf_level_cal} dB "
71
+ f"→ input {fe.input_power_dbm} dBm" if fe.if_power is not None else
72
+ " IF power: —")
73
+ L.append("")
74
+ L.append("[Signal / Levels]")
75
+ L.append(f" Baseband: {st.baseband_power} dB Noise density: {st.noise_density} dB/Hz")
76
+ L.append(f" Output lvl: {st.output_level} dB S/N0: {st.snr_per_hz} dB-Hz "
77
+ f"S/N: {st.snr} dB BW={st.bandwidth} Hz")
78
+ L.append("")
79
+ L.append("[Filter / FFT]")
80
+ L.append(f" Blocksize: {st.filter_blocksize} FIR len: {st.filter_fir_length} "
81
+ f"drops: {st.filter_drops} noise_bw: {st.noise_bw}")
82
+ if st.filter2.blocking:
83
+ L.append(f" Filter2: blk={st.filter2.blocking} size={st.filter2.blocksize} "
84
+ f"fir={st.filter2.fir_length} β={st.filter2.kaiser_beta}")
85
+ L.append("")
86
+ if st.demod_type == DemodType.FM_DEMOD:
87
+ L.append("[FM]")
88
+ L.append(f" SNR: {st.fm.fm_snr} peak dev: {st.fm.peak_deviation} Hz "
89
+ f"PL tone: {st.fm.pl_tone}/{st.fm.pl_deviation}")
90
+ L.append(f" De-emph TC: {st.fm.deemph_tc} gain: {st.fm.deemph_gain} "
91
+ f"thr-ext: {st.fm.threshold_extend}")
92
+ elif st.demod_type == DemodType.LINEAR_DEMOD:
93
+ L.append("[Linear]")
94
+ L.append(f" AGC: {st.agc_enable} gain: {st.gain} headroom: {st.headroom} "
95
+ f"hang: {st.agc_hangtime}s recov: {st.agc_recovery_rate} dB/s "
96
+ f"thresh: {st.agc_threshold} dB")
97
+ L.append(f" ISB: {st.independent_sideband} Envelope: {st.envelope}")
98
+ p = st.pll
99
+ L.append(f" PLL: enable={p.enable} lock={p.lock} sq={p.square} "
100
+ f"BW={p.bw} Hz Δf={p.freq_offset} Hz φ={p.phase} "
101
+ f"SNR={p.snr} dB wraps={p.wraps}")
102
+ elif st.demod_type in (DemodType.SPECT_DEMOD, DemodType.SPECT2_DEMOD):
103
+ sp = st.spectrum
104
+ L.append("[Spectrum]")
105
+ L.append(f" RBW: {sp.resolution_bw} Hz bins: {sp.bin_count} crossover: {sp.crossover} Hz")
106
+ L.append(f" FFT N: {sp.fft_n} window: {sp.window_type} overlap: {sp.overlap} "
107
+ f"avg: {sp.avg} shape: {sp.shape}")
108
+ L.append("")
109
+ L.append("[Squelch / Options]")
110
+ L.append(f" SNR-sq: {st.snr_squelch_enable} open: {st.squelch_open} close: {st.squelch_close}")
111
+ L.append(f" Lock: {st.lock}")
112
+ L.append("")
113
+ L.append("[Output]")
114
+ L.append(f" rate={st.output_samprate} ch={st.output_channels} enc={st.encoding_name} "
115
+ f"ttl={st.output_ttl}")
116
+ L.append(f" dest={st.output_data_dest_socket}")
117
+ L.append(f" samples={st.output_samples} pkts={st.output_data_packets} "
118
+ f"meta={st.output_metadata_packets} errs={st.output_errors}")
119
+ if st.opus.bit_rate:
120
+ L.append(f" Opus: {st.opus.bit_rate} bps dtx={st.opus.dtx} app={st.opus.application} "
121
+ f"bw={st.opus.bandwidth} fec={st.opus.fec}")
122
+ if st.tp1 is not None or st.tp2 is not None:
123
+ L.append(f" TP1={st.tp1} TP2={st.tp2}")
124
+ return "\n".join(L)
125
+
126
+
127
+ def _json_default(o: Any) -> Any:
128
+ if is_dataclass(o):
129
+ return asdict(o)
130
+ raise TypeError(f"not json-serializable: {type(o).__name__}")
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # ``set`` verb dispatch
135
+ # ---------------------------------------------------------------------------
136
+
137
+ def _coerce_bool(v: str) -> bool:
138
+ return v.lower() in ("1", "true", "yes", "on", "y", "t")
139
+
140
+
141
+ def _coerce_encoding(v: str) -> int:
142
+ try:
143
+ return int(v)
144
+ except ValueError:
145
+ return getattr(Encoding, v.upper())
146
+
147
+
148
+ def _coerce_demod(v: str) -> int:
149
+ try:
150
+ return int(v)
151
+ except ValueError:
152
+ return getattr(DemodType, v.upper() if v.upper().endswith("_DEMOD") else v.upper() + "_DEMOD")
153
+
154
+
155
+ def _coerce_window(v: str) -> int:
156
+ try:
157
+ return int(v)
158
+ except ValueError:
159
+ return getattr(WindowType, v.upper() if v.upper().endswith("_WINDOW") else v.upper() + "_WINDOW")
160
+
161
+
162
+ # (param-name → callable(control, ssrc, raw_value))
163
+ SET_VERBS = {
164
+ "frequency": lambda c, s, v: c.set_frequency(s, float(v)),
165
+ "preset": lambda c, s, v: c.set_preset(s, v),
166
+ "mode": lambda c, s, v: c.set_preset(s, v),
167
+ "sample-rate": lambda c, s, v: c.set_sample_rate(s, int(v)),
168
+ "samprate": lambda c, s, v: c.set_sample_rate(s, int(v)),
169
+ "low-edge": lambda c, s, v: c.set_filter(s, low_edge=float(v)),
170
+ "high-edge": lambda c, s, v: c.set_filter(s, high_edge=float(v)),
171
+ "kaiser-beta": lambda c, s, v: c.set_kaiser_beta(s, float(v)),
172
+ "shift": lambda c, s, v: c.set_shift_frequency(s, float(v)),
173
+ "gain": lambda c, s, v: c.set_gain(s, float(v)),
174
+ "output-level": lambda c, s, v: c.set_output_level(s, float(v)),
175
+ "headroom": lambda c, s, v: c.set_headroom(s, float(v)),
176
+ "agc": lambda c, s, v: c.set_agc(s, _coerce_bool(v)),
177
+ "agc-hangtime": lambda c, s, v: c.set_agc_hangtime(s, float(v)),
178
+ "agc-recovery": lambda c, s, v: c.set_agc_recovery_rate(s, float(v)),
179
+ "agc-threshold": lambda c, s, v: c.set_agc_threshold(s, float(v)),
180
+ "rf-gain": lambda c, s, v: c.set_rf_gain(s, float(v)),
181
+ "rf-atten": lambda c, s, v: c.set_rf_attenuation(s, float(v)),
182
+ "squelch-open": lambda c, s, v: c.set_squelch(s, enable=True, open_snr_db=float(v)),
183
+ "squelch-close": lambda c, s, v: c.set_squelch(s, enable=True, close_snr_db=float(v)),
184
+ "snr-squelch": lambda c, s, v: c.set_squelch(s, enable=_coerce_bool(v)),
185
+ "pll": lambda c, s, v: c.set_pll(s, enable=_coerce_bool(v)),
186
+ "pll-bw": lambda c, s, v: c.set_pll(s, enable=True, bandwidth_hz=float(v)),
187
+ "pll-square": lambda c, s, v: c.set_pll(s, enable=True, square=_coerce_bool(v)),
188
+ "isb": lambda c, s, v: c.set_independent_sideband(s, _coerce_bool(v)),
189
+ "envelope": lambda c, s, v: c.set_envelope_detection(s, _coerce_bool(v)),
190
+ "channels": lambda c, s, v: c.set_output_channels(s, int(v)),
191
+ "encoding": lambda c, s, v: c.set_output_encoding(s, _coerce_encoding(v)),
192
+ "demod-type": lambda c, s, v: c.set_demod_type(s, _coerce_demod(v)),
193
+ "pl-tone": lambda c, s, v: c.set_pl_tone(s, float(v)),
194
+ "threshold-extend":lambda c, s, v: c.set_fm_threshold_extension(s, _coerce_bool(v)),
195
+ "lock": lambda c, s, v: c.set_lock(s, _coerce_bool(v)),
196
+ "description": lambda c, s, v: c.set_description(s, v),
197
+ "first-lo": lambda c, s, v: c.set_first_lo(s, float(v)),
198
+ "status-interval": lambda c, s, v: c.set_status_interval(s, int(v)),
199
+ "max-delay": lambda c, s, v: c.set_max_delay(s, int(v)),
200
+ "opus-bitrate": lambda c, s, v: c.set_opus_bitrate(s, int(v)),
201
+ "opus-dtx": lambda c, s, v: c.set_opus_dtx(s, _coerce_bool(v)),
202
+ "opus-application":lambda c, s, v: c.set_opus_application(s, int(v)),
203
+ "opus-bandwidth": lambda c, s, v: c.set_opus_bandwidth(s, int(v)),
204
+ "opus-fec": lambda c, s, v: c.set_opus_fec(s, int(v)),
205
+ "window": lambda c, s, v: c.set_spectrum(s, window_type=_coerce_window(v)),
206
+ "destination": lambda c, s, v: c.set_destination(s, *_parse_addr(v)),
207
+ }
208
+
209
+
210
+ def _parse_addr(v: str):
211
+ if ":" in v:
212
+ a, p = v.rsplit(":", 1)
213
+ return a, int(p)
214
+ return v, 5004
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Command handlers
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def cmd_list(args: argparse.Namespace) -> int:
222
+ channels = discover_channels(args.host, timeout=args.timeout)
223
+ if args.json:
224
+ print(json.dumps([asdict(c) if is_dataclass(c) else c.__dict__ for c in channels],
225
+ default=_json_default, indent=2))
226
+ return 0
227
+ if not channels:
228
+ print("No channels discovered.", file=sys.stderr)
229
+ return 1
230
+ print(f"{'SSRC':>10} {'Frequency':>14} {'Preset':<8} Dest")
231
+ for c in channels:
232
+ freq = getattr(c, "frequency", None) or getattr(c, "frequency_hz", None)
233
+ ssrc = getattr(c, "ssrc", "?")
234
+ preset = getattr(c, "preset", "") or ""
235
+ dest = getattr(c, "data_dest", "") or getattr(c, "destination", "") or ""
236
+ print(f"{ssrc:>10} {(freq or 0)/1e6:>13.6f}M {preset:<8} {dest}")
237
+ return 0
238
+
239
+
240
+ def cmd_query(args: argparse.Namespace) -> int:
241
+ with RadiodControl(args.host) as control:
242
+ def show(st: ChannelStatus):
243
+ if args.field:
244
+ v = st.get_field(args.field)
245
+ if args.json:
246
+ print(json.dumps({args.field: v}, default=_json_default))
247
+ else:
248
+ print(v if v is not None else "")
249
+ elif args.json:
250
+ print(json.dumps(st.to_dict(), default=_json_default, indent=2))
251
+ else:
252
+ print(_render_status_text(st))
253
+ print()
254
+
255
+ if args.watch:
256
+ # Passive listener — radiod multicasts periodically.
257
+ ssrcs = {args.ssrc} if args.ssrc else None
258
+ try:
259
+ control.listen_status(show, ssrcs=ssrcs)
260
+ except KeyboardInterrupt:
261
+ return 0
262
+ else:
263
+ if args.ssrc is None:
264
+ print("--ssrc required (or use --watch without --ssrc to see all)", file=sys.stderr)
265
+ return 2
266
+ st = control.poll_status(args.ssrc, timeout=args.timeout)
267
+ show(st)
268
+ return 0
269
+
270
+
271
+ def cmd_set(args: argparse.Namespace) -> int:
272
+ verb = args.param.lower()
273
+ if verb not in SET_VERBS:
274
+ print(f"Unknown parameter '{verb}'. Known: {', '.join(sorted(SET_VERBS))}", file=sys.stderr)
275
+ return 2
276
+ with RadiodControl(args.host) as control:
277
+ SET_VERBS[verb](control, args.ssrc, args.value)
278
+ return 0
279
+
280
+
281
+ def cmd_tui(args: argparse.Namespace) -> int:
282
+ try:
283
+ from .tui import run_tui
284
+ except ImportError as exc:
285
+ print(f"TUI unavailable: {exc}\nInstall with: pip install ka9q-python[tui]", file=sys.stderr)
286
+ return 2
287
+ return run_tui(args.host, ssrc=args.ssrc, interface=args.interface)
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Top-level parser
292
+ # ---------------------------------------------------------------------------
293
+
294
+ def build_parser() -> argparse.ArgumentParser:
295
+ p = argparse.ArgumentParser(
296
+ prog="ka9q",
297
+ description="Control and monitor ka9q-radio channels.",
298
+ )
299
+ p.add_argument("--interface", help="Network interface for multicast (optional)")
300
+ sub = p.add_subparsers(dest="cmd", required=True)
301
+
302
+ pl = sub.add_parser("list", help="Discover channels on HOST")
303
+ pl.add_argument("host")
304
+ pl.add_argument("--timeout", type=float, default=3.0)
305
+ pl.add_argument("--json", action="store_true")
306
+ pl.set_defaults(func=cmd_list)
307
+
308
+ pq = sub.add_parser("query", help="Query a channel's full status")
309
+ pq.add_argument("host")
310
+ pq.add_argument("--ssrc", type=int)
311
+ pq.add_argument("--field", help="Dotted path, e.g. 'frontend.calibrate' or 'pll.lock'")
312
+ pq.add_argument("--json", action="store_true")
313
+ pq.add_argument("--watch", action="store_true", help="Stream passive status updates")
314
+ pq.add_argument("--timeout", type=float, default=2.0)
315
+ pq.set_defaults(func=cmd_query)
316
+
317
+ ps = sub.add_parser("set", help="Set a channel parameter")
318
+ ps.add_argument("host")
319
+ ps.add_argument("--ssrc", type=int, required=True)
320
+ ps.add_argument("param", help=f"One of: {', '.join(sorted(SET_VERBS))}")
321
+ ps.add_argument("value")
322
+ ps.set_defaults(func=cmd_set)
323
+
324
+ pt = sub.add_parser("tui", help="Launch the Textual TUI")
325
+ pt.add_argument("host")
326
+ pt.add_argument("--ssrc", type=int, help="Focus a specific SSRC at startup")
327
+ pt.set_defaults(func=cmd_tui)
328
+
329
+ return p
330
+
331
+
332
+ def main(argv: Optional[list] = None) -> int:
333
+ args = build_parser().parse_args(argv)
334
+ return args.func(args)
335
+
336
+
337
+ if __name__ == "__main__": # pragma: no cover
338
+ sys.exit(main())