tci-client-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/audio/index.cjs +343 -0
- package/dist/audio/index.cjs.map +1 -0
- package/dist/audio/index.d.cts +60 -0
- package/dist/audio/index.d.ts +60 -0
- package/dist/audio/index.js +301 -0
- package/dist/audio/index.js.map +1 -0
- package/dist/index-CK3XdXP3.d.cts +42 -0
- package/dist/index-Dfmrk2MR.d.ts +42 -0
- package/dist/index.cjs +1182 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +130 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +1117 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/index.cjs +309 -0
- package/dist/protocol/index.cjs.map +1 -0
- package/dist/protocol/index.d.cts +2 -0
- package/dist/protocol/index.d.ts +2 -0
- package/dist/protocol/index.js +274 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/testing/index.cjs +572 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +78 -0
- package/dist/testing/index.d.ts +78 -0
- package/dist/testing/index.js +533 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/text-BwCWY1k1.d.cts +21 -0
- package/dist/text-BwCWY1k1.d.ts +21 -0
- package/package.json +93 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { TciStreamFrame, BuildStreamFrameOptions } from '../audio/index.cjs';
|
|
3
|
+
import { T as TciCommand } from '../text-BwCWY1k1.cjs';
|
|
4
|
+
import { EventEmitter } from 'eventemitter3';
|
|
5
|
+
|
|
6
|
+
interface MockTciServerOptions {
|
|
7
|
+
port?: number;
|
|
8
|
+
host?: string;
|
|
9
|
+
startupCommands?: string[];
|
|
10
|
+
echoUnknown?: boolean;
|
|
11
|
+
commandDelayMs?: number;
|
|
12
|
+
}
|
|
13
|
+
interface MockTciServerCommandContext {
|
|
14
|
+
server: MockTciServer;
|
|
15
|
+
socket: WebSocket;
|
|
16
|
+
command: TciCommand;
|
|
17
|
+
}
|
|
18
|
+
type MockTciServerCommandHandler = (context: MockTciServerCommandContext) => void | boolean | Promise<void | boolean>;
|
|
19
|
+
declare class MockTciServer {
|
|
20
|
+
readonly receivedCommands: TciCommand[];
|
|
21
|
+
readonly receivedTxAudioFrames: TciStreamFrame[];
|
|
22
|
+
private readonly options;
|
|
23
|
+
private wss?;
|
|
24
|
+
private sockets;
|
|
25
|
+
private handler?;
|
|
26
|
+
private frequency;
|
|
27
|
+
private mode;
|
|
28
|
+
private ptt;
|
|
29
|
+
constructor(options?: MockTciServerOptions);
|
|
30
|
+
start(): Promise<void>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
url(): string;
|
|
33
|
+
onCommand(handler: MockTciServerCommandHandler): void;
|
|
34
|
+
broadcast(command: string): void;
|
|
35
|
+
broadcastCommand(name: string, args?: readonly unknown[]): void;
|
|
36
|
+
sendRxAudioFrame(options?: Partial<BuildStreamFrameOptions> & {
|
|
37
|
+
samples?: Float32Array | readonly number[];
|
|
38
|
+
}): void;
|
|
39
|
+
sendTxChrono(options?: Partial<BuildStreamFrameOptions> & {
|
|
40
|
+
sampleCount?: number;
|
|
41
|
+
}): void;
|
|
42
|
+
closeClients(): void;
|
|
43
|
+
private handleConnection;
|
|
44
|
+
private handleMessage;
|
|
45
|
+
private defaultReply;
|
|
46
|
+
private broadcastBinary;
|
|
47
|
+
private delay;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface FakeWebSocketSentMessage {
|
|
51
|
+
data: WebSocket.RawData | string;
|
|
52
|
+
isBinary: boolean;
|
|
53
|
+
}
|
|
54
|
+
type FakeWebSocketObserver = (socket: FakeWebSocket) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Minimal in-memory WebSocket implementation for deterministic TciClient tests.
|
|
57
|
+
* It auto-opens on the next microtask and exposes helpers to inject server data.
|
|
58
|
+
*/
|
|
59
|
+
declare class FakeWebSocket extends EventEmitter {
|
|
60
|
+
readonly url: string;
|
|
61
|
+
static readonly CONNECTING = 0;
|
|
62
|
+
static readonly OPEN = 1;
|
|
63
|
+
static readonly CLOSING = 2;
|
|
64
|
+
static readonly CLOSED = 3;
|
|
65
|
+
readonly sentMessages: FakeWebSocketSentMessage[];
|
|
66
|
+
readyState: number;
|
|
67
|
+
constructor(url: string);
|
|
68
|
+
send(data: WebSocket.RawData | string, optionsOrCallback?: {
|
|
69
|
+
binary?: boolean;
|
|
70
|
+
} | ((error?: Error) => void), callback?: (error?: Error) => void): void;
|
|
71
|
+
receive(data: WebSocket.RawData | string, isBinary?: boolean): void;
|
|
72
|
+
open(): void;
|
|
73
|
+
close(code?: number, reason?: string): void;
|
|
74
|
+
terminate(): void;
|
|
75
|
+
}
|
|
76
|
+
declare function createFakeWebSocketImpl(observer?: FakeWebSocketObserver): typeof WebSocket;
|
|
77
|
+
|
|
78
|
+
export { FakeWebSocket, type FakeWebSocketObserver, type FakeWebSocketSentMessage, MockTciServer, type MockTciServerCommandContext, type MockTciServerCommandHandler, type MockTciServerOptions, createFakeWebSocketImpl };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { TciStreamFrame, BuildStreamFrameOptions } from '../audio/index.js';
|
|
3
|
+
import { T as TciCommand } from '../text-BwCWY1k1.js';
|
|
4
|
+
import { EventEmitter } from 'eventemitter3';
|
|
5
|
+
|
|
6
|
+
interface MockTciServerOptions {
|
|
7
|
+
port?: number;
|
|
8
|
+
host?: string;
|
|
9
|
+
startupCommands?: string[];
|
|
10
|
+
echoUnknown?: boolean;
|
|
11
|
+
commandDelayMs?: number;
|
|
12
|
+
}
|
|
13
|
+
interface MockTciServerCommandContext {
|
|
14
|
+
server: MockTciServer;
|
|
15
|
+
socket: WebSocket;
|
|
16
|
+
command: TciCommand;
|
|
17
|
+
}
|
|
18
|
+
type MockTciServerCommandHandler = (context: MockTciServerCommandContext) => void | boolean | Promise<void | boolean>;
|
|
19
|
+
declare class MockTciServer {
|
|
20
|
+
readonly receivedCommands: TciCommand[];
|
|
21
|
+
readonly receivedTxAudioFrames: TciStreamFrame[];
|
|
22
|
+
private readonly options;
|
|
23
|
+
private wss?;
|
|
24
|
+
private sockets;
|
|
25
|
+
private handler?;
|
|
26
|
+
private frequency;
|
|
27
|
+
private mode;
|
|
28
|
+
private ptt;
|
|
29
|
+
constructor(options?: MockTciServerOptions);
|
|
30
|
+
start(): Promise<void>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
url(): string;
|
|
33
|
+
onCommand(handler: MockTciServerCommandHandler): void;
|
|
34
|
+
broadcast(command: string): void;
|
|
35
|
+
broadcastCommand(name: string, args?: readonly unknown[]): void;
|
|
36
|
+
sendRxAudioFrame(options?: Partial<BuildStreamFrameOptions> & {
|
|
37
|
+
samples?: Float32Array | readonly number[];
|
|
38
|
+
}): void;
|
|
39
|
+
sendTxChrono(options?: Partial<BuildStreamFrameOptions> & {
|
|
40
|
+
sampleCount?: number;
|
|
41
|
+
}): void;
|
|
42
|
+
closeClients(): void;
|
|
43
|
+
private handleConnection;
|
|
44
|
+
private handleMessage;
|
|
45
|
+
private defaultReply;
|
|
46
|
+
private broadcastBinary;
|
|
47
|
+
private delay;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface FakeWebSocketSentMessage {
|
|
51
|
+
data: WebSocket.RawData | string;
|
|
52
|
+
isBinary: boolean;
|
|
53
|
+
}
|
|
54
|
+
type FakeWebSocketObserver = (socket: FakeWebSocket) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Minimal in-memory WebSocket implementation for deterministic TciClient tests.
|
|
57
|
+
* It auto-opens on the next microtask and exposes helpers to inject server data.
|
|
58
|
+
*/
|
|
59
|
+
declare class FakeWebSocket extends EventEmitter {
|
|
60
|
+
readonly url: string;
|
|
61
|
+
static readonly CONNECTING = 0;
|
|
62
|
+
static readonly OPEN = 1;
|
|
63
|
+
static readonly CLOSING = 2;
|
|
64
|
+
static readonly CLOSED = 3;
|
|
65
|
+
readonly sentMessages: FakeWebSocketSentMessage[];
|
|
66
|
+
readyState: number;
|
|
67
|
+
constructor(url: string);
|
|
68
|
+
send(data: WebSocket.RawData | string, optionsOrCallback?: {
|
|
69
|
+
binary?: boolean;
|
|
70
|
+
} | ((error?: Error) => void), callback?: (error?: Error) => void): void;
|
|
71
|
+
receive(data: WebSocket.RawData | string, isBinary?: boolean): void;
|
|
72
|
+
open(): void;
|
|
73
|
+
close(code?: number, reason?: string): void;
|
|
74
|
+
terminate(): void;
|
|
75
|
+
}
|
|
76
|
+
declare function createFakeWebSocketImpl(observer?: FakeWebSocketObserver): typeof WebSocket;
|
|
77
|
+
|
|
78
|
+
export { FakeWebSocket, type FakeWebSocketObserver, type FakeWebSocketSentMessage, MockTciServer, type MockTciServerCommandContext, type MockTciServerCommandHandler, type MockTciServerOptions, createFakeWebSocketImpl };
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// src/testing/MockTciServer.ts
|
|
2
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var TciError = class extends Error {
|
|
6
|
+
code;
|
|
7
|
+
details;
|
|
8
|
+
constructor(code, message, details) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "TciError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// src/audio/streamFrame.ts
|
|
17
|
+
var TCI_STREAM_HEADER_BYTES = 16 * 4;
|
|
18
|
+
function parseStreamFrame(input) {
|
|
19
|
+
const buffer = toBuffer(input);
|
|
20
|
+
if (buffer.byteLength < TCI_STREAM_HEADER_BYTES) {
|
|
21
|
+
throw new TciError("invalid-frame", `TCI stream frame is shorter than ${TCI_STREAM_HEADER_BYTES} bytes`);
|
|
22
|
+
}
|
|
23
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
24
|
+
const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
|
|
25
|
+
const sampleType = normalizeSampleType(header[2]);
|
|
26
|
+
let channels = header[7];
|
|
27
|
+
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
28
|
+
const sampleCount = header[5];
|
|
29
|
+
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
30
|
+
if (channels <= 0) {
|
|
31
|
+
const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
|
|
32
|
+
if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
|
|
33
|
+
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
34
|
+
}
|
|
35
|
+
channels = inferredChannels;
|
|
36
|
+
}
|
|
37
|
+
const payloadLength = sampleCount * bytesPerSample * channels;
|
|
38
|
+
const expectedLength = TCI_STREAM_HEADER_BYTES + payloadLength;
|
|
39
|
+
if (buffer.byteLength !== expectedLength) {
|
|
40
|
+
throw new TciError(
|
|
41
|
+
"invalid-frame",
|
|
42
|
+
`TCI stream frame length mismatch: header says ${sampleCount} samples (${payloadLength} payload bytes), got ${buffer.byteLength - TCI_STREAM_HEADER_BYTES}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (payloadLength % (bytesPerSample * channels) !== 0) {
|
|
46
|
+
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
receiver: header[0],
|
|
50
|
+
sampleRate: header[1],
|
|
51
|
+
sampleType,
|
|
52
|
+
codec: header[3],
|
|
53
|
+
crc: header[4],
|
|
54
|
+
payloadLength,
|
|
55
|
+
streamType: normalizeStreamType(header[6]),
|
|
56
|
+
channels,
|
|
57
|
+
reserved: header.slice(8),
|
|
58
|
+
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
59
|
+
sampleCount
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function buildStreamFrame(options) {
|
|
63
|
+
const sampleType = normalizeSampleType(options.sampleType);
|
|
64
|
+
const payload = options.payload ? toBuffer(options.payload) : samplesToPayload(options.samples ?? [], sampleType);
|
|
65
|
+
const channels = options.channels;
|
|
66
|
+
if (channels <= 0) {
|
|
67
|
+
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
68
|
+
}
|
|
69
|
+
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
70
|
+
if (payload.byteLength % (bytesPerSample * channels) !== 0) {
|
|
71
|
+
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
72
|
+
}
|
|
73
|
+
const sampleCount = payload.byteLength / bytesPerSample / channels;
|
|
74
|
+
const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
|
|
75
|
+
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
76
|
+
const reserved = options.reserved ?? [];
|
|
77
|
+
const header = [
|
|
78
|
+
options.receiver ?? 0,
|
|
79
|
+
options.sampleRate,
|
|
80
|
+
sampleType,
|
|
81
|
+
options.codec ?? 0,
|
|
82
|
+
options.crc ?? 0,
|
|
83
|
+
sampleCount,
|
|
84
|
+
options.streamType,
|
|
85
|
+
channels,
|
|
86
|
+
...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
|
|
87
|
+
];
|
|
88
|
+
header.forEach((value, index) => view.setUint32(index * 4, value >>> 0, true));
|
|
89
|
+
payload.copy(frame, TCI_STREAM_HEADER_BYTES);
|
|
90
|
+
return frame;
|
|
91
|
+
}
|
|
92
|
+
function sampleTypeBytes(sampleType) {
|
|
93
|
+
switch (normalizeSampleType(sampleType)) {
|
|
94
|
+
case 0 /* INT16 */:
|
|
95
|
+
return 2;
|
|
96
|
+
case 1 /* INT24 */:
|
|
97
|
+
return 3;
|
|
98
|
+
case 2 /* INT32 */:
|
|
99
|
+
case 3 /* FLOAT32 */:
|
|
100
|
+
return 4;
|
|
101
|
+
default:
|
|
102
|
+
throw new TciError("invalid-frame", `Unsupported TCI sample type: ${sampleType}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function normalizeSampleType(sampleType) {
|
|
106
|
+
if (typeof sampleType === "string") {
|
|
107
|
+
switch (sampleType.toLowerCase()) {
|
|
108
|
+
case "int16":
|
|
109
|
+
return 0 /* INT16 */;
|
|
110
|
+
case "int24":
|
|
111
|
+
return 1 /* INT24 */;
|
|
112
|
+
case "int32":
|
|
113
|
+
return 2 /* INT32 */;
|
|
114
|
+
case "float32":
|
|
115
|
+
return 3 /* FLOAT32 */;
|
|
116
|
+
default:
|
|
117
|
+
throw new TciError("invalid-frame", `Unsupported TCI sample type: ${sampleType}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (sampleType >= 0 /* INT16 */ && sampleType <= 3 /* FLOAT32 */) {
|
|
121
|
+
return sampleType;
|
|
122
|
+
}
|
|
123
|
+
throw new TciError("invalid-frame", `Unsupported TCI sample type: ${sampleType}`);
|
|
124
|
+
}
|
|
125
|
+
function normalizeStreamType(streamType) {
|
|
126
|
+
if (streamType >= 0 /* IQ_STREAM */ && streamType <= 4 /* LINEOUT_STREAM */) {
|
|
127
|
+
return streamType;
|
|
128
|
+
}
|
|
129
|
+
throw new TciError("invalid-frame", `Unsupported TCI stream type: ${streamType}`);
|
|
130
|
+
}
|
|
131
|
+
function samplesToPayload(samples, sampleType) {
|
|
132
|
+
const type = normalizeSampleType(sampleType);
|
|
133
|
+
const bytes = sampleTypeBytes(type);
|
|
134
|
+
const payload = Buffer.alloc(samples.length * bytes);
|
|
135
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
136
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
137
|
+
const value = clampSample(samples[i] ?? 0);
|
|
138
|
+
const offset = i * bytes;
|
|
139
|
+
switch (type) {
|
|
140
|
+
case 0 /* INT16 */:
|
|
141
|
+
view.setInt16(offset, Math.round(value * 32767), true);
|
|
142
|
+
break;
|
|
143
|
+
case 1 /* INT24 */:
|
|
144
|
+
writeInt24(view, offset, Math.round(value * 8388607));
|
|
145
|
+
break;
|
|
146
|
+
case 2 /* INT32 */:
|
|
147
|
+
view.setInt32(offset, Math.round(value * 2147483647), true);
|
|
148
|
+
break;
|
|
149
|
+
case 3 /* FLOAT32 */:
|
|
150
|
+
view.setFloat32(offset, value, true);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return payload;
|
|
155
|
+
}
|
|
156
|
+
function toBuffer(input) {
|
|
157
|
+
if (Buffer.isBuffer(input)) {
|
|
158
|
+
return input;
|
|
159
|
+
}
|
|
160
|
+
if (input instanceof ArrayBuffer) {
|
|
161
|
+
return Buffer.from(input);
|
|
162
|
+
}
|
|
163
|
+
if (ArrayBuffer.isView(input)) {
|
|
164
|
+
return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
165
|
+
}
|
|
166
|
+
return Buffer.from(input);
|
|
167
|
+
}
|
|
168
|
+
function clampSample(value) {
|
|
169
|
+
if (!Number.isFinite(value)) {
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
return Math.max(-1, Math.min(1, value));
|
|
173
|
+
}
|
|
174
|
+
function writeInt24(view, offset, value) {
|
|
175
|
+
const clamped = Math.max(-8388608, Math.min(8388607, value));
|
|
176
|
+
view.setUint8(offset, clamped & 255);
|
|
177
|
+
view.setUint8(offset + 1, clamped >> 8 & 255);
|
|
178
|
+
view.setUint8(offset + 2, clamped >> 16 & 255);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/protocol/text.ts
|
|
182
|
+
var ESCAPE_TO_CHAR = {
|
|
183
|
+
"^": ":",
|
|
184
|
+
"~": ",",
|
|
185
|
+
"*": ";"
|
|
186
|
+
};
|
|
187
|
+
var CHAR_TO_ESCAPE = {
|
|
188
|
+
":": "^",
|
|
189
|
+
",": "~",
|
|
190
|
+
";": "*"
|
|
191
|
+
};
|
|
192
|
+
function escapeTciText(value) {
|
|
193
|
+
return String(value).replace(/[:;,]/g, (char) => CHAR_TO_ESCAPE[char] ?? char);
|
|
194
|
+
}
|
|
195
|
+
function unescapeTciText(value) {
|
|
196
|
+
return value.replace(/[\^~*]/g, (char) => ESCAPE_TO_CHAR[char] ?? char);
|
|
197
|
+
}
|
|
198
|
+
function parseTciText(text) {
|
|
199
|
+
const source = normalizeTextInput(text);
|
|
200
|
+
const commands = [];
|
|
201
|
+
for (const fragment of source.split(";")) {
|
|
202
|
+
const raw = fragment.trim();
|
|
203
|
+
if (!raw) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const colonIndex = raw.indexOf(":");
|
|
207
|
+
const originalName = (colonIndex >= 0 ? raw.slice(0, colonIndex) : raw).trim();
|
|
208
|
+
if (!originalName) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const argsText = colonIndex >= 0 ? raw.slice(colonIndex + 1) : void 0;
|
|
212
|
+
commands.push({
|
|
213
|
+
name: originalName.toLowerCase(),
|
|
214
|
+
originalName,
|
|
215
|
+
args: argsText === void 0 ? [] : splitArgs(argsText),
|
|
216
|
+
raw
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return commands;
|
|
220
|
+
}
|
|
221
|
+
function formatTciCommand(name, args = []) {
|
|
222
|
+
const commandName = name.trim().toUpperCase();
|
|
223
|
+
if (!commandName) {
|
|
224
|
+
throw new Error("TCI command name cannot be empty");
|
|
225
|
+
}
|
|
226
|
+
if (args.length === 0) {
|
|
227
|
+
return `${commandName};`;
|
|
228
|
+
}
|
|
229
|
+
return `${commandName}:${args.map(escapeTciText).join(",")};`;
|
|
230
|
+
}
|
|
231
|
+
function normalizeTextInput(text) {
|
|
232
|
+
if (typeof text === "string") {
|
|
233
|
+
return text;
|
|
234
|
+
}
|
|
235
|
+
if (Buffer.isBuffer(text)) {
|
|
236
|
+
return text.toString("utf8");
|
|
237
|
+
}
|
|
238
|
+
if (text instanceof ArrayBuffer) {
|
|
239
|
+
return Buffer.from(text).toString("utf8");
|
|
240
|
+
}
|
|
241
|
+
return Buffer.from(text.buffer, text.byteOffset, text.byteLength).toString("utf8");
|
|
242
|
+
}
|
|
243
|
+
function splitArgs(argsText) {
|
|
244
|
+
return argsText.split(",").map((arg) => unescapeTciText(arg.trim()));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/testing/MockTciServer.ts
|
|
248
|
+
var MockTciServer = class {
|
|
249
|
+
receivedCommands = [];
|
|
250
|
+
receivedTxAudioFrames = [];
|
|
251
|
+
options;
|
|
252
|
+
wss;
|
|
253
|
+
sockets = /* @__PURE__ */ new Set();
|
|
254
|
+
handler;
|
|
255
|
+
frequency = 14074e3;
|
|
256
|
+
mode = "DIGU";
|
|
257
|
+
ptt = false;
|
|
258
|
+
constructor(options = {}) {
|
|
259
|
+
this.options = {
|
|
260
|
+
port: options.port ?? 0,
|
|
261
|
+
host: options.host ?? "127.0.0.1",
|
|
262
|
+
startupCommands: options.startupCommands,
|
|
263
|
+
echoUnknown: options.echoUnknown ?? true,
|
|
264
|
+
commandDelayMs: options.commandDelayMs ?? 0
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async start() {
|
|
268
|
+
if (this.wss) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.wss = new WebSocketServer({ port: this.options.port, host: this.options.host });
|
|
272
|
+
this.wss.on("connection", (socket) => this.handleConnection(socket));
|
|
273
|
+
await new Promise((resolve, reject) => {
|
|
274
|
+
this.wss?.once("listening", () => resolve());
|
|
275
|
+
this.wss?.once("error", reject);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async stop() {
|
|
279
|
+
const sockets = [...this.sockets];
|
|
280
|
+
await Promise.all(
|
|
281
|
+
sockets.map(
|
|
282
|
+
(socket) => new Promise((resolve) => {
|
|
283
|
+
socket.once("close", () => resolve());
|
|
284
|
+
socket.close();
|
|
285
|
+
setTimeout(() => resolve(), 200).unref?.();
|
|
286
|
+
})
|
|
287
|
+
)
|
|
288
|
+
);
|
|
289
|
+
if (!this.wss) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
await new Promise((resolve, reject) => {
|
|
293
|
+
this.wss?.close((error) => error ? reject(error) : resolve());
|
|
294
|
+
});
|
|
295
|
+
this.wss = void 0;
|
|
296
|
+
}
|
|
297
|
+
url() {
|
|
298
|
+
if (!this.wss) {
|
|
299
|
+
throw new Error("MockTciServer is not started");
|
|
300
|
+
}
|
|
301
|
+
const address = this.wss.address();
|
|
302
|
+
return `ws://${address.address}:${address.port}`;
|
|
303
|
+
}
|
|
304
|
+
onCommand(handler) {
|
|
305
|
+
this.handler = handler;
|
|
306
|
+
}
|
|
307
|
+
broadcast(command) {
|
|
308
|
+
for (const socket of this.sockets) {
|
|
309
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
310
|
+
socket.send(command.endsWith(";") ? command : `${command};`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
broadcastCommand(name, args = []) {
|
|
315
|
+
this.broadcast(formatTciCommand(name, args));
|
|
316
|
+
}
|
|
317
|
+
sendRxAudioFrame(options = {}) {
|
|
318
|
+
const frame = buildStreamFrame({
|
|
319
|
+
receiver: options.receiver ?? 0,
|
|
320
|
+
sampleRate: options.sampleRate ?? 12e3,
|
|
321
|
+
sampleType: options.sampleType ?? 3 /* FLOAT32 */,
|
|
322
|
+
streamType: 1 /* RX_AUDIO_STREAM */,
|
|
323
|
+
channels: options.channels ?? 1,
|
|
324
|
+
samples: options.samples ?? new Float32Array(512),
|
|
325
|
+
payload: options.payload
|
|
326
|
+
});
|
|
327
|
+
this.broadcastBinary(frame);
|
|
328
|
+
}
|
|
329
|
+
sendTxChrono(options = {}) {
|
|
330
|
+
const sampleType = options.sampleType ?? 3 /* FLOAT32 */;
|
|
331
|
+
const channels = options.channels ?? 1;
|
|
332
|
+
const sampleCount = options.sampleCount ?? 512;
|
|
333
|
+
const payload = options.payload ?? samplesToPayload(new Float32Array(sampleCount * channels), sampleType);
|
|
334
|
+
const frame = buildStreamFrame({
|
|
335
|
+
receiver: options.receiver ?? 0,
|
|
336
|
+
sampleRate: options.sampleRate ?? 12e3,
|
|
337
|
+
sampleType,
|
|
338
|
+
streamType: 3 /* TX_CHRONO */,
|
|
339
|
+
channels,
|
|
340
|
+
payload
|
|
341
|
+
});
|
|
342
|
+
this.broadcastBinary(frame);
|
|
343
|
+
}
|
|
344
|
+
closeClients() {
|
|
345
|
+
for (const socket of this.sockets) {
|
|
346
|
+
socket.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
handleConnection(socket) {
|
|
350
|
+
this.sockets.add(socket);
|
|
351
|
+
socket.on("close", () => this.sockets.delete(socket));
|
|
352
|
+
socket.on("message", (data, isBinary) => void this.handleMessage(socket, data, isBinary));
|
|
353
|
+
const startupCommands = this.options.startupCommands ?? [
|
|
354
|
+
"PROTOCOL:2.0;",
|
|
355
|
+
"DEVICE:Mock ExpertSDR3;",
|
|
356
|
+
"MODULATIONS_LIST:LSB,USB,CW,AM,NFM,DIGU,DIGL;",
|
|
357
|
+
`VFO:0,0,${this.frequency};`,
|
|
358
|
+
`MODULATION:0,${this.mode};`,
|
|
359
|
+
`TRX:0,${this.ptt};`,
|
|
360
|
+
"READY:true;"
|
|
361
|
+
];
|
|
362
|
+
queueMicrotask(() => {
|
|
363
|
+
for (const command of startupCommands) {
|
|
364
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
365
|
+
socket.send(command);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
async handleMessage(socket, data, isBinary) {
|
|
371
|
+
if (isBinary) {
|
|
372
|
+
const frame = parseStreamFrame(dataToBuffer(data));
|
|
373
|
+
if (frame.streamType === 2 /* TX_AUDIO_STREAM */) {
|
|
374
|
+
this.receivedTxAudioFrames.push(frame);
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
for (const command of parseTciText(dataToBuffer(data))) {
|
|
379
|
+
this.receivedCommands.push(command);
|
|
380
|
+
if (this.handler) {
|
|
381
|
+
const handled = await this.handler({ server: this, socket, command });
|
|
382
|
+
if (handled === true) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
await this.delay();
|
|
387
|
+
this.defaultReply(socket, command);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
defaultReply(socket, command) {
|
|
391
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
switch (command.name) {
|
|
395
|
+
case "vfo": {
|
|
396
|
+
const receiver = command.args[0] ?? "0";
|
|
397
|
+
const vfo = command.args[1] ?? "0";
|
|
398
|
+
if (command.args[2] !== void 0) {
|
|
399
|
+
this.frequency = Number(command.args[2]);
|
|
400
|
+
}
|
|
401
|
+
socket.send(formatTciCommand("VFO", [receiver, vfo, this.frequency]));
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case "modulation": {
|
|
405
|
+
const receiver = command.args[0] ?? "0";
|
|
406
|
+
if (command.args[1] !== void 0) {
|
|
407
|
+
this.mode = command.args[command.args.length - 1]?.toUpperCase() ?? this.mode;
|
|
408
|
+
}
|
|
409
|
+
socket.send(formatTciCommand("MODULATION", [receiver, this.mode]));
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case "trx": {
|
|
413
|
+
const trx = command.args[0] ?? "0";
|
|
414
|
+
if (command.args[1] !== void 0) {
|
|
415
|
+
this.ptt = command.args[1]?.toLowerCase() === "true";
|
|
416
|
+
}
|
|
417
|
+
socket.send(formatTciCommand("TRX", [trx, this.ptt]));
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "tune":
|
|
421
|
+
case "drive":
|
|
422
|
+
case "split_enable":
|
|
423
|
+
case "cw_macros":
|
|
424
|
+
case "cw_msg":
|
|
425
|
+
case "cw_macros_stop":
|
|
426
|
+
case "audio_samplerate":
|
|
427
|
+
case "tx_stream_audio_buffering":
|
|
428
|
+
socket.send(formatTciCommand(command.originalName, command.args));
|
|
429
|
+
break;
|
|
430
|
+
default:
|
|
431
|
+
if (this.options.echoUnknown) {
|
|
432
|
+
socket.send(formatTciCommand(command.originalName, command.args));
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
broadcastBinary(frame) {
|
|
438
|
+
for (const socket of this.sockets) {
|
|
439
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
440
|
+
socket.send(frame, { binary: true });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async delay() {
|
|
445
|
+
if (this.options.commandDelayMs <= 0) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, this.options.commandDelayMs));
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
function dataToBuffer(data) {
|
|
452
|
+
if (Buffer.isBuffer(data)) {
|
|
453
|
+
return data;
|
|
454
|
+
}
|
|
455
|
+
if (data instanceof ArrayBuffer) {
|
|
456
|
+
return Buffer.from(data);
|
|
457
|
+
}
|
|
458
|
+
if (Array.isArray(data)) {
|
|
459
|
+
return Buffer.concat(data.map((item) => dataToBuffer(item)));
|
|
460
|
+
}
|
|
461
|
+
throw new Error("Unsupported WebSocket data type");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/testing/FakeWebSocket.ts
|
|
465
|
+
import { EventEmitter } from "eventemitter3";
|
|
466
|
+
var FakeWebSocket = class _FakeWebSocket extends EventEmitter {
|
|
467
|
+
constructor(url) {
|
|
468
|
+
super();
|
|
469
|
+
this.url = url;
|
|
470
|
+
queueMicrotask(() => this.open());
|
|
471
|
+
}
|
|
472
|
+
url;
|
|
473
|
+
static CONNECTING = 0;
|
|
474
|
+
static OPEN = 1;
|
|
475
|
+
static CLOSING = 2;
|
|
476
|
+
static CLOSED = 3;
|
|
477
|
+
sentMessages = [];
|
|
478
|
+
readyState = _FakeWebSocket.CONNECTING;
|
|
479
|
+
send(data, optionsOrCallback, callback) {
|
|
480
|
+
const done = typeof optionsOrCallback === "function" ? optionsOrCallback : callback;
|
|
481
|
+
const isBinary = typeof optionsOrCallback === "object" ? Boolean(optionsOrCallback.binary) : typeof data !== "string";
|
|
482
|
+
if (this.readyState !== _FakeWebSocket.OPEN) {
|
|
483
|
+
done?.(new Error("FakeWebSocket is not open"));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.sentMessages.push({ data, isBinary });
|
|
487
|
+
this.emit("sent", data, isBinary);
|
|
488
|
+
done?.();
|
|
489
|
+
}
|
|
490
|
+
receive(data, isBinary = typeof data !== "string") {
|
|
491
|
+
if (this.readyState === _FakeWebSocket.OPEN) {
|
|
492
|
+
this.emit("message", typeof data === "string" ? Buffer.from(data) : data, isBinary);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
open() {
|
|
496
|
+
if (this.readyState !== _FakeWebSocket.CONNECTING) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this.readyState = _FakeWebSocket.OPEN;
|
|
500
|
+
this.emit("open");
|
|
501
|
+
}
|
|
502
|
+
close(code = 1e3, reason = "fake close") {
|
|
503
|
+
if (this.readyState === _FakeWebSocket.CLOSED) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
this.readyState = _FakeWebSocket.CLOSING;
|
|
507
|
+
queueMicrotask(() => {
|
|
508
|
+
this.readyState = _FakeWebSocket.CLOSED;
|
|
509
|
+
this.emit("close", code, Buffer.from(reason));
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
terminate() {
|
|
513
|
+
if (this.readyState === _FakeWebSocket.CLOSED) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.readyState = _FakeWebSocket.CLOSED;
|
|
517
|
+
this.emit("close", 1006, Buffer.from("terminated"));
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
function createFakeWebSocketImpl(observer) {
|
|
521
|
+
return class FakeWebSocketImpl extends FakeWebSocket {
|
|
522
|
+
constructor(url) {
|
|
523
|
+
super(url);
|
|
524
|
+
observer?.(this);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
export {
|
|
529
|
+
FakeWebSocket,
|
|
530
|
+
MockTciServer,
|
|
531
|
+
createFakeWebSocketImpl
|
|
532
|
+
};
|
|
533
|
+
//# sourceMappingURL=index.js.map
|