[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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user