tci-client-node 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +57 -11
  3. package/dist/audio/index.cjs +52 -11
  4. package/dist/audio/index.cjs.map +1 -1
  5. package/dist/audio/index.d.cts +17 -3
  6. package/dist/audio/index.d.ts +17 -3
  7. package/dist/audio/index.js +51 -11
  8. package/dist/audio/index.js.map +1 -1
  9. package/dist/dialect/index.cjs +349 -0
  10. package/dist/dialect/index.cjs.map +1 -0
  11. package/dist/dialect/index.d.cts +29 -0
  12. package/dist/dialect/index.d.ts +29 -0
  13. package/dist/dialect/index.js +310 -0
  14. package/dist/dialect/index.js.map +1 -0
  15. package/dist/{index-CK3XdXP3.d.cts → index-lbK6NGY4.d.cts} +1 -1
  16. package/dist/{index-Dfmrk2MR.d.ts → index-paj1AOJY.d.ts} +1 -1
  17. package/dist/index.cjs +1057 -217
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +106 -12
  20. package/dist/index.d.ts +106 -12
  21. package/dist/index.js +1041 -217
  22. package/dist/index.js.map +1 -1
  23. package/dist/protocol/index.cjs.map +1 -1
  24. package/dist/protocol/index.d.cts +1 -1
  25. package/dist/protocol/index.d.ts +1 -1
  26. package/dist/protocol/index.js.map +1 -1
  27. package/dist/testing/index.cjs +79 -14
  28. package/dist/testing/index.cjs.map +1 -1
  29. package/dist/testing/index.d.cts +6 -0
  30. package/dist/testing/index.d.ts +6 -0
  31. package/dist/testing/index.js +79 -14
  32. package/dist/testing/index.js.map +1 -1
  33. package/dist/transport/index.cjs +175 -0
  34. package/dist/transport/index.cjs.map +1 -0
  35. package/dist/transport/index.d.cts +37 -0
  36. package/dist/transport/index.d.ts +37 -0
  37. package/dist/transport/index.js +138 -0
  38. package/dist/transport/index.js.map +1 -0
  39. package/dist/types-1YXGwrgI.d.cts +65 -0
  40. package/dist/types-BtPh4Dbd.d.ts +65 -0
  41. package/package.json +15 -1
package/dist/index.cjs CHANGED
@@ -33,30 +33,46 @@ __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,
38
+ TciIqStreamSession: () => TciIqStreamSession,
37
39
  TciSampleType: () => TciSampleType,
38
40
  TciStreamType: () => TciStreamType,
41
+ WebSocketTciTransport: () => WebSocketTciTransport,
42
+ aetherSdrDialect: () => aetherSdrDialect,
43
+ assertValidTciHandshake: () => assertValidTciHandshake,
39
44
  buildStreamFrame: () => buildStreamFrame,
40
45
  buildTxAudioFrame: () => buildTxAudioFrame,
46
+ builtInDialects: () => builtInDialects,
41
47
  commandKey: () => commandKey,
48
+ compareTciVersion: () => compareTciVersion,
42
49
  createTciClient: () => createTciClient,
50
+ decodeInterleavedIq: () => decodeInterleavedIq,
51
+ defaultTciDialectRegistry: () => defaultTciDialectRegistry,
43
52
  deinterleaveChannels: () => deinterleaveChannels,
44
53
  escapeTciText: () => escapeTciText,
54
+ expertSdr14Dialect: () => expertSdr14Dialect,
55
+ expertSdrLegacyDialect: () => expertSdrLegacyDialect,
56
+ expertSdrModernDialect: () => expertSdrModernDialect,
45
57
  float32ToPcm16: () => float32ToPcm16,
46
58
  formatTciCommand: () => formatTciCommand,
59
+ genericObservedDialect: () => genericObservedDialect,
47
60
  isCommandReplyTo: () => isCommandReplyTo,
48
61
  mixToMono: () => mixToMono,
49
62
  normalizeCommandName: () => normalizeCommandName,
50
63
  normalizeSampleType: () => normalizeSampleType,
51
64
  normalizeStreamType: () => normalizeStreamType,
65
+ parseProtocolIdentity: () => parseProtocolIdentity,
52
66
  parseStreamFrame: () => parseStreamFrame,
53
67
  parseTciCommand: () => parseTciCommand,
54
68
  parseTciText: () => parseTciText,
69
+ parseTciVersion: () => parseTciVersion,
55
70
  payloadToFloat32: () => payloadToFloat32,
56
71
  pcm16ToFloat32: () => pcm16ToFloat32,
57
72
  sampleTypeBytes: () => sampleTypeBytes,
58
73
  sampleTypeName: () => sampleTypeName,
59
74
  samplesToPayload: () => samplesToPayload,
75
+ thetisDialect: () => thetisDialect,
60
76
  toTciError: () => toTciError,
61
77
  unescapeTciText: () => unescapeTciText
62
78
  });
@@ -84,8 +100,8 @@ function toTciError(error, fallbackCode = "protocol-error") {
84
100
  }
85
101
 
86
102
  // src/client/TciClient.ts
87
- var import_eventemitter3 = require("eventemitter3");
88
- var import_ws = __toESM(require("ws"), 1);
103
+ var import_eventemitter32 = require("eventemitter3");
104
+ var import_ws2 = __toESM(require("ws"), 1);
89
105
 
90
106
  // src/audio/streamFrame.ts
91
107
  var TCI_STREAM_HEADER_BYTES = 16 * 4;
@@ -104,7 +120,7 @@ var TciSampleType = /* @__PURE__ */ ((TciSampleType2) => {
104
120
  TciSampleType2[TciSampleType2["FLOAT32"] = 3] = "FLOAT32";
105
121
  return TciSampleType2;
106
122
  })(TciSampleType || {});
