tci-client-node 0.1.2 → 0.2.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 +9 -0
- package/README.md +32 -6
- package/dist/audio/index.cjs +41 -11
- package/dist/audio/index.cjs.map +1 -1
- package/dist/audio/index.d.cts +16 -3
- package/dist/audio/index.d.ts +16 -3
- package/dist/audio/index.js +41 -11
- package/dist/audio/index.js.map +1 -1
- package/dist/dialect/index.cjs +331 -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 +292 -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 +854 -215
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +53 -12
- package/dist/index.d.ts +53 -12
- package/dist/index.js +840 -215
- 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 +53 -14
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +2 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +53 -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-9seY9Th-.d.ts +63 -0
- package/dist/types-xRotf9gW.d.cts +63 -0
- package/package.json +15 -1
package/dist/index.cjs
CHANGED
|
@@ -33,30 +33,44 @@ __export(src_exports, {
|
|
|
33
33
|
TCI_STREAM_HEADER_BYTES: () => TCI_STREAM_HEADER_BYTES,
|
|
34
34
|
TciClient: () => TciClient,
|
|
35
35
|
TciCommandQueue: () => TciCommandQueue,
|
|
36
|
+
TciDialectRegistry: () => TciDialectRegistry,
|
|
36
37
|
TciError: () => TciError,
|
|
37
38
|
TciSampleType: () => TciSampleType,
|
|
38
39
|
TciStreamType: () => TciStreamType,
|
|
40
|
+
WebSocketTciTransport: () => WebSocketTciTransport,
|
|
41
|
+
aetherSdrDialect: () => aetherSdrDialect,
|
|
42
|
+
assertValidTciHandshake: () => assertValidTciHandshake,
|
|
39
43
|
buildStreamFrame: () => buildStreamFrame,
|
|
40
44
|
buildTxAudioFrame: () => buildTxAudioFrame,
|
|
45
|
+
builtInDialects: () => builtInDialects,
|
|
41
46
|
commandKey: () => commandKey,
|
|
47
|
+
compareTciVersion: () => compareTciVersion,
|
|
42
48
|
createTciClient: () => createTciClient,
|
|
49
|
+
defaultTciDialectRegistry: () => defaultTciDialectRegistry,
|
|
43
50
|
deinterleaveChannels: () => deinterleaveChannels,
|
|
44
51
|
escapeTciText: () => escapeTciText,
|
|
52
|
+
expertSdr14Dialect: () => expertSdr14Dialect,
|
|
53
|
+
expertSdrLegacyDialect: () => expertSdrLegacyDialect,
|
|
54
|
+
expertSdrModernDialect: () => expertSdrModernDialect,
|
|
45
55
|
float32ToPcm16: () => float32ToPcm16,
|
|
46
56
|
formatTciCommand: () => formatTciCommand,
|
|
57
|
+
genericObservedDialect: () => genericObservedDialect,
|
|
47
58
|
isCommandReplyTo: () => isCommandReplyTo,
|
|
48
59
|
mixToMono: () => mixToMono,
|
|
49
60
|
normalizeCommandName: () => normalizeCommandName,
|
|
50
61
|
normalizeSampleType: () => normalizeSampleType,
|
|
51
62
|
normalizeStreamType: () => normalizeStreamType,
|
|
63
|
+
parseProtocolIdentity: () => parseProtocolIdentity,
|
|
52
64
|
parseStreamFrame: () => parseStreamFrame,
|
|
53
65
|
parseTciCommand: () => parseTciCommand,
|
|
54
66
|
parseTciText: () => parseTciText,
|
|
67
|
+
parseTciVersion: () => parseTciVersion,
|
|
55
68
|
payloadToFloat32: () => payloadToFloat32,
|
|
56
69
|
pcm16ToFloat32: () => pcm16ToFloat32,
|
|
57
70
|
sampleTypeBytes: () => sampleTypeBytes,
|
|
58
71
|
sampleTypeName: () => sampleTypeName,
|
|
59
72
|
samplesToPayload: () => samplesToPayload,
|
|
73
|
+
thetisDialect: () => thetisDialect,
|
|
60
74
|
toTciError: () => toTciError,
|
|
61
75
|
unescapeTciText: () => unescapeTciText
|
|
62
76
|
});
|
|
@@ -84,8 +98,8 @@ function toTciError(error, fallbackCode = "protocol-error") {
|
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
// src/client/TciClient.ts
|
|
87
|
-
var
|
|
88
|
-
var
|
|
101
|
+
var import_eventemitter32 = require("eventemitter3");
|
|
102
|
+
var import_ws2 = __toESM(require("ws"), 1);
|
|
89
103
|
|
|
90
104
|
// src/audio/streamFrame.ts
|
|
91
105
|
var TCI_STREAM_HEADER_BYTES = 16 * 4;
|
|
@@ -104,7 +118,7 @@ var TciSampleType = /* @__PURE__ */ ((TciSampleType2) => {
|
|
|
104
118
|
TciSampleType2[TciSampleType2["FLOAT32"] = 3] = "FLOAT32";
|
|
105
119
|
return TciSampleType2;
|
|
106
120
|
})(TciSampleType || {});
|
|
107
|
-
function parseStreamFrame(input) {
|
|
121
|
+
function parseStreamFrame(input, options = {}) {
|
|
108
122
|
const buffer = toBuffer(input);
|
|
109
123
|
if (buffer.byteLength < TCI_STREAM_HEADER_BYTES) {
|
|
110
124
|
throw new TciError("invalid-frame", `TCI stream frame is shorter than ${TCI_STREAM_HEADER_BYTES} bytes`);
|
|
@@ -113,15 +127,15 @@ function parseStreamFrame(input) {
|
|
|
113
127
|
const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
|
|
114
128
|
const sampleType = normalizeSampleType(header[2]);
|
|
115
129
|
const streamType = normalizeStreamType(header[6]);
|
|
116
|
-
let channels = header[7];
|
|
130
|
+
let channels = header[7] || options.negotiatedChannels || 0;
|
|
117
131
|
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
118
|
-
const
|
|
132
|
+
const headerSampleCount = header[5];
|
|
119
133
|
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
120
134
|
if (channels <= 0) {
|
|
121
135
|
if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
|
|
122
136
|
channels = 1;
|
|
123
137
|
} else {
|
|
124
|
-
const inferredChannels =
|
|
138
|
+
const inferredChannels = headerSampleCount > 0 ? actualPayloadLength / headerSampleCount / bytesPerSample : 1;
|
|
125
139
|
if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
|
|
126
140
|
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
127
141
|
}
|
|
@@ -133,16 +147,28 @@ function parseStreamFrame(input) {
|
|
|
133
147
|
if (payloadLength % alignedFrameBytes !== 0) {
|
|
134
148
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
135
149
|
}
|
|
150
|
+
const actualScalarCount = payloadLength / bytesPerSample;
|
|
151
|
+
const requestedSemantics = options.lengthSemantics ?? "auto";
|
|
152
|
+
const lengthSemantics = resolveLengthSemantics(
|
|
153
|
+
requestedSemantics,
|
|
154
|
+
streamType,
|
|
155
|
+
headerSampleCount,
|
|
156
|
+
actualScalarCount,
|
|
157
|
+
channels
|
|
158
|
+
);
|
|
159
|
+
const sampleCount = lengthSemantics === "per-channel" ? headerSampleCount * channels : headerSampleCount;
|
|
136
160
|
if (streamType !== 3 /* TX_CHRONO */) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
if (payloadLength !== expectedPerChannelPayloadLength && payloadLength !== expectedScalarPayloadLength) {
|
|
161
|
+
const expectedPayloadLength = sampleCount * bytesPerSample;
|
|
162
|
+
if (payloadLength !== expectedPayloadLength) {
|
|
140
163
|
throw new TciError(
|
|
141
164
|
"invalid-frame",
|
|
142
|
-
`TCI stream frame length mismatch: header says ${
|
|
165
|
+
`TCI stream frame length mismatch: header says ${headerSampleCount} samples using ${lengthSemantics} semantics (${expectedPayloadLength} payload bytes), got ${payloadLength}`
|
|
143
166
|
);
|
|
144
167
|
}
|
|
145
168
|
}
|
|
169
|
+
if (sampleCount % channels !== 0) {
|
|
170
|
+
throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
|
|
171
|
+
}
|
|
146
172
|
return {
|
|
147
173
|
receiver: header[0],
|
|
148
174
|
sampleRate: header[1],
|
|
@@ -154,7 +180,10 @@ function parseStreamFrame(input) {
|
|
|
154
180
|
channels,
|
|
155
181
|
reserved: header.slice(8),
|
|
156
182
|
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
157
|
-
|
|
183
|
+
headerSampleCount,
|
|
184
|
+
sampleCount,
|
|
185
|
+
frameCount: sampleCount / channels,
|
|
186
|
+
lengthSemantics
|
|
158
187
|
};
|
|
159
188
|
}
|
|
160
189
|
function buildStreamFrame(options) {
|
|
@@ -168,11 +197,19 @@ function buildStreamFrame(options) {
|
|
|
168
197
|
if (payload.byteLength % (bytesPerSample * channels) !== 0) {
|
|
169
198
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
170
199
|
}
|
|
171
|
-
const derivedSampleCount = payload.byteLength / bytesPerSample
|
|
200
|
+
const derivedSampleCount = payload.byteLength / bytesPerSample;
|
|
172
201
|
const sampleCount = options.sampleCount ?? derivedSampleCount;
|
|
173
202
|
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
|
174
203
|
throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
|
|
175
204
|
}
|
|
205
|
+
if (payload.byteLength > 0 && sampleCount !== derivedSampleCount) {
|
|
206
|
+
throw new TciError("invalid-frame", `Explicit scalar sample count ${sampleCount} does not match payload count ${derivedSampleCount}`);
|
|
207
|
+
}
|
|
208
|
+
if (sampleCount % channels !== 0) {
|
|
209
|
+
throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
|
|
210
|
+
}
|
|
211
|
+
const lengthSemantics = options.lengthSemantics === "per-channel" ? "per-channel" : "scalar";
|
|
212
|
+
const headerSampleCount = lengthSemantics === "per-channel" ? sampleCount / channels : sampleCount;
|
|
176
213
|
const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
|
|
177
214
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
178
215
|
const reserved = options.reserved ?? [];
|
|
@@ -182,7 +219,7 @@ function buildStreamFrame(options) {
|
|
|
182
219
|
sampleType,
|
|
183
220
|
options.codec ?? 0,
|
|
184
221
|
options.crc ?? 0,
|
|
185
|
-
|
|
222
|
+
headerSampleCount,
|
|
186
223
|
options.streamType,
|
|
187
224
|
channels,
|
|
188
225
|
...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
|
|
@@ -191,6 +228,13 @@ function buildStreamFrame(options) {
|
|
|
191
228
|
payload.copy(frame, TCI_STREAM_HEADER_BYTES);
|
|
192
229
|
return frame;
|
|
193
230
|
}
|
|
231
|
+
function resolveLengthSemantics(requested, streamType, headerSampleCount, actualScalarCount, channels) {
|
|
232
|
+
if (requested !== "auto") return requested;
|
|
233
|
+
if (streamType === 3 /* TX_CHRONO */ && actualScalarCount === 0) return "scalar";
|
|
234
|
+
if (actualScalarCount === headerSampleCount) return "scalar";
|
|
235
|
+
if (actualScalarCount === headerSampleCount * channels) return "per-channel";
|
|
236
|
+
return "scalar";
|
|
237
|
+
}
|
|
194
238
|
function buildTxAudioFrame(options) {
|
|
195
239
|
return buildStreamFrame({ ...options, streamType: 2 /* TX_AUDIO_STREAM */ });
|
|
196
240
|
}
|
|
@@ -622,13 +666,398 @@ function ensureSemicolon(command) {
|
|
|
622
666
|
return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
|
|
623
667
|
}
|
|
624
668
|
|
|
669
|
+
// src/dialect/builtins.ts
|
|
670
|
+
var StandardTciDialect = class {
|
|
671
|
+
id;
|
|
672
|
+
label;
|
|
673
|
+
streamLengthSemantics;
|
|
674
|
+
supportsStreamChannels;
|
|
675
|
+
supportsTxAudioSource;
|
|
676
|
+
driveHasTrx;
|
|
677
|
+
detector;
|
|
678
|
+
resolver;
|
|
679
|
+
constructor(options) {
|
|
680
|
+
this.id = options.id;
|
|
681
|
+
this.label = options.label;
|
|
682
|
+
this.streamLengthSemantics = options.streamLengthSemantics;
|
|
683
|
+
this.supportsStreamChannels = options.supportsStreamChannels;
|
|
684
|
+
this.supportsTxAudioSource = options.supportsTxAudioSource;
|
|
685
|
+
this.driveHasTrx = options.driveHasTrx;
|
|
686
|
+
this.detector = options.detect;
|
|
687
|
+
this.resolver = options.resolve;
|
|
688
|
+
}
|
|
689
|
+
detect(context) {
|
|
690
|
+
return this.detector(context);
|
|
691
|
+
}
|
|
692
|
+
resolve(context) {
|
|
693
|
+
return this.resolver?.(context) ?? this;
|
|
694
|
+
}
|
|
695
|
+
buildDriveSetArgs(trx, value) {
|
|
696
|
+
return this.driveHasTrx ? [trx, value] : [value];
|
|
697
|
+
}
|
|
698
|
+
buildDriveReadArgs(trx) {
|
|
699
|
+
return this.driveHasTrx ? [trx] : [];
|
|
700
|
+
}
|
|
701
|
+
parseDrive(args, defaultTrx) {
|
|
702
|
+
return parseDriveState(args, defaultTrx, this.driveHasTrx);
|
|
703
|
+
}
|
|
704
|
+
buildTuneDriveSetArgs(trx, value) {
|
|
705
|
+
return this.driveHasTrx ? [trx, value] : [value];
|
|
706
|
+
}
|
|
707
|
+
buildTuneDriveReadArgs(trx) {
|
|
708
|
+
return this.driveHasTrx ? [trx] : [];
|
|
709
|
+
}
|
|
710
|
+
parseTuneDrive(args, defaultTrx) {
|
|
711
|
+
return parseDriveState(args, defaultTrx, this.driveHasTrx);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
function parseDriveState(args, defaultTrx, hasTrx) {
|
|
715
|
+
const trx = hasTrx ? Number(args[0]) : defaultTrx;
|
|
716
|
+
const value = Number(args[hasTrx ? 1 : 0]);
|
|
717
|
+
if (!Number.isInteger(trx) || !Number.isFinite(value)) return void 0;
|
|
718
|
+
return { trx, value };
|
|
719
|
+
}
|
|
720
|
+
function version(context) {
|
|
721
|
+
return parseTciVersion(context.identity.protocolVersion);
|
|
722
|
+
}
|
|
723
|
+
function programIncludes(context, value) {
|
|
724
|
+
return context.identity.programName?.toLowerCase().includes(value) ?? false;
|
|
725
|
+
}
|
|
726
|
+
var expertSdr14Dialect = new StandardTciDialect({
|
|
727
|
+
id: "expertsdr-1.4",
|
|
728
|
+
label: "ExpertSDR / TCI 1.4",
|
|
729
|
+
streamLengthSemantics: "per-channel",
|
|
730
|
+
supportsStreamChannels: false,
|
|
731
|
+
supportsTxAudioSource: false,
|
|
732
|
+
driveHasTrx: false,
|
|
733
|
+
detect: (context) => {
|
|
734
|
+
const parsed = version(context);
|
|
735
|
+
if (!parsed || compareTciVersion(parsed, [1, 4]) > 0) return { score: 0, evidence: [] };
|
|
736
|
+
return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} <= 1.4`] };
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
var expertSdrLegacyDialect = new StandardTciDialect({
|
|
740
|
+
id: "expertsdr-1.5-1.8",
|
|
741
|
+
label: "ExpertSDR / TCI 1.5-1.8",
|
|
742
|
+
streamLengthSemantics: "per-channel",
|
|
743
|
+
supportsStreamChannels: false,
|
|
744
|
+
supportsTxAudioSource: false,
|
|
745
|
+
driveHasTrx: true,
|
|
746
|
+
detect: (context) => {
|
|
747
|
+
const parsed = version(context);
|
|
748
|
+
if (!parsed || compareTciVersion(parsed, [1, 5]) < 0 || compareTciVersion(parsed, [1, 9]) >= 0) return { score: 0, evidence: [] };
|
|
749
|
+
return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} is in 1.5-1.8`] };
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
var expertSdrModernDialect = new StandardTciDialect({
|
|
753
|
+
id: "expertsdr-1.9-2.0",
|
|
754
|
+
label: "ExpertSDR / TCI 1.9-2.0",
|
|
755
|
+
streamLengthSemantics: "scalar",
|
|
756
|
+
supportsStreamChannels: true,
|
|
757
|
+
supportsTxAudioSource: true,
|
|
758
|
+
driveHasTrx: true,
|
|
759
|
+
detect: (context) => {
|
|
760
|
+
const parsed = version(context);
|
|
761
|
+
if (!parsed || compareTciVersion(parsed, [1, 9]) < 0) return { score: 0, evidence: [] };
|
|
762
|
+
const future = compareTciVersion(parsed, [2, 0]) > 0;
|
|
763
|
+
return {
|
|
764
|
+
score: 70 + (programIncludes(context, "expert") ? 15 : 0),
|
|
765
|
+
evidence: [`protocol ${formatVersion(parsed)} uses modern stream negotiation`],
|
|
766
|
+
warnings: future ? [`Unknown future TCI version ${formatVersion(parsed)}; using the modern dialect`] : []
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
var thetisDialect = new StandardTciDialect({
|
|
771
|
+
id: "thetis-2.0",
|
|
772
|
+
label: "Thetis / TCI 2.0",
|
|
773
|
+
streamLengthSemantics: "scalar",
|
|
774
|
+
supportsStreamChannels: true,
|
|
775
|
+
supportsTxAudioSource: true,
|
|
776
|
+
driveHasTrx: true,
|
|
777
|
+
detect: (context) => {
|
|
778
|
+
const evidence = [];
|
|
779
|
+
let score = 0;
|
|
780
|
+
if (programIncludes(context, "thetis")) {
|
|
781
|
+
score += 120;
|
|
782
|
+
evidence.push("PROTOCOL program is Thetis");
|
|
783
|
+
}
|
|
784
|
+
const observed = ["tx_frequency_ex", "tx_profiles_ex", "tx_profile_ex", "calibration_ex"].filter((name) => context.commandNames.has(name));
|
|
785
|
+
if (observed.length > 0) {
|
|
786
|
+
score += 100;
|
|
787
|
+
evidence.push(`Thetis extension commands: ${observed.join(", ")}`);
|
|
788
|
+
}
|
|
789
|
+
if (/anan|hermes|orion|saturn/i.test(context.identity.device ?? "")) {
|
|
790
|
+
score += 30;
|
|
791
|
+
evidence.push(`Thetis-family device: ${context.identity.device}`);
|
|
792
|
+
}
|
|
793
|
+
return { score, evidence };
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
var aetherSdrDialect = new StandardTciDialect({
|
|
797
|
+
id: "aethersdr-1.5",
|
|
798
|
+
label: "AetherSDR / TCI 1.5 hybrid",
|
|
799
|
+
streamLengthSemantics: "scalar",
|
|
800
|
+
supportsStreamChannels: true,
|
|
801
|
+
supportsTxAudioSource: true,
|
|
802
|
+
driveHasTrx: true,
|
|
803
|
+
detect: (context) => {
|
|
804
|
+
if (!/^aethersdr$/i.test(context.identity.device ?? "")) return { score: 0, evidence: [] };
|
|
805
|
+
const evidence = [`AetherSDR device identity: ${context.identity.device}`];
|
|
806
|
+
const modernAudioCommands = ["audio_stream_sample_type", "audio_stream_channels", "audio_stream_samples"].filter((name) => context.commandNames.has(name));
|
|
807
|
+
if (modernAudioCommands.length > 0) evidence.push(`Modern audio negotiation: ${modernAudioCommands.join(", ")}`);
|
|
808
|
+
return {
|
|
809
|
+
score: 150,
|
|
810
|
+
evidence,
|
|
811
|
+
warnings: context.identity.protocolVersion === "1.5" ? ["AetherSDR reports TCI 1.5 but uses modern scalar audio stream semantics"] : []
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
var genericObservedDialect = new StandardTciDialect({
|
|
816
|
+
id: "generic-observed",
|
|
817
|
+
label: "Generic observed TCI",
|
|
818
|
+
streamLengthSemantics: "auto",
|
|
819
|
+
supportsStreamChannels: true,
|
|
820
|
+
supportsTxAudioSource: true,
|
|
821
|
+
driveHasTrx: true,
|
|
822
|
+
detect: (context) => ({
|
|
823
|
+
score: context.commandNames.has("ready") ? 10 : 0,
|
|
824
|
+
evidence: ["No vendor-specific match; using observed command shapes"],
|
|
825
|
+
warnings: ["Dialect identity is uncertain"]
|
|
826
|
+
}),
|
|
827
|
+
resolve: (context) => {
|
|
828
|
+
const drive = [...context.commands].reverse().find((command) => command.name === "drive");
|
|
829
|
+
const driveHasTrx = (drive?.args.length ?? 0) >= 2;
|
|
830
|
+
return new StandardTciDialect({
|
|
831
|
+
id: "generic-observed",
|
|
832
|
+
label: "Generic observed TCI",
|
|
833
|
+
streamLengthSemantics: "auto",
|
|
834
|
+
supportsStreamChannels: context.commandNames.has("audio_stream_channels"),
|
|
835
|
+
supportsTxAudioSource: compareTciVersion(version(context) ?? [0], [2, 0]) >= 0,
|
|
836
|
+
driveHasTrx,
|
|
837
|
+
detect: genericObservedDialect.detect.bind(genericObservedDialect)
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
var builtInDialects = [
|
|
842
|
+
aetherSdrDialect,
|
|
843
|
+
thetisDialect,
|
|
844
|
+
expertSdr14Dialect,
|
|
845
|
+
expertSdrLegacyDialect,
|
|
846
|
+
expertSdrModernDialect,
|
|
847
|
+
genericObservedDialect
|
|
848
|
+
];
|
|
849
|
+
function parseTciVersion(value) {
|
|
850
|
+
if (!value) return void 0;
|
|
851
|
+
const match = value.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
852
|
+
if (!match) return void 0;
|
|
853
|
+
return match.slice(1).filter((part) => part !== void 0).map(Number);
|
|
854
|
+
}
|
|
855
|
+
function compareTciVersion(left, right) {
|
|
856
|
+
const length = Math.max(left.length, right.length);
|
|
857
|
+
for (let index = 0; index < length; index += 1) {
|
|
858
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
859
|
+
if (difference !== 0) return difference;
|
|
860
|
+
}
|
|
861
|
+
return 0;
|
|
862
|
+
}
|
|
863
|
+
function formatVersion(value) {
|
|
864
|
+
return value.join(".");
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/dialect/registry.ts
|
|
868
|
+
var TciDialectRegistry = class {
|
|
869
|
+
dialects = /* @__PURE__ */ new Map();
|
|
870
|
+
constructor(dialects = builtInDialects) {
|
|
871
|
+
for (const dialect of dialects) this.register(dialect);
|
|
872
|
+
}
|
|
873
|
+
register(dialect) {
|
|
874
|
+
this.dialects.set(dialect.id, dialect);
|
|
875
|
+
}
|
|
876
|
+
get(id) {
|
|
877
|
+
return this.dialects.get(id);
|
|
878
|
+
}
|
|
879
|
+
list() {
|
|
880
|
+
return [...this.dialects.values()];
|
|
881
|
+
}
|
|
882
|
+
select(context, selection = "auto") {
|
|
883
|
+
if (typeof selection === "object") {
|
|
884
|
+
return { dialect: selection, confidence: "manual", evidence: ["Custom dialect supplied by caller"], warnings: [] };
|
|
885
|
+
}
|
|
886
|
+
if (selection !== "auto") {
|
|
887
|
+
const dialect = this.get(selection);
|
|
888
|
+
if (!dialect) throw new TciError("unknown-dialect", `Unknown TCI dialect: ${selection}`);
|
|
889
|
+
return { dialect: dialect.resolve?.(context) ?? dialect, confidence: "manual", evidence: [`Dialect ${selection} selected by caller`], warnings: [] };
|
|
890
|
+
}
|
|
891
|
+
const candidates = this.list().map((dialect) => ({ dialect, result: dialect.detect(context) })).sort((left, right) => right.result.score - left.result.score);
|
|
892
|
+
const selected = candidates[0];
|
|
893
|
+
if (!selected || selected.result.score <= 0) {
|
|
894
|
+
throw new TciError("unknown-dialect", "Unable to identify the TCI server dialect");
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
dialect: selected.dialect.resolve?.(context) ?? selected.dialect,
|
|
898
|
+
confidence: selected.result.score >= 100 ? "high" : selected.result.score >= 70 ? "medium" : "low",
|
|
899
|
+
evidence: selected.result.evidence,
|
|
900
|
+
warnings: selected.result.warnings ?? []
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
var defaultTciDialectRegistry = new TciDialectRegistry();
|
|
905
|
+
|
|
906
|
+
// src/dialect/handshake.ts
|
|
907
|
+
var IDENTITY_COMMANDS = /* @__PURE__ */ new Set(["protocol", "device", "trx_count", "channels_count", "channel_count"]);
|
|
908
|
+
var STATE_COMMANDS = /* @__PURE__ */ new Set(["vfo", "modulation", "modulations_list", "trx", "drive"]);
|
|
909
|
+
function parseProtocolIdentity(commands) {
|
|
910
|
+
const protocol = [...commands].reverse().find((command) => command.name === "protocol");
|
|
911
|
+
const device = [...commands].reverse().find((command) => command.name === "device");
|
|
912
|
+
const rawProtocolArgs = protocol?.args ?? [];
|
|
913
|
+
const firstLooksLikeVersion = /^\d+(?:\.\d+){0,2}/.test(rawProtocolArgs[0] ?? "");
|
|
914
|
+
return {
|
|
915
|
+
programName: firstLooksLikeVersion ? void 0 : rawProtocolArgs[0],
|
|
916
|
+
protocolVersion: firstLooksLikeVersion ? rawProtocolArgs[0] : rawProtocolArgs[1],
|
|
917
|
+
rawProtocolArgs: [...rawProtocolArgs],
|
|
918
|
+
device: device?.args.join(",")
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function assertValidTciHandshake(commands) {
|
|
922
|
+
const names = new Set(commands.map((command) => command.name));
|
|
923
|
+
if (!names.has("ready")) throw new TciError("handshake-timeout", "TCI READY was not received");
|
|
924
|
+
const categories = [
|
|
925
|
+
[...IDENTITY_COMMANDS].some((name) => names.has(name)),
|
|
926
|
+
[...STATE_COMMANDS].some((name) => names.has(name))
|
|
927
|
+
].filter(Boolean).length;
|
|
928
|
+
const identitySignals = [...IDENTITY_COMMANDS].filter((name) => names.has(name)).length;
|
|
929
|
+
if (categories < 2 && identitySignals < 2) {
|
|
930
|
+
throw new TciError("invalid-handshake", "WebSocket opened but did not provide enough TCI initialization evidence");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/transport/WebSocketTransport.ts
|
|
935
|
+
var import_eventemitter3 = require("eventemitter3");
|
|
936
|
+
var import_ws = __toESM(require("ws"), 1);
|
|
937
|
+
var WebSocketTciTransport = class extends import_eventemitter3.EventEmitter {
|
|
938
|
+
constructor(url, WebSocketImpl = import_ws.default) {
|
|
939
|
+
super();
|
|
940
|
+
this.url = url;
|
|
941
|
+
this.WebSocketImpl = WebSocketImpl;
|
|
942
|
+
}
|
|
943
|
+
url;
|
|
944
|
+
WebSocketImpl;
|
|
945
|
+
socket;
|
|
946
|
+
async connect(timeoutMs) {
|
|
947
|
+
if (this.socket?.readyState === import_ws.default.OPEN) return;
|
|
948
|
+
const socket = new this.WebSocketImpl(this.url);
|
|
949
|
+
this.socket = socket;
|
|
950
|
+
await new Promise((resolve, reject) => {
|
|
951
|
+
const timer = setTimeout(() => {
|
|
952
|
+
cleanup();
|
|
953
|
+
this.terminate();
|
|
954
|
+
reject(new TciError("connect-timeout", `Timed out connecting to ${this.url}`));
|
|
955
|
+
}, timeoutMs);
|
|
956
|
+
const cleanup = () => {
|
|
957
|
+
clearTimeout(timer);
|
|
958
|
+
socket.off("open", onOpen);
|
|
959
|
+
socket.off("close", onCloseBeforeOpen);
|
|
960
|
+
socket.off("error", onErrorBeforeOpen);
|
|
961
|
+
};
|
|
962
|
+
const onOpen = () => {
|
|
963
|
+
cleanup();
|
|
964
|
+
this.attach(socket);
|
|
965
|
+
this.emit("connected");
|
|
966
|
+
resolve();
|
|
967
|
+
};
|
|
968
|
+
const onCloseBeforeOpen = () => {
|
|
969
|
+
cleanup();
|
|
970
|
+
reject(new TciError("disconnected", `Disconnected while connecting to ${this.url}`));
|
|
971
|
+
};
|
|
972
|
+
const onErrorBeforeOpen = (error) => {
|
|
973
|
+
cleanup();
|
|
974
|
+
reject(toTciError(error, "disconnected"));
|
|
975
|
+
};
|
|
976
|
+
socket.once("open", onOpen);
|
|
977
|
+
socket.once("close", onCloseBeforeOpen);
|
|
978
|
+
socket.once("error", onErrorBeforeOpen);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
async disconnect(code = 1e3, reason = "client disconnect") {
|
|
982
|
+
const socket = this.socket;
|
|
983
|
+
if (!socket || socket.readyState === import_ws.default.CLOSED) return;
|
|
984
|
+
await new Promise((resolve) => {
|
|
985
|
+
const timer = setTimeout(resolve, 1e3);
|
|
986
|
+
timer.unref?.();
|
|
987
|
+
socket.once("close", () => {
|
|
988
|
+
clearTimeout(timer);
|
|
989
|
+
resolve();
|
|
990
|
+
});
|
|
991
|
+
socket.once("error", () => {
|
|
992
|
+
clearTimeout(timer);
|
|
993
|
+
resolve();
|
|
994
|
+
});
|
|
995
|
+
socket.close(code, reason);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
isConnected() {
|
|
999
|
+
return this.socket?.readyState === import_ws.default.OPEN;
|
|
1000
|
+
}
|
|
1001
|
+
async sendText(raw) {
|
|
1002
|
+
await this.send(raw);
|
|
1003
|
+
}
|
|
1004
|
+
async sendBinary(raw) {
|
|
1005
|
+
await this.send(raw, true);
|
|
1006
|
+
}
|
|
1007
|
+
terminate() {
|
|
1008
|
+
try {
|
|
1009
|
+
this.socket?.terminate();
|
|
1010
|
+
} catch {
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
attach(socket) {
|
|
1014
|
+
socket.on("message", (data, isBinary) => {
|
|
1015
|
+
try {
|
|
1016
|
+
const buffer = dataToBuffer(data);
|
|
1017
|
+
if (isBinary) this.emit("binary", buffer);
|
|
1018
|
+
else this.emit("text", buffer.toString("utf8"));
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
socket.on("close", (code, reason) => {
|
|
1024
|
+
if (this.socket === socket) this.socket = void 0;
|
|
1025
|
+
this.emit("disconnected", { code, reason: reason.toString("utf8") });
|
|
1026
|
+
});
|
|
1027
|
+
socket.on("error", (error) => this.emit("error", error));
|
|
1028
|
+
}
|
|
1029
|
+
async send(data, binary = false) {
|
|
1030
|
+
const socket = this.socket;
|
|
1031
|
+
if (!socket || socket.readyState !== import_ws.default.OPEN) {
|
|
1032
|
+
throw new TciError("not-connected", "TCI socket is not connected");
|
|
1033
|
+
}
|
|
1034
|
+
await new Promise((resolve, reject) => {
|
|
1035
|
+
socket.send(data, { binary }, (error) => error ? reject(error) : resolve());
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
function dataToBuffer(data) {
|
|
1040
|
+
if (Buffer.isBuffer(data)) return data;
|
|
1041
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
1042
|
+
if (Array.isArray(data)) return Buffer.concat(data.map((item) => dataToBuffer(item)));
|
|
1043
|
+
throw new TciError("protocol-error", "Unsupported WebSocket data type");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
625
1046
|
// src/client/TciClient.ts
|
|
626
|
-
var TciClient = class extends
|
|
1047
|
+
var TciClient = class extends import_eventemitter32.EventEmitter {
|
|
627
1048
|
options;
|
|
628
1049
|
WebSocketImpl;
|
|
629
|
-
|
|
1050
|
+
transportFactory;
|
|
1051
|
+
transport;
|
|
630
1052
|
queue;
|
|
631
1053
|
state;
|
|
1054
|
+
stateReducers;
|
|
1055
|
+
dialectRegistry;
|
|
1056
|
+
activeDialect;
|
|
1057
|
+
handshakeResult;
|
|
1058
|
+
handshakeError;
|
|
1059
|
+
initializationCommands = [];
|
|
1060
|
+
handshakeWaiter;
|
|
632
1061
|
constructor(options) {
|
|
633
1062
|
super();
|
|
634
1063
|
this.options = {
|
|
@@ -637,13 +1066,17 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
637
1066
|
trx: options.trx ?? 0,
|
|
638
1067
|
vfo: options.vfo ?? 0,
|
|
639
1068
|
connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
|
|
1069
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
|
|
640
1070
|
commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
|
|
641
1071
|
writeAckMode: options.writeAckMode ?? "state",
|
|
642
1072
|
writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
|
|
643
1073
|
writeSettleMs: options.writeSettleMs ?? 0,
|
|
644
|
-
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
|
|
1074
|
+
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
|
|
1075
|
+
dialect: options.dialect ?? "auto"
|
|
645
1076
|
};
|
|
646
|
-
this.
|
|
1077
|
+
this.dialectRegistry = options.dialectRegistry ?? defaultTciDialectRegistry;
|
|
1078
|
+
this.WebSocketImpl = options.WebSocketImpl ?? import_ws2.default;
|
|
1079
|
+
this.transportFactory = options.transportFactory ?? ((url) => new WebSocketTciTransport(url, this.WebSocketImpl));
|
|
647
1080
|
this.queue = new TciCommandQueue({
|
|
648
1081
|
timeoutMs: this.options.commandTimeoutMs,
|
|
649
1082
|
send: (raw) => this.sendRaw(raw)
|
|
@@ -659,58 +1092,49 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
659
1092
|
pttSource: {},
|
|
660
1093
|
tune: {},
|
|
661
1094
|
drive: {},
|
|
1095
|
+
tuneDrive: {},
|
|
662
1096
|
split: {},
|
|
1097
|
+
dialectWarnings: [],
|
|
663
1098
|
rxSensors: {},
|
|
664
1099
|
txSensors: {}
|
|
665
1100
|
};
|
|
1101
|
+
this.stateReducers = this.createStateReducers();
|
|
666
1102
|
}
|
|
667
1103
|
async connect() {
|
|
668
|
-
if (this.
|
|
669
|
-
return;
|
|
1104
|
+
if (this.transport?.isConnected()) {
|
|
1105
|
+
return this.handshakeResult ?? this.waitForHandshake();
|
|
670
1106
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1107
|
+
this.resetHandshake();
|
|
1108
|
+
const transport = this.transportFactory(this.options.url);
|
|
1109
|
+
this.transport = transport;
|
|
1110
|
+
this.attachTransport(transport);
|
|
1111
|
+
await transport.connect(this.options.connectTimeoutMs);
|
|
1112
|
+
this.state.connected = true;
|
|
1113
|
+
this.queue.setConnected(true);
|
|
1114
|
+
this.emit("connected");
|
|
1115
|
+
this.emitState();
|
|
1116
|
+
try {
|
|
1117
|
+
return await this.waitForHandshake();
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
transport.terminate();
|
|
1120
|
+
throw error;
|
|
674
1121
|
}
|
|
675
|
-
const ws = new this.WebSocketImpl(this.options.url);
|
|
676
|
-
this.ws = ws;
|
|
677
|
-
await this.waitForOpen(ws);
|
|
678
1122
|
}
|
|
679
1123
|
async disconnect(code = 1e3, reason = "client disconnect") {
|
|
680
|
-
const
|
|
681
|
-
if (!
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
if (ws.readyState === import_ws.default.CLOSED) {
|
|
685
|
-
this.handleClose();
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
await new Promise((resolve) => {
|
|
689
|
-
const cleanup = () => {
|
|
690
|
-
ws.off("close", onClose);
|
|
691
|
-
ws.off("error", onError);
|
|
692
|
-
};
|
|
693
|
-
const onClose = () => {
|
|
694
|
-
cleanup();
|
|
695
|
-
resolve();
|
|
696
|
-
};
|
|
697
|
-
const onError = () => {
|
|
698
|
-
cleanup();
|
|
699
|
-
resolve();
|
|
700
|
-
};
|
|
701
|
-
ws.once("close", onClose);
|
|
702
|
-
ws.once("error", onError);
|
|
703
|
-
ws.close(code, reason);
|
|
704
|
-
setTimeout(() => resolve(), 1e3).unref?.();
|
|
705
|
-
});
|
|
1124
|
+
const transport = this.transport;
|
|
1125
|
+
if (!transport) return;
|
|
1126
|
+
await transport.disconnect(code, reason);
|
|
706
1127
|
this.handleClose();
|
|
707
1128
|
}
|
|
708
1129
|
isConnected() {
|
|
709
|
-
return this.
|
|
1130
|
+
return this.transport?.isConnected() ?? false;
|
|
710
1131
|
}
|
|
711
1132
|
getState() {
|
|
712
1133
|
return cloneState(this.state);
|
|
713
1134
|
}
|
|
1135
|
+
getHandshakeResult() {
|
|
1136
|
+
return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
|
|
1137
|
+
}
|
|
714
1138
|
async sendCommand(name, args = [], options = {}) {
|
|
715
1139
|
const raw = formatTciCommand(name, args);
|
|
716
1140
|
if (options.waitForReply === false) {
|
|
@@ -784,6 +1208,9 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
784
1208
|
}
|
|
785
1209
|
async setPtt(enabled, options = {}) {
|
|
786
1210
|
const trx = options.trx ?? this.options.trx;
|
|
1211
|
+
if (options.source && !this.requireDialect().supportsTxAudioSource) {
|
|
1212
|
+
throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support a TRX audio source argument`);
|
|
1213
|
+
}
|
|
787
1214
|
const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
|
|
788
1215
|
await this.sendStateWrite(
|
|
789
1216
|
"TRX",
|
|
@@ -797,16 +1224,107 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
797
1224
|
const reply = await this.request("TRX", [trx]);
|
|
798
1225
|
return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
|
|
799
1226
|
}
|
|
800
|
-
async setTune(enabled, trx = this.options.trx) {
|
|
801
|
-
|
|
1227
|
+
async setTune(enabled, trx = this.options.trx, options = {}) {
|
|
1228
|
+
try {
|
|
1229
|
+
await this.sendStateWrite(
|
|
1230
|
+
"TUNE",
|
|
1231
|
+
[trx, enabled],
|
|
1232
|
+
(state) => state.tune[String(trx)] === enabled,
|
|
1233
|
+
`TUNE:${trx},${enabled}`,
|
|
1234
|
+
options
|
|
1235
|
+
);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1238
|
+
await this.request("TUNE", [trx], { timeoutMs: options.timeoutMs });
|
|
1239
|
+
if (this.state.tune[String(trx)] !== enabled) throw error;
|
|
1240
|
+
}
|
|
802
1241
|
}
|
|
803
1242
|
async setDrive(value, trx = this.options.trx) {
|
|
804
|
-
await this.
|
|
1243
|
+
await this.setDriveWithResult(value, trx);
|
|
1244
|
+
}
|
|
1245
|
+
async setDriveWithResult(value, trx = this.options.trx, options = {}) {
|
|
1246
|
+
const requested = normalizePercent(value);
|
|
1247
|
+
const dialect = this.requireDialect();
|
|
1248
|
+
if (this.state.drive[String(trx)] === requested) {
|
|
1249
|
+
return writeResult(requested, requested, "state");
|
|
1250
|
+
}
|
|
1251
|
+
const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
|
|
1252
|
+
const waiter = this.waitForCommand(
|
|
1253
|
+
(command) => command.name === "drive" && dialect.parseDrive(command.args, trx)?.trx === trx,
|
|
1254
|
+
Math.min(500, timeoutMs),
|
|
1255
|
+
`DRIVE state for TRX ${trx}`
|
|
1256
|
+
);
|
|
1257
|
+
await this.sendCommand("DRIVE", dialect.buildDriveSetArgs(trx, requested), { waitForReply: false });
|
|
1258
|
+
try {
|
|
1259
|
+
const command = await waiter.promise;
|
|
1260
|
+
const applied2 = dialect.parseDrive(command.args, trx)?.value;
|
|
1261
|
+
if (applied2 !== void 0) return writeResult(requested, applied2, "state");
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1264
|
+
} finally {
|
|
1265
|
+
waiter.cancel();
|
|
1266
|
+
}
|
|
1267
|
+
const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx), {
|
|
1268
|
+
timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
|
|
1269
|
+
});
|
|
1270
|
+
const applied = dialect.parseDrive(reply.args, trx)?.value;
|
|
1271
|
+
if (applied === void 0) throw new TciError("protocol-error", `Invalid DRIVE readback: ${reply.raw}`);
|
|
1272
|
+
return writeResult(requested, applied, "readback");
|
|
1273
|
+
}
|
|
1274
|
+
async getDrive(trx = this.options.trx) {
|
|
1275
|
+
const dialect = this.requireDialect();
|
|
1276
|
+
const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx));
|
|
1277
|
+
return dialect.parseDrive(reply.args, trx)?.value ?? this.state.drive[String(trx)];
|
|
1278
|
+
}
|
|
1279
|
+
async setTuneDrive(value, trx = this.options.trx, options = {}) {
|
|
1280
|
+
const requested = normalizePercent(value);
|
|
1281
|
+
const dialect = this.requireDialect();
|
|
1282
|
+
if (this.state.tuneDrive[String(trx)] === requested) return writeResult(requested, requested, "state");
|
|
1283
|
+
const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
|
|
1284
|
+
const waiter = this.waitForCommand(
|
|
1285
|
+
(command) => command.name === "tune_drive" && dialect.parseTuneDrive(command.args, trx)?.trx === trx,
|
|
1286
|
+
Math.min(500, timeoutMs),
|
|
1287
|
+
`TUNE_DRIVE state for TRX ${trx}`
|
|
1288
|
+
);
|
|
1289
|
+
await this.sendCommand("TUNE_DRIVE", dialect.buildTuneDriveSetArgs(trx, requested), { waitForReply: false });
|
|
1290
|
+
try {
|
|
1291
|
+
const command = await waiter.promise;
|
|
1292
|
+
const applied2 = dialect.parseTuneDrive(command.args, trx)?.value;
|
|
1293
|
+
if (applied2 !== void 0) return writeResult(requested, applied2, "state");
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1296
|
+
} finally {
|
|
1297
|
+
waiter.cancel();
|
|
1298
|
+
}
|
|
1299
|
+
const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx), {
|
|
1300
|
+
timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
|
|
1301
|
+
});
|
|
1302
|
+
const applied = dialect.parseTuneDrive(reply.args, trx)?.value;
|
|
1303
|
+
if (applied === void 0) throw new TciError("protocol-error", `Invalid TUNE_DRIVE readback: ${reply.raw}`);
|
|
1304
|
+
return writeResult(requested, applied, "readback");
|
|
805
1305
|
}
|
|
806
|
-
async
|
|
807
|
-
|
|
1306
|
+
async getTuneDrive(trx = this.options.trx) {
|
|
1307
|
+
const dialect = this.requireDialect();
|
|
1308
|
+
const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx));
|
|
1309
|
+
return dialect.parseTuneDrive(reply.args, trx)?.value ?? this.state.tuneDrive[String(trx)];
|
|
1310
|
+
}
|
|
1311
|
+
async setSplit(enabled, trx = this.options.trx, options = {}) {
|
|
1312
|
+
try {
|
|
1313
|
+
await this.sendStateWrite(
|
|
1314
|
+
"SPLIT_ENABLE",
|
|
1315
|
+
[trx, enabled],
|
|
1316
|
+
(state) => state.split[String(trx)] === enabled,
|
|
1317
|
+
`SPLIT_ENABLE:${trx},${enabled}`,
|
|
1318
|
+
options
|
|
1319
|
+
);
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
|
|
1322
|
+
await this.request("SPLIT_ENABLE", [trx], { timeoutMs: options.timeoutMs });
|
|
1323
|
+
if (this.state.split[String(trx)] !== enabled) throw error;
|
|
1324
|
+
}
|
|
808
1325
|
}
|
|
809
1326
|
async configureAudio(config) {
|
|
1327
|
+
const dialect = this.requireDialect();
|
|
810
1328
|
const audio = {
|
|
811
1329
|
sampleRate: config.sampleRate,
|
|
812
1330
|
sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
|
|
@@ -817,11 +1335,13 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
817
1335
|
};
|
|
818
1336
|
this.state.audio = audio;
|
|
819
1337
|
await this.sendCommand("AUDIO_SAMPLERATE", [audio.sampleRate], { waitForReply: false });
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1338
|
+
if (dialect.supportsStreamChannels) {
|
|
1339
|
+
await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
|
|
1340
|
+
await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
|
|
1341
|
+
await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
|
|
1342
|
+
if (audio.txBufferingMs !== void 0) {
|
|
1343
|
+
await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
|
|
1344
|
+
}
|
|
825
1345
|
}
|
|
826
1346
|
this.emitState();
|
|
827
1347
|
}
|
|
@@ -840,12 +1360,16 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
840
1360
|
}
|
|
841
1361
|
}
|
|
842
1362
|
sendTxAudio(options) {
|
|
843
|
-
const frame = buildTxAudioFrame({
|
|
1363
|
+
const frame = buildTxAudioFrame({
|
|
1364
|
+
receiver: this.options.receiver,
|
|
1365
|
+
lengthSemantics: this.requireDialect().streamLengthSemantics,
|
|
1366
|
+
...options
|
|
1367
|
+
});
|
|
844
1368
|
this.sendRawBinary(frame);
|
|
845
1369
|
}
|
|
846
1370
|
sendTxAudioForChrono(request, samples) {
|
|
847
1371
|
const channels = Math.max(1, Math.floor(request.channels || 1));
|
|
848
|
-
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount)
|
|
1372
|
+
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
|
|
849
1373
|
const output = new Float32Array(targetSampleLength);
|
|
850
1374
|
const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
|
|
851
1375
|
output.set(source.subarray(0, output.length));
|
|
@@ -943,78 +1467,147 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
943
1467
|
cancel: cleanup
|
|
944
1468
|
};
|
|
945
1469
|
}
|
|
946
|
-
|
|
1470
|
+
waitForCommand(predicate, timeoutMs, description) {
|
|
1471
|
+
let timer;
|
|
1472
|
+
let resolvePromise;
|
|
1473
|
+
let rejectPromise;
|
|
1474
|
+
const cleanup = () => {
|
|
1475
|
+
if (timer) clearTimeout(timer);
|
|
1476
|
+
timer = void 0;
|
|
1477
|
+
this.off("command", onCommand);
|
|
1478
|
+
this.off("disconnected", onDisconnected);
|
|
1479
|
+
};
|
|
1480
|
+
const onCommand = (command) => {
|
|
1481
|
+
if (!predicate(command)) return;
|
|
1482
|
+
cleanup();
|
|
1483
|
+
resolvePromise(command);
|
|
1484
|
+
};
|
|
1485
|
+
const onDisconnected = () => {
|
|
1486
|
+
cleanup();
|
|
1487
|
+
rejectPromise(new TciError("disconnected", `Disconnected while waiting for ${description}`));
|
|
1488
|
+
};
|
|
1489
|
+
const promise = new Promise((resolve, reject) => {
|
|
1490
|
+
resolvePromise = resolve;
|
|
1491
|
+
rejectPromise = reject;
|
|
1492
|
+
timer = setTimeout(() => {
|
|
1493
|
+
cleanup();
|
|
1494
|
+
reject(new TciError("command-timeout", `Timed out waiting for ${description}`));
|
|
1495
|
+
}, timeoutMs);
|
|
1496
|
+
this.on("command", onCommand);
|
|
1497
|
+
this.on("disconnected", onDisconnected);
|
|
1498
|
+
});
|
|
1499
|
+
return { promise, cancel: cleanup };
|
|
1500
|
+
}
|
|
1501
|
+
resetHandshake() {
|
|
1502
|
+
this.rejectHandshake(new TciError("cancelled", "TCI handshake replaced by a new connection"));
|
|
1503
|
+
this.handshakeResult = void 0;
|
|
1504
|
+
this.handshakeError = void 0;
|
|
1505
|
+
this.activeDialect = void 0;
|
|
1506
|
+
this.initializationCommands = [];
|
|
1507
|
+
this.state.ready = false;
|
|
1508
|
+
this.state.protocol = void 0;
|
|
1509
|
+
this.state.protocolName = void 0;
|
|
1510
|
+
this.state.protocolVersion = void 0;
|
|
1511
|
+
this.state.dialectId = void 0;
|
|
1512
|
+
this.state.dialectConfidence = void 0;
|
|
1513
|
+
this.state.dialectWarnings = [];
|
|
1514
|
+
}
|
|
1515
|
+
waitForHandshake() {
|
|
1516
|
+
if (this.handshakeResult) return Promise.resolve(cloneHandshake(this.handshakeResult));
|
|
1517
|
+
if (this.handshakeError) return Promise.reject(this.handshakeError);
|
|
1518
|
+
if (this.handshakeWaiter) {
|
|
1519
|
+
return new Promise((resolve, reject) => {
|
|
1520
|
+
const onHandshake = (result) => {
|
|
1521
|
+
cleanup();
|
|
1522
|
+
resolve(result);
|
|
1523
|
+
};
|
|
1524
|
+
const onError = (error) => {
|
|
1525
|
+
cleanup();
|
|
1526
|
+
reject(error);
|
|
1527
|
+
};
|
|
1528
|
+
const cleanup = () => {
|
|
1529
|
+
this.off("handshake", onHandshake);
|
|
1530
|
+
this.off("error", onError);
|
|
1531
|
+
};
|
|
1532
|
+
this.once("handshake", onHandshake);
|
|
1533
|
+
this.once("error", onError);
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
947
1536
|
return new Promise((resolve, reject) => {
|
|
948
1537
|
const timer = setTimeout(() => {
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
reject(new TciError("connect-timeout", `Timed out connecting to ${this.options.url}`));
|
|
955
|
-
}, this.options.connectTimeoutMs);
|
|
956
|
-
const cleanup = () => {
|
|
957
|
-
clearTimeout(timer);
|
|
958
|
-
ws.off("open", onOpen);
|
|
959
|
-
ws.off("close", onClose);
|
|
960
|
-
ws.off("error", onError);
|
|
961
|
-
};
|
|
962
|
-
const onOpen = () => {
|
|
963
|
-
cleanup();
|
|
964
|
-
this.attachSocket(ws);
|
|
965
|
-
this.state.connected = true;
|
|
966
|
-
this.queue.setConnected(true);
|
|
967
|
-
this.emit("connected");
|
|
968
|
-
this.emitState();
|
|
969
|
-
resolve();
|
|
970
|
-
};
|
|
971
|
-
const onClose = () => {
|
|
972
|
-
cleanup();
|
|
973
|
-
this.handleClose();
|
|
974
|
-
reject(new TciError("disconnected", `Disconnected while connecting to ${this.options.url}`));
|
|
975
|
-
};
|
|
976
|
-
const onError = (error) => {
|
|
977
|
-
cleanup();
|
|
978
|
-
this.handleError(error);
|
|
979
|
-
reject(toTciError(error, "disconnected"));
|
|
980
|
-
};
|
|
981
|
-
ws.once("open", onOpen);
|
|
982
|
-
ws.once("close", onClose);
|
|
983
|
-
ws.once("error", onError);
|
|
1538
|
+
const error = new TciError("handshake-timeout", `Timed out waiting for TCI READY from ${this.options.url}`);
|
|
1539
|
+
this.handshakeWaiter = void 0;
|
|
1540
|
+
reject(error);
|
|
1541
|
+
}, this.options.handshakeTimeoutMs);
|
|
1542
|
+
this.handshakeWaiter = { resolve, reject, timer };
|
|
984
1543
|
});
|
|
985
1544
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1545
|
+
finalizeHandshake() {
|
|
1546
|
+
if (this.handshakeResult) return this.handshakeResult;
|
|
1547
|
+
assertValidTciHandshake(this.initializationCommands);
|
|
1548
|
+
const identity = parseProtocolIdentity(this.initializationCommands);
|
|
1549
|
+
const commandNames = [...new Set(this.initializationCommands.map((command) => command.name))];
|
|
1550
|
+
const dialect = this.dialectRegistry.select(
|
|
1551
|
+
{ identity, commands: this.initializationCommands, commandNames: new Set(commandNames) },
|
|
1552
|
+
this.options.dialect
|
|
1553
|
+
);
|
|
1554
|
+
const result = { identity, dialect, ready: true, commandNames };
|
|
1555
|
+
this.handshakeResult = result;
|
|
1556
|
+
this.activeDialect = dialect.dialect;
|
|
1557
|
+
this.state.protocolName = identity.programName;
|
|
1558
|
+
this.state.protocolVersion = identity.protocolVersion;
|
|
1559
|
+
this.state.protocol = identity.protocolVersion ?? identity.programName;
|
|
1560
|
+
this.state.device = identity.device ?? this.state.device;
|
|
1561
|
+
this.state.dialectId = dialect.dialect.id;
|
|
1562
|
+
this.state.dialectConfidence = dialect.confidence;
|
|
1563
|
+
this.state.dialectWarnings = [...dialect.warnings];
|
|
1564
|
+
const waiter = this.handshakeWaiter;
|
|
1565
|
+
this.handshakeWaiter = void 0;
|
|
1566
|
+
if (waiter) {
|
|
1567
|
+
clearTimeout(waiter.timer);
|
|
1568
|
+
waiter.resolve(cloneHandshake(result));
|
|
1569
|
+
}
|
|
1570
|
+
this.emit("handshake", cloneHandshake(result));
|
|
1571
|
+
return result;
|
|
1572
|
+
}
|
|
1573
|
+
rejectHandshake(error) {
|
|
1574
|
+
const waiter = this.handshakeWaiter;
|
|
1575
|
+
this.handshakeWaiter = void 0;
|
|
1576
|
+
if (!waiter) return;
|
|
1577
|
+
clearTimeout(waiter.timer);
|
|
1578
|
+
waiter.reject(error);
|
|
1579
|
+
}
|
|
1580
|
+
requireDialect() {
|
|
1581
|
+
if (!this.activeDialect) throw new TciError("invalid-handshake", "TCI dialect is not available before READY");
|
|
1582
|
+
return this.activeDialect;
|
|
1583
|
+
}
|
|
1584
|
+
attachTransport(transport) {
|
|
1585
|
+
transport.on("text", (raw) => this.handleText(raw));
|
|
1586
|
+
transport.on("binary", (raw) => this.handleBinary(raw));
|
|
1587
|
+
transport.on("disconnected", (reason) => this.handleClose(reason));
|
|
1588
|
+
transport.on("error", (error) => this.handleError(error));
|
|
990
1589
|
}
|
|
991
1590
|
async sendRaw(raw) {
|
|
992
|
-
const
|
|
993
|
-
if (!
|
|
1591
|
+
const transport = this.transport;
|
|
1592
|
+
if (!transport?.isConnected()) {
|
|
994
1593
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
995
1594
|
}
|
|
996
1595
|
this.emit("tci:tx", raw);
|
|
997
|
-
await
|
|
998
|
-
ws.send(raw, (error) => error ? reject(error) : resolve());
|
|
999
|
-
});
|
|
1596
|
+
await transport.sendText(raw);
|
|
1000
1597
|
}
|
|
1001
1598
|
sendRawBinary(raw) {
|
|
1002
|
-
const
|
|
1003
|
-
if (!
|
|
1599
|
+
const transport = this.transport;
|
|
1600
|
+
if (!transport?.isConnected()) {
|
|
1004
1601
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
1005
1602
|
}
|
|
1006
|
-
|
|
1603
|
+
void transport.sendBinary(raw).catch((error) => this.handleError(error));
|
|
1007
1604
|
}
|
|
1008
|
-
|
|
1605
|
+
handleText(raw) {
|
|
1009
1606
|
try {
|
|
1010
|
-
if (isBinary) {
|
|
1011
|
-
this.handleBinary(data);
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
const raw = dataToBuffer(data).toString("utf8");
|
|
1015
1607
|
const commands = parseTciText(raw);
|
|
1016
1608
|
this.emit("tci:rx", raw, commands);
|
|
1017
1609
|
for (const command of commands) {
|
|
1610
|
+
if (!this.handshakeResult) this.initializationCommands.push(command);
|
|
1018
1611
|
this.queue.handleCommand(command);
|
|
1019
1612
|
this.applyCommand(command);
|
|
1020
1613
|
this.emit("command", command);
|
|
@@ -1024,7 +1617,10 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1024
1617
|
}
|
|
1025
1618
|
}
|
|
1026
1619
|
handleBinary(data) {
|
|
1027
|
-
const frame = parseStreamFrame(
|
|
1620
|
+
const frame = parseStreamFrame(data, {
|
|
1621
|
+
lengthSemantics: this.requireDialect().streamLengthSemantics,
|
|
1622
|
+
negotiatedChannels: this.state.audio?.channels
|
|
1623
|
+
});
|
|
1028
1624
|
this.emit("tci:binary", frame);
|
|
1029
1625
|
this.emit("binary", frame);
|
|
1030
1626
|
switch (frame.streamType) {
|
|
@@ -1038,7 +1634,8 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1038
1634
|
sampleRate: frame.sampleRate,
|
|
1039
1635
|
channels: frame.channels,
|
|
1040
1636
|
sampleType: frame.sampleType,
|
|
1041
|
-
sampleCount: frame.sampleCount
|
|
1637
|
+
sampleCount: frame.sampleCount,
|
|
1638
|
+
frameCount: frame.frameCount
|
|
1042
1639
|
});
|
|
1043
1640
|
break;
|
|
1044
1641
|
case 4 /* LINEOUT_STREAM */:
|
|
@@ -1050,80 +1647,78 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1050
1647
|
}
|
|
1051
1648
|
applyCommand(command) {
|
|
1052
1649
|
const readyBefore = this.state.ready;
|
|
1053
|
-
|
|
1054
|
-
case "ready":
|
|
1055
|
-
this.state.ready = command.args.length === 0 ? true : parseBoolean(command.args[0]) ?? true;
|
|
1056
|
-
break;
|
|
1057
|
-
case "protocol":
|
|
1058
|
-
this.state.protocol = command.args[0];
|
|
1059
|
-
break;
|
|
1060
|
-
case "device":
|
|
1061
|
-
this.state.device = command.args.join(",");
|
|
1062
|
-
break;
|
|
1063
|
-
case "receive_only":
|
|
1064
|
-
this.state.receiveOnly = parseBoolean(command.args[0]);
|
|
1065
|
-
break;
|
|
1066
|
-
case "trx_count":
|
|
1067
|
-
this.state.trxCount = parseNumber(command.args[0]);
|
|
1068
|
-
break;
|
|
1069
|
-
case "channels_count":
|
|
1070
|
-
case "channel_count":
|
|
1071
|
-
this.state.channelCount = parseNumber(command.args[0]);
|
|
1072
|
-
break;
|
|
1073
|
-
case "vfo_limits":
|
|
1074
|
-
this.state.vfoLimits = parseNumberPair(command.args);
|
|
1075
|
-
break;
|
|
1076
|
-
case "if_limits":
|
|
1077
|
-
this.state.ifLimits = parseNumberPair(command.args);
|
|
1078
|
-
break;
|
|
1079
|
-
case "modulations_list":
|
|
1080
|
-
this.state.modulations = command.args.map((mode) => mode.toLowerCase());
|
|
1081
|
-
break;
|
|
1082
|
-
case "vfo":
|
|
1083
|
-
this.applyVfo(command.args);
|
|
1084
|
-
break;
|
|
1085
|
-
case "modulation":
|
|
1086
|
-
this.applyModulation(command.args);
|
|
1087
|
-
break;
|
|
1088
|
-
case "trx":
|
|
1089
|
-
this.applyTrx(command.args);
|
|
1090
|
-
break;
|
|
1091
|
-
case "tune":
|
|
1092
|
-
this.applyBooleanByFirstArg(this.state.tune, command.args);
|
|
1093
|
-
break;
|
|
1094
|
-
case "drive":
|
|
1095
|
-
this.applyDrive(command.args);
|
|
1096
|
-
break;
|
|
1097
|
-
case "split_enable":
|
|
1098
|
-
this.applyBooleanByFirstArg(this.state.split, command.args);
|
|
1099
|
-
break;
|
|
1100
|
-
case "rx_channel_sensors":
|
|
1101
|
-
this.applyRxChannelSensors(command.args);
|
|
1102
|
-
break;
|
|
1103
|
-
case "rx_sensors":
|
|
1104
|
-
this.applyRxSensors(command.args);
|
|
1105
|
-
break;
|
|
1106
|
-
case "tx_sensors":
|
|
1107
|
-
this.applyTxSensors(command.args);
|
|
1108
|
-
break;
|
|
1109
|
-
case "audio_samplerate":
|
|
1110
|
-
this.state.audio = {
|
|
1111
|
-
sampleRate: parseNumber(command.args[0]) ?? this.state.audio?.sampleRate ?? 12e3,
|
|
1112
|
-
sampleType: this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
|
|
1113
|
-
channels: this.state.audio?.channels ?? 1,
|
|
1114
|
-
samplesPerFrame: this.state.audio?.samplesPerFrame ?? 512,
|
|
1115
|
-
txBufferingMs: this.state.audio?.txBufferingMs,
|
|
1116
|
-
running: this.state.audio?.running ?? false
|
|
1117
|
-
};
|
|
1118
|
-
break;
|
|
1119
|
-
default:
|
|
1120
|
-
break;
|
|
1121
|
-
}
|
|
1650
|
+
this.stateReducers.get(command.name)?.(command.args);
|
|
1122
1651
|
if (!readyBefore && this.state.ready) {
|
|
1123
|
-
|
|
1652
|
+
try {
|
|
1653
|
+
this.finalizeHandshake();
|
|
1654
|
+
this.emit("ready", this.getState());
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
const tciError = toTciError(error, "invalid-handshake");
|
|
1657
|
+
this.handshakeError = tciError;
|
|
1658
|
+
this.state.ready = false;
|
|
1659
|
+
this.rejectHandshake(tciError);
|
|
1660
|
+
this.handleError(tciError);
|
|
1661
|
+
}
|
|
1124
1662
|
}
|
|
1125
1663
|
this.emitState();
|
|
1126
1664
|
}
|
|
1665
|
+
createStateReducers() {
|
|
1666
|
+
const reducers = /* @__PURE__ */ new Map();
|
|
1667
|
+
reducers.set("ready", (args) => {
|
|
1668
|
+
this.state.ready = args.length === 0 ? true : parseBoolean(args[0]) ?? true;
|
|
1669
|
+
});
|
|
1670
|
+
reducers.set("protocol", (args) => {
|
|
1671
|
+
if (/^\d+(?:\.\d+){0,2}/.test(args[0] ?? "")) {
|
|
1672
|
+
this.state.protocol = args[0];
|
|
1673
|
+
this.state.protocolVersion = args[0];
|
|
1674
|
+
} else {
|
|
1675
|
+
this.state.protocolName = args[0];
|
|
1676
|
+
this.state.protocolVersion = args[1];
|
|
1677
|
+
this.state.protocol = args[1] ?? args[0];
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
reducers.set("device", (args) => {
|
|
1681
|
+
this.state.device = args.join(",");
|
|
1682
|
+
});
|
|
1683
|
+
reducers.set("receive_only", (args) => {
|
|
1684
|
+
this.state.receiveOnly = parseBoolean(args[0]);
|
|
1685
|
+
});
|
|
1686
|
+
reducers.set("trx_count", (args) => {
|
|
1687
|
+
this.state.trxCount = parseNumber(args[0]);
|
|
1688
|
+
});
|
|
1689
|
+
const channelCount = (args) => {
|
|
1690
|
+
this.state.channelCount = parseNumber(args[0]);
|
|
1691
|
+
};
|
|
1692
|
+
reducers.set("channels_count", channelCount);
|
|
1693
|
+
reducers.set("channel_count", channelCount);
|
|
1694
|
+
reducers.set("vfo_limits", (args) => {
|
|
1695
|
+
this.state.vfoLimits = parseNumberPair(args);
|
|
1696
|
+
});
|
|
1697
|
+
reducers.set("if_limits", (args) => {
|
|
1698
|
+
this.state.ifLimits = parseNumberPair(args);
|
|
1699
|
+
});
|
|
1700
|
+
reducers.set("modulations_list", (args) => {
|
|
1701
|
+
this.state.modulations = args.map((mode) => mode.toLowerCase());
|
|
1702
|
+
});
|
|
1703
|
+
reducers.set("vfo", (args) => this.applyVfo(args));
|
|
1704
|
+
reducers.set("modulation", (args) => this.applyModulation(args));
|
|
1705
|
+
reducers.set("trx", (args) => this.applyTrx(args));
|
|
1706
|
+
reducers.set("tune", (args) => this.applyBooleanByFirstArg(this.state.tune, args));
|
|
1707
|
+
reducers.set("drive", (args) => this.applyDrive(args));
|
|
1708
|
+
reducers.set("tune_drive", (args) => this.applyTuneDrive(args));
|
|
1709
|
+
reducers.set("split_enable", (args) => this.applyBooleanByFirstArg(this.state.split, args));
|
|
1710
|
+
reducers.set("rx_channel_sensors", (args) => this.applyRxChannelSensors(args));
|
|
1711
|
+
reducers.set("rx_sensors", (args) => this.applyRxSensors(args));
|
|
1712
|
+
reducers.set("tx_sensors", (args) => this.applyTxSensors(args));
|
|
1713
|
+
reducers.set("audio_samplerate", (args) => this.updateAudioState({ sampleRate: parseNumber(args[0]) }));
|
|
1714
|
+
reducers.set("audio_stream_sample_type", (args) => this.updateAudioState({ sampleType: parseSampleType(args[0]) }));
|
|
1715
|
+
reducers.set("audio_stream_channels", (args) => this.updateAudioState({ channels: parseNumber(args[0]) }));
|
|
1716
|
+
reducers.set("audio_stream_samples", (args) => this.updateAudioState({ samplesPerFrame: parseNumber(args[0]) }));
|
|
1717
|
+
reducers.set("tx_stream_audio_buffering", (args) => this.updateAudioState({ txBufferingMs: parseNumber(args[0]) }));
|
|
1718
|
+
reducers.set("audio_start", () => this.updateAudioState({ running: true }));
|
|
1719
|
+
reducers.set("audio_stop", () => this.updateAudioState({ running: false }));
|
|
1720
|
+
return reducers;
|
|
1721
|
+
}
|
|
1127
1722
|
applyVfo(args) {
|
|
1128
1723
|
if (args.length < 3) {
|
|
1129
1724
|
return;
|
|
@@ -1170,18 +1765,22 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1170
1765
|
}
|
|
1171
1766
|
}
|
|
1172
1767
|
applyDrive(args) {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
this.state.
|
|
1184
|
-
|
|
1768
|
+
const parsed = this.activeDialect?.parseDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
|
|
1769
|
+
if (parsed) this.state.drive[String(parsed.trx)] = parsed.value;
|
|
1770
|
+
}
|
|
1771
|
+
applyTuneDrive(args) {
|
|
1772
|
+
const parsed = this.activeDialect?.parseTuneDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
|
|
1773
|
+
if (parsed) this.state.tuneDrive[String(parsed.trx)] = parsed.value;
|
|
1774
|
+
}
|
|
1775
|
+
updateAudioState(update) {
|
|
1776
|
+
this.state.audio = {
|
|
1777
|
+
sampleRate: update.sampleRate ?? this.state.audio?.sampleRate ?? 12e3,
|
|
1778
|
+
sampleType: update.sampleType ?? this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
|
|
1779
|
+
channels: update.channels ?? this.state.audio?.channels ?? 1,
|
|
1780
|
+
samplesPerFrame: update.samplesPerFrame ?? this.state.audio?.samplesPerFrame ?? 512,
|
|
1781
|
+
txBufferingMs: update.txBufferingMs ?? this.state.audio?.txBufferingMs,
|
|
1782
|
+
running: update.running ?? this.state.audio?.running ?? false
|
|
1783
|
+
};
|
|
1185
1784
|
}
|
|
1186
1785
|
applyRxChannelSensors(args) {
|
|
1187
1786
|
if (args.length < 3) {
|
|
@@ -1217,11 +1816,14 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1217
1816
|
};
|
|
1218
1817
|
}
|
|
1219
1818
|
handleClose(reason) {
|
|
1220
|
-
|
|
1819
|
+
const transport = this.transport;
|
|
1820
|
+
this.transport = void 0;
|
|
1821
|
+
transport?.removeAllListeners();
|
|
1221
1822
|
const wasConnected = this.state.connected;
|
|
1222
1823
|
this.state.connected = false;
|
|
1223
1824
|
this.state.ready = false;
|
|
1224
1825
|
this.queue.setConnected(false);
|
|
1826
|
+
this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
|
|
1225
1827
|
if (wasConnected) {
|
|
1226
1828
|
this.emit("disconnected", reason);
|
|
1227
1829
|
this.emitState();
|
|
@@ -1238,18 +1840,6 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1238
1840
|
function createTciClient(options) {
|
|
1239
1841
|
return new TciClient(options);
|
|
1240
1842
|
}
|
|
1241
|
-
function dataToBuffer(data) {
|
|
1242
|
-
if (Buffer.isBuffer(data)) {
|
|
1243
|
-
return data;
|
|
1244
|
-
}
|
|
1245
|
-
if (data instanceof ArrayBuffer) {
|
|
1246
|
-
return Buffer.from(data);
|
|
1247
|
-
}
|
|
1248
|
-
if (Array.isArray(data)) {
|
|
1249
|
-
return Buffer.concat(data.map((item) => dataToBuffer(item)));
|
|
1250
|
-
}
|
|
1251
|
-
throw new TciError("protocol-error", "Unsupported WebSocket data type");
|
|
1252
|
-
}
|
|
1253
1843
|
function rxVfoKey(receiver, vfo) {
|
|
1254
1844
|
return `${receiver}:${vfo}`;
|
|
1255
1845
|
}
|
|
@@ -1273,6 +1863,27 @@ function parseBoolean(value) {
|
|
|
1273
1863
|
}
|
|
1274
1864
|
return void 0;
|
|
1275
1865
|
}
|
|
1866
|
+
function parseSampleType(value) {
|
|
1867
|
+
if (!value) return void 0;
|
|
1868
|
+
try {
|
|
1869
|
+
return normalizeSampleType(value.toLowerCase());
|
|
1870
|
+
} catch {
|
|
1871
|
+
return void 0;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
function parseObservedDrive(args, defaultTrx) {
|
|
1875
|
+
const hasTrx = args.length >= 2;
|
|
1876
|
+
const trx = hasTrx ? parseNumber(args[0]) : defaultTrx;
|
|
1877
|
+
const value = parseNumber(args[hasTrx ? 1 : 0]);
|
|
1878
|
+
return trx === void 0 || value === void 0 ? void 0 : { trx, value };
|
|
1879
|
+
}
|
|
1880
|
+
function normalizePercent(value) {
|
|
1881
|
+
if (!Number.isFinite(value)) throw new TciError("protocol-error", `Invalid TCI percentage: ${value}`);
|
|
1882
|
+
return Math.round(Math.max(0, Math.min(100, value)));
|
|
1883
|
+
}
|
|
1884
|
+
function writeResult(requested, applied, acknowledgement) {
|
|
1885
|
+
return { requested, applied, outcome: requested === applied ? "applied" : "clamped", acknowledgement };
|
|
1886
|
+
}
|
|
1276
1887
|
function parseNumberPair(args) {
|
|
1277
1888
|
const first = parseNumber(args[0]);
|
|
1278
1889
|
const second = parseNumber(args[1]);
|
|
@@ -1288,12 +1899,26 @@ function cloneState(state) {
|
|
|
1288
1899
|
pttSource: { ...state.pttSource },
|
|
1289
1900
|
tune: { ...state.tune },
|
|
1290
1901
|
drive: { ...state.drive },
|
|
1902
|
+
tuneDrive: { ...state.tuneDrive },
|
|
1291
1903
|
split: { ...state.split },
|
|
1904
|
+
dialectWarnings: [...state.dialectWarnings],
|
|
1292
1905
|
rxSensors: cloneNested(state.rxSensors),
|
|
1293
1906
|
txSensors: cloneNested(state.txSensors),
|
|
1294
1907
|
audio: state.audio ? { ...state.audio } : void 0
|
|
1295
1908
|
};
|
|
1296
1909
|
}
|
|
1910
|
+
function cloneHandshake(result) {
|
|
1911
|
+
return {
|
|
1912
|
+
identity: { ...result.identity, rawProtocolArgs: [...result.identity.rawProtocolArgs] },
|
|
1913
|
+
dialect: {
|
|
1914
|
+
...result.dialect,
|
|
1915
|
+
evidence: [...result.dialect.evidence],
|
|
1916
|
+
warnings: [...result.dialect.warnings]
|
|
1917
|
+
},
|
|
1918
|
+
ready: true,
|
|
1919
|
+
commandNames: [...result.commandNames]
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1297
1922
|
function cloneNested(value) {
|
|
1298
1923
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, { ...item }]));
|
|
1299
1924
|
}
|
|
@@ -1302,30 +1927,44 @@ function cloneNested(value) {
|
|
|
1302
1927
|
TCI_STREAM_HEADER_BYTES,
|
|
1303
1928
|
TciClient,
|
|
1304
1929
|
TciCommandQueue,
|
|
1930
|
+
TciDialectRegistry,
|
|
1305
1931
|
TciError,
|
|
1306
1932
|
TciSampleType,
|
|
1307
1933
|
TciStreamType,
|
|
1934
|
+
WebSocketTciTransport,
|
|
1935
|
+
aetherSdrDialect,
|
|
1936
|
+
assertValidTciHandshake,
|
|
1308
1937
|
buildStreamFrame,
|
|
1309
1938
|
buildTxAudioFrame,
|
|
1939
|
+
builtInDialects,
|
|
1310
1940
|
commandKey,
|
|
1941
|
+
compareTciVersion,
|
|
1311
1942
|
createTciClient,
|
|
1943
|
+
defaultTciDialectRegistry,
|
|
1312
1944
|
deinterleaveChannels,
|
|
1313
1945
|
escapeTciText,
|
|
1946
|
+
expertSdr14Dialect,
|
|
1947
|
+
expertSdrLegacyDialect,
|
|
1948
|
+
expertSdrModernDialect,
|
|
1314
1949
|
float32ToPcm16,
|
|
1315
1950
|
formatTciCommand,
|
|
1951
|
+
genericObservedDialect,
|
|
1316
1952
|
isCommandReplyTo,
|
|
1317
1953
|
mixToMono,
|
|
1318
1954
|
normalizeCommandName,
|
|
1319
1955
|
normalizeSampleType,
|
|
1320
1956
|
normalizeStreamType,
|
|
1957
|
+
parseProtocolIdentity,
|
|
1321
1958
|
parseStreamFrame,
|
|
1322
1959
|
parseTciCommand,
|
|
1323
1960
|
parseTciText,
|
|
1961
|
+
parseTciVersion,
|
|
1324
1962
|
payloadToFloat32,
|
|
1325
1963
|
pcm16ToFloat32,
|
|
1326
1964
|
sampleTypeBytes,
|
|
1327
1965
|
sampleTypeName,
|
|
1328
1966
|
samplesToPayload,
|
|
1967
|
+
thetisDialect,
|
|
1329
1968
|
toTciError,
|
|
1330
1969
|
unescapeTciText
|
|
1331
1970
|
});
|