tci-client-node 0.1.2 → 0.3.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/CHANGELOG.md +17 -0
- package/README.md +57 -11
- package/dist/audio/index.cjs +52 -11
- package/dist/audio/index.cjs.map +1 -1
- package/dist/audio/index.d.cts +17 -3
- package/dist/audio/index.d.ts +17 -3
- package/dist/audio/index.js +51 -11
- package/dist/audio/index.js.map +1 -1
- package/dist/dialect/index.cjs +349 -0
- package/dist/dialect/index.cjs.map +1 -0
- package/dist/dialect/index.d.cts +29 -0
- package/dist/dialect/index.d.ts +29 -0
- package/dist/dialect/index.js +310 -0
- package/dist/dialect/index.js.map +1 -0
- package/dist/{index-CK3XdXP3.d.cts → index-lbK6NGY4.d.cts} +1 -1
- package/dist/{index-Dfmrk2MR.d.ts → index-paj1AOJY.d.ts} +1 -1
- package/dist/index.cjs +1057 -217
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +106 -12
- package/dist/index.d.ts +106 -12
- package/dist/index.js +1041 -217
- package/dist/index.js.map +1 -1
- package/dist/protocol/index.cjs.map +1 -1
- package/dist/protocol/index.d.cts +1 -1
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/index.js.map +1 -1
- package/dist/testing/index.cjs +79 -14
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +6 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +79 -14
- package/dist/testing/index.js.map +1 -1
- package/dist/transport/index.cjs +175 -0
- package/dist/transport/index.cjs.map +1 -0
- package/dist/transport/index.d.cts +37 -0
- package/dist/transport/index.d.ts +37 -0
- package/dist/transport/index.js +138 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/types-1YXGwrgI.d.cts +65 -0
- package/dist/types-BtPh4Dbd.d.ts +65 -0
- package/package.json +15 -1
package/dist/index.js
CHANGED
|
@@ -20,8 +20,8 @@ function toTciError(error, fallbackCode = "protocol-error") {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// src/client/TciClient.ts
|
|
23
|
-
import { EventEmitter } from "eventemitter3";
|
|
24
|
-
import
|
|
23
|
+
import { EventEmitter as EventEmitter2 } from "eventemitter3";
|
|
24
|
+
import WebSocket2 from "ws";
|
|
25
25
|
|
|
26
26
|
// src/audio/streamFrame.ts
|
|
27
27
|
var TCI_STREAM_HEADER_BYTES = 16 * 4;
|
|
@@ -40,7 +40,7 @@ var TciSampleType = /* @__PURE__ */ ((TciSampleType2) => {
|
|
|
40
40
|
TciSampleType2[TciSampleType2["FLOAT32"] = 3] = "FLOAT32";
|
|
41
41
|
return TciSampleType2;
|
|
42
42
|
})(TciSampleType || {});
|
|
43
|
-
function parseStreamFrame(input) {
|
|
43
|
+
function parseStreamFrame(input, options = {}) {
|
|
44
44
|
const buffer = toBuffer(input);
|
|
45
45
|
if (buffer.byteLength < TCI_STREAM_HEADER_BYTES) {
|
|
46
46
|
throw new TciError("invalid-frame", `TCI stream frame is shorter than ${TCI_STREAM_HEADER_BYTES} bytes`);
|
|
@@ -49,15 +49,15 @@ function parseStreamFrame(input) {
|
|
|
49
49
|
const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
|
|
50
50
|
const sampleType = normalizeSampleType(header[2]);
|
|
51
51
|
const streamType = normalizeStreamType(header[6]);
|
|
52
|
-
let channels = header[7];
|
|
52
|
+
let channels = header[7] || options.negotiatedChannels || 0;
|
|
53
53
|
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
54
|
-
const
|
|
54
|
+
const headerSampleCount = header[5];
|
|
55
55
|
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
56
56
|
if (channels <= 0) {
|
|
57
57
|
if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
|
|
58
58
|
channels = 1;
|
|
59
59
|
} else {
|
|
60
|
-
const inferredChannels =
|
|
60
|
+
const inferredChannels = headerSampleCount > 0 ? actualPayloadLength / headerSampleCount / bytesPerSample : 1;
|
|
61
61
|
if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
|
|
62
62
|
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
63
63
|
}
|
|
@@ -69,16 +69,28 @@ function parseStreamFrame(input) {
|
|
|
69
69
|
if (payloadLength % alignedFrameBytes !== 0) {
|
|
70
70
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
71
71
|
}
|
|
72
|
+
const actualScalarCount = payloadLength / bytesPerSample;
|
|
73
|
+
const requestedSemantics = options.lengthSemantics ?? "auto";
|
|
74
|
+
const lengthSemantics = resolveLengthSemantics(
|
|
75
|
+
requestedSemantics,
|
|
76
|
+
streamType,
|
|
77
|
+
headerSampleCount,
|
|
78
|
+
actualScalarCount,
|
|
79
|
+
channels
|
|
80
|
+
);
|
|
81
|
+
const sampleCount = lengthSemantics === "per-channel" ? headerSampleCount * channels : headerSampleCount;
|
|
72
82
|
if (streamType !== 3 /* TX_CHRONO */) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
if (payloadLength !== expectedPerChannelPayloadLength && payloadLength !== expectedScalarPayloadLength) {
|
|
83
|
+
const expectedPayloadLength = sampleCount * bytesPerSample;
|
|
84
|
+
if (payloadLength !== expectedPayloadLength) {
|
|
76
85
|
throw new TciError(
|
|
77
86
|
"invalid-frame",
|
|
78
|
-
`TCI stream frame length mismatch: header says ${
|
|
87
|
+
`TCI stream frame length mismatch: header says ${headerSampleCount} samples using ${lengthSemantics} semantics (${expectedPayloadLength} payload bytes), got ${payloadLength}`
|
|
79
88
|
);
|
|
80
89
|
}
|
|
81
90
|
}
|
|
91
|
+
if (sampleCount % channels !== 0) {
|
|
92
|
+
throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
|
|
93
|
+
}
|
|
82
94
|
return {
|
|
83
95
|
receiver: header[0],
|
|
84
96
|
sampleRate: header[1],
|
|
@@ -90,7 +102,10 @@ function parseStreamFrame(input) {
|
|
|
90
102
|
channels,
|
|
91
103
|
reserved: header.slice(8),
|
|
92
104
|
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
93
|
-
|
|
105
|
+
headerSampleCount,
|
|
106
|
+
sampleCount,
|
|
107
|
+
frameCount: sampleCount / channels,
|
|
108
|
+
lengthSemantics
|
|
94
109
|
};
|
|
95
110
|
}
|
|
96
111
|
function buildStreamFrame(options) {
|
|
@@ -104,11 +119,19 @@ function buildStreamFrame(options) {
|
|
|
104
119
|
if (payload.byteLength % (bytesPerSample * channels) !== 0) {
|
|
105
120
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
106
121
|
}
|
|
107
|
-
const derivedSampleCount = payload.byteLength / bytesPerSample
|
|
122
|
+
const derivedSampleCount = payload.byteLength / bytesPerSample;
|
|
108
123
|
const sampleCount = options.sampleCount ?? derivedSampleCount;
|
|
109
124
|
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
|
110
125
|
throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
|
|
111
126
|
}
|
|
127
|
+
if (payload.byteLength > 0 && sampleCount !== derivedSampleCount) {
|
|
128
|
+
throw new TciError("invalid-frame", `Explicit scalar sample count ${sampleCount} does not match payload count ${derivedSampleCount}`);
|
|
129
|
+
}
|
|
130
|
+
if (sampleCount % channels !== 0) {
|
|
131
|
+
throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
|
|
132
|
+
}
|
|
133
|
+
const lengthSemantics = options.lengthSemantics === "per-channel" ? "per-channel" : "scalar";
|
|
134
|
+
const headerSampleCount = lengthSemantics === "per-channel" ? sampleCount / channels : sampleCount;
|
|
112
135
|
const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
|
|
113
136
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
114
137
|
const reserved = options.reserved ?? [];
|
|
@@ -118,7 +141,7 @@ function buildStreamFrame(options) {
|
|
|
118
141
|
sampleType,
|
|
119
142
|
options.codec ?? 0,
|
|
120
143
|
options.crc ?? 0,
|
|
121
|
-
|
|
144
|
+
headerSampleCount,
|
|
122
145
|
options.streamType,
|
|
123
146
|
channels,
|
|
124
147
|
...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
|
|
@@ -127,6 +150,13 @@ function buildStreamFrame(options) {
|
|
|
127
150
|
payload.copy(frame, TCI_STREAM_HEADER_BYTES);
|
|
128
151
|
return frame;
|
|
129
152
|
}
|
|
153
|
+
function resolveLengthSemantics(requested, streamType, headerSampleCount, actualScalarCount, channels) {
|
|
154
|
+
if (requested !== "auto") return requested;
|
|
155
|
+
if (streamType === 3 /* TX_CHRONO */ && actualScalarCount === 0) return "scalar";
|
|
156
|
+
if (actualScalarCount === headerSampleCount) return "scalar";
|
|
157
|
+
if (actualScalarCount === headerSampleCount * channels) return "per-channel";
|
|
158
|
+
return "scalar";
|
|
159
|
+
}
|
|
130
160
|
function buildTxAudioFrame(options) {
|
|
131
161
|
return buildStreamFrame({ ...options, streamType: 2 /* TX_AUDIO_STREAM */ });
|
|
132
162
|
}
|
|
@@ -211,6 +241,15 @@ function payloadToFloat32(frameOrPayload, sampleType) {
|
|
|
211
241
|
}
|
|
212
242
|
return output;
|
|
213
243
|
}
|
|
244
|
+
function decodeInterleavedIq(frame) {
|
|
245
|
+
if (frame.streamType !== 0 /* IQ_STREAM */) {
|
|
246
|
+
throw new TciError("invalid-frame", "Expected a TCI IQ stream frame");
|
|
247
|
+
}
|
|
248
|
+
if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
|
|
249
|
+
throw new TciError("invalid-frame", `TCI IQ frame must contain interleaved I/Q pairs, got ${frame.channels} channels`);
|
|
250
|
+
}
|
|
251
|
+
return payloadToFloat32(frame);
|
|
252
|
+
}
|
|
214
253
|
function samplesToPayload(samples, sampleType) {
|
|
215
254
|
const type = normalizeSampleType(sampleType);
|
|
216
255
|
const bytes = sampleTypeBytes(type);
|
|
@@ -558,13 +597,488 @@ function ensureSemicolon(command) {
|
|
|
558
597
|
return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
|
|
559
598
|
}
|
|
560
599
|
|
|
600
|
+
// src/dialect/builtins.ts
|
|
601
|
+
var StandardTciDialect = class {
|
|
602
|
+
id;
|
|
603
|
+
label;
|
|
604
|
+
streamLengthSemantics;
|
|
605
|
+
supportsStreamChannels;
|
|
606
|
+
supportsTxAudioSource;
|
|
607
|
+
supportsIqStream;
|
|
608
|
+
iqSampleRates;
|
|
609
|
+
driveHasTrx;
|
|
610
|
+
detector;
|
|
611
|
+
resolver;
|
|
612
|
+
constructor(options) {
|
|
613
|
+
this.id = options.id;
|
|
614
|
+
this.label = options.label;
|
|
615
|
+
this.streamLengthSemantics = options.streamLengthSemantics;
|
|
616
|
+
this.supportsStreamChannels = options.supportsStreamChannels;
|
|
617
|
+
this.supportsTxAudioSource = options.supportsTxAudioSource;
|
|
618
|
+
this.supportsIqStream = options.supportsIqStream;
|
|
619
|
+
this.iqSampleRates = [...options.iqSampleRates];
|
|
620
|
+
this.driveHasTrx = options.driveHasTrx;
|
|
621
|
+
this.detector = options.detect;
|
|
622
|
+
this.resolver = options.resolve;
|
|
623
|
+
}
|
|
624
|
+
detect(context) {
|
|
625
|
+
return this.detector(context);
|
|
626
|
+
}
|
|
627
|
+
resolve(context) {
|
|
628
|
+
return this.resolver?.(context) ?? this;
|
|
629
|
+
}
|
|
630
|
+
buildDriveSetArgs(trx, value) {
|
|
631
|
+
return this.driveHasTrx ? [trx, value] : [value];
|
|
632
|
+
}
|
|
633
|
+
buildDriveReadArgs(trx) {
|
|
634
|
+
return this.driveHasTrx ? [trx] : [];
|
|
635
|
+
}
|
|
636
|
+
parseDrive(args, defaultTrx) {
|
|
637
|
+
return parseDriveState(args, defaultTrx, this.driveHasTrx);
|
|
638
|
+
}
|
|
639
|
+
buildTuneDriveSetArgs(trx, value) {
|
|
640
|
+
return this.driveHasTrx ? [trx, value] : [value];
|
|
641
|
+
}
|
|
642
|
+
buildTuneDriveReadArgs(trx) {
|
|
643
|
+
return this.driveHasTrx ? [trx] : [];
|
|
644
|
+
}
|
|
645
|
+
parseTuneDrive(args, defaultTrx) {
|
|
646
|
+
return parseDriveState(args, defaultTrx, this.driveHasTrx);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
function parseDriveState(args, defaultTrx, hasTrx) {
|
|
650
|
+
const trx = hasTrx ? Number(args[0]) : defaultTrx;
|
|
651
|
+
const value = Number(args[hasTrx ? 1 : 0]);
|
|
652
|
+
if (!Number.isInteger(trx) || !Number.isFinite(value)) return void 0;
|
|
653
|
+
return { trx, value };
|
|
654
|
+
}
|
|
655
|
+
function version(context) {
|
|
656
|
+
return parseTciVersion(context.identity.protocolVersion);
|
|
657
|
+
}
|
|
658
|
+
function programIncludes(context, value) {
|
|
659
|
+
return context.identity.programName?.toLowerCase().includes(value) ?? false;
|
|
660
|
+
}
|
|
661
|
+
var expertSdr14Dialect = new StandardTciDialect({
|
|
662
|
+
id: "expertsdr-1.4",
|
|
663
|
+
label: "ExpertSDR / TCI 1.4",
|
|
664
|
+
streamLengthSemantics: "per-channel",
|
|
665
|
+
supportsStreamChannels: false,
|
|
666
|
+
supportsTxAudioSource: false,
|
|
667
|
+
supportsIqStream: true,
|
|
668
|
+
iqSampleRates: [48e3, 96e3, 192e3, 384e3],
|
|
669
|
+
driveHasTrx: false,
|
|
670
|
+
detect: (context) => {
|
|
671
|
+
const parsed = version(context);
|
|
672
|
+
if (!parsed || compareTciVersion(parsed, [1, 4]) > 0) return { score: 0, evidence: [] };
|
|
673
|
+
return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} <= 1.4`] };
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
var expertSdrLegacyDialect = new StandardTciDialect({
|
|
677
|
+
id: "expertsdr-1.5-1.8",
|
|
678
|
+
label: "ExpertSDR / TCI 1.5-1.8",
|
|
679
|
+
streamLengthSemantics: "per-channel",
|
|
680
|
+
supportsStreamChannels: false,
|
|
681
|
+
supportsTxAudioSource: false,
|
|
682
|
+
supportsIqStream: true,
|
|
683
|
+
iqSampleRates: [48e3, 96e3, 192e3, 384e3],
|
|
684
|
+
driveHasTrx: true,
|
|
685
|
+
detect: (context) => {
|
|
686
|
+
const parsed = version(context);
|
|
687
|
+
if (!parsed || compareTciVersion(parsed, [1, 5]) < 0 || compareTciVersion(parsed, [1, 9]) >= 0) return { score: 0, evidence: [] };
|
|
688
|
+
return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} is in 1.5-1.8`] };
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
var expertSdrModernDialect = new StandardTciDialect({
|
|
692
|
+
id: "expertsdr-1.9-2.0",
|
|
693
|
+
label: "ExpertSDR / TCI 1.9-2.0",
|
|
694
|
+
streamLengthSemantics: "scalar",
|
|
695
|
+
supportsStreamChannels: true,
|
|
696
|
+
supportsTxAudioSource: true,
|
|
697
|
+
supportsIqStream: true,
|
|
698
|
+
iqSampleRates: [48e3, 96e3, 192e3, 384e3],
|
|
699
|
+
driveHasTrx: true,
|
|
700
|
+
detect: (context) => {
|
|
701
|
+
const parsed = version(context);
|
|
702
|
+
if (!parsed || compareTciVersion(parsed, [1, 9]) < 0) return { score: 0, evidence: [] };
|
|
703
|
+
const future = compareTciVersion(parsed, [2, 0]) > 0;
|
|
704
|
+
return {
|
|
705
|
+
score: 70 + (programIncludes(context, "expert") ? 15 : 0),
|
|
706
|
+
evidence: [`protocol ${formatVersion(parsed)} uses modern stream negotiation`],
|
|
707
|
+
warnings: future ? [`Unknown future TCI version ${formatVersion(parsed)}; using the modern dialect`] : []
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
var thetisDialect = new StandardTciDialect({
|
|
712
|
+
id: "thetis-2.0",
|
|
713
|
+
label: "Thetis / TCI 2.0",
|
|
714
|
+
streamLengthSemantics: "scalar",
|
|
715
|
+
supportsStreamChannels: true,
|
|
716
|
+
supportsTxAudioSource: true,
|
|
717
|
+
supportsIqStream: true,
|
|
718
|
+
iqSampleRates: [48e3, 96e3, 192e3, 384e3],
|
|
719
|
+
driveHasTrx: true,
|
|
720
|
+
detect: (context) => {
|
|
721
|
+
const evidence = [];
|
|
722
|
+
let score = 0;
|
|
723
|
+
if (programIncludes(context, "thetis")) {
|
|
724
|
+
score += 120;
|
|
725
|
+
evidence.push("PROTOCOL program is Thetis");
|
|
726
|
+
}
|
|
727
|
+
const observed = ["tx_frequency_ex", "tx_profiles_ex", "tx_profile_ex", "calibration_ex"].filter((name) => context.commandNames.has(name));
|
|
728
|
+
if (observed.length > 0) {
|
|
729
|
+
score += 100;
|
|
730
|
+
evidence.push(`Thetis extension commands: ${observed.join(", ")}`);
|
|
731
|
+
}
|
|
732
|
+
if (/anan|hermes|orion|saturn/i.test(context.identity.device ?? "")) {
|
|
733
|
+
score += 30;
|
|
734
|
+
evidence.push(`Thetis-family device: ${context.identity.device}`);
|
|
735
|
+
}
|
|
736
|
+
return { score, evidence };
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
var aetherSdrDialect = new StandardTciDialect({
|
|
740
|
+
id: "aethersdr-1.5",
|
|
741
|
+
label: "AetherSDR / TCI 1.5 hybrid",
|
|
742
|
+
streamLengthSemantics: "scalar",
|
|
743
|
+
supportsStreamChannels: true,
|
|
744
|
+
supportsTxAudioSource: true,
|
|
745
|
+
supportsIqStream: true,
|
|
746
|
+
iqSampleRates: [24e3, 48e3, 96e3, 192e3],
|
|
747
|
+
driveHasTrx: true,
|
|
748
|
+
detect: (context) => {
|
|
749
|
+
if (!/^aethersdr$/i.test(context.identity.device ?? "")) return { score: 0, evidence: [] };
|
|
750
|
+
const evidence = [`AetherSDR device identity: ${context.identity.device}`];
|
|
751
|
+
const modernAudioCommands = ["audio_stream_sample_type", "audio_stream_channels", "audio_stream_samples"].filter((name) => context.commandNames.has(name));
|
|
752
|
+
if (modernAudioCommands.length > 0) evidence.push(`Modern audio negotiation: ${modernAudioCommands.join(", ")}`);
|
|
753
|
+
return {
|
|
754
|
+
score: 150,
|
|
755
|
+
evidence,
|
|
756
|
+
warnings: context.identity.protocolVersion === "1.5" ? ["AetherSDR reports TCI 1.5 but uses modern scalar audio stream semantics"] : []
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
var genericObservedDialect = new StandardTciDialect({
|
|
761
|
+
id: "generic-observed",
|
|
762
|
+
label: "Generic observed TCI",
|
|
763
|
+
streamLengthSemantics: "auto",
|
|
764
|
+
supportsStreamChannels: true,
|
|
765
|
+
supportsTxAudioSource: true,
|
|
766
|
+
supportsIqStream: false,
|
|
767
|
+
iqSampleRates: [],
|
|
768
|
+
driveHasTrx: true,
|
|
769
|
+
detect: (context) => ({
|
|
770
|
+
score: context.commandNames.has("ready") ? 10 : 0,
|
|
771
|
+
evidence: ["No vendor-specific match; using observed command shapes"],
|
|
772
|
+
warnings: ["Dialect identity is uncertain"]
|
|
773
|
+
}),
|
|
774
|
+
resolve: (context) => {
|
|
775
|
+
const drive = [...context.commands].reverse().find((command) => command.name === "drive");
|
|
776
|
+
const driveHasTrx = (drive?.args.length ?? 0) >= 2;
|
|
777
|
+
return new StandardTciDialect({
|
|
778
|
+
id: "generic-observed",
|
|
779
|
+
label: "Generic observed TCI",
|
|
780
|
+
streamLengthSemantics: "auto",
|
|
781
|
+
supportsStreamChannels: context.commandNames.has("audio_stream_channels"),
|
|
782
|
+
supportsTxAudioSource: compareTciVersion(version(context) ?? [0], [2, 0]) >= 0,
|
|
783
|
+
supportsIqStream: context.commandNames.has("iq_samplerate"),
|
|
784
|
+
iqSampleRates: context.commandNames.has("iq_samplerate") ? [48e3] : [],
|
|
785
|
+
driveHasTrx,
|
|
786
|
+
detect: genericObservedDialect.detect.bind(genericObservedDialect)
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
var builtInDialects = [
|
|
791
|
+
aetherSdrDialect,
|
|
792
|
+
thetisDialect,
|
|
793
|
+
expertSdr14Dialect,
|
|
794
|
+
expertSdrLegacyDialect,
|
|
795
|
+
expertSdrModernDialect,
|
|
796
|
+
genericObservedDialect
|
|
797
|
+
];
|
|
798
|
+
function parseTciVersion(value) {
|
|
799
|
+
if (!value) return void 0;
|
|
800
|
+
const match = value.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
801
|
+
if (!match) return void 0;
|
|
802
|
+
return match.slice(1).filter((part) => part !== void 0).map(Number);
|
|
803
|
+
}
|
|
804
|
+
function compareTciVersion(left, right) {
|
|
805
|
+
const length = Math.max(left.length, right.length);
|
|
806
|
+
for (let index = 0; index < length; index += 1) {
|
|
807
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
808
|
+
if (difference !== 0) return difference;
|
|
809
|
+
}
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
function formatVersion(value) {
|
|
813
|
+
return value.join(".");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/dialect/registry.ts
|
|
817
|
+
var TciDialectRegistry = class {
|
|
818
|
+
dialects = /* @__PURE__ */ new Map();
|
|
819
|
+
constructor(dialects = builtInDialects) {
|
|
820
|
+
for (const dialect of dialects) this.register(dialect);
|
|
821
|
+
}
|
|
822
|
+
register(dialect) {
|
|
823
|
+
this.dialects.set(dialect.id, dialect);
|
|
824
|
+
}
|
|
825
|
+
get(id) {
|
|
826
|
+
return this.dialects.get(id);
|
|
827
|
+
}
|
|
828
|
+
list() {
|
|
829
|
+
return [...this.dialects.values()];
|
|
830
|
+
}
|
|
831
|
+
select(context, selection = "auto") {
|
|
832
|
+
if (typeof selection === "object") {
|
|
833
|
+
return { dialect: selection, confidence: "manual", evidence: ["Custom dialect supplied by caller"], warnings: [] };
|
|
834
|
+
}
|
|
835
|
+
if (selection !== "auto") {
|
|
836
|
+
const dialect = this.get(selection);
|
|
837
|
+
if (!dialect) throw new TciError("unknown-dialect", `Unknown TCI dialect: ${selection}`);
|
|
838
|
+
return { dialect: dialect.resolve?.(context) ?? dialect, confidence: "manual", evidence: [`Dialect ${selection} selected by caller`], warnings: [] };
|
|
839
|
+
}
|
|
840
|
+
const candidates = this.list().map((dialect) => ({ dialect, result: dialect.detect(context) })).sort((left, right) => right.result.score - left.result.score);
|
|
841
|
+
const selected = candidates[0];
|
|
842
|
+
if (!selected || selected.result.score <= 0) {
|
|
843
|
+
throw new TciError("unknown-dialect", "Unable to identify the TCI server dialect");
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
dialect: selected.dialect.resolve?.(context) ?? selected.dialect,
|
|
847
|
+
confidence: selected.result.score >= 100 ? "high" : selected.result.score >= 70 ? "medium" : "low",
|
|
848
|
+
evidence: selected.result.evidence,
|
|
849
|
+
warnings: selected.result.warnings ?? []
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
var defaultTciDialectRegistry = new TciDialectRegistry();
|
|
854
|
+
|
|
855
|
+
// src/dialect/handshake.ts
|
|
856
|
+
var IDENTITY_COMMANDS = /* @__PURE__ */ new Set(["protocol", "device", "trx_count", "channels_count", "channel_count"]);
|
|
857
|
+
var STATE_COMMANDS = /* @__PURE__ */ new Set(["vfo", "modulation", "modulations_list", "trx", "drive"]);
|
|
858
|
+
function parseProtocolIdentity(commands) {
|
|
859
|
+
const protocol = [...commands].reverse().find((command) => command.name === "protocol");
|
|
860
|
+
const device = [...commands].reverse().find((command) => command.name === "device");
|
|
861
|
+
const rawProtocolArgs = protocol?.args ?? [];
|
|
862
|
+
const firstLooksLikeVersion = /^\d+(?:\.\d+){0,2}/.test(rawProtocolArgs[0] ?? "");
|
|
863
|
+
return {
|
|
864
|
+
programName: firstLooksLikeVersion ? void 0 : rawProtocolArgs[0],
|
|
865
|
+
protocolVersion: firstLooksLikeVersion ? rawProtocolArgs[0] : rawProtocolArgs[1],
|
|
866
|
+
rawProtocolArgs: [...rawProtocolArgs],
|
|
867
|
+
device: device?.args.join(",")
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function assertValidTciHandshake(commands) {
|
|
871
|
+
const names = new Set(commands.map((command) => command.name));
|
|
872
|
+
if (!names.has("ready")) throw new TciError("handshake-timeout", "TCI READY was not received");
|
|
873
|
+
const categories = [
|
|
874
|
+
[...IDENTITY_COMMANDS].some((name) => names.has(name)),
|
|
875
|
+
[...STATE_COMMANDS].some((name) => names.has(name))
|
|
876
|
+
].filter(Boolean).length;
|
|
877
|
+
const identitySignals = [...IDENTITY_COMMANDS].filter((name) => names.has(name)).length;
|
|
878
|
+
if (categories < 2 && identitySignals < 2) {
|
|
879
|
+
throw new TciError("invalid-handshake", "WebSocket opened but did not provide enough TCI initialization evidence");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/transport/WebSocketTransport.ts
|
|
884
|
+
import { EventEmitter } from "eventemitter3";
|
|
885
|
+
import WebSocket from "ws";
|
|
886
|
+
var WebSocketTciTransport = class extends EventEmitter {
|
|
887
|
+
constructor(url, WebSocketImpl = WebSocket) {
|
|
888
|
+
super();
|
|
889
|
+
this.url = url;
|
|
890
|
+
this.WebSocketImpl = WebSocketImpl;
|
|
891
|
+
}
|
|
892
|
+
url;
|
|
893
|
+
WebSocketImpl;
|
|
894
|
+
socket;
|
|
895
|
+
async connect(timeoutMs) {
|
|
896
|
+
if (this.socket?.readyState === WebSocket.OPEN) return;
|
|
897
|
+
const socket = new this.WebSocketImpl(this.url);
|
|
898
|
+
this.socket = socket;
|
|
899
|
+
await new Promise((resolve, reject) => {
|
|
900
|
+
const timer = setTimeout(() => {
|
|
901
|
+
cleanup();
|
|
902
|
+
this.terminate();
|
|
903
|
+
reject(new TciError("connect-timeout", `Timed out connecting to ${this.url}`));
|
|
904
|
+
}, timeoutMs);
|
|
905
|
+
const cleanup = () => {
|
|
906
|
+
clearTimeout(timer);
|
|
907
|
+
socket.off("open", onOpen);
|
|
908
|
+
socket.off("close", onCloseBeforeOpen);
|
|
909
|
+
socket.off("error", onErrorBeforeOpen);
|
|
910
|
+
};
|
|
911
|
+
const onOpen = () => {
|
|
912
|
+
cleanup();
|
|
913
|
+
this.attach(socket);
|
|
914
|
+
this.emit("connected");
|
|
915
|
+
resolve();
|
|
916
|
+
};
|
|
917
|
+
const onCloseBeforeOpen = () => {
|
|
918
|
+
cleanup();
|
|
919
|
+
reject(new TciError("disconnected", `Disconnected while connecting to ${this.url}`));
|
|
920
|
+
};
|
|
921
|
+
const onErrorBeforeOpen = (error) => {
|
|
922
|
+
cleanup();
|
|
923
|
+
reject(toTciError(error, "disconnected"));
|
|
924
|
+
};
|
|
925
|
+
socket.once("open", onOpen);
|
|
926
|
+
socket.once("close", onCloseBeforeOpen);
|
|
927
|
+
socket.once("error", onErrorBeforeOpen);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async disconnect(code = 1e3, reason = "client disconnect") {
|
|
931
|
+
const socket = this.socket;
|
|
932
|
+
if (!socket || socket.readyState === WebSocket.CLOSED) return;
|
|
933
|
+
await new Promise((resolve) => {
|
|
934
|
+
const timer = setTimeout(resolve, 1e3);
|
|
935
|
+
timer.unref?.();
|
|
936
|
+
socket.once("close", () => {
|
|
937
|
+
clearTimeout(timer);
|
|
938
|
+
resolve();
|
|
939
|
+
});
|
|
940
|
+
socket.once("error", () => {
|
|
941
|
+
clearTimeout(timer);
|
|
942
|
+
resolve();
|
|
943
|
+
});
|
|
944
|
+
socket.close(code, reason);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
isConnected() {
|
|
948
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
949
|
+
}
|
|
950
|
+
async sendText(raw) {
|
|
951
|
+
await this.send(raw);
|
|
952
|
+
}
|
|
953
|
+
async sendBinary(raw) {
|
|
954
|
+
await this.send(raw, true);
|
|
955
|
+
}
|
|
956
|
+
terminate() {
|
|
957
|
+
try {
|
|
958
|
+
this.socket?.terminate();
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
attach(socket) {
|
|
963
|
+
socket.on("message", (data, isBinary) => {
|
|
964
|
+
try {
|
|
965
|
+
const buffer = dataToBuffer(data);
|
|
966
|
+
if (isBinary) this.emit("binary", buffer);
|
|
967
|
+
else this.emit("text", buffer.toString("utf8"));
|
|
968
|
+
} catch (error) {
|
|
969
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
socket.on("close", (code, reason) => {
|
|
973
|
+
if (this.socket === socket) this.socket = void 0;
|
|
974
|
+
this.emit("disconnected", { code, reason: reason.toString("utf8") });
|
|
975
|
+
});
|
|
976
|
+
socket.on("error", (error) => this.emit("error", error));
|
|
977
|
+
}
|
|
978
|
+
async send(data, binary = false) {
|
|
979
|
+
const socket = this.socket;
|
|
980
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
981
|
+
throw new TciError("not-connected", "TCI socket is not connected");
|
|
982
|
+
}
|
|
983
|
+
await new Promise((resolve, reject) => {
|
|
984
|
+
socket.send(data, { binary }, (error) => error ? reject(error) : resolve());
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
function dataToBuffer(data) {
|
|
989
|
+
if (Buffer.isBuffer(data)) return data;
|
|
990
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
991
|
+
if (Array.isArray(data)) return Buffer.concat(data.map((item) => dataToBuffer(item)));
|
|
992
|
+
throw new TciError("protocol-error", "Unsupported WebSocket data type");
|
|
993
|
+
}
|
|
994
|
+
|
|
561
995
|
// src/client/TciClient.ts
|
|
562
|
-
var
|
|
996
|
+
var TciIqStreamSession = class extends EventEmitter2 {
|
|
997
|
+
receiver;
|
|
998
|
+
_appliedSampleRate;
|
|
999
|
+
callbacks;
|
|
1000
|
+
closed = false;
|
|
1001
|
+
firstFrame;
|
|
1002
|
+
constructor(receiver, appliedSampleRate, callbacks) {
|
|
1003
|
+
super();
|
|
1004
|
+
this.receiver = receiver;
|
|
1005
|
+
this._appliedSampleRate = appliedSampleRate;
|
|
1006
|
+
this.callbacks = callbacks;
|
|
1007
|
+
}
|
|
1008
|
+
get appliedSampleRate() {
|
|
1009
|
+
return this._appliedSampleRate;
|
|
1010
|
+
}
|
|
1011
|
+
async setSampleRate(sampleRate) {
|
|
1012
|
+
if (this.closed) throw new TciError("cancelled", "TCI IQ stream is closed");
|
|
1013
|
+
const result = await this.callbacks.setSampleRate(sampleRate);
|
|
1014
|
+
this._appliedSampleRate = result.applied;
|
|
1015
|
+
return result;
|
|
1016
|
+
}
|
|
1017
|
+
async close() {
|
|
1018
|
+
if (this.closed) return;
|
|
1019
|
+
this.closed = true;
|
|
1020
|
+
this.rejectFirstFrame(new TciError("cancelled", "TCI IQ stream closed before its first frame"));
|
|
1021
|
+
try {
|
|
1022
|
+
await this.callbacks.close();
|
|
1023
|
+
} finally {
|
|
1024
|
+
this.emit("closed");
|
|
1025
|
+
this.removeAllListeners();
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
waitForFirstFrame(timeoutMs) {
|
|
1029
|
+
if (this.firstFrame) return this.firstFrame.promise;
|
|
1030
|
+
let resolvePromise;
|
|
1031
|
+
let rejectPromise;
|
|
1032
|
+
const promise = new Promise((resolve, reject) => {
|
|
1033
|
+
resolvePromise = resolve;
|
|
1034
|
+
rejectPromise = reject;
|
|
1035
|
+
});
|
|
1036
|
+
const timer = setTimeout(() => {
|
|
1037
|
+
this.firstFrame = void 0;
|
|
1038
|
+
rejectPromise(new TciError("command-timeout", `Timed out waiting for TCI IQ frame for receiver ${this.receiver}`));
|
|
1039
|
+
}, timeoutMs);
|
|
1040
|
+
this.firstFrame = { promise, resolve: resolvePromise, reject: rejectPromise, timer };
|
|
1041
|
+
return promise;
|
|
1042
|
+
}
|
|
1043
|
+
_acceptFrame(frame) {
|
|
1044
|
+
if (this.closed || frame.receiver !== this.receiver) return;
|
|
1045
|
+
this._appliedSampleRate = frame.sampleRate;
|
|
1046
|
+
const firstFrame = this.firstFrame;
|
|
1047
|
+
if (firstFrame) {
|
|
1048
|
+
clearTimeout(firstFrame.timer);
|
|
1049
|
+
this.firstFrame = void 0;
|
|
1050
|
+
firstFrame.resolve(frame);
|
|
1051
|
+
}
|
|
1052
|
+
this.emit("frame", frame);
|
|
1053
|
+
}
|
|
1054
|
+
_fail(error) {
|
|
1055
|
+
if (this.closed) return;
|
|
1056
|
+
this.rejectFirstFrame(error);
|
|
1057
|
+
this.emit("error", error);
|
|
1058
|
+
}
|
|
1059
|
+
rejectFirstFrame(error) {
|
|
1060
|
+
const firstFrame = this.firstFrame;
|
|
1061
|
+
if (!firstFrame) return;
|
|
1062
|
+
clearTimeout(firstFrame.timer);
|
|
1063
|
+
this.firstFrame = void 0;
|
|
1064
|
+
firstFrame.reject(error);
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
var TciClient = class extends EventEmitter2 {
|
|
563
1068
|
options;
|
|
564
1069
|
WebSocketImpl;
|
|
565
|
-
|
|
1070
|
+
transportFactory;
|
|
1071
|
+
transport;
|
|
566
1072
|
queue;
|
|
567
1073
|
state;
|
|
1074
|
+
stateReducers;
|
|
1075
|
+
dialectRegistry;
|
|
1076
|
+
activeDialect;
|
|
1077
|
+
handshakeResult;
|
|
1078
|
+
handshakeError;
|
|
1079
|
+
initializationCommands = [];
|
|
1080
|
+
handshakeWaiter;
|
|
1081
|
+
activeIqSession;
|
|
568
1082
|
constructor(options) {
|
|
569
1083
|
super();
|
|
570
1084
|
this.options = {
|
|
@@ -573,13 +1087,17 @@ var TciClient = class extends EventEmitter {
|
|
|
573
1087
|
trx: options.trx ?? 0,
|
|
574
1088
|
vfo: options.vfo ?? 0,
|
|
575
1089
|
connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
|
|
1090
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
|
|
576
1091
|
commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
|
|
577
1092
|
writeAckMode: options.writeAckMode ?? "state",
|
|
578
1093
|
writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
|
|
579
1094
|
writeSettleMs: options.writeSettleMs ?? 0,
|
|
580
|
-
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
|
|
1095
|
+
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
|
|
1096
|
+
dialect: options.dialect ?? "auto"
|
|
581
1097
|
};
|
|
582
|
-
this.
|
|
1098
|
+
this.dialectRegistry = options.dialectRegistry ?? defaultTciDialectRegistry;
|
|
1099
|
+
this.WebSocketImpl = options.WebSocketImpl ?? WebSocket2;
|
|
1100
|
+
this.transportFactory = options.transportFactory ?? ((url) => new WebSocketTciTransport(url, this.WebSocketImpl));
|
|
583
1101
|
this.queue = new TciCommandQueue({
|
|
584
1102
|
timeoutMs: this.options.commandTimeoutMs,
|
|
585
1103
|
send: (raw) => this.sendRaw(raw)
|
|
@@ -595,58 +1113,113 @@ var TciClient = class extends EventEmitter {
|
|
|
595
1113
|
pttSource: {},
|
|
596
1114
|
tune: {},
|
|
597
1115
|
drive: {},
|
|
1116
|
+
tuneDrive: {},
|
|
598
1117
|
split: {},
|
|
1118
|
+
dialectWarnings: [],
|
|
599
1119
|
rxSensors: {},
|
|
600
|
-
txSensors: {}
|
|
1120
|
+
txSensors: {},
|
|
1121
|
+
iq: { activeReceivers: {} },
|
|
1122
|
+
dds: {}
|
|
601
1123
|
};
|
|
1124
|
+
this.stateReducers = this.createStateReducers();
|
|
602
1125
|
}
|
|
603
1126
|
async connect() {
|
|
604
|
-
if (this.
|
|
605
|
-
return;
|
|
1127
|
+
if (this.transport?.isConnected()) {
|
|
1128
|
+
return this.handshakeResult ?? this.waitForHandshake();
|
|
606
1129
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1130
|
+
this.resetHandshake();
|
|
1131
|
+
const transport = this.transportFactory(this.options.url);
|
|
1132
|
+
this.transport = transport;
|
|
1133
|
+
this.attachTransport(transport);
|
|
1134
|
+
await transport.connect(this.options.connectTimeoutMs);
|
|
1135
|
+
this.state.connected = true;
|
|
1136
|
+
this.queue.setConnected(true);
|
|
1137
|
+
this.emit("connected");
|
|
1138
|
+
this.emitState();
|
|
1139
|
+
try {
|
|
1140
|
+
return await this.waitForHandshake();
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
transport.terminate();
|
|
1143
|
+
throw error;
|
|
610
1144
|
}
|
|
611
|
-
const ws = new this.WebSocketImpl(this.options.url);
|
|
612
|
-
this.ws = ws;
|
|
613
|
-
await this.waitForOpen(ws);
|
|
614
1145
|
}
|
|
615
1146
|
async disconnect(code = 1e3, reason = "client disconnect") {
|
|
616
|
-
const
|
|
617
|
-
if (!
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
if (ws.readyState === WebSocket.CLOSED) {
|
|
621
|
-
this.handleClose();
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
await new Promise((resolve) => {
|
|
625
|
-
const cleanup = () => {
|
|
626
|
-
ws.off("close", onClose);
|
|
627
|
-
ws.off("error", onError);
|
|
628
|
-
};
|
|
629
|
-
const onClose = () => {
|
|
630
|
-
cleanup();
|
|
631
|
-
resolve();
|
|
632
|
-
};
|
|
633
|
-
const onError = () => {
|
|
634
|
-
cleanup();
|
|
635
|
-
resolve();
|
|
636
|
-
};
|
|
637
|
-
ws.once("close", onClose);
|
|
638
|
-
ws.once("error", onError);
|
|
639
|
-
ws.close(code, reason);
|
|
640
|
-
setTimeout(() => resolve(), 1e3).unref?.();
|
|
641
|
-
});
|
|
1147
|
+
const transport = this.transport;
|
|
1148
|
+
if (!transport) return;
|
|
1149
|
+
await transport.disconnect(code, reason);
|
|
642
1150
|
this.handleClose();
|
|
643
1151
|
}
|
|
644
1152
|
isConnected() {
|
|
645
|
-
return this.
|
|
1153
|
+
return this.transport?.isConnected() ?? false;
|
|
646
1154
|
}
|
|
647
1155
|
getState() {
|
|
648
1156
|
return cloneState(this.state);
|
|
649
1157
|
}
|
|
1158
|
+
getHandshakeResult() {
|
|
1159
|
+
return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
|
|
1160
|
+
}
|
|
1161
|
+
getIqCapabilities() {
|
|
1162
|
+
const dialect = this.requireDialect();
|
|
1163
|
+
return {
|
|
1164
|
+
supported: dialect.supportsIqStream,
|
|
1165
|
+
currentSampleRate: this.state.iq.sampleRate,
|
|
1166
|
+
supportedSampleRates: [...dialect.iqSampleRates]
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
async setIqSampleRate(sampleRate, timeoutMs = this.options.commandTimeoutMs) {
|
|
1170
|
+
const capabilities = this.getIqCapabilities();
|
|
1171
|
+
const requested = Math.round(sampleRate);
|
|
1172
|
+
if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
|
|
1173
|
+
if (!Number.isFinite(requested) || requested <= 0) throw new TciError("protocol-error", `Invalid TCI IQ sample rate: ${sampleRate}`);
|
|
1174
|
+
if (capabilities.supportedSampleRates.length > 0 && !capabilities.supportedSampleRates.includes(requested)) {
|
|
1175
|
+
throw new TciError("protocol-error", `TCI IQ sample rate ${requested} is not supported by dialect ${this.requireDialect().id}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (this.state.iq.sampleRate === requested) return writeResult(requested, requested, "state");
|
|
1178
|
+
const waiter = this.waitForCommand((command) => command.name === "iq_samplerate", timeoutMs, "IQ_SAMPLERATE readback");
|
|
1179
|
+
try {
|
|
1180
|
+
await this.sendCommand("IQ_SAMPLERATE", [requested], { waitForReply: false });
|
|
1181
|
+
const reply = await waiter.promise;
|
|
1182
|
+
const applied = parseNumber(reply.args[0]);
|
|
1183
|
+
if (applied === void 0 || applied <= 0) throw new TciError("protocol-error", `Invalid IQ_SAMPLERATE readback: ${reply.raw}`);
|
|
1184
|
+
return writeResult(requested, applied, "reply");
|
|
1185
|
+
} finally {
|
|
1186
|
+
waiter.cancel();
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
async openIqStream(options = {}) {
|
|
1190
|
+
if (this.activeIqSession) throw new TciError("protocol-error", "This TCI client already has an active IQ stream session");
|
|
1191
|
+
const capabilities = this.getIqCapabilities();
|
|
1192
|
+
if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
|
|
1193
|
+
const receiver = Math.floor(options.receiver ?? this.options.receiver);
|
|
1194
|
+
if (!Number.isInteger(receiver) || receiver < 0) throw new TciError("protocol-error", `Invalid TCI IQ receiver: ${receiver}`);
|
|
1195
|
+
const requestedRate = options.sampleRate ?? capabilities.currentSampleRate ?? capabilities.supportedSampleRates[0] ?? 48e3;
|
|
1196
|
+
const rateResult = await this.setIqSampleRate(requestedRate);
|
|
1197
|
+
let session;
|
|
1198
|
+
session = new TciIqStreamSession(receiver, rateResult.applied, {
|
|
1199
|
+
setSampleRate: (rate) => this.setIqSampleRate(rate),
|
|
1200
|
+
close: async () => {
|
|
1201
|
+
try {
|
|
1202
|
+
if (this.isConnected()) await this.sendCommand("IQ_STOP", [receiver], { waitForReply: false });
|
|
1203
|
+
} finally {
|
|
1204
|
+
this.state.iq.activeReceivers[String(receiver)] = false;
|
|
1205
|
+
if (this.activeIqSession === session) this.activeIqSession = void 0;
|
|
1206
|
+
this.emitState();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
this.activeIqSession = session;
|
|
1211
|
+
const firstFrame = session.waitForFirstFrame(options.firstFrameTimeoutMs ?? 5e3);
|
|
1212
|
+
try {
|
|
1213
|
+
await this.sendCommand("IQ_START", [receiver], { waitForReply: false });
|
|
1214
|
+
this.state.iq.activeReceivers[String(receiver)] = true;
|
|
1215
|
+
this.emitState();
|
|
1216
|
+
await firstFrame;
|
|
1217
|
+
return session;
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
await session.close().catch(() => void 0);
|
|
1220
|
+
throw error;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
650
1223
|
async sendCommand(name, args = [], options = {}) {
|
|
651
1224
|
const raw = formatTciCommand(name, args);
|
|
652
1225
|
if (options.waitForReply === false) {
|
|
@@ -720,6 +1293,9 @@ var TciClient = class extends EventEmitter {
|
|
|
720
1293
|
}
|
|
721
1294
|
async setPtt(enabled, options = {}) {
|
|
722
1295
|
const trx = options.trx ?? this.options.trx;
|
|
1296
|
+
if (options.source && !this.requireDialect().supportsTxAudioSource) {
|
|
1297
|
+
throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support a TRX audio source argument`);
|
|
1298
|
+
}
|
|
723
1299
|
const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
|
|
724
1300
|
await this.sendStateWrite(
|
|
725
1301
|
"TRX",
|
|
@@ -733,16 +1309,107 @@ var TciClient = class extends EventEmitter {
|
|
|
733
1309
|
const reply = await this.request("TRX", [trx]);
|
|
734
1310
|
return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
|
|
735
1311
|
}
|
|
736
|
-
async setTune(enabled, trx = this.options.trx) {
|
|
737
|
-
|
|
1312
|
+
async setTune(enabled, trx = this.options.trx, options = {}) {
|
|
1313
|
+
try {
|
|
1314
|
+
await this.sendStateWrite(
|
|
1315
|
+
"TUNE",
|
|
1316
|
+
[trx, enabled],
|
|
1317
|
+
(state) => state.tune[String(trx)] === enabled,
|
|
1318
|
+
`TUNE:${trx},${enabled}`,
|
|
1319
|
+
options
|
|
1320
|
+
);
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1323
|
+
await this.request("TUNE", [trx], { timeoutMs: options.timeoutMs });
|
|
1324
|
+
if (this.state.tune[String(trx)] !== enabled) throw error;
|
|
1325
|
+
}
|
|
738
1326
|
}
|
|
739
1327
|
async setDrive(value, trx = this.options.trx) {
|
|
740
|
-
await this.
|
|
1328
|
+
await this.setDriveWithResult(value, trx);
|
|
741
1329
|
}
|
|
742
|
-
async
|
|
743
|
-
|
|
1330
|
+
async setDriveWithResult(value, trx = this.options.trx, options = {}) {
|
|
1331
|
+
const requested = normalizePercent(value);
|
|
1332
|
+
const dialect = this.requireDialect();
|
|
1333
|
+
if (this.state.drive[String(trx)] === requested) {
|
|
1334
|
+
return writeResult(requested, requested, "state");
|
|
1335
|
+
}
|
|
1336
|
+
const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
|
|
1337
|
+
const waiter = this.waitForCommand(
|
|
1338
|
+
(command) => command.name === "drive" && dialect.parseDrive(command.args, trx)?.trx === trx,
|
|
1339
|
+
Math.min(500, timeoutMs),
|
|
1340
|
+
`DRIVE state for TRX ${trx}`
|
|
1341
|
+
);
|
|
1342
|
+
await this.sendCommand("DRIVE", dialect.buildDriveSetArgs(trx, requested), { waitForReply: false });
|
|
1343
|
+
try {
|
|
1344
|
+
const command = await waiter.promise;
|
|
1345
|
+
const applied2 = dialect.parseDrive(command.args, trx)?.value;
|
|
1346
|
+
if (applied2 !== void 0) return writeResult(requested, applied2, "state");
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1349
|
+
} finally {
|
|
1350
|
+
waiter.cancel();
|
|
1351
|
+
}
|
|
1352
|
+
const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx), {
|
|
1353
|
+
timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
|
|
1354
|
+
});
|
|
1355
|
+
const applied = dialect.parseDrive(reply.args, trx)?.value;
|
|
1356
|
+
if (applied === void 0) throw new TciError("protocol-error", `Invalid DRIVE readback: ${reply.raw}`);
|
|
1357
|
+
return writeResult(requested, applied, "readback");
|
|
1358
|
+
}
|
|
1359
|
+
async getDrive(trx = this.options.trx) {
|
|
1360
|
+
const dialect = this.requireDialect();
|
|
1361
|
+
const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx));
|
|
1362
|
+
return dialect.parseDrive(reply.args, trx)?.value ?? this.state.drive[String(trx)];
|
|
1363
|
+
}
|
|
1364
|
+
async setTuneDrive(value, trx = this.options.trx, options = {}) {
|
|
1365
|
+
const requested = normalizePercent(value);
|
|
1366
|
+
const dialect = this.requireDialect();
|
|
1367
|
+
if (this.state.tuneDrive[String(trx)] === requested) return writeResult(requested, requested, "state");
|
|
1368
|
+
const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
|
|
1369
|
+
const waiter = this.waitForCommand(
|
|
1370
|
+
(command) => command.name === "tune_drive" && dialect.parseTuneDrive(command.args, trx)?.trx === trx,
|
|
1371
|
+
Math.min(500, timeoutMs),
|
|
1372
|
+
`TUNE_DRIVE state for TRX ${trx}`
|
|
1373
|
+
);
|
|
1374
|
+
await this.sendCommand("TUNE_DRIVE", dialect.buildTuneDriveSetArgs(trx, requested), { waitForReply: false });
|
|
1375
|
+
try {
|
|
1376
|
+
const command = await waiter.promise;
|
|
1377
|
+
const applied2 = dialect.parseTuneDrive(command.args, trx)?.value;
|
|
1378
|
+
if (applied2 !== void 0) return writeResult(requested, applied2, "state");
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1381
|
+
} finally {
|
|
1382
|
+
waiter.cancel();
|
|
1383
|
+
}
|
|
1384
|
+
const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx), {
|
|
1385
|
+
timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
|
|
1386
|
+
});
|
|
1387
|
+
const applied = dialect.parseTuneDrive(reply.args, trx)?.value;
|
|
1388
|
+
if (applied === void 0) throw new TciError("protocol-error", `Invalid TUNE_DRIVE readback: ${reply.raw}`);
|
|
1389
|
+
return writeResult(requested, applied, "readback");
|
|
1390
|
+
}
|
|
1391
|
+
async getTuneDrive(trx = this.options.trx) {
|
|
1392
|
+
const dialect = this.requireDialect();
|
|
1393
|
+
const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx));
|
|
1394
|
+
return dialect.parseTuneDrive(reply.args, trx)?.value ?? this.state.tuneDrive[String(trx)];
|
|
1395
|
+
}
|
|
1396
|
+
async setSplit(enabled, trx = this.options.trx, options = {}) {
|
|
1397
|
+
try {
|
|
1398
|
+
await this.sendStateWrite(
|
|
1399
|
+
"SPLIT_ENABLE",
|
|
1400
|
+
[trx, enabled],
|
|
1401
|
+
(state) => state.split[String(trx)] === enabled,
|
|
1402
|
+
`SPLIT_ENABLE:${trx},${enabled}`,
|
|
1403
|
+
options
|
|
1404
|
+
);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1407
|
+
await this.request("SPLIT_ENABLE", [trx], { timeoutMs: options.timeoutMs });
|
|
1408
|
+
if (this.state.split[String(trx)] !== enabled) throw error;
|
|
1409
|
+
}
|
|
744
1410
|
}
|
|
745
1411
|
async configureAudio(config) {
|
|
1412
|
+
const dialect = this.requireDialect();
|
|
746
1413
|
const audio = {
|
|
747
1414
|
sampleRate: config.sampleRate,
|
|
748
1415
|
sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
|
|
@@ -753,11 +1420,13 @@ var TciClient = class extends EventEmitter {
|
|
|
753
1420
|
};
|
|
754
1421
|
this.state.audio = audio;
|
|
755
1422
|
await this.sendCommand("AUDIO_SAMPLERATE", [audio.sampleRate], { waitForReply: false });
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1423
|
+
if (dialect.supportsStreamChannels) {
|
|
1424
|
+
await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
|
|
1425
|
+
await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
|
|
1426
|
+
await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
|
|
1427
|
+
if (audio.txBufferingMs !== void 0) {
|
|
1428
|
+
await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
|
|
1429
|
+
}
|
|
761
1430
|
}
|
|
762
1431
|
this.emitState();
|
|
763
1432
|
}
|
|
@@ -776,12 +1445,16 @@ var TciClient = class extends EventEmitter {
|
|
|
776
1445
|
}
|
|
777
1446
|
}
|
|
778
1447
|
sendTxAudio(options) {
|
|
779
|
-
const frame = buildTxAudioFrame({
|
|
1448
|
+
const frame = buildTxAudioFrame({
|
|
1449
|
+
receiver: this.options.receiver,
|
|
1450
|
+
lengthSemantics: this.requireDialect().streamLengthSemantics,
|
|
1451
|
+
...options
|
|
1452
|
+
});
|
|
780
1453
|
this.sendRawBinary(frame);
|
|
781
1454
|
}
|
|
782
1455
|
sendTxAudioForChrono(request, samples) {
|
|
783
1456
|
const channels = Math.max(1, Math.floor(request.channels || 1));
|
|
784
|
-
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount)
|
|
1457
|
+
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
|
|
785
1458
|
const output = new Float32Array(targetSampleLength);
|
|
786
1459
|
const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
|
|
787
1460
|
output.set(source.subarray(0, output.length));
|
|
@@ -879,78 +1552,147 @@ var TciClient = class extends EventEmitter {
|
|
|
879
1552
|
cancel: cleanup
|
|
880
1553
|
};
|
|
881
1554
|
}
|
|
882
|
-
|
|
1555
|
+
waitForCommand(predicate, timeoutMs, description) {
|
|
1556
|
+
let timer;
|
|
1557
|
+
let resolvePromise;
|
|
1558
|
+
let rejectPromise;
|
|
1559
|
+
const cleanup = () => {
|
|
1560
|
+
if (timer) clearTimeout(timer);
|
|
1561
|
+
timer = void 0;
|
|
1562
|
+
this.off("command", onCommand);
|
|
1563
|
+
this.off("disconnected", onDisconnected);
|
|
1564
|
+
};
|
|
1565
|
+
const onCommand = (command) => {
|
|
1566
|
+
if (!predicate(command)) return;
|
|
1567
|
+
cleanup();
|
|
1568
|
+
resolvePromise(command);
|
|
1569
|
+
};
|
|
1570
|
+
const onDisconnected = () => {
|
|
1571
|
+
cleanup();
|
|
1572
|
+
rejectPromise(new TciError("disconnected", `Disconnected while waiting for ${description}`));
|
|
1573
|
+
};
|
|
1574
|
+
const promise = new Promise((resolve, reject) => {
|
|
1575
|
+
resolvePromise = resolve;
|
|
1576
|
+
rejectPromise = reject;
|
|
1577
|
+
timer = setTimeout(() => {
|
|
1578
|
+
cleanup();
|
|
1579
|
+
reject(new TciError("command-timeout", `Timed out waiting for ${description}`));
|
|
1580
|
+
}, timeoutMs);
|
|
1581
|
+
this.on("command", onCommand);
|
|
1582
|
+
this.on("disconnected", onDisconnected);
|
|
1583
|
+
});
|
|
1584
|
+
return { promise, cancel: cleanup };
|
|
1585
|
+
}
|
|
1586
|
+
resetHandshake() {
|
|
1587
|
+
this.rejectHandshake(new TciError("cancelled", "TCI handshake replaced by a new connection"));
|
|
1588
|
+
this.handshakeResult = void 0;
|
|
1589
|
+
this.handshakeError = void 0;
|
|
1590
|
+
this.activeDialect = void 0;
|
|
1591
|
+
this.initializationCommands = [];
|
|
1592
|
+
this.state.ready = false;
|
|
1593
|
+
this.state.protocol = void 0;
|
|
1594
|
+
this.state.protocolName = void 0;
|
|
1595
|
+
this.state.protocolVersion = void 0;
|
|
1596
|
+
this.state.dialectId = void 0;
|
|
1597
|
+
this.state.dialectConfidence = void 0;
|
|
1598
|
+
this.state.dialectWarnings = [];
|
|
1599
|
+
}
|
|
1600
|
+
waitForHandshake() {
|
|
1601
|
+
if (this.handshakeResult) return Promise.resolve(cloneHandshake(this.handshakeResult));
|
|
1602
|
+
if (this.handshakeError) return Promise.reject(this.handshakeError);
|
|
1603
|
+
if (this.handshakeWaiter) {
|
|
1604
|
+
return new Promise((resolve, reject) => {
|
|
1605
|
+
const onHandshake = (result) => {
|
|
1606
|
+
cleanup();
|
|
1607
|
+
resolve(result);
|
|
1608
|
+
};
|
|
1609
|
+
const onError = (error) => {
|
|
1610
|
+
cleanup();
|
|
1611
|
+
reject(error);
|
|
1612
|
+
};
|
|
1613
|
+
const cleanup = () => {
|
|
1614
|
+
this.off("handshake", onHandshake);
|
|
1615
|
+
this.off("error", onError);
|
|
1616
|
+
};
|
|
1617
|
+
this.once("handshake", onHandshake);
|
|
1618
|
+
this.once("error", onError);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
883
1621
|
return new Promise((resolve, reject) => {
|
|
884
1622
|
const timer = setTimeout(() => {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
reject(new TciError("connect-timeout", `Timed out connecting to ${this.options.url}`));
|
|
891
|
-
}, this.options.connectTimeoutMs);
|
|
892
|
-
const cleanup = () => {
|
|
893
|
-
clearTimeout(timer);
|
|
894
|
-
ws.off("open", onOpen);
|
|
895
|
-
ws.off("close", onClose);
|
|
896
|
-
ws.off("error", onError);
|
|
897
|
-
};
|
|
898
|
-
const onOpen = () => {
|
|
899
|
-
cleanup();
|
|
900
|
-
this.attachSocket(ws);
|
|
901
|
-
this.state.connected = true;
|
|
902
|
-
this.queue.setConnected(true);
|
|
903
|
-
this.emit("connected");
|
|
904
|
-
this.emitState();
|
|
905
|
-
resolve();
|
|
906
|
-
};
|
|
907
|
-
const onClose = () => {
|
|
908
|
-
cleanup();
|
|
909
|
-
this.handleClose();
|
|
910
|
-
reject(new TciError("disconnected", `Disconnected while connecting to ${this.options.url}`));
|
|
911
|
-
};
|
|
912
|
-
const onError = (error) => {
|
|
913
|
-
cleanup();
|
|
914
|
-
this.handleError(error);
|
|
915
|
-
reject(toTciError(error, "disconnected"));
|
|
916
|
-
};
|
|
917
|
-
ws.once("open", onOpen);
|
|
918
|
-
ws.once("close", onClose);
|
|
919
|
-
ws.once("error", onError);
|
|
1623
|
+
const error = new TciError("handshake-timeout", `Timed out waiting for TCI READY from ${this.options.url}`);
|
|
1624
|
+
this.handshakeWaiter = void 0;
|
|
1625
|
+
reject(error);
|
|
1626
|
+
}, this.options.handshakeTimeoutMs);
|
|
1627
|
+
this.handshakeWaiter = { resolve, reject, timer };
|
|
920
1628
|
});
|
|
921
1629
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1630
|
+
finalizeHandshake() {
|
|
1631
|
+
if (this.handshakeResult) return this.handshakeResult;
|
|
1632
|
+
assertValidTciHandshake(this.initializationCommands);
|
|
1633
|
+
const identity = parseProtocolIdentity(this.initializationCommands);
|
|
1634
|
+
const commandNames = [...new Set(this.initializationCommands.map((command) => command.name))];
|
|
1635
|
+
const dialect = this.dialectRegistry.select(
|
|
1636
|
+
{ identity, commands: this.initializationCommands, commandNames: new Set(commandNames) },
|
|
1637
|
+
this.options.dialect
|
|
1638
|
+
);
|
|
1639
|
+
const result = { identity, dialect, ready: true, commandNames };
|
|
1640
|
+
this.handshakeResult = result;
|
|
1641
|
+
this.activeDialect = dialect.dialect;
|
|
1642
|
+
this.state.protocolName = identity.programName;
|
|
1643
|
+
this.state.protocolVersion = identity.protocolVersion;
|
|
1644
|
+
this.state.protocol = identity.protocolVersion ?? identity.programName;
|
|
1645
|
+
this.state.device = identity.device ?? this.state.device;
|
|
1646
|
+
this.state.dialectId = dialect.dialect.id;
|
|
1647
|
+
this.state.dialectConfidence = dialect.confidence;
|
|
1648
|
+
this.state.dialectWarnings = [...dialect.warnings];
|
|
1649
|
+
const waiter = this.handshakeWaiter;
|
|
1650
|
+
this.handshakeWaiter = void 0;
|
|
1651
|
+
if (waiter) {
|
|
1652
|
+
clearTimeout(waiter.timer);
|
|
1653
|
+
waiter.resolve(cloneHandshake(result));
|
|
1654
|
+
}
|
|
1655
|
+
this.emit("handshake", cloneHandshake(result));
|
|
1656
|
+
return result;
|
|
1657
|
+
}
|
|
1658
|
+
rejectHandshake(error) {
|
|
1659
|
+
const waiter = this.handshakeWaiter;
|
|
1660
|
+
this.handshakeWaiter = void 0;
|
|
1661
|
+
if (!waiter) return;
|
|
1662
|
+
clearTimeout(waiter.timer);
|
|
1663
|
+
waiter.reject(error);
|
|
1664
|
+
}
|
|
1665
|
+
requireDialect() {
|
|
1666
|
+
if (!this.activeDialect) throw new TciError("invalid-handshake", "TCI dialect is not available before READY");
|
|
1667
|
+
return this.activeDialect;
|
|
1668
|
+
}
|
|
1669
|
+
attachTransport(transport) {
|
|
1670
|
+
transport.on("text", (raw) => this.handleText(raw));
|
|
1671
|
+
transport.on("binary", (raw) => this.handleBinary(raw));
|
|
1672
|
+
transport.on("disconnected", (reason) => this.handleClose(reason));
|
|
1673
|
+
transport.on("error", (error) => this.handleError(error));
|
|
926
1674
|
}
|
|
927
1675
|
async sendRaw(raw) {
|
|
928
|
-
const
|
|
929
|
-
if (!
|
|
1676
|
+
const transport = this.transport;
|
|
1677
|
+
if (!transport?.isConnected()) {
|
|
930
1678
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
931
1679
|
}
|
|
932
1680
|
this.emit("tci:tx", raw);
|
|
933
|
-
await
|
|
934
|
-
ws.send(raw, (error) => error ? reject(error) : resolve());
|
|
935
|
-
});
|
|
1681
|
+
await transport.sendText(raw);
|
|
936
1682
|
}
|
|
937
1683
|
sendRawBinary(raw) {
|
|
938
|
-
const
|
|
939
|
-
if (!
|
|
1684
|
+
const transport = this.transport;
|
|
1685
|
+
if (!transport?.isConnected()) {
|
|
940
1686
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
941
1687
|
}
|
|
942
|
-
|
|
1688
|
+
void transport.sendBinary(raw).catch((error) => this.handleError(error));
|
|
943
1689
|
}
|
|
944
|
-
|
|
1690
|
+
handleText(raw) {
|
|
945
1691
|
try {
|
|
946
|
-
if (isBinary) {
|
|
947
|
-
this.handleBinary(data);
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
const raw = dataToBuffer(data).toString("utf8");
|
|
951
1692
|
const commands = parseTciText(raw);
|
|
952
1693
|
this.emit("tci:rx", raw, commands);
|
|
953
1694
|
for (const command of commands) {
|
|
1695
|
+
if (!this.handshakeResult) this.initializationCommands.push(command);
|
|
954
1696
|
this.queue.handleCommand(command);
|
|
955
1697
|
this.applyCommand(command);
|
|
956
1698
|
this.emit("command", command);
|
|
@@ -960,10 +1702,29 @@ var TciClient = class extends EventEmitter {
|
|
|
960
1702
|
}
|
|
961
1703
|
}
|
|
962
1704
|
handleBinary(data) {
|
|
963
|
-
const frame = parseStreamFrame(
|
|
1705
|
+
const frame = parseStreamFrame(data, {
|
|
1706
|
+
lengthSemantics: this.requireDialect().streamLengthSemantics,
|
|
1707
|
+
negotiatedChannels: this.state.audio?.channels
|
|
1708
|
+
});
|
|
964
1709
|
this.emit("tci:binary", frame);
|
|
965
1710
|
this.emit("binary", frame);
|
|
966
1711
|
switch (frame.streamType) {
|
|
1712
|
+
case 0 /* IQ_STREAM */: {
|
|
1713
|
+
if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
|
|
1714
|
+
throw new TciError("invalid-frame", "TCI IQ stream frame must contain interleaved I/Q pairs");
|
|
1715
|
+
}
|
|
1716
|
+
this.state.iq.sampleRate = frame.sampleRate;
|
|
1717
|
+
const iqFrame = {
|
|
1718
|
+
frame,
|
|
1719
|
+
receiver: frame.receiver,
|
|
1720
|
+
sampleRate: frame.sampleRate,
|
|
1721
|
+
centerFrequency: this.state.dds[String(frame.receiver)],
|
|
1722
|
+
complexSampleCount: frame.frameCount
|
|
1723
|
+
};
|
|
1724
|
+
this.emit("iqFrame", iqFrame);
|
|
1725
|
+
this.activeIqSession?._acceptFrame(iqFrame);
|
|
1726
|
+
break;
|
|
1727
|
+
}
|
|
967
1728
|
case 1 /* RX_AUDIO_STREAM */:
|
|
968
1729
|
this.emit("rxAudioFrame", frame);
|
|
969
1730
|
break;
|
|
@@ -974,7 +1735,8 @@ var TciClient = class extends EventEmitter {
|
|
|
974
1735
|
sampleRate: frame.sampleRate,
|
|
975
1736
|
channels: frame.channels,
|
|
976
1737
|
sampleType: frame.sampleType,
|
|
977
|
-
sampleCount: frame.sampleCount
|
|
1738
|
+
sampleCount: frame.sampleCount,
|
|
1739
|
+
frameCount: frame.frameCount
|
|
978
1740
|
});
|
|
979
1741
|
break;
|
|
980
1742
|
case 4 /* LINEOUT_STREAM */:
|
|
@@ -986,80 +1748,92 @@ var TciClient = class extends EventEmitter {
|
|
|
986
1748
|
}
|
|
987
1749
|
applyCommand(command) {
|
|
988
1750
|
const readyBefore = this.state.ready;
|
|
989
|
-
|
|
990
|
-
case "ready":
|
|
991
|
-
this.state.ready = command.args.length === 0 ? true : parseBoolean(command.args[0]) ?? true;
|
|
992
|
-
break;
|
|
993
|
-
case "protocol":
|
|
994
|
-
this.state.protocol = command.args[0];
|
|
995
|
-
break;
|
|
996
|
-
case "device":
|
|
997
|
-
this.state.device = command.args.join(",");
|
|
998
|
-
break;
|
|
999
|
-
case "receive_only":
|
|
1000
|
-
this.state.receiveOnly = parseBoolean(command.args[0]);
|
|
1001
|
-
break;
|
|
1002
|
-
case "trx_count":
|
|
1003
|
-
this.state.trxCount = parseNumber(command.args[0]);
|
|
1004
|
-
break;
|
|
1005
|
-
case "channels_count":
|
|
1006
|
-
case "channel_count":
|
|
1007
|
-
this.state.channelCount = parseNumber(command.args[0]);
|
|
1008
|
-
break;
|
|
1009
|
-
case "vfo_limits":
|
|
1010
|
-
this.state.vfoLimits = parseNumberPair(command.args);
|
|
1011
|
-
break;
|
|
1012
|
-
case "if_limits":
|
|
1013
|
-
this.state.ifLimits = parseNumberPair(command.args);
|
|
1014
|
-
break;
|
|
1015
|
-
case "modulations_list":
|
|
1016
|
-
this.state.modulations = command.args.map((mode) => mode.toLowerCase());
|
|
1017
|
-
break;
|
|
1018
|
-
case "vfo":
|
|
1019
|
-
this.applyVfo(command.args);
|
|
1020
|
-
break;
|
|
1021
|
-
case "modulation":
|
|
1022
|
-
this.applyModulation(command.args);
|
|
1023
|
-
break;
|
|
1024
|
-
case "trx":
|
|
1025
|
-
this.applyTrx(command.args);
|
|
1026
|
-
break;
|
|
1027
|
-
case "tune":
|
|
1028
|
-
this.applyBooleanByFirstArg(this.state.tune, command.args);
|
|
1029
|
-
break;
|
|
1030
|
-
case "drive":
|
|
1031
|
-
this.applyDrive(command.args);
|
|
1032
|
-
break;
|
|
1033
|
-
case "split_enable":
|
|
1034
|
-
this.applyBooleanByFirstArg(this.state.split, command.args);
|
|
1035
|
-
break;
|
|
1036
|
-
case "rx_channel_sensors":
|
|
1037
|
-
this.applyRxChannelSensors(command.args);
|
|
1038
|
-
break;
|
|
1039
|
-
case "rx_sensors":
|
|
1040
|
-
this.applyRxSensors(command.args);
|
|
1041
|
-
break;
|
|
1042
|
-
case "tx_sensors":
|
|
1043
|
-
this.applyTxSensors(command.args);
|
|
1044
|
-
break;
|
|
1045
|
-
case "audio_samplerate":
|
|
1046
|
-
this.state.audio = {
|
|
1047
|
-
sampleRate: parseNumber(command.args[0]) ?? this.state.audio?.sampleRate ?? 12e3,
|
|
1048
|
-
sampleType: this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
|
|
1049
|
-
channels: this.state.audio?.channels ?? 1,
|
|
1050
|
-
samplesPerFrame: this.state.audio?.samplesPerFrame ?? 512,
|
|
1051
|
-
txBufferingMs: this.state.audio?.txBufferingMs,
|
|
1052
|
-
running: this.state.audio?.running ?? false
|
|
1053
|
-
};
|
|
1054
|
-
break;
|
|
1055
|
-
default:
|
|
1056
|
-
break;
|
|
1057
|
-
}
|
|
1751
|
+
this.stateReducers.get(command.name)?.(command.args);
|
|
1058
1752
|
if (!readyBefore && this.state.ready) {
|
|
1059
|
-
|
|
1753
|
+
try {
|
|
1754
|
+
this.finalizeHandshake();
|
|
1755
|
+
this.emit("ready", this.getState());
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
const tciError = toTciError(error, "invalid-handshake");
|
|
1758
|
+
this.handshakeError = tciError;
|
|
1759
|
+
this.state.ready = false;
|
|
1760
|
+
this.rejectHandshake(tciError);
|
|
1761
|
+
this.handleError(tciError);
|
|
1762
|
+
}
|
|
1060
1763
|
}
|
|
1061
1764
|
this.emitState();
|
|
1062
1765
|
}
|
|
1766
|
+
createStateReducers() {
|
|
1767
|
+
const reducers = /* @__PURE__ */ new Map();
|
|
1768
|
+
reducers.set("ready", (args) => {
|
|
1769
|
+
this.state.ready = args.length === 0 ? true : parseBoolean(args[0]) ?? true;
|
|
1770
|
+
});
|
|
1771
|
+
reducers.set("protocol", (args) => {
|
|
1772
|
+
if (/^\d+(?:\.\d+){0,2}/.test(args[0] ?? "")) {
|
|
1773
|
+
this.state.protocol = args[0];
|
|
1774
|
+
this.state.protocolVersion = args[0];
|
|
1775
|
+
} else {
|
|
1776
|
+
this.state.protocolName = args[0];
|
|
1777
|
+
this.state.protocolVersion = args[1];
|
|
1778
|
+
this.state.protocol = args[1] ?? args[0];
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
reducers.set("device", (args) => {
|
|
1782
|
+
this.state.device = args.join(",");
|
|
1783
|
+
});
|
|
1784
|
+
reducers.set("receive_only", (args) => {
|
|
1785
|
+
this.state.receiveOnly = parseBoolean(args[0]);
|
|
1786
|
+
});
|
|
1787
|
+
reducers.set("trx_count", (args) => {
|
|
1788
|
+
this.state.trxCount = parseNumber(args[0]);
|
|
1789
|
+
});
|
|
1790
|
+
const channelCount = (args) => {
|
|
1791
|
+
this.state.channelCount = parseNumber(args[0]);
|
|
1792
|
+
};
|
|
1793
|
+
reducers.set("channels_count", channelCount);
|
|
1794
|
+
reducers.set("channel_count", channelCount);
|
|
1795
|
+
reducers.set("vfo_limits", (args) => {
|
|
1796
|
+
this.state.vfoLimits = parseNumberPair(args);
|
|
1797
|
+
});
|
|
1798
|
+
reducers.set("if_limits", (args) => {
|
|
1799
|
+
this.state.ifLimits = parseNumberPair(args);
|
|
1800
|
+
});
|
|
1801
|
+
reducers.set("modulations_list", (args) => {
|
|
1802
|
+
this.state.modulations = args.map((mode) => mode.toLowerCase());
|
|
1803
|
+
});
|
|
1804
|
+
reducers.set("vfo", (args) => this.applyVfo(args));
|
|
1805
|
+
reducers.set("dds", (args) => {
|
|
1806
|
+
const receiver = parseNumber(args[0]);
|
|
1807
|
+
const frequency = parseNumber(args[1]);
|
|
1808
|
+
if (receiver !== void 0 && frequency !== void 0 && frequency >= 0) this.state.dds[String(receiver)] = frequency;
|
|
1809
|
+
});
|
|
1810
|
+
reducers.set("modulation", (args) => this.applyModulation(args));
|
|
1811
|
+
reducers.set("trx", (args) => this.applyTrx(args));
|
|
1812
|
+
reducers.set("tune", (args) => this.applyBooleanByFirstArg(this.state.tune, args));
|
|
1813
|
+
reducers.set("drive", (args) => this.applyDrive(args));
|
|
1814
|
+
reducers.set("tune_drive", (args) => this.applyTuneDrive(args));
|
|
1815
|
+
reducers.set("split_enable", (args) => this.applyBooleanByFirstArg(this.state.split, args));
|
|
1816
|
+
reducers.set("rx_channel_sensors", (args) => this.applyRxChannelSensors(args));
|
|
1817
|
+
reducers.set("rx_sensors", (args) => this.applyRxSensors(args));
|
|
1818
|
+
reducers.set("tx_sensors", (args) => this.applyTxSensors(args));
|
|
1819
|
+
reducers.set("audio_samplerate", (args) => this.updateAudioState({ sampleRate: parseNumber(args[0]) }));
|
|
1820
|
+
reducers.set("audio_stream_sample_type", (args) => this.updateAudioState({ sampleType: parseSampleType(args[0]) }));
|
|
1821
|
+
reducers.set("audio_stream_channels", (args) => this.updateAudioState({ channels: parseNumber(args[0]) }));
|
|
1822
|
+
reducers.set("audio_stream_samples", (args) => this.updateAudioState({ samplesPerFrame: parseNumber(args[0]) }));
|
|
1823
|
+
reducers.set("tx_stream_audio_buffering", (args) => this.updateAudioState({ txBufferingMs: parseNumber(args[0]) }));
|
|
1824
|
+
reducers.set("audio_start", () => this.updateAudioState({ running: true }));
|
|
1825
|
+
reducers.set("audio_stop", () => this.updateAudioState({ running: false }));
|
|
1826
|
+
reducers.set("iq_samplerate", (args) => {
|
|
1827
|
+
this.state.iq.sampleRate = parseNumber(args[0]);
|
|
1828
|
+
});
|
|
1829
|
+
reducers.set("iq_start", (args) => {
|
|
1830
|
+
this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = true;
|
|
1831
|
+
});
|
|
1832
|
+
reducers.set("iq_stop", (args) => {
|
|
1833
|
+
this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = false;
|
|
1834
|
+
});
|
|
1835
|
+
return reducers;
|
|
1836
|
+
}
|
|
1063
1837
|
applyVfo(args) {
|
|
1064
1838
|
if (args.length < 3) {
|
|
1065
1839
|
return;
|
|
@@ -1106,18 +1880,22 @@ var TciClient = class extends EventEmitter {
|
|
|
1106
1880
|
}
|
|
1107
1881
|
}
|
|
1108
1882
|
applyDrive(args) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
this.state.
|
|
1120
|
-
|
|
1883
|
+
const parsed = this.activeDialect?.parseDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
|
|
1884
|
+
if (parsed) this.state.drive[String(parsed.trx)] = parsed.value;
|
|
1885
|
+
}
|
|
1886
|
+
applyTuneDrive(args) {
|
|
1887
|
+
const parsed = this.activeDialect?.parseTuneDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
|
|
1888
|
+
if (parsed) this.state.tuneDrive[String(parsed.trx)] = parsed.value;
|
|
1889
|
+
}
|
|
1890
|
+
updateAudioState(update) {
|
|
1891
|
+
this.state.audio = {
|
|
1892
|
+
sampleRate: update.sampleRate ?? this.state.audio?.sampleRate ?? 12e3,
|
|
1893
|
+
sampleType: update.sampleType ?? this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
|
|
1894
|
+
channels: update.channels ?? this.state.audio?.channels ?? 1,
|
|
1895
|
+
samplesPerFrame: update.samplesPerFrame ?? this.state.audio?.samplesPerFrame ?? 512,
|
|
1896
|
+
txBufferingMs: update.txBufferingMs ?? this.state.audio?.txBufferingMs,
|
|
1897
|
+
running: update.running ?? this.state.audio?.running ?? false
|
|
1898
|
+
};
|
|
1121
1899
|
}
|
|
1122
1900
|
applyRxChannelSensors(args) {
|
|
1123
1901
|
if (args.length < 3) {
|
|
@@ -1153,11 +1931,16 @@ var TciClient = class extends EventEmitter {
|
|
|
1153
1931
|
};
|
|
1154
1932
|
}
|
|
1155
1933
|
handleClose(reason) {
|
|
1156
|
-
|
|
1934
|
+
const transport = this.transport;
|
|
1935
|
+
this.transport = void 0;
|
|
1936
|
+
transport?.removeAllListeners();
|
|
1157
1937
|
const wasConnected = this.state.connected;
|
|
1158
1938
|
this.state.connected = false;
|
|
1159
1939
|
this.state.ready = false;
|
|
1160
1940
|
this.queue.setConnected(false);
|
|
1941
|
+
this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
|
|
1942
|
+
this.activeIqSession?._fail(new TciError("disconnected", "TCI connection closed while IQ stream was active", reason));
|
|
1943
|
+
this.activeIqSession = void 0;
|
|
1161
1944
|
if (wasConnected) {
|
|
1162
1945
|
this.emit("disconnected", reason);
|
|
1163
1946
|
this.emitState();
|
|
@@ -1174,18 +1957,6 @@ var TciClient = class extends EventEmitter {
|
|
|
1174
1957
|
function createTciClient(options) {
|
|
1175
1958
|
return new TciClient(options);
|
|
1176
1959
|
}
|
|
1177
|
-
function dataToBuffer(data) {
|
|
1178
|
-
if (Buffer.isBuffer(data)) {
|
|
1179
|
-
return data;
|
|
1180
|
-
}
|
|
1181
|
-
if (data instanceof ArrayBuffer) {
|
|
1182
|
-
return Buffer.from(data);
|
|
1183
|
-
}
|
|
1184
|
-
if (Array.isArray(data)) {
|
|
1185
|
-
return Buffer.concat(data.map((item) => dataToBuffer(item)));
|
|
1186
|
-
}
|
|
1187
|
-
throw new TciError("protocol-error", "Unsupported WebSocket data type");
|
|
1188
|
-
}
|
|
1189
1960
|
function rxVfoKey(receiver, vfo) {
|
|
1190
1961
|
return `${receiver}:${vfo}`;
|
|
1191
1962
|
}
|
|
@@ -1209,6 +1980,27 @@ function parseBoolean(value) {
|
|
|
1209
1980
|
}
|
|
1210
1981
|
return void 0;
|
|
1211
1982
|
}
|
|
1983
|
+
function parseSampleType(value) {
|
|
1984
|
+
if (!value) return void 0;
|
|
1985
|
+
try {
|
|
1986
|
+
return normalizeSampleType(value.toLowerCase());
|
|
1987
|
+
} catch {
|
|
1988
|
+
return void 0;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
function parseObservedDrive(args, defaultTrx) {
|
|
1992
|
+
const hasTrx = args.length >= 2;
|
|
1993
|
+
const trx = hasTrx ? parseNumber(args[0]) : defaultTrx;
|
|
1994
|
+
const value = parseNumber(args[hasTrx ? 1 : 0]);
|
|
1995
|
+
return trx === void 0 || value === void 0 ? void 0 : { trx, value };
|
|
1996
|
+
}
|
|
1997
|
+
function normalizePercent(value) {
|
|
1998
|
+
if (!Number.isFinite(value)) throw new TciError("protocol-error", `Invalid TCI percentage: ${value}`);
|
|
1999
|
+
return Math.round(Math.max(0, Math.min(100, value)));
|
|
2000
|
+
}
|
|
2001
|
+
function writeResult(requested, applied, acknowledgement) {
|
|
2002
|
+
return { requested, applied, outcome: requested === applied ? "applied" : "clamped", acknowledgement };
|
|
2003
|
+
}
|
|
1212
2004
|
function parseNumberPair(args) {
|
|
1213
2005
|
const first = parseNumber(args[0]);
|
|
1214
2006
|
const second = parseNumber(args[1]);
|
|
@@ -1224,10 +2016,26 @@ function cloneState(state) {
|
|
|
1224
2016
|
pttSource: { ...state.pttSource },
|
|
1225
2017
|
tune: { ...state.tune },
|
|
1226
2018
|
drive: { ...state.drive },
|
|
2019
|
+
tuneDrive: { ...state.tuneDrive },
|
|
1227
2020
|
split: { ...state.split },
|
|
2021
|
+
dialectWarnings: [...state.dialectWarnings],
|
|
1228
2022
|
rxSensors: cloneNested(state.rxSensors),
|
|
1229
2023
|
txSensors: cloneNested(state.txSensors),
|
|
1230
|
-
audio: state.audio ? { ...state.audio } : void 0
|
|
2024
|
+
audio: state.audio ? { ...state.audio } : void 0,
|
|
2025
|
+
iq: { ...state.iq, activeReceivers: { ...state.iq.activeReceivers } },
|
|
2026
|
+
dds: { ...state.dds }
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
function cloneHandshake(result) {
|
|
2030
|
+
return {
|
|
2031
|
+
identity: { ...result.identity, rawProtocolArgs: [...result.identity.rawProtocolArgs] },
|
|
2032
|
+
dialect: {
|
|
2033
|
+
...result.dialect,
|
|
2034
|
+
evidence: [...result.dialect.evidence],
|
|
2035
|
+
warnings: [...result.dialect.warnings]
|
|
2036
|
+
},
|
|
2037
|
+
ready: true,
|
|
2038
|
+
commandNames: [...result.commandNames]
|
|
1231
2039
|
};
|
|
1232
2040
|
}
|
|
1233
2041
|
function cloneNested(value) {
|
|
@@ -1237,30 +2045,46 @@ export {
|
|
|
1237
2045
|
TCI_STREAM_HEADER_BYTES,
|
|
1238
2046
|
TciClient,
|
|
1239
2047
|
TciCommandQueue,
|
|
2048
|
+
TciDialectRegistry,
|
|
1240
2049
|
TciError,
|
|
2050
|
+
TciIqStreamSession,
|
|
1241
2051
|
TciSampleType,
|
|
1242
2052
|
TciStreamType,
|
|
2053
|
+
WebSocketTciTransport,
|
|
2054
|
+
aetherSdrDialect,
|
|
2055
|
+
assertValidTciHandshake,
|
|
1243
2056
|
buildStreamFrame,
|
|
1244
2057
|
buildTxAudioFrame,
|
|
2058
|
+
builtInDialects,
|
|
1245
2059
|
commandKey,
|
|
2060
|
+
compareTciVersion,
|
|
1246
2061
|
createTciClient,
|
|
2062
|
+
decodeInterleavedIq,
|
|
2063
|
+
defaultTciDialectRegistry,
|
|
1247
2064
|
deinterleaveChannels,
|
|
1248
2065
|
escapeTciText,
|
|
2066
|
+
expertSdr14Dialect,
|
|
2067
|
+
expertSdrLegacyDialect,
|
|
2068
|
+
expertSdrModernDialect,
|
|
1249
2069
|
float32ToPcm16,
|
|
1250
2070
|
formatTciCommand,
|
|
2071
|
+
genericObservedDialect,
|
|
1251
2072
|
isCommandReplyTo,
|
|
1252
2073
|
mixToMono,
|
|
1253
2074
|
normalizeCommandName,
|
|
1254
2075
|
normalizeSampleType,
|
|
1255
2076
|
normalizeStreamType,
|
|
2077
|
+
parseProtocolIdentity,
|
|
1256
2078
|
parseStreamFrame,
|
|
1257
2079
|
parseTciCommand,
|
|
1258
2080
|
parseTciText,
|
|
2081
|
+
parseTciVersion,
|
|
1259
2082
|
payloadToFloat32,
|
|
1260
2083
|
pcm16ToFloat32,
|
|
1261
2084
|
sampleTypeBytes,
|
|
1262
2085
|
sampleTypeName,
|
|
1263
2086
|
samplesToPayload,
|
|
2087
|
+
thetisDialect,
|
|
1264
2088
|
toTciError,
|
|
1265
2089
|
unescapeTciText
|
|
1266
2090
|
};
|