diff --git a/apps/web/src/context/controls.tsx b/apps/web/src/context/controls.tsx index b164f982..57469568 100644 --- a/apps/web/src/context/controls.tsx +++ b/apps/web/src/context/controls.tsx @@ -1222,7 +1222,7 @@ export const CONTROL_DEFINITIONS = [ const value = action.op === "adjust" ? slice.nrsLevel + action.delta - : fromNormalized(action.value, 100); + : fromNormalized(action.value, 50); slice.setNrsLevel(value); }, @@ -1254,25 +1254,6 @@ export const CONTROL_DEFINITIONS = [ }, }), - defineControl>({ - target: "slice.nrf.level", - label: "Slice NRF Level", - scope: "slice", - ops: ["adjust", "set"], - editor: { kind: "normalized" }, - execute(ctx, action) { - const slice = ctx.getSlice(action.slice); - if (!slice) return; - - const value = - action.op === "adjust" - ? slice.nrfLevel + action.delta - : fromNormalized(action.value, 100); - - slice.setNrfLevel(value); - }, - }), - defineControl>({ target: "slice.nr.enabled", label: "Slice NR Enabled", @@ -2109,7 +2090,7 @@ export const ControlsProvider: ParentComponent = (props) => { const getChoices = (target: ControlTarget, slice?: SliceSelector) => { const definition = CONTROL_REGISTRY[target]; - if (definition.editor.kind !== "choice") return []; + if (definition?.editor.kind !== "choice") return []; const editor = definition.editor as { kind: "choice"; diff --git a/apps/web/src/context/flexradio.tsx b/apps/web/src/context/flexradio.tsx index 4984b93b..f515926e 100644 --- a/apps/web/src/context/flexradio.tsx +++ b/apps/web/src/context/flexradio.tsx @@ -2,7 +2,9 @@ import * as ToastPrimitive from "@kobalte/core/toast"; import type { ApdSnapshot, AudioStreamSnapshot, + AutoDxSnapshot, ConnectionProgressDetail, + CwDecoderSnapshot, CwxSnapshot, DisconnectedReason, DisplayMarkerSnapshot, @@ -15,7 +17,9 @@ import type { GuiClientSnapshot, MemorySnapshot, MeterSnapshot, + NtpSourceSnapshot, PanadapterSnapshot, + PresetSnapshot, RadioSnapshot, RadioStateChange, SliceSnapshot, @@ -131,6 +135,10 @@ export type DisplayMarkerState = Omit< "raw" >; export type FilterPresetState = Omit, "raw">; +export type CwDecoderState = Omit, "raw">; +export type AutoDxState = Omit, "raw">; +export type PresetState = Omit, "raw">; +export type NtpSourceState = Omit, "raw">; export interface ConnectModalState { status: ConnectionState; @@ -159,6 +167,10 @@ export interface StatusState { dvk: DvkState; displayMarker: Record>; filterPreset: FilterPresetState; + cwDecoder: CwDecoderState; + autoDx: AutoDxState; + preset: Record; + ntpSource: Record; } export interface AppState { @@ -200,6 +212,10 @@ export const initialState = () => displayMarker: {}, filterPreset: {}, waveform: {}, + autoDx: {}, + cwDecoder: {}, + ntpSource: {}, + preset: {}, }, }) as AppState; @@ -386,38 +402,44 @@ export const FlexRadioProvider: ParentComponent = (props) => { }; const handleStateChange = (change: RadioStateChange) => { - switch (change.entity) { - case "spot": - handleSpotChange(change); - break; - case "displayMarker": - handleDisplayMarkerChange(change); - break; - case "apd": - case "cwx": - case "dvk": - case "radio": - case "featureLicense": - case "filterPreset": - // singleton entities are never removed - if (!change.removed) setState("status", change.entity, change.diff); - break; - case "unknown": - console.warn("Unknown state change", change); - break; - default: - if (change.removed) { - setState( - "status", - change.entity, - produce((items) => { - delete items[change.id]; - }), - ); - } else { - setState("status", change.entity, change.id, change.diff); - } - break; + try { + switch (change.entity) { + case "spot": + handleSpotChange(change); + break; + case "displayMarker": + handleDisplayMarkerChange(change); + break; + case "apd": + case "autoDx": + case "cwDecoder": + case "cwx": + case "dvk": + case "featureLicense": + case "filterPreset": + case "radio": + // singleton entities are never removed + if (!change.removed) setState("status", change.entity, change.diff); + break; + case "unknown": + console.warn("Unknown state change", change); + break; + default: + if (change.removed) { + setState( + "status", + change.entity, + produce((items) => { + delete items[change.id]; + }), + ); + } else { + setState("status", change.entity, change.id, change.diff); + } + break; + } + } catch (err) { + console.error("Failed to update state", change, err); } }; diff --git a/packages/flexlib/src/flex/apd.ts b/packages/flexlib/src/flex/apd.ts index d43621d7..7fd028cd 100644 --- a/packages/flexlib/src/flex/apd.ts +++ b/packages/flexlib/src/flex/apd.ts @@ -2,7 +2,12 @@ import { type Subscription, TypedEventEmitter } from "../util/events.js"; import { formatBooleanFlag } from "./controller-helpers.js"; import { FlexStateUnavailableError } from "./errors.js"; import type { RadioSession } from "./radio-core.js"; -import type { ApdSamplerPort, ApdSnapshot, ApdTxAntenna } from "./state/apd.js"; +import type { + ApdLifecycleState, + ApdSamplerPort, + ApdSnapshot, + ApdTxAntenna, +} from "./state/apd.js"; import type { ApdStateChange } from "./state/index.js"; export interface ApdControllerEvents { @@ -49,6 +54,10 @@ export class ApdControllerImpl implements ApdController { return this.current().configurable; } + get state(): ApdLifecycleState | undefined { + return this.current().state; + } + get equalizerActive(): boolean { return this.current().equalizerActive; } diff --git a/packages/flexlib/src/flex/audio-stream.ts b/packages/flexlib/src/flex/audio-stream.ts index 94f448be..a6388482 100644 --- a/packages/flexlib/src/flex/audio-stream.ts +++ b/packages/flexlib/src/flex/audio-stream.ts @@ -1,10 +1,14 @@ import { type Subscription, TypedEventEmitter } from "../util/events.js"; import { + daxAudioClassForRate, + daxAudioSampleRateFromClass, + daxReducedBwClassForRate, VitaDaxAudioPacket, VitaDaxReducedBwPacket, } from "../vita/dax-audio-packet.js"; import { VitaOpusPacket } from "../vita/opus-packet.js"; import type { VitaPacket } from "../vita/parser.js"; +import { parseClientHandle } from "./client-handle.js"; import { clampInteger } from "./controller-helpers.js"; import { FlexStateUnavailableError } from "./errors.js"; import type { RadioSession } from "./radio-core.js"; @@ -79,8 +83,9 @@ export interface DaxRxAudioStreamController extends AudioStreamController { /** * Set the RX gain (0..100) for this DAX RX channel. * - * Requires the stream to be bound to a slice. Throws - * {@link FlexStateUnavailableError} if no slice is currently bound. + * Only a stream this client owns sends the gain; for any other stream the + * call does nothing. While the stream has no bound slice, the gain is held + * and sent once a slice is bound. Only the most recent held gain is kept. */ setRxGain(gain: number): Promise; } @@ -100,6 +105,7 @@ export class AudioStreamControllerImpl implements AudioStreamController { private readonly events = new TypedEventEmitter(); private streamHandle?: string; + private micRateHz = 0; private dataListeners = 0; private dataSubscription?: Subscription; @@ -182,8 +188,24 @@ export class AudioStreamControllerImpl implements AudioStreamController { return snapshot; } + get sampleRateHz(): number { + switch (this.current().type) { + case "dax_rx": + case "dax_tx": + return this.followedSliceRateHz(); + case "dax_mic": + return this.micRateHz; + default: + return 0; + } + } + snapshot(): AudioStreamSnapshot { - return this.current(); + const current = this.current(); + const sampleRateHz = this.sampleRateHz; + return sampleRateHz === current.sampleRateHz + ? current + : { ...current, sampleRateHz }; } on( @@ -225,6 +247,28 @@ export class AudioStreamControllerImpl implements AudioStreamController { } } + /** + * Sample rate in Hz of the slice this stream follows, or 0 when it is not + * known. A stream bound to a slice follows that slice. A stream with no + * bound slice follows the transmit slice that its client (or its GUI client) + * owns. + */ + protected followedSliceRateHz(): number { + const snapshot = this.current(); + const slices = this.radio.getStore().getSlices(); + let slice = snapshot.slice + ? slices.find((s) => s.indexLetter === snapshot.slice) + : undefined; + if (!slice) { + const owners = [snapshot.clientGuiHandle, snapshot.clientHandle]; + slice = + slices.find( + (s) => s.isTransmitEnabled && owners.includes(s.clientHandle), + ) ?? slices.find((s) => s.isTransmitEnabled); + } + return slice && slice.sampleRateHz > 0 ? slice.sampleRateHz : 0; + } + protected requireNumericStreamId(): number { const handle = this.streamHandle ?? this.current().streamId; const numericId = parseStreamIdentifier(handle); @@ -243,6 +287,12 @@ export class AudioStreamControllerImpl implements AudioStreamController { this.dataSubscription = this.radio.registerStreamHandler( streamNumericId, (packet) => { + if (packet.kind === "daxAudio" || packet.kind === "daxReducedBw") { + const rate = daxAudioSampleRateFromClass( + packet.classId.packetClassCode, + ); + if (rate !== undefined) this.micRateHz = rate; + } this.events.emit("data", packet); }, ); @@ -273,6 +323,8 @@ export class AudioStreamTxControllerImpl sendAudio(left: Float32Array, right: Float32Array): void { const numericId = this.requireNumericStreamId(); + const classCode = daxAudioClassForRate(this.followedSliceRateHz()); + if (classCode === undefined) return; if (!this.txAudioPkt) { this.txAudioPkt = new VitaDaxAudioPacket(); this.txAudioPkt.streamId = numericId; @@ -281,6 +333,7 @@ export class AudioStreamTxControllerImpl } const pkt = this.txAudioPkt; pkt.streamId = numericId; + pkt.classId.packetClassCode = classCode; pkt.header.packetCount = this.txPacketCount; const frames = Math.min(left.length, right.length, DAX_PACKET_SAMPLES); pkt.left.set(left.subarray(0, frames)); @@ -291,6 +344,8 @@ export class AudioStreamTxControllerImpl sendReducedBwAudio(samples: Int16Array): void { const numericId = this.requireNumericStreamId(); + const classCode = daxReducedBwClassForRate(this.followedSliceRateHz()); + if (classCode === undefined) return; if (!this.txReducedBwPkt) { this.txReducedBwPkt = new VitaDaxReducedBwPacket(); this.txReducedBwPkt.streamId = numericId; @@ -298,6 +353,7 @@ export class AudioStreamTxControllerImpl } const pkt = this.txReducedBwPkt; pkt.streamId = numericId; + pkt.classId.packetClassCode = classCode; pkt.header.packetCount = this.txPacketCount; const count = Math.min(samples.length, DAX_PACKET_SAMPLES); pkt.samples.set(samples.subarray(0, count)); @@ -321,13 +377,38 @@ export class DaxRxAudioStreamControllerImpl extends AudioStreamControllerImpl implements DaxRxAudioStreamController { + private pendingGain?: number; + async setRxGain(gain: number): Promise { - const sliceLetter = this.slice; - if (sliceLetter === undefined) { - throw new FlexStateUnavailableError( - `DAX RX stream ${this.id} has no slice bound; cannot set gain`, - ); + const clamped = clampInteger(gain, 0, 100, "DAX RX gain"); + if (!this.ownedByThisClient()) return; + if (this.slice === undefined) { + this.pendingGain = clamped; + return; } + this.pendingGain = undefined; + await this.sendGain(clamped); + } + + override onStateChange(change: AudioStreamStateChange): void { + super.onStateChange(change); + if (change.removed || this.pendingGain === undefined) return; + if (this.slice === undefined || !this.ownedByThisClient()) return; + const gain = this.pendingGain; + this.pendingGain = undefined; + this.sendGain(gain).catch(() => { + // The caller already returned; a refused or unsendable gain is dropped. + }); + } + + private ownedByThisClient(): boolean { + const own = this.radio.clientHandle; + const ownHandle = own ? parseClientHandle(own) : undefined; + return ownHandle !== undefined && this.clientHandle === ownHandle; + } + + private async sendGain(gain: number): Promise { + const sliceLetter = this.slice; const slice = this.radio .getStore() .getSlices() @@ -337,9 +418,8 @@ export class DaxRxAudioStreamControllerImpl `DAX RX stream ${this.id} bound to slice ${sliceLetter} but slice is not in store`, ); } - const clamped = clampInteger(gain, 0, 100, "DAX RX gain"); await this.radio.command( - `audio stream ${this.id} slice ${slice.id} gain ${clamped}`, + `audio stream ${this.id} slice ${slice.id} gain ${gain}`, ); } } diff --git a/packages/flexlib/src/flex/autodx.ts b/packages/flexlib/src/flex/autodx.ts new file mode 100644 index 00000000..0f697169 --- /dev/null +++ b/packages/flexlib/src/flex/autodx.ts @@ -0,0 +1,320 @@ +import { type Subscription, TypedEventEmitter } from "../util/events.js"; +import { FlexStateUnavailableError } from "./errors.js"; +import type { RadioSession } from "./radio-core.js"; +import { createAutoDxSnapshot } from "./state/autodx.js"; +import type { + AutoDxSnapshot, + AutoDxStateChange, + AutoDxTrigger, +} from "./state/index.js"; + +/** Shortest wait for a reply after calling, in ms. */ +export const AUTODX_AWAIT_REPLY_MIN_MS = 2000; +/** Longest wait for a reply after calling, in ms. */ +export const AUTODX_AWAIT_REPLY_MAX_MS = 60000; +/** Shortest delay before transmitting, in ms. */ +export const AUTODX_TX_DELAY_MIN_MS = 100; +/** Longest delay before transmitting, in ms. */ +export const AUTODX_TX_DELAY_MAX_MS = 2000; +/** Shortest QSO timeout, in ms. */ +export const AUTODX_TIMEOUT_MIN_MS = 60000; +/** Longest QSO timeout, in ms. */ +export const AUTODX_TIMEOUT_MAX_MS = 600000; + +/** Payload of the {@link AutoDxControllerEvents.sentText} event. */ +export interface AutoDxSentTextEvent { + /** Text the controller just transmitted. */ + readonly text: string; +} + +/** Events emitted by an {@link AutoDxController}. */ +export interface AutoDxControllerEvents { + readonly change: AutoDxStateChange; + /** + * The controller transmitted text. The first transmission number the radio + * reports only sets a baseline and does not fire this event. + */ + readonly sentText: AutoDxSentTextEvent; +} + +/** + * Controller for AutoDX on one slice. AutoDX is an automated CW contact + * controller that runs on the radio; this client sends its configuration and + * shows the state it reports. The radio only arms AutoDX on a slice whose + * chain is CW (see the slice's `chainMode`), and it refuses the arm command + * otherwise. + */ +export interface AutoDxController + extends Readonly> { + /** Returns the current snapshot of this controller's state. */ + snapshot(): AutoDxSnapshot; + + /** Registers a listener for controller events. */ + on( + event: TKey, + listener: (payload: AutoDxControllerEvents[TKey]) => void, + ): Subscription; + + /** + * Arms or disarms the controller. The new value shows at once. If the radio + * refuses the command, the controller shows as disarmed and the error is + * thrown. Disarming is always accepted. + */ + setEnabled(enabled: boolean): Promise; + + /** + * Sets the DX call to chase. Only the first word is used, because an + * operator may type the call and UP together. An empty value clears the call. + */ + setDetect(detect: string): Promise; + + /** Sets which copied tokens may start a call. `unknown` is never sent. */ + setTrigger(trigger: AutoDxTrigger): Promise; + + /** Sets the exchange to send. It may contain spaces but not a line break. */ + setExchange(exchange: string): Promise; + + /** Sets the wait for a reply after calling. 0 lets the controller choose; other values are clamped to 2000 to 60000 ms. */ + setAwaitReplyMs(ms: number): Promise; + + /** Sets the delay before transmitting. 0 lets the controller choose; other values are clamped to 100 to 2000 ms. */ + setTxDelayMs(ms: number): Promise; + + /** Sets the QSO timeout. 0 lets the controller choose; other values are clamped to 60000 to 600000 ms. */ + setTimeoutMs(ms: number): Promise; + + /** Sets whether the timeout restarts on activity. */ + setTimeoutRestart(restart: boolean): Promise; + + /** Sets how many times to repeat the call. Negative values become 0. */ + setCallRetries(retries: number): Promise; + + /** Asks the radio to send the full controller status again. */ + requestStatus(): Promise; +} + +function clampOrChoose( + value: number, + min: number, + max: number, + label: string, +): number { + if (!Number.isFinite(value)) { + throw new RangeError(`${label} must be a finite number`); + } + if (value <= 0) return 0; + return Math.min(max, Math.max(min, Math.round(value))); +} + +export class AutoDxControllerImpl implements AutoDxController { + private readonly events = new TypedEventEmitter(); + + constructor( + private readonly radio: RadioSession, + readonly sliceId: string, + ) {} + + snapshot(): AutoDxSnapshot { + return this.current(); + } + + get state() { + return this.current().state; + } + + get enabled() { + return this.current().enabled; + } + + get detect() { + return this.current().detect; + } + + get trigger() { + return this.current().trigger; + } + + get exchange() { + return this.current().exchange; + } + + get awaitReplyMs() { + return this.current().awaitReplyMs; + } + + get txDelayMs() { + return this.current().txDelayMs; + } + + get timeoutMs() { + return this.current().timeoutMs; + } + + get timeoutRestart() { + return this.current().timeoutRestart; + } + + get callRetries() { + return this.current().callRetries; + } + + get dxCall() { + return this.current().dxCall; + } + + get rxRst() { + return this.current().rxRst; + } + + get rxExchange() { + return this.current().rxExchange; + } + + get qsoCount() { + return this.current().qsoCount; + } + + get sentSeq() { + return this.current().sentSeq; + } + + get lastSentText() { + return this.current().lastSentText; + } + + get sentCount() { + return this.current().sentCount; + } + + async setEnabled(enabled: boolean): Promise { + if (this.current().enabled === enabled) return; + this.patch({ enabled: enabled ? "1" : "0" }); + try { + await this.radio.command( + `autodx ${this.sliceId} enable ${enabled ? "1" : "0"}`, + ); + } catch (error) { + this.patch({ enabled: "0" }); + throw error; + } + } + + async setDetect(detect: string): Promise { + const call = detect.split(/\s+/).find((token) => token.length > 0) ?? ""; + if (this.current().detect === call) return; + this.patch({ detect: call }); + // A quoted empty value would not clear the call. Three tokens do. + await this.radio.command( + call + ? `autodx ${this.sliceId} detect ${call}` + : `autodx ${this.sliceId} detect`, + ); + } + + async setTrigger(trigger: AutoDxTrigger): Promise { + if (trigger === "unknown" || this.current().trigger === trigger) return; + this.patch({ trigger }); + await this.radio.command(`autodx ${this.sliceId} trigger ${trigger}`); + } + + async setExchange(exchange: string): Promise { + if (/[\r\n]/.test(exchange)) { + throw new RangeError("AutoDX exchange cannot hold a line break"); + } + if (this.current().exchange === exchange) return; + this.patch({ exchange }); + await this.radio.command(`autodx ${this.sliceId} exchange "${exchange}"`); + } + + async setAwaitReplyMs(ms: number): Promise { + const value = clampOrChoose( + ms, + AUTODX_AWAIT_REPLY_MIN_MS, + AUTODX_AWAIT_REPLY_MAX_MS, + "AutoDX await reply", + ); + if (this.current().awaitReplyMs === value) return; + this.patch({ await_reply_ms: String(value) }); + await this.radio.command(`autodx ${this.sliceId} await_reply_ms ${value}`); + } + + async setTxDelayMs(ms: number): Promise { + const value = clampOrChoose( + ms, + AUTODX_TX_DELAY_MIN_MS, + AUTODX_TX_DELAY_MAX_MS, + "AutoDX transmit delay", + ); + if (this.current().txDelayMs === value) return; + this.patch({ tx_delay_ms: String(value) }); + await this.radio.command(`autodx ${this.sliceId} tx_delay_ms ${value}`); + } + + async setTimeoutMs(ms: number): Promise { + const value = clampOrChoose( + ms, + AUTODX_TIMEOUT_MIN_MS, + AUTODX_TIMEOUT_MAX_MS, + "AutoDX timeout", + ); + if (this.current().timeoutMs === value) return; + this.patch({ timeout_ms: String(value) }); + await this.radio.command(`autodx ${this.sliceId} timeout_ms ${value}`); + } + + async setTimeoutRestart(restart: boolean): Promise { + if (this.current().timeoutRestart === restart) return; + this.patch({ timeout_restart: restart ? "1" : "0" }); + await this.radio.command( + `autodx ${this.sliceId} timeout_restart ${restart ? "1" : "0"}`, + ); + } + + async setCallRetries(retries: number): Promise { + if (!Number.isFinite(retries)) { + throw new RangeError("AutoDX call retries must be a finite number"); + } + const value = Math.max(0, Math.round(retries)); + if (this.current().callRetries === value) return; + this.patch({ call_retries: String(value) }); + await this.radio.command(`autodx ${this.sliceId} call_retries ${value}`); + } + + async requestStatus(): Promise { + this.current(); + await this.radio.command(`autodx ${this.sliceId} status`); + } + + on( + event: TKey, + listener: (payload: AutoDxControllerEvents[TKey]) => void, + ): Subscription { + return this.events.on(event, listener); + } + + onStateChange(change: AutoDxStateChange): void { + this.events.emit("change", change); + if (!change.removed && (change.diff.sentCount ?? 0) > 0) { + const text = this.radio.getStore().getAutoDx(this.sliceId)?.lastSentText; + if (text) this.events.emit("sentText", { text }); + } + } + + private patch(attributes: Record): void { + const change = this.radio.getStore().patchAutoDx(this.sliceId, attributes); + if (change) this.radio.applyStateChange(change); + } + + private current(): AutoDxSnapshot { + const store = this.radio.getStore(); + if (!store.getSlice(this.sliceId)) { + throw new FlexStateUnavailableError( + `Slice ${this.sliceId} is no longer available`, + ); + } + return ( + store.getAutoDx(this.sliceId) ?? + createAutoDxSnapshot(this.sliceId, {}).snapshot + ); + } +} diff --git a/packages/flexlib/src/flex/client-handle.ts b/packages/flexlib/src/flex/client-handle.ts new file mode 100644 index 00000000..997425ae --- /dev/null +++ b/packages/flexlib/src/flex/client-handle.ts @@ -0,0 +1,34 @@ +/** + * Render a 32-bit client handle for use in a command: `0x` and eight + * uppercase hex digits. Throws if `handle` is not an unsigned 32-bit integer. + */ +export function formatClientHandle(handle: number): string { + if (!Number.isInteger(handle) || handle < 0 || handle > 0xffffffff) { + throw new Error(`Invalid client handle: ${handle}`); + } + return `0x${handle.toString(16).toUpperCase().padStart(8, "0")}`; +} + +/** + * Parse a client handle string as the radio emits it: hex, with or without + * the `0x` prefix, padded or not. An unprefixed value is never decimal. + * Returns `undefined` when the string is not a handle. + */ +export function parseClientHandle(value: string): number | undefined { + const trimmed = value.trim(); + const digits = /^0x/i.test(trimmed) ? trimmed.slice(2) : trimmed; + if (!/^[0-9a-f]{1,8}$/i.test(digits)) return undefined; + return Number.parseInt(digits, 16); +} + +/** + * Compare two client handles by value, so width and `0x` prefix do not + * matter. Falls back to a case-insensitive string compare when either side is + * not a handle. + */ +export function clientHandlesMatch(a: string, b: string): boolean { + const left = parseClientHandle(a); + const right = parseClientHandle(b); + if (left !== undefined && right !== undefined) return left === right; + return a.toLowerCase() === b.toLowerCase(); +} diff --git a/packages/flexlib/src/flex/cwx.ts b/packages/flexlib/src/flex/cwx.ts index 71a70200..557f620d 100644 --- a/packages/flexlib/src/flex/cwx.ts +++ b/packages/flexlib/src/flex/cwx.ts @@ -2,6 +2,7 @@ import { type Subscription, TypedEventEmitter } from "../util/events.js"; import { clampInteger, formatBooleanFlag } from "./controller-helpers.js"; import { FlexStateUnavailableError } from "./errors.js"; import type { RadioSession } from "./radio-core.js"; +import { encodeCwxText } from "./state/cwx.js"; import type { CwxSnapshot, CwxStateChange } from "./state/index.js"; /** Result of a tracked CWX send, correlating buffer position with a block id. */ @@ -99,6 +100,17 @@ export interface CwxController extends Readonly> { */ send(text: string, block?: number): Promise; + /** + * Inserts CW text into the radio's CWX buffer at `radioIndex`, tracked under + * the caller-chosen `block` id. The reply has the same meaning as for + * {@link send}. + */ + insert( + radioIndex: number, + text: string, + block: number, + ): Promise; + /** Erases the specified number of characters from the CW buffer. */ erase(count: number): Promise; @@ -186,7 +198,9 @@ export class CwxControllerImpl implements CwxController { async setMacro(index: number, text: string): Promise { const slot = clampInteger(index, 0, 11, "CWX macro index"); const wireIndex = slot + 1; // wire protocol is 1-indexed - await this.radio.command(`cwx macro save ${wireIndex} "${text}"`); + await this.radio.command( + `cwx macro save ${wireIndex} "${encodeCwxText(text, false)}"`, + ); const change = this.radio .getStore() .patchCwx({ [`macro${wireIndex}`]: text }); @@ -203,12 +217,25 @@ export class CwxControllerImpl implements CwxController { } async send(text: string, block?: number): Promise { - const escaped = text.replaceAll(" ", "\x7f"); const suffix = block === undefined ? "" : ` ${block}`; - const response = await this.radio.command(`cwx send "${escaped}"${suffix}`); + const response = await this.radio.command( + `cwx send "${encodeCwxText(text)}"${suffix}`, + ); return parseQueueReply(response.message, block ?? -1); } + async insert( + radioIndex: number, + text: string, + block: number, + ): Promise { + const index = clampInteger(radioIndex, 0, 0xffff, "CWX insert index"); + const response = await this.radio.command( + `cwx insert ${index} "${encodeCwxText(text)}" ${block}`, + ); + return parseQueueReply(response.message, block); + } + async erase(count: number): Promise { const n = clampInteger(count, 1, 255, "CWX erase count"); await this.radio.command(`cwx erase ${n}`); diff --git a/packages/flexlib/src/flex/flex-client.ts b/packages/flexlib/src/flex/flex-client.ts index 3093cd9d..56c17e45 100644 --- a/packages/flexlib/src/flex/flex-client.ts +++ b/packages/flexlib/src/flex/flex-client.ts @@ -11,6 +11,7 @@ import { type Subscription, TypedEventEmitter } from "../util/events.js"; import { parseVitaPacket } from "../vita/parser.js"; import type { FlexRadioDescriptor, Logger } from "./adapters.js"; +import { formatClientHandle, parseClientHandle } from "./client-handle.js"; import { decodeDiscoveryPayload } from "./discovery.js"; import { FlexCommandRejectedError } from "./errors.js"; import { type FlexReplyMessage, parseFlexMessage } from "./protocol.js"; @@ -427,20 +428,12 @@ function decodeTcpChunk(data: string | Uint8Array): string { } function normalizeClientHandle(clientHandle: number | string): string { - if (typeof clientHandle === "number") { - if (!Number.isInteger(clientHandle) || clientHandle < 0) { - throw new Error(`Invalid client handle: ${clientHandle}`); - } - return `0x${clientHandle.toString(16).toUpperCase()}`; - } + if (typeof clientHandle === "number") return formatClientHandle(clientHandle); - const trimmed = clientHandle.trim(); - if (!trimmed) throw new Error("Client handle must not be empty"); - if (/^0x[0-9a-f]+$/i.test(trimmed)) { - return `0x${trimmed.slice(2).toUpperCase()}`; - } - if (/^\d+$/.test(trimmed)) { - return `0x${Number.parseInt(trimmed, 10).toString(16).toUpperCase()}`; + if (!clientHandle.trim()) throw new Error("Client handle must not be empty"); + const parsed = parseClientHandle(clientHandle); + if (parsed === undefined) { + throw new Error(`Invalid client handle: ${clientHandle}`); } - throw new Error(`Invalid client handle: ${clientHandle}`); + return formatClientHandle(parsed); } diff --git a/packages/flexlib/src/flex/index.ts b/packages/flexlib/src/flex/index.ts index 6bfea0a6..53791a73 100644 --- a/packages/flexlib/src/flex/index.ts +++ b/packages/flexlib/src/flex/index.ts @@ -15,6 +15,8 @@ export * from "./transport.js"; export type { FlexRadioDescriptor, Logger } from "./adapters.js"; export * from "./apd.js"; export * from "./audio-stream.js"; +export * from "./autodx.js"; +export * from "./client-handle.js"; export * from "./cw-break-in-delay.js"; export * from "./cwx.js"; // Discovery parsing @@ -30,8 +32,9 @@ export * from "./file-transfer.js"; export * from "./filter-preset.js"; export * from "./gui-client.js"; export * from "./meter.js"; -export * from "./meter.js"; +export * from "./ntp-source.js"; export * from "./panadapter.js"; +export * from "./preset.js"; export * from "./protocol.js"; export * from "./response-codes.js"; // Controllers diff --git a/packages/flexlib/src/flex/meter.ts b/packages/flexlib/src/flex/meter.ts index a550c2e6..de1567ab 100644 --- a/packages/flexlib/src/flex/meter.ts +++ b/packages/flexlib/src/flex/meter.ts @@ -59,7 +59,7 @@ export class MeterControllerImpl implements MeterController { return this.current().source; } - get sourceIndex(): number { + get sourceIndex(): number | undefined { return this.current().sourceIndex; } diff --git a/packages/flexlib/src/flex/ntp-source.ts b/packages/flexlib/src/flex/ntp-source.ts new file mode 100644 index 00000000..0f55ff6b --- /dev/null +++ b/packages/flexlib/src/flex/ntp-source.ts @@ -0,0 +1,203 @@ +import { type Subscription, TypedEventEmitter } from "../util/events.js"; +import { FlexStateUnavailableError } from "./errors.js"; +import type { RadioSession } from "./radio-core.js"; +import type { + NtpDirective, + NtpSourceSnapshot, + NtpSourceStateChange, +} from "./state/index.js"; + +/** Most NTP sources the radio keeps at one time. */ +export const NTP_MAX_SOURCES = 10; + +/** Longest description that always survives the radio's command parsing. */ +export const NTP_MAX_DESCRIPTION_LENGTH = 62; + +const VALID_ID = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/; +const VALID_ADDRESS = /^[A-Za-z0-9._:%-]+$/; +const VALID_DESCRIPTION = /^[A-Za-z0-9._\- ]*$/; +const DIRECTIVES: ReadonlySet = new Set(["server", "pool", "peer"]); + +/** True for 1 to 64 letters, digits, and hyphens that do not start or end with a hyphen. */ +export function isValidNtpSourceId(id: string): boolean { + return id.length <= 64 && VALID_ID.test(id); +} + +/** True for 1 to 253 characters from letters, digits, and `. : - _ %`. */ +export function isValidNtpAddress(address: string): boolean { + return address.length <= 253 && VALID_ADDRESS.test(address); +} + +/** True for up to 62 characters from letters, digits, spaces, and `. _ -`. */ +export function isValidNtpDescription(description: string): boolean { + return ( + description.length <= NTP_MAX_DESCRIPTION_LENGTH && + VALID_DESCRIPTION.test(description) + ); +} + +/** True for `server`, `pool`, or `peer`. */ +export function isValidNtpDirective(directive: string): boolean { + return DIRECTIVES.has(directive); +} + +/** Fields for creating an NTP source. */ +export interface NtpSourceCreateOptions { + /** Unique id for the new source. See {@link isValidNtpSourceId}. */ + readonly id: string; + /** Host name or IP address. See {@link isValidNtpAddress}. */ + readonly address: string; + readonly directive: NtpDirective; + /** Optional description. See {@link isValidNtpDescription}. */ + readonly description?: string; + /** Whether the time service should prefer this source. Defaults to false. */ + readonly prefer?: boolean; +} + +/** + * Builds the create command, or throws {@link RangeError} when a field is not + * one the radio accepts. Nothing is sent for an invalid field. + */ +export function buildNtpSourceCreateCommand( + options: NtpSourceCreateOptions, +): string { + const { id, address, directive, description = "", prefer } = options; + if (!isValidNtpSourceId(id)) { + throw new RangeError(`Invalid NTP source id: ${id}`); + } + if (!isValidNtpAddress(address)) { + throw new RangeError(`Invalid NTP source address: ${address}`); + } + if (!isValidNtpDirective(directive)) { + throw new RangeError(`Invalid NTP source directive: ${directive}`); + } + if (!isValidNtpDescription(description)) { + throw new RangeError("Invalid NTP source description"); + } + let command = `ntp_sources create id=${id} address=${address} directive=${directive}`; + if (description) command += ` description="${description}"`; + if (prefer) command += " prefer=1"; + return command; +} + +/** Events emitted by an {@link NtpSourceController}. */ +export interface NtpSourceControllerEvents { + readonly change: NtpSourceStateChange; +} + +/** + * Controller for one NTP time source. The radio has no command to edit a + * source. To change one, remove it and create it again. + */ +export interface NtpSourceController + extends Readonly> { + /** Returns the current snapshot of this source. */ + snapshot(): NtpSourceSnapshot; + + /** Registers a listener for source state changes. */ + on( + event: TKey, + listener: (payload: NtpSourceControllerEvents[TKey]) => void, + ): Subscription; + + /** + * Enables or disables the source. The new value shows at once and is undone + * if the radio refuses the command. + */ + setEnabled(enabled: boolean): Promise; + + /** Removes the source from the radio. */ + remove(): Promise; +} + +export class NtpSourceControllerImpl implements NtpSourceController { + private readonly events = new TypedEventEmitter(); + + constructor( + private readonly radio: RadioSession, + readonly id: string, + ) {} + + snapshot(): NtpSourceSnapshot { + return this.current(); + } + + get address(): string { + return this.current().address; + } + + get directive() { + return this.current().directive; + } + + get description(): string { + return this.current().description; + } + + get prefer(): boolean { + return this.current().prefer; + } + + get selected(): boolean { + return this.current().selected; + } + + get enabled(): boolean { + return this.current().enabled; + } + + get state(): string { + return this.current().state; + } + + get stratum(): number { + return this.current().stratum; + } + + get reachability(): number { + return this.current().reachability; + } + + async setEnabled(enabled: boolean): Promise { + const previous = this.current().enabled; + this.patch({ enabled: enabled ? "1" : "0" }); + try { + await this.radio.command( + `ntp_sources ${enabled ? "enable" : "disable"} id=${this.id}`, + ); + } catch (error) { + this.patch({ enabled: previous ? "1" : "0" }); + throw error; + } + } + + async remove(): Promise { + await this.radio.command(`ntp_sources remove id=${this.id}`); + } + + on( + event: TKey, + listener: (payload: NtpSourceControllerEvents[TKey]) => void, + ): Subscription { + return this.events.on(event, listener); + } + + onStateChange(change: NtpSourceStateChange): void { + this.events.emit("change", change); + } + + private patch(attributes: Record): void { + const change = this.radio.getStore().patchNtpSource(this.id, attributes); + if (change) this.radio.applyStateChange(change); + } + + private current(): NtpSourceSnapshot { + const snapshot = this.radio.getStore().getNtpSource(this.id); + if (!snapshot) { + throw new FlexStateUnavailableError( + `NTP source ${this.id} is no longer available`, + ); + } + return snapshot; + } +} diff --git a/packages/flexlib/src/flex/panadapter.ts b/packages/flexlib/src/flex/panadapter.ts index e74e4287..2df3c13a 100644 --- a/packages/flexlib/src/flex/panadapter.ts +++ b/packages/flexlib/src/flex/panadapter.ts @@ -83,6 +83,12 @@ export interface PanadapterController setWnbLevel(level: number): Promise; setNoiseFloorPosition(value: number): Promise; setNoiseFloorPositionEnabled(enabled: boolean): Promise; + /** + * Sets the noise-floor range in dB. The value is sent with one decimal + * place. Nothing is sent when it differs from the current range by less + * than 0.05. Throws {@link RangeError} if `value` is not a finite number. + */ + setNoiseFloorRange(value: number): Promise; setRxAntenna(port: string): Promise; setRfGain(value: number): Promise; setDaxIqChannel(channel: number): Promise; @@ -198,6 +204,10 @@ export class PanadapterControllerImpl implements PanadapterController { return this.current().noiseFloorPositionEnabled; } + get noiseFloorRange(): number | undefined { + return this.current().noiseFloorRange; + } + get rxAntenna(): string { return this.current().rxAntenna; } @@ -391,6 +401,16 @@ export class PanadapterControllerImpl implements PanadapterController { }); } + async setNoiseFloorRange(value: number): Promise { + if (!Number.isFinite(value)) { + throw new RangeError(`Invalid noise floor range: ${value}`); + } + const current = this.current().noiseFloorRange; + if (current !== undefined && Math.abs(current - value) < 0.05) return; + const rounded = Math.round(value * 10) / 10 + 0; + await this.sendSet({ noise_floor_range: rounded.toFixed(1) }); + } + async setNoiseFloorPositionEnabled(enabled: boolean): Promise { await this.sendSet({ pan_position_enable: formatBooleanFlag(enabled) }); } diff --git a/packages/flexlib/src/flex/preset.ts b/packages/flexlib/src/flex/preset.ts new file mode 100644 index 00000000..6fdf5de0 --- /dev/null +++ b/packages/flexlib/src/flex/preset.ts @@ -0,0 +1,88 @@ +import { type Subscription, TypedEventEmitter } from "../util/events.js"; +import { FlexStateUnavailableError } from "./errors.js"; +import type { RadioSession } from "./radio-core.js"; +import type { PresetSnapshot, PresetStateChange } from "./state/index.js"; + +/** Events emitted by a {@link PresetController}. */ +export interface PresetControllerEvents { + readonly change: PresetStateChange; +} + +/** + * Controller for a radio preset, a named bundle of settings the radio applies + * together. Presets are read only: a client can apply one but not create, + * edit, or delete it. + */ +export interface PresetController + extends Readonly> { + /** Returns the current snapshot of this preset. */ + snapshot(): PresetSnapshot; + + /** Registers a listener for preset state changes. */ + on( + event: TKey, + listener: (payload: PresetControllerEvents[TKey]) => void, + ): Subscription; + + /** + * Applies this preset on the radio. Does nothing when this preset is + * already the active one. The preset becomes active when the radio confirms. + */ + select(): Promise; +} + +export class PresetControllerImpl implements PresetController { + private readonly events = new TypedEventEmitter(); + + constructor( + private readonly radio: RadioSession, + readonly id: string, + ) {} + + snapshot(): PresetSnapshot { + return this.current(); + } + + get name(): string { + return this.current().name; + } + + get sliceConfig(): string { + return this.current().sliceConfig; + } + + get aleConfig(): string { + return this.current().aleConfig; + } + + get active(): boolean { + return this.current().active; + } + + async select(): Promise { + this.current(); + if (this.radio.getStore().getActivePresetId() === this.id) return; + await this.radio.command(`preset apply id=${this.id}`); + } + + on( + event: TKey, + listener: (payload: PresetControllerEvents[TKey]) => void, + ): Subscription { + return this.events.on(event, listener); + } + + onStateChange(change: PresetStateChange): void { + this.events.emit("change", change); + } + + private current(): PresetSnapshot { + const snapshot = this.radio.getStore().getPreset(this.id); + if (!snapshot) { + throw new FlexStateUnavailableError( + `Preset ${this.id} is no longer available`, + ); + } + return snapshot; + } +} diff --git a/packages/flexlib/src/flex/protocol.ts b/packages/flexlib/src/flex/protocol.ts index 32d88b79..e003eaed 100644 --- a/packages/flexlib/src/flex/protocol.ts +++ b/packages/flexlib/src/flex/protocol.ts @@ -345,7 +345,14 @@ function parseSpaceSeparatedAttributes( if (token) attributes[token] = ""; break; } - const key = segment.slice(index, equals); + let key = segment.slice(index, equals); + const lastSpace = key.lastIndexOf(" "); + if (lastSpace !== -1) { + for (const bare of key.slice(0, lastSpace).split(" ")) { + if (bare) attributes[bare] = ""; + } + key = key.slice(lastSpace + 1); + } index = equals + 1; let end = index; let quoted = false; diff --git a/packages/flexlib/src/flex/radio-core.ts b/packages/flexlib/src/flex/radio-core.ts index dd71605f..0f4f3045 100644 --- a/packages/flexlib/src/flex/radio-core.ts +++ b/packages/flexlib/src/flex/radio-core.ts @@ -88,6 +88,7 @@ import { type RemoteAudioTxStreamController, RemoteAudioTxStreamControllerImpl, } from "./audio-stream.js"; +import { type AutoDxController, AutoDxControllerImpl } from "./autodx.js"; import { type CwxController, CwxControllerImpl } from "./cwx.js"; import { type DisplayMarkerController, @@ -122,10 +123,18 @@ import { type RadioNetworkDiagnosticsSnapshot, RadioNetworkDiagnosticsTracker, } from "./network-diagnostics.js"; +import { + buildNtpSourceCreateCommand, + NTP_MAX_SOURCES, + type NtpSourceController, + NtpSourceControllerImpl, + type NtpSourceCreateOptions, +} from "./ntp-source.js"; import { type PanadapterController, PanadapterControllerImpl, } from "./panadapter.js"; +import { type PresetController, PresetControllerImpl } from "./preset.js"; import { type SliceController, SliceControllerImpl } from "./slice.js"; import { type SpotController, @@ -134,6 +143,8 @@ import { } from "./spot.js"; import type { ApdStateChange, + AutoDxStateChange, + CwDecoderStateChange, CwxStateChange, DisplayMarkerStateChange, DvkStateChange, @@ -141,6 +152,8 @@ import type { EqualizerStateChange, FilterPresetStateChange, MemoryStateChange, + NtpSourceStateChange, + PresetStateChange, SpotStateChange, TnfStateChange, TxBandSettingStateChange, @@ -250,6 +263,16 @@ export interface RadioClientInfo { // Event map // --------------------------------------------------------------------------- +/** Payload of the {@link RadioEvents.waveformRestarted} event. */ +export interface WaveformRestartedEvent { + /** Name of the container waveform that finished restarting or recreating. */ + readonly name: string; + /** False when the radio reported `result=error`. */ + readonly success: boolean; + /** Result token the radio sent, if any. */ + readonly result?: string; +} + /** Event map for the {@link Radio} class. */ export interface RadioEvents { readonly connectionStateChange: RadioConnectionState; @@ -261,6 +284,7 @@ export interface RadioEvents { readonly notice: FlexNoticeMessage; readonly message: FlexWireMessage; readonly spotTriggered: SpotTriggeredEvent; + readonly waveformRestarted: WaveformRestartedEvent; readonly ready: undefined; readonly disconnected: DisconnectedReason | undefined; readonly error: unknown; @@ -314,9 +338,15 @@ export interface RadioSession { */ readonly modelInfo: RadioModelInfo; + /** Handle the radio assigned to this client, or `null` before the handshake. */ + readonly clientHandle: string | null; + /** Look up (or lazily create) a slice controller by ID. */ slice(id: string): SliceController | undefined; + /** The AutoDX controller for a slice, or `undefined` when the slice does not exist. */ + autoDx(sliceId: string): AutoDxController | undefined; + /** * Propagate a state change from an optimistic update. * @@ -408,6 +438,11 @@ const DEFAULT_HANDSHAKE_COMMANDS: readonly string[] = [ "sub filt_preset all", "sub waveform all", "keepalive enable", + "profile cw info", + "sub ha_api system", + "sub cw all", + "sub preset all", + "sub ntp_sources all", ]; // --------------------------------------------------------------------------- @@ -465,7 +500,7 @@ function encodeCwIambicMode(mode: RadioCwIambicMode): number { const FILTER_SHARPNESS_MIN_LEVEL = 0; const FILTER_SHARPNESS_MAX_LEVEL = 3; -type ProfileLoadDomain = "global" | "tx" | "mic" | "display"; +type ProfileLoadDomain = "global" | "tx" | "mic" | "cw" | "display"; type PreparedProfileName = { readonly normalized: string; @@ -585,6 +620,12 @@ class RadioImpl { TxBandSettingControllerImpl >(); private readonly xvtrControllers = new Map(); + private readonly autoDxControllers = new Map(); + private readonly presetControllers = new Map(); + private readonly ntpSourceControllers = new Map< + string, + NtpSourceControllerImpl + >(); private readonly displayMarkerControllers = new Map< string, Map @@ -892,6 +933,80 @@ class RadioImpl { return controller; } + /** + * The AutoDX controller for a slice. It exists as soon as the slice does, so + * its configuration can be set before the radio reports any state. The + * controller is dropped when the slice is removed. + */ + autoDx(sliceId: string): AutoDxController | undefined { + return this.getOrCreateController( + "autoDx", + sliceId, + this.autoDxControllers, + () => { + if (!this.store.getSlice(sliceId)) return undefined; + return new AutoDxControllerImpl(this, sliceId); + }, + ); + } + + /** All known presets. */ + presets(): PresetController[] { + return Array.from(this.presetControllers.values()); + } + + preset(id: string): PresetController | undefined { + return this.getOrCreateController( + "preset", + id, + this.presetControllers, + () => { + if (!this.store.getPreset(id)) return undefined; + return new PresetControllerImpl(this, id); + }, + ); + } + + /** Id of the last preset the radio confirmed applying since this client connected. */ + get activePresetId(): string | undefined { + return this.store.getActivePresetId(); + } + + /** All known NTP time sources. */ + ntpSources(): NtpSourceController[] { + return Array.from(this.ntpSourceControllers.values()); + } + + ntpSource(id: string): NtpSourceController | undefined { + return this.getOrCreateController( + "ntpSource", + id, + this.ntpSourceControllers, + () => { + if (!this.store.getNtpSource(id)) return undefined; + return new NtpSourceControllerImpl(this, id); + }, + ); + } + + /** + * Asks the radio to create an NTP source. The source appears in + * {@link ntpSources} when the radio reports it. The radio has no edit + * command; to change a source, remove it and create it again. + * + * Throws {@link RangeError} without sending when a field is not one the + * radio accepts, or when the radio already holds the most sources it allows. + */ + async createNtpSource(options: NtpSourceCreateOptions): Promise { + const command = buildNtpSourceCreateCommand(options); + if (this.store.getNtpSources().length >= NTP_MAX_SOURCES) { + throw new RangeError( + `The radio holds at most ${NTP_MAX_SOURCES} NTP sources`, + ); + } + await this.command(command); + } + tnfs(): TnfController[] { return Array.from(this.tnfControllers.values()); } @@ -948,6 +1063,42 @@ class RadioImpl { await this.command("spot clear"); } + /** + * Stages one environment variable for the next container waveform deploy or + * recreate. The value may contain spaces. Throws {@link RangeError} without + * sending when the key is empty or holds whitespace or `=`, or when the + * value holds a line break. + */ + async stageWaveformEnv(key: string, value: string): Promise { + if (!key || /[\s=]/.test(key)) { + throw new RangeError(`Invalid waveform environment key: ${key}`); + } + if (/[\r\n]/.test(value)) { + throw new RangeError( + "Waveform environment value cannot hold a line break", + ); + } + await this.command(`waveform env ${key}=${value}`); + } + + /** Clears every environment variable staged on the radio. */ + async clearWaveformEnv(): Promise { + await this.command("waveform env clear"); + } + + /** + * Recreates a container waveform from its installed image with the staged + * environment, without uploading the image again. The radio reports the + * outcome with the {@link RadioEvents.waveformRestarted} event. Throws + * {@link RangeError} without sending when `name` is empty or holds whitespace. + */ + async recreateWaveform(name: string): Promise { + if (!name || /\s/.test(name)) { + throw new RangeError(`Invalid waveform name: ${name}`); + } + await this.command(`waveform recreate ${name}`); + } + waveforms(): WaveformController[] { return Array.from(this.waveformControllers.values()); } @@ -1503,6 +1654,12 @@ class RadioImpl { return new SliceControllerImpl(this, change.id); }, ); + if (change.removed) { + this.autoDxControllers.delete(change.id); + for (const childChange of this.store.removeSliceChildren(change.id)) { + this.handleStateChange(childChange); + } + } break; case "panadapter": this.updateController(change as Identified, this.panControllers, () => { @@ -1579,6 +1736,44 @@ class RadioImpl { }, ); break; + case "autoDx": + this.updateController( + change as Extract & + AutoDxStateChange, + this.autoDxControllers, + () => { + if (!this.store.getSlice(change.id)) return undefined; + return new AutoDxControllerImpl(this, change.id); + }, + ); + break; + case "cwDecoder": + this.sliceControllers + .get(change.id) + ?.onCwDecoderChange(change as CwDecoderStateChange); + break; + case "preset": + this.updateController( + change as Extract & + PresetStateChange, + this.presetControllers, + () => { + if (!this.store.getPreset(change.id)) return undefined; + return new PresetControllerImpl(this, change.id); + }, + ); + break; + case "ntpSource": + this.updateController( + change as Extract & + NtpSourceStateChange, + this.ntpSourceControllers, + () => { + if (!this.store.getNtpSource(change.id)) return undefined; + return new NtpSourceControllerImpl(this, change.id); + }, + ); + break; case "displayMarker": this.updateDisplayMarkerController(change as DisplayMarkerStateChange); break; @@ -1901,6 +2096,7 @@ class RadioImpl { this.command("profile tx info"), this.command("profile mic info"), this.command("profile display info"), + this.command("profile cw info"), ]); } @@ -1959,9 +2155,10 @@ class RadioImpl { async setNickname(nickname: string): Promise { const sanitized = sanitizeNickname(nickname); - await this.commandAndPatch(`radio name ${sanitized}`, { - nickname: sanitized, - }); + await this.commandAndPatch( + sanitized ? `radio name ${sanitized}` : "radio clear_name", + { nickname: sanitized }, + ); } async setCallsign(callsign: string): Promise { @@ -2331,6 +2528,10 @@ class RadioImpl { await this.setTransmitInteger("vox_level", level, 0, 100, "VOX level"); } + async setPttMicOverride(enabled: boolean): Promise { + await this.setTransmitBoolean("ptt_mic_override", enabled); + } + async setVoxDelay(delay: number): Promise { await this.setTransmitInteger("vox_delay", delay, 0, 100, "VOX delay"); } @@ -2398,6 +2599,20 @@ class RadioImpl { }); } + async setDebugFakePaOvertemp(enabled: boolean): Promise { + const encoded = formatBooleanFlag(enabled); + await this.commandAndPatch(`radio set debug_fake_pa_overtemp=${encoded}`, { + debug_fake_pa_overtemp: encoded, + }); + } + + async setDebugFakePaSwrFault(enabled: boolean): Promise { + const encoded = formatBooleanFlag(enabled); + await this.commandAndPatch(`radio set debug_fake_pa_swr_fault=${encoded}`, { + debug_fake_pa_swr_fault: encoded, + }); + } + async setMuteLocalAudioWhenRemote(enabled: boolean): Promise { const encoded = formatBooleanFlag(enabled); await this.commandAndPatch( @@ -2634,6 +2849,22 @@ class RadioImpl { await this.loadProfileSelection("tx", name); } + async loadCwProfile(name: string): Promise { + await this.loadProfileSelection("cw", name); + } + + async createCwProfile(name: string): Promise { + await this.sendProfileCommand("profile cw create", name); + } + + async resetCwProfile(name: string): Promise { + await this.sendProfileCommand("profile cw reset", name); + } + + async deleteCwProfile(name: string): Promise { + await this.sendProfileCommand("profile cw delete", name); + } + async loadDisplayProfile(name: string): Promise { await this.loadProfileSelection("display", name); } @@ -2733,6 +2964,8 @@ class RadioImpl { } } this.handleSpotTriggered(parsed); + this.handlePresetCreated(parsed); + this.handleWaveformRestarted(parsed); this.handleFileUpdate(parsed); break; case "reply": @@ -2745,6 +2978,29 @@ class RadioImpl { } } + private handleWaveformRestarted(message: FlexStatusMessage): void { + if (message.source !== "waveform" || message.identifier !== "restarted") { + return; + } + const { name, result } = message.attributes; + if (!name) return; + this.events.emit("waveformRestarted", { + name, + success: result?.toLowerCase() !== "error", + result, + }); + } + + /** A bare `created` status carries no fields, so subscribe again to get them. */ + private handlePresetCreated(message: FlexStatusMessage): void { + if (message.source !== "preset" || !("created" in message.attributes)) { + return; + } + this.command("sub preset all").catch(() => { + // The next connect subscribes again; a refused subscribe leaves the list as it was. + }); + } + private handleSpotTriggered(message: FlexStatusMessage): void { if (message.source !== "spot" || !message.positional.includes("triggered")) return; @@ -3079,6 +3335,9 @@ class RadioImpl { this.equalizerControllers.clear(); this.txBandControllers.clear(); this.xvtrControllers.clear(); + this.autoDxControllers.clear(); + this.presetControllers.clear(); + this.ntpSourceControllers.clear(); this.tnfControllers.clear(); this.memoryControllers.clear(); this.spotControllers.clear(); diff --git a/packages/flexlib/src/flex/slice.ts b/packages/flexlib/src/flex/slice.ts index 22a0cbf7..3b0b7926 100644 --- a/packages/flexlib/src/flex/slice.ts +++ b/packages/flexlib/src/flex/slice.ts @@ -1,5 +1,7 @@ import { type Subscription, TypedEventEmitter } from "../util/events.js"; +import type { AutoDxController } from "./autodx.js"; import { + clampInteger, formatBooleanFlag, formatInteger, formatMegahertz, @@ -7,15 +9,54 @@ import { import { FlexError, FlexStateUnavailableError } from "./errors.js"; import type { RadioSession } from "./radio-core.js"; import type { + CwDecoderSnapshot, + CwDecoderStateChange, SliceAgcMode, + SliceAgcThresholdMode, + SliceDspAdaptMode, + SliceDspIsdftMode, SliceRepeaterOffsetDirection, SliceSnapshot, SliceStateChange, SliceToneMode, } from "./state/index.js"; +const ADAPT_MODES: ReadonlySet = new Set(["auto", "fast", "slow"]); +const ISDFT_MODES: ReadonlySet = new Set(["one", "half", "zero"]); +const DSP_RESET_ALGORITHMS: ReadonlySet = new Set([ + "all", + "nr", + "anf", + "lms_nr", + "lms_anf", + "nrf", + "speex_nr", + "anft", +]); + +/** DSP algorithm names accepted by {@link SliceController.resetDspParameters}. */ +export type SliceDspResetAlgorithm = + | "all" + | "nr" + | "anf" + | "lms_nr" + | "lms_anf" + | "nrf" + | "speex_nr" + | "anft"; + +function clampDsp(value: number, min: number, max: number): string { + return clampInteger(value, min, max, "DSP parameter").toString(10); +} + +function isPowerOfTwo(value: number): boolean { + return Number.isInteger(value) && value > 0 && (value & (value - 1)) === 0; +} + export interface SliceControllerEvents { readonly change: SliceStateChange; + /** The CW decoder state on this slice changed. */ + readonly cwDecoder: CwDecoderStateChange; } export interface SliceUpdateRequest { @@ -52,7 +93,6 @@ export interface SliceUpdateRequest { rnnEnabled?: boolean; anftEnabled?: boolean; nrfEnabled?: boolean; - nrfLevel?: number; escEnabled?: boolean; escGain?: number; escPhaseShift?: number; @@ -88,6 +128,13 @@ export interface SliceUpdateRequest { export interface SliceController extends Readonly> { snapshot(): SliceSnapshot; + /** + * State of the CW decoder on this slice, or `undefined` until the radio + * reports one. Decoded text is not a DSP setting of the slice. + */ + readonly cwDecoder: CwDecoderSnapshot | undefined; + /** AutoDX controller for this slice. It is available before the radio reports any AutoDX state. */ + readonly autoDx: AutoDxController; on( event: TKey, listener: (payload: SliceControllerEvents[TKey]) => void, @@ -207,7 +254,7 @@ export interface SliceController extends Readonly> { */ setNrsEnabled(enabled: boolean): Promise; /** - * Sets the spectral subtraction noise reduction (NRS) level from 0 to 100. + * Sets the spectral subtraction noise reduction (NRS) level from 0 to 50. Values outside that range are clamped. */ setNrsLevel(level: number): Promise; /** @@ -222,10 +269,56 @@ export interface SliceController extends Readonly> { * Enables or disables noise reduction with filter (NRF) for the slice. */ setNrfEnabled(enabled: boolean): Promise; + /** Sets the NR delay, clamped to 1 to 512. */ + setNrDelay(delay: number): Promise; + /** + * Sets the NR window length, clamped to 32 to 512. A value that is not a + * power of two is not sent. + */ + setNrWlen(length: number): Promise; + /** Sets the NR adapt mode. A value outside the allowed set is not sent. */ + setNrAdaptMode(mode: SliceDspAdaptMode): Promise; + /** Sets which time index NR uses in its inverse SDFT. A value outside the allowed set is not sent. */ + setNrIsdftMode(mode: SliceDspIsdftMode): Promise; + /** Sets the LMS NR (NRL) filter size, clamped to 1 to 512. */ + setNrlFilterSize(size: number): Promise; + /** Sets the LMS NR (NRL) delay, clamped to 1 to 512. */ + setNrlDelay(delay: number): Promise; + /** Sets the LMS NR (NRL) leakage level, clamped to 0 to 100. */ + setNrlLeakageLevel(level: number): Promise; + /** + * Sets the NRF window length, clamped to 512 to 8192. A value that is not a + * power of two is not sent. + */ + setNrfWlen(length: number): Promise; /** - * Sets the noise reduction with filter (NRF) level from 0 to 100. + * Sets the ANF window length, clamped to 32 to 512. A value that is not a + * power of two is not sent. */ - setNrfLevel(level: number): Promise; + setAnfWlen(length: number): Promise; + /** Sets the ANF delay, clamped to 1 to 512. */ + setAnfDelay(delay: number): Promise; + /** Sets the ANF adapt mode. A value outside the allowed set is not sent. */ + setAnfAdaptMode(mode: SliceDspAdaptMode): Promise; + /** Sets which time index ANF uses in its inverse SDFT. A value outside the allowed set is not sent. */ + setAnfIsdftMode(mode: SliceDspIsdftMode): Promise; + /** Sets the LMS ANF (ANFL) filter size, clamped to 1 to 512. */ + setAnflFilterSize(size: number): Promise; + /** Sets the LMS ANF (ANFL) delay, clamped to 1 to 512. */ + setAnflDelay(delay: number): Promise; + /** Sets the LMS ANF (ANFL) leakage level, clamped to 0 to 100. */ + setAnflLeakageLevel(level: number): Promise; + /** Sets the minimum level in dB for the FFT auto-notch (ANFT). */ + setAnftMinDb(db: number): Promise; + /** Sets the tone-to-noise difference in dB for the FFT auto-notch (ANFT). */ + setAnftToneDbDiff(db: number): Promise; + /** Sets whether the AGC threshold follows the radio (`auto`) or the caller (`manual`). */ + setAgcThresholdMode(mode: SliceAgcThresholdMode): Promise; + /** + * Resets the tuning parameters of one DSP algorithm, or of all of them, to + * the radio's defaults. + */ + resetDspParameters(algorithm: SliceDspResetAlgorithm): Promise; /** * Enables or disables ESC (Enhanced Signal Clarity) processing for the slice. */ @@ -591,12 +684,84 @@ export class SliceControllerImpl implements SliceController { return this.current().anftEnabled; } - get nrfEnabled(): boolean { - return this.current().nrfEnabled; + get nrDelay(): number | undefined { + return this.current().nrDelay; } - get nrfLevel(): number { - return this.current().nrfLevel; + get nrWlen(): number | undefined { + return this.current().nrWlen; + } + + get nrlFilterSize(): number | undefined { + return this.current().nrlFilterSize; + } + + get nrlDelay(): number | undefined { + return this.current().nrlDelay; + } + + get nrlLeakageLevel(): number | undefined { + return this.current().nrlLeakageLevel; + } + + get nrfWlen(): number | undefined { + return this.current().nrfWlen; + } + + get anfWlen(): number | undefined { + return this.current().anfWlen; + } + + get anfDelay(): number | undefined { + return this.current().anfDelay; + } + + get anflFilterSize(): number | undefined { + return this.current().anflFilterSize; + } + + get anflDelay(): number | undefined { + return this.current().anflDelay; + } + + get anflLeakageLevel(): number | undefined { + return this.current().anflLeakageLevel; + } + + get anftMinDb(): number | undefined { + return this.current().anftMinDb; + } + + get anftToneDbDiff(): number | undefined { + return this.current().anftToneDbDiff; + } + + get nrAdaptMode(): SliceDspAdaptMode | undefined { + return this.current().nrAdaptMode; + } + + get nrIsdftMode(): SliceDspIsdftMode | undefined { + return this.current().nrIsdftMode; + } + + get anfAdaptMode(): SliceDspAdaptMode | undefined { + return this.current().anfAdaptMode; + } + + get anfIsdftMode(): SliceDspIsdftMode | undefined { + return this.current().anfIsdftMode; + } + + get agcThresholdMode(): SliceAgcThresholdMode | undefined { + return this.current().agcThresholdMode; + } + + get chainMode(): string | undefined { + return this.current().chainMode; + } + + get nrfEnabled(): boolean { + return this.current().nrfEnabled; } get escEnabled(): boolean { @@ -893,7 +1058,9 @@ export class SliceControllerImpl implements SliceController { } async setNrsLevel(level: number): Promise { - await this.sendSet({ speex_nr_level: formatInteger(level) }); + await this.sendSet({ + speex_nr_level: formatInteger(clampInteger(level, 0, 50, "NRS level")), + }); } async setRnnEnabled(enabled: boolean): Promise { @@ -904,12 +1071,93 @@ export class SliceControllerImpl implements SliceController { await this.sendSet({ anft: formatBooleanFlag(enabled) }); } - async setNrfEnabled(enabled: boolean): Promise { - await this.sendSet({ nrf: formatBooleanFlag(enabled) }); + async setNrDelay(delay: number): Promise { + await this.sendSet({ nr_delay: clampDsp(delay, 1, 512) }); + } + + async setNrWlen(length: number): Promise { + if (!isPowerOfTwo(length)) return; + await this.sendSet({ nr_wlen: clampDsp(length, 32, 512) }); + } + + async setNrAdaptMode(mode: SliceDspAdaptMode): Promise { + if (!ADAPT_MODES.has(mode)) return; + await this.sendSet({ nr_adapt_mode: mode }); + } + + async setNrIsdftMode(mode: SliceDspIsdftMode): Promise { + if (!ISDFT_MODES.has(mode)) return; + await this.sendSet({ nr_isdft_mode: mode }); + } + + async setNrlFilterSize(size: number): Promise { + await this.sendSet({ lms_nr_taps: clampDsp(size, 1, 512) }); + } + + async setNrlDelay(delay: number): Promise { + await this.sendSet({ lms_nr_delay: clampDsp(delay, 1, 512) }); + } + + async setNrlLeakageLevel(level: number): Promise { + await this.sendSet({ lms_nr_leakage_level: clampDsp(level, 0, 100) }); + } + + async setNrfWlen(length: number): Promise { + if (!isPowerOfTwo(length)) return; + await this.sendSet({ nrf_wlen: clampDsp(length, 512, 8192) }); + } + + async setAnfWlen(length: number): Promise { + if (!isPowerOfTwo(length)) return; + await this.sendSet({ anf_wlen: clampDsp(length, 32, 512) }); + } + + async setAnfDelay(delay: number): Promise { + await this.sendSet({ anf_delay: clampDsp(delay, 1, 512) }); + } + + async setAnfAdaptMode(mode: SliceDspAdaptMode): Promise { + if (!ADAPT_MODES.has(mode)) return; + await this.sendSet({ anf_adapt_mode: mode }); + } + + async setAnfIsdftMode(mode: SliceDspIsdftMode): Promise { + if (!ISDFT_MODES.has(mode)) return; + await this.sendSet({ anf_isdft_mode: mode }); + } + + async setAnflFilterSize(size: number): Promise { + await this.sendSet({ lms_anf_taps: clampDsp(size, 1, 512) }); } - async setNrfLevel(level: number): Promise { - await this.sendSet({ nrf_level: formatInteger(level) }); + async setAnflDelay(delay: number): Promise { + await this.sendSet({ lms_anf_delay: clampDsp(delay, 1, 512) }); + } + + async setAnflLeakageLevel(level: number): Promise { + await this.sendSet({ lms_anf_leakage_level: clampDsp(level, 0, 100) }); + } + + async setAnftMinDb(db: number): Promise { + await this.sendSet({ anft_min_db: formatInteger(db) }); + } + + async setAnftToneDbDiff(db: number): Promise { + await this.sendSet({ anft_tone_db_diff: formatInteger(db) }); + } + + async setAgcThresholdMode(mode: SliceAgcThresholdMode): Promise { + if (mode !== "auto" && mode !== "manual") return; + await this.sendSet({ agc_threshold_mode: mode }); + } + + async resetDspParameters(algorithm: SliceDspResetAlgorithm): Promise { + if (!DSP_RESET_ALGORITHMS.has(algorithm)) return; + await this.radio.command(`slice reset ${this.id} dsp_param=${algorithm}`); + } + + async setNrfEnabled(enabled: boolean): Promise { + await this.sendSet({ nrf: formatBooleanFlag(enabled) }); } async setEscEnabled(enabled: boolean): Promise { @@ -1097,6 +1345,26 @@ export class SliceControllerImpl implements SliceController { this.events.emit("change", change); } + onCwDecoderChange(change: CwDecoderStateChange): void { + this.events.emit("cwDecoder", change); + } + + get cwDecoder(): CwDecoderSnapshot | undefined { + this.current(); + return this.radio.getStore().getCwDecoder(this.id); + } + + get autoDx(): AutoDxController { + this.current(); + const controller = this.radio.autoDx(this.id); + if (!controller) { + throw new FlexStateUnavailableError( + `Slice ${this.id} is no longer available`, + ); + } + return controller; + } + private async sendSet(entries: Record): Promise { const parts = Object.entries(entries).map( ([key, value]) => `${key}=${value}`, @@ -1188,15 +1456,15 @@ export class SliceControllerImpl implements SliceController { if (request.nrsEnabled !== undefined) entries.speex_nr = formatBooleanFlag(request.nrsEnabled); if (request.nrsLevel !== undefined) - entries.speex_nr_level = formatInteger(request.nrsLevel); + entries.speex_nr_level = formatInteger( + clampInteger(request.nrsLevel, 0, 50, "NRS level"), + ); if (request.rnnEnabled !== undefined) entries.rnnoise = formatBooleanFlag(request.rnnEnabled); if (request.anftEnabled !== undefined) entries.anft = formatBooleanFlag(request.anftEnabled); if (request.nrfEnabled !== undefined) entries.nrf = formatBooleanFlag(request.nrfEnabled); - if (request.nrfLevel !== undefined) - entries.nrf_level = formatInteger(request.nrfLevel); if (request.escEnabled !== undefined) entries.esc = request.escEnabled ? "on" : "off"; if (request.escGain !== undefined) diff --git a/packages/flexlib/src/flex/state/apd.ts b/packages/flexlib/src/flex/state/apd.ts index ea259a36..a1a3f96e 100644 --- a/packages/flexlib/src/flex/state/apd.ts +++ b/packages/flexlib/src/flex/state/apd.ts @@ -25,10 +25,30 @@ export type ApdSamplerPort = | "XVTB" | (string & {}); +/** APD lifecycle state. A value outside this set is left off the snapshot and stays on `raw`. */ +export type ApdLifecycleState = + | "OFF" + | "IDLE" + | "TRAINING" + | "ACTIVE" + | "HOLD" + | "FAULT"; + +const APD_LIFECYCLE_STATES: ReadonlySet = new Set([ + "OFF", + "IDLE", + "TRAINING", + "ACTIVE", + "HOLD", + "FAULT", +]); + export interface ApdSnapshot { readonly enabled: boolean; readonly configurable: boolean; readonly equalizerActive: boolean; + /** Lifecycle state the radio reports. Unset until the radio sends one. Wire: `state`. */ + readonly state?: ApdLifecycleState; readonly antenna?: string; readonly frequencyMHz?: number; readonly rfPower?: number; @@ -36,6 +56,9 @@ export interface ApdSnapshot { readonly rxErrorMilliHz?: number; readonly sliceId?: string; readonly mmx?: number; + readonly deployed?: boolean; + readonly qualityDb?: number; + readonly backend?: string; readonly clientHandle?: number; readonly sampleIndex?: number; readonly availableSamplerPortsAnt1: readonly string[]; @@ -86,6 +109,13 @@ export function createApdSnapshot( case "equalizer_active": partial.equalizerActive = isTruthy(value); break; + case "state": { + const upper = value.toUpperCase(); + if (APD_LIFECYCLE_STATES.has(upper)) { + partial.state = upper as ApdLifecycleState; + } + break; + } case "equalizer_reset": partial.equalizerActive = false; break; @@ -144,6 +174,18 @@ export function createApdSnapshot( case "selected_sampler": case "valid_samplers": break; + case "deployed": + partial.deployed = isTruthy(value); + break; + case "quality_db": { + const parsed = parseFloatSafe(value); + if (parsed !== undefined) partial.qualityDb = parsed; + else if (value) logParseError("apd", key, value); + break; + } + case "backend": + partial.backend = value || undefined; + break; default: logUnknownAttribute("apd", key, value); break; diff --git a/packages/flexlib/src/flex/state/audio-stream.ts b/packages/flexlib/src/flex/state/audio-stream.ts index cf29df60..70a36b2b 100644 --- a/packages/flexlib/src/flex/state/audio-stream.ts +++ b/packages/flexlib/src/flex/state/audio-stream.ts @@ -33,6 +33,15 @@ export interface AudioStreamSnapshot { readonly clientGuiHandle?: number; readonly payloadEndian?: string; readonly slice?: string; + /** RX gain (0-100) the radio reports for this stream. */ + readonly rxGain?: number; + /** + * Sample rate of the audio on this stream in Hz, or 0 while unknown. The + * store keeps 0; the stream controller fills in the live value: the followed + * slice's rate for DAX RX and DAX TX, and the rate from the last received + * packet class for DAX mic. + */ + readonly sampleRateHz: number; readonly tx: boolean; readonly raw: Readonly>; } @@ -54,7 +63,7 @@ export function createAudioStreamSnapshot( const rawDiff = freezeAttributes(attributes); const partial: Mutable> = previous ? {} - : { id, radioAck: false, tx: false }; + : { id, radioAck: false, tx: false, sampleRateHz: 0 }; for (const [key, value] of Object.entries(attributes)) { switch (key) { @@ -122,6 +131,12 @@ export function createAudioStreamSnapshot( case "payload_endian": partial.payloadEndian = value; break; + case "gain": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.rxGain = parsed; + else logParseError("audio_stream", key, value); + break; + } default: logUnknownAttribute("audio_stream", key, value); break; diff --git a/packages/flexlib/src/flex/state/autodx.ts b/packages/flexlib/src/flex/state/autodx.ts new file mode 100644 index 00000000..7552ab2b --- /dev/null +++ b/packages/flexlib/src/flex/state/autodx.ts @@ -0,0 +1,183 @@ +import type { Mutable, SnapshotUpdate } from "./common.js"; +import { freezeAttributes, parseInteger } from "./common.js"; +import { decodeCwxText } from "./cwx.js"; + +/** Lifecycle state of the AutoDX controller. */ +export type AutoDxState = + | "Disabled" + | "Monitor" + | "TxDelay" + | "Calling" + | "AwaitReply" + | "SendExchange" + | "Log" + | "Unknown"; + +/** + * Which copied tokens may put the radio on the air once the DX call has been + * copied. `unknown` is a trigger this library does not know; it is reported, + * never sent. + */ +export type AutoDxTrigger = "either" | "call_up" | "up" | "unknown"; + +/** + * Immutable snapshot of the AutoDX controller for one slice. The controller + * runs on the radio; its configuration and state arrive on the `autodx` status. + */ +export interface AutoDxSnapshot { + /** Id of the slice the controller is bound to. */ + readonly sliceId: string; + /** Wire: `state`. */ + readonly state: AutoDxState; + /** Whether the controller is armed. Wire: `enabled`. */ + readonly enabled: boolean; + /** DX call to chase. Wire: `detect`. */ + readonly detect: string; + /** Wire: `trigger`. */ + readonly trigger: AutoDxTrigger; + /** Exchange to send. Wire: `exchange`. */ + readonly exchange: string; + /** Wait for a reply after calling, in ms. 0 lets the controller choose. Wire: `await_reply_ms`. */ + readonly awaitReplyMs: number; + /** Delay before transmitting, in ms. 0 lets the controller choose. Wire: `tx_delay_ms`. */ + readonly txDelayMs: number; + /** QSO timeout in ms. 0 lets the controller choose. Wire: `timeout_ms`. */ + readonly timeoutMs: number; + /** Whether the timeout restarts on activity. Wire: `timeout_restart`. */ + readonly timeoutRestart: boolean; + /** Number of times to repeat the call. Wire: `call_retries`. */ + readonly callRetries: number; + /** Call of the station being worked. Wire: `dx_call`. */ + readonly dxCall: string; + /** Report received from the DX. Wire: `rx_rst`. */ + readonly rxRst: string; + /** Exchange copied from the DX. Wire: `rx_exchange`. */ + readonly rxExchange: string; + /** Number of QSOs logged. Wire: `qso_count`. */ + readonly qsoCount: number; + /** + * Last transmission sequence number the radio reported. The first one seen + * is a baseline, not a transmission. Wire: `sent_seq`. + */ + readonly sentSeq?: number; + /** Text of the most recent transmission after the baseline. Wire: `sent_text`. */ + readonly lastSentText: string; + /** Number of transmissions reported after the baseline. */ + readonly sentCount: number; + readonly raw: Readonly>; +} + +const STATE_BY_WIRE: Readonly> = { + disabled: "Disabled", + monitor: "Monitor", + calling: "Calling", + tx_delay: "TxDelay", + await_reply: "AwaitReply", + send_exchange: "SendExchange", + log: "Log", +}; + +const TRIGGERS: ReadonlySet = new Set(["either", "call_up", "up"]); + +export function createAutoDxSnapshot( + sliceId: string, + attributes: Record, + previous?: AutoDxSnapshot, +): SnapshotUpdate { + const rawDiff = freezeAttributes(attributes); + const partial: Mutable> = previous + ? {} + : { + sliceId, + state: "Disabled", + enabled: false, + detect: "", + trigger: "either", + exchange: "", + awaitReplyMs: 0, + txDelayMs: 0, + timeoutMs: 0, + timeoutRestart: false, + callRetries: 0, + dxCall: "", + rxRst: "", + rxExchange: "", + qsoCount: 0, + lastSentText: "", + sentCount: 0, + }; + + for (const [key, value] of Object.entries(attributes)) { + switch (key) { + case "state": + partial.state = STATE_BY_WIRE[value.toLowerCase()] ?? "Unknown"; + break; + case "enabled": + partial.enabled = value === "1"; + break; + case "detect": + partial.detect = value; + break; + case "exchange": + partial.exchange = value; + break; + case "trigger": + partial.trigger = TRIGGERS.has(value) + ? (value as AutoDxTrigger) + : "unknown"; + break; + case "await_reply_ms": + case "tx_delay_ms": + case "timeout_ms": + case "call_retries": + case "qso_count": { + const parsed = parseInteger(value); + if (parsed === undefined) break; + if (key === "await_reply_ms") partial.awaitReplyMs = parsed; + else if (key === "tx_delay_ms") partial.txDelayMs = parsed; + else if (key === "timeout_ms") partial.timeoutMs = parsed; + else if (key === "call_retries") partial.callRetries = parsed; + else partial.qsoCount = parsed; + break; + } + case "timeout_restart": + partial.timeoutRestart = value === "1"; + break; + case "dx_call": + partial.dxCall = value; + break; + case "rx_rst": + partial.rxRst = value; + break; + case "rx_exchange": + partial.rxExchange = decodeCwxText(value); + break; + case "sent_text": + partial.lastSentText = value; + break; + } + } + + if ("sent_seq" in attributes) { + const seq = parseInteger(attributes.sent_seq); + if (seq !== undefined) { + const seen = previous?.sentSeq; + if (seen === undefined) { + // The first sequence only sets the baseline. A transmission it + // describes happened before this client arrived, so it is not counted. + partial.sentSeq = seq; + } else if (seq !== seen) { + partial.sentSeq = seq; + const text = partial.lastSentText ?? previous?.lastSentText ?? ""; + if (text.length > 0) partial.sentCount = (previous?.sentCount ?? 0) + 1; + } + } + } + + const snapshot = Object.freeze({ + ...(previous ?? {}), + ...partial, + raw: Object.freeze({ ...previous?.raw, ...attributes }), + }) as AutoDxSnapshot; + return { snapshot, diff: Object.freeze(partial), rawDiff }; +} diff --git a/packages/flexlib/src/flex/state/cw-decoder.ts b/packages/flexlib/src/flex/state/cw-decoder.ts new file mode 100644 index 00000000..ee5dfbc4 --- /dev/null +++ b/packages/flexlib/src/flex/state/cw-decoder.ts @@ -0,0 +1,92 @@ +import type { Mutable, SnapshotUpdate } from "./common.js"; +import { freezeAttributes, parseFloatSafe, parseInteger } from "./common.js"; +import { decodeCwxText } from "./cwx.js"; + +/** + * Immutable snapshot of the CW decoder running on one slice. The radio + * reports it on the `cw` status object, routed by the slice index. + */ +export interface CwDecoderSnapshot { + /** Id of the slice the decoder runs on. */ + readonly sliceId: string; + /** Whether a decoder is running on the slice. Wire: `active`. */ + readonly active: boolean; + /** Most recently decoded text. Wire: `text`. */ + readonly text: string; + /** Estimated sending speed in words per minute. Wire: `wpm`. */ + readonly wpm: number; + /** Estimated signal-to-noise ratio in dB. Wire: `snr`. */ + readonly snrDb: number; + /** Whether the decoder timing is locked to the signal. Wire: `lock`. */ + readonly locked: boolean; + /** Effective tone pitch in Hz. Wire: `pitch`. */ + readonly pitchHz: number; + /** Whether the decoder copies on the lower sideband. Wire: `cwl`. */ + readonly lowerSideband: boolean; + readonly raw: Readonly>; +} + +export function createCwDecoderSnapshot( + sliceId: string, + attributes: Record, + previous?: CwDecoderSnapshot, +): SnapshotUpdate { + const rawDiff = freezeAttributes(attributes); + const partial: Mutable> = previous + ? {} + : { + sliceId, + active: false, + text: "", + wpm: 0, + snrDb: 0, + locked: false, + pitchHz: 0, + lowerSideband: false, + }; + + for (const [key, value] of Object.entries(attributes)) { + switch (key) { + case "active": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.active = parsed !== 0; + break; + } + case "lock": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.locked = parsed !== 0; + break; + } + case "cwl": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.lowerSideband = parsed !== 0; + break; + } + case "wpm": { + const parsed = parseFloatSafe(value); + if (parsed !== undefined) partial.wpm = parsed; + break; + } + case "snr": { + const parsed = parseFloatSafe(value); + if (parsed !== undefined) partial.snrDb = parsed; + break; + } + case "pitch": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.pitchHz = parsed; + break; + } + case "text": + partial.text = decodeCwxText(value); + break; + } + } + + const snapshot = Object.freeze({ + ...(previous ?? {}), + ...partial, + raw: Object.freeze({ ...previous?.raw, ...attributes }), + }) as CwDecoderSnapshot; + return { snapshot, diff: Object.freeze(partial), rawDiff }; +} diff --git a/packages/flexlib/src/flex/state/cwx.ts b/packages/flexlib/src/flex/state/cwx.ts index eb8d6617..305c7ed2 100644 --- a/packages/flexlib/src/flex/state/cwx.ts +++ b/packages/flexlib/src/flex/state/cwx.ts @@ -113,6 +113,24 @@ export function parseCwxProgressEvents( return events.length > 0 ? Object.freeze(events) : NO_PROGRESS; } +const CWX_SPACE = "\x7f"; +const CWX_QUOTE = "\x1e"; + +/** + * Encodes CW text for a quoted command argument. A double quote becomes a + * substitute byte so it cannot end the argument. Spaces also become a + * substitute byte unless `spaces` is false (macro text keeps its spaces). + */ +export function encodeCwxText(text: string, spaces = true): string { + const quoted = text.replaceAll('"', CWX_QUOTE); + return spaces ? quoted.replaceAll(" ", CWX_SPACE) : quoted; +} + +/** Reverses {@link encodeCwxText} for text the radio sends back. */ +export function decodeCwxText(text: string): string { + return text.replaceAll(CWX_QUOTE, '"').replaceAll(CWX_SPACE, " "); +} + /** * Parses wire attributes into an immutable {@link CwxSnapshot}. * @@ -138,7 +156,7 @@ export function createCwxSnapshot( if (!macros) { macros = [...(previous?.macros ?? EMPTY_MACROS)]; } - macros[index] = value; + macros[index] = decodeCwxText(value); macrosChanged = true; } continue; diff --git a/packages/flexlib/src/flex/state/feature-license.ts b/packages/flexlib/src/flex/state/feature-license.ts index 1c92dc30..4854e91f 100644 --- a/packages/flexlib/src/flex/state/feature-license.ts +++ b/packages/flexlib/src/flex/state/feature-license.ts @@ -19,8 +19,22 @@ export interface FeatureLicenseFeature { readonly name: string; readonly enabled: boolean; readonly reason: FeatureLicenseReason; + /** + * True when the feature is enabled and was bought outright or earned through + * SmartSDR+ or Early Access, which the radio keeps after the subscription + * ends. The alpha-program feature is granted through the license file but + * can be withdrawn, so it is never permanent. + */ + readonly permanent: boolean; } +const ALPHA_FEATURE_NAME = "alpha"; +const PERMANENT_REASONS: ReadonlySet = new Set([ + "license_file", + "plus", + "ea", +]); + export interface FeatureLicenseSubscription { readonly name: string; readonly expiration: Date; @@ -123,10 +137,16 @@ function applyFeatureAttributes( return; } + const enabled = Boolean(enabledNumber); + const reason = normalizeReason(reasonValue); const feature: FeatureLicenseFeature = Object.freeze({ name: normalizedName, - enabled: Boolean(enabledNumber), - reason: normalizeReason(reasonValue), + enabled, + reason, + permanent: + enabled && + normalizedName !== ALPHA_FEATURE_NAME && + PERMANENT_REASONS.has(reason), }); partial.features = Object.freeze({ diff --git a/packages/flexlib/src/flex/state/index.ts b/packages/flexlib/src/flex/state/index.ts index 1a563088..ad13dbd8 100644 --- a/packages/flexlib/src/flex/state/index.ts +++ b/packages/flexlib/src/flex/state/index.ts @@ -7,12 +7,17 @@ import { AUDIO_STREAM_TYPES, createAudioStreamSnapshot, } from "./audio-stream.js"; +import { type AutoDxSnapshot, createAutoDxSnapshot } from "./autodx.js"; import type { SnapshotDiff } from "./common.js"; import { parseInteger, parseIntegerHex, setRadioStateLogger, } from "./common.js"; +import { + type CwDecoderSnapshot, + createCwDecoderSnapshot, +} from "./cw-decoder.js"; import { type CwxProgressEvent, type CwxSnapshot, @@ -46,10 +51,15 @@ import { } from "./gui-client.js"; import { createMemorySnapshot, type MemorySnapshot } from "./memory.js"; import { createMeterSnapshot, type MeterSnapshot } from "./meter.js"; +import { + createNtpSourceSnapshot, + type NtpSourceSnapshot, +} from "./ntp-source.js"; import { createPanadapterSnapshot, type PanadapterSnapshot, } from "./panadapter.js"; +import { createPresetSnapshot, type PresetSnapshot } from "./preset.js"; import { createRadioSnapshot, type RadioSnapshot, @@ -74,9 +84,20 @@ import { } from "./waveform.js"; import { createXvtrSnapshot, type XvtrSnapshot } from "./xvtr.js"; -export type { ApdSamplerPort, ApdSnapshot, ApdTxAntenna } from "./apd.js"; +export type { + ApdLifecycleState, + ApdSamplerPort, + ApdSnapshot, + ApdTxAntenna, +} from "./apd.js"; export type { AudioStreamKind, AudioStreamSnapshot } from "./audio-stream.js"; +export type { + AutoDxSnapshot, + AutoDxState, + AutoDxTrigger, +} from "./autodx.js"; export type { SnapshotDiff } from "./common.js"; +export type { CwDecoderSnapshot } from "./cw-decoder.js"; export type { CwxProgressEvent, CwxSnapshot } from "./cwx.js"; export type { DisplayMarkerSnapshot } from "./display-marker.js"; export type { DvkRecording, DvkSnapshot, DvkStatus } from "./dvk.js"; @@ -95,7 +116,9 @@ export type { GuiClientSnapshot } from "./gui-client.js"; export type { MemorySnapshot } from "./memory.js"; export type { KnownMeterUnits, MeterSnapshot, MeterUnits } from "./meter.js"; export { KNOWN_METER_UNITS } from "./meter.js"; +export type { NtpDirective, NtpSourceSnapshot } from "./ntp-source.js"; export type { PanadapterSnapshot } from "./panadapter.js"; +export type { PresetSnapshot } from "./preset.js"; export type { RadioAtuTuneStatus, RadioCwIambicMode, @@ -111,6 +134,9 @@ export type { } from "./radio.js"; export type { SliceAgcMode, + SliceAgcThresholdMode, + SliceDspAdaptMode, + SliceDspIsdftMode, SliceRepeaterOffsetDirection, SliceSnapshot, SliceToneMode, @@ -151,6 +177,10 @@ export type RadioStateChange = } & ChangeMetadata) | ({ entity: "guiClient"; id: string } & ChangeMetadata) | ({ entity: "xvtr"; id: string } & ChangeMetadata) + | ({ entity: "cwDecoder"; id: string } & ChangeMetadata) + | ({ entity: "autoDx"; id: string } & ChangeMetadata) + | ({ entity: "preset"; id: string } & ChangeMetadata) + | ({ entity: "ntpSource"; id: string } & ChangeMetadata) | ({ entity: "spot"; id: string } & ChangeMetadata) | DisplayMarkerStateChange | ({ entity: "tnf"; id: string } & ChangeMetadata) @@ -205,6 +235,16 @@ export type TxBandSettingStateChange = Extract< { entity: "txBandSetting" } >; export type XvtrStateChange = Extract; +export type CwDecoderStateChange = Extract< + RadioStateChange, + { entity: "cwDecoder" } +>; +export type AutoDxStateChange = Extract; +export type PresetStateChange = Extract; +export type NtpSourceStateChange = Extract< + RadioStateChange, + { entity: "ntpSource" } +>; export type SpotStateChange = Extract; export type CwxStateChange = Extract; export type DvkStateChange = Extract; @@ -225,6 +265,10 @@ export interface RadioStateSnapshot { readonly equalizers: readonly EqualizerSnapshot[]; readonly txBandSettings: readonly TxBandSettingSnapshot[]; readonly xvtrs: readonly XvtrSnapshot[]; + readonly cwDecoders: readonly CwDecoderSnapshot[]; + readonly autoDxes: readonly AutoDxSnapshot[]; + readonly presets: readonly PresetSnapshot[]; + readonly ntpSources: readonly NtpSourceSnapshot[]; readonly spots: readonly SpotSnapshot[]; readonly displayMarkers: readonly DisplayMarkerSnapshot[]; readonly tnfs: readonly TnfSnapshot[]; @@ -255,6 +299,30 @@ export interface RadioStateStore { getEqualizers(): readonly EqualizerSnapshot[]; getTxBandSetting(id: string): TxBandSettingSnapshot | undefined; getTxBandSettings(): readonly TxBandSettingSnapshot[]; + /** CW decoder state for a slice, keyed by slice id. */ + getCwDecoder(sliceId: string): CwDecoderSnapshot | undefined; + /** AutoDX state for a slice, keyed by slice id. Absent until the radio reports it or a setter runs. */ + getAutoDx(sliceId: string): AutoDxSnapshot | undefined; + /** + * Writes AutoDX fields for a live slice, creating the default state first + * when none exists. Returns nothing when the slice is gone. + */ + patchAutoDx( + sliceId: string, + attributes: Record, + ): AutoDxStateChange | undefined; + /** Drops the CW decoder and AutoDX state of a removed slice. */ + removeSliceChildren(sliceId: string): readonly RadioStateChange[]; + getPreset(id: string): PresetSnapshot | undefined; + getPresets(): readonly PresetSnapshot[]; + /** Id of the last preset the radio confirmed applying since connect, if any. */ + getActivePresetId(): string | undefined; + getNtpSource(id: string): NtpSourceSnapshot | undefined; + getNtpSources(): readonly NtpSourceSnapshot[]; + patchNtpSource( + id: string, + attributes: Record, + ): NtpSourceStateChange | undefined; getXvtr(id: string): XvtrSnapshot | undefined; getXvtrs(): readonly XvtrSnapshot[]; getSpot(id: string): SpotSnapshot | undefined; @@ -362,6 +430,9 @@ export interface RadioStateStoreOptions { readonly logger?: Pick; } +/** Meter source code shared by amplifiers and tuners. */ +const AMPLIFIER_METER_SOURCE = "AMP"; + export function createRadioStateStore( options: RadioStateStoreOptions = {}, ): RadioStateStore { @@ -376,6 +447,11 @@ export function createRadioStateStore( const equalizers = new Map(); const txBandSettings = new Map(); const xvtrs = new Map(); + const presets = new Map(); + const cwDecoders = new Map(); + const autoDxes = new Map(); + const ntpSources = new Map(); + let activePresetId: string | undefined; const spots = new Map(); const displayMarkers = new Map>(); const tnfs = new Map(); @@ -418,6 +494,11 @@ export function createRadioStateStore( equalizers.clear(); txBandSettings.clear(); xvtrs.clear(); + presets.clear(); + cwDecoders.clear(); + autoDxes.clear(); + ntpSources.clear(); + activePresetId = undefined; spots.clear(); displayMarkers.clear(); tnfs.clear(); @@ -443,10 +524,12 @@ export function createRadioStateStore( case "stream": return [handleStream(message)]; case "meter": - return [handleMeter(message)]; + return handleMeter(message); case "radio": case "gps": return [handleRadio(message)]; + case "ha_api": + return message.identifier === "system" ? [handleRadio(message)] : []; case "interlock": case "transmit": { if (message.identifier === "band") { @@ -465,6 +548,14 @@ export function createRadioStateStore( } case "xvtr": return [handleXvtr(message)]; + case "cw": + return handleCwDecoder(message); + case "autodx": + return handleAutoDx(message); + case "preset": + return handlePreset(message); + case "ntp_sources": + return [handleNtpSource(message)]; case "tnf": return [handleTnf(message)]; case "memory": @@ -547,6 +638,10 @@ export function createRadioStateStore( equalizers: Array.from(equalizers.values()), txBandSettings: Array.from(txBandSettings.values()), xvtrs: Array.from(xvtrs.values()), + cwDecoders: Array.from(cwDecoders.values()), + autoDxes: Array.from(autoDxes.values()), + presets: Array.from(presets.values()), + ntpSources: Array.from(ntpSources.values()), spots: Array.from(spots.values()), displayMarkers: flattenDisplayMarkers(), tnfs: Array.from(tnfs.values()), @@ -596,6 +691,43 @@ export function createRadioStateStore( getTxBandSettings() { return Array.from(txBandSettings.values()); }, + getCwDecoder(sliceId) { + return cwDecoders.get(sliceId); + }, + getAutoDx(sliceId) { + return autoDxes.get(sliceId); + }, + patchAutoDx(sliceId, attributes) { + return patchAutoDx(sliceId, attributes); + }, + removeSliceChildren(sliceId) { + const changes: RadioStateChange[] = []; + if (cwDecoders.delete(sliceId)) { + changes.push({ entity: "cwDecoder", id: sliceId, removed: true }); + } + if (autoDxes.delete(sliceId)) { + changes.push({ entity: "autoDx", id: sliceId, removed: true }); + } + return changes; + }, + getPreset(id) { + return presets.get(id); + }, + getPresets() { + return Array.from(presets.values()); + }, + getActivePresetId() { + return activePresetId; + }, + getNtpSource(id) { + return ntpSources.get(id); + }, + getNtpSources() { + return Array.from(ntpSources.values()); + }, + patchNtpSource(id, attributes) { + return patchNtpSource(id, attributes); + }, getXvtr(id) { return xvtrs.get(id); }, @@ -1180,27 +1312,25 @@ export function createRadioStateStore( }; } - function handleMeter(message: FlexStatusMessage): RadioStateChange { + function handleMeter(message: FlexStatusMessage): RadioStateChange[] { const id = resolveIdentifier( message, message.identifier, message.attributes.num, ); if (!id) { - return { - entity: "unknown", - source: message.source, - attributes: message.attributes, - }; + return [ + { + entity: "unknown", + source: message.source, + attributes: message.attributes, + }, + ]; } if (isMarkedDeleted(message)) { meters.delete(id); - return { - entity: "meter", - id, - removed: true, - }; + return [{ entity: "meter", id, removed: true }]; } const previous = meters.get(id); @@ -1210,12 +1340,48 @@ export function createRadioStateStore( previous, ); meters.set(id, snapshot); - return { - entity: "meter", - id, - diff, - removed: false, - }; + + const changes: RadioStateChange[] = []; + const superseded = findSupersededMeters(id, snapshot); + for (const supersededId of superseded) { + meters.delete(supersededId); + changes.push({ entity: "meter", id: supersededId, removed: true }); + } + if (!superseded.includes(id)) { + changes.unshift({ entity: "meter", id, diff, removed: false }); + } + return changes; + } + + /** + * An amplifier or tuner that registers a second meter under a name it + * already used gets a higher meter number, and the radio streams only the + * newest. Returns the ids of the older duplicates, which may include `id` + * itself when a newer one already exists. Slice meters are exempt: every + * slice has a meter with the same name. + */ + function findSupersededMeters(id: string, meter: MeterSnapshot): string[] { + if ( + meter.source.toUpperCase() !== AMPLIFIER_METER_SOURCE || + meter.sourceIndex === undefined || + !meter.name + ) { + return []; + } + const superseded: string[] = []; + for (const [otherId, other] of meters) { + if ( + otherId === id || + other.source?.toUpperCase() !== AMPLIFIER_METER_SOURCE || + other.sourceIndex !== meter.sourceIndex || + other.name !== meter.name + ) { + continue; + } + if (Number(otherId) > Number(id)) return [id]; + superseded.push(otherId); + } + return superseded; } function handleLicense( @@ -1626,6 +1792,145 @@ export function createRadioStateStore( }; } + /** Status for a slice that is gone is ignored, so no state outlives its slice. */ + function liveSliceId(message: FlexStatusMessage): string | undefined { + const sliceId = message.attributes.slice; + return sliceId && slices.has(sliceId) ? sliceId : undefined; + } + + function handleCwDecoder(message: FlexStatusMessage): RadioStateChange[] { + const sliceId = liveSliceId(message); + if (!sliceId) return []; + const previous = cwDecoders.get(sliceId); + const { snapshot, diff } = createCwDecoderSnapshot( + sliceId, + message.attributes, + previous, + ); + cwDecoders.set(sliceId, snapshot); + return [{ entity: "cwDecoder", id: sliceId, diff, removed: false }]; + } + + function handleAutoDx(message: FlexStatusMessage): RadioStateChange[] { + const sliceId = liveSliceId(message); + if (!sliceId) return []; + const previous = autoDxes.get(sliceId); + const { snapshot, diff } = createAutoDxSnapshot( + sliceId, + message.attributes, + previous, + ); + autoDxes.set(sliceId, snapshot); + return [{ entity: "autoDx", id: sliceId, diff, removed: false }]; + } + + function patchAutoDx( + sliceId: string, + attributes: Record, + ): AutoDxStateChange | undefined { + if (!slices.has(sliceId)) return undefined; + const previous = autoDxes.get(sliceId); + const { snapshot, diff } = createAutoDxSnapshot( + sliceId, + attributes, + previous, + ); + autoDxes.set(sliceId, snapshot); + if (Object.keys(diff as Record).length === 0) { + return undefined; + } + return { entity: "autoDx", id: sliceId, diff, removed: false }; + } + + function handlePreset(message: FlexStatusMessage): RadioStateChange[] { + const id = message.attributes.id; + if (!id) { + return [ + { + entity: "unknown", + source: message.source, + attributes: message.attributes, + }, + ]; + } + + if ("removed" in message.attributes) { + if (!presets.delete(id)) return []; + return [{ entity: "preset", id, removed: true }]; + } + + if ("applied" in message.attributes) { + activePresetId = id; + const changes: RadioStateChange[] = []; + for (const [presetId, preset] of presets) { + const active = presetId === id; + if (preset.active === active) continue; + const { snapshot, diff } = createPresetSnapshot( + presetId, + { active: active ? "1" : "0" }, + preset, + ); + presets.set(presetId, snapshot); + changes.push({ entity: "preset", id: presetId, diff, removed: false }); + } + return changes; + } + + // A bare "created" carries no fields; the Radio re-subscribes to get them. + if ("created" in message.attributes) return []; + + const previous = presets.get(id); + const attributes = previous + ? message.attributes + : { ...message.attributes, active: id === activePresetId ? "1" : "0" }; + const { snapshot, diff } = createPresetSnapshot(id, attributes, previous); + presets.set(id, snapshot); + return [{ entity: "preset", id, diff, removed: false }]; + } + + function handleNtpSource(message: FlexStatusMessage): RadioStateChange { + const id = message.attributes.id; + if (!id) { + return { + entity: "unknown", + source: message.source, + attributes: message.attributes, + }; + } + + if ("removed" in message.attributes) { + ntpSources.delete(id); + return { entity: "ntpSource", id, removed: true }; + } + + const previous = ntpSources.get(id); + const { snapshot, diff } = createNtpSourceSnapshot( + id, + message.attributes, + previous, + ); + ntpSources.set(id, snapshot); + return { entity: "ntpSource", id, diff, removed: false }; + } + + function patchNtpSource( + id: string, + attributes: Record, + ): NtpSourceStateChange | undefined { + const previous = ntpSources.get(id); + if (!previous || Object.keys(attributes).length === 0) return undefined; + const { snapshot, diff } = createNtpSourceSnapshot( + id, + attributes, + previous, + ); + ntpSources.set(id, snapshot); + if (Object.keys(diff as Record).length === 0) { + return undefined; + } + return { entity: "ntpSource", id, diff, removed: false }; + } + function handleSpot( message: FlexStatusMessage, ): RadioStateChange | undefined { @@ -1877,6 +2182,9 @@ export function createRadioStateStore( return handleContainerWaveform(message); } + // Transient event; the Radio reports it directly. + if (message.identifier === "restarted") return []; + if ("installed_list" in message.attributes) { return replaceLegacyWaveforms(message.raw); } diff --git a/packages/flexlib/src/flex/state/meter.ts b/packages/flexlib/src/flex/state/meter.ts index 6c73215f..9bf7c6e4 100644 --- a/packages/flexlib/src/flex/state/meter.ts +++ b/packages/flexlib/src/flex/state/meter.ts @@ -5,6 +5,7 @@ import { logUnknownAttribute, parseFloatSafe, parseInteger, + parseIntegerHex, } from "./common.js"; export type KnownMeterUnits = @@ -41,7 +42,13 @@ export const KNOWN_METER_UNITS: readonly KnownMeterUnits[] = Object.freeze([ export interface MeterSnapshot { readonly id: string; readonly source: string; - readonly sourceIndex: number; + /** + * What the meter measures, read according to `source`: a slice index for a + * slice meter, or the 32-bit client handle for an amplifier or tuner meter. + * Unset until the radio reports it, because 0 is a valid slice index. The + * first value wins; a later `num` is ignored. + */ + readonly sourceIndex?: number; readonly name: string; readonly description: string; readonly units: MeterUnits; @@ -76,7 +83,10 @@ export function createMeterSnapshot( partial.source = value; break; case "num": { - const parsed = parseInteger(value); + if (previous?.sourceIndex !== undefined) break; + const parsed = /^0x/i.test(value) + ? parseIntegerHex(value) + : parseInteger(value); if (parsed !== undefined) partial.sourceIndex = parsed; else logParseError("meter", key, value); break; diff --git a/packages/flexlib/src/flex/state/ntp-source.ts b/packages/flexlib/src/flex/state/ntp-source.ts new file mode 100644 index 00000000..88ab482d --- /dev/null +++ b/packages/flexlib/src/flex/state/ntp-source.ts @@ -0,0 +1,108 @@ +import type { Mutable, SnapshotUpdate } from "./common.js"; +import { freezeAttributes, logParseError, parseInteger } from "./common.js"; + +/** NTP source directive. */ +export type NtpDirective = "server" | "pool" | "peer"; + +/** + * Immutable snapshot of one NTP time source the radio synchronizes to. + */ +export interface NtpSourceSnapshot { + /** Source id chosen by the caller when the source was created. */ + readonly id: string; + /** Host name or IP address. Wire: `address`. */ + readonly address: string; + /** Wire: `directive`. An unexpected value is kept as sent. */ + readonly directive: NtpDirective | (string & {}); + /** Free text description. Wire: `description`. */ + readonly description: string; + /** Whether the time service prefers this source while it is reachable. Wire: `prefer`. */ + readonly prefer: boolean; + /** Whether the radio is synchronized to this source. Wire: `selected`. */ + readonly selected: boolean; + /** Whether the source is enabled. Wire: `enabled`. */ + readonly enabled: boolean; + /** + * Source state from the radio's time service, such as `selected`, + * `candidate`, `outlier`, `falseticker`, `jittery`, or `non-selectable`. + * Empty when the radio has no data for the source. Wire: `state`. + */ + readonly state: string; + /** Stratum, or -1 when unknown. Wire: `stratum`. */ + readonly stratum: number; + /** 8-bit reachability register (0 to 255), or -1 when unknown. Wire: `reach`. */ + readonly reachability: number; + readonly raw: Readonly>; +} + +export function createNtpSourceSnapshot( + id: string, + attributes: Record, + previous?: NtpSourceSnapshot, +): SnapshotUpdate { + const rawDiff = freezeAttributes(attributes); + const partial: Mutable> = previous + ? {} + : { + id, + address: "", + directive: "", + description: "", + prefer: false, + selected: false, + enabled: false, + state: "", + stratum: -1, + reachability: -1, + }; + + for (const [key, value] of Object.entries(attributes)) { + switch (key) { + case "address": + partial.address = value; + break; + case "directive": + partial.directive = value; + break; + case "description": + partial.description = value; + break; + case "enabled": + case "selected": + case "prefer": { + if (value !== "0" && value !== "1") { + logParseError("ntp_sources", key, value); + break; + } + partial[key] = value === "1"; + break; + } + case "state": + partial.state = value; + if (value === "unknown") { + partial.stratum = -1; + partial.reachability = -1; + } + break; + case "stratum": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.stratum = parsed; + else logParseError("ntp_sources", key, value); + break; + } + case "reach": { + const parsed = parseInteger(value); + if (parsed !== undefined) partial.reachability = parsed; + else logParseError("ntp_sources", key, value); + break; + } + } + } + + const snapshot = Object.freeze({ + ...(previous ?? {}), + ...partial, + raw: Object.freeze({ ...previous?.raw, ...attributes }), + }) as NtpSourceSnapshot; + return { snapshot, diff: Object.freeze(partial), rawDiff }; +} diff --git a/packages/flexlib/src/flex/state/panadapter.ts b/packages/flexlib/src/flex/state/panadapter.ts index fb72052b..4cd0f6e8 100644 --- a/packages/flexlib/src/flex/state/panadapter.ts +++ b/packages/flexlib/src/flex/state/panadapter.ts @@ -38,6 +38,8 @@ export interface PanadapterSnapshot { readonly wnbUpdating: boolean; readonly noiseFloorPosition: number; readonly noiseFloorPositionEnabled: boolean; + /** Noise-floor range in dB. Wire: `noise_floor_range`. Unset until the radio reports it. */ + readonly noiseFloorRange?: number; readonly width: number; readonly height: number; readonly fps: number; @@ -212,6 +214,12 @@ export function createPanadapterSnapshot( else logParseError("panadapter", key, value); break; } + case "noise_floor_range": { + const parsed = parseFloatSafe(value); + if (parsed !== undefined) partial.noiseFloorRange = parsed; + else logParseError("panadapter", key, value); + break; + } case "pan_position_enable": case "noise_floor_position_enable": partial.noiseFloorPositionEnabled = isTruthy(value); diff --git a/packages/flexlib/src/flex/state/preset.ts b/packages/flexlib/src/flex/state/preset.ts new file mode 100644 index 00000000..7e8675d2 --- /dev/null +++ b/packages/flexlib/src/flex/state/preset.ts @@ -0,0 +1,60 @@ +import type { Mutable, SnapshotUpdate } from "./common.js"; +import { freezeAttributes } from "./common.js"; + +/** + * Immutable snapshot of a radio preset: a named bundle that references + * sub-configurations to apply together. Presets are read only; a client can + * apply one but not create, edit, or delete it. + */ +export interface PresetSnapshot { + /** Preset id, as the radio numbers it. */ + readonly id: string; + /** Display name. Wire: `name`. */ + readonly name: string; + /** Slice configuration blob. Wire: `slice_config`. */ + readonly sliceConfig: string; + /** ALE configuration blob. Wire: `ale_config`. */ + readonly aleConfig: string; + /** True for the last preset the radio confirmed applying since this client connected. */ + readonly active: boolean; + readonly raw: Readonly>; +} + +/** + * Parses wire attributes into a {@link PresetSnapshot}. Keys this library does + * not model, such as `radio_config`, stay on `raw`. + */ +export function createPresetSnapshot( + id: string, + attributes: Record, + previous?: PresetSnapshot, +): SnapshotUpdate { + const rawDiff = freezeAttributes(attributes); + const partial: Mutable> = previous + ? {} + : { id, name: "", sliceConfig: "", aleConfig: "", active: false }; + + for (const [key, value] of Object.entries(attributes)) { + switch (key) { + case "name": + partial.name = value; + break; + case "slice_config": + partial.sliceConfig = value; + break; + case "ale_config": + partial.aleConfig = value; + break; + case "active": + partial.active = value === "1"; + break; + } + } + + const snapshot = Object.freeze({ + ...(previous ?? {}), + ...partial, + raw: Object.freeze({ ...previous?.raw, ...attributes }), + }) as PresetSnapshot; + return { snapshot, diff: Object.freeze(partial), rawDiff }; +} diff --git a/packages/flexlib/src/flex/state/radio.ts b/packages/flexlib/src/flex/state/radio.ts index c811871c..7ab3046f 100644 --- a/packages/flexlib/src/flex/state/radio.ts +++ b/packages/flexlib/src/flex/state/radio.ts @@ -59,7 +59,11 @@ export type RadioPttSource = | "RCA" | "TUNE" | "SWCW" - | "DVK"; + | "DVK" + | "VOX"; + +/** GPS fix quality. A value outside this set is left off the typed field and stays on `raw`. */ +export type RadioGpsFix = "3D" | "2D" | "TIME_ONLY" | "NO_FIX"; export type RadioCwIambicMode = "a" | "b" | "strict_b" | "bug"; @@ -114,17 +118,20 @@ export interface RadioSnapshot { readonly micInputList: readonly string[]; readonly profileMicList: readonly string[]; readonly profileTxList: readonly string[]; + readonly profileCwList: readonly string[]; /** Display profiles are absent on 4.x firmware — never observed on the wire. */ readonly profileDisplayList?: readonly string[]; readonly profileGlobalList: readonly string[]; readonly profileMicSelection?: string; readonly profileTxSelection?: string; + readonly profileCwSelection?: string; readonly profileDisplaySelection?: string; readonly profileGlobalSelection?: string; readonly profileImportInProgress: boolean; readonly profileExportInProgress: boolean; readonly profileUnsavedChangesTx: boolean; readonly profileUnsavedChangesMic: boolean; + readonly profileUnsavedChangesCw: boolean; readonly versionsRaw: string; readonly trxPsocVersion: string; readonly paPsocVersion: string; @@ -157,6 +164,9 @@ export interface RadioSnapshot { readonly calibrationFrequencyMhz: number; readonly frequencyErrorPpb: number; readonly daxIqCapacity: number; + readonly cwDecodeAvailable: boolean; + readonly debugFakePaOvertemp: boolean; + readonly debugFakePaSwrFault: boolean; readonly gpsInstalled: boolean; readonly gpsLatitude?: number; readonly gpsLongitude?: number; @@ -167,6 +177,10 @@ export interface RadioSnapshot { readonly gpsSpeed?: string; readonly gpsFreqError?: string; readonly gpsStatus?: string; + /** GPS fix quality. Wire: `fix`. Unset when the radio sends a value this library does not know. */ + readonly gpsFix?: RadioGpsFix; + /** Software version the amplifier subsystem reports. Wire: `ha_api system`, key `version`. */ + readonly amplifierSoftwareVersion?: string; readonly gpsUtcTime?: string; readonly gpsTrack?: number; readonly gpsGnssPoweredAntenna?: boolean; @@ -202,6 +216,7 @@ export interface RadioSnapshot { readonly interlockTx1DelayMs: number; readonly interlockTx2DelayMs: number; readonly interlockTx3DelayMs: number; + readonly interlockTuneTxSuppress: boolean; readonly interlockAccTxDelayMs: number; readonly interlockTxDelayMs: number; readonly interlockAmplifierHandles: readonly string[]; @@ -230,6 +245,8 @@ export interface RadioSnapshot { readonly voxEnabled: boolean; readonly voxLevel: number; readonly voxDelay: number; + /** Whether the mic PTT overrides other PTT sources. Wire: `ptt_mic_override`. */ + readonly pttMicOverride: boolean; readonly txMonitorAvailable: boolean; readonly txMonitorEnabled: boolean; readonly txCwMonitorGain: number; @@ -299,6 +316,9 @@ export function createRadioSnapshot( logParseError("profile", "context", ""); } break; + case "ha_api": + applyHaApiSystemAttributes(attributes, partial); + break; case "log": applyLogAttributes(attributes, partial, previous); break; @@ -398,6 +418,13 @@ function applyRadioSourceAttributes( case "diversity_allowed": partial.diversityAllowed = isTruthy(value); break; + case "cw_decode_available": + if (value === "0" || value === "1") { + partial.cwDecodeAvailable = value === "1"; + } else { + logParseError("radio", key, value); + } + break; case "atu_present": partial.atuPresent = isTruthy(value); break; @@ -541,6 +568,12 @@ function applyRadioSourceAttributes( case "tnf_enabled": partial.tnfEnabled = isTruthy(value); break; + case "debug_fake_pa_overtemp": + partial.debugFakePaOvertemp = isTruthy(value); + break; + case "debug_fake_pa_swr_fault": + partial.debugFakePaSwrFault = isTruthy(value); + break; case "binaural_rx": partial.binauralRx = isTruthy(value); break; @@ -729,6 +762,9 @@ function applyInterlockAttributes( case "mox": partial.mox = isTruthy(value); break; + case "tune_tx_suppress": + partial.interlockTuneTxSuppress = isTruthy(value); + break; default: logUnknownAttribute("interlock", key, value); break; @@ -909,6 +945,9 @@ function applyTransmitAttributes( key, ); break; + case "ptt_mic_override": + partial.pttMicOverride = isTruthy(value); + break; case "vox_enable": partial.voxEnabled = isTruthy(value); break; @@ -1032,7 +1071,11 @@ function parseInterlockReason( function parsePttSource(value: string | undefined): RadioPttSource | undefined { if (!value) return undefined; - return PTT_SOURCE_BY_TOKEN[value.toUpperCase()]; + for (const token of value.split(",")) { + const source = PTT_SOURCE_BY_TOKEN[token.trim().toUpperCase()]; + if (source) return source; + } + return undefined; } function parseInterlockDelay( @@ -1155,6 +1198,7 @@ const PTT_SOURCE_BY_TOKEN: Record = { TUNE: "TUNE", SWCW: "SWCW", DVK: "DVK", + VOX: "VOX", }; const INTERLOCK_MOX_STATES = new Set([ @@ -1188,6 +1232,13 @@ const ATU_TUNE_STATUS_BY_TOKEN: Record = { TGXL_ABORTED: "TGXL_ABORTED", }; +const GPS_FIX_BY_TOKEN: Record = { + "3D": "3D", + "2D": "2D", + TIME_ONLY: "TIME_ONLY", + NO_FIX: "NO_FIX", +}; + const GPS_LOCKED_STATUSES = new Set(["locked", "fine lock", "coarse lock"]); function parseAtuTuneStatus( @@ -1298,6 +1349,19 @@ function applyOscillatorAttributes( } } +function applyHaApiSystemAttributes( + attributes: Record, + partial: Mutable>, +): void { + for (const [key, value] of Object.entries(attributes)) { + if (key === "version") { + if (value) partial.amplifierSoftwareVersion = value; + } else { + logUnknownAttribute("ha_api", key, value); + } + } +} + function applyGpsStatusAttributes( attributes: Record, partial: Mutable>, @@ -1343,6 +1407,11 @@ function applyGpsStatusAttributes( case "status": partial.gpsStatus = value; break; + case "fix": { + const fix = GPS_FIX_BY_TOKEN[value.toUpperCase()]; + if (fix) partial.gpsFix = fix; + break; + } case "time": partial.gpsUtcTime = value; break; @@ -1387,6 +1456,7 @@ type RadioContextKind = | "log" | "atu" | "waveform" + | "ha_api" | "file"; function resolveRadioContext(context?: RadioStatusContext): RadioContextKind { @@ -1399,6 +1469,7 @@ function resolveRadioContext(context?: RadioStatusContext): RadioContextKind { if (source === "atu") return "atu"; if (source === "waveform") return "waveform"; if (source === "file") return "file"; + if (source === "ha_api") return "ha_api"; const identifier = context?.identifier?.toLowerCase(); if (identifier === "gps") return "gps"; @@ -1409,7 +1480,7 @@ function resolveRadioContext(context?: RadioStatusContext): RadioContextKind { return "radio"; } -type ProfileDomain = "global" | "tx" | "mic" | "display"; +type ProfileDomain = "global" | "tx" | "mic" | "cw" | "display"; function applyProfileAttributes( attributes: Record, @@ -1451,6 +1522,9 @@ function applyProfileAttributes( case "unsaved_changes_mic": partial.profileUnsavedChangesMic = isTruthy(value); break; + case "unsaved_changes_cw": + partial.profileUnsavedChangesCw = isTruthy(value); + break; default: logUnknownAttribute("profile", key, value); break; @@ -1467,6 +1541,7 @@ function parseProfileDomain(identifier?: string): ProfileDomain | undefined { normalized === "global" || normalized === "tx" || normalized === "mic" || + normalized === "cw" || normalized === "display" ) { return normalized; @@ -1496,6 +1571,9 @@ function assignProfileList( case "tx": partial.profileTxList = list; break; + case "cw": + partial.profileCwList = list; + break; case "display": partial.profileDisplayList = list; break; @@ -1517,6 +1595,9 @@ function assignProfileSelection( case "tx": partial.profileTxSelection = selection; break; + case "cw": + partial.profileCwSelection = selection; + break; case "display": partial.profileDisplaySelection = selection; break; diff --git a/packages/flexlib/src/flex/state/slice.ts b/packages/flexlib/src/flex/state/slice.ts index 431427e3..a6de485b 100644 --- a/packages/flexlib/src/flex/state/slice.ts +++ b/packages/flexlib/src/flex/state/slice.ts @@ -13,6 +13,10 @@ import { parseMegahertz, } from "./common.js"; +export type SliceDspAdaptMode = "auto" | "fast" | "slow"; +export type SliceDspIsdftMode = "one" | "half" | "zero"; +export type SliceAgcThresholdMode = "auto" | "manual"; + export type SliceAgcMode = "off" | "slow" | "med" | "fast" | (string & {}); export type SliceToneMode = "OFF" | "CTCSS_TX" | (string & {}); export type SliceRepeaterOffsetDirection = @@ -69,12 +73,54 @@ export interface SliceSnapshot { readonly rnnEnabled: boolean; readonly anftEnabled: boolean; readonly nrfEnabled: boolean; - readonly nrfLevel: number; + /** Wire: `nr_delay`. Unset until the radio reports it. */ + readonly nrDelay?: number; + /** Wire: `nr_wlen`. */ + readonly nrWlen?: number; + /** Wire: `nr_adapt_mode`. A value outside the known set stays on `raw`. */ + readonly nrAdaptMode?: SliceDspAdaptMode; + /** Wire: `nr_isdft_mode`. A value outside the known set stays on `raw`. */ + readonly nrIsdftMode?: SliceDspIsdftMode; + /** Wire: `nrl_filter_size`. */ + readonly nrlFilterSize?: number; + /** Wire: `nrl_delay`. */ + readonly nrlDelay?: number; + /** Wire: `nrl_leakage_level`. */ + readonly nrlLeakageLevel?: number; + /** Wire: `nrf_wlen`. */ + readonly nrfWlen?: number; + /** Wire: `anf_wlen`. */ + readonly anfWlen?: number; + /** Wire: `anf_delay`. */ + readonly anfDelay?: number; + /** Wire: `anf_adapt_mode`. A value outside the known set stays on `raw`. */ + readonly anfAdaptMode?: SliceDspAdaptMode; + /** Wire: `anf_isdft_mode`. A value outside the known set stays on `raw`. */ + readonly anfIsdftMode?: SliceDspIsdftMode; + /** Wire: `anfl_filter_size`. */ + readonly anflFilterSize?: number; + /** Wire: `anfl_delay`. */ + readonly anflDelay?: number; + /** Wire: `anfl_leakage_level`. */ + readonly anflLeakageLevel?: number; + /** Wire: `anft_min_db`. */ + readonly anftMinDb?: number; + /** Wire: `anft_tone_db_diff`. */ + readonly anftToneDbDiff?: number; readonly escEnabled: boolean; readonly escGain: number; readonly escPhaseShift: number; readonly agcMode: SliceAgcMode; readonly agcThreshold: number; + /** Wire: `agc_threshold_mode`. A value other than `auto` or `manual` stays on `raw`. */ + readonly agcThresholdMode?: SliceAgcThresholdMode; + /** + * Chain the radio runs for this slice, such as `CW`. A waveform slice + * reports the waveform name as `mode` and the underlying chain here. + * Unset when the firmware never sent the key; an unset chain does not mean + * the slice is not CW. Wire: `chain_mode`. Read only. + */ + readonly chainMode?: string; readonly agcOffLevel: number; readonly loopAEnabled: boolean; readonly loopBEnabled: boolean; @@ -359,12 +405,6 @@ export function createSliceSnapshot( case "nrf": partial.nrfEnabled = isTruthy(value); break; - case "nrf_level": { - const parsed = parseInteger(value); - if (parsed !== undefined) partial.nrfLevel = parsed; - else logParseError("slice", key, value); - break; - } case "esc": partial.escEnabled = isTruthy(value); break; @@ -380,23 +420,73 @@ export function createSliceSnapshot( else logParseError("slice", key, value); break; } - case "nr_wlen": + case "nrf_winc": + // Not typed; tracked via the raw map only. + break; case "nr_delay": + setDspInt(partial, "nrDelay", value, key); + break; + case "nr_wlen": + setDspInt(partial, "nrWlen", value, key); + break; case "nr_adapt_mode": + if (isAdaptMode(value)) partial.nrAdaptMode = value; + break; case "nr_isdft_mode": + if (isIsdftMode(value)) partial.nrIsdftMode = value; + break; case "nrl_filter_size": + case "lms_nr_taps": + setDspInt(partial, "nrlFilterSize", value, key); + break; case "nrl_delay": + case "lms_nr_delay": + setDspInt(partial, "nrlDelay", value, key); + break; case "nrl_leakage_level": - case "nrf_winc": + case "lms_nr_leakage_level": + setDspInt(partial, "nrlLeakageLevel", value, key); + break; case "nrf_wlen": + setDspInt(partial, "nrfWlen", value, key); + break; case "anf_wlen": + setDspInt(partial, "anfWlen", value, key); + break; case "anf_delay": + setDspInt(partial, "anfDelay", value, key); + break; case "anf_adapt_mode": + if (isAdaptMode(value)) partial.anfAdaptMode = value; + break; case "anf_isdft_mode": + if (isIsdftMode(value)) partial.anfIsdftMode = value; + break; case "anfl_filter_size": + case "lms_anf_taps": + setDspInt(partial, "anflFilterSize", value, key); + break; case "anfl_delay": + case "lms_anf_delay": + setDspInt(partial, "anflDelay", value, key); + break; case "anfl_leakage_level": - // Advanced DSP parameters exposed on 4.x radios; tracked via raw map only. + case "lms_anf_leakage_level": + setDspInt(partial, "anflLeakageLevel", value, key); + break; + case "anft_min_db": + setDspInt(partial, "anftMinDb", value, key); + break; + case "anft_tone_db_diff": + setDspInt(partial, "anftToneDbDiff", value, key); + break; + case "agc_threshold_mode": + if (value === "auto" || value === "manual") { + partial.agcThresholdMode = value; + } + break; + case "chain_mode": + if (value) partial.chainMode = value; break; case "agc_mode": partial.agcMode = value; @@ -650,3 +740,37 @@ export function createSliceSnapshot( rawDiff, }; } + +type SliceDspIntField = + | "nrDelay" + | "nrWlen" + | "nrlFilterSize" + | "nrlDelay" + | "nrlLeakageLevel" + | "nrfWlen" + | "anfWlen" + | "anfDelay" + | "anflFilterSize" + | "anflDelay" + | "anflLeakageLevel" + | "anftMinDb" + | "anftToneDbDiff"; + +function setDspInt( + partial: Mutable>, + field: SliceDspIntField, + value: string, + key: string, +): void { + const parsed = parseInteger(value); + if (parsed !== undefined) partial[field] = parsed; + else logParseError("slice", key, value); +} + +function isAdaptMode(value: string): value is SliceDspAdaptMode { + return value === "auto" || value === "fast" || value === "slow"; +} + +function isIsdftMode(value: string): value is SliceDspIsdftMode { + return value === "one" || value === "half" || value === "zero"; +} diff --git a/packages/flexlib/src/flex/state/tx-band-settings.ts b/packages/flexlib/src/flex/state/tx-band-settings.ts index 98e01017..fa787b89 100644 --- a/packages/flexlib/src/flex/state/tx-band-settings.ts +++ b/packages/flexlib/src/flex/state/tx-band-settings.ts @@ -20,6 +20,7 @@ export interface TxBandSettingSnapshot { readonly rcaTx1Enabled?: boolean; readonly rcaTx2Enabled?: boolean; readonly rcaTx3Enabled?: boolean; + readonly tuneTxSuppress?: boolean; readonly raw: Readonly>; } @@ -69,6 +70,9 @@ export function createTxBandSettingSnapshot( case "tx3_enabled": partial.rcaTx3Enabled = isTruthy(value); break; + case "tune_tx_suppress": + partial.tuneTxSuppress = isTruthy(value); + break; default: logUnknownAttribute("tx_band", key, value); break; diff --git a/packages/flexlib/src/flex/state/waveform.ts b/packages/flexlib/src/flex/state/waveform.ts index 0a848f84..984aec7a 100644 --- a/packages/flexlib/src/flex/state/waveform.ts +++ b/packages/flexlib/src/flex/state/waveform.ts @@ -7,6 +7,12 @@ export interface WaveformSnapshot { readonly version: string; readonly isContainer: boolean; readonly displayName: string; + /** + * Environment variables the radio holds for a container waveform. Empty for + * a legacy waveform, or when the radio sent no environment or one this + * library cannot decode. Wire: `env`, base64 of a JSON object. + */ + readonly env: Readonly>; readonly raw: Readonly>; } @@ -42,7 +48,9 @@ export function createWaveformSnapshot( previous?: WaveformSnapshot, ): SnapshotUpdate { const rawDiff = freezeAttributes(attributes); - const partial: Mutable> = previous ? {} : { id }; + const partial: Mutable> = previous + ? {} + : { id, env: EMPTY_ENV }; for (const [key, value] of Object.entries(attributes)) { switch (key) { @@ -55,6 +63,9 @@ export function createWaveformSnapshot( case "is_container": partial.isContainer = isTruthy(value); break; + case "env": + partial.env = decodeWaveformEnv(value); + break; } } @@ -79,6 +90,41 @@ export function createWaveformSnapshot( }; } +const EMPTY_ENV: Readonly> = Object.freeze({}); + +/** + * Decodes a waveform `env` value: base64 of UTF-8 JSON holding an object of + * string values. Any failure, or a payload that is not an object, gives an + * empty dictionary. + */ +export function decodeWaveformEnv( + value: string | undefined, +): Readonly> { + if (!value) return EMPTY_ENV; + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return EMPTY_ENV; + } + const env: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") env[key] = entry; + else if (typeof entry === "number" || typeof entry === "boolean") { + env[key] = String(entry); + } + } + return Object.freeze(env); + } catch { + return EMPTY_ENV; + } +} + export function parseLegacyWaveformList( value: string | undefined, ): ParsedLegacyWaveformEntry[] { diff --git a/packages/flexlib/src/flex/tx-band-settings.ts b/packages/flexlib/src/flex/tx-band-settings.ts index 1ae5d93a..1413ca9e 100644 --- a/packages/flexlib/src/flex/tx-band-settings.ts +++ b/packages/flexlib/src/flex/tx-band-settings.ts @@ -83,6 +83,10 @@ export class TxBandSettingControllerImpl implements TxBandSettingController { return this.current().rcaTx3Enabled; } + get tuneTxSuppress(): boolean | undefined { + return this.current().tuneTxSuppress; + } + async setHwAlcEnabled(enabled: boolean): Promise { await this.sendBandsetCommand("transmit", { hwalc_enabled: formatBooleanFlag(enabled), diff --git a/packages/flexlib/src/flex/waveform.ts b/packages/flexlib/src/flex/waveform.ts index e68f8077..4dbe97c8 100644 --- a/packages/flexlib/src/flex/waveform.ts +++ b/packages/flexlib/src/flex/waveform.ts @@ -55,6 +55,10 @@ export class WaveformControllerImpl implements WaveformController { return this.current().displayName; } + get env(): Readonly> { + return this.current().env; + } + async uninstall({ timeoutMs = 30_000, }: Pick = {}): Promise { diff --git a/packages/flexlib/src/vita/dax-audio-packet.ts b/packages/flexlib/src/vita/dax-audio-packet.ts index 9889e3d6..010bf8b4 100644 --- a/packages/flexlib/src/vita/dax-audio-packet.ts +++ b/packages/flexlib/src/vita/dax-audio-packet.ts @@ -15,11 +15,79 @@ import { writeBigUint64BE as writeU64, } from "./common"; -/** Packet class code for DAX stereo audio (float32 interleaved L/R). */ -export const VITA_FLEX_DAX_AUDIO_CLASS = 0x03e3; +/** Packet class code for DAX stereo audio (float32 interleaved L/R) at 24 kHz. */ +export const VITA_FLEX_DAX_AUDIO_24_CLASS = 0x03e3; +/** Packet class code for DAX stereo audio at 48 kHz. */ +export const VITA_FLEX_DAX_AUDIO_48_CLASS = 0x03e4; +/** Packet class code for DAX stereo audio at 96 kHz. */ +export const VITA_FLEX_DAX_AUDIO_96_CLASS = 0x03e5; + +/** Packet class code for DAX reduced-bandwidth audio (int16 mono) at 24 kHz. */ +export const VITA_FLEX_DAX_REDUCED_BW_24_CLASS = 0x0123; +/** Packet class code for DAX reduced-bandwidth audio at 48 kHz. */ +export const VITA_FLEX_DAX_REDUCED_BW_48_CLASS = 0x0124; +/** Packet class code for DAX reduced-bandwidth audio at 96 kHz. */ +export const VITA_FLEX_DAX_REDUCED_BW_96_CLASS = 0x0125; + +/** Sample rates the radio carries on DAX audio streams. */ +export type DaxAudioSampleRate = 24000 | 48000 | 96000; + +const DAX_AUDIO_RATE_BY_CLASS: ReadonlyMap = + new Map([ + [VITA_FLEX_DAX_AUDIO_24_CLASS, 24000], + [VITA_FLEX_DAX_AUDIO_48_CLASS, 48000], + [VITA_FLEX_DAX_AUDIO_96_CLASS, 96000], + [VITA_FLEX_DAX_REDUCED_BW_24_CLASS, 24000], + [VITA_FLEX_DAX_REDUCED_BW_48_CLASS, 48000], + [VITA_FLEX_DAX_REDUCED_BW_96_CLASS, 96000], + ]); + +const DAX_AUDIO_CLASS_BY_RATE = { + 24000: VITA_FLEX_DAX_AUDIO_24_CLASS, + 48000: VITA_FLEX_DAX_AUDIO_48_CLASS, + 96000: VITA_FLEX_DAX_AUDIO_96_CLASS, +} as const; + +const DAX_REDUCED_BW_CLASS_BY_RATE = { + 24000: VITA_FLEX_DAX_REDUCED_BW_24_CLASS, + 48000: VITA_FLEX_DAX_REDUCED_BW_48_CLASS, + 96000: VITA_FLEX_DAX_REDUCED_BW_96_CLASS, +} as const; + +/** True when `code` is any DAX stereo float32 audio class. */ +export function isDaxAudioClass(code: number): boolean { + return ( + code === VITA_FLEX_DAX_AUDIO_24_CLASS || + code === VITA_FLEX_DAX_AUDIO_48_CLASS || + code === VITA_FLEX_DAX_AUDIO_96_CLASS + ); +} + +/** True when `code` is any DAX reduced-bandwidth int16 audio class. */ +export function isDaxReducedBwClass(code: number): boolean { + return ( + code === VITA_FLEX_DAX_REDUCED_BW_24_CLASS || + code === VITA_FLEX_DAX_REDUCED_BW_48_CLASS || + code === VITA_FLEX_DAX_REDUCED_BW_96_CLASS + ); +} + +/** Sample rate in Hz for a DAX audio packet class code, or `undefined` if the code is not DAX audio. */ +export function daxAudioSampleRateFromClass( + code: number, +): DaxAudioSampleRate | undefined { + return DAX_AUDIO_RATE_BY_CLASS.get(code); +} + +/** Stereo float32 packet class code for `rateHz`, or `undefined` if the rate is not 24, 48, or 96 kHz. */ +export function daxAudioClassForRate(rateHz: number): number | undefined { + return (DAX_AUDIO_CLASS_BY_RATE as Record)[rateHz]; +} -/** Packet class code for DAX reduced-bandwidth audio (int16 mono). */ -export const VITA_FLEX_DAX_REDUCED_BW_CLASS = 0x0123; +/** Reduced-bandwidth int16 packet class code for `rateHz`, or `undefined` if the rate is not 24, 48, or 96 kHz. */ +export function daxReducedBwClassForRate(rateHz: number): number | undefined { + return (DAX_REDUCED_BW_CLASS_BY_RATE as Record)[rateHz]; +} // Preamble layout (bytes): // header(4) + streamId(4) + classId(8) + timestampInt(4) + timestampFrac(8) = 28 @@ -62,7 +130,7 @@ function writePreamble( } /** - * VITA-49 DAX stereo audio packet (FlexRadio class 0x03e3). + * VITA-49 DAX stereo audio packet (FlexRadio classes 0x03e3, 0x03e4, 0x03e5 for 24, 48, 96 kHz). * * Payload is stereo 32-bit float samples written as big-endian interleaved L/R pairs: * `[L0, R0, L1, R1, …]`. The frame count is `min(left.length, right.length)`. @@ -92,7 +160,7 @@ export class VitaDaxAudioPacket { this.classId = { oui: VITA_FLEX_OUI, informationClassCode: VITA_FLEX_INFO_CLASS, - packetClassCode: VITA_FLEX_DAX_AUDIO_CLASS, + packetClassCode: VITA_FLEX_DAX_AUDIO_24_CLASS, }; if (data) this.parse(data); } @@ -185,11 +253,11 @@ export class VitaDaxAudioPacket { } /** - * VITA-49 DAX reduced-bandwidth audio packet (FlexRadio class 0x0123). + * VITA-49 DAX reduced-bandwidth audio packet (FlexRadio classes 0x0123, 0x0124, 0x0125 for 24, 48, 96 kHz). * * Payload is mono 16-bit signed integer samples (range [−32768, 32767]). * - * Same 24 kHz sample rate and 128-frame cadence as the stereo float32 variant — + * Same sample rates and frame cadence as the stereo float32 variant — * the "reduced bandwidth" comes from the encoding (mono int16 = 2 bytes/frame vs * stereo float32 = 8 bytes/frame), giving 4× lower wire bandwidth. */ @@ -214,7 +282,7 @@ export class VitaDaxReducedBwPacket { this.classId = { oui: VITA_FLEX_OUI, informationClassCode: VITA_FLEX_INFO_CLASS, - packetClassCode: VITA_FLEX_DAX_REDUCED_BW_CLASS, + packetClassCode: VITA_FLEX_DAX_REDUCED_BW_24_CLASS, }; if (data) this.parse(data); } diff --git a/packages/flexlib/src/vita/parser.ts b/packages/flexlib/src/vita/parser.ts index 67968e41..33bfb82e 100644 --- a/packages/flexlib/src/vita/parser.ts +++ b/packages/flexlib/src/vita/parser.ts @@ -1,7 +1,11 @@ import { createPacketContext } from "./common"; import { - VITA_FLEX_DAX_AUDIO_CLASS, - VITA_FLEX_DAX_REDUCED_BW_CLASS, + VITA_FLEX_DAX_AUDIO_24_CLASS, + VITA_FLEX_DAX_AUDIO_48_CLASS, + VITA_FLEX_DAX_AUDIO_96_CLASS, + VITA_FLEX_DAX_REDUCED_BW_24_CLASS, + VITA_FLEX_DAX_REDUCED_BW_48_CLASS, + VITA_FLEX_DAX_REDUCED_BW_96_CLASS, VitaDaxAudioPacket, VitaDaxReducedBwPacket, } from "./dax-audio-packet"; @@ -83,7 +87,9 @@ export function parseVitaPacket( packet.parseWithContext(ctx); return packet; } - case VITA_FLEX_DAX_REDUCED_BW_CLASS: { + case VITA_FLEX_DAX_REDUCED_BW_24_CLASS: + case VITA_FLEX_DAX_REDUCED_BW_48_CLASS: + case VITA_FLEX_DAX_REDUCED_BW_96_CLASS: { const packet = new VitaDaxReducedBwPacket(); packet.parseWithContext(ctx, options.daxReducedBw); return packet; @@ -96,7 +102,9 @@ export function parseVitaPacket( packet.parseWithContext(ctx, options.daxIq); return packet; } - case VITA_FLEX_DAX_AUDIO_CLASS: { + case VITA_FLEX_DAX_AUDIO_24_CLASS: + case VITA_FLEX_DAX_AUDIO_48_CLASS: + case VITA_FLEX_DAX_AUDIO_96_CLASS: { const packet = new VitaDaxAudioPacket(); packet.parseWithContext(ctx, options.daxAudio); return packet; diff --git a/packages/flexlib/tests/flex/apd.spec.ts b/packages/flexlib/tests/flex/apd.spec.ts index b407ae7e..e7f32aa8 100644 --- a/packages/flexlib/tests/flex/apd.spec.ts +++ b/packages/flexlib/tests/flex/apd.spec.ts @@ -63,4 +63,18 @@ describe("APD controller", () => { ); expect(controller.selectedSamplerPortAnt1).toBe("XVTA"); }); + + it("tracks the lifecycle state and ignores unknown values", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|apd enable=1 state=TRAINING"); + expect(radio.apd().state).toBe("TRAINING"); + + connection.emitStatus("S2|apd state=hold equalizer_active=1"); + expect(radio.apd().state).toBe("HOLD"); + expect(radio.apd().equalizerActive).toBe(true); + + connection.emitStatus("S3|apd state=REBOOTING"); + expect(radio.apd().state).toBe("HOLD"); + expect(radio.apd().snapshot()?.raw.state).toBe("REBOOTING"); + }); }); diff --git a/packages/flexlib/tests/flex/audio-stream-tx.spec.ts b/packages/flexlib/tests/flex/audio-stream-tx.spec.ts new file mode 100644 index 00000000..31c8820a --- /dev/null +++ b/packages/flexlib/tests/flex/audio-stream-tx.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { parseVitaPacket } from "../../src/vita/parser.js"; +import { createConnectedRadio } from "../helpers.js"; + +async function createTxStream(sliceStatus: string) { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus(`S1|slice 0 index_letter=A ${sliceStatus}`); + connection.prepareResponse("stream create", { message: "2000003" }); + const creation = radio.createDaxTxAudioStream(); + connection.emitStatus( + "S1|stream 0x02000003 type=dax_tx client_handle=0x12345678 slice=A", + ); + return { stream: await creation, connection }; +} + +describe("DAX TX class follows the slice rate", () => { + it.each([ + [24000, 0x03e3, 0x0123], + [48000, 0x03e4, 0x0124], + [96000, 0x03e5, 0x0125], + ])("%i Hz stamps the matching class", async (rate, floatClass, intClass) => { + const { stream, connection } = await createTxStream(`sample_rate=${rate}`); + + stream.sendAudio(new Float32Array(128), new Float32Array(128)); + stream.sendReducedBwAudio(new Int16Array(128)); + + expect(connection.sentUdp).toHaveLength(2); + const [stereo, mono] = connection.sentUdp.map((bytes) => + parseVitaPacket(bytes), + ); + expect(stereo?.kind).toBe("daxAudio"); + expect(stereo?.kind === "daxAudio" && stereo.classId.packetClassCode).toBe( + floatClass, + ); + expect(mono?.kind === "daxReducedBw" && mono.classId.packetClassCode).toBe( + intClass, + ); + }); + + it("drops the packet when the slice rate is missing or unsupported", async () => { + const missing = await createTxStream(""); + missing.stream.sendAudio(new Float32Array(128), new Float32Array(128)); + missing.stream.sendReducedBwAudio(new Int16Array(128)); + expect(missing.connection.sentUdp).toHaveLength(0); + + const odd = await createTxStream("sample_rate=44100"); + odd.stream.sendAudio(new Float32Array(128), new Float32Array(128)); + expect(odd.connection.sentUdp).toHaveLength(0); + }); + + it("reports the followed slice rate as sampleRateHz, 0 while unknown", async () => { + const known = await createTxStream("sample_rate=48000"); + expect(known.stream.sampleRateHz).toBe(48000); + expect(known.stream.snapshot().sampleRateHz).toBe(48000); + + const unknown = await createTxStream(""); + expect(unknown.stream.sampleRateHz).toBe(0); + }); +}); diff --git a/packages/flexlib/tests/flex/autodx.spec.ts b/packages/flexlib/tests/flex/autodx.spec.ts new file mode 100644 index 00000000..80fc2ff9 --- /dev/null +++ b/packages/flexlib/tests/flex/autodx.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { createConnectedRadio } from "../helpers.js"; + +async function setup(sliceStatus = "mode=CW chain_mode=CW") { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus(`S1|slice 0 index_letter=A ${sliceStatus}`); + const autoDx = radio.slice("0")?.autoDx; + if (!autoDx) throw new Error("autodx missing"); + return { radio, connection, autoDx }; +} + +describe("AutoDX", () => { + it("is available before any status and has defaults", async () => { + const { autoDx } = await setup(); + expect(autoDx.state).toBe("Disabled"); + expect(autoDx.enabled).toBe(false); + expect(autoDx.trigger).toBe("either"); + expect(autoDx.detect).toBe(""); + }); + + it("applies enable locally and sends it", async () => { + const { autoDx, connection } = await setup(); + await autoDx.setEnabled(true); + expect(connection.lastCommand()).toBe("autodx 0 enable 1"); + expect(autoDx.enabled).toBe(true); + await autoDx.setEnabled(false); + expect(connection.lastCommand()).toBe("autodx 0 enable 0"); + expect(autoDx.enabled).toBe(false); + }); + + it("undoes enable when the radio rejects it, without refusing locally", async () => { + // an unset chain is unknown, so the command is still sent + const { autoDx, connection } = await setup("mode=FREEDV"); + connection.prepareResponse("autodx 0 enable", { + code: 0x50000001, + message: "not cw", + }); + await expect(autoDx.setEnabled(true)).rejects.toThrow(); + expect(connection.lastCommand()).toBe("autodx 0 enable 1"); + expect(autoDx.enabled).toBe(false); + }); + + it("takes the first word of the detect call and clears with three tokens", async () => { + const { autoDx, connection } = await setup(); + await autoDx.setDetect("KH7XY UP"); + expect(connection.lastCommand()).toBe("autodx 0 detect KH7XY"); + expect(autoDx.detect).toBe("KH7XY"); + + const before = connection.commands.length; + await autoDx.setDetect(" KH7XY up "); + expect(connection.commands).toHaveLength(before); + + await autoDx.setDetect(" "); + expect(connection.lastCommand()).toBe("autodx 0 detect"); + expect(autoDx.detect).toBe(""); + }); + + it("sends trigger, exchange, and timing settings with their ranges", async () => { + const { autoDx, connection } = await setup(); + await autoDx.setTrigger("call_up"); + expect(connection.lastCommand()).toBe("autodx 0 trigger call_up"); + const before = connection.commands.length; + await autoDx.setTrigger("unknown"); + expect(connection.commands).toHaveLength(before); + + await autoDx.setExchange("5NN TU 23"); + expect(connection.lastCommand()).toBe('autodx 0 exchange "5NN TU 23"'); + + await autoDx.setAwaitReplyMs(100); + expect(connection.lastCommand()).toBe("autodx 0 await_reply_ms 2000"); + await autoDx.setAwaitReplyMs(999999); + expect(connection.lastCommand()).toBe("autodx 0 await_reply_ms 60000"); + await autoDx.setTxDelayMs(5); + expect(connection.lastCommand()).toBe("autodx 0 tx_delay_ms 100"); + await autoDx.setTimeoutMs(1); + expect(connection.lastCommand()).toBe("autodx 0 timeout_ms 60000"); + await autoDx.setTimeoutMs(0); + expect(connection.lastCommand()).toBe("autodx 0 timeout_ms 0"); + await autoDx.setTimeoutRestart(true); + expect(connection.lastCommand()).toBe("autodx 0 timeout_restart 1"); + await autoDx.setCallRetries(-4); + expect(connection.commands.at(-1)).toBe("autodx 0 timeout_restart 1"); + await autoDx.setCallRetries(3); + expect(connection.lastCommand()).toBe("autodx 0 call_retries 3"); + await autoDx.requestStatus(); + expect(connection.lastCommand()).toBe("autodx 0 status"); + + await expect(autoDx.setExchange("a\nb")).rejects.toThrow(RangeError); + }); + + it("rejects when the radio refuses a config command", async () => { + const { autoDx, connection } = await setup(); + connection.prepareResponse("autodx 0 trigger", { + code: 0x50000001, + message: "bad", + }); + await expect(autoDx.setTrigger("up")).rejects.toThrow(); + }); + + it("stores status without sending commands", async () => { + const { autoDx, connection } = await setup(); + const before = connection.commands.length; + connection.emitStatus( + 'S2|autodx slice=0 state=await_reply enabled=1 detect=KH7XY trigger=up exchange="5NN 23" await_reply_ms=5000 tx_delay_ms=300 timeout_ms=120000 timeout_restart=1 call_retries=2 dx_call=KH7XY rx_rst=599 rx_exchange="a \x1eb\x1e" qso_count=4', + ); + expect(connection.commands).toHaveLength(before); + expect(autoDx.state).toBe("AwaitReply"); + expect(autoDx.enabled).toBe(true); + expect(autoDx.detect).toBe("KH7XY"); + expect(autoDx.trigger).toBe("up"); + expect(autoDx.exchange).toBe("5NN 23"); + expect(autoDx.awaitReplyMs).toBe(5000); + expect(autoDx.txDelayMs).toBe(300); + expect(autoDx.timeoutMs).toBe(120000); + expect(autoDx.timeoutRestart).toBe(true); + expect(autoDx.callRetries).toBe(2); + expect(autoDx.dxCall).toBe("KH7XY"); + expect(autoDx.rxRst).toBe("599"); + expect(autoDx.rxExchange).toBe('a "b"'); + expect(autoDx.qsoCount).toBe(4); + + connection.emitStatus("S3|autodx slice=0 state=bogus trigger=weird"); + expect(autoDx.state).toBe("Unknown"); + expect(autoDx.trigger).toBe("unknown"); + }); + + it("treats the first sent_seq as a baseline and surfaces later ones", async () => { + const { autoDx, connection } = await setup(); + const sent: string[] = []; + autoDx.on("sentText", ({ text }) => sent.push(text)); + + connection.emitStatus('S2|autodx slice=0 sent_seq=7 sent_text="CQ DX"'); + expect(sent).toEqual([]); + connection.emitStatus('S3|autodx slice=0 sent_seq=7 sent_text="CQ DX"'); + expect(sent).toEqual([]); + + connection.emitStatus('S4|autodx slice=0 sent_seq=8 sent_text="KH7XY"'); + connection.emitStatus('S5|autodx slice=0 sent_seq=9 sent_text="KH7XY"'); + expect(sent).toEqual(["KH7XY", "KH7XY"]); + }); + + it("ignores status for a missing slice and drops the controller with its slice", async () => { + const { radio, connection, autoDx } = await setup(); + connection.emitStatus("S2|autodx slice=9 state=monitor"); + expect(radio.stateSnapshot().autoDxes).toHaveLength(0); + + connection.emitStatus("S3|autodx slice=0 state=monitor"); + expect(autoDx.state).toBe("Monitor"); + + connection.emitStatus("S4|slice 0 in_use=0"); + expect(radio.autoDx("0")).toBeUndefined(); + expect(radio.stateSnapshot().autoDxes).toHaveLength(0); + expect(() => autoDx.snapshot()).toThrow(); + }); +}); diff --git a/packages/flexlib/tests/flex/cw-decoder.spec.ts b/packages/flexlib/tests/flex/cw-decoder.spec.ts new file mode 100644 index 00000000..75642fa4 --- /dev/null +++ b/packages/flexlib/tests/flex/cw-decoder.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { createConnectedRadio } from "../helpers.js"; + +describe("CW decoder", () => { + it("subscribes in the handshake and routes status by slice", async () => { + const { radio, connection } = await createConnectedRadio(); + expect(connection.commands).toContain("sub cw all"); + expect(connection.commands.filter((c) => c === "sub cw all")).toHaveLength( + 1, + ); + + connection.emitStatus("S1|slice 0 index_letter=A mode=CW"); + connection.emitStatus("S2|slice 1 index_letter=B mode=CW"); + const slice = radio.slice("0"); + const other = radio.slice("1"); + expect(slice?.cwDecoder).toBeUndefined(); + + const events: unknown[] = []; + slice?.on("cwDecoder", (change) => events.push(change)); + + connection.emitStatus( + 'S3|cw slice=0 active=1 text="CQ DE W1AW" wpm=22.5 snr=11.5 lock=1 pitch=600 cwl=0', + ); + + expect(slice?.cwDecoder).toMatchObject({ + sliceId: "0", + active: true, + text: "CQ DE W1AW", + wpm: 22.5, + snrDb: 11.5, + locked: true, + pitchHz: 600, + lowerSideband: false, + }); + expect(other?.cwDecoder).toBeUndefined(); + expect(events).toHaveLength(1); + }); + + it("restores a substituted double quote in decoded text", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|slice 0 index_letter=A mode=CW"); + connection.emitStatus('S2|cw slice=0 text="say \x1ehi\x1e"'); + expect(radio.slice("0")?.cwDecoder?.text).toBe('say "hi"'); + }); + + it("keeps earlier fields when a later message carries only some", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|slice 0 index_letter=A mode=CW"); + connection.emitStatus("S2|cw slice=0 active=1 wpm=18 pitch=500"); + connection.emitStatus("S3|cw slice=0 snr=9 lock=1"); + expect(radio.slice("0")?.cwDecoder).toMatchObject({ + active: true, + wpm: 18, + pitchHz: 500, + snrDb: 9, + locked: true, + }); + }); + + it("ignores a message with no slice or an unknown slice, and drops state with the slice", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|slice 0 index_letter=A mode=CW"); + connection.emitStatus("S2|cw active=1 wpm=20"); + connection.emitStatus("S3|cw slice=7 active=1 wpm=20"); + expect(radio.slice("0")?.cwDecoder).toBeUndefined(); + expect(radio.stateSnapshot().cwDecoders).toHaveLength(0); + + connection.emitStatus("S4|cw slice=0 active=1"); + expect(radio.slice("0")?.cwDecoder).toBeDefined(); + connection.emitStatus("S5|slice 0 in_use=0"); + expect(radio.stateSnapshot().cwDecoders).toHaveLength(0); + }); +}); diff --git a/packages/flexlib/tests/flex/cwx.spec.ts b/packages/flexlib/tests/flex/cwx.spec.ts index b16ae03b..8e80b27c 100644 --- a/packages/flexlib/tests/flex/cwx.spec.ts +++ b/packages/flexlib/tests/flex/cwx.spec.ts @@ -167,6 +167,42 @@ describe("CWX controller", () => { expect(connection.lastCommand()).toBe('cwx send "single"'); }); + it("substitutes double quotes in send, macro save, and inbound macros", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|cwx wpm=20"); + + await radio.cwx().send('say "hi"'); + expect(connection.lastCommand()).toBe('cwx send "say\x7f\x1ehi\x1e"'); + + await radio.cwx().setMacro(0, 'a "b"'); + expect(connection.lastCommand()).toBe('cwx macro save 1 "a \x1eb\x1e"'); + + connection.emitStatus("S2|cwx macro2=x\x1ey\x1e\x7fz"); + expect(radio.cwx().macros[1]).toBe('x"y" z'); + }); + + it("inserts text at a buffer index through the same codec", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|cwx wpm=20"); + connection.prepareResponse("cwx insert", { message: "12,3" }); + + const queued = await radio.cwx().insert(10, 'a "b"', 3); + + expect(connection.lastCommand()).toBe('cwx insert 10 "a\x7f\x1eb\x1e" 3'); + expect(queued).toEqual({ block: 3, radioIndex: 12 }); + }); + + it("rejects insert when the radio refuses it", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|cwx wpm=20"); + connection.prepareResponse("cwx insert", { + code: 0x50000001, + message: "bad index", + }); + + await expect(radio.cwx().insert(99, "x", 1)).rejects.toThrow(); + }); + it("tracks sends by block id and reports the queued buffer position", async () => { // given a connected radio that answers a tracked send const { radio, connection } = await createConnectedRadio(); diff --git a/packages/flexlib/tests/flex/flex-client.spec.ts b/packages/flexlib/tests/flex/flex-client.spec.ts index ae0580e2..c3feea55 100644 --- a/packages/flexlib/tests/flex/flex-client.spec.ts +++ b/packages/flexlib/tests/flex/flex-client.spec.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + clientHandlesMatch, + formatClientHandle, + parseClientHandle, +} from "../../src/flex/client-handle.js"; import { FlexCommandRejectedError } from "../../src/flex/errors.js"; import { FlexClient } from "../../src/flex/flex-client.js"; import { MockFlexTransport } from "../helpers.js"; @@ -19,6 +24,32 @@ describe("FlexClient", () => { ]); }); + it("pads short handles to eight hex digits", async () => { + const transport = new MockFlexTransport(); + const client = new FlexClient({ transport }); + + await client.disconnectClient({ host: "192.168.1.100", port: 4992 }, 0x1); + await client.disconnectClient({ host: "192.168.1.100", port: 4992 }, "2a"); + + expect(transport.connections[0]?.commands).toEqual([ + "client disconnect 0x00000001", + ]); + expect(transport.connections[1]?.commands).toEqual([ + "client disconnect 0x0000002A", + ]); + }); + + it("formats, parses, and compares handles by value", () => { + expect(formatClientHandle(0xabc)).toBe("0x00000ABC"); + expect(parseClientHandle("10")).toBe(0x10); + expect(parseClientHandle("0X0000ff")).toBe(0xff); + expect(parseClientHandle("zz")).toBeUndefined(); + expect(clientHandlesMatch("0x1", "0x00000001")).toBe(true); + expect(clientHandlesMatch("1", "0x00000001")).toBe(true); + expect(clientHandlesMatch("0x1", "0x2")).toBe(false); + expect(clientHandlesMatch("abc-not", "ABC-NOT")).toBe(true); + }); + it("surfaces disconnect command rejections", async () => { const transport = new MockFlexTransport(); const client = new FlexClient({ transport }); diff --git a/packages/flexlib/tests/flex/meter.spec.ts b/packages/flexlib/tests/flex/meter.spec.ts index 6f1eb1f3..abea640e 100644 --- a/packages/flexlib/tests/flex/meter.spec.ts +++ b/packages/flexlib/tests/flex/meter.spec.ts @@ -54,6 +54,57 @@ describe("Meter controller", () => { expect(() => controller.snapshot()).toThrow(FlexStateUnavailableError); }); + it("keeps only the newest amplifier meter when a name is registered twice", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus( + "S1|meter 5.src=AMP#5.num=0x80001234#5.nam=FWDPWR#5.unit=Watts#", + ); + expect(radio.meter("5")).toBeDefined(); + + // a re-registered meter with a higher number supersedes the first + connection.emitStatus( + "S2|meter 9.src=AMP#9.num=0x80001234#9.nam=FWDPWR#9.unit=Watts#", + ); + expect(radio.meter("5")).toBeUndefined(); + expect(radio.meter("9")?.sourceIndex).toBe(0x80001234); + + // a late arrival with a lower number is dropped on arrival + connection.emitStatus( + "S3|meter 7.src=AMP#7.num=0x80001234#7.nam=FWDPWR#7.unit=Watts#", + ); + expect(radio.meter("7")).toBeUndefined(); + expect(radio.meters().map((m) => m.snapshot().id)).toEqual(["9"]); + + // a different amplifier handle or a different name is untouched + connection.emitStatus( + "S4|meter 10.src=AMP#10.num=0x80005678#10.nam=FWDPWR#", + ); + connection.emitStatus( + "S5|meter 11.src=AMP#11.num=0x80001234#11.nam=REFPWR#", + ); + expect(radio.meters()).toHaveLength(3); + }); + + it("keeps slice meters that share a name", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|meter 1.src=SLC#1.num=0#1.nam=LEVEL#"); + connection.emitStatus("S2|meter 2.src=SLC#2.num=1#2.nam=LEVEL#"); + expect(radio.meters()).toHaveLength(2); + expect(radio.meter("1")?.sourceIndex).toBe(0); + }); + + it("treats a missing num as unset and ignores a later num once set", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|meter 3.src=SLC#3.nam=LEVEL#"); + expect(radio.meter("3")?.sourceIndex).toBeUndefined(); + + connection.emitStatus("S2|meter 3.num=0#"); + expect(radio.meter("3")?.sourceIndex).toBe(0); + + connection.emitStatus("S3|meter 3.num=4#"); + expect(radio.meter("3")?.sourceIndex).toBe(0); + }); + it("scales raw meter samples using meter units", () => { // given/when various raw values with different unit types // then they are scaled correctly diff --git a/packages/flexlib/tests/flex/ntp-source.spec.ts b/packages/flexlib/tests/flex/ntp-source.spec.ts new file mode 100644 index 00000000..c9b150c4 --- /dev/null +++ b/packages/flexlib/tests/flex/ntp-source.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { buildNtpSourceCreateCommand } from "../../src/flex/ntp-source.js"; +import { createConnectedRadio } from "../helpers.js"; + +describe("NTP sources", () => { + it("subscribes in the handshake and parses status", async () => { + const { radio, connection } = await createConnectedRadio(); + expect(connection.commands).toContain("sub ntp_sources all"); + + connection.emitStatus( + 'S1|ntp_sources id=pool1 address=pool.ntp.org directive=pool description="Public pool" enabled=1 selected=1 prefer=1 state=selected stratum=2 reach=255', + ); + const source = radio.ntpSource("pool1"); + expect(source?.address).toBe("pool.ntp.org"); + expect(source?.directive).toBe("pool"); + expect(source?.description).toBe("Public pool"); + expect(source?.enabled).toBe(true); + expect(source?.selected).toBe(true); + expect(source?.prefer).toBe(true); + expect(source?.state).toBe("selected"); + expect(source?.stratum).toBe(2); + expect(source?.reachability).toBe(255); + + connection.emitStatus("S2|ntp_sources id=pool1 state=unknown"); + expect(radio.ntpSource("pool1")?.stratum).toBe(-1); + expect(radio.ntpSource("pool1")?.reachability).toBe(-1); + }); + + it("creates, enables, disables, and removes", async () => { + const { radio, connection } = await createConnectedRadio(); + + await radio.createNtpSource({ + id: "a1", + address: "time.example.com", + directive: "server", + description: "My server", + prefer: true, + }); + expect(connection.lastCommand()).toBe( + 'ntp_sources create id=a1 address=time.example.com directive=server description="My server" prefer=1', + ); + + connection.emitStatus( + "S1|ntp_sources id=a1 address=time.example.com directive=server enabled=1", + ); + const source = radio.ntpSource("a1"); + await source?.setEnabled(false); + expect(connection.lastCommand()).toBe("ntp_sources disable id=a1"); + expect(source?.enabled).toBe(false); + await source?.setEnabled(true); + expect(connection.lastCommand()).toBe("ntp_sources enable id=a1"); + + await source?.remove(); + expect(connection.lastCommand()).toBe("ntp_sources remove id=a1"); + connection.emitStatus("S2|ntp_sources id=a1 removed"); + expect(radio.ntpSource("a1")).toBeUndefined(); + }); + + it("undoes an enable change the radio refuses", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus( + "S1|ntp_sources id=a1 address=x directive=server enabled=1", + ); + connection.prepareResponse("ntp_sources disable", { + code: 0x50000001, + message: "gone", + }); + const source = radio.ntpSource("a1"); + await expect(source?.setEnabled(false)).rejects.toThrow(); + expect(source?.enabled).toBe(true); + }); + + it("rejects a create the radio refuses", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.prepareResponse("ntp_sources create", { + code: 0x50000001, + message: "bad", + }); + await expect( + radio.createNtpSource({ id: "a", address: "x", directive: "peer" }), + ).rejects.toThrow(); + }); + + it("validates fields before sending", async () => { + const base = { + id: "a1", + address: "h.example", + directive: "server" as const, + }; + expect(() => buildNtpSourceCreateCommand({ ...base, id: "-a" })).toThrow( + RangeError, + ); + expect(() => buildNtpSourceCreateCommand({ ...base, id: "a-" })).toThrow( + RangeError, + ); + expect(() => + buildNtpSourceCreateCommand({ ...base, id: "x".repeat(65) }), + ).toThrow(RangeError); + expect(() => + buildNtpSourceCreateCommand({ ...base, address: "bad host" }), + ).toThrow(RangeError); + expect(() => + buildNtpSourceCreateCommand({ ...base, address: "a".repeat(254) }), + ).toThrow(RangeError); + expect(() => + buildNtpSourceCreateCommand({ ...base, description: 'say "hi"' }), + ).toThrow(RangeError); + expect(() => + buildNtpSourceCreateCommand({ ...base, description: "d".repeat(63) }), + ).toThrow(RangeError); + expect(() => + buildNtpSourceCreateCommand({ ...base, directive: "bogus" as never }), + ).toThrow(RangeError); + expect( + buildNtpSourceCreateCommand({ ...base, description: "d".repeat(62) }), + ).toContain("description="); + + const { radio, connection } = await createConnectedRadio(); + const before = connection.commands.length; + await expect( + radio.createNtpSource({ ...base, id: "-bad" }), + ).rejects.toThrow(RangeError); + expect(connection.commands).toHaveLength(before); + }); + + it("does not send a create at the 10-source cap", async () => { + const { radio, connection } = await createConnectedRadio(); + for (let i = 0; i < 10; i += 1) { + connection.emitStatus( + `S${i}|ntp_sources id=s${i} address=h${i}.example directive=server enabled=1`, + ); + } + const before = connection.commands.length; + await expect( + radio.createNtpSource({ + id: "extra", + address: "x.example", + directive: "server", + }), + ).rejects.toThrow(RangeError); + expect(connection.commands).toHaveLength(before); + }); +}); diff --git a/packages/flexlib/tests/flex/panadapter.spec.ts b/packages/flexlib/tests/flex/panadapter.spec.ts index 14fe2618..94536b8e 100644 --- a/packages/flexlib/tests/flex/panadapter.spec.ts +++ b/packages/flexlib/tests/flex/panadapter.spec.ts @@ -224,4 +224,41 @@ describe("Panadapter controller", () => { // then the creation promise rejects with a closed error await expect(creation).rejects.toBeInstanceOf(FlexClientClosedError); }); + + it("parses and sets the noise floor range with one decimal place", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus( + "S1|display pan 0x40000000 client_handle=0x12345678 noise_floor_range=40.0", + ); + const controller = radio.panadapter("0x40000000"); + expect(controller?.noiseFloorRange).toBe(40); + + await controller?.setNoiseFloorRange(55.26); + expect(connection.lastCommand()).toBe( + "display pan set 0x40000000 noise_floor_range=55.3", + ); + expect(controller?.noiseFloorRange).toBeCloseTo(55.3); + + const sent = connection.lastCommand(); + await controller?.setNoiseFloorRange(55.31); + expect(connection.lastCommand()).toBe(sent); + + await expect(controller?.setNoiseFloorRange(Number.NaN)).rejects.toThrow( + RangeError, + ); + }); + + it("rejects when the radio refuses a noise floor range", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus( + "S1|display pan 0x40000000 client_handle=0x12345678 noise_floor_range=40.0", + ); + connection.prepareResponse("display pan set", { + code: 0x50000001, + message: "bad", + }); + await expect( + radio.panadapter("0x40000000")?.setNoiseFloorRange(10), + ).rejects.toThrow(); + }); }); diff --git a/packages/flexlib/tests/flex/preset.spec.ts b/packages/flexlib/tests/flex/preset.spec.ts new file mode 100644 index 00000000..9b017b57 --- /dev/null +++ b/packages/flexlib/tests/flex/preset.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { createConnectedRadio } from "../helpers.js"; + +describe("Presets", () => { + it("subscribes in the handshake and stores preset fields", async () => { + const { radio, connection } = await createConnectedRadio(); + expect(connection.commands).toContain("sub preset all"); + + connection.emitStatus( + "S1|preset id=1 name=Contest slice_config=abc ale_config=def radio_config=x", + ); + connection.emitStatus("S2|preset id=2 name=Rag"); + + expect(radio.presets()).toHaveLength(2); + const preset = radio.preset("1"); + expect(preset?.name).toBe("Contest"); + expect(preset?.sliceConfig).toBe("abc"); + expect(preset?.aleConfig).toBe("def"); + expect(preset?.active).toBe(false); + expect(preset?.snapshot().raw.radio_config).toBe("x"); + }); + + it("applies a preset, then skips the send once it is active", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|preset id=1 name=A"); + connection.emitStatus("S2|preset id=2 name=B"); + + await radio.preset("1")?.select(); + expect(connection.lastCommand()).toBe("preset apply id=1"); + + connection.emitStatus("S3|preset id=1 applied"); + expect(radio.activePresetId).toBe("1"); + expect(radio.preset("1")?.active).toBe(true); + expect(radio.preset("2")?.active).toBe(false); + + const before = connection.commands.length; + await radio.preset("1")?.select(); + expect(connection.commands).toHaveLength(before); + + await radio.preset("2")?.select(); + expect(connection.lastCommand()).toBe("preset apply id=2"); + connection.emitStatus("S4|preset id=2 applied"); + expect(radio.preset("1")?.active).toBe(false); + expect(radio.preset("2")?.active).toBe(true); + }); + + it("subscribes again on created and drops a removed preset", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|preset id=1 name=A"); + + connection.emitStatus("S2|preset id=5 created"); + expect(connection.lastCommand()).toBe("sub preset all"); + expect(radio.preset("5")).toBeUndefined(); + + const controller = radio.preset("1"); + connection.emitStatus("S3|preset id=1 removed"); + expect(radio.preset("1")).toBeUndefined(); + expect(() => controller?.snapshot()).toThrow(); + }); + + it("rejects when the radio refuses an apply", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|preset id=1 name=A"); + connection.prepareResponse("preset apply", { + code: 0x50000001, + message: "bad", + }); + await expect(radio.preset("1")?.select()).rejects.toThrow(); + }); +}); diff --git a/packages/flexlib/tests/flex/protocol.spec.ts b/packages/flexlib/tests/flex/protocol.spec.ts index 9ac3ff8e..2beb9312 100644 --- a/packages/flexlib/tests/flex/protocol.spec.ts +++ b/packages/flexlib/tests/flex/protocol.spec.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest"; import { parseFlexMessage } from "../../src/flex/protocol.js"; describe("parseFlexMessage", () => { + it("keeps attributes that follow a stray bare token", () => { + const parsed = parseFlexMessage( + "SB65DF43|memory 9 owner=KF0SMY mode=FDVU NOT step=500 repeater=SIMPLEX", + 0, + ); + expect(parsed?.kind).toBe("status"); + if (parsed?.kind !== "status") return; + expect(parsed.attributes.mode).toBe("FDVU"); + expect(parsed.attributes.NOT).toBe(""); + expect(parsed.attributes.step).toBe("500"); + expect(parsed.attributes.repeater).toBe("SIMPLEX"); + }); + it("parses slice status with attributes", () => { const now = Date.now(); const message = parseFlexMessage( diff --git a/packages/flexlib/tests/flex/radio.spec.ts b/packages/flexlib/tests/flex/radio.spec.ts index 7c149f13..530c83f9 100644 --- a/packages/flexlib/tests/flex/radio.spec.ts +++ b/packages/flexlib/tests/flex/radio.spec.ts @@ -86,8 +86,10 @@ describe("Radio", () => { await slice?.setNrfEnabled(true); expect(connection.lastCommand()).toBe("slice set 0 nrf=1"); - await slice?.setNrfLevel(40); - expect(connection.lastCommand()).toBe("slice set 0 nrf_level=40"); + await slice?.setNrsLevel(80); + expect(connection.lastCommand()).toBe("slice set 0 speex_nr_level=50"); + await slice?.setNrsLevel(-5); + expect(connection.lastCommand()).toBe("slice set 0 speex_nr_level=0"); await slice?.setEscEnabled(true); expect(connection.lastCommand()).toBe("slice set 0 esc=on"); @@ -234,7 +236,7 @@ describe("Radio", () => { const creationPromise = radio.createDaxRxAudioStream({ daxChannel: 3 }); // stream status reports slice binding as the letter connection.emitStatus( - "S1|stream 0x02000002 type=dax_rx dax_channel=3 slice=B client_handle=0x9ABC", + "S1|stream 0x02000002 type=dax_rx dax_channel=3 slice=B client_handle=0x12345678", ); const stream = await creationPromise; @@ -247,16 +249,59 @@ describe("Radio", () => { }, ); - it("throws when setting dax rx gain on a stream with no slice bound", async () => { + it("holds a dax rx gain until a slice is bound, then sends it", async () => { const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|slice 1 index_letter=B"); connection.prepareResponse("stream create", { message: "2000002" }); const creationPromise = radio.createDaxRxAudioStream({ daxChannel: 3 }); connection.emitStatus( - "S1|stream 0x02000002 type=dax_rx dax_channel=3 client_handle=0x9ABC", + "S1|stream 0x02000002 type=dax_rx dax_channel=3 client_handle=0x12345678", + ); + const stream = await creationPromise; + + await stream.setRxGain(40); + await stream.setRxGain(60); + expect(connection.lastCommand()).not.toMatch(/gain/); + + connection.emitStatus("S1|stream 0x02000002 slice=B"); + await Promise.resolve(); + expect(connection.lastCommand()).toBe( + "audio stream 0x02000002 slice 1 gain 60", + ); + }); + + it("tracks the gain the radio reports without sending anything back", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|slice 1 index_letter=B"); + connection.prepareResponse("stream create", { message: "2000002" }); + const creationPromise = radio.createDaxRxAudioStream({ daxChannel: 3 }); + connection.emitStatus( + "S1|stream 0x02000002 type=dax_rx dax_channel=3 slice=B client_handle=0x12345678 gain=50", + ); + const stream = await creationPromise; + await stream.setRxGain(70); + const sent = connection.commands.length; + + connection.emitStatus("S1|stream 0x02000002 gain=30"); + await Promise.resolve(); + + expect(stream.snapshot()?.rxGain).toBe(30); + expect(connection.commands.length).toBe(sent); + }); + + it("does not send dax rx gain for a stream another client owns", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus("S1|slice 1 index_letter=B"); + connection.prepareResponse("stream create", { message: "2000002" }); + const creationPromise = radio.createDaxRxAudioStream({ daxChannel: 3 }); + connection.emitStatus( + "S1|stream 0x02000002 type=dax_rx dax_channel=3 slice=B client_handle=0x9ABC", ); const stream = await creationPromise; + const before = connection.lastCommand(); - await expect(stream.setRxGain(50)).rejects.toThrow(/no slice bound/); + await expect(stream.setRxGain(50)).resolves.toBeUndefined(); + expect(connection.lastCommand()).toBe(before); }); it("creates dax tx audio stream controllers and requests tx ownership", async () => { @@ -466,9 +511,34 @@ describe("Radio", () => { await radio.setNickname("New Nick!"); expect(connection.lastCommand()).toBe("radio name New Nick"); + await radio.setNickname(" !! "); + expect(connection.lastCommand()).toBe("radio clear_name"); + expect(radio.snapshot()?.nickname).toBe(""); + + await radio.loadCwProfile("Contest"); + expect(connection.lastCommand()).toBe('profile cw load "Contest"'); + await radio.createCwProfile("New CW"); + expect(connection.lastCommand()).toBe('profile cw create "New CW"'); + await radio.resetCwProfile("New CW"); + expect(connection.lastCommand()).toBe('profile cw reset "New CW"'); + await radio.deleteCwProfile("New CW"); + expect(connection.lastCommand()).toBe('profile cw delete "New CW"'); + + await radio.setPttMicOverride(true); + expect(connection.lastCommand()).toBe("transmit set ptt_mic_override=1"); + await radio.setCallsign("kf0smy"); expect(connection.lastCommand()).toBe("radio callsign KF0SMY"); + await radio.setDebugFakePaOvertemp(true); + expect(connection.lastCommand()).toBe("radio set debug_fake_pa_overtemp=1"); + expect(radio.getStore().getRadio()?.debugFakePaOvertemp).toBe(true); + await radio.setDebugFakePaSwrFault(true); + expect(connection.lastCommand()).toBe( + "radio set debug_fake_pa_swr_fault=1", + ); + expect(radio.getStore().getRadio()?.debugFakePaSwrFault).toBe(true); + await radio.setFullDuplexEnabled(false); expect(connection.lastCommand()).toBe("radio set full_duplex_enabled=0"); @@ -545,4 +615,26 @@ describe("Radio", () => { expect(reasons).toEqual(["duplicate_client_id"]); }); + + it("sends the cw profile and ha_api system subscriptions in the handshake", async () => { + const { connection } = await createConnectedRadio(); + expect(connection.commands).toContain("profile cw info"); + expect(connection.commands).toContain("sub ha_api system"); + }); + + it("rejects cw profile, mic override, and clear-name commands the radio refuses", async () => { + const { radio, connection } = await createConnectedRadio(); + for (const prefix of [ + "profile cw create", + "transmit set ptt_mic_override", + "radio clear_name", + "radio set debug_fake_pa_overtemp", + ]) { + connection.prepareResponse(prefix, { code: 0x50000001, message: "no" }); + } + await expect(radio.createCwProfile("X")).rejects.toThrow(); + await expect(radio.setPttMicOverride(true)).rejects.toThrow(); + await expect(radio.setNickname("")).rejects.toThrow(); + await expect(radio.setDebugFakePaOvertemp(true)).rejects.toThrow(); + }); }); diff --git a/packages/flexlib/tests/flex/slice-dsp.spec.ts b/packages/flexlib/tests/flex/slice-dsp.spec.ts new file mode 100644 index 00000000..0214a7a7 --- /dev/null +++ b/packages/flexlib/tests/flex/slice-dsp.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { createConnectedRadio } from "../helpers.js"; + +async function setup(status = "") { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus(`S1|slice 0 index_letter=A mode=USB ${status}`.trim()); + const slice = radio.slice("0"); + if (!slice) throw new Error("slice missing"); + return { slice, connection }; +} + +describe("slice DSP parameters", () => { + it("parses typed fields from status", async () => { + const { slice } = await setup( + "nr_delay=25 nr_wlen=256 nr_adapt_mode=fast nr_isdft_mode=half nrl_filter_size=128 nrl_delay=16 nrl_leakage_level=50 nrf_wlen=2048 anf_wlen=64 anf_delay=8 anf_adapt_mode=slow anf_isdft_mode=zero anfl_filter_size=64 anfl_delay=12 anfl_leakage_level=40 anft_min_db=-80 anft_tone_db_diff=6", + ); + expect(slice.nrDelay).toBe(25); + expect(slice.nrWlen).toBe(256); + expect(slice.nrAdaptMode).toBe("fast"); + expect(slice.nrIsdftMode).toBe("half"); + expect(slice.nrlFilterSize).toBe(128); + expect(slice.nrlDelay).toBe(16); + expect(slice.nrlLeakageLevel).toBe(50); + expect(slice.nrfWlen).toBe(2048); + expect(slice.anfWlen).toBe(64); + expect(slice.anfDelay).toBe(8); + expect(slice.anfAdaptMode).toBe("slow"); + expect(slice.anfIsdftMode).toBe("zero"); + expect(slice.anflFilterSize).toBe(64); + expect(slice.anflDelay).toBe(12); + expect(slice.anflLeakageLevel).toBe(40); + expect(slice.anftMinDb).toBe(-80); + expect(slice.anftToneDbDiff).toBe(6); + }); + + it("keeps an unknown adapt mode off the typed field", async () => { + const { slice } = await setup("nr_adapt_mode=2 nr_isdft_mode=1"); + expect(slice.nrAdaptMode).toBeUndefined(); + expect(slice.nrIsdftMode).toBeUndefined(); + expect(slice.snapshot().raw.nr_adapt_mode).toBe("2"); + }); + + it("sends the command key, which differs from the status key", async () => { + const { slice, connection } = await setup(); + await slice.setNrlFilterSize(600); + expect(connection.lastCommand()).toBe("slice set 0 lms_nr_taps=512"); + expect(slice.nrlFilterSize).toBe(512); + await slice.setNrlDelay(0); + expect(connection.lastCommand()).toBe("slice set 0 lms_nr_delay=1"); + await slice.setNrlLeakageLevel(150); + expect(connection.lastCommand()).toBe( + "slice set 0 lms_nr_leakage_level=100", + ); + await slice.setAnflFilterSize(64); + expect(connection.lastCommand()).toBe("slice set 0 lms_anf_taps=64"); + await slice.setAnflDelay(9); + expect(connection.lastCommand()).toBe("slice set 0 lms_anf_delay=9"); + await slice.setAnflLeakageLevel(10); + expect(connection.lastCommand()).toBe( + "slice set 0 lms_anf_leakage_level=10", + ); + }); + + it("sends the plain-key setters", async () => { + const { slice, connection } = await setup(); + await slice.setNrDelay(30); + expect(connection.lastCommand()).toBe("slice set 0 nr_delay=30"); + await slice.setNrAdaptMode("slow"); + expect(connection.lastCommand()).toBe("slice set 0 nr_adapt_mode=slow"); + await slice.setNrIsdftMode("one"); + expect(connection.lastCommand()).toBe("slice set 0 nr_isdft_mode=one"); + await slice.setAnfDelay(5); + expect(connection.lastCommand()).toBe("slice set 0 anf_delay=5"); + await slice.setAnfAdaptMode("auto"); + expect(connection.lastCommand()).toBe("slice set 0 anf_adapt_mode=auto"); + await slice.setAnfIsdftMode("half"); + expect(connection.lastCommand()).toBe("slice set 0 anf_isdft_mode=half"); + await slice.setAnftMinDb(-70); + expect(connection.lastCommand()).toBe("slice set 0 anft_min_db=-70"); + await slice.setAnftToneDbDiff(8); + expect(connection.lastCommand()).toBe("slice set 0 anft_tone_db_diff=8"); + }); + + it("sends a window length only when it is a power of two, then clamps", async () => { + const { slice, connection } = await setup(); + await slice.setNrWlen(100); + expect(connection.lastCommand()).not.toMatch(/nr_wlen/); + await slice.setNrWlen(1024); + expect(connection.lastCommand()).toBe("slice set 0 nr_wlen=512"); + await slice.setAnfWlen(16); + expect(connection.lastCommand()).toBe("slice set 0 anf_wlen=32"); + await slice.setNrfWlen(1000); + expect(connection.lastCommand()).not.toMatch(/nrf_wlen/); + await slice.setNrfWlen(256); + expect(connection.lastCommand()).toBe("slice set 0 nrf_wlen=512"); + await slice.setNrfWlen(16384); + expect(connection.lastCommand()).toBe("slice set 0 nrf_wlen=8192"); + }); + + it("does not send an unknown mode string", async () => { + const { slice, connection } = await setup(); + const before = connection.lastCommand(); + await slice.setNrAdaptMode("bogus" as never); + await slice.setAgcThresholdMode("bogus" as never); + expect(connection.lastCommand()).toBe(before); + }); + + it("resets one algorithm", async () => { + const { slice, connection } = await setup(); + await slice.resetDspParameters("lms_nr"); + expect(connection.lastCommand()).toBe("slice reset 0 dsp_param=lms_nr"); + await slice.resetDspParameters("all"); + expect(connection.lastCommand()).toBe("slice reset 0 dsp_param=all"); + }); + + it("rejects when the radio refuses a DSP command", async () => { + const { slice, connection } = await setup(); + connection.prepareResponse("slice set 0 nr_delay", { + code: 0x50000001, + message: "bad", + }); + await expect(slice.setNrDelay(10)).rejects.toThrow(); + connection.prepareResponse("slice reset", { + code: 0x50000001, + message: "bad", + }); + await expect(slice.resetDspParameters("nr")).rejects.toThrow(); + }); +}); + +describe("AGC threshold mode and chain mode", () => { + it("parses the AGC threshold mode and sends it", async () => { + const { slice, connection } = await setup("agc_threshold_mode=auto"); + expect(slice.agcThresholdMode).toBe("auto"); + await slice.setAgcThresholdMode("manual"); + expect(connection.lastCommand()).toBe( + "slice set 0 agc_threshold_mode=manual", + ); + expect(slice.agcThresholdMode).toBe("manual"); + }); + + it("leaves an unknown AGC threshold mode on raw", async () => { + const { slice } = await setup("agc_threshold_mode=semi"); + expect(slice.agcThresholdMode).toBeUndefined(); + expect(slice.snapshot().raw.agc_threshold_mode).toBe("semi"); + }); + + it("reports chain_mode, and leaves it unset when absent or empty", async () => { + const { slice, connection } = await setup("chain_mode=CW"); + expect(slice.chainMode).toBe("CW"); + connection.emitStatus("S2|slice 0 chain_mode="); + expect(slice.chainMode).toBe("CW"); + + const bare = await setup(); + expect(bare.slice.chainMode).toBeUndefined(); + }); +}); diff --git a/packages/flexlib/tests/flex/state.spec.ts b/packages/flexlib/tests/flex/state.spec.ts index 0fa63141..b4a81d46 100644 --- a/packages/flexlib/tests/flex/state.spec.ts +++ b/packages/flexlib/tests/flex/state.spec.ts @@ -113,7 +113,7 @@ describe("createRadioStateStore", () => { expect(slice?.rnnEnabled).toBe(true); expect(slice?.anftEnabled).toBe(true); expect(slice?.nrfEnabled).toBe(true); - expect(slice?.nrfLevel).toBe(55); + expect(slice?.raw.nrf_level).toBe("55"); expect(slice?.escEnabled).toBe(true); expect(slice?.escGain).toBeCloseTo(0.75); expect(slice?.escPhaseShift).toBeCloseTo(15.5); @@ -168,6 +168,89 @@ describe("createRadioStateStore", () => { expect(store.getRadio()?.interlockPttSource).toBe("DVK"); }); + it("parses cw profile list, selection, and unsaved changes", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|profile cw list=Default^Contest^")); + store.apply(makeStatus("S2|profile cw current=Contest")); + store.apply(makeStatus("S3|profile cw unsaved_changes_cw=1")); + const radio = store.getRadio(); + expect(radio?.profileCwList).toEqual(["Default", "Contest"]); + expect(radio?.profileCwSelection).toBe("Contest"); + expect(radio?.profileUnsavedChangesCw).toBe(true); + }); + + it("accepts a cw profile selection before the list arrives", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|profile cw current=Solo")); + expect(store.getRadio()?.profileCwSelection).toBe("Solo"); + expect(store.getRadio()?.profileCwList).toBeUndefined(); + }); + + it("parses gps fix and keeps an unknown fix off the typed field", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|gps fix=3D#tracked=5")); + expect(store.getRadio()?.gpsFix).toBe("3D"); + store.apply(makeStatus("S2|gps fix=NO_FIX")); + expect(store.getRadio()?.gpsFix).toBe("NO_FIX"); + store.apply(makeStatus("S3|gps fix=SURVEY#tracked=7")); + expect(store.getRadio()?.gpsFix).toBe("NO_FIX"); + expect(store.getRadio()?.gpsSatellitesTracked).toBe(7); + expect(store.getRadio()?.raw.fix).toBe("SURVEY"); + }); + + it("parses ptt_mic_override from transmit status", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|transmit ptt_mic_override=1 vox_enable=0")); + expect(store.getRadio()?.pttMicOverride).toBe(true); + store.apply(makeStatus("S2|transmit ptt_mic_override=0")); + expect(store.getRadio()?.pttMicOverride).toBe(false); + }); + + it("parses the amplifier software version from ha_api system status", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|ha_api system version=1.2.3")); + expect(store.getRadio()?.amplifierSoftwareVersion).toBe("1.2.3"); + store.apply(makeStatus("S2|ha_api system version=")); + expect(store.getRadio()?.amplifierSoftwareVersion).toBe("1.2.3"); + }); + + it("marks features permanent by reason, never alpha", () => { + const store = createRadioStateStore(); + for (const line of [ + "S1|license feature name=a enabled=1 reason=license_file", + "S2|license feature name=b enabled=1 reason=plus", + "S3|license feature name=c enabled=1 reason=ea", + "S4|license feature name=d enabled=1 reason=built_in", + "S5|license feature name=e enabled=0 reason=license_file", + "S6|license feature name=alpha enabled=1 reason=license_file", + ]) { + store.apply(makeStatus(line)); + } + const f = store.getFeatureLicense()?.features; + expect(f?.a?.permanent).toBe(true); + expect(f?.b?.permanent).toBe(true); + expect(f?.c?.permanent).toBe(true); + expect(f?.d?.permanent).toBe(false); + expect(f?.e?.permanent).toBe(false); + expect(f?.alpha?.permanent).toBe(false); + }); + + it("parses VOX and comma-joined ptt sources", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|interlock source=VOX")); + expect(store.getRadio()?.interlockPttSource).toBe("VOX"); + + store.apply(makeStatus("S2|interlock source=MIC,VOX")); + expect(store.getRadio()?.interlockPttSource).toBe("MIC"); + + store.apply(makeStatus("S3|interlock source=FOO,vox")); + expect(store.getRadio()?.interlockPttSource).toBe("VOX"); + + store.apply(makeStatus("S4|interlock source=FOO,BAR timeout=7")); + expect(store.getRadio()?.interlockPttSource).toBe("VOX"); + expect(store.getRadio()?.interlockTimeoutMs).toBe(7); + }); + it("parses transmit status fields", () => { const store = createRadioStateStore(); store.apply( @@ -409,6 +492,27 @@ describe("createRadioStateStore", () => { expect(radio?.gpsInstalled).toBe(true); }); + it("types cw_decode_available without logging it as unhandled", () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const store = createRadioStateStore(); + store.apply(makeStatus("S1|radio cw_decode_available=1")); + expect(store.getRadio()?.cwDecodeAvailable).toBe(true); + expect(debug).not.toHaveBeenCalled(); + debug.mockRestore(); + }); + + it("parses tune_tx_suppress and apd deployed as booleans", () => { + const store = createRadioStateStore(); + store.apply(makeStatus("S1|interlock tune_tx_suppress=1")); + store.apply( + makeStatus("S1|apd deployed=1 quality_db=-71.18 backend=polynomial"), + ); + expect(store.getRadio()?.interlockTuneTxSuppress).toBe(true); + expect(store.getApd()?.deployed).toBe(true); + expect(store.getApd()?.qualityDb).toBeCloseTo(-71.18); + expect(store.getApd()?.backend).toBe("polynomial"); + }); + it("parses filter sharpness, static network, and oscillator statuses", () => { const store = createRadioStateStore(); const filterVoice = diff --git a/packages/flexlib/tests/flex/waveform.spec.ts b/packages/flexlib/tests/flex/waveform.spec.ts index 6c3c35b3..85eae202 100644 --- a/packages/flexlib/tests/flex/waveform.spec.ts +++ b/packages/flexlib/tests/flex/waveform.spec.ts @@ -48,4 +48,83 @@ describe("waveform controller", () => { FlexStateUnavailableError, ); }); + + it("decodes the container environment and replaces it in place", async () => { + const { radio, connection } = await createConnectedRadio(); + const b64 = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64"); + + connection.emitStatus( + `S1|waveform container name=FT8 version=0.9 env=${b64({ MODE: "fast", GREETING: "héllo wörld=1" })}`, + ); + const id = radio.stateSnapshot().waveforms[0]?.id ?? ""; + const controller = radio.waveform(id); + expect(controller?.env).toEqual({ + MODE: "fast", + GREETING: "héllo wörld=1", + }); + expect(Object.isFrozen(controller?.env)).toBe(true); + + connection.emitStatus( + `S2|waveform container name=FT8 version=0.9 env=${b64({ MODE: "slow" })}`, + ); + expect(radio.waveform(id)).toBe(controller); + expect(radio.waveforms()).toHaveLength(1); + expect(controller?.env).toEqual({ MODE: "slow" }); + }); + + it("treats an undecodable environment as empty", async () => { + const { radio, connection } = await createConnectedRadio(); + connection.emitStatus( + "S1|waveform container name=FT8 version=0.9 env=%%%not-base64", + ); + connection.emitStatus( + `S2|waveform container name=PSK version=1.0 env=${Buffer.from("[1,2]").toString("base64")}`, + ); + for (const waveform of radio.stateSnapshot().waveforms) { + expect(waveform.env).toEqual({}); + } + }); + + it("stages, clears, and recreates, and rejects refusals", async () => { + const { radio, connection } = await createConnectedRadio(); + + await radio.stageWaveformEnv("CALL", "W1AW portable"); + expect(connection.lastCommand()).toBe("waveform env CALL=W1AW portable"); + await radio.clearWaveformEnv(); + expect(connection.lastCommand()).toBe("waveform env clear"); + await radio.recreateWaveform("FT8"); + expect(connection.lastCommand()).toBe("waveform recreate FT8"); + + const before = connection.commands.length; + await expect(radio.stageWaveformEnv("", "x")).rejects.toThrow(RangeError); + await expect(radio.stageWaveformEnv("A B", "x")).rejects.toThrow( + RangeError, + ); + await expect(radio.stageWaveformEnv("A", "x\nquit")).rejects.toThrow( + RangeError, + ); + await expect(radio.recreateWaveform("a b")).rejects.toThrow(RangeError); + expect(connection.commands).toHaveLength(before); + + connection.prepareResponse("waveform recreate", { + code: 0x50000001, + message: "no", + }); + await expect(radio.recreateWaveform("FT8")).rejects.toThrow(); + }); + + it("reports the restarted status as an event", async () => { + const { radio, connection } = await createConnectedRadio(); + const events: unknown[] = []; + radio.on("waveformRestarted", (event) => events.push(event)); + + connection.emitStatus("S1|waveform restarted name=FT8 result=ok"); + connection.emitStatus("S2|waveform restarted name=FT8 result=error"); + + expect(events).toEqual([ + { name: "FT8", success: true, result: "ok" }, + { name: "FT8", success: false, result: "error" }, + ]); + }); }); diff --git a/packages/flexlib/tests/vita/dax-audio-packet.spec.ts b/packages/flexlib/tests/vita/dax-audio-packet.spec.ts index d43084ff..a96ccc40 100644 --- a/packages/flexlib/tests/vita/dax-audio-packet.spec.ts +++ b/packages/flexlib/tests/vita/dax-audio-packet.spec.ts @@ -7,11 +7,15 @@ import { VitaTimeStampIntegerType, } from "../../src/vita/common"; import { - VITA_FLEX_DAX_AUDIO_CLASS, - VITA_FLEX_DAX_REDUCED_BW_CLASS, + daxAudioClassForRate, + daxAudioSampleRateFromClass, + daxReducedBwClassForRate, + VITA_FLEX_DAX_AUDIO_24_CLASS, + VITA_FLEX_DAX_REDUCED_BW_24_CLASS, VitaDaxAudioPacket, VitaDaxReducedBwPacket, } from "../../src/vita/dax-audio-packet"; +import { parseVitaPacket } from "../../src/vita/parser"; // --------------------------------------------------------------------------- // VitaDaxAudioPacket @@ -29,7 +33,7 @@ describe("VitaDaxAudioPacket: default header", () => { ); expect(p.classId.oui).toBe(VITA_FLEX_OUI); expect(p.classId.informationClassCode).toBe(VITA_FLEX_INFO_CLASS); - expect(p.classId.packetClassCode).toBe(VITA_FLEX_DAX_AUDIO_CLASS); + expect(p.classId.packetClassCode).toBe(VITA_FLEX_DAX_AUDIO_24_CLASS); }); }); @@ -62,7 +66,7 @@ describe("VitaDaxAudioPacket: round-trip (128 frames)", () => { expect(p2.streamId).toBe(0xdeadbeef); expect(p2.classId.oui).toBe(VITA_FLEX_OUI); expect(p2.classId.informationClassCode).toBe(VITA_FLEX_INFO_CLASS); - expect(p2.classId.packetClassCode).toBe(VITA_FLEX_DAX_AUDIO_CLASS); + expect(p2.classId.packetClassCode).toBe(VITA_FLEX_DAX_AUDIO_24_CLASS); expect(p2.numFrames).toBe(FRAMES); // float32 round-trip is exact @@ -151,7 +155,7 @@ describe("VitaDaxReducedBwPacket: default header", () => { ); expect(p.classId.oui).toBe(VITA_FLEX_OUI); expect(p.classId.informationClassCode).toBe(VITA_FLEX_INFO_CLASS); - expect(p.classId.packetClassCode).toBe(VITA_FLEX_DAX_REDUCED_BW_CLASS); + expect(p.classId.packetClassCode).toBe(VITA_FLEX_DAX_REDUCED_BW_24_CLASS); }); }); @@ -173,7 +177,7 @@ describe("VitaDaxReducedBwPacket: round-trip (128 frames)", () => { expect(p2.header.hasTrailer).toBe(false); expect(p2.header.packetCount).toBe(3); expect(p2.streamId).toBe(0x12345678); - expect(p2.classId.packetClassCode).toBe(VITA_FLEX_DAX_REDUCED_BW_CLASS); + expect(p2.classId.packetClassCode).toBe(VITA_FLEX_DAX_REDUCED_BW_24_CLASS); expect(p2.numFrames).toBe(FRAMES); expect(Array.from(p2.samples)).toEqual(Array.from(p1.samples)); }); @@ -247,7 +251,9 @@ describe("VitaDaxReducedBwPacket: empty payload", () => { describe("class codes", () => { it("audio and reduced-bw class codes differ", () => { - expect(VITA_FLEX_DAX_AUDIO_CLASS).not.toBe(VITA_FLEX_DAX_REDUCED_BW_CLASS); + expect(VITA_FLEX_DAX_AUDIO_24_CLASS).not.toBe( + VITA_FLEX_DAX_REDUCED_BW_24_CLASS, + ); }); it("parsing audio bytes with VitaDaxReducedBwPacket yields different frame count", () => { @@ -263,3 +269,38 @@ describe("class codes", () => { expect(reducedBw.numFrames).toBe(512); }); }); + +describe("DAX audio class codes follow the sample rate", () => { + it.each([ + [24000, 0x03e3, 0x0123], + [48000, 0x03e4, 0x0124], + [96000, 0x03e5, 0x0125], + ])( + "%i Hz parses through the float and int16 parsers", + (rate, floatClass, intClass) => { + expect(daxAudioClassForRate(rate)).toBe(floatClass); + expect(daxReducedBwClassForRate(rate)).toBe(intClass); + expect(daxAudioSampleRateFromClass(floatClass)).toBe(rate); + expect(daxAudioSampleRateFromClass(intClass)).toBe(rate); + + const stereo = new VitaDaxAudioPacket(); + stereo.classId.packetClassCode = floatClass; + stereo.left = Float32Array.of(0.5); + stereo.right = Float32Array.of(-0.5); + const parsedStereo = parseVitaPacket(stereo.toBytes()); + expect(parsedStereo?.kind).toBe("daxAudio"); + + const mono = new VitaDaxReducedBwPacket(); + mono.classId.packetClassCode = intClass; + mono.samples = Int16Array.of(1, 2); + const parsedMono = parseVitaPacket(mono.toBytes()); + expect(parsedMono?.kind).toBe("daxReducedBw"); + }, + ); + + it("has no class for an unsupported rate", () => { + expect(daxAudioClassForRate(0)).toBeUndefined(); + expect(daxAudioClassForRate(192000)).toBeUndefined(); + expect(daxReducedBwClassForRate(44100)).toBeUndefined(); + }); +});