tci-client-node 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +57 -11
  3. package/dist/audio/index.cjs +52 -11
  4. package/dist/audio/index.cjs.map +1 -1
  5. package/dist/audio/index.d.cts +17 -3
  6. package/dist/audio/index.d.ts +17 -3
  7. package/dist/audio/index.js +51 -11
  8. package/dist/audio/index.js.map +1 -1
  9. package/dist/dialect/index.cjs +349 -0
  10. package/dist/dialect/index.cjs.map +1 -0
  11. package/dist/dialect/index.d.cts +29 -0
  12. package/dist/dialect/index.d.ts +29 -0
  13. package/dist/dialect/index.js +310 -0
  14. package/dist/dialect/index.js.map +1 -0
  15. package/dist/{index-CK3XdXP3.d.cts → index-lbK6NGY4.d.cts} +1 -1
  16. package/dist/{index-Dfmrk2MR.d.ts → index-paj1AOJY.d.ts} +1 -1
  17. package/dist/index.cjs +1057 -217
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +106 -12
  20. package/dist/index.d.ts +106 -12
  21. package/dist/index.js +1041 -217
  22. package/dist/index.js.map +1 -1
  23. package/dist/protocol/index.cjs.map +1 -1
  24. package/dist/protocol/index.d.cts +1 -1
  25. package/dist/protocol/index.d.ts +1 -1
  26. package/dist/protocol/index.js.map +1 -1
  27. package/dist/testing/index.cjs +79 -14
  28. package/dist/testing/index.cjs.map +1 -1
  29. package/dist/testing/index.d.cts +6 -0
  30. package/dist/testing/index.d.ts +6 -0
  31. package/dist/testing/index.js +79 -14
  32. package/dist/testing/index.js.map +1 -1
  33. package/dist/transport/index.cjs +175 -0
  34. package/dist/transport/index.cjs.map +1 -0
  35. package/dist/transport/index.d.cts +37 -0
  36. package/dist/transport/index.d.ts +37 -0
  37. package/dist/transport/index.js +138 -0
  38. package/dist/transport/index.js.map +1 -0
  39. package/dist/types-1YXGwrgI.d.cts +65 -0
  40. package/dist/types-BtPh4Dbd.d.ts +65 -0
  41. package/package.json +15 -1
package/dist/index.js CHANGED
@@ -20,8 +20,8 @@ function toTciError(error, fallbackCode = "protocol-error") {
20
20
  }
21
21
 
22
22
  // src/client/TciClient.ts
23
- import { EventEmitter } from "eventemitter3";
24
- import WebSocket from "ws";
23
+ import { EventEmitter as EventEmitter2 } from "eventemitter3";
24
+ import WebSocket2 from "ws";
25
25
 
26
26
  // src/audio/streamFrame.ts
27
27
  var TCI_STREAM_HEADER_BYTES = 16 * 4;
@@ -40,7 +40,7 @@ var TciSampleType = /* @__PURE__ */ ((TciSampleType2) => {
40
40
  TciSampleType2[TciSampleType2["FLOAT32"] = 3] = "FLOAT32";
41
41
  return TciSampleType2;
42
42
  })(TciSampleType || {});