107
- function parseStreamFrame(input) {
123
+ function parseStreamFrame(input, options = {}) {
108
124
  const buffer = toBuffer(input);
109
125
  if (buffer.byteLength < TCI_STREAM_HEADER_BYTES) {
110
126
  throw new TciError("invalid-frame", `TCI stream frame is shorter than ${TCI_STREAM_HEADER_BYTES} bytes`);
@@ -113,15 +129,15 @@ function parseStreamFrame(input) {
113
129
  const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
114
130
  const sampleType = normalizeSampleType(header[2]);
115
131
  const streamType = normalizeStreamType(header[6]);
116
- let channels = header[7];
132
+ let channels = header[7] || options.negotiatedChannels || 0;
117
133
  const bytesPerSample = sampleTypeBytes(sampleType);
118
- const sampleCount = header[5];
134
+ const headerSampleCount = header[5];
119
135
  const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
120
136
  if (channels <= 0) {
121
137
  if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
122
138
  channels = 1;
123
139
  } else {
124
- const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
140
+ const inferredChannels = headerSampleCount > 0 ? actualPayloadLength / headerSampleCount / bytesPerSample : 1;
125
141
  if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
126
142
  throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
127
143
  }
@@ -133,16 +149,28 @@ function parseStreamFrame(input) {
133
149
  if (payloadLength % alignedFrameBytes !== 0) {
134
150
  throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
135
151
  }
152
+ const actualScalarCount = payloadLength / bytesPerSample;
153
+ const requestedSemantics = options.lengthSemantics ?? "auto";
154
+ const lengthSemantics = resolveLengthSemantics(
155
+ requestedSemantics,
156
+ streamType,
157
+ headerSampleCount,
158
+ actualScalarCount,
159
+ channels
160
+ );
161
+ const sampleCount = lengthSemantics === "per-channel" ? headerSampleCount * channels : headerSampleCount;
136
162
  if (streamType !== 3 /* TX_CHRONO */) {
137
- const expectedPerChannelPayloadLength = sampleCount * bytesPerSample * channels;
138
- const expectedScalarPayloadLength = sampleCount * bytesPerSample;
139
- if (payloadLength !== expectedPerChannelPayloadLength && payloadLength !== expectedScalarPayloadLength) {
163
+ const expectedPayloadLength = sampleCount * bytesPerSample;
164
+ if (payloadLength !== expectedPayloadLength) {
140
165
  throw new TciError(
141
166
  "invalid-frame",
142
- `TCI stream frame length mismatch: header says ${sampleCount} samples (${expectedPerChannelPayloadLength} payload bytes), got ${payloadLength}`
167
+ `TCI stream frame length mismatch: header says ${headerSampleCount} samples using ${lengthSemantics} semantics (${expectedPayloadLength} payload bytes), got ${payloadLength}`
143
168
  );
144
169
  }
145
170
  }
171
+ if (sampleCount % channels !== 0) {
172
+ throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
173
+ }
146
174
  return {
147
175
  receiver: header[0],
148
176
  sampleRate: header[1],
@@ -154,7 +182,10 @@ function parseStreamFrame(input) {
154
182
  channels,
155
183
  reserved: header.slice(8),
156
184
  payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
157
- sampleCount
185
+ headerSampleCount,
186
+ sampleCount,
187
+ frameCount: sampleCount / channels,
188
+ lengthSemantics
158
189
  };
159
190
  }
160
191
  function buildStreamFrame(options) {
@@ -168,11 +199,19 @@ function buildStreamFrame(options) {
168
199
  if (payload.byteLength % (bytesPerSample * channels) !== 0) {
169
200
  throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
170
201
  }
171
- const derivedSampleCount = payload.byteLength / bytesPerSample / channels;
202
+ const derivedSampleCount = payload.byteLength / bytesPerSample;
172
203
  const sampleCount = options.sampleCount ?? derivedSampleCount;
173
204
  if (!Number.isInteger(sampleCount) || sampleCount < 0) {
174
205
  throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
175
206
  }
207
+ if (payload.byteLength > 0 && sampleCount !== derivedSampleCount) {
208
+ throw new TciError("invalid-frame", `Explicit scalar sample count ${sampleCount} does not match payload count ${derivedSampleCount}`);
209
+ }
210
+ if (sampleCount % channels !== 0) {
211
+ throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
212
+ }
213
+ const lengthSemantics = options.lengthSemantics === "per-channel" ? "per-channel" : "scalar";
214
+ const headerSampleCount = lengthSemantics === "per-channel" ? sampleCount / channels : sampleCount;
176
215
  const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
177
216
  const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
178
217
  const reserved = options.reserved ?? [];
@@ -182,7 +221,7 @@ function buildStreamFrame(options) {
182
221
  sampleType,
183
222
  options.codec ?? 0,
184
223
  options.crc ?? 0,
185
- sampleCount,
224
+ headerSampleCount,
186
225
  options.streamType,
187
226
  channels,
188
227
  ...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
@@ -191,6 +230,13 @@ function buildStreamFrame(options) {
191
230
  payload.copy(frame, TCI_STREAM_HEADER_BYTES);
192
231
  return frame;
193
232
  }
233
+ function resolveLengthSemantics(requested, streamType, headerSampleCount, actualScalarCount, channels) {
234
+ if (requested !== "auto") return requested;
235
+ if (streamType === 3 /* TX_CHRONO */ && actualScalarCount === 0) return "scalar";
236
+ if (actualScalarCount === headerSampleCount) return "scalar";
237
+ if (actualScalarCount === headerSampleCount * channels) return "per-channel";
238
+ return "scalar";
239
+ }
194
240
  function buildTxAudioFrame(options) {
195
241
  return buildStreamFrame({ ...options, streamType: 2 /* TX_AUDIO_STREAM */ });
196
242
  }
@@ -275,6 +321,15 @@ function payloadToFloat32(frameOrPayload, sampleType) {
275
321
  }
276
322
  return output;
277
323
  }
324
+ function decodeInterleavedIq(frame) {
325
+ if (frame.streamType !== 0 /* IQ_STREAM */) {
326
+ throw new TciError("invalid-frame", "Expected a TCI IQ stream frame");
327
+ }
328
+ if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
329
+ throw new TciError("invalid-frame", `TCI IQ frame must contain interleaved I/Q pairs, got ${frame.channels} channels`);
330
+ }
331
+ return payloadToFloat32(frame);
332
+ }
278
333
  function samplesToPayload(samples, sampleType) {
279
334
  const type = normalizeSampleType(sampleType);
280
335
  const bytes = sampleTypeBytes(type);
@@ -622,13 +677,488 @@ function ensureSemicolon(command) {
622
677
  return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
623
678
  }
624
679
 
680
+ // src/dialect/builtins.ts
681
+ var StandardTciDialect = class {
682
+ id;
683
+ label;
684
+ streamLengthSemantics;
685
+ supportsStreamChannels;
686
+ supportsTxAudioSource;
687
+ supportsIqStream;
688
+ iqSampleRates;
689
+ driveHasTrx;
690
+ detector;
691
+ resolver;
692
+ constructor(options) {
693
+ this.id = options.id;
694
+ this.label = options.label;
695
+ this.streamLengthSemantics = options.streamLengthSemantics;
696
+ this.supportsStreamChannels = options.supportsStreamChannels;
697
+ this.supportsTxAudioSource = options.supportsTxAudioSource;
698
+ this.supportsIqStream = options.supportsIqStream;
699
+ this.iqSampleRates = [...options.iqSampleRates];
700
+ this.driveHasTrx = options.driveHasTrx;
701
+ this.detector = options.detect;
702
+ this.resolver = options.resolve;
703
+ }
704
+ detect(context) {
705
+ return this.detector(context);
706
+ }
707
+ resolve(context) {
708
+ return this.resolver?.(context) ?? this;
709
+ }
710
+ buildDriveSetArgs(trx, value) {
711
+ return this.driveHasTrx ? [trx, value] : [value];
712
+ }
713
+ buildDriveReadArgs(trx) {
714
+ return this.driveHasTrx ? [trx] : [];
715
+ }
716
+ parseDrive(args, defaultTrx) {
717
+ return parseDriveState(args, defaultTrx, this.driveHasTrx);
718
+ }
719
+ buildTuneDriveSetArgs(trx, value) {
720
+ return this.driveHasTrx ? [trx, value] : [value];
721
+ }
722
+ buildTuneDriveReadArgs(trx) {
723
+ return this.driveHasTrx ? [trx] : [];
724
+ }
725
+ parseTuneDrive(args, defaultTrx) {
726
+ return parseDriveState(args, defaultTrx, this.driveHasTrx);
727
+ }
728
+ };
729
+ function parseDriveState(args, defaultTrx, hasTrx) {
730
+ const trx = hasTrx ? Number(args[0]) : defaultTrx;
731
+ const value = Number(args[hasTrx ? 1 : 0]);
732
+ if (!Number.isInteger(trx) || !Number.isFinite(value)) return void 0;
733
+ return { trx, value };
734
+ }
735
+ function version(context) {
736
+ return parseTciVersion(context.identity.protocolVersion);
737
+ }
738
+ function programIncludes(context, value) {
739
+ return context.identity.programName?.toLowerCase().includes(value) ?? false;
740
+ }
741
+ var expertSdr14Dialect = new StandardTciDialect({
742
+ id: "expertsdr-1.4",
743
+ label: "ExpertSDR / TCI 1.4",
744
+ streamLengthSemantics: "per-channel",
745
+ supportsStreamChannels: false,
746
+ supportsTxAudioSource: false,
747
+ supportsIqStream: true,
748
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
749
+ driveHasTrx: false,
750
+ detect: (context) => {
751
+ const parsed = version(context);
752
+ if (!parsed || compareTciVersion(parsed, [1, 4]) > 0) return { score: 0, evidence: [] };
753
+ return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} <= 1.4`] };
754
+ }
755
+ });
756
+ var expertSdrLegacyDialect = new StandardTciDialect({
757
+ id: "expertsdr-1.5-1.8",
758
+ label: "ExpertSDR / TCI 1.5-1.8",
759
+ streamLengthSemantics: "per-channel",
760
+ supportsStreamChannels: false,
761
+ supportsTxAudioSource: false,
762
+ supportsIqStream: true,
763
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
764
+ driveHasTrx: true,
765
+ detect: (context) => {
766
+ const parsed = version(context);
767
+ if (!parsed || compareTciVersion(parsed, [1, 5]) < 0 || compareTciVersion(parsed, [1, 9]) >= 0) return { score: 0, evidence: [] };
768
+ return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} is in 1.5-1.8`] };
769
+ }
770
+ });
771
+ var expertSdrModernDialect = new StandardTciDialect({
772
+ id: "expertsdr-1.9-2.0",
773
+ label: "ExpertSDR / TCI 1.9-2.0",
774
+ streamLengthSemantics: "scalar",
775
+ supportsStreamChannels: true,
776
+ supportsTxAudioSource: true,
777
+ supportsIqStream: true,
778
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
779
+ driveHasTrx: true,
780
+ detect: (context) => {
781
+ const parsed = version(context);
782
+ if (!parsed || compareTciVersion(parsed, [1, 9]) < 0) return { score: 0, evidence: [] };
783
+ const future = compareTciVersion(parsed, [2, 0]) > 0;
784
+ return {
785
+ score: 70 + (programIncludes(context, "expert") ? 15 : 0),
786
+ evidence: [`protocol ${formatVersion(parsed)} uses modern stream negotiation`],
787
+ warnings: future ? [`Unknown future TCI version ${formatVersion(parsed)}; using the modern dialect`] : []
788
+ };
789
+ }
790
+ });
791
+ var thetisDialect = new StandardTciDialect({
792
+ id: "thetis-2.0",
793
+ label: "Thetis / TCI 2.0",
794
+ streamLengthSemantics: "scalar",
795
+ supportsStreamChannels: true,
796
+ supportsTxAudioSource: true,
797
+ supportsIqStream: true,
798
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
799
+ driveHasTrx: true,
800
+ detect: (context) => {
801
+ const evidence = [];
802
+ let score = 0;
803
+ if (programIncludes(context, "thetis")) {
804
+ score += 120;
805
+ evidence.push("PROTOCOL program is Thetis");
806
+ }
807
+ const observed = ["tx_frequency_ex", "tx_profiles_ex", "tx_profile_ex", "calibration_ex"].filter((name) => context.commandNames.has(name));
808
+ if (observed.length > 0) {
809
+ score += 100;
810
+ evidence.push(`Thetis extension commands: ${observed.join(", ")}`);
811
+ }
812
+ if (/anan|hermes|orion|saturn/i.test(context.identity.device ?? "")) {
813
+ score += 30;
814
+ evidence.push(`Thetis-family device: ${context.identity.device}`);
815
+ }
816
+ return { score, evidence };
817
+ }
818
+ });
819
+ var aetherSdrDialect = new StandardTciDialect({
820
+ id: "aethersdr-1.5",
821
+ label: "AetherSDR / TCI 1.5 hybrid",
822
+ streamLengthSemantics: "scalar",
823
+ supportsStreamChannels: true,
824
+ supportsTxAudioSource: true,
825
+ supportsIqStream: true,
826
+ iqSampleRates: [24e3, 48e3, 96e3, 192e3],
827
+ driveHasTrx: true,
828
+ detect: (context) => {
829
+ if (!/^aethersdr$/i.test(context.identity.device ?? "")) return { score: 0, evidence: [] };
830
+ const evidence = [`AetherSDR device identity: ${context.identity.device}`];
831
+ const modernAudioCommands = ["audio_stream_sample_type", "audio_stream_channels", "audio_stream_samples"].filter((name) => context.commandNames.has(name));
832
+ if (modernAudioCommands.length > 0) evidence.push(`Modern audio negotiation: ${modernAudioCommands.join(", ")}`);
833
+ return {
834
+ score: 150,
835
+ evidence,
836
+ warnings: context.identity.protocolVersion === "1.5" ? ["AetherSDR reports TCI 1.5 but uses modern scalar audio stream semantics"] : []
837
+ };
838
+ }
839
+ });
840
+ var genericObservedDialect = new StandardTciDialect({
841
+ id: "generic-observed",
842
+ label: "Generic observed TCI",
843
+ streamLengthSemantics: "auto",
844
+ supportsStreamChannels: true,
845
+ supportsTxAudioSource: true,
846
+ supportsIqStream: false,
847
+ iqSampleRates: [],
848
+ driveHasTrx: true,
849
+ detect: (context) => ({
850
+ score: context.commandNames.has("ready") ? 10 : 0,
851
+ evidence: ["No vendor-specific match; using observed command shapes"],
852
+ warnings: ["Dialect identity is uncertain"]
853
+ }),
854
+ resolve: (context) => {
855
+ const drive = [...context.commands].reverse().find((command) => command.name === "drive");
856
+ const driveHasTrx = (drive?.args.length ?? 0) >= 2;
857
+ return new StandardTciDialect({
858
+ id: "generic-observed",
859
+ label: "Generic observed TCI",
860
+ streamLengthSemantics: "auto",
861
+ supportsStreamChannels: context.commandNames.has("audio_stream_channels"),
862
+ supportsTxAudioSource: compareTciVersion(version(context) ?? [0], [2, 0]) >= 0,
863
+ supportsIqStream: context.commandNames.has("iq_samplerate"),
864
+ iqSampleRates: context.commandNames.has("iq_samplerate") ? [48e3] : [],
865
+ driveHasTrx,
866
+ detect: genericObservedDialect.detect.bind(genericObservedDialect)
867
+ });
868
+ }
869
+ });
870
+ var builtInDialects = [
871
+ aetherSdrDialect,
872
+ thetisDialect,
873
+ expertSdr14Dialect,
874
+ expertSdrLegacyDialect,
875
+ expertSdrModernDialect,
876
+ genericObservedDialect
877
+ ];
878
+ function parseTciVersion(value) {
879
+ if (!value) return void 0;
880
+ const match = value.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
881
+ if (!match) return void 0;
882
+ return match.slice(1).filter((part) => part !== void 0).map(Number);
883
+ }
884
+ function compareTciVersion(left, right) {
885
+ const length = Math.max(left.length, right.length);
886
+ for (let index = 0; index < length; index += 1) {
887
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
888
+ if (difference !== 0) return difference;
889
+ }
890
+ return 0;
891
+ }
892
+ function formatVersion(value) {
893
+ return value.join(".");
894
+ }
895
+
896
+ // src/dialect/registry.ts
897
+ var TciDialectRegistry = class {
898
+ dialects = /* @__PURE__ */ new Map();
899
+ constructor(dialects = builtInDialects) {
900
+ for (const dialect of dialects) this.register(dialect);
901
+ }
902
+ register(dialect) {
903
+ this.dialects.set(dialect.id, dialect);
904
+ }
905
+ get(id) {
906
+ return this.dialects.get(id);
907
+ }
908
+ list() {
909
+ return [...this.dialects.values()];
910
+ }
911
+ select(context, selection = "auto") {
912
+ if (typeof selection === "object") {
913
+ return { dialect: selection, confidence: "manual", evidence: ["Custom dialect supplied by caller"], warnings: [] };
914
+ }
915
+ if (selection !== "auto") {
916
+ const dialect = this.get(selection);
917
+ if (!dialect) throw new TciError("unknown-dialect", `Unknown TCI dialect: ${selection}`);
918
+ return { dialect: dialect.resolve?.(context) ?? dialect, confidence: "manual", evidence: [`Dialect ${selection} selected by caller`], warnings: [] };
919
+ }
920
+ const candidates = this.list().map((dialect) => ({ dialect, result: dialect.detect(context) })).sort((left, right) => right.result.score - left.result.score);
921
+ const selected = candidates[0];
922
+ if (!selected || selected.result.score <= 0) {
923
+ throw new TciError("unknown-dialect", "Unable to identify the TCI server dialect");
924
+ }
925
+ return {
926
+ dialect: selected.dialect.resolve?.(context) ?? selected.dialect,
927
+ confidence: selected.result.score >= 100 ? "high" : selected.result.score >= 70 ? "medium" : "low",
928
+ evidence: selected.result.evidence,
929
+ warnings: selected.result.warnings ?? []
930
+ };
931
+ }
932
+ };
933
+ var defaultTciDialectRegistry = new TciDialectRegistry();
934
+
935
+ // src/dialect/handshake.ts
936
+ var IDENTITY_COMMANDS = /* @__PURE__ */ new Set(["protocol", "device", "trx_count", "channels_count", "channel_count"]);
937
+ var STATE_COMMANDS = /* @__PURE__ */ new Set(["vfo", "modulation", "modulations_list", "trx", "drive"]);
938
+ function parseProtocolIdentity(commands) {
939
+ const protocol = [...commands].reverse().find((command) => command.name === "protocol");
940
+ const device = [...commands].reverse().find((command) => command.name === "device");
941
+ const rawProtocolArgs = protocol?.args ?? [];
942
+ const firstLooksLikeVersion = /^\d+(?:\.\d+){0,2}/.test(rawProtocolArgs[0] ?? "");
943
+ return {
944
+ programName: firstLooksLikeVersion ? void 0 : rawProtocolArgs[0],
945
+ protocolVersion: firstLooksLikeVersion ? rawProtocolArgs[0] : rawProtocolArgs[1],
946
+ rawProtocolArgs: [...rawProtocolArgs],
947
+ device: device?.args.join(",")
948
+ };
949
+ }
950
+ function assertValidTciHandshake(commands) {
951
+ const names = new Set(commands.map((command) => command.name));
952
+ if (!names.has("ready")) throw new TciError("handshake-timeout", "TCI READY was not received");
953
+ const categories = [
954
+ [...IDENTITY_COMMANDS].some((name) => names.has(name)),
955
+ [...STATE_COMMANDS].some((name) => names.has(name))
956
+ ].filter(Boolean).length;
957
+ const identitySignals = [...IDENTITY_COMMANDS].filter((name) => names.has(name)).length;
958
+ if (categories < 2 && identitySignals < 2) {
959
+ throw new TciError("invalid-handshake", "WebSocket opened but did not provide enough TCI initialization evidence");
960
+ }
961
+ }
962
+
963
+ // src/transport/WebSocketTransport.ts
964
+ var import_eventemitter3 = require("eventemitter3");
965
+ var import_ws = __toESM(require("ws"), 1);
966
+ var WebSocketTciTransport = class extends import_eventemitter3.EventEmitter {
967
+ constructor(url, WebSocketImpl = import_ws.default) {
968
+ super();
969
+ this.url = url;
970
+ this.WebSocketImpl = WebSocketImpl;
971
+ }
972
+ url;
973
+ WebSocketImpl;
974
+ socket;
975
+ async connect(timeoutMs) {
976
+ if (this.socket?.readyState === import_ws.default.OPEN) return;
977
+ const socket = new this.WebSocketImpl(this.url);
978
+ this.socket = socket;
979
+ await new Promise((resolve, reject) => {
980
+ const timer = setTimeout(() => {
981
+ cleanup();
982
+ this.terminate();
983
+ reject(new TciError("connect-timeout", `Timed out connecting to ${this.url}`));
984
+ }, timeoutMs);
985
+ const cleanup = () => {
986
+ clearTimeout(timer);
987
+ socket.off("open", onOpen);
988
+ socket.off("close", onCloseBeforeOpen);
989
+ socket.off("error", onErrorBeforeOpen);
990
+ };
991
+ const onOpen = () => {
992
+ cleanup();
993
+ this.attach(socket);
994
+ this.emit("connected");
995
+ resolve();
996
+ };
997
+ const onCloseBeforeOpen = () => {
998
+ cleanup();
999
+ reject(new TciError("disconnected", `Disconnected while connecting to ${this.url}`));
1000
+ };
1001
+ const onErrorBeforeOpen = (error) => {
1002
+ cleanup();
1003
+ reject(toTciError(error, "disconnected"));
1004
+ };
1005
+ socket.once("open", onOpen);
1006
+ socket.once("close", onCloseBeforeOpen);
1007
+ socket.once("error", onErrorBeforeOpen);
1008
+ });
1009
+ }
1010
+ async disconnect(code = 1e3, reason = "client disconnect") {
1011
+ const socket = this.socket;
1012
+ if (!socket || socket.readyState === import_ws.default.CLOSED) return;
1013
+ await new Promise((resolve) => {
1014
+ const timer = setTimeout(resolve, 1e3);
1015
+ timer.unref?.();
1016
+ socket.once("close", () => {
1017
+ clearTimeout(timer);
1018
+ resolve();
1019
+ });
1020
+ socket.once("error", () => {
1021
+ clearTimeout(timer);
1022
+ resolve();
1023
+ });
1024
+ socket.close(code, reason);
1025
+ });
1026
+ }
1027
+ isConnected() {
1028
+ return this.socket?.readyState === import_ws.default.OPEN;
1029
+ }
1030
+ async sendText(raw) {
1031
+ await this.send(raw);
1032
+ }
1033
+ async sendBinary(raw) {
1034
+ await this.send(raw, true);
1035
+ }
1036
+ terminate() {
1037
+ try {
1038
+ this.socket?.terminate();
1039
+ } catch {
1040
+ }
1041
+ }
1042
+ attach(socket) {
1043
+ socket.on("message", (data, isBinary) => {
1044
+ try {
1045
+ const buffer = dataToBuffer(data);
1046
+ if (isBinary) this.emit("binary", buffer);
1047
+ else this.emit("text", buffer.toString("utf8"));
1048
+ } catch (error) {
1049
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
1050
+ }
1051
+ });
1052
+ socket.on("close", (code, reason) => {
1053
+ if (this.socket === socket) this.socket = void 0;
1054
+ this.emit("disconnected", { code, reason: reason.toString("utf8") });
1055
+ });
1056
+ socket.on("error", (error) => this.emit("error", error));
1057
+ }
1058
+ async send(data, binary = false) {
1059
+ const socket = this.socket;
1060
+ if (!socket || socket.readyState !== import_ws.default.OPEN) {
1061
+ throw new TciError("not-connected", "TCI socket is not connected");
1062
+ }
1063
+ await new Promise((resolve, reject) => {
1064
+ socket.send(data, { binary }, (error) => error ? reject(error) : resolve());
1065
+ });
1066
+ }
1067
+ };
1068
+ function dataToBuffer(data) {
1069
+ if (Buffer.isBuffer(data)) return data;
1070
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
1071
+ if (Array.isArray(data)) return Buffer.concat(data.map((item) => dataToBuffer(item)));
1072
+ throw new TciError("protocol-error", "Unsupported WebSocket data type");
1073
+ }
1074
+
625
1075
  // src/client/TciClient.ts
626
- var TciClient = class extends import_eventemitter3.EventEmitter {
1076
+ var TciIqStreamSession = class extends import_eventemitter32.EventEmitter {
1077
+ receiver;
1078
+ _appliedSampleRate;
1079
+ callbacks;
1080
+ closed = false;
1081
+ firstFrame;
1082
+ constructor(receiver, appliedSampleRate, callbacks) {
1083
+ super();
1084
+ this.receiver = receiver;
1085
+ this._appliedSampleRate = appliedSampleRate;
1086
+ this.callbacks = callbacks;
1087
+ }
1088
+ get appliedSampleRate() {
1089
+ return this._appliedSampleRate;
1090
+ }
1091
+ async setSampleRate(sampleRate) {
1092
+ if (this.closed) throw new TciError("cancelled", "TCI IQ stream is closed");
1093
+ const result = await this.callbacks.setSampleRate(sampleRate);
1094
+ this._appliedSampleRate = result.applied;
1095
+ return result;
1096
+ }
1097
+ async close() {
1098
+ if (this.closed) return;
1099
+ this.closed = true;
1100
+ this.rejectFirstFrame(new TciError("cancelled", "TCI IQ stream closed before its first frame"));
1101
+ try {
1102
+ await this.callbacks.close();
1103
+ } finally {
1104
+ this.emit("closed");
1105
+ this.removeAllListeners();
1106
+ }
1107
+ }
1108
+ waitForFirstFrame(timeoutMs) {
1109
+ if (this.firstFrame) return this.firstFrame.promise;
1110
+ let resolvePromise;
1111
+ let rejectPromise;
1112
+ const promise = new Promise((resolve, reject) => {
1113
+ resolvePromise = resolve;
1114
+ rejectPromise = reject;
1115
+ });
1116
+ const timer = setTimeout(() => {
1117
+ this.firstFrame = void 0;
1118
+ rejectPromise(new TciError("command-timeout", `Timed out waiting for TCI IQ frame for receiver ${this.receiver}`));
1119
+ }, timeoutMs);
1120
+ this.firstFrame = { promise, resolve: resolvePromise, reject: rejectPromise, timer };
1121
+ return promise;
1122
+ }
1123
+ _acceptFrame(frame) {
1124
+ if (this.closed || frame.receiver !== this.receiver) return;
1125
+ this._appliedSampleRate = frame.sampleRate;
1126
+ const firstFrame = this.firstFrame;
1127
+ if (firstFrame) {
1128
+ clearTimeout(firstFrame.timer);
1129
+ this.firstFrame = void 0;
1130
+ firstFrame.resolve(frame);
1131
+ }
1132
+ this.emit("frame", frame);
1133
+ }
1134
+ _fail(error) {
1135
+ if (this.closed) return;
1136
+ this.rejectFirstFrame(error);
1137
+ this.emit("error", error);
1138
+ }
1139
+ rejectFirstFrame(error) {
1140
+ const firstFrame = this.firstFrame;
1141
+ if (!firstFrame) return;
1142
+ clearTimeout(firstFrame.timer);
1143
+ this.firstFrame = void 0;
1144
+ firstFrame.reject(error);
1145
+ }
1146
+ };
1147
+ var TciClient = class extends import_eventemitter32.EventEmitter {
627
1148
  options;
628
1149
  WebSocketImpl;
629
- ws;
1150
+ transportFactory;
1151
+ transport;
630
1152
  queue;
631
1153
  state;
1154
+ stateReducers;
1155
+ dialectRegistry;
1156
+ activeDialect;
1157
+ handshakeResult;
1158
+ handshakeError;
1159
+ initializationCommands = [];
1160
+ handshakeWaiter;
1161
+ activeIqSession;
632
1162
  constructor(options) {
633
1163
  super();
634
1164
  this.options = {
@@ -637,13 +1167,17 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
637
1167
  trx: options.trx ?? 0,
638
1168
  vfo: options.vfo ?? 0,
639
1169
  connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
1170
+ handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
640
1171
  commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
641
1172
  writeAckMode: options.writeAckMode ?? "state",
642
1173
  writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
643
1174
  writeSettleMs: options.writeSettleMs ?? 0,
644
- frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
1175
+ frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
1176
+ dialect: options.dialect ?? "auto"
645
1177
  };
646
- this.WebSocketImpl = options.WebSocketImpl ?? import_ws.default;
1178
+ this.dialectRegistry = options.dialectRegistry ?? defaultTciDialectRegistry;
1179
+ this.WebSocketImpl = options.WebSocketImpl ?? import_ws2.default;
1180
+ this.transportFactory = options.transportFactory ?? ((url) => new WebSocketTciTransport(url, this.WebSocketImpl));
647
1181
  this.queue = new TciCommandQueue({
648
1182
  timeoutMs: this.options.commandTimeoutMs,
649
1183
  send: (raw) => this.sendRaw(raw)
@@ -659,58 +1193,113 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
659
1193
  pttSource: {},
660
1194
  tune: {},
661
1195
  drive: {},
1196
+ tuneDrive: {},
662
1197
  split: {},
1198
+ dialectWarnings: [],
663
1199
  rxSensors: {},
664
- txSensors: {}
1200
+ txSensors: {},
1201
+ iq: { activeReceivers: {} },
1202
+ dds: {}
665
1203
  };
1204
+ this.stateReducers = this.createStateReducers();
666
1205
  }
667
1206
  async connect() {
668
- if (this.ws?.readyState === import_ws.default.OPEN) {
669
- return;
1207
+ if (this.transport?.isConnected()) {
1208
+ return this.handshakeResult ?? this.waitForHandshake();
670
1209
  }
671
- if (this.ws && this.ws.readyState === import_ws.default.CONNECTING) {
672
- await this.waitForOpen(this.ws);
673
- return;
1210
+ this.resetHandshake();
1211
+ const transport = this.transportFactory(this.options.url);
1212
+ this.transport = transport;
1213
+ this.attachTransport(transport);
1214
+ await transport.connect(this.options.connectTimeoutMs);
1215
+ this.state.connected = true;
1216
+ this.queue.setConnected(true);
1217
+ this.emit("connected");
1218
+ this.emitState();
1219
+ try {
1220
+ return await this.waitForHandshake();
1221
+ } catch (error) {
1222
+ transport.terminate();
1223
+ throw error;
674
1224
  }
675
- const ws = new this.WebSocketImpl(this.options.url);
676
- this.ws = ws;
677
- await this.waitForOpen(ws);
678
1225
  }
679
1226
  async disconnect(code = 1e3, reason = "client disconnect") {
680
- const ws = this.ws;
681
- if (!ws) {
682
- return;
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
- });
1227
+ const transport = this.transport;
1228
+ if (!transport) return;
1229
+ await transport.disconnect(code, reason);
706
1230
  this.handleClose();
707
1231
  }
708
1232
  isConnected() {
709
- return this.ws?.readyState === import_ws.default.OPEN;
1233
+ return this.transport?.isConnected() ?? false;
710
1234
  }
711
1235
  getState() {
712
1236
  return cloneState(this.state);
713
1237
  }
1238
+ getHandshakeResult() {
1239
+ return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
1240
+ }
1241
+ getIqCapabilities() {
1242
+ const dialect = this.requireDialect();
1243
+ return {
1244
+ supported: dialect.supportsIqStream,
1245
+ currentSampleRate: this.state.iq.sampleRate,
1246
+ supportedSampleRates: [...dialect.iqSampleRates]
1247
+ };
1248
+ }
1249
+ async setIqSampleRate(sampleRate, timeoutMs = this.options.commandTimeoutMs) {
1250
+ const capabilities = this.getIqCapabilities();
1251
+ const requested = Math.round(sampleRate);
1252
+ if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
1253
+ if (!Number.isFinite(requested) || requested <= 0) throw new TciError("protocol-error", `Invalid TCI IQ sample rate: ${sampleRate}`);
1254
+ if (capabilities.supportedSampleRates.length > 0 && !capabilities.supportedSampleRates.includes(requested)) {
1255
+ throw new TciError("protocol-error", `TCI IQ sample rate ${requested} is not supported by dialect ${this.requireDialect().id}`);
1256
+ }
1257
+ if (this.state.iq.sampleRate === requested) return writeResult(requested, requested, "state");
1258
+ const waiter = this.waitForCommand((command) => command.name === "iq_samplerate", timeoutMs, "IQ_SAMPLERATE readback");
1259
+ try {
1260
+ await this.sendCommand("IQ_SAMPLERATE", [requested], { waitForReply: false });
1261
+ const reply = await waiter.promise;
1262
+ const applied = parseNumber(reply.args[0]);
1263
+ if (applied === void 0 || applied <= 0) throw new TciError("protocol-error", `Invalid IQ_SAMPLERATE readback: ${reply.raw}`);
1264
+ return writeResult(requested, applied, "reply");
1265
+ } finally {
1266
+ waiter.cancel();
1267
+ }
1268
+ }
1269
+ async openIqStream(options = {}) {
1270
+ if (this.activeIqSession) throw new TciError("protocol-error", "This TCI client already has an active IQ stream session");
1271
+ const capabilities = this.getIqCapabilities();
1272
+ if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
1273
+ const receiver = Math.floor(options.receiver ?? this.options.receiver);
1274
+ if (!Number.isInteger(receiver) || receiver < 0) throw new TciError("protocol-error", `Invalid TCI IQ receiver: ${receiver}`);
1275
+ const requestedRate = options.sampleRate ?? capabilities.currentSampleRate ?? capabilities.supportedSampleRates[0] ?? 48e3;
1276
+ const rateResult = await this.setIqSampleRate(requestedRate);
1277
+ let session;
1278
+ session = new TciIqStreamSession(receiver, rateResult.applied, {
1279
+ setSampleRate: (rate) => this.setIqSampleRate(rate),
1280
+ close: async () => {
1281
+ try {
1282
+ if (this.isConnected()) await this.sendCommand("IQ_STOP", [receiver], { waitForReply: false });
1283
+ } finally {
1284
+ this.state.iq.activeReceivers[String(receiver)] = false;
1285
+ if (this.activeIqSession === session) this.activeIqSession = void 0;
1286
+ this.emitState();
1287
+ }
1288
+ }
1289
+ });
1290
+ this.activeIqSession = session;
1291
+ const firstFrame = session.waitForFirstFrame(options.firstFrameTimeoutMs ?? 5e3);
1292
+ try {
1293
+ await this.sendCommand("IQ_START", [receiver], { waitForReply: false });
1294
+ this.state.iq.activeReceivers[String(receiver)] = true;
1295
+ this.emitState();
1296
+ await firstFrame;
1297
+ return session;
1298
+ } catch (error) {
1299
+ await session.close().catch(() => void 0);
1300
+ throw error;
1301
+ }
1302
+ }
714
1303
  async sendCommand(name, args = [], options = {}) {
715
1304
  const raw = formatTciCommand(name, args);
716
1305
  if (options.waitForReply === false) {
@@ -784,6 +1373,9 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
784
1373
  }
785
1374
  async setPtt(enabled, options = {}) {
786
1375
  const trx = options.trx ?? this.options.trx;
1376
+ if (options.source && !this.requireDialect().supportsTxAudioSource) {
1377
+ throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support a TRX audio source argument`);
1378
+ }
787
1379
  const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
788
1380
  await this.sendStateWrite(
789
1381
  "TRX",
@@ -797,16 +1389,107 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
797
1389
  const reply = await this.request("TRX", [trx]);
798
1390
  return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
799
1391
  }
800
- async setTune(enabled, trx = this.options.trx) {
801
- await this.sendCommand("TUNE", [trx, enabled]);
1392
+ async setTune(enabled, trx = this.options.trx, options = {}) {
1393
+ try {
1394
+ await this.sendStateWrite(
1395
+ "TUNE",
1396
+ [trx, enabled],
1397
+ (state) => state.tune[String(trx)] === enabled,
1398
+ `TUNE:${trx},${enabled}`,
1399
+ options
1400
+ );
1401
+ } catch (error) {
1402
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1403
+ await this.request("TUNE", [trx], { timeoutMs: options.timeoutMs });
1404
+ if (this.state.tune[String(trx)] !== enabled) throw error;
1405
+ }
802
1406
  }
803
1407
  async setDrive(value, trx = this.options.trx) {
804
- await this.sendCommand("DRIVE", [trx, value]);
1408
+ await this.setDriveWithResult(value, trx);
1409
+ }
1410
+ async setDriveWithResult(value, trx = this.options.trx, options = {}) {
1411
+ const requested = normalizePercent(value);
1412
+ const dialect = this.requireDialect();
1413
+ if (this.state.drive[String(trx)] === requested) {
1414
+ return writeResult(requested, requested, "state");
1415
+ }
1416
+ const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
1417
+ const waiter = this.waitForCommand(
1418
+ (command) => command.name === "drive" && dialect.parseDrive(command.args, trx)?.trx === trx,
1419
+ Math.min(500, timeoutMs),
1420
+ `DRIVE state for TRX ${trx}`
1421
+ );
1422
+ await this.sendCommand("DRIVE", dialect.buildDriveSetArgs(trx, requested), { waitForReply: false });
1423
+ try {
1424
+ const command = await waiter.promise;
1425
+ const applied2 = dialect.parseDrive(command.args, trx)?.value;
1426
+ if (applied2 !== void 0) return writeResult(requested, applied2, "state");
1427
+ } catch (error) {
1428
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1429
+ } finally {
1430
+ waiter.cancel();
1431
+ }
1432
+ const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx), {
1433
+ timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
1434
+ });
1435
+ const applied = dialect.parseDrive(reply.args, trx)?.value;
1436
+ if (applied === void 0) throw new TciError("protocol-error", `Invalid DRIVE readback: ${reply.raw}`);
1437
+ return writeResult(requested, applied, "readback");
1438
+ }
1439
+ async getDrive(trx = this.options.trx) {
1440
+ const dialect = this.requireDialect();
1441
+ const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx));
1442
+ return dialect.parseDrive(reply.args, trx)?.value ?? this.state.drive[String(trx)];
1443
+ }
1444
+ async setTuneDrive(value, trx = this.options.trx, options = {}) {
1445
+ const requested = normalizePercent(value);
1446
+ const dialect = this.requireDialect();
1447
+ if (this.state.tuneDrive[String(trx)] === requested) return writeResult(requested, requested, "state");
1448
+ const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
1449
+ const waiter = this.waitForCommand(
1450
+ (command) => command.name === "tune_drive" && dialect.parseTuneDrive(command.args, trx)?.trx === trx,
1451
+ Math.min(500, timeoutMs),
1452
+ `TUNE_DRIVE state for TRX ${trx}`
1453
+ );
1454
+ await this.sendCommand("TUNE_DRIVE", dialect.buildTuneDriveSetArgs(trx, requested), { waitForReply: false });
1455
+ try {
1456
+ const command = await waiter.promise;
1457
+ const applied2 = dialect.parseTuneDrive(command.args, trx)?.value;
1458
+ if (applied2 !== void 0) return writeResult(requested, applied2, "state");
1459
+ } catch (error) {
1460
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1461
+ } finally {
1462
+ waiter.cancel();
1463
+ }
1464
+ const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx), {
1465
+ timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
1466
+ });
1467
+ const applied = dialect.parseTuneDrive(reply.args, trx)?.value;
1468
+ if (applied === void 0) throw new TciError("protocol-error", `Invalid TUNE_DRIVE readback: ${reply.raw}`);
1469
+ return writeResult(requested, applied, "readback");
805
1470
  }
806
- async setSplit(enabled, trx = this.options.trx) {
807
- await this.sendCommand("SPLIT_ENABLE", [trx, enabled]);
1471
+ async getTuneDrive(trx = this.options.trx) {
1472
+ const dialect = this.requireDialect();
1473
+ const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx));
1474
+ return dialect.parseTuneDrive(reply.args, trx)?.value ?? this.state.tuneDrive[String(trx)];
1475
+ }
1476
+ async setSplit(enabled, trx = this.options.trx, options = {}) {
1477
+ try {
1478
+ await this.sendStateWrite(
1479
+ "SPLIT_ENABLE",
1480
+ [trx, enabled],
1481
+ (state) => state.split[String(trx)] === enabled,
1482
+ `SPLIT_ENABLE:${trx},${enabled}`,
1483
+ options
1484
+ );
1485
+ } catch (error) {
1486
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1487
+ await this.request("SPLIT_ENABLE", [trx], { timeoutMs: options.timeoutMs });
1488
+ if (this.state.split[String(trx)] !== enabled) throw error;
1489
+ }
808
1490
  }
809
1491
  async configureAudio(config) {
1492
+ const dialect = this.requireDialect();
810
1493
  const audio = {
811
1494
  sampleRate: config.sampleRate,
812
1495
  sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
@@ -817,11 +1500,13 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
817
1500
  };
818
1501
  this.state.audio = audio;
819
1502
  await this.sendCommand("AUDIO_SAMPLERATE", [audio.sampleRate], { waitForReply: false });
820
- await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
821
- await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
822
- await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
823
- if (audio.txBufferingMs !== void 0) {
824
- await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
1503
+ if (dialect.supportsStreamChannels) {
1504
+ await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
1505
+ await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
1506
+ await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
1507
+ if (audio.txBufferingMs !== void 0) {
1508
+ await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
1509
+ }
825
1510
  }
826
1511
  this.emitState();
827
1512
  }
@@ -840,12 +1525,16 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
840
1525
  }
841
1526
  }
842
1527
  sendTxAudio(options) {
843
- const frame = buildTxAudioFrame({ receiver: this.options.receiver, ...options });
1528
+ const frame = buildTxAudioFrame({
1529
+ receiver: this.options.receiver,
1530
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1531
+ ...options
1532
+ });
844
1533
  this.sendRawBinary(frame);
845
1534
  }
846
1535
  sendTxAudioForChrono(request, samples) {
847
1536
  const channels = Math.max(1, Math.floor(request.channels || 1));
848
- const targetSampleLength = Math.max(0, Math.floor(request.sampleCount) * channels);
1537
+ const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
849
1538
  const output = new Float32Array(targetSampleLength);
850
1539
  const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
851
1540
  output.set(source.subarray(0, output.length));
@@ -943,78 +1632,147 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
943
1632
  cancel: cleanup
944
1633
  };
945
1634
  }
946
- waitForOpen(ws) {
1635
+ waitForCommand(predicate, timeoutMs, description) {
1636
+ let timer;
1637
+ let resolvePromise;
1638
+ let rejectPromise;
1639
+ const cleanup = () => {
1640
+ if (timer) clearTimeout(timer);
1641
+ timer = void 0;
1642
+ this.off("command", onCommand);
1643
+ this.off("disconnected", onDisconnected);
1644
+ };
1645
+ const onCommand = (command) => {
1646
+ if (!predicate(command)) return;
1647
+ cleanup();
1648
+ resolvePromise(command);
1649
+ };
1650
+ const onDisconnected = () => {
1651
+ cleanup();
1652
+ rejectPromise(new TciError("disconnected", `Disconnected while waiting for ${description}`));
1653
+ };
1654
+ const promise = new Promise((resolve, reject) => {
1655
+ resolvePromise = resolve;
1656
+ rejectPromise = reject;
1657
+ timer = setTimeout(() => {
1658
+ cleanup();
1659
+ reject(new TciError("command-timeout", `Timed out waiting for ${description}`));
1660
+ }, timeoutMs);
1661
+ this.on("command", onCommand);
1662
+ this.on("disconnected", onDisconnected);
1663
+ });
1664
+ return { promise, cancel: cleanup };
1665
+ }
1666
+ resetHandshake() {
1667
+ this.rejectHandshake(new TciError("cancelled", "TCI handshake replaced by a new connection"));
1668
+ this.handshakeResult = void 0;
1669
+ this.handshakeError = void 0;
1670
+ this.activeDialect = void 0;
1671
+ this.initializationCommands = [];
1672
+ this.state.ready = false;
1673
+ this.state.protocol = void 0;
1674
+ this.state.protocolName = void 0;
1675
+ this.state.protocolVersion = void 0;
1676
+ this.state.dialectId = void 0;
1677
+ this.state.dialectConfidence = void 0;
1678
+ this.state.dialectWarnings = [];
1679
+ }
1680
+ waitForHandshake() {
1681
+ if (this.handshakeResult) return Promise.resolve(cloneHandshake(this.handshakeResult));
1682
+ if (this.handshakeError) return Promise.reject(this.handshakeError);
1683
+ if (this.handshakeWaiter) {
1684
+ return new Promise((resolve, reject) => {
1685
+ const onHandshake = (result) => {
1686
+ cleanup();
1687
+ resolve(result);
1688
+ };
1689
+ const onError = (error) => {
1690
+ cleanup();
1691
+ reject(error);
1692
+ };
1693
+ const cleanup = () => {
1694
+ this.off("handshake", onHandshake);
1695
+ this.off("error", onError);
1696
+ };
1697
+ this.once("handshake", onHandshake);
1698
+ this.once("error", onError);
1699
+ });
1700
+ }
947
1701
  return new Promise((resolve, reject) => {
948
1702
  const timer = setTimeout(() => {
949
- cleanup();
950
- try {
951
- ws.terminate();
952
- } catch {
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);
1703
+ const error = new TciError("handshake-timeout", `Timed out waiting for TCI READY from ${this.options.url}`);
1704
+ this.handshakeWaiter = void 0;
1705
+ reject(error);
1706
+ }, this.options.handshakeTimeoutMs);
1707
+ this.handshakeWaiter = { resolve, reject, timer };
984
1708
  });
985
1709
  }
986
- attachSocket(ws) {
987
- ws.on("message", (data, isBinary) => this.handleMessage(data, isBinary));
988
- ws.on("close", () => this.handleClose());
989
- ws.on("error", (error) => this.handleError(error));
1710
+ finalizeHandshake() {
1711
+ if (this.handshakeResult) return this.handshakeResult;
1712
+ assertValidTciHandshake(this.initializationCommands);
1713
+ const identity = parseProtocolIdentity(this.initializationCommands);
1714
+ const commandNames = [...new Set(this.initializationCommands.map((command) => command.name))];
1715
+ const dialect = this.dialectRegistry.select(
1716
+ { identity, commands: this.initializationCommands, commandNames: new Set(commandNames) },
1717
+ this.options.dialect
1718
+ );
1719
+ const result = { identity, dialect, ready: true, commandNames };
1720
+ this.handshakeResult = result;
1721
+ this.activeDialect = dialect.dialect;
1722
+ this.state.protocolName = identity.programName;
1723
+ this.state.protocolVersion = identity.protocolVersion;
1724
+ this.state.protocol = identity.protocolVersion ?? identity.programName;
1725
+ this.state.device = identity.device ?? this.state.device;
1726
+ this.state.dialectId = dialect.dialect.id;
1727
+ this.state.dialectConfidence = dialect.confidence;
1728
+ this.state.dialectWarnings = [...dialect.warnings];
1729
+ const waiter = this.handshakeWaiter;
1730
+ this.handshakeWaiter = void 0;
1731
+ if (waiter) {
1732
+ clearTimeout(waiter.timer);
1733
+ waiter.resolve(cloneHandshake(result));
1734
+ }
1735
+ this.emit("handshake", cloneHandshake(result));
1736
+ return result;
1737
+ }
1738
+ rejectHandshake(error) {
1739
+ const waiter = this.handshakeWaiter;
1740
+ this.handshakeWaiter = void 0;
1741
+ if (!waiter) return;
1742
+ clearTimeout(waiter.timer);
1743
+ waiter.reject(error);
1744
+ }
1745
+ requireDialect() {
1746
+ if (!this.activeDialect) throw new TciError("invalid-handshake", "TCI dialect is not available before READY");
1747
+ return this.activeDialect;
1748
+ }
1749
+ attachTransport(transport) {
1750
+ transport.on("text", (raw) => this.handleText(raw));
1751
+ transport.on("binary", (raw) => this.handleBinary(raw));
1752
+ transport.on("disconnected", (reason) => this.handleClose(reason));
1753
+ transport.on("error", (error) => this.handleError(error));
990
1754
  }
991
1755
  async sendRaw(raw) {
992
- const ws = this.ws;
993
- if (!ws || ws.readyState !== import_ws.default.OPEN) {
1756
+ const transport = this.transport;
1757
+ if (!transport?.isConnected()) {
994
1758
  throw new TciError("not-connected", "TCI socket is not connected");
995
1759
  }
996
1760
  this.emit("tci:tx", raw);
997
- await new Promise((resolve, reject) => {
998
- ws.send(raw, (error) => error ? reject(error) : resolve());
999
- });
1761
+ await transport.sendText(raw);
1000
1762
  }
1001
1763
  sendRawBinary(raw) {
1002
- const ws = this.ws;
1003
- if (!ws || ws.readyState !== import_ws.default.OPEN) {
1764
+ const transport = this.transport;
1765
+ if (!transport?.isConnected()) {
1004
1766
  throw new TciError("not-connected", "TCI socket is not connected");
1005
1767
  }
1006
- ws.send(raw, { binary: true });
1768
+ void transport.sendBinary(raw).catch((error) => this.handleError(error));
1007
1769
  }
1008
- handleMessage(data, isBinary) {
1770
+ handleText(raw) {
1009
1771
  try {
1010
- if (isBinary) {
1011
- this.handleBinary(data);
1012
- return;
1013
- }
1014
- const raw = dataToBuffer(data).toString("utf8");
1015
1772
  const commands = parseTciText(raw);
1016
1773
  this.emit("tci:rx", raw, commands);
1017
1774
  for (const command of commands) {
1775
+ if (!this.handshakeResult) this.initializationCommands.push(command);
1018
1776
  this.queue.handleCommand(command);
1019
1777
  this.applyCommand(command);
1020
1778
  this.emit("command", command);
@@ -1024,10 +1782,29 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
1024
1782
  }
1025
1783
  }
1026
1784
  handleBinary(data) {
1027
- const frame = parseStreamFrame(dataToBuffer(data));
1785
+ const frame = parseStreamFrame(data, {
1786
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1787
+ negotiatedChannels: this.state.audio?.channels
1788
+ });
1028
1789
  this.emit("tci:binary", frame);
1029
1790
  this.emit("binary", frame);
1030
1791
  switch (frame.streamType) {
1792
+ case 0 /* IQ_STREAM */: {
1793
+ if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
1794
+ throw new TciError("invalid-frame", "TCI IQ stream frame must contain interleaved I/Q pairs");
1795
+ }
1796
+ this.state.iq.sampleRate = frame.sampleRate;
1797
+ const iqFrame = {
1798
+ frame,
1799
+ receiver: frame.receiver,
1800
+ sampleRate: frame.sampleRate,
1801
+ centerFrequency: this.state.dds[String(frame.receiver)],
1802
+ complexSampleCount: frame.frameCount
1803
+ };
1804
+ this.emit("iqFrame", iqFrame);
1805
+ this.activeIqSession?._acceptFrame(iqFrame);
1806
+ break;
1807
+ }
1031
1808
  case 1 /* RX_AUDIO_STREAM */:
1032
1809
  this.emit("rxAudioFrame", frame);
1033
1810
  break;
@@ -1038,7 +1815,8 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
1038
1815
  sampleRate: frame.sampleRate,
1039
1816
  channels: frame.channels,
1040
1817
  sampleType: frame.sampleType,
1041
- sampleCount: frame.sampleCount
1818
+ sampleCount: frame.sampleCount,
1819
+ frameCount: frame.frameCount
1042
1820
  });
1043
1821
  break;
1044
1822
  case 4 /* LINEOUT_STREAM */:
@@ -1050,80 +1828,92 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
1050
1828
  }
1051
1829
  applyCommand(command) {
1052
1830
  const readyBefore = this.state.ready;
1053
- switch (command.name) {
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
- }
1831
+ this.stateReducers.get(command.name)?.(command.args);
1122
1832
  if (!readyBefore && this.state.ready) {
1123
- this.emit("ready", this.getState());
1833
+ try {
1834
+ this.finalizeHandshake();
1835
+ this.emit("ready", this.getState());
1836
+ } catch (error) {
1837
+ const tciError = toTciError(error, "invalid-handshake");
1838
+ this.handshakeError = tciError;
1839
+ this.state.ready = false;
1840
+ this.rejectHandshake(tciError);
1841
+ this.handleError(tciError);
1842
+ }
1124
1843
  }
1125
1844
  this.emitState();
1126
1845
  }
1846
+ createStateReducers() {
1847
+ const reducers = /* @__PURE__ */ new Map();
1848
+ reducers.set("ready", (args) => {
1849
+ this.state.ready = args.length === 0 ? true : parseBoolean(args[0]) ?? true;
1850
+ });
1851
+ reducers.set("protocol", (args) => {
1852
+ if (/^\d+(?:\.\d+){0,2}/.test(args[0] ?? "")) {
1853
+ this.state.protocol = args[0];
1854
+ this.state.protocolVersion = args[0];
1855
+ } else {
1856
+ this.state.protocolName = args[0];
1857
+ this.state.protocolVersion = args[1];
1858
+ this.state.protocol = args[1] ?? args[0];
1859
+ }
1860
+ });
1861
+ reducers.set("device", (args) => {
1862
+ this.state.device = args.join(",");
1863
+ });
1864
+ reducers.set("receive_only", (args) => {
1865
+ this.state.receiveOnly = parseBoolean(args[0]);
1866
+ });
1867
+ reducers.set("trx_count", (args) => {
1868
+ this.state.trxCount = parseNumber(args[0]);
1869
+ });
1870
+ const channelCount = (args) => {
1871
+ this.state.channelCount = parseNumber(args[0]);
1872
+ };
1873
+ reducers.set("channels_count", channelCount);
1874
+ reducers.set("channel_count", channelCount);
1875
+ reducers.set("vfo_limits", (args) => {
1876
+ this.state.vfoLimits = parseNumberPair(args);
1877
+ });
1878
+ reducers.set("if_limits", (args) => {
1879
+ this.state.ifLimits = parseNumberPair(args);
1880
+ });
1881
+ reducers.set("modulations_list", (args) => {
1882
+ this.state.modulations = args.map((mode) => mode.toLowerCase());
1883
+ });
1884
+ reducers.set("vfo", (args) => this.applyVfo(args));
1885
+ reducers.set("dds", (args) => {
1886
+ const receiver = parseNumber(args[0]);
1887
+ const frequency = parseNumber(args[1]);
1888
+ if (receiver !== void 0 && frequency !== void 0 && frequency >= 0) this.state.dds[String(receiver)] = frequency;
1889
+ });
1890
+ reducers.set("modulation", (args) => this.applyModulation(args));
1891
+ reducers.set("trx", (args) => this.applyTrx(args));
1892
+ reducers.set("tune", (args) => this.applyBooleanByFirstArg(this.state.tune, args));
1893
+ reducers.set("drive", (args) => this.applyDrive(args));
1894
+ reducers.set("tune_drive", (args) => this.applyTuneDrive(args));
1895
+ reducers.set("split_enable", (args) => this.applyBooleanByFirstArg(this.state.split, args));
1896
+ reducers.set("rx_channel_sensors", (args) => this.applyRxChannelSensors(args));
1897
+ reducers.set("rx_sensors", (args) => this.applyRxSensors(args));
1898
+ reducers.set("tx_sensors", (args) => this.applyTxSensors(args));
1899
+ reducers.set("audio_samplerate", (args) => this.updateAudioState({ sampleRate: parseNumber(args[0]) }));
1900
+ reducers.set("audio_stream_sample_type", (args) => this.updateAudioState({ sampleType: parseSampleType(args[0]) }));
1901
+ reducers.set("audio_stream_channels", (args) => this.updateAudioState({ channels: parseNumber(args[0]) }));
1902
+ reducers.set("audio_stream_samples", (args) => this.updateAudioState({ samplesPerFrame: parseNumber(args[0]) }));
1903
+ reducers.set("tx_stream_audio_buffering", (args) => this.updateAudioState({ txBufferingMs: parseNumber(args[0]) }));
1904
+ reducers.set("audio_start", () => this.updateAudioState({ running: true }));
1905
+ reducers.set("audio_stop", () => this.updateAudioState({ running: false }));
1906
+ reducers.set("iq_samplerate", (args) => {
1907
+ this.state.iq.sampleRate = parseNumber(args[0]);
1908
+ });
1909
+ reducers.set("iq_start", (args) => {
1910
+ this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = true;
1911
+ });
1912
+ reducers.set("iq_stop", (args) => {
1913
+ this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = false;
1914
+ });
1915
+ return reducers;
1916
+ }
1127
1917
  applyVfo(args) {
1128
1918
  if (args.length < 3) {
1129
1919
  return;
@@ -1170,18 +1960,22 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
1170
1960
  }
1171
1961
  }
1172
1962
  applyDrive(args) {
1173
- if (args.length === 1) {
1174
- const value2 = parseNumber(args[0]);
1175
- if (value2 !== void 0) {
1176
- this.state.drive[String(this.options.trx)] = value2;
1177
- }
1178
- return;
1179
- }
1180
- const trx = args[0] ?? String(this.options.trx);
1181
- const value = parseNumber(args[1]);
1182
- if (value !== void 0) {
1183
- this.state.drive[trx] = value;
1184
- }
1963
+ const parsed = this.activeDialect?.parseDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
1964
+ if (parsed) this.state.drive[String(parsed.trx)] = parsed.value;
1965
+ }
1966
+ applyTuneDrive(args) {
1967
+ const parsed = this.activeDialect?.parseTuneDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
1968
+ if (parsed) this.state.tuneDrive[String(parsed.trx)] = parsed.value;
1969
+ }
1970
+ updateAudioState(update) {
1971
+ this.state.audio = {
1972
+ sampleRate: update.sampleRate ?? this.state.audio?.sampleRate ?? 12e3,
1973
+ sampleType: update.sampleType ?? this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
1974
+ channels: update.channels ?? this.state.audio?.channels ?? 1,
1975
+ samplesPerFrame: update.samplesPerFrame ?? this.state.audio?.samplesPerFrame ?? 512,
1976
+ txBufferingMs: update.txBufferingMs ?? this.state.audio?.txBufferingMs,
1977
+ running: update.running ?? this.state.audio?.running ?? false
1978
+ };
1185
1979
  }
1186
1980
  applyRxChannelSensors(args) {
1187
1981
  if (args.length < 3) {
@@ -1217,11 +2011,16 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
1217
2011
  };
1218
2012
  }
1219
2013
  handleClose(reason) {
1220
- this.ws = void 0;
2014
+ const transport = this.transport;
2015
+ this.transport = void 0;
2016
+ transport?.removeAllListeners();
1221
2017
  const wasConnected = this.state.connected;
1222
2018
  this.state.connected = false;
1223
2019
  this.state.ready = false;
1224
2020
  this.queue.setConnected(false);
2021
+ this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
2022
+ this.activeIqSession?._fail(new TciError("disconnected", "TCI connection closed while IQ stream was active", reason));
2023
+ this.activeIqSession = void 0;
1225
2024
  if (wasConnected) {
1226
2025
  this.emit("disconnected", reason);
1227
2026
  this.emitState();
@@ -1238,18 +2037,6 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
1238
2037
  function createTciClient(options) {
1239
2038
  return new TciClient(options);
1240
2039
  }
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
2040
  function rxVfoKey(receiver, vfo) {
1254
2041
  return `${receiver}:${vfo}`;
1255
2042
  }
@@ -1273,6 +2060,27 @@ function parseBoolean(value) {
1273
2060
  }
1274
2061
  return void 0;
1275
2062
  }
2063
+ function parseSampleType(value) {
2064
+ if (!value) return void 0;
2065
+ try {
2066
+ return normalizeSampleType(value.toLowerCase());
2067
+ } catch {
2068
+ return void 0;
2069
+ }
2070
+ }
2071
+ function parseObservedDrive(args, defaultTrx) {
2072
+ const hasTrx = args.length >= 2;
2073
+ const trx = hasTrx ? parseNumber(args[0]) : defaultTrx;
2074
+ const value = parseNumber(args[hasTrx ? 1 : 0]);
2075
+ return trx === void 0 || value === void 0 ? void 0 : { trx, value };
2076
+ }
2077
+ function normalizePercent(value) {
2078
+ if (!Number.isFinite(value)) throw new TciError("protocol-error", `Invalid TCI percentage: ${value}`);
2079
+ return Math.round(Math.max(0, Math.min(100, value)));
2080
+ }
2081
+ function writeResult(requested, applied, acknowledgement) {
2082
+ return { requested, applied, outcome: requested === applied ? "applied" : "clamped", acknowledgement };
2083
+ }
1276
2084
  function parseNumberPair(args) {
1277
2085
  const first = parseNumber(args[0]);
1278
2086
  const second = parseNumber(args[1]);
@@ -1288,10 +2096,26 @@ function cloneState(state) {
1288
2096
  pttSource: { ...state.pttSource },
1289
2097
  tune: { ...state.tune },
1290
2098
  drive: { ...state.drive },
2099
+ tuneDrive: { ...state.tuneDrive },
1291
2100
  split: { ...state.split },
2101
+ dialectWarnings: [...state.dialectWarnings],
1292
2102
  rxSensors: cloneNested(state.rxSensors),
1293
2103
  txSensors: cloneNested(state.txSensors),
1294
- audio: state.audio ? { ...state.audio } : void 0
2104
+ audio: state.audio ? { ...state.audio } : void 0,
2105
+ iq: { ...state.iq, activeReceivers: { ...state.iq.activeReceivers } },
2106
+ dds: { ...state.dds }
2107
+ };
2108
+ }
2109
+ function cloneHandshake(result) {
2110
+ return {
2111
+ identity: { ...result.identity, rawProtocolArgs: [...result.identity.rawProtocolArgs] },
2112
+ dialect: {
2113
+ ...result.dialect,
2114
+ evidence: [...result.dialect.evidence],
2115
+ warnings: [...result.dialect.warnings]
2116
+ },
2117
+ ready: true,
2118
+ commandNames: [...result.commandNames]
1295
2119
  };
1296
2120
  }
1297
2121
  function cloneNested(value) {
@@ -1302,30 +2126,46 @@ function cloneNested(value) {
1302
2126
  TCI_STREAM_HEADER_BYTES,
1303
2127
  TciClient,
1304
2128
  TciCommandQueue,
2129
+ TciDialectRegistry,
1305
2130
  TciError,
2131
+ TciIqStreamSession,
1306
2132
  TciSampleType,
1307
2133
  TciStreamType,
2134
+ WebSocketTciTransport,
2135
+ aetherSdrDialect,
2136
+ assertValidTciHandshake,
1308
2137
  buildStreamFrame,
1309
2138
  buildTxAudioFrame,
2139
+ builtInDialects,
1310
2140
  commandKey,
2141
+ compareTciVersion,
1311
2142
  createTciClient,
2143
+ decodeInterleavedIq,
2144
+ defaultTciDialectRegistry,
1312
2145
  deinterleaveChannels,
1313
2146
  escapeTciText,
2147
+ expertSdr14Dialect,
2148
+ expertSdrLegacyDialect,
2149
+ expertSdrModernDialect,
1314
2150
  float32ToPcm16,
1315
2151
  formatTciCommand,
2152
+ genericObservedDialect,
1316
2153
  isCommandReplyTo,
1317
2154
  mixToMono,
1318
2155
  normalizeCommandName,
1319
2156
  normalizeSampleType,
1320
2157
  normalizeStreamType,
2158
+ parseProtocolIdentity,
1321
2159
  parseStreamFrame,
1322
2160
  parseTciCommand,
1323
2161
  parseTciText,
2162
+ parseTciVersion,
1324
2163
  payloadToFloat32,
1325
2164
  pcm16ToFloat32,
1326
2165
  sampleTypeBytes,
1327
2166
  sampleTypeName,
1328
2167
  samplesToPayload,
2168
+ thetisDialect,
1329
2169
  toTciError,
1330
2170
  unescapeTciText
1331
2171
  });