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.
Files changed (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +33 -13
  3. package/dist/audio/index.cjs +63 -20
  4. package/dist/audio/index.cjs.map +1 -1
  5. package/dist/audio/index.d.cts +18 -3
  6. package/dist/audio/index.d.ts +18 -3
  7. package/dist/audio/index.js +63 -20
  8. package/dist/audio/index.js.map +1 -1
  9. package/dist/dialect/index.cjs +331 -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 +292 -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 +890 -224
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +54 -12
  20. package/dist/index.d.ts +54 -12
  21. package/dist/index.js +876 -224
  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 +77 -25
  28. package/dist/testing/index.cjs.map +1 -1
  29. package/dist/testing/index.d.cts +2 -0
  30. package/dist/testing/index.d.ts +2 -0
  31. package/dist/testing/index.js +77 -25
  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-9seY9Th-.d.ts +63 -0
  40. package/dist/types-xRotf9gW.d.cts +63 -0
  41. 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 import_eventemitter3 = require("eventemitter3");
88
- var import_ws = __toESM(require("ws"), 1);
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
- let channels = header[7];
129
+ const streamType = normalizeStreamType(header[6]);
130
+ let channels = header[7] || options.negotiatedChannels || 0;
116
131
  const bytesPerSample = sampleTypeBytes(sampleType);
117
- const sampleCount = header[5];
132
+ const headerSampleCount = header[5];
118
133
  const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
119
134
  if (channels <= 0) {
120
- const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
121
- if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
122
- throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
123
- }
124
- channels = inferredChannels;
125
- }
126
- const payloadLength = sampleCount * bytesPerSample * channels;
127
- const expectedLength = TCI_STREAM_HEADER_BYTES + payloadLength;
128
- if (buffer.byteLength !== expectedLength) {
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
- if (payloadLength % (bytesPerSample * channels) !== 0) {
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: normalizeStreamType(header[6]),
179
+ streamType,
145
180
  channels,
146
181
  reserved: header.slice(8),
147
182
  payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
148
- sampleCount
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 sampleCount = payload.byteLength / bytesPerSample / channels;
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
- sampleCount,
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 import_eventemitter3.EventEmitter {
1047
+ var TciClient = class extends import_eventemitter32.EventEmitter {
614
1048
  options;
615
1049
  WebSocketImpl;
616
- ws;
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.WebSocketImpl = options.WebSocketImpl ?? import_ws.default;
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.ws?.readyState === import_ws.default.OPEN) {
656
- return;
1104
+ if (this.transport?.isConnected()) {
1105
+ return this.handshakeResult ?? this.waitForHandshake();
657
1106
  }
658
- if (this.ws && this.ws.readyState === import_ws.default.CONNECTING) {
659
- await this.waitForOpen(this.ws);
660
- return;
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 ws = this.ws;
668
- if (!ws) {
669
- return;
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.ws?.readyState === import_ws.default.OPEN;
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
- await this.sendCommand("TUNE", [trx, enabled]);
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.sendCommand("DRIVE", [trx, value]);
1243
+ await this.setDriveWithResult(value, trx);
792
1244
  }
793
- async setSplit(enabled, trx = this.options.trx) {
794
- await this.sendCommand("SPLIT_ENABLE", [trx, enabled]);
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
- await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
808
- await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
809
- await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
810
- if (audio.txBufferingMs !== void 0) {
811
- await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
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({ receiver: this.options.receiver, ...options });
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
- waitForOpen(ws) {
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
- cleanup();
923
- try {
924
- ws.terminate();
925
- } catch {
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
- attachSocket(ws) {
960
- ws.on("message", (data, isBinary) => this.handleMessage(data, isBinary));
961
- ws.on("close", () => this.handleClose());
962
- ws.on("error", (error) => this.handleError(error));
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 ws = this.ws;
966
- if (!ws || ws.readyState !== import_ws.default.OPEN) {
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 new Promise((resolve, reject) => {
971
- ws.send(raw, (error) => error ? reject(error) : resolve());
972
- });
1596
+ await transport.sendText(raw);
973
1597
  }
974
1598
  sendRawBinary(raw) {
975
- const ws = this.ws;
976
- if (!ws || ws.readyState !== import_ws.default.OPEN) {
1599
+ const transport = this.transport;
1600
+ if (!transport?.isConnected()) {
977
1601
  throw new TciError("not-connected", "TCI socket is not connected");
978
1602
  }
979
- ws.send(raw, { binary: true });
1603
+ void transport.sendBinary(raw).catch((error) => this.handleError(error));
980
1604
  }
981
- handleMessage(data, isBinary) {
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(dataToBuffer(data));
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
- switch (command.name) {
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
- this.emit("ready", this.getState());
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
- if (args.length === 1) {
1147
- const value2 = parseNumber(args[0]);
1148
- if (value2 !== void 0) {
1149
- this.state.drive[String(this.options.trx)] = value2;
1150
- }
1151
- return;
1152
- }
1153
- const trx = args[0] ?? String(this.options.trx);
1154
- const value = parseNumber(args[1]);
1155
- if (value !== void 0) {
1156
- this.state.drive[trx] = value;
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
- this.ws = void 0;
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
  });