tci-client-node 0.1.1 → 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 +33 -13
- package/dist/audio/index.cjs +63 -20
- package/dist/audio/index.cjs.map +1 -1
- package/dist/audio/index.d.cts +18 -3
- package/dist/audio/index.d.ts +18 -3
- package/dist/audio/index.js +63 -20
- 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 +890 -224
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -12
- package/dist/index.d.ts +54 -12
- package/dist/index.js +876 -224
- 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 +77 -25
- 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 +77 -25
- 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`);
|
|
@@ -112,28 +126,49 @@ function parseStreamFrame(input) {
|
|
|
112
126
|
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
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]);
|
|
130
|
+
let channels = header[7] || options.negotiatedChannels || 0;
|
|
116
131
|
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
117
|
-
const
|
|
132
|
+
const headerSampleCount = header[5];
|
|
118
133
|
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
119
134
|
if (channels <= 0) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
throw new TciError(
|
|
130
|
-
"invalid-frame",
|
|
131
|
-
`TCI stream frame length mismatch: header says ${sampleCount} samples (${payloadLength} payload bytes), got ${buffer.byteLength - TCI_STREAM_HEADER_BYTES}`
|
|
132
|
-
);
|
|
135
|
+
if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
|
|
136
|
+
channels = 1;
|
|
137
|
+
} else {
|
|
138
|
+
const inferredChannels = headerSampleCount > 0 ? actualPayloadLength / headerSampleCount / bytesPerSample : 1;
|
|
139
|
+
if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
|
|
140
|
+
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
141
|
+
}
|
|
142
|
+
channels = inferredChannels;
|
|
143
|
+
}
|
|
133
144
|
}
|
|
134
|
-
|
|
145
|
+
const payloadLength = actualPayloadLength;
|
|
146
|
+
const alignedFrameBytes = bytesPerSample * channels;
|
|
147
|
+
if (payloadLength % alignedFrameBytes !== 0) {
|
|
135
148
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
136
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;
|
|
160
|
+
if (streamType !== 3 /* TX_CHRONO */) {
|
|
161
|
+
const expectedPayloadLength = sampleCount * bytesPerSample;
|
|
162
|
+
if (payloadLength !== expectedPayloadLength) {
|
|
163
|
+
throw new TciError(
|
|
164
|
+
"invalid-frame",
|
|
165
|
+
`TCI stream frame length mismatch: header says ${headerSampleCount} samples using ${lengthSemantics} semantics (${expectedPayloadLength} payload bytes), got ${payloadLength}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (sampleCount % channels !== 0) {
|
|
170
|
+
throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
|
|
171
|
+
}
|
|
137
172
|
return {
|
|
138
173
|
receiver: header[0],
|
|
139
174
|
sampleRate: header[1],
|
|
@@ -141,11 +176,14 @@ function parseStreamFrame(input) {
|
|
|
141
176
|
codec: header[3],
|
|
142
177
|
crc: header[4],
|
|
143
178
|
payloadLength,
|
|
144
|
-
streamType
|
|
179
|
+
streamType,
|
|
145
180
|
channels,
|
|
146
181
|
reserved: header.slice(8),
|
|
147
182
|
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
148
|
-
|
|
183
|
+
headerSampleCount,
|
|
184
|
+
sampleCount,
|
|
185
|
+
frameCount: sampleCount / channels,
|
|
186
|
+
lengthSemantics
|
|
149
187
|
};
|
|
150
188
|
}
|
|
151
189
|
function buildStreamFrame(options) {
|
|
@@ -159,7 +197,19 @@ function buildStreamFrame(options) {
|
|
|
159
197
|
if (payload.byteLength % (bytesPerSample * channels) !== 0) {
|
|
160
198
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
161
199
|
}
|
|
162
|
-
const
|
|
200
|
+
const derivedSampleCount = payload.byteLength / bytesPerSample;
|
|
201
|
+
const sampleCount = options.sampleCount ?? derivedSampleCount;
|
|
202
|
+
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
|
203
|
+
throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
|
|
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;
|
|
163
213
|
const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
|
|
164
214
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
165
215
|
const reserved = options.reserved ?? [];
|
|
@@ -169,7 +219,7 @@ function buildStreamFrame(options) {
|
|
|
169
219
|
sampleType,
|
|
170
220
|
options.codec ?? 0,
|
|
171
221
|
options.crc ?? 0,
|
|
172
|
-
|
|
222
|
+
headerSampleCount,
|
|
173
223
|
options.streamType,
|
|
174
224
|
channels,
|
|
175
225
|
...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
|
|
@@ -178,6 +228,13 @@ function buildStreamFrame(options) {
|
|
|
178
228
|
payload.copy(frame, TCI_STREAM_HEADER_BYTES);
|
|
179
229
|
return frame;
|
|
180
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
|
+
}
|
|
181
238
|
function buildTxAudioFrame(options) {
|
|
182
239
|
return buildStreamFrame({ ...options, streamType: 2 /* TX_AUDIO_STREAM */ });
|
|
183
240
|
}
|
|
@@ -609,13 +666,398 @@ function ensureSemicolon(command) {
|
|
|
609
666
|
return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
|
|
610
667
|
}
|
|
611
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
|
+
|
|
612
1046
|
// src/client/TciClient.ts
|
|
613
|
-
var TciClient = class extends
|
|
1047
|
+
var TciClient = class extends import_eventemitter32.EventEmitter {
|
|
614
1048
|
options;
|
|
615
1049
|
WebSocketImpl;
|
|
616
|
-
|
|
1050
|
+
transportFactory;
|
|
1051
|
+
transport;
|
|
617
1052
|
queue;
|
|
618
1053
|
state;
|
|
1054
|
+
stateReducers;
|
|
1055
|
+
dialectRegistry;
|
|
1056
|
+
activeDialect;
|
|
1057
|
+
handshakeResult;
|
|
1058
|
+
handshakeError;
|
|
1059
|
+
initializationCommands = [];
|
|
1060
|
+
handshakeWaiter;
|
|
619
1061
|
constructor(options) {
|
|
620
1062
|
super();
|
|
621
1063
|
this.options = {
|
|
@@ -624,13 +1066,17 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
624
1066
|
trx: options.trx ?? 0,
|
|
625
1067
|
vfo: options.vfo ?? 0,
|
|
626
1068
|
connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
|
|
1069
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
|
|
627
1070
|
commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
|
|
628
1071
|
writeAckMode: options.writeAckMode ?? "state",
|
|
629
1072
|
writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
|
|
630
1073
|
writeSettleMs: options.writeSettleMs ?? 0,
|
|
631
|
-
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
|
|
1074
|
+
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
|
|
1075
|
+
dialect: options.dialect ?? "auto"
|
|
632
1076
|
};
|
|
633
|
-
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));
|
|
634
1080
|
this.queue = new TciCommandQueue({
|
|
635
1081
|
timeoutMs: this.options.commandTimeoutMs,
|
|
636
1082
|
send: (raw) => this.sendRaw(raw)
|
|
@@ -646,58 +1092,49 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
646
1092
|
pttSource: {},
|
|
647
1093
|
tune: {},
|
|
648
1094
|
drive: {},
|
|
1095
|
+
tuneDrive: {},
|
|
649
1096
|
split: {},
|
|
1097
|
+
dialectWarnings: [],
|
|
650
1098
|
rxSensors: {},
|
|
651
1099
|
txSensors: {}
|
|
652
1100
|
};
|
|
1101
|
+
this.stateReducers = this.createStateReducers();
|
|
653
1102
|
}
|
|
654
1103
|
async connect() {
|
|
655
|
-
if (this.
|
|
656
|
-
return;
|
|
1104
|
+
if (this.transport?.isConnected()) {
|
|
1105
|
+
return this.handshakeResult ?? this.waitForHandshake();
|
|
657
1106
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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;
|
|
661
1121
|
}
|
|
662
|
-
const ws = new this.WebSocketImpl(this.options.url);
|
|
663
|
-
this.ws = ws;
|
|
664
|
-
await this.waitForOpen(ws);
|
|
665
1122
|
}
|
|
666
1123
|
async disconnect(code = 1e3, reason = "client disconnect") {
|
|
667
|
-
const
|
|
668
|
-
if (!
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
if (ws.readyState === import_ws.default.CLOSED) {
|
|
672
|
-
this.handleClose();
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
await new Promise((resolve) => {
|
|
676
|
-
const cleanup = () => {
|
|
677
|
-
ws.off("close", onClose);
|
|
678
|
-
ws.off("error", onError);
|
|
679
|
-
};
|
|
680
|
-
const onClose = () => {
|
|
681
|
-
cleanup();
|
|
682
|
-
resolve();
|
|
683
|
-
};
|
|
684
|
-
const onError = () => {
|
|
685
|
-
cleanup();
|
|
686
|
-
resolve();
|
|
687
|
-
};
|
|
688
|
-
ws.once("close", onClose);
|
|
689
|
-
ws.once("error", onError);
|
|
690
|
-
ws.close(code, reason);
|
|
691
|
-
setTimeout(() => resolve(), 1e3).unref?.();
|
|
692
|
-
});
|
|
1124
|
+
const transport = this.transport;
|
|
1125
|
+
if (!transport) return;
|
|
1126
|
+
await transport.disconnect(code, reason);
|
|
693
1127
|
this.handleClose();
|
|
694
1128
|
}
|
|
695
1129
|
isConnected() {
|
|
696
|
-
return this.
|
|
1130
|
+
return this.transport?.isConnected() ?? false;
|
|
697
1131
|
}
|
|
698
1132
|
getState() {
|
|
699
1133
|
return cloneState(this.state);
|
|
700
1134
|
}
|
|
1135
|
+
getHandshakeResult() {
|
|
1136
|
+
return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
|
|
1137
|
+
}
|
|
701
1138
|
async sendCommand(name, args = [], options = {}) {
|
|
702
1139
|
const raw = formatTciCommand(name, args);
|
|
703
1140
|
if (options.waitForReply === false) {
|
|
@@ -771,6 +1208,9 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
771
1208
|
}
|
|
772
1209
|
async setPtt(enabled, options = {}) {
|
|
773
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
|
+
}
|
|
774
1214
|
const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
|
|
775
1215
|
await this.sendStateWrite(
|
|
776
1216
|
"TRX",
|
|
@@ -784,16 +1224,107 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
784
1224
|
const reply = await this.request("TRX", [trx]);
|
|
785
1225
|
return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
|
|
786
1226
|
}
|
|
787
|
-
async setTune(enabled, trx = this.options.trx) {
|
|
788
|
-
|
|
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
|
+
}
|
|
789
1241
|
}
|
|
790
1242
|
async setDrive(value, trx = this.options.trx) {
|
|
791
|
-
await this.
|
|
1243
|
+
await this.setDriveWithResult(value, trx);
|
|
792
1244
|
}
|
|
793
|
-
async
|
|
794
|
-
|
|
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");
|
|
1305
|
+
}
|
|
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
|
+
}
|
|
795
1325
|
}
|
|
796
1326
|
async configureAudio(config) {
|
|
1327
|
+
const dialect = this.requireDialect();
|
|
797
1328
|
const audio = {
|
|
798
1329
|
sampleRate: config.sampleRate,
|
|
799
1330
|
sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
|
|
@@ -804,11 +1335,13 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
804
1335
|
};
|
|
805
1336
|
this.state.audio = audio;
|
|
806
1337
|
await this.sendCommand("AUDIO_SAMPLERATE", [audio.sampleRate], { waitForReply: false });
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
+
}
|
|
812
1345
|
}
|
|
813
1346
|
this.emitState();
|
|
814
1347
|
}
|
|
@@ -827,9 +1360,27 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
827
1360
|
}
|
|
828
1361
|
}
|
|
829
1362
|
sendTxAudio(options) {
|
|
830
|
-
const frame = buildTxAudioFrame({
|
|
1363
|
+
const frame = buildTxAudioFrame({
|
|
1364
|
+
receiver: this.options.receiver,
|
|
1365
|
+
lengthSemantics: this.requireDialect().streamLengthSemantics,
|
|
1366
|
+
...options
|
|
1367
|
+
});
|
|
831
1368
|
this.sendRawBinary(frame);
|
|
832
1369
|
}
|
|
1370
|
+
sendTxAudioForChrono(request, samples) {
|
|
1371
|
+
const channels = Math.max(1, Math.floor(request.channels || 1));
|
|
1372
|
+
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
|
|
1373
|
+
const output = new Float32Array(targetSampleLength);
|
|
1374
|
+
const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
|
|
1375
|
+
output.set(source.subarray(0, output.length));
|
|
1376
|
+
this.sendTxAudio({
|
|
1377
|
+
receiver: request.receiver,
|
|
1378
|
+
sampleRate: request.sampleRate,
|
|
1379
|
+
sampleType: request.sampleType,
|
|
1380
|
+
channels,
|
|
1381
|
+
samples: output
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
833
1384
|
async setRxSensorsEnabled(enabled, intervalMs) {
|
|
834
1385
|
const args = intervalMs === void 0 ? [enabled] : [enabled, intervalMs];
|
|
835
1386
|
await this.sendCommand("RX_SENSORS_ENABLE", args, { waitForReply: false });
|
|
@@ -916,78 +1467,147 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
916
1467
|
cancel: cleanup
|
|
917
1468
|
};
|
|
918
1469
|
}
|
|
919
|
-
|
|
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
|
+
}
|
|
920
1536
|
return new Promise((resolve, reject) => {
|
|
921
1537
|
const timer = setTimeout(() => {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
reject(new TciError("connect-timeout", `Timed out connecting to ${this.options.url}`));
|
|
928
|
-
}, this.options.connectTimeoutMs);
|
|
929
|
-
const cleanup = () => {
|
|
930
|
-
clearTimeout(timer);
|
|
931
|
-
ws.off("open", onOpen);
|
|
932
|
-
ws.off("close", onClose);
|
|
933
|
-
ws.off("error", onError);
|
|
934
|
-
};
|
|
935
|
-
const onOpen = () => {
|
|
936
|
-
cleanup();
|
|
937
|
-
this.attachSocket(ws);
|
|
938
|
-
this.state.connected = true;
|
|
939
|
-
this.queue.setConnected(true);
|
|
940
|
-
this.emit("connected");
|
|
941
|
-
this.emitState();
|
|
942
|
-
resolve();
|
|
943
|
-
};
|
|
944
|
-
const onClose = () => {
|
|
945
|
-
cleanup();
|
|
946
|
-
this.handleClose();
|
|
947
|
-
reject(new TciError("disconnected", `Disconnected while connecting to ${this.options.url}`));
|
|
948
|
-
};
|
|
949
|
-
const onError = (error) => {
|
|
950
|
-
cleanup();
|
|
951
|
-
this.handleError(error);
|
|
952
|
-
reject(toTciError(error, "disconnected"));
|
|
953
|
-
};
|
|
954
|
-
ws.once("open", onOpen);
|
|
955
|
-
ws.once("close", onClose);
|
|
956
|
-
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 };
|
|
957
1543
|
});
|
|
958
1544
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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));
|
|
963
1589
|
}
|
|
964
1590
|
async sendRaw(raw) {
|
|
965
|
-
const
|
|
966
|
-
if (!
|
|
1591
|
+
const transport = this.transport;
|
|
1592
|
+
if (!transport?.isConnected()) {
|
|
967
1593
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
968
1594
|
}
|
|
969
1595
|
this.emit("tci:tx", raw);
|
|
970
|
-
await
|
|
971
|
-
ws.send(raw, (error) => error ? reject(error) : resolve());
|
|
972
|
-
});
|
|
1596
|
+
await transport.sendText(raw);
|
|
973
1597
|
}
|
|
974
1598
|
sendRawBinary(raw) {
|
|
975
|
-
const
|
|
976
|
-
if (!
|
|
1599
|
+
const transport = this.transport;
|
|
1600
|
+
if (!transport?.isConnected()) {
|
|
977
1601
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
978
1602
|
}
|
|
979
|
-
|
|
1603
|
+
void transport.sendBinary(raw).catch((error) => this.handleError(error));
|
|
980
1604
|
}
|
|
981
|
-
|
|
1605
|
+
handleText(raw) {
|
|
982
1606
|
try {
|
|
983
|
-
if (isBinary) {
|
|
984
|
-
this.handleBinary(data);
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
const raw = dataToBuffer(data).toString("utf8");
|
|
988
1607
|
const commands = parseTciText(raw);
|
|
989
1608
|
this.emit("tci:rx", raw, commands);
|
|
990
1609
|
for (const command of commands) {
|
|
1610
|
+
if (!this.handshakeResult) this.initializationCommands.push(command);
|
|
991
1611
|
this.queue.handleCommand(command);
|
|
992
1612
|
this.applyCommand(command);
|
|
993
1613
|
this.emit("command", command);
|
|
@@ -997,7 +1617,10 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
997
1617
|
}
|
|
998
1618
|
}
|
|
999
1619
|
handleBinary(data) {
|
|
1000
|
-
const frame = parseStreamFrame(
|
|
1620
|
+
const frame = parseStreamFrame(data, {
|
|
1621
|
+
lengthSemantics: this.requireDialect().streamLengthSemantics,
|
|
1622
|
+
negotiatedChannels: this.state.audio?.channels
|
|
1623
|
+
});
|
|
1001
1624
|
this.emit("tci:binary", frame);
|
|
1002
1625
|
this.emit("binary", frame);
|
|
1003
1626
|
switch (frame.streamType) {
|
|
@@ -1011,7 +1634,8 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1011
1634
|
sampleRate: frame.sampleRate,
|
|
1012
1635
|
channels: frame.channels,
|
|
1013
1636
|
sampleType: frame.sampleType,
|
|
1014
|
-
sampleCount: frame.sampleCount
|
|
1637
|
+
sampleCount: frame.sampleCount,
|
|
1638
|
+
frameCount: frame.frameCount
|
|
1015
1639
|
});
|
|
1016
1640
|
break;
|
|
1017
1641
|
case 4 /* LINEOUT_STREAM */:
|
|
@@ -1023,80 +1647,78 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1023
1647
|
}
|
|
1024
1648
|
applyCommand(command) {
|
|
1025
1649
|
const readyBefore = this.state.ready;
|
|
1026
|
-
|
|
1027
|
-
case "ready":
|
|
1028
|
-
this.state.ready = command.args.length === 0 ? true : parseBoolean(command.args[0]) ?? true;
|
|
1029
|
-
break;
|
|
1030
|
-
case "protocol":
|
|
1031
|
-
this.state.protocol = command.args[0];
|
|
1032
|
-
break;
|
|
1033
|
-
case "device":
|
|
1034
|
-
this.state.device = command.args.join(",");
|
|
1035
|
-
break;
|
|
1036
|
-
case "receive_only":
|
|
1037
|
-
this.state.receiveOnly = parseBoolean(command.args[0]);
|
|
1038
|
-
break;
|
|
1039
|
-
case "trx_count":
|
|
1040
|
-
this.state.trxCount = parseNumber(command.args[0]);
|
|
1041
|
-
break;
|
|
1042
|
-
case "channels_count":
|
|
1043
|
-
case "channel_count":
|
|
1044
|
-
this.state.channelCount = parseNumber(command.args[0]);
|
|
1045
|
-
break;
|
|
1046
|
-
case "vfo_limits":
|
|
1047
|
-
this.state.vfoLimits = parseNumberPair(command.args);
|
|
1048
|
-
break;
|
|
1049
|
-
case "if_limits":
|
|
1050
|
-
this.state.ifLimits = parseNumberPair(command.args);
|
|
1051
|
-
break;
|
|
1052
|
-
case "modulations_list":
|
|
1053
|
-
this.state.modulations = command.args.map((mode) => mode.toLowerCase());
|
|
1054
|
-
break;
|
|
1055
|
-
case "vfo":
|
|
1056
|
-
this.applyVfo(command.args);
|
|
1057
|
-
break;
|
|
1058
|
-
case "modulation":
|
|
1059
|
-
this.applyModulation(command.args);
|
|
1060
|
-
break;
|
|
1061
|
-
case "trx":
|
|
1062
|
-
this.applyTrx(command.args);
|
|
1063
|
-
break;
|
|
1064
|
-
case "tune":
|
|
1065
|
-
this.applyBooleanByFirstArg(this.state.tune, command.args);
|
|
1066
|
-
break;
|
|
1067
|
-
case "drive":
|
|
1068
|
-
this.applyDrive(command.args);
|
|
1069
|
-
break;
|
|
1070
|
-
case "split_enable":
|
|
1071
|
-
this.applyBooleanByFirstArg(this.state.split, command.args);
|
|
1072
|
-
break;
|
|
1073
|
-
case "rx_channel_sensors":
|
|
1074
|
-
this.applyRxChannelSensors(command.args);
|
|
1075
|
-
break;
|
|
1076
|
-
case "rx_sensors":
|
|
1077
|
-
this.applyRxSensors(command.args);
|
|
1078
|
-
break;
|
|
1079
|
-
case "tx_sensors":
|
|
1080
|
-
this.applyTxSensors(command.args);
|
|
1081
|
-
break;
|
|
1082
|
-
case "audio_samplerate":
|
|
1083
|
-
this.state.audio = {
|
|
1084
|
-
sampleRate: parseNumber(command.args[0]) ?? this.state.audio?.sampleRate ?? 12e3,
|
|
1085
|
-
sampleType: this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
|
|
1086
|
-
channels: this.state.audio?.channels ?? 1,
|
|
1087
|
-
samplesPerFrame: this.state.audio?.samplesPerFrame ?? 512,
|
|
1088
|
-
txBufferingMs: this.state.audio?.txBufferingMs,
|
|
1089
|
-
running: this.state.audio?.running ?? false
|
|
1090
|
-
};
|
|
1091
|
-
break;
|
|
1092
|
-
default:
|
|
1093
|
-
break;
|
|
1094
|
-
}
|
|
1650
|
+
this.stateReducers.get(command.name)?.(command.args);
|
|
1095
1651
|
if (!readyBefore && this.state.ready) {
|
|
1096
|
-
|
|
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
|
+
}
|
|
1097
1662
|
}
|
|
1098
1663
|
this.emitState();
|
|
1099
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
|
+
}
|
|
1100
1722
|
applyVfo(args) {
|
|
1101
1723
|
if (args.length < 3) {
|
|
1102
1724
|
return;
|
|
@@ -1143,18 +1765,22 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1143
1765
|
}
|
|
1144
1766
|
}
|
|
1145
1767
|
applyDrive(args) {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
this.state.
|
|
1157
|
-
|
|
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
|
+
};
|
|
1158
1784
|
}
|
|
1159
1785
|
applyRxChannelSensors(args) {
|
|
1160
1786
|
if (args.length < 3) {
|
|
@@ -1190,11 +1816,14 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1190
1816
|
};
|
|
1191
1817
|
}
|
|
1192
1818
|
handleClose(reason) {
|
|
1193
|
-
|
|
1819
|
+
const transport = this.transport;
|
|
1820
|
+
this.transport = void 0;
|
|
1821
|
+
transport?.removeAllListeners();
|
|
1194
1822
|
const wasConnected = this.state.connected;
|
|
1195
1823
|
this.state.connected = false;
|
|
1196
1824
|
this.state.ready = false;
|
|
1197
1825
|
this.queue.setConnected(false);
|
|
1826
|
+
this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
|
|
1198
1827
|
if (wasConnected) {
|
|
1199
1828
|
this.emit("disconnected", reason);
|
|
1200
1829
|
this.emitState();
|
|
@@ -1211,18 +1840,6 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
1211
1840
|
function createTciClient(options) {
|
|
1212
1841
|
return new TciClient(options);
|
|
1213
1842
|
}
|
|
1214
|
-
function dataToBuffer(data) {
|
|
1215
|
-
if (Buffer.isBuffer(data)) {
|
|
1216
|
-
return data;
|
|
1217
|
-
}
|
|
1218
|
-
if (data instanceof ArrayBuffer) {
|
|
1219
|
-
return Buffer.from(data);
|
|
1220
|
-
}
|
|
1221
|
-
if (Array.isArray(data)) {
|
|
1222
|
-
return Buffer.concat(data.map((item) => dataToBuffer(item)));
|
|
1223
|
-
}
|
|
1224
|
-
throw new TciError("protocol-error", "Unsupported WebSocket data type");
|
|
1225
|
-
}
|
|
1226
1843
|
function rxVfoKey(receiver, vfo) {
|
|
1227
1844
|
return `${receiver}:${vfo}`;
|
|
1228
1845
|
}
|
|
@@ -1246,6 +1863,27 @@ function parseBoolean(value) {
|
|
|
1246
1863
|
}
|
|
1247
1864
|
return void 0;
|
|
1248
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
|
+
}
|
|
1249
1887
|
function parseNumberPair(args) {
|
|
1250
1888
|
const first = parseNumber(args[0]);
|
|
1251
1889
|
const second = parseNumber(args[1]);
|
|
@@ -1261,12 +1899,26 @@ function cloneState(state) {
|
|
|
1261
1899
|
pttSource: { ...state.pttSource },
|
|
1262
1900
|
tune: { ...state.tune },
|
|
1263
1901
|
drive: { ...state.drive },
|
|
1902
|
+
tuneDrive: { ...state.tuneDrive },
|
|
1264
1903
|
split: { ...state.split },
|
|
1904
|
+
dialectWarnings: [...state.dialectWarnings],
|
|
1265
1905
|
rxSensors: cloneNested(state.rxSensors),
|
|
1266
1906
|
txSensors: cloneNested(state.txSensors),
|
|
1267
1907
|
audio: state.audio ? { ...state.audio } : void 0
|
|
1268
1908
|
};
|
|
1269
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
|
+
}
|
|
1270
1922
|
function cloneNested(value) {
|
|
1271
1923
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, { ...item }]));
|
|
1272
1924
|
}
|
|
@@ -1275,30 +1927,44 @@ function cloneNested(value) {
|
|
|
1275
1927
|
TCI_STREAM_HEADER_BYTES,
|
|
1276
1928
|
TciClient,
|
|
1277
1929
|
TciCommandQueue,
|
|
1930
|
+
TciDialectRegistry,
|
|
1278
1931
|
TciError,
|
|
1279
1932
|
TciSampleType,
|
|
1280
1933
|
TciStreamType,
|
|
1934
|
+
WebSocketTciTransport,
|
|
1935
|
+
aetherSdrDialect,
|
|
1936
|
+
assertValidTciHandshake,
|
|
1281
1937
|
buildStreamFrame,
|
|
1282
1938
|
buildTxAudioFrame,
|
|
1939
|
+
builtInDialects,
|
|
1283
1940
|
commandKey,
|
|
1941
|
+
compareTciVersion,
|
|
1284
1942
|
createTciClient,
|
|
1943
|
+
defaultTciDialectRegistry,
|
|
1285
1944
|
deinterleaveChannels,
|
|
1286
1945
|
escapeTciText,
|
|
1946
|
+
expertSdr14Dialect,
|
|
1947
|
+
expertSdrLegacyDialect,
|
|
1948
|
+
expertSdrModernDialect,
|
|
1287
1949
|
float32ToPcm16,
|
|
1288
1950
|
formatTciCommand,
|
|
1951
|
+
genericObservedDialect,
|
|
1289
1952
|
isCommandReplyTo,
|
|
1290
1953
|
mixToMono,
|
|
1291
1954
|
normalizeCommandName,
|
|
1292
1955
|
normalizeSampleType,
|
|
1293
1956
|
normalizeStreamType,
|
|
1957
|
+
parseProtocolIdentity,
|
|
1294
1958
|
parseStreamFrame,
|
|
1295
1959
|
parseTciCommand,
|
|
1296
1960
|
parseTciText,
|
|
1961
|
+
parseTciVersion,
|
|
1297
1962
|
payloadToFloat32,
|
|
1298
1963
|
pcm16ToFloat32,
|
|
1299
1964
|
sampleTypeBytes,
|
|
1300
1965
|
sampleTypeName,
|
|
1301
1966
|
samplesToPayload,
|
|
1967
|
+
thetisDialect,
|
|
1302
1968
|
toTciError,
|
|
1303
1969
|
unescapeTciText
|
|
1304
1970
|
});
|