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.
- {ka9q_python-3.8.0/ka9q_python.egg-info → ka9q_python-3.9.0}/PKG-INFO +4 -1
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/README.md +1 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/CHANGELOG.md +24 -0
- ka9q_python-3.9.0/docs/TUI_GUIDE.md +148 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/__init__.py +21 -1
- ka9q_python-3.9.0/ka9q/cli.py +338 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/control.py +163 -0
- ka9q_python-3.9.0/ka9q/status.py +648 -0
- ka9q_python-3.9.0/ka9q/tui.py +516 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0/ka9q_python.egg-info}/PKG-INFO +4 -1
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/SOURCES.txt +6 -0
- ka9q_python-3.9.0/ka9q_python.egg-info/entry_points.txt +2 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/requires.txt +3 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/pyproject.toml +7 -1
- ka9q_python-3.9.0/tests/test_status_decoder.py +161 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/CHANGELOG.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/LICENSE +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/MANIFEST.in +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/RELEASE_NOTES_v3.5.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/CROSS_PLATFORM_SUPPORT.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/DISTRIBUTION_RECOMMENDATION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/MULTI_HOMED_QUICK_REF.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/NATIVE_DISCOVERY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/PYPI_PUBLICATION_GUIDE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/QUICK_REFERENCE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/RTP_RECORDER_PASS_ALL_PACKETS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/RTP_TIMING_IMPLEMENTATION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/SSRC_COLLISION_PREVENTION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/TESTING_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/TEST_RESULTS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_ENHANCEMENT_GUIDE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_ENHANCEMENT_IMPLEMENTED.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_ESCAPE_SEQUENCE_FIX.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_FUNCTIONALITY_REVIEW.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_IMPLEMENTATION_STATUS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/WEB_UI_INTERACTIVE_COMPLETE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANGES_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANNEL_CLEANUP_ADDITION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANNEL_CLEANUP_COMPLETE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CHANNEL_TUNING_DIAGNOSTICS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CODE_REVIEW_RECOMMENDATIONS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CODE_REVIEW_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/COMMIT_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/CRITICAL_FIXES_CHECKLIST.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/FINAL_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/FIXES_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/GIT_STATUS_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_COMPLETE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_STATUS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPLEMENTATION_SUMMARY_v2.2.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/IMPROVEMENTS_IMPLEMENTED.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/MULTI_HOMED_ACTION_PLAN.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/MULTI_HOMED_IMPLEMENTATION_COMPLETE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/MULTI_HOMED_SUPPORT_REVIEW.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PACKAGE_STATUS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PACKAGE_VERIFICATION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_FIXES_APPLIED.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_REVIEW.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_REVIEW_V2.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/PERFORMANCE_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/QUICK_ACTION_ITEMS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/QUICK_START_DIAGNOSIS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/RELEASE_CHECKLIST_v3.0.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/SUMMARY_WEB_UI_FIXES.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/TUNE_IMPLEMENTATION.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/WEBUI_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/development/WEB_UI_ENHANCEMENTS_COMPLETE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/CONTROL_COMPARISON.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/DESTINATION_AWARE_CHANNELS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/NEW_FEATURES.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/RADIOD_FEATURES_SUMMARY.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/features/RTP_DESTINATION_FEATURE.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_INSTRUCTIONS.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v2.2.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v2.4.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v2.5.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v3.0.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v3.1.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/GITHUB_RELEASE_v3.2.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.1.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.2.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.3.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RELEASE_NOTES_v2.4.0.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/docs/releases/RTP_TIMING_RELEASE_NOTES.md +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/tune.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/multi_stream.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/types.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/setup.cfg +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/setup.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/__init__.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/conftest.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.8.0 → ka9q_python-3.9.0}/tests/test_tune_live.py +0 -0
- {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.
|
|
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.
|
|
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())
|