[feat](trx-rs): add ft8 decoder

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-09 21:19:56 +01:00
parent 7bd1a70607
commit 1199ab85e9
206 changed files with 9613 additions and 5 deletions
+3 -2
View File
@@ -16,7 +16,8 @@ use tracing::{info, warn};
use trx_core::audio::{
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_APRS_DECODE,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO,
AUDIO_MSG_TX_FRAME,
};
use trx_core::decode::DecodedMessage;
@@ -88,7 +89,7 @@ async fn handle_audio_connection(
Ok((AUDIO_MSG_RX_FRAME, payload)) => {
let _ = rx_tx.send(Bytes::from(payload));
}
Ok((AUDIO_MSG_APRS_DECODE | AUDIO_MSG_CW_DECODE, payload)) => {
Ok((AUDIO_MSG_APRS_DECODE | AUDIO_MSG_CW_DECODE | AUDIO_MSG_FT8_DECODE, payload)) => {
if let Ok(msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
let _ = decode_tx.send(msg);
}
+2
View File
@@ -277,8 +277,10 @@ async fn async_init() -> DynResult<AppState> {
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
ft8_decode_enabled: false,
aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0,
};
let (state_tx, state_rx) = watch::channel(initial_state);
+4
View File
@@ -151,8 +151,10 @@ fn map_rig_command(cmd: trx_core::RigCommand) -> ClientCommand {
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::SetFt8DecodeEnabled(enabled) => ClientCommand::SetFt8DecodeEnabled { enabled },
trx_core::RigCommand::ResetAprsDecoder => ClientCommand::ResetAprsDecoder,
trx_core::RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder,
trx_core::RigCommand::ResetFt8Decoder => ClientCommand::ResetFt8Decoder,
}
}
@@ -196,8 +198,10 @@ pub fn state_from_snapshot(snapshot: trx_core::RigSnapshot) -> RigState {
cw_auto: snapshot.cw_auto,
cw_wpm: snapshot.cw_wpm,
cw_tone_hz: snapshot.cw_tone_hz,
ft8_decode_enabled: snapshot.ft8_decode_enabled,
aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0,
}
}
@@ -145,8 +145,10 @@ async fn handle_client(
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
ClientCommand::SetCwToneHz { tone_hz } => RigCommand::SetCwToneHz(tone_hz),
ClientCommand::SetFt8DecodeEnabled { enabled } => RigCommand::SetFt8DecodeEnabled(enabled),
ClientCommand::ResetAprsDecoder => RigCommand::ResetAprsDecoder,
ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder,
ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder,
};
let (resp_tx, resp_rx) = oneshot::channel();
@@ -248,12 +248,17 @@ function render(update) {
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
const aprsStatus = document.getElementById("aprs-status");
const cwStatus = document.getElementById("cw-status");
const ft8Status = document.getElementById("ft8-status");
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
aprsStatus.textContent = "Connected, listening for packets";
}
if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") {
cwStatus.textContent = "Connected, listening for packets";
}
const ft8Enabled = !!update.ft8_decode_enabled;
if (ft8Status && (!ft8Enabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && ft8Status.textContent === "Receiving") {
ft8Status.textContent = "Connected, listening for packets";
}
if (update.status && typeof update.status.tx_en === "boolean") {
lastTxEn = update.status.tx_en;
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
@@ -267,6 +272,13 @@ function render(update) {
pttBtn.style.color = "";
}
}
const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn");
if (ft8ToggleBtn) {
const ft8On = !!update.ft8_decode_enabled;
ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8";
ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : "";
ft8ToggleBtn.style.color = ft8On ? "#00d17f" : "";
}
const cwAutoEl = document.getElementById("cw-auto");
const cwWpmEl = document.getElementById("cw-wpm");
const cwToneEl = document.getElementById("cw-tone");
@@ -1156,8 +1168,10 @@ let decodeConnected = false;
function updateDecodeStatus(text) {
const aprs = document.getElementById("aprs-status");
const cw = document.getElementById("cw-status");
const ft8 = document.getElementById("ft8-status");
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
}
function connectDecode() {
if (decodeSource) { decodeSource.close(); }
@@ -1171,6 +1185,7 @@ function connectDecode() {
const msg = JSON.parse(evt.data);
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
} catch (e) {
// ignore parse errors
}
@@ -124,6 +124,7 @@
<button class="sub-tab active" data-subtab="overview">Overview</button>
<button class="sub-tab" data-subtab="map">Map</button>
<button class="sub-tab" data-subtab="aprs">APRS</button>
<button class="sub-tab" data-subtab="ft8">FT8</button>
<button class="sub-tab" data-subtab="cw">CW</button>
</div>
<div id="subtab-overview" class="sub-tab-panel">
@@ -150,6 +151,14 @@
</div>
<div id="aprs-packets"></div>
</div>
<div id="subtab-ft8" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="ft8-decode-toggle-btn" type="button">Enable FT8</button>
<button id="ft8-clear-btn" type="button">Clear</button>
<small id="ft8-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div id="ft8-messages"></div>
</div>
<div id="subtab-cw" class="sub-tab-panel" style="display:none;">
<div class="cw-controls">
<button id="cw-clear-btn" type="button">Clear</button>
@@ -184,6 +193,7 @@
</div>
<script src="/app.js"></script>
<script src="/aprs.js"></script>
<script src="/ft8.js"></script>
<script src="/cw.js"></script>
</body>
</html>
@@ -0,0 +1,47 @@
// --- FT8 Decoder Plugin (server-side decode) ---
const ft8Status = document.getElementById("ft8-status");
const ft8MessagesEl = document.getElementById("ft8-messages");
const FT8_MAX_MESSAGES = 200;
function fmtTime(tsMs) {
if (!tsMs) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function renderFt8Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const freq = Number.isFinite(msg.freq_hz) ? msg.freq_hz.toFixed(0) : "--";
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${msg.message || ""}</span>`;
return row;
}
function addFt8Message(msg) {
ft8MessagesEl.prepend(renderFt8Row(msg));
while (ft8MessagesEl.children.length > FT8_MAX_MESSAGES) {
ft8MessagesEl.removeChild(ft8MessagesEl.lastChild);
}
}
document.getElementById("ft8-decode-toggle-btn").addEventListener("click", async () => {
try { await postPath("/toggle_ft8_decode"); } catch (e) { console.error("FT8 toggle failed", e); }
});
document.getElementById("ft8-clear-btn").addEventListener("click", async () => {
ft8MessagesEl.innerHTML = "";
try { await postPath("/clear_ft8_decode"); } catch (e) { console.error("FT8 clear failed", e); }
});
// --- Server-side FT8 decode handler ---
window.onServerFt8 = function(msg) {
ft8Status.textContent = "Receiving";
addFt8Message({
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
dt_s: msg.dt_s,
freq_hz: msg.freq_hz,
message: msg.message,
});
};
@@ -223,6 +223,15 @@ small { color: var(--text-muted); }
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
.aprs-pos:hover { text-decoration: underline; }
.aprs-byte { color: var(--accent-yellow); background: rgba(255, 214, 0, 0.12); border: 1px solid rgba(255, 214, 0, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-size: 0.78em; }
.ft8-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
#ft8-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.35rem 0.5rem; }
.ft8-row { display: flex; gap: 0.6rem; line-height: 1.4; border-bottom: 1px solid var(--border); padding: 0.25rem 0; }
.ft8-row:last-child { border-bottom: none; }
.ft8-time { color: var(--text-muted); min-width: 4.6rem; }
.ft8-snr { color: var(--accent-yellow); min-width: 3.6rem; text-align: right; }
.ft8-dt { color: var(--text-muted); min-width: 3.6rem; text-align: right; }
.ft8-freq { color: var(--accent-green); min-width: 4.6rem; text-align: right; }
.ft8-msg { flex: 1; }
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
.cw-config { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
@@ -321,6 +321,22 @@ pub async fn set_cw_tone(
send_command(&rig_tx, RigCommand::SetCwToneHz(query.tone_hz)).await
}
#[post("/toggle_ft8_decode")]
pub async fn toggle_ft8_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().ft8_decode_enabled;
send_command(&rig_tx, RigCommand::SetFt8DecodeEnabled(!enabled)).await
}
#[post("/clear_ft8_decode")]
pub async fn clear_ft8_decode(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::ResetFt8Decoder).await
}
#[post("/clear_aprs_decode")]
pub async fn clear_aprs_decode(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
@@ -353,14 +369,17 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_cw_auto)
.service(set_cw_wpm)
.service(set_cw_tone)
.service(toggle_ft8_decode)
.service(clear_aprs_decode)
.service(clear_cw_decode)
.service(clear_ft8_decode)
.service(crate::server::audio::audio_ws)
.service(favicon)
.service(logo)
.service(style_css)
.service(app_js)
.service(aprs_js)
.service(ft8_js)
.service(cw_js);
}
@@ -406,6 +425,13 @@ async fn aprs_js() -> impl Responder {
.body(status::APRS_JS)
}
#[get("/ft8.js")]
async fn ft8_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
.body(status::FT8_JS)
}
#[get("/cw.js")]
async fn cw_js() -> impl Responder {
HttpResponse::Ok()
@@ -488,6 +514,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
cw_auto: state.cw_auto,
cw_wpm: state.cw_wpm,
cw_tone_hz: state.cw_tone_hz,
ft8_decode_enabled: state.ft8_decode_enabled,
})
}
@@ -9,6 +9,7 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
pub fn index_html() -> String {