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 | +++--- |
| M | Cargo.lock | | | 245 | ++++++++++++++----------------------------------------------------------------- |
| M | Cargo.toml | | | 6 | ++++-- |
| A | Makefile | | | 91 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | civ_module/Cargo.lock | | | 7 | ------- |
| D | civ_module/Cargo.toml | | | 9 | --------- |
| D | civ_module/Makefile | | | 72 | ------------------------------------------------------------------------ |
| D | civ_module/shim/CMakeLists.txt | | | 41 | ----------------------------------------- |
| D | civ_module/shim/src/main.cpp | | | 65 | ----------------------------------------------------------------- |
| D | civ_module/src/lib.rs | | | 81 | ------------------------------------------------------------------------------- |
| M | flake.nix | | | 48 | ++---------------------------------------------- |
| A | shim/CMakeLists.txt | | | 44 | ++++++++++++++++++++++++++++++++++++++++++++ |
| A | shim/src/main.cpp | | | 171 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/civ.rs | | | 335 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | src/ic7300.rs | | | 193 | ------------------------------------------------------------------------------- |
| A | src/lib.rs | | | 202 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | src/main.rs | | | 73 | ------------------------------------------------------------------------- |
| D | src/rspduo.rs | | | 53 | ----------------------------------------------------- |
| A | src/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();
+ }
+}