From bbc53d56b021061c5e02ffde91874a5d390f86f2 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 11:58:09 +0200 Subject: [PATCH] feat: generate typed frontend API contracts --- Cargo.lock | 35 +++++ .../trx-frontend/trx-frontend-http/Cargo.toml | 1 + .../assets/web/generated/api-client.js | 71 +++++++++ .../examples/generate_typescript.rs | 64 ++++++++ .../trx-frontend-http/frontend/build.mjs | 1 + .../trx-frontend-http/frontend/package.json | 3 +- .../frontend/src/api/client.ts | 134 ++++++++++++++++ .../frontend/src/api/generated.ts | 148 ++++++++++++++++++ .../trx-frontend-http/src/api/mod.rs | 2 +- .../trx-frontend-http/src/api/rig.rs | 32 ++-- src/trx-core/Cargo.toml | 1 + src/trx-core/src/radio/freq.rs | 8 +- src/trx-core/src/rig/mod.rs | 18 ++- src/trx-core/src/rig/state.rs | 18 ++- src/trx-protocol/Cargo.toml | 1 + src/trx-protocol/src/decoders.rs | 5 +- 16 files changed, 504 insertions(+), 38 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts diff --git a/Cargo.lock b/Cargo.lock index 62da9099..7a157b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2645,6 +2645,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3137,6 +3146,7 @@ dependencies = [ "sgp4", "tokio", "tracing", + "ts-rs", "uuid", ] @@ -3195,6 +3205,7 @@ dependencies = [ "trx-core", "trx-frontend", "trx-protocol", + "ts-rs", "uuid", ] @@ -3238,6 +3249,7 @@ dependencies = [ "serde", "serde_json", "trx-core", + "ts-rs", ] [[package]] @@ -3333,6 +3345,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 498adad3..18a130d8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -29,3 +29,4 @@ hex = "0.4" pickledb = "0.5" dirs = "6" uuid = { workspace = true } +ts-rs = "12.0.1" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js new file mode 100644 index 00000000..4b57b3db --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js @@ -0,0 +1,71 @@ +"use strict"; +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + this.name = "ApiError"; + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +export function isRigSnapshot(value) { + if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) { + return false; + } + const { info, status } = value; + return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean"; +} +export function isRigListResponse(value) { + return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every( + (rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean" + ); +} +export function isDecoderRegistry(value) { + return Array.isArray(value) && value.every( + (decoder) => isRecord(decoder) && typeof decoder.id === "string" && typeof decoder.label === "string" && (decoder.activation === "mode_bound" || decoder.activation === "toggle") && Array.isArray(decoder.active_modes) && decoder.active_modes.every((mode) => typeof mode === "string") && typeof decoder.background_decode === "boolean" && typeof decoder.bookmark_selectable === "boolean" + ); +} +export class TrxApi { + constructor(baseUrl = "") { + this.baseUrl = baseUrl; + } + async get(path, validate) { + return this.request(path, { cache: "no-store" }, validate); + } + async post(path, body, validate) { + return this.request( + path, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }, + validate + ); + } + async request(path, init, validate) { + const response = await fetch(`${this.baseUrl}${path}`, init); + if (!response.ok) { + const detail = await response.text(); + throw new ApiError(response.status, detail || response.statusText); + } + const value = await response.json(); + if (!validate(value)) { + throw new ApiError(response.status, `Malformed response from ${path}`); + } + return value; + } +} +export function decodeServerEvent(event, validate) { + let value; + try { + value = JSON.parse(event.data); + } catch (error) { + throw new TypeError("Server event is not valid JSON", { cause: error }); + } + if (!validate(value)) { + throw new TypeError("Server event has an unexpected shape"); + } + return value; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs new file mode 100644 index 00000000..483efcb6 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use trx_core::radio::freq::{Band, Freq}; +use trx_core::rig::state::{SpectrumData, VchanRdsEntry}; +use trx_core::rig::{ + RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo, + RigVfoEntry, +}; +use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel}; +use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse}; +use trx_protocol::{DecoderActivation, DecoderDescriptor}; +use ts_rs::{Config, TS}; + +fn main() -> Result<(), Box> { + let mut output = String::from( + "// SPDX-FileCopyrightText: 2026 Stan Grams \n\ + //\n\ + // SPDX-License-Identifier: GPL-2.0-or-later\n\n\ + // Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\ + // Do not edit manually.\n\n", + ); + let config = Config::default(); + + macro_rules! export { + ($type:ty) => { + writeln!(output, "export {}\n", <$type as TS>::decl(&config))?; + }; + } + + export!(Band); + export!(Freq); + export!(RigMode); + export!(RigAccessMethod); + export!(RigCapabilities); + export!(RigInfo); + export!(RigVfoEntry); + export!(RigVfo); + export!(RigTxStatus); + export!(RigRxStatus); + export!(RigStatus); + export!(DecoderConfig); + export!(WfmDenoiseLevel); + export!(RigFilterState); + export!(RdsData); + export!(SpectrumData); + export!(VchanRdsEntry); + export!(RigSnapshot); + export!(RigListItem); + export!(RigListResponse); + export!(DecoderActivation); + export!(DecoderDescriptor); + + let output_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("frontend/src/api/generated.ts"); + fs::create_dir_all(output_path.parent().expect("generated file has a parent"))?; + fs::write(output_path, output)?; + Ok(()) +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index a686f502..0743fb67 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -15,6 +15,7 @@ await rm(outputDir, { recursive: true, force: true }); await build({ entryPoints: { + "api-client": path.join(sourceDir, "api", "client.ts"), app: path.join(sourceDir, "app.js"), "ui-core": path.join(sourceDir, "ui-core.js"), "map-core": path.join(sourceDir, "map-core.js"), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index d91233dd..45646d52 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -8,10 +8,11 @@ }, "scripts": { "build": "node build.mjs", + "generate-types": "cargo run -p trx-frontend-http --example generate_typescript", "typecheck": "tsc --project tsconfig.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "test": "node --test tests/*.test.mjs", - "verify-generated": "npm run build && git diff --exit-code -- ../assets/web/generated" + "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" }, "devDependencies": { "@eslint/js": "9.39.2", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts new file mode 100644 index 00000000..587fdc18 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { + DecoderDescriptor, + RigListResponse, + RigSnapshot, +} from "./generated"; + +export class ApiError extends Error { + public constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +type Validator = (value: unknown) => value is T; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isRigSnapshot(value: unknown): value is RigSnapshot { + if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) { + return false; + } + const { info, status } = value; + return ( + typeof value.initialized === "boolean" && + typeof info.manufacturer === "string" && + typeof info.model === "string" && + isRecord(status.freq) && + typeof status.freq.hz === "number" && + (typeof status.mode === "string" || isRecord(status.mode)) && + typeof status.tx_en === "boolean" + ); +} + +export function isRigListResponse(value: unknown): value is RigListResponse { + return ( + isRecord(value) && + (value.active_remote === null || typeof value.active_remote === "string") && + Array.isArray(value.rigs) && + value.rigs.every( + (rig) => + isRecord(rig) && + typeof rig.remote === "string" && + typeof rig.manufacturer === "string" && + typeof rig.model === "string" && + Array.isArray(rig.supported_modes) && + typeof rig.tx === "boolean" && + typeof rig.filter_controls === "boolean" && + typeof rig.initialized === "boolean", + ) + ); +} + +export function isDecoderRegistry(value: unknown): value is DecoderDescriptor[] { + return ( + Array.isArray(value) && + value.every( + (decoder) => + isRecord(decoder) && + typeof decoder.id === "string" && + typeof decoder.label === "string" && + (decoder.activation === "mode_bound" || decoder.activation === "toggle") && + Array.isArray(decoder.active_modes) && + decoder.active_modes.every((mode) => typeof mode === "string") && + typeof decoder.background_decode === "boolean" && + typeof decoder.bookmark_selectable === "boolean", + ) + ); +} + +export class TrxApi { + public constructor(private readonly baseUrl = "") {} + + public async get(path: string, validate: Validator): Promise { + return this.request(path, { cache: "no-store" }, validate); + } + + public async post( + path: string, + body: unknown, + validate: Validator, + ): Promise { + return this.request( + path, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + validate, + ); + } + + private async request( + path: string, + init: RequestInit, + validate: Validator, + ): Promise { + const response = await fetch(`${this.baseUrl}${path}`, init); + if (!response.ok) { + const detail = await response.text(); + throw new ApiError(response.status, detail || response.statusText); + } + const value: unknown = await response.json(); + if (!validate(value)) { + throw new ApiError(response.status, `Malformed response from ${path}`); + } + return value; + } +} + +export function decodeServerEvent( + event: MessageEvent, + validate: Validator, +): T { + let value: unknown; + try { + value = JSON.parse(event.data) as unknown; + } catch (error: unknown) { + throw new TypeError("Server event is not valid JSON", { cause: error }); + } + if (!validate(value)) { + throw new TypeError("Server event has an unexpected shape"); + } + return value; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts new file mode 100644 index 00000000..51ee7a65 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// Generated by `cargo run -p trx-frontend-http --example generate_typescript`. +// Do not edit manually. + +export type Band = { low_hz: number, high_hz: number, tx_allowed: boolean, }; + +export type Freq = { hz: number, }; + +export type RigMode = "LSB" | "USB" | "CW" | "CWR" | "AM" | "SAM" | "WFM" | "FM" | "AIS" | "VDES" | "DIG" | "PKT" | { "Other": string }; + +export type RigAccessMethod = { "Serial": { path: string, baud: number, } } | { "Tcp": { addr: string, } }; + +export type RigCapabilities = { min_freq_step_hz: number, supported_bands: Array, supported_modes: Array, num_vfos: number, lock: boolean, lockable: boolean, attenuator: boolean, preamp: boolean, rit: boolean, rpt: boolean, split: boolean, +/** + * Backend supports transmit: PTT, power on/off, TX meters, TX audio. + */ +tx: boolean, +/** + * Backend supports get_tx_limit / set_tx_limit. + */ +tx_limit: boolean, +/** + * Backend supports toggle_vfo. + */ +vfo_switch: boolean, +/** + * Backend supports runtime filter adjustment (bandwidth). + */ +filter_controls: boolean, +/** + * Backend returns a meaningful RX signal strength value. + */ +signal_meter: boolean, }; + +export type RigInfo = { manufacturer: string, model: string, revision: string, capabilities: RigCapabilities, access: RigAccessMethod, }; + +export type RigVfoEntry = { name: string, freq: Freq, mode: RigMode | null, }; + +export type RigVfo = { entries: Array, +/** + * Index into `entries` for the active VFO, if known. + */ +active: number | null, }; + +export type RigTxStatus = { power: number | null, limit: number | null, swr: number | null, alc: number | null, }; + +export type RigRxStatus = { sig: number | null, }; + +export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, }; + +export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, }; + +export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high"; + +export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel, +/** + * Co-Channel Interference level (0–100 scale). + */ +wfm_cci: number, +/** + * Adjacent Channel Interference level (0–100 scale). + */ +wfm_aci: number, +/** + * SAM stereo width (0.0 = mono, 1.0 = full stereo). + */ +sam_stereo_width: number, +/** + * SAM carrier synchronization enabled. + */ +sam_carrier_sync: boolean, }; + +export type RdsData = { pi?: number | null, program_service?: string | null, radio_text?: string | null, program_type_name_long?: string | null, pty?: number | null, pty_name?: string | null, traffic_program?: boolean | null, traffic_announcement?: boolean | null, music?: boolean | null, stereo?: boolean | null, artificial_head?: boolean | null, compressed?: boolean | null, dynamic_pty?: boolean | null, alternative_frequencies_hz?: Array | null, }; + +export type SpectrumData = { +/** + * FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2. + */ +bins: Array, +/** + * Centre frequency of the SDR capture in Hz. + */ +center_hz: number, +/** + * SDR capture sample rate in Hz; the displayed span is ±sample_rate/2. + */ +sample_rate: number, +/** + * Decoded Radio Data System state, when available for WFM. + */ +rds?: RdsData | null, }; + +export type VchanRdsEntry = { +/** + * Virtual channel UUID. + */ +id: string, +/** + * Latest RDS data, if decoded. + */ +rds?: RdsData | null, +/** + * Channel signal level in dBFS. + */ +signal_db?: number | null, }; + +export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | null, enabled: boolean | null, initialized: boolean, server_callsign?: string | null, server_version?: string | null, server_build_date?: string | null, server_latitude?: number | null, server_longitude?: number | null, pskreporter_status?: string | null, aprs_is_status?: string | null, cw_auto: boolean, cw_wpm: number, cw_tone_hz: number, filter?: RigFilterState | null, spectrum?: SpectrumData | null, +/** + * Per-virtual-channel RDS snapshots, when available. + */ +vchan_rds?: Array | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, }; + +export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, }; + +export type RigListResponse = { active_remote: string | null, rigs: Array, }; + +export type DecoderActivation = "mode_bound" | "toggle"; + +export type DecoderDescriptor = { +/** + * Machine identifier, e.g. `"ft8"`, `"aprs"`. + */ +id: string, +/** + * Human-readable label, e.g. `"FT8"`, `"APRS"`. + */ +label: string, +/** + * How the decoder is activated. + */ +activation: DecoderActivation, +/** + * Rig modes where this decoder operates (upper-case). + */ +active_modes: Array, +/** + * Whether the decoder can run on SDR virtual channels + * (background-decode / scheduler). + */ +background_decode: boolean, +/** + * Whether this decoder should appear in bookmark forms. + */ +bookmark_selectable: boolean, }; + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs index 1edb7c8d..81019b68 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs @@ -8,7 +8,7 @@ mod assets; mod bookmarks; mod decoder; pub mod recorder; -mod rig; +pub mod rig; mod sse; mod vchan; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs index f2834458..e681ef2c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs @@ -382,26 +382,26 @@ pub async fn set_sam_carrier_sync( // Rig list / selection // ============================================================================ -#[derive(serde::Serialize)] -struct RigListItem { - remote: String, - display_name: Option, - manufacturer: String, - model: String, - supported_modes: Vec, - tx: bool, - filter_controls: bool, - initialized: bool, +#[derive(serde::Serialize, ts_rs::TS)] +pub struct RigListItem { + pub remote: String, + pub display_name: Option, + pub manufacturer: String, + pub model: String, + pub supported_modes: Vec, + pub tx: bool, + pub filter_controls: bool, + pub initialized: bool, #[serde(skip_serializing_if = "Option::is_none")] - latitude: Option, + pub latitude: Option, #[serde(skip_serializing_if = "Option::is_none")] - longitude: Option, + pub longitude: Option, } -#[derive(serde::Serialize)] -struct RigListResponse { - active_remote: Option, - rigs: Vec, +#[derive(serde::Serialize, ts_rs::TS)] +pub struct RigListResponse { + pub active_remote: Option, + pub rigs: Vec, } fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse { diff --git a/src/trx-core/Cargo.toml b/src/trx-core/Cargo.toml index 5c36dfaa..fab3ce3d 100644 --- a/src/trx-core/Cargo.toml +++ b/src/trx-core/Cargo.toml @@ -14,5 +14,6 @@ serde_json = { workspace = true } tracing = { workspace = true } flate2 = { workspace = true } uuid = { workspace = true } +ts-rs = { version = "12.0.1", features = ["uuid-impl"] } sgp4 = "2" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/src/trx-core/src/radio/freq.rs b/src/trx-core/src/radio/freq.rs index adf2c4e3..e8d7ffb0 100644 --- a/src/trx-core/src/radio/freq.rs +++ b/src/trx-core/src/radio/freq.rs @@ -3,13 +3,16 @@ // SPDX-License-Identifier: GPL-2.0-or-later use serde::{Deserialize, Serialize}; +use ts_rs::TS; const SPEED_OF_LIGHT_M_PER_S: f64 = 299_792_458.0; /// Supported band range in Hz. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct Band { + #[ts(type = "number")] pub low_hz: u64, + #[ts(type = "number")] pub high_hz: u64, pub tx_allowed: bool, } @@ -23,8 +26,9 @@ impl Band { } /// Frequency wrapper (Hz). -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct Freq { + #[ts(type = "number")] pub hz: u64, } diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs index 9fafd5d0..0a6cec44 100644 --- a/src/trx-core/src/rig/mod.rs +++ b/src/trx-core/src/rig/mod.rs @@ -6,6 +6,7 @@ use std::future::Future; use std::pin::Pin; use serde::{Deserialize, Serialize}; +use ts_rs::TS; use crate::radio::freq::{Band, Freq}; use crate::{DynResult, RigMode}; @@ -21,14 +22,14 @@ pub mod response; pub mod state; /// How this backend communicates with the rig. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub enum RigAccessMethod { Serial { path: String, baud: u32 }, Tcp { addr: String }, } /// Static info describing a rig backend. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigInfo { pub manufacturer: String, pub model: String, @@ -37,9 +38,10 @@ pub struct RigInfo { pub access: RigAccessMethod, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct RigCapabilities { #[serde(default = "default_min_freq_step_hz")] + #[ts(type = "number")] pub min_freq_step_hz: u64, pub supported_bands: Vec, pub supported_modes: Vec, @@ -314,7 +316,7 @@ pub trait RigSdr: Send { } /// Snapshot of a rig's status that every backend can expose. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigStatus { pub freq: Freq, pub mode: RigMode, @@ -330,21 +332,21 @@ pub trait RigStatusProvider { fn status(&self) -> RigStatus; } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigVfo { pub entries: Vec, /// Index into `entries` for the active VFO, if known. pub active: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigVfoEntry { pub name: String, pub freq: Freq, pub mode: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigTxStatus { pub power: Option, pub limit: Option, @@ -352,7 +354,7 @@ pub struct RigTxStatus { pub alc: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigRxStatus { pub sig: Option, } diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index e012e6ea..b1167d55 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later use serde::{Deserialize, Serialize}; +use ts_rs::TS; use uuid::Uuid; use crate::radio::freq::Freq; @@ -12,7 +13,7 @@ use crate::rig::{RigControl, RigInfo, RigRxStatus, RigStatus, RigStatusProvider, /// /// Flattened into `RigState` and `RigSnapshot` so the JSON wire format is /// unchanged (backward compatible with existing clients). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TS)] pub struct DecoderConfig { #[serde(default)] pub aprs_decode_enabled: bool, @@ -113,7 +114,7 @@ pub struct RigState { } /// Mode supported by the rig. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub enum RigMode { LSB, USB, @@ -315,7 +316,7 @@ impl RigState { } /// Current filter/DSP state for backends that support runtime filter adjustment. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigFilterState { pub bandwidth_hz: u32, pub cw_center_hz: u32, @@ -355,7 +356,7 @@ pub struct RigFilterState { pub sam_carrier_sync: bool, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] #[serde(rename_all = "lowercase")] pub enum WfmDenoiseLevel { Off, @@ -386,11 +387,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel { } /// Spectrum data from SDR backends (FFT magnitude over the full capture bandwidth). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct SpectrumData { /// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2. pub bins: Vec, /// Centre frequency of the SDR capture in Hz. + #[ts(type = "number")] pub center_hz: u64, /// SDR capture sample rate in Hz; the displayed span is ±sample_rate/2. pub sample_rate: u32, @@ -400,7 +402,7 @@ pub struct SpectrumData { } /// Live RDS metadata decoded from a WFM broadcast. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, TS)] pub struct RdsData { #[serde(default, skip_serializing_if = "Option::is_none")] pub pi: Option, @@ -437,7 +439,7 @@ pub struct RdsData { /// `PartialEq` intentionally ignores `signal_db` so that rapidly-changing /// signal levels do not cause the main state snapshot to diff on every poll /// cycle (signal_db flows through the spectrum SSE instead). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct VchanRdsEntry { /// Virtual channel UUID. pub id: Uuid, @@ -456,7 +458,7 @@ impl PartialEq for VchanRdsEntry { } /// Read-only projection of state shared with clients. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RigSnapshot { pub info: RigInfo, pub status: RigStatus, diff --git a/src/trx-protocol/Cargo.toml b/src/trx-protocol/Cargo.toml index 131c2461..533d877b 100644 --- a/src/trx-protocol/Cargo.toml +++ b/src/trx-protocol/Cargo.toml @@ -15,3 +15,4 @@ ft2 = [] serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } trx-core = { path = "../trx-core" } +ts-rs = "12.0.1" diff --git a/src/trx-protocol/src/decoders.rs b/src/trx-protocol/src/decoders.rs index dc3dc77a..26a951e9 100644 --- a/src/trx-protocol/src/decoders.rs +++ b/src/trx-protocol/src/decoders.rs @@ -9,13 +9,14 @@ //! their decoder knowledge from [`DECODER_REGISTRY`]. use serde::Serialize; +use ts_rs::TS; // ============================================================================ // Types // ============================================================================ /// How a decoder is activated. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "snake_case")] pub enum DecoderActivation { /// Automatically active when the rig mode matches. @@ -25,7 +26,7 @@ pub enum DecoderActivation { } /// Static descriptor for a single decoder. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, TS)] pub struct DecoderDescriptor { /// Machine identifier, e.g. `"ft8"`, `"aprs"`. pub id: &'static str,