[feat](trx-rs): add cw auto/manual controls

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-09 20:50:31 +01:00
parent dfc0f220e8
commit 0daf9e27ae
15 changed files with 249 additions and 16 deletions
+3
View File
@@ -274,6 +274,9 @@ async fn async_init() -> DynResult<AppState> {
server_longitude: None,
aprs_decode_enabled: false,
cw_decode_enabled: false,
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
};
+6
View File
@@ -148,6 +148,9 @@ fn map_rig_command(cmd: trx_core::RigCommand) -> ClientCommand {
trx_core::RigCommand::Unlock => ClientCommand::Unlock,
trx_core::RigCommand::SetAprsDecodeEnabled(enabled) => ClientCommand::SetAprsDecodeEnabled { enabled },
trx_core::RigCommand::SetCwDecodeEnabled(enabled) => ClientCommand::SetCwDecodeEnabled { enabled },
trx_core::RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled },
trx_core::RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
trx_core::RigCommand::SetCwToneHz(tone_hz) => ClientCommand::SetCwToneHz { tone_hz },
trx_core::RigCommand::ResetAprsDecoder => ClientCommand::ResetAprsDecoder,
trx_core::RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder,
}
@@ -190,6 +193,9 @@ pub fn state_from_snapshot(snapshot: trx_core::RigSnapshot) -> RigState {
server_longitude: snapshot.server_longitude,
aprs_decode_enabled: snapshot.aprs_decode_enabled,
cw_decode_enabled: snapshot.cw_decode_enabled,
cw_auto: snapshot.cw_auto,
cw_wpm: snapshot.cw_wpm,
cw_tone_hz: snapshot.cw_tone_hz,
aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
}
@@ -267,6 +267,25 @@ function render(update) {
pttBtn.style.color = "";
}
}
const cwAutoEl = document.getElementById("cw-auto");
const cwWpmEl = document.getElementById("cw-wpm");
const cwToneEl = document.getElementById("cw-tone");
if (cwAutoEl && typeof update.cw_auto === "boolean") {
cwAutoEl.checked = update.cw_auto;
}
if (cwWpmEl && typeof update.cw_wpm === "number") {
cwWpmEl.value = update.cw_wpm;
}
if (cwToneEl && typeof update.cw_tone_hz === "number") {
cwToneEl.value = update.cw_tone_hz;
}
if (cwWpmEl && cwToneEl && typeof update.cw_auto === "boolean") {
const disabled = update.cw_auto;
cwWpmEl.disabled = disabled;
cwWpmEl.readOnly = disabled;
cwToneEl.disabled = disabled;
cwToneEl.readOnly = disabled;
}
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
const entries = update.status.vfo.entries;
const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null;
@@ -157,9 +157,9 @@
<div id="cw-signal-indicator" class="cw-signal-off"></div>
</div>
<div class="cw-config">
<label class="cw-auto-label">Auto <input type="checkbox" id="cw-auto" checked disabled /></label>
<label>WPM <input type="number" id="cw-wpm" min="5" max="40" value="15" readonly /></label>
<label>Tone (Hz) <input type="number" id="cw-tone" min="300" max="1200" value="700" readonly /></label>
<label class="cw-auto-label">Auto <input type="checkbox" id="cw-auto" checked /></label>
<label>WPM <input type="number" id="cw-wpm" min="5" max="40" value="15" /></label>
<label>Tone (Hz) <input type="number" id="cw-tone" min="300" max="1200" value="700" /></label>
</div>
<div id="cw-output"></div>
</div>
@@ -1,11 +1,53 @@
// --- CW (Morse) Decoder Plugin (server-side decode) ---
const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto");
const cwWpmInput = document.getElementById("cw-wpm");
const cwToneInput = document.getElementById("cw-tone");
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const CW_MAX_LINES = 200;
function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) {
cwWpmInput.disabled = enabled;
cwWpmInput.readOnly = enabled;
}
if (cwToneInput) {
cwToneInput.disabled = enabled;
cwToneInput.readOnly = enabled;
}
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => {
const enabled = cwAutoInput.checked;
applyCwAutoUi(enabled);
try { await postPath(`/set_cw_auto?enabled=${enabled ? 1 : 0}`); }
catch (e) { console.error("CW auto toggle failed", e); }
});
}
if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return;
const wpm = Math.max(5, Math.min(40, Number(cwWpmInput.value)));
cwWpmInput.value = wpm;
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (e) { console.error("CW WPM set failed", e); }
});
}
if (cwToneInput) {
cwToneInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return;
const tone = Math.max(300, Math.min(1200, Number(cwToneInput.value)));
cwToneInput.value = tone;
try { await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(tone)}`); }
catch (e) { console.error("CW tone set failed", e); }
});
}
document.getElementById("cw-clear-btn").addEventListener("click", async () => {
cwOutputEl.innerHTML = "";
try { await postPath("/clear_cw_decode"); } catch (e) { console.error("CW clear failed", e); }
@@ -34,6 +76,8 @@ window.onServerCw = function(evt) {
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
cwWpmInput.value = evt.wpm;
cwToneInput.value = evt.tone_hz;
if (!cwAutoInput || cwAutoInput.checked) {
cwWpmInput.value = evt.wpm;
cwToneInput.value = evt.tone_hz;
}
};
@@ -232,7 +232,7 @@ small { color: var(--text-muted); }
.cw-line { line-height: 1.5; }
.cw-signal-on { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); flex-shrink: 0; }
.cw-signal-off { width: 10px; height: 10px; border-radius: 50%; background: var(--border-light); flex-shrink: 0; }
.cw-auto-label { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.82rem; color: var(--text-muted); cursor: pointer; }
.cw-config .cw-auto-label { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: var(--text-muted); cursor: pointer; flex-direction: row; }
.cw-auto-label input[type="checkbox"] { margin: 0; cursor: pointer; }
.cw-config input[type="number"][readonly] { opacity: 0.6; }
@@ -282,6 +282,45 @@ pub async fn toggle_cw_decode(
send_command(&rig_tx, RigCommand::SetCwDecodeEnabled(!enabled)).await
}
#[derive(serde::Deserialize)]
pub struct CwAutoQuery {
pub enabled: bool,
}
#[post("/set_cw_auto")]
pub async fn set_cw_auto(
query: web::Query<CwAutoQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCwAuto(query.enabled)).await
}
#[derive(serde::Deserialize)]
pub struct CwWpmQuery {
pub wpm: u32,
}
#[post("/set_cw_wpm")]
pub async fn set_cw_wpm(
query: web::Query<CwWpmQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCwWpm(query.wpm)).await
}
#[derive(serde::Deserialize)]
pub struct CwToneQuery {
pub tone_hz: u32,
}
#[post("/set_cw_tone")]
pub async fn set_cw_tone(
query: web::Query<CwToneQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCwToneHz(query.tone_hz)).await
}
#[post("/clear_aprs_decode")]
pub async fn clear_aprs_decode(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
@@ -311,6 +350,9 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_tx_limit)
.service(toggle_aprs_decode)
.service(toggle_cw_decode)
.service(set_cw_auto)
.service(set_cw_wpm)
.service(set_cw_tone)
.service(clear_aprs_decode)
.service(clear_cw_decode)
.service(crate::server::audio::audio_ws)
@@ -443,6 +485,9 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
server_longitude: state.server_longitude,
aprs_decode_enabled: state.aprs_decode_enabled,
cw_decode_enabled: state.cw_decode_enabled,
cw_auto: state.cw_auto,
cw_wpm: state.cw_wpm,
cw_tone_hz: state.cw_tone_hz,
})
}
+3
View File
@@ -23,6 +23,9 @@ pub enum ClientCommand {
SetTxLimit { limit: u8 },
SetAprsDecodeEnabled { enabled: bool },
SetCwDecodeEnabled { enabled: bool },
SetCwAuto { enabled: bool },
SetCwWpm { wpm: u32 },
SetCwToneHz { tone_hz: u32 },
ResetAprsDecoder,
ResetCwDecoder,
}
+3
View File
@@ -21,6 +21,9 @@ pub enum RigCommand {
Unlock,
SetAprsDecodeEnabled(bool),
SetCwDecodeEnabled(bool),
SetCwAuto(bool),
SetCwWpm(u32),
SetCwToneHz(u32),
ResetAprsDecoder,
ResetCwDecoder,
}
+15
View File
@@ -27,6 +27,12 @@ pub struct RigState {
pub aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool,
#[serde(default)]
pub cw_auto: bool,
#[serde(default)]
pub cw_wpm: u32,
#[serde(default)]
pub cw_tone_hz: u32,
#[serde(default, skip_serializing)]
pub aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
@@ -78,6 +84,9 @@ impl RigState {
server_longitude: self.server_longitude,
aprs_decode_enabled: self.aprs_decode_enabled,
cw_decode_enabled: self.cw_decode_enabled,
cw_auto: self.cw_auto,
cw_wpm: self.cw_wpm,
cw_tone_hz: self.cw_tone_hz,
})
}
@@ -124,4 +133,10 @@ pub struct RigSnapshot {
pub aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool,
#[serde(default)]
pub cw_auto: bool,
#[serde(default)]
pub cw_wpm: u32,
#[serde(default)]
pub cw_tone_hz: u32,
}
+42
View File
@@ -417,6 +417,12 @@ pub async fn run_cw_decoder(
let mut was_active = false;
let mut last_reset_seq: u64 = 0;
let mut active = matches!(state_rx.borrow().status.mode, RigMode::CW | RigMode::CWR);
let mut last_auto = state_rx.borrow().cw_auto;
let mut last_wpm = state_rx.borrow().cw_wpm;
let mut last_tone = state_rx.borrow().cw_tone_hz;
decoder.set_auto(last_auto);
decoder.set_wpm(last_wpm);
decoder.set_tone_hz(last_tone);
loop {
if !active {
@@ -427,6 +433,18 @@ pub async fn run_cw_decoder(
if active {
pcm_rx = pcm_rx.resubscribe();
}
if state.cw_auto != last_auto {
last_auto = state.cw_auto;
decoder.set_auto(last_auto);
}
if state.cw_wpm != last_wpm {
last_wpm = state.cw_wpm;
decoder.set_wpm(last_wpm);
}
if state.cw_tone_hz != last_tone {
last_tone = state.cw_tone_hz;
decoder.set_tone_hz(last_tone);
}
if state.cw_decode_reset_seq != last_reset_seq {
last_reset_seq = state.cw_decode_reset_seq;
decoder.reset();
@@ -443,6 +461,18 @@ pub async fn run_cw_decoder(
match recv {
Ok(frame) => {
let state = state_rx.borrow();
if state.cw_auto != last_auto {
last_auto = state.cw_auto;
decoder.set_auto(last_auto);
}
if state.cw_wpm != last_wpm {
last_wpm = state.cw_wpm;
decoder.set_wpm(last_wpm);
}
if state.cw_tone_hz != last_tone {
last_tone = state.cw_tone_hz;
decoder.set_tone_hz(last_tone);
}
if state.cw_decode_reset_seq != last_reset_seq {
last_reset_seq = state.cw_decode_reset_seq;
decoder.reset();
@@ -477,6 +507,18 @@ pub async fn run_cw_decoder(
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::CW | RigMode::CWR);
if state.cw_auto != last_auto {
last_auto = state.cw_auto;
decoder.set_auto(last_auto);
}
if state.cw_wpm != last_wpm {
last_wpm = state.cw_wpm;
decoder.set_wpm(last_wpm);
}
if state.cw_tone_hz != last_tone {
last_tone = state.cw_tone_hz;
decoder.set_tone_hz(last_tone);
}
if state.cw_decode_reset_seq != last_reset_seq {
last_reset_seq = state.cw_decode_reset_seq;
decoder.reset();
+39 -10
View File
@@ -124,6 +124,10 @@ pub struct CwDecoder {
// WPM
wpm: u32,
// Auto control
auto_tone: bool,
auto_wpm: bool,
// Auto tone detection
tone_scan_bins: Vec<ToneScanBin>,
tone_stable_bin: i32,
@@ -172,6 +176,8 @@ impl CwDecoder {
current_symbol: String::new(),
sample_counter: 0,
wpm: 15,
auto_tone: true,
auto_wpm: true,
tone_scan_bins,
tone_stable_bin: -1,
tone_stable_count: 0,
@@ -180,6 +186,20 @@ impl CwDecoder {
}
}
pub fn set_auto(&mut self, enabled: bool) {
self.auto_tone = enabled;
self.auto_wpm = enabled;
}
pub fn set_wpm(&mut self, wpm: u32) {
self.wpm = wpm.clamp(5, 40);
}
pub fn set_tone_hz(&mut self, tone_hz: u32) {
let tone_hz = tone_hz.clamp(TONE_SCAN_LOW, TONE_SCAN_HIGH);
self.recompute_goertzel(tone_hz);
}
fn recompute_goertzel(&mut self, new_freq: u32) {
self.tone_freq = new_freq;
let k = (new_freq as f32 * self.window_size as f32 / self.sample_rate as f32)
@@ -300,8 +320,9 @@ impl CwDecoder {
}
fn process_window(&mut self) {
// Auto tone detection
self.auto_detect_tone();
if self.auto_tone {
self.auto_detect_tone();
}
let detected = self.goertzel_detect();
let now = self.now_ms();
@@ -345,12 +366,14 @@ impl CwDecoder {
}
self.tone_off_at = now;
// Collect for auto WPM
self.on_durations.push(on_duration);
if self.on_durations.len() > 30 {
self.on_durations.remove(0);
if self.auto_wpm {
// Collect for auto WPM
self.on_durations.push(on_duration);
if self.on_durations.len() > 30 {
self.on_durations.remove(0);
}
self.auto_detect_wpm();
}
self.auto_detect_wpm();
}
// Flush pending character after long silence
@@ -387,6 +410,10 @@ impl CwDecoder {
}
pub fn reset(&mut self) {
let tone = self.tone_freq;
let wpm = self.wpm;
let auto_tone = self.auto_tone;
let auto_wpm = self.auto_wpm;
self.sample_buf.fill(0.0);
self.sample_idx = 0;
self.tone_on = false;
@@ -394,9 +421,11 @@ impl CwDecoder {
self.tone_off_at = 0.0;
self.current_symbol.clear();
self.sample_counter = 0;
self.wpm = 15;
self.tone_freq = 700;
self.recompute_goertzel(700);
self.wpm = wpm;
self.tone_freq = tone;
self.auto_tone = auto_tone;
self.auto_wpm = auto_wpm;
self.recompute_goertzel(tone);
self.tone_stable_bin = -1;
self.tone_stable_count = 0;
self.on_durations.clear();
+3
View File
@@ -190,6 +190,9 @@ fn map_command(cmd: ClientCommand) -> RigCommand {
ClientCommand::SetTxLimit { limit } => RigCommand::SetTxLimit(limit),
ClientCommand::SetAprsDecodeEnabled { enabled } => RigCommand::SetAprsDecodeEnabled(enabled),
ClientCommand::SetCwDecodeEnabled { enabled } => RigCommand::SetCwDecodeEnabled(enabled),
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
ClientCommand::SetCwToneHz { tone_hz } => RigCommand::SetCwToneHz(tone_hz),
ClientCommand::ResetAprsDecoder => RigCommand::ResetAprsDecoder,
ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder,
}
+3
View File
@@ -224,6 +224,9 @@ fn build_initial_state(cfg: &ServerConfig, resolved: &ResolvedConfig) -> RigStat
server_longitude: resolved.longitude,
aprs_decode_enabled: false,
cw_decode_enabled: false,
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
}
+18
View File
@@ -125,6 +125,9 @@ pub async fn run_rig_task(
server_longitude,
aprs_decode_enabled: false,
cw_decode_enabled: false,
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
};
@@ -360,6 +363,21 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetCwAuto(en) => {
ctx.state.cw_auto = en;
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetCwWpm(wpm) => {
ctx.state.cw_wpm = wpm.clamp(5, 40);
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetCwToneHz(tone_hz) => {
ctx.state.cw_tone_hz = tone_hz.clamp(300, 1200);
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::ResetAprsDecoder => {
audio::clear_aprs_history();
ctx.state.aprs_decode_reset_seq += 1;