vfo-sync

Bidirectional VFO synchronization for IC-7300 and SDR++
git clone https://git.bradleygannon.com/vfo-sync.git
Log | Files | Refs

commit 2d159453f5503dd13bd74a002e967d4ef1ca19b8
parent 8536b610f03dee528e6cb2d8c908125fca906fa8
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Mon,  7 Sep 2026 16:39:19 -0400

Get VFO syncing working

Diffstat:
M.gitignore | 6+++---
MCargo.lock | 245++++++++++++++-----------------------------------------------------------------
MCargo.toml | 6++++--
AMakefile | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dciv_module/Cargo.lock | 7-------
Dciv_module/Cargo.toml | 9---------
Dciv_module/Makefile | 72------------------------------------------------------------------------
Dciv_module/shim/CMakeLists.txt | 41-----------------------------------------
Dciv_module/shim/src/main.cpp | 65-----------------------------------------------------------------
Dciv_module/src/lib.rs | 81-------------------------------------------------------------------------------
Mflake.nix | 48++----------------------------------------------
Ashim/CMakeLists.txt | 44++++++++++++++++++++++++++++++++++++++++++++
Ashim/src/main.cpp | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/civ.rs | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/ic7300.rs | 193-------------------------------------------------------------------------------
Asrc/lib.rs | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/main.rs | 73-------------------------------------------------------------------------
Dsrc/rspduo.rs | 53-----------------------------------------------------
Asrc/sync.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 1057 insertions(+), 849 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,4 +1,4 @@ /target -/civ_module/target -/civ_module/build -/civ_module/dist +/build +/dist +/hidden diff --git a/Cargo.lock b/Cargo.lock @@ -1,64 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "0.6.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" - -[[package]] -name = "anstyle-parse" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" -dependencies = [ - "anstyle", - "windows-sys", -] +version = 4 [[package]] name = "bitflags" @@ -68,27 +10,29 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +name = "civ_sync" +version = "0.1.0" +dependencies = [ + "log", + "serialport", +] [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -101,35 +45,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "env_filter" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "humantime", - "log", -] - -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - -[[package]] name = "io-kit-sys" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -140,16 +55,10 @@ dependencies = [ ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] name = "libc" -version = "0.2.162" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libudev" @@ -173,26 +82,20 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mach2" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] [[package]] -name = "memchr" -version = "2.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] name = "nix" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -205,67 +108,29 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.37" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "remote-sdr-control" -version = "0.1.0" -dependencies = [ - "env_logger", - "log", - "serialport", -] - -[[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -273,11 +138,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serialport" -version = "4.6.0" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7331eefcaafaa382c0df95bcd84068f0b3e3c215c300750dde2316e9b8806ed5" +checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.13.1", "cfg-if", "core-foundation", "core-foundation-sys", @@ -287,14 +152,14 @@ dependencies = [ "nix", "scopeguard", "unescaper", - "winapi", + "windows-sys", ] [[package]] name = "syn" -version = "2.0.87" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -303,18 +168,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -323,52 +188,24 @@ dependencies = [ [[package]] name = "unescaper" -version = "0.1.5" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c878a167baa8afd137494101a688ef8c67125089ff2249284bd2b5f9bfedb815" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ "thiserror", ] [[package]] name = "unicode-ident" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] diff --git a/Cargo.toml b/Cargo.toml @@ -1,9 +1,11 @@ [package] -name = "remote-sdr-control" +name = "civ_sync" version = "0.1.0" edition = "2021" +[lib] +crate-type = ["staticlib"] + [dependencies] -env_logger = "0.11.5" log = "0.4.22" serialport = "4.6.0" diff --git a/Makefile b/Makefile @@ -0,0 +1,91 @@ +# Builds the civ_sync SDR++ module (Rust staticlib + C++ shim) and installs the +# resulting .so into a local SDR++ config root for GUI loading. +# +# The SDR++ source location is configurable via the SDRPP_DIR environment +# variable or `make SDRPP_DIR=...`; it defaults to $(HOME)/SDRPlusPlus. +# +# If a prebuilt core is available (e.g. the nixpkgs `sdrpp` output, or an +# installed SDR++), point SDRPP_CORE_LIB at it to skip building the core from +# source. Leave it unset to build libsdrpp_core.so from SDRPP_DIR. +# +# Run inside `nix develop` so cargo, cmake, g++, and the SDR++ deps are present. + +SHELL := bash + +ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + +SDRPP_DIR ?= $(HOME)/SDRPlusPlus +SDRPP_CORE_LIB ?= +CORE_LIB := $(if $(SDRPP_CORE_LIB),$(SDRPP_CORE_LIB),$(SDRPP_DIR)/build/core/libsdrpp_core.so) + +MOD_BUILD := $(ROOT)/build +MODULE_SO := $(MOD_BUILD)/civ_sync.so + +DIST_ROOT ?= $(ROOT)/dist/root +MODULES_DIR ?= $(DIST_ROOT)/modules +INSTALLED_SO := $(MODULES_DIR)/civ_sync.so + +NPROC ?= $(shell nproc 2>/dev/null || echo 4) + +.PHONY: all module rust core install clean help + +all: module install + +module: rust $(CORE_LIB) + @test -d "$(SDRPP_DIR)" || { \ + echo "Error: SDRPP_DIR '$(SDRPP_DIR)' does not exist."; \ + echo " Set it to your SDR++ source tree:"; \ + echo " make SDRPP_DIR=/path/to/SDRPlusPlus"; \ + echo " (or set SDRPP_CORE_LIB to a prebuilt libsdrpp_core.so and SDRPP_DIR to its source)."; \ + exit 1; \ + } + @echo "==> Configuring civ_sync module" + cmake -S "$(ROOT)/shim" -B "$(MOD_BUILD)" \ + -DCMAKE_BUILD_TYPE=Release \ + -DSDRPP_DIR="$(SDRPP_DIR)" \ + -DSDRPP_CORE_LIB="$(CORE_LIB)" + @echo "==> Building civ_sync module" + cmake --build "$(MOD_BUILD)" -j$(NPROC) + +rust: + @echo "==> Building Rust staticlib" + cargo build --release + +$(CORE_LIB): + @test -d "$(SDRPP_DIR)" || { \ + echo "Error: SDRPP_DIR '$(SDRPP_DIR)' does not exist."; \ + echo " Clone SDR++ (https://github.com/AlexandreRouma/SDRPlusPlus) and set:"; \ + echo " make SDRPP_DIR=/path/to/SDRPlusPlus"; \ + exit 1; \ + } + @echo "==> Configuring SDR++ (core only)" + @opts=""; for opt in $$(grep -oE 'option\(OPT_BUILD_[A-Z0-9_]+' "$(SDRPP_DIR)/CMakeLists.txt" | sed 's/option(//' | sort -u); do opts="$${opts} -D$${opt}=OFF"; done; \ + cmake -S "$(SDRPP_DIR)" -B "$(SDRPP_DIR)/build" -DCMAKE_BUILD_TYPE=Release $${opts} + @echo "==> Building sdrpp_core" + cmake --build "$(SDRPP_DIR)/build" --target sdrpp_core -j$(NPROC) + +core: $(CORE_LIB) + +install: module + @echo "==> Installing module to $(MODULES_DIR)" + mkdir -p "$(MODULES_DIR)" + cp -f "$(MODULE_SO)" "$(INSTALLED_SO)" + +clean: + rm -rf "$(MOD_BUILD)" "$(DIST_ROOT)" + +help: + @printf '%s\n' \ + 'Targets:' \ + ' all build module + install into $(MODULES_DIR) (default)' \ + ' module build $(MODULE_SO)' \ + ' rust build the Rust staticlib' \ + ' core build the SDR++ core library only' \ + ' install copy $(MODULE_SO) into $(MODULES_DIR)' \ + ' clean remove module build dir and installed output' \ + '' \ + 'Variables (env or `make VAR=...`):' \ + ' SDRPP_DIR SDR++ source root [default: $(SDRPP_DIR)]' \ + ' SDRPP_CORE_LIB prebuilt core .so [default: built from SDRPP_DIR]' \ + ' MODULES_DIR module install dir [default: $(MODULES_DIR)]' \ + ' NPROC parallel jobs [default: $(NPROC)]' diff --git a/civ_module/Cargo.lock b/civ_module/Cargo.lock @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "civ_sync" -version = "0.1.0" diff --git a/civ_module/Cargo.toml b/civ_module/Cargo.toml @@ -1,9 +0,0 @@ -[package] -name = "civ_sync" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["staticlib"] - -[dependencies] diff --git a/civ_module/Makefile b/civ_module/Makefile @@ -1,72 +0,0 @@ -# Builds the civ_sync SDR++ module (Rust staticlib + C++ shim) and installs the -# resulting .so into a local SDR++ config root for GUI loading. -# -# The SDR++ source location is configurable via the SDRPP_DIR environment -# variable or `make SDRPP_DIR=...`; it defaults to ../.hidden/SDRPlusPlus. -# -# Run inside `nix develop` so cargo, cmake, g++, and the SDR++ deps are present. - -SHELL := bash - -CIV_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) -REPO_ROOT := $(abspath $(CIV_DIR)/..) - -SDRPP_DIR ?= $(abspath $(REPO_ROOT)/.hidden/SDRPlusPlus) -SDRPP_BUILD := $(SDRPP_DIR)/build -CORE_LIB := $(SDRPP_BUILD)/core/libsdrpp_core.so - -MOD_BUILD := $(CIV_DIR)/build -MODULE_SO := $(MOD_BUILD)/civ_sync.so - -DIST_ROOT ?= $(CIV_DIR)/dist/root -MODULES_DIR ?= $(DIST_ROOT)/modules -INSTALLED_SO := $(MODULES_DIR)/civ_sync.so - -NPROC ?= $(shell nproc 2>/dev/null || echo 4) - -.PHONY: all module rust core install clean help - -all: module install - -module: rust $(CORE_LIB) - @echo "==> Configuring civ_sync module" - cmake -S "$(CIV_DIR)/shim" -B "$(MOD_BUILD)" \ - -DCMAKE_BUILD_TYPE=Release -DSDRPP_DIR="$(SDRPP_DIR)" - @echo "==> Building civ_sync module" - cmake --build "$(MOD_BUILD)" -j$(NPROC) - -rust: - @echo "==> Building Rust staticlib" - cargo build --release --manifest-path "$(CIV_DIR)/Cargo.toml" - -$(CORE_LIB): - @echo "==> Configuring SDR++ (core only)" - @opts=""; for opt in $$(grep -oE 'option\(OPT_BUILD_[A-Z0-9_]+' "$(SDRPP_DIR)/CMakeLists.txt" | sed 's/option(//' | sort -u); do opts="$${opts} -D$${opt}=OFF"; done; \ - cmake -S "$(SDRPP_DIR)" -B "$(SDRPP_BUILD)" -DCMAKE_BUILD_TYPE=Release $${opts} - @echo "==> Building sdrpp_core" - cmake --build "$(SDRPP_BUILD)" --target sdrpp_core -j$(NPROC) - -core: $(CORE_LIB) - -install: module - @echo "==> Installing module to $(MODULES_DIR)" - mkdir -p "$(MODULES_DIR)" - cp -f "$(MODULE_SO)" "$(INSTALLED_SO)" - -clean: - rm -rf "$(MOD_BUILD)" "$(DIST_ROOT)" - -help: - @printf '%s\n' \ - 'Targets:' \ - ' all build module + install into $(MODULES_DIR) (default)' \ - ' module build $(MODULE_SO)' \ - ' rust build the Rust staticlib' \ - ' core build the SDR++ core library only' \ - ' install copy $(MODULE_SO) into $(MODULES_DIR)' \ - ' clean remove module build dir and installed output' \ - '' \ - 'Variables (env or `make VAR=...`):' \ - ' SDRPP_DIR SDR++ source root [default: $(SDRPP_DIR)]' \ - ' MODULES_DIR module install dir [default: $(MODULES_DIR)]' \ - ' NPROC parallel jobs [default: $(NPROC)]' diff --git a/civ_module/shim/CMakeLists.txt b/civ_module/shim/CMakeLists.txt @@ -1,41 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(civ_sync) - -set(SDRPP_DIR "" CACHE PATH "Path to the SDR++ source root") -if (NOT SDRPP_DIR) - message(FATAL_ERROR "SDRPP_DIR must be set to the SDR++ source root") -endif() - -set(CIV_STATICLIB "${CMAKE_CURRENT_SOURCE_DIR}/../target/release/libciv_sync.a" - CACHE FILEPATH "Path to the built Rust staticlib") -if (NOT EXISTS "${CIV_STATICLIB}") - message(FATAL_ERROR "Rust staticlib not found at ${CIV_STATICLIB}; run 'cargo build --release' first") -endif() - -set(SDRPP_CORE_LIB "${SDRPP_DIR}/build/core/libsdrpp_core.so" - CACHE FILEPATH "Path to the built libsdrpp_core.so") -if (NOT EXISTS "${SDRPP_CORE_LIB}") - message(FATAL_ERROR "libsdrpp_core.so not found at ${SDRPP_CORE_LIB}; build the SDR++ core first") -endif() - -find_package(OpenGL REQUIRED) - -file(GLOB SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") - -add_library(${PROJECT_NAME} SHARED ${SRC}) - -target_include_directories(${PROJECT_NAME} PRIVATE - "${SDRPP_DIR}/core/src" - "${SDRPP_DIR}/core/src/imgui" - ${OPENGL_INCLUDE_DIRS} -) - -target_link_libraries(${PROJECT_NAME} PRIVATE - "${SDRPP_CORE_LIB}" - "${CIV_STATICLIB}" - pthread dl m -) - -target_compile_options(${PROJECT_NAME} PRIVATE -std=c++17 -O2 -fPIC) - -set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "") diff --git a/civ_module/shim/src/main.cpp b/civ_module/shim/src/main.cpp @@ -1,65 +0,0 @@ -#include <imgui.h> -#include <module.h> -#include <gui/gui.h> - -extern "C" { - void* civ_create(const char* name); - void civ_destroy(void* ctx); - void civ_post_init(void* ctx); - void civ_enable(void* ctx); - void civ_disable(void* ctx); - bool civ_is_enabled(void* ctx); - const char* civ_status(void* ctx); -} - -SDRPP_MOD_INFO{ - /* Name: */ "civ_sync", - /* Description: */ "Sync an Icom IC-7300 (CI-V) with an SDR++ VFO", - /* Author: */ "vfo-sync", - /* Version: */ 0, 1, 0, - /* Max instances */ 1 -}; - -class CivInstance : public ModuleManager::Instance { -public: - CivInstance(std::string name) { - this->name = name; - rustCtx = civ_create(name.c_str()); - gui::menu.registerEntry(name, menuHandler, this, this); - } - - ~CivInstance() { - gui::menu.removeEntry(name); - civ_destroy(rustCtx); - } - - void postInit() override { civ_post_init(rustCtx); } - void enable() override { civ_enable(rustCtx); } - void disable() override { civ_disable(rustCtx); } - bool isEnabled() override { return civ_is_enabled(rustCtx); } - -private: - static void menuHandler(void* ctx) { - CivInstance* _this = (CivInstance*)ctx; - ImGui::TextUnformatted(civ_status(_this->rustCtx)); - } - - std::string name; - void* rustCtx = nullptr; -}; - -MOD_EXPORT void _INIT_() { - // Nothing here -} - -MOD_EXPORT ModuleManager::Instance* _CREATE_INSTANCE_(std::string name) { - return new CivInstance(name); -} - -MOD_EXPORT void _DELETE_INSTANCE_(void* instance) { - delete (CivInstance*)instance; -} - -MOD_EXPORT void _END_() { - // Nothing here -} diff --git a/civ_module/src/lib.rs b/civ_module/src/lib.rs @@ -1,81 +0,0 @@ -#![allow(clippy::missing_safety_doc)] - -use std::ffi::{c_char, CStr, CString}; - -pub struct CivCtx { - name: String, - status: CString, - enabled: bool, -} - -#[no_mangle] -pub unsafe extern "C" fn civ_create(name: *const c_char) -> *mut CivCtx { - let name = if name.is_null() { - "<unnamed>".to_string() - } else { - unsafe { CStr::from_ptr(name) } - .to_string_lossy() - .into_owned() - }; - - let msg = format!("Rust core alive (instance: {})", name); - eprintln!("[civ_sync] {}", msg); - - let ctx = CivCtx { - name, - status: CString::new(msg).unwrap(), - enabled: false, - }; - Box::into_raw(Box::new(ctx)) -} - -#[no_mangle] -pub unsafe extern "C" fn civ_destroy(ctx: *mut CivCtx) { - if ctx.is_null() { - return; - } - let ctx = Box::from_raw(ctx); - eprintln!("[civ_sync] Rust core destroyed (instance: {})", ctx.name); -} - -#[no_mangle] -pub unsafe extern "C" fn civ_post_init(ctx: *mut CivCtx) { - if ctx.is_null() { - return; - } - eprintln!("[civ_sync] Rust core post-init (instance: {})", (*ctx).name); -} - -#[no_mangle] -pub unsafe extern "C" fn civ_enable(ctx: *mut CivCtx) { - if ctx.is_null() { - return; - } - (*ctx).enabled = true; - eprintln!("[civ_sync] Rust core enabled (instance: {})", (*ctx).name); -} - -#[no_mangle] -pub unsafe extern "C" fn civ_disable(ctx: *mut CivCtx) { - if ctx.is_null() { - return; - } - (*ctx).enabled = false; - eprintln!("[civ_sync] Rust core disabled (instance: {})", (*ctx).name); -} - -#[no_mangle] -pub unsafe extern "C" fn civ_is_enabled(ctx: *mut CivCtx) -> bool { - if ctx.is_null() { - return false; - } - (*ctx).enabled -} - -#[no_mangle] -pub unsafe extern "C" fn civ_status(ctx: *mut CivCtx) -> *const c_char { - if ctx.is_null() { - return std::ptr::null(); - } - (*ctx).status.as_ptr() -} diff --git a/flake.nix b/flake.nix @@ -21,54 +21,8 @@ zstd ]; lib-path = with stable; lib.makeLibraryPath libInputs; - - civ-sync = stable.stdenv.mkDerivation { - pname = "civ-sync"; - version = "0.1.0"; - - src = ./civ_module; - - nativeBuildInputs = with stable; [ - cmake - cargo - rustc - ]; - - buildInputs = with stable; [ - fftwFloat - glfw - libGL - sdrpp - volk - zstd - ]; - - dontConfigure = true; - - buildPhase = '' - runHook preBuild - cargo build --release --offline - cmake -S shim -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DSDRPP_DIR=${stable.sdrpp.src} \ - -DSDRPP_CORE_LIB=${stable.sdrpp}/lib/libsdrpp_core.so - cmake --build build - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - install -Dm755 build/civ_sync.so "$out/lib/sdrpp/plugins/civ_sync.so" - runHook postInstall - ''; - }; in { - packages.${system} = { - inherit civ-sync; - default = civ-sync; - }; - devShells.${system}.default = stable.mkShellNoCC { packages = with stable; [ cargo @@ -85,6 +39,8 @@ ] ++ libInputs; shellHook = '' + export SDRPP_DIR="${stable.sdrpp.src}" + export SDRPP_CORE_LIB="${stable.sdrpp}/lib/libsdrpp_core.so" export "LD_LIBRARY_PATH=$LD_LIBRARY_PATH:${lib-path}" ''; }; diff --git a/shim/CMakeLists.txt b/shim/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.13) +project(civ_sync) + +set(SDRPP_DIR "" CACHE PATH "Path to the SDR++ source root") +if (NOT SDRPP_DIR) + message(FATAL_ERROR "SDRPP_DIR must be set to the SDR++ source root") +endif() + +set(CIV_STATICLIB "${CMAKE_CURRENT_SOURCE_DIR}/../target/release/libciv_sync.a" + CACHE FILEPATH "Path to the built Rust staticlib") +if (NOT EXISTS "${CIV_STATICLIB}") + message(FATAL_ERROR "Rust staticlib not found at ${CIV_STATICLIB}; run 'cargo build --release' first") +endif() + +set(SDRPP_CORE_LIB "${SDRPP_DIR}/build/core/libsdrpp_core.so" + CACHE FILEPATH "Path to the built libsdrpp_core.so") +if (NOT EXISTS "${SDRPP_CORE_LIB}") + message(FATAL_ERROR "libsdrpp_core.so not found at ${SDRPP_CORE_LIB}; build the SDR++ core first") +endif() + +find_package(OpenGL REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(UDEV REQUIRED IMPORTED_TARGET libudev) + +file(GLOB SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") + +add_library(${PROJECT_NAME} SHARED ${SRC}) + +target_include_directories(${PROJECT_NAME} PRIVATE + "${SDRPP_DIR}/core/src" + "${SDRPP_DIR}/core/src/imgui" + ${OPENGL_INCLUDE_DIRS} +) + +target_link_libraries(${PROJECT_NAME} PRIVATE + "${SDRPP_CORE_LIB}" + "${CIV_STATICLIB}" + PkgConfig::UDEV + pthread dl m +) + +target_compile_options(${PROJECT_NAME} PRIVATE -std=c++17 -O2 -fPIC) + +set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "") diff --git a/shim/src/main.cpp b/shim/src/main.cpp @@ -0,0 +1,171 @@ +#include <imgui.h> +#include <module.h> +#include <gui/gui.h> +#include <gui/tuner.h> +#include <signal_path/signal_path.h> +#include <utils/flog.h> + +#include <cstring> +#include <cstdint> +#include <mutex> +#include <string> + +extern "C" { + void* civ_create(const char* name); + void civ_destroy(void* ctx); + void civ_post_init(void* ctx); + void civ_enable(void* ctx); + void civ_disable(void* ctx); + bool civ_is_enabled(void* ctx); + void civ_status(void* ctx, char* out, size_t out_len); + void civ_set_serial_port(void* ctx, const char* port); + void civ_set_serial_baud(void* ctx, uint32_t baud); +} + +SDRPP_MOD_INFO{ + /* Name: */ "civ_sync", + /* Description: */ "Sync an Icom IC-7300 (CI-V) with an SDR++ VFO", + /* Author: */ "vfo-sync", + /* Version: */ 0, 1, 0, + /* Max instances */ 1 +}; + +// Guard the GUI-side state accessors (waterfall/VFO) called off-thread. +static std::mutex guiMtx; + +extern "C" void civ_bridge_tune(double freq) { + std::lock_guard<std::mutex> lck(guiMtx); + tuner::tune(tuner::TUNER_MODE_NORMAL, gui::waterfall.selectedVFO, freq); +} + +extern "C" double civ_bridge_get_vfo_freq() { + std::lock_guard<std::mutex> lck(guiMtx); + double freq = gui::waterfall.getCenterFrequency(); + if (sigpath::vfoManager.vfoExists(gui::waterfall.selectedVFO)) { + freq += sigpath::vfoManager.getOffset(gui::waterfall.selectedVFO); + } + return freq; +} + +extern "C" void civ_bridge_log(int level, const char* msg) { + if (msg == nullptr) { return; } + switch (level) { + case 0: flog::debug("{0}", msg); break; + case 1: flog::info("{0}", msg); break; + case 2: flog::warn("{0}", msg); break; + default: flog::error("{0}", msg); break; + } +} + +class CivInstance : public ModuleManager::Instance { +public: + CivInstance(std::string name) { + this->name = name; + strncpy(portBuf, "/dev/ttyUSB1", sizeof(portBuf) - 1); + rustCtx = civ_create(name.c_str()); + civ_set_serial_port(rustCtx, portBuf); + civ_set_serial_baud(rustCtx, baud); + gui::menu.registerEntry(name, menuHandler, this, this); + } + + ~CivInstance() { + gui::menu.removeEntry(name); + civ_destroy(rustCtx); + } + + void postInit() override { civ_post_init(rustCtx); } + void enable() override { civ_enable(rustCtx); } + void disable() override { civ_disable(rustCtx); } + bool isEnabled() override { return civ_is_enabled(rustCtx); } + +private: + static void menuHandler(void* ctx) { + CivInstance* _this = (CivInstance*)ctx; + + char statusBuf[256] = {0}; + civ_status(_this->rustCtx, statusBuf, sizeof(statusBuf)); + ImGui::TextUnformatted(statusBuf); + + if (ImGui::InputText("Serial device", _this->portBuf, sizeof(_this->portBuf))) { + civ_set_serial_port(_this->rustCtx, _this->portBuf); + } + + static const uint32_t baudRates[] = { 0, 4800, 9600, 19200, 38400, 57600, 115200 }; + static const char* baudNames[] = { "Auto", "4800", "9600", "19200", "38400", "57600", "115200" }; + static const int baudCount = 7; + + int selected = -1; + for (int i = 0; i < baudCount; i++) { + if (!_this->customBaudMode && _this->baud == baudRates[i]) { selected = i; break; } + } + + std::string preview; + if (_this->customBaudMode) { + preview = "Custom (" + std::to_string(_this->baud) + ")"; + } + else if (selected >= 0) { + preview = baudNames[selected]; + } + else { + preview = std::to_string(_this->baud); + } + + if (ImGui::BeginCombo("Baud rate", preview.c_str())) { + for (int i = 0; i < baudCount; i++) { + if (ImGui::Selectable(baudNames[i], !_this->customBaudMode && selected == i)) { + _this->customBaudMode = false; + _this->baud = baudRates[i]; + civ_set_serial_baud(_this->rustCtx, _this->baud); + } + } + if (ImGui::Selectable("Custom...", _this->customBaudMode)) { + _this->customBaudMode = true; + _this->customBaud = _this->baud > 0 ? (int)_this->baud : 115200; + _this->baud = (uint32_t)_this->customBaud; + civ_set_serial_baud(_this->rustCtx, _this->baud); + } + ImGui::EndCombo(); + } + + if (_this->customBaudMode) { + if (ImGui::InputInt("Custom baud", &_this->customBaud)) { + if (_this->customBaud > 0) { + _this->baud = (uint32_t)_this->customBaud; + civ_set_serial_baud(_this->rustCtx, _this->baud); + } + } + } + + if (ImGui::Button(_this->isEnabled() ? "Disconnect" : "Connect")) { + if (_this->isEnabled()) { + _this->disable(); + } + else { + _this->enable(); + } + } + } + + std::string name; + void* rustCtx = nullptr; + char portBuf[256] = {0}; + uint32_t baud = 0; + bool customBaudMode = false; + int customBaud = 115200; +}; + +MOD_EXPORT void _INIT_() { + // Nothing here +} + +MOD_EXPORT ModuleManager::Instance* _CREATE_INSTANCE_(std::string name) { + return new CivInstance(name); +} + +MOD_EXPORT void _DELETE_INSTANCE_(void* instance) { + delete (CivInstance*)instance; +} + +MOD_EXPORT void _END_() { + // Nothing here +} diff --git a/src/civ.rs b/src/civ.rs @@ -0,0 +1,335 @@ +//! CI-V framing, BCD encoding, and frame parsing for the IC-7300. + +use log::trace; + +/// Frequency in Hz. +pub type Freq = u32; + +/// Two frequencies within this many Hz are considered equal. +pub const TOL_HZ: i64 = 5; + +/// CI-V frame preamble / terminator. +const PREAMBLE: [u8; 2] = [0xfe, 0xfe]; +const TERMINATOR: u8 = 0xfd; + +/// IC-7300 radio address. +const RADIO_ADDR: u8 = 0x94; +/// Controller (this program) address. +const CONTROLLER_ADDR: u8 = 0xE1; + +/// "Sets the current operating frequency" (set-only, no echo). +const CMD_SET_FREQ: u8 = 0x05; +/// "Sets and outputs the current operating frequency" — what the radio +/// broadcasts when CI-V Transceive is enabled. +const CMD_BROADCAST_FREQ: u8 = 0x00; +/// "Reads the current operating frequency" — side-effect-free, used to probe +/// the radio during baud-rate auto-detection. +const CMD_READ_FREQ: u8 = 0x03; + +fn to_bcd(n: u8) -> u8 { + ((n / 10) * 16) + (n % 10) +} + +fn from_bcd(n: u8) -> u8 { + ((n / 16) * 10) + (n % 16) +} + +/// Encode a `0x05` set-frequency command. +/// +/// Frequency data is 10 BCD digits (5 bytes), least-significant pair first: +/// 1 Hz, 10 Hz, 100 Hz, 1 kHz, 10 kHz, 100 kHz, 1 MHz, 10 MHz, then the +/// 100 MHz and 1 GHz digits which are fixed at 0 for the IC-7300. +pub fn encode_set_freq(freq: Freq) -> Vec<u8> { + let ones_tens_hz = (freq % 100) as u8; + let hundreds_hz_khz = ((freq / 100) % 100) as u8; + let tens_hundreds_khz = ((freq / 10_000) % 100) as u8; + let ones_tens_mhz = ((freq / 1_000_000) % 100) as u8; + let frame = vec![ + PREAMBLE[0], + PREAMBLE[1], + RADIO_ADDR, + CONTROLLER_ADDR, + CMD_SET_FREQ, + to_bcd(ones_tens_hz), + to_bcd(hundreds_hz_khz), + to_bcd(tens_hundreds_khz), + to_bcd(ones_tens_mhz), + 0x00, // 100 MHz + 1 GHz digits (fixed 0) + TERMINATOR, + ]; + trace!("encoded set_freq {} Hz -> {:02x?}", freq, frame); + frame +} + +/// Enable CI-V Transceive (`1A 05 00 89 01`) so the radio pushes `0x00` +/// broadcasts on knob/mode changes. +pub fn encode_transceive_enable() -> Vec<u8> { + vec![ + PREAMBLE[0], + PREAMBLE[1], + RADIO_ADDR, + CONTROLLER_ADDR, + 0x1a, + 0x05, + 0x00, + 0x89, + 0x01, + TERMINATOR, + ] +} + +/// "Reads the current operating frequency" (`0x03`, no data). The radio +/// replies with a `0x03` frequency frame; used to probe the serial baud rate. +pub fn encode_read_freq() -> Vec<u8> { + vec![ + PREAMBLE[0], + PREAMBLE[1], + RADIO_ADDR, + CONTROLLER_ADDR, + CMD_READ_FREQ, + TERMINATOR, + ] +} + +#[derive(Debug, PartialEq, Eq)] +pub enum CivMsg { + Freq(Freq), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum CivMsgError { + InputTooShort, + InvalidStartOrEnd, + WrongAddress, + WrongCommand, +} + +impl TryFrom<&[u8]> for CivMsg { + type Error = CivMsgError; + + fn try_from(frame: &[u8]) -> Result<Self, Self::Error> { + if frame.len() < 5 { + return Err(CivMsgError::InputTooShort); + } + if frame[..2] != PREAMBLE || frame.last() != Some(&TERMINATOR) { + return Err(CivMsgError::InvalidStartOrEnd); + } + // Accept any frame *from* the radio (`to`/`from` = `* 94`). The + // destination (`frame[2]`) varies: `00` broadcast, `E0` the default + // controller address, or `E1` us. Echoes of our own commands have + // `to`/`from` = `94 E1` and are rejected here. + if frame[3] != RADIO_ADDR { + return Err(CivMsgError::WrongAddress); + } + // `0x00` (set-and-output / transceive broadcast) and `0x03` (read + // reply) both carry the same 5-byte BCD frequency payload. + if (frame[4] == CMD_BROADCAST_FREQ || frame[4] == CMD_READ_FREQ) && frame.len() == 11 { + let freq = from_bcd(frame[5]) as Freq + + from_bcd(frame[6]) as Freq * 100 + + from_bcd(frame[7]) as Freq * 10_000 + + from_bcd(frame[8]) as Freq * 1_000_000; + return Ok(CivMsg::Freq(freq)); + } + Err(CivMsgError::WrongCommand) + } +} + +/// Streaming CI-V frame splitter. Accumulates bytes across reads and yields +/// complete frames, tolerating frames that span reads and stray/garbage bytes. +pub struct Framer { + buf: Vec<u8>, +} + +impl Framer { + pub fn new() -> Self { + Framer { buf: Vec::new() } + } + + pub fn push(&mut self, data: &[u8]) -> Vec<Vec<u8>> { + self.buf.extend_from_slice(data); + + let mut frames = Vec::new(); + while let Some((start, end)) = next_frame(&self.buf) { + frames.push(self.buf[start..end].to_vec()); + self.buf.drain(..end); + } + + // Drop leading garbage when no frame start is present, keeping a + // possible trailing 0xfe that may be half of a split preamble. + if self.buf.len() >= 2 && !self.buf.windows(2).any(|w| w == PREAMBLE) { + let keep = usize::from(self.buf.last() == Some(&PREAMBLE[0])); + let drop = self.buf.len() - keep; + if drop > 0 { + self.buf.drain(..drop); + } + } + + // Resync if a frame never terminates. + if self.buf.len() > 4096 { + self.buf.clear(); + } + + frames + } +} + +fn next_frame(buf: &[u8]) -> Option<(usize, usize)> { + let start = buf.windows(2).position(|w| w == PREAMBLE)?; + let body = &buf[start + 2..]; + let end_rel = body.iter().position(|&b| b == TERMINATOR)?; + Some((start, start + 2 + end_rel + 1)) +} + +impl Default for Framer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bcd_roundtrip() { + for n in 0u8..=99 { + assert_eq!(from_bcd(to_bcd(n)), n); + } + } + + #[test] + fn set_freq_uses_command_05() { + let frame = encode_set_freq(7_100_000); + assert_eq!(frame.len(), 11); + assert_eq!(frame[..5], [0xfe, 0xfe, 0x94, 0xe1, 0x05]); + assert_eq!(frame[10], 0xfd); + } + + #[test] + fn read_freq_uses_command_03() { + let frame = encode_read_freq(); + assert_eq!(frame, [0xfe, 0xfe, 0x94, 0xe1, 0x03, 0xfd]); + } + + #[test] + fn encode_set_freq_matches_reference() { + for freq in [0, 1, 99, 7_100_000, 14_250_500, 99_999_999] { + let frame = encode_set_freq(freq); + // Decode our own 0x05 frame by re-reading the BCD digits. + let decoded = from_bcd(frame[5]) as u32 + + from_bcd(frame[6]) as u32 * 100 + + from_bcd(frame[7]) as u32 * 10_000 + + from_bcd(frame[8]) as u32 * 1_000_000; + assert_eq!(decoded, freq, "freq {}", freq); + } + } + + #[test] + fn parse_broadcast_freq() { + // 7.1 MHz broadcast from the radio: FE FE E1 94 00 <bcd> 00 FD + let frame = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Freq(7_100_000))); + } + + #[test] + fn parse_read_freq_reply() { + // 7.1 MHz reply to a 0x03 read: FE FE E1 94 03 <bcd> 00 FD + let frame = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x03, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Freq(7_100_000))); + } + + #[test] + fn parse_broadcast_to_default_controller() { + // Broadcast directed at 0xE0 (default controller): FE FE E0 94 00 ... + let frame = [ + 0xfe, 0xfe, 0xe0, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Freq(7_100_000))); + } + + #[test] + fn rejects_echo_of_our_command() { + // Our own 0x05 set-freq command echoed back: FE FE 94 E1 05 ... + let frame = [ + 0xfe, 0xfe, 0x94, 0xe1, 0x05, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + assert_eq!(CivMsg::try_from(&frame[..]), Err(CivMsgError::WrongAddress)); + } + + #[test] + fn parse_rejects_wrong_address_and_command() { + let freq_frame = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + let wrong_addr = [ + 0xfe, 0xfe, 0xe1, 0x55, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + let wrong_cmd = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x04, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + let short = [0xfe, 0xfe, 0xe1, 0x94]; + let bad_end = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0x00, + ]; + assert_eq!( + CivMsg::try_from(&freq_frame[..]), + Ok(CivMsg::Freq(7_100_000)) + ); + assert_eq!( + CivMsg::try_from(&wrong_addr[..]), + Err(CivMsgError::WrongAddress) + ); + assert_eq!( + CivMsg::try_from(&wrong_cmd[..]), + Err(CivMsgError::WrongCommand) + ); + assert_eq!( + CivMsg::try_from(&short[..]), + Err(CivMsgError::InputTooShort) + ); + assert_eq!( + CivMsg::try_from(&bad_end[..]), + Err(CivMsgError::InvalidStartOrEnd) + ); + } + + #[test] + fn framer_splits_across_reads() { + let frame = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + let mut f = Framer::new(); + assert!(f.push(&frame[..3]).is_empty()); + let out = f.push(&frame[3..]); + assert_eq!(out, vec![frame.to_vec()]); + } + + #[test] + fn framer_drops_garbage_and_yields_frames() { + let frame = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + let mut data = vec![0xaa, 0xbb]; + data.extend_from_slice(&frame); + data.extend_from_slice(&frame); + let out = Framer::new().push(&data); + assert_eq!(out, vec![frame.to_vec(), frame.to_vec()]); + } + + #[test] + fn framer_handles_lone_preamble_byte() { + let frame = [ + 0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + ]; + let mut f = Framer::new(); + assert!(f.push(&[0xfe]).is_empty()); + let mut rest = vec![0xfe]; + rest.extend_from_slice(&frame[2..]); + let out = f.push(&rest); + assert_eq!(out, vec![frame.to_vec()]); + } +} diff --git a/src/ic7300.rs b/src/ic7300.rs @@ -1,193 +0,0 @@ -use log::{debug, trace}; -use serialport::{DataBits::Eight, Parity::None, StopBits::One}; -use std::{ - sync::mpsc::{Receiver, Sender}, - time::{Duration, Instant}, -}; - -use crate::{Freq, Ptt, State}; - -enum CivCmd { - SetFreq(Freq), - SetPtt(Ptt), - ReadPtt, -} - -fn to_bcd(n: u8) -> u8 { - ((n / 10) * 16) + (n % 10) -} - -fn from_bcd(n: u8) -> u8 { - ((n / 16) * 10) + (n % 16) -} - -impl From<CivCmd> for Vec<u8> { - fn from(value: CivCmd) -> Self { - match value { - CivCmd::SetFreq(f) => { - let tens_ones_hz = (f % 100) as u8; - let thousands_hundreds_hz = ((f / 100) % 100) as u8; - let hundreds_tens_khz = ((f / 10_000) % 100) as u8; - let tens_ones_mhz = ((f / 1_000_000) % 100) as u8; - let bytes = vec![ - 0xfe, - 0xfe, - 0x94, - 0xe1, - 0x00, - to_bcd(tens_ones_hz), - to_bcd(thousands_hundreds_hz), - to_bcd(hundreds_tens_khz), - to_bcd(tens_ones_mhz), - 0x00, - 0xfd, - ]; - trace!("Frequency is {}, converted to {:x?}", f, bytes); - bytes - } - CivCmd::SetPtt(p) => { - let bytes = vec![ - 0xfe, - 0xfe, - 0x94, - 0xe1, - 0x1c, - 0x00, - if p { 0x01 } else { 0x00 }, - 0xfd, - ]; - trace!("PTT is {}, converted to {:x?}", p, bytes); - bytes - } - CivCmd::ReadPtt => vec![0xfe, 0xfe, 0x94, 0xe1, 0x1c, 0x00, 0xfd], - } - } -} - -#[derive(Debug)] -enum CivMsg { - Freq(Freq), - Ptt(Ptt), -} - -#[derive(Debug)] -enum CivMsgError { - InputTooShort, - InvalidStartOrEnd, - WrongAddress, - WrongCommand, -} - -impl TryFrom<&[u8]> for CivMsg { - type Error = CivMsgError; - fn try_from(value: &[u8]) -> Result<Self, Self::Error> { - if value.len() < 8 { - trace!("Input too short: {} < 8", value.len()); - return Err(CivMsgError::InputTooShort); - } - if value[..2] != [0xfe, 0xfe] || *value.last().unwrap() != 0xfd { - trace!( - "Invalid start or end: [0x{:x}, 0x{:x}, ... , 0x{:x}]", - value[0], - value[1], - value.last().unwrap() - ); - return Err(CivMsgError::InvalidStartOrEnd); - } - if value[2..4] != [0xe1, 0x94] && value[2..4] != [0x00, 0x94] { - trace!( - "Wrong address: [..., 0x{:x}, 0x{:x}, ...]", - value[2], - value[3] - ); - return Err(CivMsgError::WrongAddress); - } - if value[4] == 0x00 && value.len() == 11 { - let freq = from_bcd(value[5]) as u32 - + from_bcd(value[6]) as u32 * 100 - + from_bcd(value[7]) as u32 * 10_000 - + from_bcd(value[8]) as u32 * 1_000_000; - return Ok(CivMsg::Freq(freq)); - } - if value[4..6] == [0x1c, 0x00] && value.len() == 8 { - return Ok(CivMsg::Ptt(value[6] != 0x00)); - } - trace!("Command doesn't apply to us"); - Err(CivMsgError::WrongCommand) - } -} - -pub(crate) fn ic7300_handler(tx: Sender<State>, rx: Receiver<State>) { - let mut port = serialport::new("/dev/ttyUSB1", 115_200) - .data_bits(Eight) - .parity(None) - .stop_bits(One) - .open() - .unwrap(); - debug!("Serial port set up"); - let mut current_state = State::default(); - let mut read_buf = [0u8; 1024]; - let mut last_ptt_check = Instant::now(); - loop { - if let Some(new_state) = rx.try_iter().last() { - trace!("Got new state from main thread"); - if new_state.freq != current_state.freq { - let mut freq_bytes: Vec<u8> = CivCmd::SetFreq(new_state.freq).into(); - port.write_all(&mut freq_bytes).unwrap(); - debug!("Set new frequency to {}", new_state.freq); - } - if new_state.ptt != current_state.ptt { - let mut ptt_bytes: Vec<u8> = CivCmd::SetPtt(new_state.ptt).into(); - port.write_all(&mut ptt_bytes).unwrap(); - debug!("Set PTT to {}", new_state.ptt); - } - current_state = new_state; - } - if last_ptt_check.elapsed() > Duration::from_millis(100) { - let mut ptt_bytes: Vec<u8> = CivCmd::ReadPtt.into(); - port.write_all(&mut ptt_bytes).unwrap(); - last_ptt_check = Instant::now(); - trace!("Sent ReadPtt command"); - } - let mut new_state = current_state; - if port.bytes_to_read().unwrap() == 0 { - // trace!("No bytes to read, continuing"); - continue; - } - let n = match port.read(&mut read_buf) { - Ok(n) => n, - Err(_) => return, - }; - trace!("{:?}", read_buf[..n].to_vec()); - let mut idx = 0; - let mut start = Option::None; - let mut msgs: Vec<CivMsg> = vec![]; - while idx < n { - // trace!("{}", idx); - if read_buf[idx] == 0xfe && read_buf[idx + 1] == 0xfe { - start = Some(idx); - } else if read_buf[idx] == 0xfd { - let end = idx + 1; - match read_buf[start.unwrap()..end].try_into() { - Ok(msg) => { - debug!("Got new CivMsg: {:?}", msg); - msgs.push(msg); - } - Err(e) => trace!("Got a message that we don't care about: {:?}", e), - } - start = Option::None; - } - idx += 1; - } - for msg in msgs { - match msg { - CivMsg::Freq(f) => new_state.freq = f, - CivMsg::Ptt(p) => new_state.ptt = p, - } - } - if new_state != current_state { - tx.send(new_state).unwrap(); - debug!("Sent new state to main thread"); - } - } -} diff --git a/src/lib.rs b/src/lib.rs @@ -0,0 +1,202 @@ +#![allow(clippy::missing_safety_doc)] + +mod civ; +mod sync; + +use std::ffi::{c_char, CStr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Once}; +use std::thread::JoinHandle; + +use log::{debug, trace, Level, Log, Metadata, Record}; + +const DEFAULT_PORT: &str = "/dev/ttyUSB1"; + +extern "C" { + fn civ_bridge_tune(freq: f64); + fn civ_bridge_get_vfo_freq() -> f64; + fn civ_bridge_log(level: i32, msg: *const c_char); +} + +struct FlogLogger; + +impl Log for FlogLogger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + let level = match record.level() { + Level::Error => 3, + Level::Warn => 2, + Level::Info => 1, + Level::Debug | Level::Trace => 0, + }; + let msg = std::ffi::CString::new(format!("[civ_sync] {}", record.args())) + .unwrap_or_else(|_| std::ffi::CString::new("[civ_sync] <invalid utf8>").unwrap()); + unsafe { civ_bridge_log(level, msg.as_ptr()) }; + } + + fn flush(&self) {} +} + +static LOGGER: FlogLogger = FlogLogger; +static INIT: Once = Once::new(); + +fn init_logger() { + INIT.call_once(|| { + log::set_logger(&LOGGER).ok(); + log::set_max_level(log::LevelFilter::Trace); + }); +} + +pub struct CivCtx { + name: String, + port: String, + baud: u32, + enabled: bool, + status: Arc<Mutex<String>>, + stop: Arc<AtomicBool>, + handle: Option<JoinHandle<()>>, +} + +#[no_mangle] +pub unsafe extern "C" fn civ_create(name: *const c_char) -> *mut CivCtx { + init_logger(); + let name = if name.is_null() { + "<unnamed>".to_string() + } else { + unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() + }; + let ctx = CivCtx { + name: name.clone(), + port: DEFAULT_PORT.to_string(), + baud: 0, + enabled: false, + status: Arc::new(Mutex::new("Disconnected".to_string())), + stop: Arc::new(AtomicBool::new(false)), + handle: None, + }; + debug!("Rust core created (instance: {})", name); + Box::into_raw(Box::new(ctx)) +} + +#[no_mangle] +pub unsafe extern "C" fn civ_destroy(ctx: *mut CivCtx) { + if ctx.is_null() { + return; + } + let mut ctx = Box::from_raw(ctx); + if ctx.enabled { + stop_thread(&mut ctx); + } + debug!("Rust core destroyed (instance: {})", ctx.name); +} + +#[no_mangle] +pub unsafe extern "C" fn civ_post_init(ctx: *mut CivCtx) { + if ctx.is_null() { + return; + } + trace!("Rust core post-init (instance: {})", (*ctx).name); +} + +#[no_mangle] +pub unsafe extern "C" fn civ_enable(ctx: *mut CivCtx) { + if ctx.is_null() { + return; + } + let ctx = &mut *ctx; + if ctx.enabled { + return; + } + ctx.enabled = true; + ctx.stop.store(false, Ordering::Relaxed); + let port = ctx.port.clone(); + let baud = ctx.baud; + let stop = Arc::clone(&ctx.stop); + let status = Arc::clone(&ctx.status); + debug!("starting sync thread (instance: {})", ctx.name); + ctx.handle = Some(std::thread::spawn(move || { + sync::run(port, baud, stop, status); + })); +} + +#[no_mangle] +pub unsafe extern "C" fn civ_disable(ctx: *mut CivCtx) { + if ctx.is_null() { + return; + } + let ctx = &mut *ctx; + if ctx.enabled { + stop_thread(ctx); + } +} + +fn stop_thread(ctx: &mut CivCtx) { + ctx.enabled = false; + ctx.stop.store(true, Ordering::Relaxed); + if let Some(handle) = ctx.handle.take() { + let _ = handle.join(); + } + if let Ok(mut s) = ctx.status.lock() { + *s = "Disconnected".to_string(); + } +} + +#[no_mangle] +pub unsafe extern "C" fn civ_is_enabled(ctx: *mut CivCtx) -> bool { + if ctx.is_null() { + return false; + } + (*ctx).enabled +} + +#[no_mangle] +pub unsafe extern "C" fn civ_status(ctx: *mut CivCtx, out: *mut c_char, out_len: usize) { + if ctx.is_null() || out.is_null() || out_len == 0 { + return; + } + let text = { + let s = (*ctx).status.lock().unwrap(); + s.clone() + }; + let bytes = text.as_bytes(); + let n = bytes.len().min(out_len - 1); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out as *mut u8, n); + *out.add(n) = 0; +} + +#[no_mangle] +pub unsafe extern "C" fn civ_set_serial_port(ctx: *mut CivCtx, port: *const c_char) { + if ctx.is_null() { + return; + } + let port = if port.is_null() { + DEFAULT_PORT.to_string() + } else { + unsafe { CStr::from_ptr(port) } + .to_string_lossy() + .into_owned() + }; + debug!("serial port set to {}", port); + (*ctx).port = port; +} + +#[no_mangle] +pub unsafe extern "C" fn civ_set_serial_baud(ctx: *mut CivCtx, baud: u32) { + if ctx.is_null() { + return; + } + debug!( + "serial baud set to {}", + if baud == 0 { + "auto".to_string() + } else { + baud.to_string() + } + ); + (*ctx).baud = baud; +} diff --git a/src/main.rs b/src/main.rs @@ -1,73 +0,0 @@ -use std::{sync::mpsc, thread}; - -use ic7300::ic7300_handler; -use log::{debug, error, trace}; -use rspduo::rspduo_handler; - -mod ic7300; -mod rspduo; - -type Freq = u32; -type Ptt = bool; - -#[derive(Debug, Clone, Copy, PartialEq)] -struct State { - freq: Freq, - ptt: Ptt, -} - -impl Default for State { - fn default() -> Self { - State { - freq: 0, - ptt: false, - } - } -} - -fn main() { - env_logger::init(); - - // By convention, foo uses foo_tx_bar to transmit to bar and uses foo_rx_bar to receive from bar - let (main_tx_ic7300, ic7300_rx_main) = mpsc::channel(); - let (main_tx_rspduo, rspduo_rx_main) = mpsc::channel(); - let (ic7300_tx_main, main_rx_ic7300) = mpsc::channel(); - let (rspduo_tx_main, main_rx_rspduo) = mpsc::channel(); - debug!("Set up thread channels"); - - let mut current_state = State { - freq: 0, - ptt: false, - }; - - let ic7300 = thread::spawn(|| ic7300_handler(ic7300_tx_main, ic7300_rx_main)); - debug!("Spawned thread for IC-7300"); - let rspduo = thread::spawn(|| rspduo_handler(rspduo_tx_main, rspduo_rx_main)); - debug!("Spawned thread for RSPduo"); - - loop { - if ic7300.is_finished() || rspduo.is_finished() { - error!( - "A thread exited, closing... (IC-7300: {}, RSPduo: {})", - ic7300.is_finished(), - rspduo.is_finished() - ); - break; - } - if let Some(new_state) = main_rx_ic7300.try_iter().last() { - trace!("Got new state from IC-7300: {:?}", new_state); - if new_state != current_state { - main_tx_rspduo.send(new_state).unwrap(); - current_state = new_state; - debug!("Sent new state to RSPduo"); - } - } else if let Some(new_state) = main_rx_rspduo.try_iter().last() { - trace!("Got new state from RSPduo: {:?}", new_state); - if new_state != current_state { - main_tx_ic7300.send(new_state).unwrap(); - current_state = new_state; - debug!("Sent new state to IC-7300"); - } - } - } -} diff --git a/src/rspduo.rs b/src/rspduo.rs @@ -1,53 +0,0 @@ -use std::{ - io::{Read, Write}, - net::TcpStream, - sync::mpsc::{Receiver, Sender}, - time::{Duration, Instant}, -}; - -use log::{debug, trace}; - -use crate::State; - -pub(crate) fn rspduo_handler(tx: Sender<State>, rx: Receiver<State>) { - let mut stream = TcpStream::connect("127.0.0.1:4532").unwrap(); - stream - .set_read_timeout(Some(Duration::from_millis(100))) - .unwrap(); - debug!("Connected to rigctl server"); - let mut current_state = State::default(); - let mut read_buf = [0x00; 128]; - let mut last_freq_check = Instant::now(); - loop { - if let Some(new_state) = rx.try_iter().last() { - trace!("Got new state from main thread"); - if new_state.freq != current_state.freq { - let cmd = format!("F {}\n", new_state.freq); - let mut cmd_bytes = cmd.as_bytes(); - stream.write_all(&mut cmd_bytes).unwrap(); - stream.read(&mut read_buf).unwrap(); - debug!("Set new frequency to {}", new_state.freq); - } - current_state = new_state; - } - if last_freq_check.elapsed() > Duration::from_millis(100) { - let mut new_state = current_state; - let _ = stream.read(&mut read_buf); - let mut cmd_bytes = "f\n".as_bytes(); - stream.write_all(&mut cmd_bytes).unwrap(); - let n = stream.read(&mut read_buf).unwrap(); - let resp = String::from_utf8(read_buf[..n].to_vec()).unwrap(); - let resp = resp.trim(); - trace!("Received {:?} \"{}\"", read_buf[..n].to_vec(), resp); - let freq = resp.parse().unwrap(); - new_state.freq = freq; - trace!("Current frequency is {}", new_state.freq); - if new_state != current_state { - tx.send(new_state).unwrap(); - debug!("Sent new state to main thread"); - } - current_state = new_state; - last_freq_check = Instant::now(); - } - } -} diff --git a/src/sync.rs b/src/sync.rs @@ -0,0 +1,164 @@ +//! The background sync thread: reads CI-V frames from the IC-7300 and polls the +//! SDR++ VFO frequency, mirroring changes in both directions with dedup. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use log::{debug, error, trace}; +use serialport::{DataBits, Parity, StopBits}; + +use crate::civ::{self, CivMsg, Framer, Freq, TOL_HZ}; + +const DEFAULT_BAUD: u32 = 115_200; +const DETECT_TIMEOUT: Duration = Duration::from_millis(300); +const READ_TIMEOUT: Duration = Duration::from_millis(100); +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Candidate rates probed during auto-detection, most common first. Standard +/// Icom CI-V rates (19200/9600/4800) precede the USB-only high rates. +const BAUD_CANDIDATES: [u32; 6] = [19_200, 9_600, 4_800, 115_200, 38_400, 57_600]; + +pub fn run(port: String, baud: u32, stop: Arc<AtomicBool>, status: Arc<Mutex<String>>) { + set_status(&status, "Connecting..."); + + // 0 means "auto": probe the radio with a read-frequency command at each + // candidate rate and use the first that yields a valid CI-V frame. + let baud = if baud == 0 { + detect_baud(&port).unwrap_or(DEFAULT_BAUD) + } else { + baud + }; + + let mut serial = match open_port(&port, baud) { + Ok(serial) => serial, + Err(e) => { + set_status(&status, &format!("Serial error: {}", e)); + error!("failed to open serial port {} @ {}: {}", port, baud, e); + return; + } + }; + set_status(&status, &format!("Connected @ {} baud", baud)); + + // Make the radio broadcast frequency changes to us. + let transceive = civ::encode_transceive_enable(); + if let Err(e) = serial.write_all(&transceive) { + set_status(&status, &format!("Serial error: {}", e)); + error!("failed to enable CI-V transceive: {}", e); + return; + } + + // Adopt the SDR's current frequency as the baseline so we don't force a + // retune in either direction on connect. + let mut current = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq; + debug!("initial frequency baseline: {} Hz", current); + + let mut framer = Framer::new(); + let mut read_buf = [0u8; 1024]; + let mut last_poll = Instant::now(); + + while !stop.load(Ordering::Relaxed) { + // Radio -> SDR: parse incoming CI-V frames. + match serial.read(&mut read_buf) { + Ok(0) => {} + Ok(n) => { + trace!("read {} bytes: {:02x?}", n, &read_buf[..n]); + for frame in framer.push(&read_buf[..n]) { + match CivMsg::try_from(frame.as_slice()) { + Ok(CivMsg::Freq(freq)) => { + trace!("radio -> SDR: {} Hz", freq); + if freq_diff(freq, current) > TOL_HZ { + debug!("tuning SDR to {} Hz", freq); + unsafe { crate::civ_bridge_tune(freq as f64) }; + current = freq; + } + } + Err(e) => debug!("ignored CI-V frame ({:?}): {:02x?}", e, frame), + } + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + set_status(&status, &format!("Serial error: {}", e)); + error!("serial read failed: {}", e); + break; + } + } + + // SDR -> Radio: poll the VFO frequency every 100 ms. + if last_poll.elapsed() >= POLL_INTERVAL { + let sdr = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq; + if freq_diff(sdr, current) > TOL_HZ { + debug!("setting radio to {} Hz", sdr); + let frame = civ::encode_set_freq(sdr); + if let Err(e) = serial.write_all(&frame) { + set_status(&status, &format!("Serial error: {}", e)); + error!("serial write failed: {}", e); + break; + } + current = sdr; + } + last_poll = Instant::now(); + } + } + + set_status(&status, "Disconnected"); +} + +fn open_port(port: &str, baud: u32) -> Result<Box<dyn serialport::SerialPort>, serialport::Error> { + serialport::new(port, baud) + .data_bits(DataBits::Eight) + .parity(Parity::None) + .stop_bits(StopBits::One) + .timeout(READ_TIMEOUT) + .open() +} + +/// Try each candidate baud rate, sending a side-effect-free `0x03` read +/// command and watching for a parseable CI-V frame in reply. +fn detect_baud(port: &str) -> Option<u32> { + for &baud in &BAUD_CANDIDATES { + let mut serial = match open_port(port, baud) { + Ok(serial) => serial, + Err(_) => continue, + }; + if serial.write_all(&civ::encode_read_freq()).is_err() { + continue; + } + + let mut framer = Framer::new(); + let mut buf = [0u8; 256]; + let deadline = Instant::now() + DETECT_TIMEOUT; + let mut found = false; + while Instant::now() < deadline && !found { + match serial.read(&mut buf) { + Ok(0) => {} + Ok(n) => { + for frame in framer.push(&buf[..n]) { + if CivMsg::try_from(frame.as_slice()).is_ok() { + found = true; + } + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {} + Err(_) => break, + } + } + if found { + debug!("auto-detected serial baud: {}", baud); + return Some(baud); + } + debug!("no CI-V reply at {} baud", baud); + } + None +} + +fn freq_diff(a: Freq, b: Freq) -> i64 { + (a as i64 - b as i64).abs() +} + +fn set_status(status: &Arc<Mutex<String>>, text: &str) { + if let Ok(mut s) = status.lock() { + *s = text.to_string(); + } +}