43
- function parseStreamFrame(input) {
43
+ function parseStreamFrame(input, options = {}) {
44
44
  const buffer = toBuffer(input);
45
45
  if (buffer.byteLength < TCI_STREAM_HEADER_BYTES) {
46
46
  throw new TciError("invalid-frame", `TCI stream frame is shorter than ${TCI_STREAM_HEADER_BYTES} bytes`);
@@ -49,15 +49,15 @@ function parseStreamFrame(input) {
49
49
  const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
50
50
  const sampleType = normalizeSampleType(header[2]);
51
51
  const streamType = normalizeStreamType(header[6]);
52
- let channels = header[7];
52
+ let channels = header[7] || options.negotiatedChannels || 0;
53
53
  const bytesPerSample = sampleTypeBytes(sampleType);
54
- const sampleCount = header[5];
54
+ const headerSampleCount = header[5];
55
55
  const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
56
56
  if (channels <= 0) {
57
57
  if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
58
58
  channels = 1;
59
59
  } else {
60
- const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
60
+ const inferredChannels = headerSampleCount > 0 ? actualPayloadLength / headerSampleCount / bytesPerSample : 1;
61
61
  if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
62
62
  throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
63
63
  }
@@ -69,16 +69,28 @@ function parseStreamFrame(input) {
69
69
  if (payloadLength % alignedFrameBytes !== 0) {
70
70
  throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
71
71
  }
72
+ const actualScalarCount = payloadLength / bytesPerSample;
73
+ const requestedSemantics = options.lengthSemantics ?? "auto";
74
+ const lengthSemantics = resolveLengthSemantics(
75
+ requestedSemantics,
76
+ streamType,
77
+ headerSampleCount,
78
+ actualScalarCount,
79
+ channels
80
+ );
81
+ const sampleCount = lengthSemantics === "per-channel" ? headerSampleCount * channels : headerSampleCount;
72
82
  if (streamType !== 3 /* TX_CHRONO */) {
73
- const expectedPerChannelPayloadLength = sampleCount * bytesPerSample * channels;
74
- const expectedScalarPayloadLength = sampleCount * bytesPerSample;
75
- if (payloadLength !== expectedPerChannelPayloadLength && payloadLength !== expectedScalarPayloadLength) {
83
+ const expectedPayloadLength = sampleCount * bytesPerSample;
84
+ if (payloadLength !== expectedPayloadLength) {
76
85
  throw new TciError(
77
86
  "invalid-frame",
78
- `TCI stream frame length mismatch: header says ${sampleCount} samples (${expectedPerChannelPayloadLength} payload bytes), got ${payloadLength}`
87
+ `TCI stream frame length mismatch: header says ${headerSampleCount} samples using ${lengthSemantics} semantics (${expectedPayloadLength} payload bytes), got ${payloadLength}`
79
88
  );
80
89
  }
81
90
  }
91
+ if (sampleCount % channels !== 0) {
92
+ throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
93
+ }
82
94
  return {
83
95
  receiver: header[0],
84
96
  sampleRate: header[1],
@@ -90,7 +102,10 @@ function parseStreamFrame(input) {
90
102
  channels,
91
103
  reserved: header.slice(8),
92
104
  payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
93
- sampleCount
105
+ headerSampleCount,
106
+ sampleCount,
107
+ frameCount: sampleCount / channels,
108
+ lengthSemantics
94
109
  };
95
110
  }
96
111
  function buildStreamFrame(options) {
@@ -104,11 +119,19 @@ function buildStreamFrame(options) {
104
119
  if (payload.byteLength % (bytesPerSample * channels) !== 0) {
105
120
  throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
106
121
  }
107
- const derivedSampleCount = payload.byteLength / bytesPerSample / channels;
122
+ const derivedSampleCount = payload.byteLength / bytesPerSample;
108
123
  const sampleCount = options.sampleCount ?? derivedSampleCount;
109
124
  if (!Number.isInteger(sampleCount) || sampleCount < 0) {
110
125
  throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
111
126
  }
127
+ if (payload.byteLength > 0 && sampleCount !== derivedSampleCount) {
128
+ throw new TciError("invalid-frame", `Explicit scalar sample count ${sampleCount} does not match payload count ${derivedSampleCount}`);
129
+ }
130
+ if (sampleCount % channels !== 0) {
131
+ throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
132
+ }
133
+ const lengthSemantics = options.lengthSemantics === "per-channel" ? "per-channel" : "scalar";
134
+ const headerSampleCount = lengthSemantics === "per-channel" ? sampleCount / channels : sampleCount;
112
135
  const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
113
136
  const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
114
137
  const reserved = options.reserved ?? [];
@@ -118,7 +141,7 @@ function buildStreamFrame(options) {
118
141
  sampleType,
119
142
  options.codec ?? 0,
120
143
  options.crc ?? 0,
121
- sampleCount,
144
+ headerSampleCount,
122
145
  options.streamType,
123
146
  channels,
124
147
  ...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
@@ -127,6 +150,13 @@ function buildStreamFrame(options) {
127
150
  payload.copy(frame, TCI_STREAM_HEADER_BYTES);
128
151
  return frame;
129
152
  }
153
+ function resolveLengthSemantics(requested, streamType, headerSampleCount, actualScalarCount, channels) {
154
+ if (requested !== "auto") return requested;
155
+ if (streamType === 3 /* TX_CHRONO */ && actualScalarCount === 0) return "scalar";
156
+ if (actualScalarCount === headerSampleCount) return "scalar";
157
+ if (actualScalarCount === headerSampleCount * channels) return "per-channel";
158
+ return "scalar";
159
+ }
130
160
  function buildTxAudioFrame(options) {
131
161
  return buildStreamFrame({ ...options, streamType: 2 /* TX_AUDIO_STREAM */ });
132
162
  }
@@ -211,6 +241,15 @@ function payloadToFloat32(frameOrPayload, sampleType) {
211
241
  }
212
242
  return output;
213
243
  }
244
+ function decodeInterleavedIq(frame) {
245
+ if (frame.streamType !== 0 /* IQ_STREAM */) {
246
+ throw new TciError("invalid-frame", "Expected a TCI IQ stream frame");
247
+ }
248
+ if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
249
+ throw new TciError("invalid-frame", `TCI IQ frame must contain interleaved I/Q pairs, got ${frame.channels} channels`);
250
+ }
251
+ return payloadToFloat32(frame);
252
+ }
214
253
  function samplesToPayload(samples, sampleType) {
215
254
  const type = normalizeSampleType(sampleType);
216
255
  const bytes = sampleTypeBytes(type);
@@ -558,13 +597,488 @@ function ensureSemicolon(command) {
558
597
  return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
559
598
  }
560
599
 
600
+ // src/dialect/builtins.ts
601
+ var StandardTciDialect = class {
602
+ id;
603
+ label;
604
+ streamLengthSemantics;
605
+ supportsStreamChannels;
606
+ supportsTxAudioSource;
607
+ supportsIqStream;
608
+ iqSampleRates;
609
+ driveHasTrx;
610
+ detector;
611
+ resolver;
612
+ constructor(options) {
613
+ this.id = options.id;
614
+ this.label = options.label;
615
+ this.streamLengthSemantics = options.streamLengthSemantics;
616
+ this.supportsStreamChannels = options.supportsStreamChannels;
617
+ this.supportsTxAudioSource = options.supportsTxAudioSource;
618
+ this.supportsIqStream = options.supportsIqStream;
619
+ this.iqSampleRates = [...options.iqSampleRates];
620
+ this.driveHasTrx = options.driveHasTrx;
621
+ this.detector = options.detect;
622
+ this.resolver = options.resolve;
623
+ }
624
+ detect(context) {
625
+ return this.detector(context);
626
+ }
627
+ resolve(context) {
628
+ return this.resolver?.(context) ?? this;
629
+ }
630
+ buildDriveSetArgs(trx, value) {
631
+ return this.driveHasTrx ? [trx, value] : [value];
632
+ }
633
+ buildDriveReadArgs(trx) {
634
+ return this.driveHasTrx ? [trx] : [];
635
+ }
636
+ parseDrive(args, defaultTrx) {
637
+ return parseDriveState(args, defaultTrx, this.driveHasTrx);
638
+ }
639
+ buildTuneDriveSetArgs(trx, value) {
640
+ return this.driveHasTrx ? [trx, value] : [value];
641
+ }
642
+ buildTuneDriveReadArgs(trx) {
643
+ return this.driveHasTrx ? [trx] : [];
644
+ }
645
+ parseTuneDrive(args, defaultTrx) {
646
+ return parseDriveState(args, defaultTrx, this.driveHasTrx);
647
+ }
648
+ };
649
+ function parseDriveState(args, defaultTrx, hasTrx) {
650
+ const trx = hasTrx ? Number(args[0]) : defaultTrx;
651
+ const value = Number(args[hasTrx ? 1 : 0]);
652
+ if (!Number.isInteger(trx) || !Number.isFinite(value)) return void 0;
653
+ return { trx, value };
654
+ }
655
+ function version(context) {
656
+ return parseTciVersion(context.identity.protocolVersion);
657
+ }
658
+ function programIncludes(context, value) {
659
+ return context.identity.programName?.toLowerCase().includes(value) ?? false;
660
+ }
661
+ var expertSdr14Dialect = new StandardTciDialect({
662
+ id: "expertsdr-1.4",
663
+ label: "ExpertSDR / TCI 1.4",
664
+ streamLengthSemantics: "per-channel",
665
+ supportsStreamChannels: false,
666
+ supportsTxAudioSource: false,
667
+ supportsIqStream: true,
668
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
669
+ driveHasTrx: false,
670
+ detect: (context) => {
671
+ const parsed = version(context);
672
+ if (!parsed || compareTciVersion(parsed, [1, 4]) > 0) return { score: 0, evidence: [] };
673
+ return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} <= 1.4`] };
674
+ }
675
+ });
676
+ var expertSdrLegacyDialect = new StandardTciDialect({
677
+ id: "expertsdr-1.5-1.8",
678
+ label: "ExpertSDR / TCI 1.5-1.8",
679
+ streamLengthSemantics: "per-channel",
680
+ supportsStreamChannels: false,
681
+ supportsTxAudioSource: false,
682
+ supportsIqStream: true,
683
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
684
+ driveHasTrx: true,
685
+ detect: (context) => {
686
+ const parsed = version(context);
687
+ if (!parsed || compareTciVersion(parsed, [1, 5]) < 0 || compareTciVersion(parsed, [1, 9]) >= 0) return { score: 0, evidence: [] };
688
+ return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} is in 1.5-1.8`] };
689
+ }
690
+ });
691
+ var expertSdrModernDialect = new StandardTciDialect({
692
+ id: "expertsdr-1.9-2.0",
693
+ label: "ExpertSDR / TCI 1.9-2.0",
694
+ streamLengthSemantics: "scalar",
695
+ supportsStreamChannels: true,
696
+ supportsTxAudioSource: true,
697
+ supportsIqStream: true,
698
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
699
+ driveHasTrx: true,
700
+ detect: (context) => {
701
+ const parsed = version(context);
702
+ if (!parsed || compareTciVersion(parsed, [1, 9]) < 0) return { score: 0, evidence: [] };
703
+ const future = compareTciVersion(parsed, [2, 0]) > 0;
704
+ return {
705
+ score: 70 + (programIncludes(context, "expert") ? 15 : 0),
706
+ evidence: [`protocol ${formatVersion(parsed)} uses modern stream negotiation`],
707
+ warnings: future ? [`Unknown future TCI version ${formatVersion(parsed)}; using the modern dialect`] : []
708
+ };
709
+ }
710
+ });
711
+ var thetisDialect = new StandardTciDialect({
712
+ id: "thetis-2.0",
713
+ label: "Thetis / TCI 2.0",
714
+ streamLengthSemantics: "scalar",
715
+ supportsStreamChannels: true,
716
+ supportsTxAudioSource: true,
717
+ supportsIqStream: true,
718
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
719
+ driveHasTrx: true,
720
+ detect: (context) => {
721
+ const evidence = [];
722
+ let score = 0;
723
+ if (programIncludes(context, "thetis")) {
724
+ score += 120;
725
+ evidence.push("PROTOCOL program is Thetis");
726
+ }
727
+ const observed = ["tx_frequency_ex", "tx_profiles_ex", "tx_profile_ex", "calibration_ex"].filter((name) => context.commandNames.has(name));
728
+ if (observed.length > 0) {
729
+ score += 100;
730
+ evidence.push(`Thetis extension commands: ${observed.join(", ")}`);
731
+ }
732
+ if (/anan|hermes|orion|saturn/i.test(context.identity.device ?? "")) {
733
+ score += 30;
734
+ evidence.push(`Thetis-family device: ${context.identity.device}`);
735
+ }
736
+ return { score, evidence };
737
+ }
738
+ });
739
+ var aetherSdrDialect = new StandardTciDialect({
740
+ id: "aethersdr-1.5",
741
+ label: "AetherSDR / TCI 1.5 hybrid",
742
+ streamLengthSemantics: "scalar",
743
+ supportsStreamChannels: true,
744
+ supportsTxAudioSource: true,
745
+ supportsIqStream: true,
746
+ iqSampleRates: [24e3, 48e3, 96e3, 192e3],
747
+ driveHasTrx: true,
748
+ detect: (context) => {
749
+ if (!/^aethersdr$/i.test(context.identity.device ?? "")) return { score: 0, evidence: [] };
750
+ const evidence = [`AetherSDR device identity: ${context.identity.device}`];
751
+ const modernAudioCommands = ["audio_stream_sample_type", "audio_stream_channels", "audio_stream_samples"].filter((name) => context.commandNames.has(name));
752
+ if (modernAudioCommands.length > 0) evidence.push(`Modern audio negotiation: ${modernAudioCommands.join(", ")}`);
753
+ return {
754
+ score: 150,
755
+ evidence,
756
+ warnings: context.identity.protocolVersion === "1.5" ? ["AetherSDR reports TCI 1.5 but uses modern scalar audio stream semantics"] : []
757
+ };
758
+ }
759
+ });
760
+ var genericObservedDialect = new StandardTciDialect({
761
+ id: "generic-observed",
762
+ label: "Generic observed TCI",
763
+ streamLengthSemantics: "auto",
764
+ supportsStreamChannels: true,
765
+ supportsTxAudioSource: true,
766
+ supportsIqStream: false,
767
+ iqSampleRates: [],
768
+ driveHasTrx: true,
769
+ detect: (context) => ({
770
+ score: context.commandNames.has("ready") ? 10 : 0,
771
+ evidence: ["No vendor-specific match; using observed command shapes"],
772
+ warnings: ["Dialect identity is uncertain"]
773
+ }),
774
+ resolve: (context) => {
775
+ const drive = [...context.commands].reverse().find((command) => command.name === "drive");
776
+ const driveHasTrx = (drive?.args.length ?? 0) >= 2;
777
+ return new StandardTciDialect({
778
+ id: "generic-observed",
779
+ label: "Generic observed TCI",
780
+ streamLengthSemantics: "auto",
781
+ supportsStreamChannels: context.commandNames.has("audio_stream_channels"),
782
+ supportsTxAudioSource: compareTciVersion(version(context) ?? [0], [2, 0]) >= 0,
783
+ supportsIqStream: context.commandNames.has("iq_samplerate"),
784
+ iqSampleRates: context.commandNames.has("iq_samplerate") ? [48e3] : [],
785
+ driveHasTrx,
786
+ detect: genericObservedDialect.detect.bind(genericObservedDialect)
787
+ });
788
+ }
789
+ });
790
+ var builtInDialects = [
791
+ aetherSdrDialect,
792
+ thetisDialect,
793
+ expertSdr14Dialect,
794
+ expertSdrLegacyDialect,
795
+ expertSdrModernDialect,
796
+ genericObservedDialect
797
+ ];
798
+ function parseTciVersion(value) {
799
+ if (!value) return void 0;
800
+ const match = value.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
801
+ if (!match) return void 0;
802
+ return match.slice(1).filter((part) => part !== void 0).map(Number);
803
+ }
804
+ function compareTciVersion(left, right) {
805
+ const length = Math.max(left.length, right.length);
806
+ for (let index = 0; index < length; index += 1) {
807
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
808
+ if (difference !== 0) return difference;
809
+ }
810
+ return 0;
811
+ }
812
+ function formatVersion(value) {
813
+ return value.join(".");
814
+ }
815
+
816
+ // src/dialect/registry.ts
817
+ var TciDialectRegistry = class {
818
+ dialects = /* @__PURE__ */ new Map();
819
+ constructor(dialects = builtInDialects) {
820
+ for (const dialect of dialects) this.register(dialect);
821
+ }
822
+ register(dialect) {
823
+ this.dialects.set(dialect.id, dialect);
824
+ }
825
+ get(id) {
826
+ return this.dialects.get(id);
827
+ }
828
+ list() {
829
+ return [...this.dialects.values()];
830
+ }
831
+ select(context, selection = "auto") {
832
+ if (typeof selection === "object") {
833
+ return { dialect: selection, confidence: "manual", evidence: ["Custom dialect supplied by caller"], warnings: [] };
834
+ }
835
+ if (selection !== "auto") {
836
+ const dialect = this.get(selection);
837
+ if (!dialect) throw new TciError("unknown-dialect", `Unknown TCI dialect: ${selection}`);
838
+ return { dialect: dialect.resolve?.(context) ?? dialect, confidence: "manual", evidence: [`Dialect ${selection} selected by caller`], warnings: [] };
839
+ }
840
+ const candidates = this.list().map((dialect) => ({ dialect, result: dialect.detect(context) })).sort((left, right) => right.result.score - left.result.score);
841
+ const selected = candidates[0];
842
+ if (!selected || selected.result.score <= 0) {
843
+ throw new TciError("unknown-dialect", "Unable to identify the TCI server dialect");
844
+ }
845
+ return {
846
+ dialect: selected.dialect.resolve?.(context) ?? selected.dialect,
847
+ confidence: selected.result.score >= 100 ? "high" : selected.result.score >= 70 ? "medium" : "low",
848
+ evidence: selected.result.evidence,
849
+ warnings: selected.result.warnings ?? []
850
+ };
851
+ }
852
+ };
853
+ var defaultTciDialectRegistry = new TciDialectRegistry();
854
+
855
+ // src/dialect/handshake.ts
856
+ var IDENTITY_COMMANDS = /* @__PURE__ */ new Set(["protocol", "device", "trx_count", "channels_count", "channel_count"]);
857
+ var STATE_COMMANDS = /* @__PURE__ */ new Set(["vfo", "modulation", "modulations_list", "trx", "drive"]);
858
+ function parseProtocolIdentity(commands) {
859
+ const protocol = [...commands].reverse().find((command) => command.name === "protocol");
860
+ const device = [...commands].reverse().find((command) => command.name === "device");
861
+ const rawProtocolArgs = protocol?.args ?? [];
862
+ const firstLooksLikeVersion = /^\d+(?:\.\d+){0,2}/.test(rawProtocolArgs[0] ?? "");
863
+ return {
864
+ programName: firstLooksLikeVersion ? void 0 : rawProtocolArgs[0],
865
+ protocolVersion: firstLooksLikeVersion ? rawProtocolArgs[0] : rawProtocolArgs[1],
866
+ rawProtocolArgs: [...rawProtocolArgs],
867
+ device: device?.args.join(",")
868
+ };
869
+ }
870
+ function assertValidTciHandshake(commands) {
871
+ const names = new Set(commands.map((command) => command.name));
872
+ if (!names.has("ready")) throw new TciError("handshake-timeout", "TCI READY was not received");
873
+ const categories = [
874
+ [...IDENTITY_COMMANDS].some((name) => names.has(name)),
875
+ [...STATE_COMMANDS].some((name) => names.has(name))
876
+ ].filter(Boolean).length;
877
+ const identitySignals = [...IDENTITY_COMMANDS].filter((name) => names.has(name)).length;
878
+ if (categories < 2 && identitySignals < 2) {
879
+ throw new TciError("invalid-handshake", "WebSocket opened but did not provide enough TCI initialization evidence");
880
+ }
881
+ }
882
+
883
+ // src/transport/WebSocketTransport.ts
884
+ import { EventEmitter } from "eventemitter3";
885
+ import WebSocket from "ws";
886
+ var WebSocketTciTransport = class extends EventEmitter {
887
+ constructor(url, WebSocketImpl = WebSocket) {
888
+ super();
889
+ this.url = url;
890
+ this.WebSocketImpl = WebSocketImpl;
891
+ }
892
+ url;
893
+ WebSocketImpl;
894
+ socket;
895
+ async connect(timeoutMs) {
896
+ if (this.socket?.readyState === WebSocket.OPEN) return;
897
+ const socket = new this.WebSocketImpl(this.url);
898
+ this.socket = socket;
899
+ await new Promise((resolve, reject) => {
900
+ const timer = setTimeout(() => {
901
+ cleanup();
902
+ this.terminate();
903
+ reject(new TciError("connect-timeout", `Timed out connecting to ${this.url}`));
904
+ }, timeoutMs);
905
+ const cleanup = () => {
906
+ clearTimeout(timer);
907
+ socket.off("open", onOpen);
908
+ socket.off("close", onCloseBeforeOpen);
909
+ socket.off("error", onErrorBeforeOpen);
910
+ };
911
+ const onOpen = () => {
912
+ cleanup();
913
+ this.attach(socket);
914
+ this.emit("connected");
915
+ resolve();
916
+ };
917
+ const onCloseBeforeOpen = () => {
918
+ cleanup();
919
+ reject(new TciError("disconnected", `Disconnected while connecting to ${this.url}`));
920
+ };
921
+ const onErrorBeforeOpen = (error) => {
922
+ cleanup();
923
+ reject(toTciError(error, "disconnected"));
924
+ };
925
+ socket.once("open", onOpen);
926
+ socket.once("close", onCloseBeforeOpen);
927
+ socket.once("error", onErrorBeforeOpen);
928
+ });
929
+ }
930
+ async disconnect(code = 1e3, reason = "client disconnect") {
931
+ const socket = this.socket;
932
+ if (!socket || socket.readyState === WebSocket.CLOSED) return;
933
+ await new Promise((resolve) => {
934
+ const timer = setTimeout(resolve, 1e3);
935
+ timer.unref?.();
936
+ socket.once("close", () => {
937
+ clearTimeout(timer);
938
+ resolve();
939
+ });
940
+ socket.once("error", () => {
941
+ clearTimeout(timer);
942
+ resolve();
943
+ });
944
+ socket.close(code, reason);
945
+ });
946
+ }
947
+ isConnected() {
948
+ return this.socket?.readyState === WebSocket.OPEN;
949
+ }
950
+ async sendText(raw) {
951
+ await this.send(raw);
952
+ }
953
+ async sendBinary(raw) {
954
+ await this.send(raw, true);
955
+ }
956
+ terminate() {
957
+ try {
958
+ this.socket?.terminate();
959
+ } catch {
960
+ }
961
+ }
962
+ attach(socket) {
963
+ socket.on("message", (data, isBinary) => {
964
+ try {
965
+ const buffer = dataToBuffer(data);
966
+ if (isBinary) this.emit("binary", buffer);
967
+ else this.emit("text", buffer.toString("utf8"));
968
+ } catch (error) {
969
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
970
+ }
971
+ });
972
+ socket.on("close", (code, reason) => {
973
+ if (this.socket === socket) this.socket = void 0;
974
+ this.emit("disconnected", { code, reason: reason.toString("utf8") });
975
+ });
976
+ socket.on("error", (error) => this.emit("error", error));
977
+ }
978
+ async send(data, binary = false) {
979
+ const socket = this.socket;
980
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
981
+ throw new TciError("not-connected", "TCI socket is not connected");
982
+ }
983
+ await new Promise((resolve, reject) => {
984
+ socket.send(data, { binary }, (error) => error ? reject(error) : resolve());
985
+ });
986
+ }
987
+ };
988
+ function dataToBuffer(data) {
989
+ if (Buffer.isBuffer(data)) return data;
990
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
991
+ if (Array.isArray(data)) return Buffer.concat(data.map((item) => dataToBuffer(item)));
992
+ throw new TciError("protocol-error", "Unsupported WebSocket data type");
993
+ }
994
+
561
995
  // src/client/TciClient.ts
562
- var TciClient = class extends EventEmitter {
996
+ var TciIqStreamSession = class extends EventEmitter2 {
997
+ receiver;
998
+ _appliedSampleRate;
999
+ callbacks;
1000
+ closed = false;
1001
+ firstFrame;
1002
+ constructor(receiver, appliedSampleRate, callbacks) {
1003
+ super();
1004
+ this.receiver = receiver;
1005
+ this._appliedSampleRate = appliedSampleRate;
1006
+ this.callbacks = callbacks;
1007
+ }
1008
+ get appliedSampleRate() {
1009
+ return this._appliedSampleRate;
1010
+ }
1011
+ async setSampleRate(sampleRate) {
1012
+ if (this.closed) throw new TciError("cancelled", "TCI IQ stream is closed");
1013
+ const result = await this.callbacks.setSampleRate(sampleRate);
1014
+ this._appliedSampleRate = result.applied;
1015
+ return result;
1016
+ }
1017
+ async close() {
1018
+ if (this.closed) return;
1019
+ this.closed = true;
1020
+ this.rejectFirstFrame(new TciError("cancelled", "TCI IQ stream closed before its first frame"));
1021
+ try {
1022
+ await this.callbacks.close();
1023
+ } finally {
1024
+ this.emit("closed");
1025
+ this.removeAllListeners();
1026
+ }
1027
+ }
1028
+ waitForFirstFrame(timeoutMs) {
1029
+ if (this.firstFrame) return this.firstFrame.promise;
1030
+ let resolvePromise;
1031
+ let rejectPromise;
1032
+ const promise = new Promise((resolve, reject) => {
1033
+ resolvePromise = resolve;
1034
+ rejectPromise = reject;
1035
+ });
1036
+ const timer = setTimeout(() => {
1037
+ this.firstFrame = void 0;
1038
+ rejectPromise(new TciError("command-timeout", `Timed out waiting for TCI IQ frame for receiver ${this.receiver}`));
1039
+ }, timeoutMs);
1040
+ this.firstFrame = { promise, resolve: resolvePromise, reject: rejectPromise, timer };
1041
+ return promise;
1042
+ }
1043
+ _acceptFrame(frame) {
1044
+ if (this.closed || frame.receiver !== this.receiver) return;
1045
+ this._appliedSampleRate = frame.sampleRate;
1046
+ const firstFrame = this.firstFrame;
1047
+ if (firstFrame) {
1048
+ clearTimeout(firstFrame.timer);
1049
+ this.firstFrame = void 0;
1050
+ firstFrame.resolve(frame);
1051
+ }
1052
+ this.emit("frame", frame);
1053
+ }
1054
+ _fail(error) {
1055
+ if (this.closed) return;
1056
+ this.rejectFirstFrame(error);
1057
+ this.emit("error", error);
1058
+ }
1059
+ rejectFirstFrame(error) {
1060
+ const firstFrame = this.firstFrame;
1061
+ if (!firstFrame) return;
1062
+ clearTimeout(firstFrame.timer);
1063
+ this.firstFrame = void 0;
1064
+ firstFrame.reject(error);
1065
+ }
1066
+ };
1067
+ var TciClient = class extends EventEmitter2 {
563
1068
  options;
564
1069
  WebSocketImpl;
565
- ws;
1070
+ transportFactory;
1071
+ transport;
566
1072
  queue;
567
1073
  state;
1074
+ stateReducers;
1075
+ dialectRegistry;
1076
+ activeDialect;
1077
+ handshakeResult;
1078
+ handshakeError;
1079
+ initializationCommands = [];
1080
+ handshakeWaiter;
1081
+ activeIqSession;
568
1082
  constructor(options) {
569
1083
  super();
570
1084
  this.options = {
@@ -573,13 +1087,17 @@ var TciClient = class extends EventEmitter {
573
1087
  trx: options.trx ?? 0,
574
1088
  vfo: options.vfo ?? 0,
575
1089
  connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
1090
+ handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
576
1091
  commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
577
1092
  writeAckMode: options.writeAckMode ?? "state",
578
1093
  writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
579
1094
  writeSettleMs: options.writeSettleMs ?? 0,
580
- frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
1095
+ frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
1096
+ dialect: options.dialect ?? "auto"
581
1097
  };
582
- this.WebSocketImpl = options.WebSocketImpl ?? WebSocket;
1098
+ this.dialectRegistry = options.dialectRegistry ?? defaultTciDialectRegistry;
1099
+ this.WebSocketImpl = options.WebSocketImpl ?? WebSocket2;
1100
+ this.transportFactory = options.transportFactory ?? ((url) => new WebSocketTciTransport(url, this.WebSocketImpl));
583
1101
  this.queue = new TciCommandQueue({
584
1102
  timeoutMs: this.options.commandTimeoutMs,
585
1103
  send: (raw) => this.sendRaw(raw)
@@ -595,58 +1113,113 @@ var TciClient = class extends EventEmitter {
595
1113
  pttSource: {},
596
1114
  tune: {},
597
1115
  drive: {},
1116
+ tuneDrive: {},
598
1117
  split: {},
1118
+ dialectWarnings: [],
599
1119
  rxSensors: {},
600
- txSensors: {}
1120
+ txSensors: {},
1121
+ iq: { activeReceivers: {} },
1122
+ dds: {}
601
1123
  };
1124
+ this.stateReducers = this.createStateReducers();
602
1125
  }
603
1126
  async connect() {
604
- if (this.ws?.readyState === WebSocket.OPEN) {
605
- return;
1127
+ if (this.transport?.isConnected()) {
1128
+ return this.handshakeResult ?? this.waitForHandshake();
606
1129
  }
607
- if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
608
- await this.waitForOpen(this.ws);
609
- return;
1130
+ this.resetHandshake();
1131
+ const transport = this.transportFactory(this.options.url);
1132
+ this.transport = transport;
1133
+ this.attachTransport(transport);
1134
+ await transport.connect(this.options.connectTimeoutMs);
1135
+ this.state.connected = true;
1136
+ this.queue.setConnected(true);
1137
+ this.emit("connected");
1138
+ this.emitState();
1139
+ try {
1140
+ return await this.waitForHandshake();
1141
+ } catch (error) {
1142
+ transport.terminate();
1143
+ throw error;
610
1144
  }
611
- const ws = new this.WebSocketImpl(this.options.url);
612
- this.ws = ws;
613
- await this.waitForOpen(ws);
614
1145
  }
615
1146
  async disconnect(code = 1e3, reason = "client disconnect") {
616
- const ws = this.ws;
617
- if (!ws) {
618
- return;
619
- }
620
- if (ws.readyState === WebSocket.CLOSED) {
621
- this.handleClose();
622
- return;
623
- }
624
- await new Promise((resolve) => {
625
- const cleanup = () => {
626
- ws.off("close", onClose);
627
- ws.off("error", onError);
628
- };
629
- const onClose = () => {
630
- cleanup();
631
- resolve();
632
- };
633
- const onError = () => {
634
- cleanup();
635
- resolve();
636
- };
637
- ws.once("close", onClose);
638
- ws.once("error", onError);
639
- ws.close(code, reason);
640
- setTimeout(() => resolve(), 1e3).unref?.();
641
- });
1147
+ const transport = this.transport;
1148
+ if (!transport) return;
1149
+ await transport.disconnect(code, reason);
642
1150
  this.handleClose();
643
1151
  }
644
1152
  isConnected() {
645
- return this.ws?.readyState === WebSocket.OPEN;
1153
+ return this.transport?.isConnected() ?? false;
646
1154
  }
647
1155
  getState() {
648
1156
  return cloneState(this.state);
649
1157
  }
1158
+ getHandshakeResult() {
1159
+ return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
1160
+ }
1161
+ getIqCapabilities() {
1162
+ const dialect = this.requireDialect();
1163
+ return {
1164
+ supported: dialect.supportsIqStream,
1165
+ currentSampleRate: this.state.iq.sampleRate,
1166
+ supportedSampleRates: [...dialect.iqSampleRates]
1167
+ };
1168
+ }
1169
+ async setIqSampleRate(sampleRate, timeoutMs = this.options.commandTimeoutMs) {
1170
+ const capabilities = this.getIqCapabilities();
1171
+ const requested = Math.round(sampleRate);
1172
+ if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
1173
+ if (!Number.isFinite(requested) || requested <= 0) throw new TciError("protocol-error", `Invalid TCI IQ sample rate: ${sampleRate}`);
1174
+ if (capabilities.supportedSampleRates.length > 0 && !capabilities.supportedSampleRates.includes(requested)) {
1175
+ throw new TciError("protocol-error", `TCI IQ sample rate ${requested} is not supported by dialect ${this.requireDialect().id}`);
1176
+ }
1177
+ if (this.state.iq.sampleRate === requested) return writeResult(requested, requested, "state");
1178
+ const waiter = this.waitForCommand((command) => command.name === "iq_samplerate", timeoutMs, "IQ_SAMPLERATE readback");
1179
+ try {
1180
+ await this.sendCommand("IQ_SAMPLERATE", [requested], { waitForReply: false });
1181
+ const reply = await waiter.promise;
1182
+ const applied = parseNumber(reply.args[0]);
1183
+ if (applied === void 0 || applied <= 0) throw new TciError("protocol-error", `Invalid IQ_SAMPLERATE readback: ${reply.raw}`);
1184
+ return writeResult(requested, applied, "reply");
1185
+ } finally {
1186
+ waiter.cancel();
1187
+ }
1188
+ }
1189
+ async openIqStream(options = {}) {
1190
+ if (this.activeIqSession) throw new TciError("protocol-error", "This TCI client already has an active IQ stream session");
1191
+ const capabilities = this.getIqCapabilities();
1192
+ if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
1193
+ const receiver = Math.floor(options.receiver ?? this.options.receiver);
1194
+ if (!Number.isInteger(receiver) || receiver < 0) throw new TciError("protocol-error", `Invalid TCI IQ receiver: ${receiver}`);
1195
+ const requestedRate = options.sampleRate ?? capabilities.currentSampleRate ?? capabilities.supportedSampleRates[0] ?? 48e3;
1196
+ const rateResult = await this.setIqSampleRate(requestedRate);
1197
+ let session;
1198
+ session = new TciIqStreamSession(receiver, rateResult.applied, {
1199
+ setSampleRate: (rate) => this.setIqSampleRate(rate),
1200
+ close: async () => {
1201
+ try {
1202
+ if (this.isConnected()) await this.sendCommand("IQ_STOP", [receiver], { waitForReply: false });
1203
+ } finally {
1204
+ this.state.iq.activeReceivers[String(receiver)] = false;
1205
+ if (this.activeIqSession === session) this.activeIqSession = void 0;
1206
+ this.emitState();
1207
+ }
1208
+ }
1209
+ });
1210
+ this.activeIqSession = session;
1211
+ const firstFrame = session.waitForFirstFrame(options.firstFrameTimeoutMs ?? 5e3);
1212
+ try {
1213
+ await this.sendCommand("IQ_START", [receiver], { waitForReply: false });
1214
+ this.state.iq.activeReceivers[String(receiver)] = true;
1215
+ this.emitState();
1216
+ await firstFrame;
1217
+ return session;
1218
+ } catch (error) {
1219
+ await session.close().catch(() => void 0);
1220
+ throw error;
1221
+ }
1222
+ }
650
1223
  async sendCommand(name, args = [], options = {}) {
651
1224
  const raw = formatTciCommand(name, args);
652
1225
  if (options.waitForReply === false) {
@@ -720,6 +1293,9 @@ var TciClient = class extends EventEmitter {
720
1293
  }
721
1294
  async setPtt(enabled, options = {}) {
722
1295
  const trx = options.trx ?? this.options.trx;
1296
+ if (options.source && !this.requireDialect().supportsTxAudioSource) {
1297
+ throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support a TRX audio source argument`);
1298
+ }
723
1299
  const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
724
1300
  await this.sendStateWrite(
725
1301
  "TRX",
@@ -733,16 +1309,107 @@ var TciClient = class extends EventEmitter {
733
1309
  const reply = await this.request("TRX", [trx]);
734
1310
  return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
735
1311
  }
736
- async setTune(enabled, trx = this.options.trx) {
737
- await this.sendCommand("TUNE", [trx, enabled]);
1312
+ async setTune(enabled, trx = this.options.trx, options = {}) {
1313
+ try {
1314
+ await this.sendStateWrite(
1315
+ "TUNE",
1316
+ [trx, enabled],
1317
+ (state) => state.tune[String(trx)] === enabled,
1318
+ `TUNE:${trx},${enabled}`,
1319
+ options
1320
+ );
1321
+ } catch (error) {
1322
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1323
+ await this.request("TUNE", [trx], { timeoutMs: options.timeoutMs });
1324
+ if (this.state.tune[String(trx)] !== enabled) throw error;
1325
+ }
738
1326
  }
739
1327
  async setDrive(value, trx = this.options.trx) {
740
- await this.sendCommand("DRIVE", [trx, value]);
1328
+ await this.setDriveWithResult(value, trx);
741
1329
  }
742
- async setSplit(enabled, trx = this.options.trx) {
743
- await this.sendCommand("SPLIT_ENABLE", [trx, enabled]);
1330
+ async setDriveWithResult(value, trx = this.options.trx, options = {}) {
1331
+ const requested = normalizePercent(value);
1332
+ const dialect = this.requireDialect();
1333
+ if (this.state.drive[String(trx)] === requested) {
1334
+ return writeResult(requested, requested, "state");
1335
+ }
1336
+ const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
1337
+ const waiter = this.waitForCommand(
1338
+ (command) => command.name === "drive" && dialect.parseDrive(command.args, trx)?.trx === trx,
1339
+ Math.min(500, timeoutMs),
1340
+ `DRIVE state for TRX ${trx}`
1341
+ );
1342
+ await this.sendCommand("DRIVE", dialect.buildDriveSetArgs(trx, requested), { waitForReply: false });
1343
+ try {
1344
+ const command = await waiter.promise;
1345
+ const applied2 = dialect.parseDrive(command.args, trx)?.value;
1346
+ if (applied2 !== void 0) return writeResult(requested, applied2, "state");
1347
+ } catch (error) {
1348
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1349
+ } finally {
1350
+ waiter.cancel();
1351
+ }
1352
+ const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx), {
1353
+ timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
1354
+ });
1355
+ const applied = dialect.parseDrive(reply.args, trx)?.value;
1356
+ if (applied === void 0) throw new TciError("protocol-error", `Invalid DRIVE readback: ${reply.raw}`);
1357
+ return writeResult(requested, applied, "readback");
1358
+ }
1359
+ async getDrive(trx = this.options.trx) {
1360
+ const dialect = this.requireDialect();
1361
+ const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx));
1362
+ return dialect.parseDrive(reply.args, trx)?.value ?? this.state.drive[String(trx)];
1363
+ }
1364
+ async setTuneDrive(value, trx = this.options.trx, options = {}) {
1365
+ const requested = normalizePercent(value);
1366
+ const dialect = this.requireDialect();
1367
+ if (this.state.tuneDrive[String(trx)] === requested) return writeResult(requested, requested, "state");
1368
+ const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
1369
+ const waiter = this.waitForCommand(
1370
+ (command) => command.name === "tune_drive" && dialect.parseTuneDrive(command.args, trx)?.trx === trx,
1371
+ Math.min(500, timeoutMs),
1372
+ `TUNE_DRIVE state for TRX ${trx}`
1373
+ );
1374
+ await this.sendCommand("TUNE_DRIVE", dialect.buildTuneDriveSetArgs(trx, requested), { waitForReply: false });
1375
+ try {
1376
+ const command = await waiter.promise;
1377
+ const applied2 = dialect.parseTuneDrive(command.args, trx)?.value;
1378
+ if (applied2 !== void 0) return writeResult(requested, applied2, "state");
1379
+ } catch (error) {
1380
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1381
+ } finally {
1382
+ waiter.cancel();
1383
+ }
1384
+ const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx), {
1385
+ timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
1386
+ });
1387
+ const applied = dialect.parseTuneDrive(reply.args, trx)?.value;
1388
+ if (applied === void 0) throw new TciError("protocol-error", `Invalid TUNE_DRIVE readback: ${reply.raw}`);
1389
+ return writeResult(requested, applied, "readback");
1390
+ }
1391
+ async getTuneDrive(trx = this.options.trx) {
1392
+ const dialect = this.requireDialect();
1393
+ const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx));
1394
+ return dialect.parseTuneDrive(reply.args, trx)?.value ?? this.state.tuneDrive[String(trx)];
1395
+ }
1396
+ async setSplit(enabled, trx = this.options.trx, options = {}) {
1397
+ try {
1398
+ await this.sendStateWrite(
1399
+ "SPLIT_ENABLE",
1400
+ [trx, enabled],
1401
+ (state) => state.split[String(trx)] === enabled,
1402
+ `SPLIT_ENABLE:${trx},${enabled}`,
1403
+ options
1404
+ );
1405
+ } catch (error) {
1406
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1407
+ await this.request("SPLIT_ENABLE", [trx], { timeoutMs: options.timeoutMs });
1408
+ if (this.state.split[String(trx)] !== enabled) throw error;
1409
+ }
744
1410
  }
745
1411
  async configureAudio(config) {
1412
+ const dialect = this.requireDialect();
746
1413
  const audio = {
747
1414
  sampleRate: config.sampleRate,
748
1415
  sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
@@ -753,11 +1420,13 @@ var TciClient = class extends EventEmitter {
753
1420
  };
754
1421
  this.state.audio = audio;
755
1422
  await this.sendCommand("AUDIO_SAMPLERATE", [audio.sampleRate], { waitForReply: false });
756
- await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
757
- await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
758
- await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
759
- if (audio.txBufferingMs !== void 0) {
760
- await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
1423
+ if (dialect.supportsStreamChannels) {
1424
+ await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
1425
+ await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
1426
+ await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
1427
+ if (audio.txBufferingMs !== void 0) {
1428
+ await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
1429
+ }
761
1430
  }
762
1431
  this.emitState();
763
1432
  }
@@ -776,12 +1445,16 @@ var TciClient = class extends EventEmitter {
776
1445
  }
777
1446
  }
778
1447
  sendTxAudio(options) {
779
- const frame = buildTxAudioFrame({ receiver: this.options.receiver, ...options });
1448
+ const frame = buildTxAudioFrame({
1449
+ receiver: this.options.receiver,
1450
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1451
+ ...options
1452
+ });
780
1453
  this.sendRawBinary(frame);
781
1454
  }
782
1455
  sendTxAudioForChrono(request, samples) {
783
1456
  const channels = Math.max(1, Math.floor(request.channels || 1));
784
- const targetSampleLength = Math.max(0, Math.floor(request.sampleCount) * channels);
1457
+ const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
785
1458
  const output = new Float32Array(targetSampleLength);
786
1459
  const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
787
1460
  output.set(source.subarray(0, output.length));
@@ -879,78 +1552,147 @@ var TciClient = class extends EventEmitter {
879
1552
  cancel: cleanup
880
1553
  };
881
1554
  }
882
- waitForOpen(ws) {
1555
+ waitForCommand(predicate, timeoutMs, description) {
1556
+ let timer;
1557
+ let resolvePromise;
1558
+ let rejectPromise;
1559
+ const cleanup = () => {
1560
+ if (timer) clearTimeout(timer);
1561
+ timer = void 0;
1562
+ this.off("command", onCommand);
1563
+ this.off("disconnected", onDisconnected);
1564
+ };
1565
+ const onCommand = (command) => {
1566
+ if (!predicate(command)) return;
1567
+ cleanup();
1568
+ resolvePromise(command);
1569
+ };
1570
+ const onDisconnected = () => {
1571
+ cleanup();
1572
+ rejectPromise(new TciError("disconnected", `Disconnected while waiting for ${description}`));
1573
+ };
1574
+ const promise = new Promise((resolve, reject) => {
1575
+ resolvePromise = resolve;
1576
+ rejectPromise = reject;
1577
+ timer = setTimeout(() => {
1578
+ cleanup();
1579
+ reject(new TciError("command-timeout", `Timed out waiting for ${description}`));
1580
+ }, timeoutMs);
1581
+ this.on("command", onCommand);
1582
+ this.on("disconnected", onDisconnected);
1583
+ });
1584
+ return { promise, cancel: cleanup };
1585
+ }
1586
+ resetHandshake() {
1587
+ this.rejectHandshake(new TciError("cancelled", "TCI handshake replaced by a new connection"));
1588
+ this.handshakeResult = void 0;
1589
+ this.handshakeError = void 0;
1590
+ this.activeDialect = void 0;
1591
+ this.initializationCommands = [];
1592
+ this.state.ready = false;
1593
+ this.state.protocol = void 0;
1594
+ this.state.protocolName = void 0;
1595
+ this.state.protocolVersion = void 0;
1596
+ this.state.dialectId = void 0;
1597
+ this.state.dialectConfidence = void 0;
1598
+ this.state.dialectWarnings = [];
1599
+ }
1600
+ waitForHandshake() {
1601
+ if (this.handshakeResult) return Promise.resolve(cloneHandshake(this.handshakeResult));
1602
+ if (this.handshakeError) return Promise.reject(this.handshakeError);
1603
+ if (this.handshakeWaiter) {
1604
+ return new Promise((resolve, reject) => {
1605
+ const onHandshake = (result) => {
1606
+ cleanup();
1607
+ resolve(result);
1608
+ };
1609
+ const onError = (error) => {
1610
+ cleanup();
1611
+ reject(error);
1612
+ };
1613
+ const cleanup = () => {
1614
+ this.off("handshake", onHandshake);
1615
+ this.off("error", onError);
1616
+ };
1617
+ this.once("handshake", onHandshake);
1618
+ this.once("error", onError);
1619
+ });
1620
+ }
883
1621
  return new Promise((resolve, reject) => {
884
1622
  const timer = setTimeout(() => {
885
- cleanup();
886
- try {
887
- ws.terminate();
888
- } catch {
889
- }
890
- reject(new TciError("connect-timeout", `Timed out connecting to ${this.options.url}`));
891
- }, this.options.connectTimeoutMs);
892
- const cleanup = () => {
893
- clearTimeout(timer);
894
- ws.off("open", onOpen);
895
- ws.off("close", onClose);
896
- ws.off("error", onError);
897
- };
898
- const onOpen = () => {
899
- cleanup();
900
- this.attachSocket(ws);
901
- this.state.connected = true;
902
- this.queue.setConnected(true);
903
- this.emit("connected");
904
- this.emitState();
905
- resolve();
906
- };
907
- const onClose = () => {
908
- cleanup();
909
- this.handleClose();
910
- reject(new TciError("disconnected", `Disconnected while connecting to ${this.options.url}`));
911
- };
912
- const onError = (error) => {
913
- cleanup();
914
- this.handleError(error);
915
- reject(toTciError(error, "disconnected"));
916
- };
917
- ws.once("open", onOpen);
918
- ws.once("close", onClose);
919
- ws.once("error", onError);
1623
+ const error = new TciError("handshake-timeout", `Timed out waiting for TCI READY from ${this.options.url}`);
1624
+ this.handshakeWaiter = void 0;
1625
+ reject(error);
1626
+ }, this.options.handshakeTimeoutMs);
1627
+ this.handshakeWaiter = { resolve, reject, timer };
920
1628
  });
921
1629
  }
922
- attachSocket(ws) {
923
- ws.on("message", (data, isBinary) => this.handleMessage(data, isBinary));
924
- ws.on("close", () => this.handleClose());
925
- ws.on("error", (error) => this.handleError(error));
1630
+ finalizeHandshake() {
1631
+ if (this.handshakeResult) return this.handshakeResult;
1632
+ assertValidTciHandshake(this.initializationCommands);
1633
+ const identity = parseProtocolIdentity(this.initializationCommands);
1634
+ const commandNames = [...new Set(this.initializationCommands.map((command) => command.name))];
1635
+ const dialect = this.dialectRegistry.select(
1636
+ { identity, commands: this.initializationCommands, commandNames: new Set(commandNames) },
1637
+ this.options.dialect
1638
+ );
1639
+ const result = { identity, dialect, ready: true, commandNames };
1640
+ this.handshakeResult = result;
1641
+ this.activeDialect = dialect.dialect;
1642
+ this.state.protocolName = identity.programName;
1643
+ this.state.protocolVersion = identity.protocolVersion;
1644
+ this.state.protocol = identity.protocolVersion ?? identity.programName;
1645
+ this.state.device = identity.device ?? this.state.device;
1646
+ this.state.dialectId = dialect.dialect.id;
1647
+ this.state.dialectConfidence = dialect.confidence;
1648
+ this.state.dialectWarnings = [...dialect.warnings];
1649
+ const waiter = this.handshakeWaiter;
1650
+ this.handshakeWaiter = void 0;
1651
+ if (waiter) {
1652
+ clearTimeout(waiter.timer);
1653
+ waiter.resolve(cloneHandshake(result));
1654
+ }
1655
+ this.emit("handshake", cloneHandshake(result));
1656
+ return result;
1657
+ }
1658
+ rejectHandshake(error) {
1659
+ const waiter = this.handshakeWaiter;
1660
+ this.handshakeWaiter = void 0;
1661
+ if (!waiter) return;
1662
+ clearTimeout(waiter.timer);
1663
+ waiter.reject(error);
1664
+ }
1665
+ requireDialect() {
1666
+ if (!this.activeDialect) throw new TciError("invalid-handshake", "TCI dialect is not available before READY");
1667
+ return this.activeDialect;
1668
+ }
1669
+ attachTransport(transport) {
1670
+ transport.on("text", (raw) => this.handleText(raw));
1671
+ transport.on("binary", (raw) => this.handleBinary(raw));
1672
+ transport.on("disconnected", (reason) => this.handleClose(reason));
1673
+ transport.on("error", (error) => this.handleError(error));
926
1674
  }
927
1675
  async sendRaw(raw) {
928
- const ws = this.ws;
929
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1676
+ const transport = this.transport;
1677
+ if (!transport?.isConnected()) {
930
1678
  throw new TciError("not-connected", "TCI socket is not connected");
931
1679
  }
932
1680
  this.emit("tci:tx", raw);
933
- await new Promise((resolve, reject) => {
934
- ws.send(raw, (error) => error ? reject(error) : resolve());
935
- });
1681
+ await transport.sendText(raw);
936
1682
  }
937
1683
  sendRawBinary(raw) {
938
- const ws = this.ws;
939
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1684
+ const transport = this.transport;
1685
+ if (!transport?.isConnected()) {
940
1686
  throw new TciError("not-connected", "TCI socket is not connected");
941
1687
  }
942
- ws.send(raw, { binary: true });
1688
+ void transport.sendBinary(raw).catch((error) => this.handleError(error));
943
1689
  }
944
- handleMessage(data, isBinary) {
1690
+ handleText(raw) {
945
1691
  try {
946
- if (isBinary) {
947
- this.handleBinary(data);
948
- return;
949
- }
950
- const raw = dataToBuffer(data).toString("utf8");
951
1692
  const commands = parseTciText(raw);
952
1693
  this.emit("tci:rx", raw, commands);
953
1694
  for (const command of commands) {
1695
+ if (!this.handshakeResult) this.initializationCommands.push(command);
954
1696
  this.queue.handleCommand(command);
955
1697
  this.applyCommand(command);
956
1698
  this.emit("command", command);
@@ -960,10 +1702,29 @@ var TciClient = class extends EventEmitter {
960
1702
  }
961
1703
  }
962
1704
  handleBinary(data) {
963
- const frame = parseStreamFrame(dataToBuffer(data));
1705
+ const frame = parseStreamFrame(data, {
1706
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1707
+ negotiatedChannels: this.state.audio?.channels
1708
+ });
964
1709
  this.emit("tci:binary", frame);
965
1710
  this.emit("binary", frame);
966
1711
  switch (frame.streamType) {
1712
+ case 0 /* IQ_STREAM */: {
1713
+ if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
1714
+ throw new TciError("invalid-frame", "TCI IQ stream frame must contain interleaved I/Q pairs");
1715
+ }
1716
+ this.state.iq.sampleRate = frame.sampleRate;
1717
+ const iqFrame = {
1718
+ frame,
1719
+ receiver: frame.receiver,
1720
+ sampleRate: frame.sampleRate,
1721
+ centerFrequency: this.state.dds[String(frame.receiver)],
1722
+ complexSampleCount: frame.frameCount
1723
+ };
1724
+ this.emit("iqFrame", iqFrame);
1725
+ this.activeIqSession?._acceptFrame(iqFrame);
1726
+ break;
1727
+ }
967
1728
  case 1 /* RX_AUDIO_STREAM */:
968
1729
  this.emit("rxAudioFrame", frame);
969
1730
  break;
@@ -974,7 +1735,8 @@ var TciClient = class extends EventEmitter {
974
1735
  sampleRate: frame.sampleRate,
975
1736
  channels: frame.channels,
976
1737
  sampleType: frame.sampleType,
977
- sampleCount: frame.sampleCount
1738
+ sampleCount: frame.sampleCount,
1739
+ frameCount: frame.frameCount
978
1740
  });
979
1741
  break;
980
1742
  case 4 /* LINEOUT_STREAM */:
@@ -986,80 +1748,92 @@ var TciClient = class extends EventEmitter {
986
1748
  }
987
1749
  applyCommand(command) {
988
1750
  const readyBefore = this.state.ready;
989
- switch (command.name) {
990
- case "ready":
991
- this.state.ready = command.args.length === 0 ? true : parseBoolean(command.args[0]) ?? true;
992
- break;
993
- case "protocol":
994
- this.state.protocol = command.args[0];
995
- break;
996
- case "device":
997
- this.state.device = command.args.join(",");
998
- break;
999
- case "receive_only":
1000
- this.state.receiveOnly = parseBoolean(command.args[0]);
1001
- break;
1002
- case "trx_count":
1003
- this.state.trxCount = parseNumber(command.args[0]);
1004
- break;
1005
- case "channels_count":
1006
- case "channel_count":
1007
- this.state.channelCount = parseNumber(command.args[0]);
1008
- break;
1009
- case "vfo_limits":
1010
- this.state.vfoLimits = parseNumberPair(command.args);
1011
- break;
1012
- case "if_limits":
1013
- this.state.ifLimits = parseNumberPair(command.args);
1014
- break;
1015
- case "modulations_list":
1016
- this.state.modulations = command.args.map((mode) => mode.toLowerCase());
1017
- break;
1018
- case "vfo":
1019
- this.applyVfo(command.args);
1020
- break;
1021
- case "modulation":
1022
- this.applyModulation(command.args);
1023
- break;
1024
- case "trx":
1025
- this.applyTrx(command.args);
1026
- break;
1027
- case "tune":
1028
- this.applyBooleanByFirstArg(this.state.tune, command.args);
1029
- break;
1030
- case "drive":
1031
- this.applyDrive(command.args);
1032
- break;
1033
- case "split_enable":
1034
- this.applyBooleanByFirstArg(this.state.split, command.args);
1035
- break;
1036
- case "rx_channel_sensors":
1037
- this.applyRxChannelSensors(command.args);
1038
- break;
1039
- case "rx_sensors":
1040
- this.applyRxSensors(command.args);
1041
- break;
1042
- case "tx_sensors":
1043
- this.applyTxSensors(command.args);
1044
- break;
1045
- case "audio_samplerate":
1046
- this.state.audio = {
1047
- sampleRate: parseNumber(command.args[0]) ?? this.state.audio?.sampleRate ?? 12e3,
1048
- sampleType: this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
1049
- channels: this.state.audio?.channels ?? 1,
1050
- samplesPerFrame: this.state.audio?.samplesPerFrame ?? 512,
1051
- txBufferingMs: this.state.audio?.txBufferingMs,
1052
- running: this.state.audio?.running ?? false
1053
- };
1054
- break;
1055
- default:
1056
- break;
1057
- }
1751
+ this.stateReducers.get(command.name)?.(command.args);
1058
1752
  if (!readyBefore && this.state.ready) {
1059
- this.emit("ready", this.getState());
1753
+ try {
1754
+ this.finalizeHandshake();
1755
+ this.emit("ready", this.getState());
1756
+ } catch (error) {
1757
+ const tciError = toTciError(error, "invalid-handshake");
1758
+ this.handshakeError = tciError;
1759
+ this.state.ready = false;
1760
+ this.rejectHandshake(tciError);
1761
+ this.handleError(tciError);
1762
+ }
1060
1763
  }
1061
1764
  this.emitState();
1062
1765
  }
1766
+ createStateReducers() {
1767
+ const reducers = /* @__PURE__ */ new Map();
1768
+ reducers.set("ready", (args) => {
1769
+ this.state.ready = args.length === 0 ? true : parseBoolean(args[0]) ?? true;
1770
+ });
1771
+ reducers.set("protocol", (args) => {
1772
+ if (/^\d+(?:\.\d+){0,2}/.test(args[0] ?? "")) {
1773
+ this.state.protocol = args[0];
1774
+ this.state.protocolVersion = args[0];
1775
+ } else {
1776
+ this.state.protocolName = args[0];
1777
+ this.state.protocolVersion = args[1];
1778
+ this.state.protocol = args[1] ?? args[0];
1779
+ }
1780
+ });
1781
+ reducers.set("device", (args) => {
1782
+ this.state.device = args.join(",");
1783
+ });
1784
+ reducers.set("receive_only", (args) => {
1785
+ this.state.receiveOnly = parseBoolean(args[0]);
1786
+ });
1787
+ reducers.set("trx_count", (args) => {
1788
+ this.state.trxCount = parseNumber(args[0]);
1789
+ });
1790
+ const channelCount = (args) => {
1791
+ this.state.channelCount = parseNumber(args[0]);
1792
+ };
1793
+ reducers.set("channels_count", channelCount);
1794
+ reducers.set("channel_count", channelCount);
1795
+ reducers.set("vfo_limits", (args) => {
1796
+ this.state.vfoLimits = parseNumberPair(args);
1797
+ });
1798
+ reducers.set("if_limits", (args) => {
1799
+ this.state.ifLimits = parseNumberPair(args);
1800
+ });
1801
+ reducers.set("modulations_list", (args) => {
1802
+ this.state.modulations = args.map((mode) => mode.toLowerCase());
1803
+ });
1804
+ reducers.set("vfo", (args) => this.applyVfo(args));
1805
+ reducers.set("dds", (args) => {
1806
+ const receiver = parseNumber(args[0]);
1807
+ const frequency = parseNumber(args[1]);
1808
+ if (receiver !== void 0 && frequency !== void 0 && frequency >= 0) this.state.dds[String(receiver)] = frequency;
1809
+ });
1810
+ reducers.set("modulation", (args) => this.applyModulation(args));
1811
+ reducers.set("trx", (args) => this.applyTrx(args));
1812
+ reducers.set("tune", (args) => this.applyBooleanByFirstArg(this.state.tune, args));
1813
+ reducers.set("drive", (args) => this.applyDrive(args));
1814
+ reducers.set("tune_drive", (args) => this.applyTuneDrive(args));
1815
+ reducers.set("split_enable", (args) => this.applyBooleanByFirstArg(this.state.split, args));
1816
+ reducers.set("rx_channel_sensors", (args) => this.applyRxChannelSensors(args));
1817
+ reducers.set("rx_sensors", (args) => this.applyRxSensors(args));
1818
+ reducers.set("tx_sensors", (args) => this.applyTxSensors(args));
1819
+ reducers.set("audio_samplerate", (args) => this.updateAudioState({ sampleRate: parseNumber(args[0]) }));
1820
+ reducers.set("audio_stream_sample_type", (args) => this.updateAudioState({ sampleType: parseSampleType(args[0]) }));
1821
+ reducers.set("audio_stream_channels", (args) => this.updateAudioState({ channels: parseNumber(args[0]) }));
1822
+ reducers.set("audio_stream_samples", (args) => this.updateAudioState({ samplesPerFrame: parseNumber(args[0]) }));
1823
+ reducers.set("tx_stream_audio_buffering", (args) => this.updateAudioState({ txBufferingMs: parseNumber(args[0]) }));
1824
+ reducers.set("audio_start", () => this.updateAudioState({ running: true }));
1825
+ reducers.set("audio_stop", () => this.updateAudioState({ running: false }));
1826
+ reducers.set("iq_samplerate", (args) => {
1827
+ this.state.iq.sampleRate = parseNumber(args[0]);
1828
+ });
1829
+ reducers.set("iq_start", (args) => {
1830
+ this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = true;
1831
+ });
1832
+ reducers.set("iq_stop", (args) => {
1833
+ this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = false;
1834
+ });
1835
+ return reducers;
1836
+ }
1063
1837
  applyVfo(args) {
1064
1838
  if (args.length < 3) {
1065
1839
  return;
@@ -1106,18 +1880,22 @@ var TciClient = class extends EventEmitter {
1106
1880
  }
1107
1881
  }
1108
1882
  applyDrive(args) {
1109
- if (args.length === 1) {
1110
- const value2 = parseNumber(args[0]);
1111
- if (value2 !== void 0) {
1112
- this.state.drive[String(this.options.trx)] = value2;
1113
- }
1114
- return;
1115
- }
1116
- const trx = args[0] ?? String(this.options.trx);
1117
- const value = parseNumber(args[1]);
1118
- if (value !== void 0) {
1119
- this.state.drive[trx] = value;
1120
- }
1883
+ const parsed = this.activeDialect?.parseDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
1884
+ if (parsed) this.state.drive[String(parsed.trx)] = parsed.value;
1885
+ }
1886
+ applyTuneDrive(args) {
1887
+ const parsed = this.activeDialect?.parseTuneDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
1888
+ if (parsed) this.state.tuneDrive[String(parsed.trx)] = parsed.value;
1889
+ }
1890
+ updateAudioState(update) {
1891
+ this.state.audio = {
1892
+ sampleRate: update.sampleRate ?? this.state.audio?.sampleRate ?? 12e3,
1893
+ sampleType: update.sampleType ?? this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
1894
+ channels: update.channels ?? this.state.audio?.channels ?? 1,
1895
+ samplesPerFrame: update.samplesPerFrame ?? this.state.audio?.samplesPerFrame ?? 512,
1896
+ txBufferingMs: update.txBufferingMs ?? this.state.audio?.txBufferingMs,
1897
+ running: update.running ?? this.state.audio?.running ?? false
1898
+ };
1121
1899
  }
1122
1900
  applyRxChannelSensors(args) {
1123
1901
  if (args.length < 3) {
@@ -1153,11 +1931,16 @@ var TciClient = class extends EventEmitter {
1153
1931
  };
1154
1932
  }
1155
1933
  handleClose(reason) {
1156
- this.ws = void 0;
1934
+ const transport = this.transport;
1935
+ this.transport = void 0;
1936
+ transport?.removeAllListeners();
1157
1937
  const wasConnected = this.state.connected;
1158
1938
  this.state.connected = false;
1159
1939
  this.state.ready = false;
1160
1940
  this.queue.setConnected(false);
1941
+ this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
1942
+ this.activeIqSession?._fail(new TciError("disconnected", "TCI connection closed while IQ stream was active", reason));
1943
+ this.activeIqSession = void 0;
1161
1944
  if (wasConnected) {
1162
1945
  this.emit("disconnected", reason);
1163
1946
  this.emitState();
@@ -1174,18 +1957,6 @@ var TciClient = class extends EventEmitter {
1174
1957
  function createTciClient(options) {
1175
1958
  return new TciClient(options);
1176
1959
  }
1177
- function dataToBuffer(data) {
1178
- if (Buffer.isBuffer(data)) {
1179
- return data;
1180
- }
1181
- if (data instanceof ArrayBuffer) {
1182
- return Buffer.from(data);
1183
- }
1184
- if (Array.isArray(data)) {
1185
- return Buffer.concat(data.map((item) => dataToBuffer(item)));
1186
- }
1187
- throw new TciError("protocol-error", "Unsupported WebSocket data type");
1188
- }
1189
1960
  function rxVfoKey(receiver, vfo) {
1190
1961
  return `${receiver}:${vfo}`;
1191
1962
  }
@@ -1209,6 +1980,27 @@ function parseBoolean(value) {
1209
1980
  }
1210
1981
  return void 0;
1211
1982
  }
1983
+ function parseSampleType(value) {
1984
+ if (!value) return void 0;
1985
+ try {
1986
+ return normalizeSampleType(value.toLowerCase());
1987
+ } catch {
1988
+ return void 0;
1989
+ }
1990
+ }
1991
+ function parseObservedDrive(args, defaultTrx) {
1992
+ const hasTrx = args.length >= 2;
1993
+ const trx = hasTrx ? parseNumber(args[0]) : defaultTrx;
1994
+ const value = parseNumber(args[hasTrx ? 1 : 0]);
1995
+ return trx === void 0 || value === void 0 ? void 0 : { trx, value };
1996
+ }
1997
+ function normalizePercent(value) {
1998
+ if (!Number.isFinite(value)) throw new TciError("protocol-error", `Invalid TCI percentage: ${value}`);
1999
+ return Math.round(Math.max(0, Math.min(100, value)));
2000
+ }
2001
+ function writeResult(requested, applied, acknowledgement) {
2002
+ return { requested, applied, outcome: requested === applied ? "applied" : "clamped", acknowledgement };
2003
+ }
1212
2004
  function parseNumberPair(args) {
1213
2005
  const first = parseNumber(args[0]);
1214
2006
  const second = parseNumber(args[1]);
@@ -1224,10 +2016,26 @@ function cloneState(state) {
1224
2016
  pttSource: { ...state.pttSource },
1225
2017
  tune: { ...state.tune },
1226
2018
  drive: { ...state.drive },
2019
+ tuneDrive: { ...state.tuneDrive },
1227
2020
  split: { ...state.split },
2021
+ dialectWarnings: [...state.dialectWarnings],
1228
2022
  rxSensors: cloneNested(state.rxSensors),
1229
2023
  txSensors: cloneNested(state.txSensors),
1230
- audio: state.audio ? { ...state.audio } : void 0
2024
+ audio: state.audio ? { ...state.audio } : void 0,
2025
+ iq: { ...state.iq, activeReceivers: { ...state.iq.activeReceivers } },
2026
+ dds: { ...state.dds }
2027
+ };
2028
+ }
2029
+ function cloneHandshake(result) {
2030
+ return {
2031
+ identity: { ...result.identity, rawProtocolArgs: [...result.identity.rawProtocolArgs] },
2032
+ dialect: {
2033
+ ...result.dialect,
2034
+ evidence: [...result.dialect.evidence],
2035
+ warnings: [...result.dialect.warnings]
2036
+ },
2037
+ ready: true,
2038
+ commandNames: [...result.commandNames]
1231
2039
  };
1232
2040
  }
1233
2041
  function cloneNested(value) {
@@ -1237,30 +2045,46 @@ export {
1237
2045
  TCI_STREAM_HEADER_BYTES,
1238
2046
  TciClient,
1239
2047
  TciCommandQueue,
2048
+ TciDialectRegistry,
1240
2049
  TciError,
2050
+ TciIqStreamSession,
1241
2051
  TciSampleType,
1242
2052
  TciStreamType,
2053
+ WebSocketTciTransport,
2054
+ aetherSdrDialect,
2055
+ assertValidTciHandshake,
1243
2056
  buildStreamFrame,
1244
2057
  buildTxAudioFrame,
2058
+ builtInDialects,
1245
2059
  commandKey,
2060
+ compareTciVersion,
1246
2061
  createTciClient,
2062
+ decodeInterleavedIq,
2063
+ defaultTciDialectRegistry,
1247
2064
  deinterleaveChannels,
1248
2065
  escapeTciText,
2066
+ expertSdr14Dialect,
2067
+ expertSdrLegacyDialect,
2068
+ expertSdrModernDialect,
1249
2069
  float32ToPcm16,
1250
2070
  formatTciCommand,
2071
+ genericObservedDialect,
1251
2072
  isCommandReplyTo,
1252
2073
  mixToMono,
1253
2074
  normalizeCommandName,
1254
2075
  normalizeSampleType,
1255
2076
  normalizeStreamType,
2077
+ parseProtocolIdentity,
1256
2078
  parseStreamFrame,
1257
2079
  parseTciCommand,
1258
2080
  parseTciText,
2081
+ parseTciVersion,
1259
2082
  payloadToFloat32,
1260
2083
  pcm16ToFloat32,
1261
2084
  sampleTypeBytes,
1262
2085
  sampleTypeName,
1263
2086
  samplesToPayload,
2087
+ thetisDialect,
1264
2088
  toTciError,
1265
2089
  unescapeTciText
1266
2090
  };