feat: generate typed frontend API contracts
This commit is contained in:
Generated
+35
@@ -2645,6 +2645,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3137,6 +3146,7 @@ dependencies = [
|
|||||||
"sgp4",
|
"sgp4",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3195,6 +3205,7 @@ dependencies = [
|
|||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3238,6 +3249,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
|
"ts-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3333,6 +3345,29 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ hex = "0.4"
|
|||||||
pickledb = "0.5"
|
pickledb = "0.5"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
ts-rs = "12.0.1"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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<dyn std::error::Error>> {
|
||||||
|
let mut output = String::from(
|
||||||
|
"// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\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(())
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ await rm(outputDir, { recursive: true, force: true });
|
|||||||
|
|
||||||
await build({
|
await build({
|
||||||
entryPoints: {
|
entryPoints: {
|
||||||
|
"api-client": path.join(sourceDir, "api", "client.ts"),
|
||||||
app: path.join(sourceDir, "app.js"),
|
app: path.join(sourceDir, "app.js"),
|
||||||
"ui-core": path.join(sourceDir, "ui-core.js"),
|
"ui-core": path.join(sourceDir, "ui-core.js"),
|
||||||
"map-core": path.join(sourceDir, "map-core.js"),
|
"map-core": path.join(sourceDir, "map-core.js"),
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
|
"generate-types": "cargo run -p trx-frontend-http --example generate_typescript",
|
||||||
"typecheck": "tsc --project tsconfig.json",
|
"typecheck": "tsc --project tsconfig.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.2",
|
"@eslint/js": "9.39.2",
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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<T> = (value: unknown) => value is T;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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<T>(path: string, validate: Validator<T>): Promise<T> {
|
||||||
|
return this.request(path, { cache: "no-store" }, validate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async post<T>(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
validate: Validator<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return this.request(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
validate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit,
|
||||||
|
validate: Validator<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(
|
||||||
|
event: MessageEvent<string>,
|
||||||
|
validate: Validator<T>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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<Band>, supported_modes: Array<RigMode>, 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<RigVfoEntry>,
|
||||||
|
/**
|
||||||
|
* 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<number> | null, };
|
||||||
|
|
||||||
|
export type SpectrumData = {
|
||||||
|
/**
|
||||||
|
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
||||||
|
*/
|
||||||
|
bins: Array<number>,
|
||||||
|
/**
|
||||||
|
* 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<VchanRdsEntry> | 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<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||||
|
|
||||||
|
export type RigListResponse = { active_remote: string | null, rigs: Array<RigListItem>, };
|
||||||
|
|
||||||
|
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<string>,
|
||||||
|
/**
|
||||||
|
* 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, };
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ mod assets;
|
|||||||
mod bookmarks;
|
mod bookmarks;
|
||||||
mod decoder;
|
mod decoder;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
mod rig;
|
pub mod rig;
|
||||||
mod sse;
|
mod sse;
|
||||||
mod vchan;
|
mod vchan;
|
||||||
|
|
||||||
|
|||||||
@@ -382,26 +382,26 @@ pub async fn set_sam_carrier_sync(
|
|||||||
// Rig list / selection
|
// Rig list / selection
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize, ts_rs::TS)]
|
||||||
struct RigListItem {
|
pub struct RigListItem {
|
||||||
remote: String,
|
pub remote: String,
|
||||||
display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
manufacturer: String,
|
pub manufacturer: String,
|
||||||
model: String,
|
pub model: String,
|
||||||
supported_modes: Vec<trx_core::RigMode>,
|
pub supported_modes: Vec<trx_core::RigMode>,
|
||||||
tx: bool,
|
pub tx: bool,
|
||||||
filter_controls: bool,
|
pub filter_controls: bool,
|
||||||
initialized: bool,
|
pub initialized: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
latitude: Option<f64>,
|
pub latitude: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
longitude: Option<f64>,
|
pub longitude: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize, ts_rs::TS)]
|
||||||
struct RigListResponse {
|
pub struct RigListResponse {
|
||||||
active_remote: Option<String>,
|
pub active_remote: Option<String>,
|
||||||
rigs: Vec<RigListItem>,
|
pub rigs: Vec<RigListItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
|
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ serde_json = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
flate2 = { workspace = true }
|
flate2 = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
|
||||||
sgp4 = "2"
|
sgp4 = "2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
const SPEED_OF_LIGHT_M_PER_S: f64 = 299_792_458.0;
|
const SPEED_OF_LIGHT_M_PER_S: f64 = 299_792_458.0;
|
||||||
|
|
||||||
/// Supported band range in Hz.
|
/// Supported band range in Hz.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
|
||||||
pub struct Band {
|
pub struct Band {
|
||||||
|
#[ts(type = "number")]
|
||||||
pub low_hz: u64,
|
pub low_hz: u64,
|
||||||
|
#[ts(type = "number")]
|
||||||
pub high_hz: u64,
|
pub high_hz: u64,
|
||||||
pub tx_allowed: bool,
|
pub tx_allowed: bool,
|
||||||
}
|
}
|
||||||
@@ -23,8 +26,9 @@ impl Band {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Frequency wrapper (Hz).
|
/// Frequency wrapper (Hz).
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
|
||||||
pub struct Freq {
|
pub struct Freq {
|
||||||
|
#[ts(type = "number")]
|
||||||
pub hz: u64,
|
pub hz: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::future::Future;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::radio::freq::{Band, Freq};
|
use crate::radio::freq::{Band, Freq};
|
||||||
use crate::{DynResult, RigMode};
|
use crate::{DynResult, RigMode};
|
||||||
@@ -21,14 +22,14 @@ pub mod response;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
/// How this backend communicates with the rig.
|
/// 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 {
|
pub enum RigAccessMethod {
|
||||||
Serial { path: String, baud: u32 },
|
Serial { path: String, baud: u32 },
|
||||||
Tcp { addr: String },
|
Tcp { addr: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static info describing a rig backend.
|
/// Static info describing a rig backend.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct RigInfo {
|
pub struct RigInfo {
|
||||||
pub manufacturer: String,
|
pub manufacturer: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
@@ -37,9 +38,10 @@ pub struct RigInfo {
|
|||||||
pub access: RigAccessMethod,
|
pub access: RigAccessMethod,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
|
||||||
pub struct RigCapabilities {
|
pub struct RigCapabilities {
|
||||||
#[serde(default = "default_min_freq_step_hz")]
|
#[serde(default = "default_min_freq_step_hz")]
|
||||||
|
#[ts(type = "number")]
|
||||||
pub min_freq_step_hz: u64,
|
pub min_freq_step_hz: u64,
|
||||||
pub supported_bands: Vec<Band>,
|
pub supported_bands: Vec<Band>,
|
||||||
pub supported_modes: Vec<RigMode>,
|
pub supported_modes: Vec<RigMode>,
|
||||||
@@ -314,7 +316,7 @@ pub trait RigSdr: Send {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of a rig's status that every backend can expose.
|
/// 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 struct RigStatus {
|
||||||
pub freq: Freq,
|
pub freq: Freq,
|
||||||
pub mode: RigMode,
|
pub mode: RigMode,
|
||||||
@@ -330,21 +332,21 @@ pub trait RigStatusProvider {
|
|||||||
fn status(&self) -> RigStatus;
|
fn status(&self) -> RigStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct RigVfo {
|
pub struct RigVfo {
|
||||||
pub entries: Vec<RigVfoEntry>,
|
pub entries: Vec<RigVfoEntry>,
|
||||||
/// Index into `entries` for the active VFO, if known.
|
/// Index into `entries` for the active VFO, if known.
|
||||||
pub active: Option<usize>,
|
pub active: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct RigVfoEntry {
|
pub struct RigVfoEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub freq: Freq,
|
pub freq: Freq,
|
||||||
pub mode: Option<RigMode>,
|
pub mode: Option<RigMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct RigTxStatus {
|
pub struct RigTxStatus {
|
||||||
pub power: Option<u8>,
|
pub power: Option<u8>,
|
||||||
pub limit: Option<u8>,
|
pub limit: Option<u8>,
|
||||||
@@ -352,7 +354,7 @@ pub struct RigTxStatus {
|
|||||||
pub alc: Option<u8>,
|
pub alc: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct RigRxStatus {
|
pub struct RigRxStatus {
|
||||||
pub sig: Option<f64>,
|
pub sig: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::radio::freq::Freq;
|
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
|
/// Flattened into `RigState` and `RigSnapshot` so the JSON wire format is
|
||||||
/// unchanged (backward compatible with existing clients).
|
/// unchanged (backward compatible with existing clients).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TS)]
|
||||||
pub struct DecoderConfig {
|
pub struct DecoderConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub aprs_decode_enabled: bool,
|
pub aprs_decode_enabled: bool,
|
||||||
@@ -113,7 +114,7 @@ pub struct RigState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mode supported by the rig.
|
/// Mode supported by the rig.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
|
||||||
pub enum RigMode {
|
pub enum RigMode {
|
||||||
LSB,
|
LSB,
|
||||||
USB,
|
USB,
|
||||||
@@ -315,7 +316,7 @@ impl RigState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Current filter/DSP state for backends that support runtime filter adjustment.
|
/// 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 struct RigFilterState {
|
||||||
pub bandwidth_hz: u32,
|
pub bandwidth_hz: u32,
|
||||||
pub cw_center_hz: u32,
|
pub cw_center_hz: u32,
|
||||||
@@ -355,7 +356,7 @@ pub struct RigFilterState {
|
|||||||
pub sam_carrier_sync: bool,
|
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")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum WfmDenoiseLevel {
|
pub enum WfmDenoiseLevel {
|
||||||
Off,
|
Off,
|
||||||
@@ -386,11 +387,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spectrum data from SDR backends (FFT magnitude over the full capture bandwidth).
|
/// 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 {
|
pub struct SpectrumData {
|
||||||
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
||||||
pub bins: Vec<f32>,
|
pub bins: Vec<f32>,
|
||||||
/// Centre frequency of the SDR capture in Hz.
|
/// Centre frequency of the SDR capture in Hz.
|
||||||
|
#[ts(type = "number")]
|
||||||
pub center_hz: u64,
|
pub center_hz: u64,
|
||||||
/// SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
|
/// SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
@@ -400,7 +402,7 @@ pub struct SpectrumData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Live RDS metadata decoded from a WFM broadcast.
|
/// 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 {
|
pub struct RdsData {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub pi: Option<u16>,
|
pub pi: Option<u16>,
|
||||||
@@ -437,7 +439,7 @@ pub struct RdsData {
|
|||||||
/// `PartialEq` intentionally ignores `signal_db` so that rapidly-changing
|
/// `PartialEq` intentionally ignores `signal_db` so that rapidly-changing
|
||||||
/// signal levels do not cause the main state snapshot to diff on every poll
|
/// signal levels do not cause the main state snapshot to diff on every poll
|
||||||
/// cycle (signal_db flows through the spectrum SSE instead).
|
/// cycle (signal_db flows through the spectrum SSE instead).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
pub struct VchanRdsEntry {
|
pub struct VchanRdsEntry {
|
||||||
/// Virtual channel UUID.
|
/// Virtual channel UUID.
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -456,7 +458,7 @@ impl PartialEq for VchanRdsEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only projection of state shared with clients.
|
/// 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 struct RigSnapshot {
|
||||||
pub info: RigInfo,
|
pub info: RigInfo,
|
||||||
pub status: RigStatus,
|
pub status: RigStatus,
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ ft2 = []
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
trx-core = { path = "../trx-core" }
|
trx-core = { path = "../trx-core" }
|
||||||
|
ts-rs = "12.0.1"
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
//! their decoder knowledge from [`DECODER_REGISTRY`].
|
//! their decoder knowledge from [`DECODER_REGISTRY`].
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// How a decoder is activated.
|
/// 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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DecoderActivation {
|
pub enum DecoderActivation {
|
||||||
/// Automatically active when the rig mode matches.
|
/// Automatically active when the rig mode matches.
|
||||||
@@ -25,7 +26,7 @@ pub enum DecoderActivation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Static descriptor for a single decoder.
|
/// Static descriptor for a single decoder.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
pub struct DecoderDescriptor {
|
pub struct DecoderDescriptor {
|
||||||
/// Machine identifier, e.g. `"ft8"`, `"aprs"`.
|
/// Machine identifier, e.g. `"ft8"`, `"aprs"`.
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
|
|||||||
Reference in New Issue
Block a user