tci-client-node 0.1.2 → 0.2.0

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