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.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
  }
@@ -558,13 +588,398 @@ function ensureSemicolon(command) {
558
588
  return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
559
589
  }
560
590
 
591
+ // src/dialect/builtins.ts
592
+ var StandardTciDialect = class {
593
+ id;
594
+ label;
595
+ streamLengthSemantics;
596
+ supportsStreamChannels;
597
+ supportsTxAudioSource;
598
+ driveHasTrx;
599
+ detector;
600
+ resolver;
601
+ constructor(options) {
602
+ this.id = options.id;
603
+ this.label = options.label;
604
+ this.streamLengthSemantics = options.streamLengthSemantics;
605
+ this.supportsStreamChannels = options.supportsStreamChannels;
606
+ this.supportsTxAudioSource = options.supportsTxAudioSource;
607
+ this.driveHasTrx = options.driveHasTrx;
608
+ this.detector = options.detect;
609
+ this.resolver = options.resolve;
610
+ }
611
+ detect(context) {
612
+ return this.detector(context);
613
+ }
614
+ resolve(context) {
615
+ return this.resolver?.(context) ?? this;
616
+ }
617
+ buildDriveSetArgs(trx, value) {
618
+ return this.driveHasTrx ? [trx, value] : [value];
619
+ }
620
+ buildDriveReadArgs(trx) {
621
+ return this.driveHasTrx ? [trx] : [];
622
+ }
623
+ parseDrive(args, defaultTrx) {
624
+ return parseDriveState(args, defaultTrx, this.driveHasTrx);
625
+ }
626
+ buildTuneDriveSetArgs(trx, value) {
627
+ return this.driveHasTrx ? [trx, value] : [value];
628
+ }
629
+ buildTuneDriveReadArgs(trx) {
630
+ return this.driveHasTrx ? [trx] : [];
631
+ }
632
+ parseTuneDrive(args, defaultTrx) {
633
+ return parseDriveState(args, defaultTrx, this.driveHasTrx);
634
+ }
635
+ };
636
+ function parseDriveState(args, defaultTrx, hasTrx) {
637
+ const trx = hasTrx ? Number(args[0]) : defaultTrx;
638
+ const value = Number(args[hasTrx ? 1 : 0]);
639
+ if (!Number.isInteger(trx) || !Number.isFinite(value)) return void 0;
640
+ return { trx, value };
641
+ }
642
+ function version(context) {
643
+ return parseTciVersion(context.identity.protocolVersion);
644
+ }
645
+ function programIncludes(context, value) {
646
+ return context.identity.programName?.toLowerCase().includes(value) ?? false;
647
+ }
648
+ var expertSdr14Dialect = new StandardTciDialect({
649
+ id: "expertsdr-1.4",
650
+ label: "ExpertSDR / TCI 1.4",
651
+ streamLengthSemantics: "per-channel",
652
+ supportsStreamChannels: false,
653
+ supportsTxAudioSource: false,
654
+ driveHasTrx: false,
655
+ detect: (context) => {
656
+ const parsed = version(context);
657
+ if (!parsed || compareTciVersion(parsed, [1, 4]) > 0) return { score: 0, evidence: [] };
658
+ return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} <= 1.4`] };
659
+ }
660
+ });
661
+ var expertSdrLegacyDialect = new StandardTciDialect({
662
+ id: "expertsdr-1.5-1.8",
663
+ label: "ExpertSDR / TCI 1.5-1.8",
664
+ streamLengthSemantics: "per-channel",
665
+ supportsStreamChannels: false,
666
+ supportsTxAudioSource: false,
667
+ driveHasTrx: true,
668
+ detect: (context) => {
669
+ const parsed = version(context);
670
+ if (!parsed || compareTciVersion(parsed, [1, 5]) < 0 || compareTciVersion(parsed, [1, 9]) >= 0) return { score: 0, evidence: [] };
671
+ return { score: 80 + (programIncludes(context, "expert") ? 10 : 0), evidence: [`protocol ${formatVersion(parsed)} is in 1.5-1.8`] };
672
+ }
673
+ });
674
+ var expertSdrModernDialect = new StandardTciDialect({
675
+ id: "expertsdr-1.9-2.0",
676
+ label: "ExpertSDR / TCI 1.9-2.0",
677
+ streamLengthSemantics: "scalar",
678
+ supportsStreamChannels: true,
679
+ supportsTxAudioSource: true,
680
+ driveHasTrx: true,
681
+ detect: (context) => {
682
+ const parsed = version(context);
683
+ if (!parsed || compareTciVersion(parsed, [1, 9]) < 0) return { score: 0, evidence: [] };
684
+ const future = compareTciVersion(parsed, [2, 0]) > 0;
685
+ return {
686
+ score: 70 + (programIncludes(context, "expert") ? 15 : 0),
687
+ evidence: [`protocol ${formatVersion(parsed)} uses modern stream negotiation`],
688
+ warnings: future ? [`Unknown future TCI version ${formatVersion(parsed)}; using the modern dialect`] : []
689
+ };
690
+ }
691
+ });
692
+ var thetisDialect = new StandardTciDialect({
693
+ id: "thetis-2.0",
694
+ label: "Thetis / TCI 2.0",
695
+ streamLengthSemantics: "scalar",
696
+ supportsStreamChannels: true,
697
+ supportsTxAudioSource: true,
698
+ driveHasTrx: true,
699
+ detect: (context) => {
700
+ const evidence = [];
701
+ let score = 0;
702
+ if (programIncludes(context, "thetis")) {
703
+ score += 120;
704
+ evidence.push("PROTOCOL program is Thetis");
705
+ }
706
+ const observed = ["tx_frequency_ex", "tx_profiles_ex", "tx_profile_ex", "calibration_ex"].filter((name) => context.commandNames.has(name));
707
+ if (observed.length > 0) {
708
+ score += 100;
709
+ evidence.push(`Thetis extension commands: ${observed.join(", ")}`);
710
+ }
711
+ if (/anan|hermes|orion|saturn/i.test(context.identity.device ?? "")) {
712
+ score += 30;
713
+ evidence.push(`Thetis-family device: ${context.identity.device}`);
714
+ }
715
+ return { score, evidence };
716
+ }
717
+ });
718
+ var aetherSdrDialect = new StandardTciDialect({
719
+ id: "aethersdr-1.5",
720
+ label: "AetherSDR / TCI 1.5 hybrid",
721
+ streamLengthSemantics: "scalar",
722
+ supportsStreamChannels: true,
723
+ supportsTxAudioSource: true,
724
+ driveHasTrx: true,
725
+ detect: (context) => {
726
+ if (!/^aethersdr$/i.test(context.identity.device ?? "")) return { score: 0, evidence: [] };
727
+ const evidence = [`AetherSDR device identity: ${context.identity.device}`];
728
+ const modernAudioCommands = ["audio_stream_sample_type", "audio_stream_channels", "audio_stream_samples"].filter((name) => context.commandNames.has(name));
729
+ if (modernAudioCommands.length > 0) evidence.push(`Modern audio negotiation: ${modernAudioCommands.join(", ")}`);
730
+ return {
731
+ score: 150,
732
+ evidence,
733
+ warnings: context.identity.protocolVersion === "1.5" ? ["AetherSDR reports TCI 1.5 but uses modern scalar audio stream semantics"] : []
734
+ };
735
+ }
736
+ });
737
+ var genericObservedDialect = new StandardTciDialect({
738
+ id: "generic-observed",
739
+ label: "Generic observed TCI",
740
+ streamLengthSemantics: "auto",
741
+ supportsStreamChannels: true,
742
+ supportsTxAudioSource: true,
743
+ driveHasTrx: true,
744
+ detect: (context) => ({
745
+ score: context.commandNames.has("ready") ? 10 : 0,
746
+ evidence: ["No vendor-specific match; using observed command shapes"],
747
+ warnings: ["Dialect identity is uncertain"]
748
+ }),
749
+ resolve: (context) => {
750
+ const drive = [...context.commands].reverse().find((command) => command.name === "drive");
751
+ const driveHasTrx = (drive?.args.length ?? 0) >= 2;
752
+ return new StandardTciDialect({
753
+ id: "generic-observed",
754
+ label: "Generic observed TCI",
755
+ streamLengthSemantics: "auto",
756
+ supportsStreamChannels: context.commandNames.has("audio_stream_channels"),
757
+ supportsTxAudioSource: compareTciVersion(version(context) ?? [0], [2, 0]) >= 0,
758
+ driveHasTrx,
759
+ detect: genericObservedDialect.detect.bind(genericObservedDialect)
760
+ });
761
+ }
762
+ });
763
+ var builtInDialects = [
764
+ aetherSdrDialect,
765
+ thetisDialect,
766
+ expertSdr14Dialect,
767
+ expertSdrLegacyDialect,
768
+ expertSdrModernDialect,
769
+ genericObservedDialect
770
+ ];
771
+ function parseTciVersion(value) {
772
+ if (!value) return void 0;
773
+ const match = value.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
774
+ if (!match) return void 0;
775
+ return match.slice(1).filter((part) => part !== void 0).map(Number);
776
+ }
777
+ function compareTciVersion(left, right) {
778
+ const length = Math.max(left.length, right.length);
779
+ for (let index = 0; index < length; index += 1) {
780
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
781
+ if (difference !== 0) return difference;
782
+ }
783
+ return 0;
784
+ }
785
+ function formatVersion(value) {
786
+ return value.join(".");
787
+ }
788
+
789
+ // src/dialect/registry.ts
790
+ var TciDialectRegistry = class {
791
+ dialects = /* @__PURE__ */ new Map();
792
+ constructor(dialects = builtInDialects) {
793
+ for (const dialect of dialects) this.register(dialect);
794
+ }
795
+ register(dialect) {
796
+ this.dialects.set(dialect.id, dialect);
797
+ }
798
+ get(id) {
799
+ return this.dialects.get(id);
800
+ }
801
+ list() {
802
+ return [...this.dialects.values()];
803
+ }
804
+ select(context, selection = "auto") {
805
+ if (typeof selection === "object") {
806
+ return { dialect: selection, confidence: "manual", evidence: ["Custom dialect supplied by caller"], warnings: [] };
807
+ }
808
+ if (selection !== "auto") {
809
+ const dialect = this.get(selection);
810
+ if (!dialect) throw new TciError("unknown-dialect", `Unknown TCI dialect: ${selection}`);
811
+ return { dialect: dialect.resolve?.(context) ?? dialect, confidence: "manual", evidence: [`Dialect ${selection} selected by caller`], warnings: [] };
812
+ }
813
+ const candidates = this.list().map((dialect) => ({ dialect, result: dialect.detect(context) })).sort((left, right) => right.result.score - left.result.score);
814
+ const selected = candidates[0];
815
+ if (!selected || selected.result.score <= 0) {
816
+ throw new TciError("unknown-dialect", "Unable to identify the TCI server dialect");
817
+ }
818
+ return {
819
+ dialect: selected.dialect.resolve?.(context) ?? selected.dialect,
820
+ confidence: selected.result.score >= 100 ? "high" : selected.result.score >= 70 ? "medium" : "low",
821
+ evidence: selected.result.evidence,
822
+ warnings: selected.result.warnings ?? []
823
+ };
824
+ }
825
+ };
826
+ var defaultTciDialectRegistry = new TciDialectRegistry();
827
+
828
+ // src/dialect/handshake.ts
829
+ var IDENTITY_COMMANDS = /* @__PURE__ */ new Set(["protocol", "device", "trx_count", "channels_count", "channel_count"]);
830
+ var STATE_COMMANDS = /* @__PURE__ */ new Set(["vfo", "modulation", "modulations_list", "trx", "drive"]);
831
+ function parseProtocolIdentity(commands) {
832
+ const protocol = [...commands].reverse().find((command) => command.name === "protocol");
833
+ const device = [...commands].reverse().find((command) => command.name === "device");
834
+ const rawProtocolArgs = protocol?.args ?? [];
835
+ const firstLooksLikeVersion = /^\d+(?:\.\d+){0,2}/.test(rawProtocolArgs[0] ?? "");
836
+ return {
837
+ programName: firstLooksLikeVersion ? void 0 : rawProtocolArgs[0],
838
+ protocolVersion: firstLooksLikeVersion ? rawProtocolArgs[0] : rawProtocolArgs[1],
839
+ rawProtocolArgs: [...rawProtocolArgs],
840
+ device: device?.args.join(",")
841
+ };
842
+ }
843
+ function assertValidTciHandshake(commands) {
844
+ const names = new Set(commands.map((command) => command.name));
845
+ if (!names.has("ready")) throw new TciError("handshake-timeout", "TCI READY was not received");
846
+ const categories = [
847
+ [...IDENTITY_COMMANDS].some((name) => names.has(name)),
848
+ [...STATE_COMMANDS].some((name) => names.has(name))
849
+ ].filter(Boolean).length;
850
+ const identitySignals = [...IDENTITY_COMMANDS].filter((name) => names.has(name)).length;
851
+ if (categories < 2 && identitySignals < 2) {
852
+ throw new TciError("invalid-handshake", "WebSocket opened but did not provide enough TCI initialization evidence");
853
+ }
854
+ }
855
+
856
+ // src/transport/WebSocketTransport.ts
857
+ import { EventEmitter } from "eventemitter3";
858
+ import WebSocket from "ws";
859
+ var WebSocketTciTransport = class extends EventEmitter {
860
+ constructor(url, WebSocketImpl = WebSocket) {
861
+ super();
862
+ this.url = url;
863
+ this.WebSocketImpl = WebSocketImpl;
864
+ }
865
+ url;
866
+ WebSocketImpl;
867
+ socket;
868
+ async connect(timeoutMs) {
869
+ if (this.socket?.readyState === WebSocket.OPEN) return;
870
+ const socket = new this.WebSocketImpl(this.url);
871
+ this.socket = socket;
872
+ await new Promise((resolve, reject) => {
873
+ const timer = setTimeout(() => {
874
+ cleanup();
875
+ this.terminate();
876
+ reject(new TciError("connect-timeout", `Timed out connecting to ${this.url}`));
877
+ }, timeoutMs);
878
+ const cleanup = () => {
879
+ clearTimeout(timer);
880
+ socket.off("open", onOpen);
881
+ socket.off("close", onCloseBeforeOpen);
882
+ socket.off("error", onErrorBeforeOpen);
883
+ };
884
+ const onOpen = () => {
885
+ cleanup();
886
+ this.attach(socket);
887
+ this.emit("connected");
888
+ resolve();
889
+ };
890
+ const onCloseBeforeOpen = () => {
891
+ cleanup();
892
+ reject(new TciError("disconnected", `Disconnected while connecting to ${this.url}`));
893
+ };
894
+ const onErrorBeforeOpen = (error) => {
895
+ cleanup();
896
+ reject(toTciError(error, "disconnected"));
897
+ };
898
+ socket.once("open", onOpen);
899
+ socket.once("close", onCloseBeforeOpen);
900
+ socket.once("error", onErrorBeforeOpen);
901
+ });
902
+ }
903
+ async disconnect(code = 1e3, reason = "client disconnect") {
904
+ const socket = this.socket;
905
+ if (!socket || socket.readyState === WebSocket.CLOSED) return;
906
+ await new Promise((resolve) => {
907
+ const timer = setTimeout(resolve, 1e3);
908
+ timer.unref?.();
909
+ socket.once("close", () => {
910
+ clearTimeout(timer);
911
+ resolve();
912
+ });
913
+ socket.once("error", () => {
914
+ clearTimeout(timer);
915
+ resolve();
916
+ });
917
+ socket.close(code, reason);
918
+ });
919
+ }
920
+ isConnected() {
921
+ return this.socket?.readyState === WebSocket.OPEN;
922
+ }
923
+ async sendText(raw) {
924
+ await this.send(raw);
925
+ }
926
+ async sendBinary(raw) {
927
+ await this.send(raw, true);
928
+ }
929
+ terminate() {
930
+ try {
931
+ this.socket?.terminate();
932
+ } catch {
933
+ }
934
+ }
935
+ attach(socket) {
936
+ socket.on("message", (data, isBinary) => {
937
+ try {
938
+ const buffer = dataToBuffer(data);
939
+ if (isBinary) this.emit("binary", buffer);
940
+ else this.emit("text", buffer.toString("utf8"));
941
+ } catch (error) {
942
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
943
+ }
944
+ });
945
+ socket.on("close", (code, reason) => {
946
+ if (this.socket === socket) this.socket = void 0;
947
+ this.emit("disconnected", { code, reason: reason.toString("utf8") });
948
+ });
949
+ socket.on("error", (error) => this.emit("error", error));
950
+ }
951
+ async send(data, binary = false) {
952
+ const socket = this.socket;
953
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
954
+ throw new TciError("not-connected", "TCI socket is not connected");
955
+ }
956
+ await new Promise((resolve, reject) => {
957
+ socket.send(data, { binary }, (error) => error ? reject(error) : resolve());
958
+ });
959
+ }
960
+ };
961
+ function dataToBuffer(data) {
962
+ if (Buffer.isBuffer(data)) return data;
963
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
964
+ if (Array.isArray(data)) return Buffer.concat(data.map((item) => dataToBuffer(item)));
965
+ throw new TciError("protocol-error", "Unsupported WebSocket data type");
966
+ }
967
+
561
968
  // src/client/TciClient.ts
562
- var TciClient = class extends EventEmitter {
969
+ var TciClient = class extends EventEmitter2 {
563
970
  options;
564
971
  WebSocketImpl;
565
- ws;
972
+ transportFactory;
973
+ transport;
566
974
  queue;
567
975
  state;
976
+ stateReducers;
977
+ dialectRegistry;
978
+ activeDialect;
979
+ handshakeResult;
980
+ handshakeError;
981
+ initializationCommands = [];
982
+ handshakeWaiter;
568
983
  constructor(options) {
569
984
  super();
570
985
  this.options = {
@@ -573,13 +988,17 @@ var TciClient = class extends EventEmitter {
573
988
  trx: options.trx ?? 0,
574
989
  vfo: options.vfo ?? 0,
575
990
  connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
991
+ handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
576
992
  commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
577
993
  writeAckMode: options.writeAckMode ?? "state",
578
994
  writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
579
995
  writeSettleMs: options.writeSettleMs ?? 0,
580
- frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
996
+ frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
997
+ dialect: options.dialect ?? "auto"
581
998
  };
582
- this.WebSocketImpl = options.WebSocketImpl ?? WebSocket;
999
+ this.dialectRegistry = options.dialectRegistry ?? defaultTciDialectRegistry;
1000
+ this.WebSocketImpl = options.WebSocketImpl ?? WebSocket2;
1001
+ this.transportFactory = options.transportFactory ?? ((url) => new WebSocketTciTransport(url, this.WebSocketImpl));
583
1002
  this.queue = new TciCommandQueue({
584
1003
  timeoutMs: this.options.commandTimeoutMs,
585
1004
  send: (raw) => this.sendRaw(raw)
@@ -595,58 +1014,49 @@ var TciClient = class extends EventEmitter {
595
1014
  pttSource: {},
596
1015
  tune: {},
597
1016
  drive: {},
1017
+ tuneDrive: {},
598
1018
  split: {},
1019
+ dialectWarnings: [],
599
1020
  rxSensors: {},
600
1021
  txSensors: {}
601
1022
  };
1023
+ this.stateReducers = this.createStateReducers();
602
1024
  }
603
1025
  async connect() {
604
- if (this.ws?.readyState === WebSocket.OPEN) {
605
- return;
1026
+ if (this.transport?.isConnected()) {
1027
+ return this.handshakeResult ?? this.waitForHandshake();
606
1028
  }
607
- if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
608
- await this.waitForOpen(this.ws);
609
- return;
1029
+ this.resetHandshake();
1030
+ const transport = this.transportFactory(this.options.url);
1031
+ this.transport = transport;
1032
+ this.attachTransport(transport);
1033
+ await transport.connect(this.options.connectTimeoutMs);
1034
+ this.state.connected = true;
1035
+ this.queue.setConnected(true);
1036
+ this.emit("connected");
1037
+ this.emitState();
1038
+ try {
1039
+ return await this.waitForHandshake();
1040
+ } catch (error) {
1041
+ transport.terminate();
1042
+ throw error;
610
1043
  }
611
- const ws = new this.WebSocketImpl(this.options.url);
612
- this.ws = ws;
613
- await this.waitForOpen(ws);
614
1044
  }
615
1045
  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
- });
1046
+ const transport = this.transport;
1047
+ if (!transport) return;
1048
+ await transport.disconnect(code, reason);
642
1049
  this.handleClose();
643
1050
  }
644
1051
  isConnected() {
645
- return this.ws?.readyState === WebSocket.OPEN;
1052
+ return this.transport?.isConnected() ?? false;
646
1053
  }
647
1054
  getState() {
648
1055
  return cloneState(this.state);
649
1056
  }
1057
+ getHandshakeResult() {
1058
+ return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
1059
+ }
650
1060
  async sendCommand(name, args = [], options = {}) {
651
1061
  const raw = formatTciCommand(name, args);
652
1062
  if (options.waitForReply === false) {
@@ -720,6 +1130,9 @@ var TciClient = class extends EventEmitter {
720
1130
  }
721
1131
  async setPtt(enabled, options = {}) {
722
1132
  const trx = options.trx ?? this.options.trx;
1133
+ if (options.source && !this.requireDialect().supportsTxAudioSource) {
1134
+ throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support a TRX audio source argument`);
1135
+ }
723
1136
  const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
724
1137
  await this.sendStateWrite(
725
1138
  "TRX",
@@ -733,16 +1146,107 @@ var TciClient = class extends EventEmitter {
733
1146
  const reply = await this.request("TRX", [trx]);
734
1147
  return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
735
1148
  }
736
- async setTune(enabled, trx = this.options.trx) {
737
- await this.sendCommand("TUNE", [trx, enabled]);
1149
+ async setTune(enabled, trx = this.options.trx, options = {}) {
1150
+ try {
1151
+ await this.sendStateWrite(
1152
+ "TUNE",
1153
+ [trx, enabled],
1154
+ (state) => state.tune[String(trx)] === enabled,
1155
+ `TUNE:${trx},${enabled}`,
1156
+ options
1157
+ );
1158
+ } catch (error) {
1159
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1160
+ await this.request("TUNE", [trx], { timeoutMs: options.timeoutMs });
1161
+ if (this.state.tune[String(trx)] !== enabled) throw error;
1162
+ }
738
1163
  }
739
1164
  async setDrive(value, trx = this.options.trx) {
740
- await this.sendCommand("DRIVE", [trx, value]);
1165
+ await this.setDriveWithResult(value, trx);
741
1166
  }
742
- async setSplit(enabled, trx = this.options.trx) {
743
- await this.sendCommand("SPLIT_ENABLE", [trx, enabled]);
1167
+ async setDriveWithResult(value, trx = this.options.trx, options = {}) {
1168
+ const requested = normalizePercent(value);
1169
+ const dialect = this.requireDialect();
1170
+ if (this.state.drive[String(trx)] === requested) {
1171
+ return writeResult(requested, requested, "state");
1172
+ }
1173
+ const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
1174
+ const waiter = this.waitForCommand(
1175
+ (command) => command.name === "drive" && dialect.parseDrive(command.args, trx)?.trx === trx,
1176
+ Math.min(500, timeoutMs),
1177
+ `DRIVE state for TRX ${trx}`
1178
+ );
1179
+ await this.sendCommand("DRIVE", dialect.buildDriveSetArgs(trx, requested), { waitForReply: false });
1180
+ try {
1181
+ const command = await waiter.promise;
1182
+ const applied2 = dialect.parseDrive(command.args, trx)?.value;
1183
+ if (applied2 !== void 0) return writeResult(requested, applied2, "state");
1184
+ } catch (error) {
1185
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1186
+ } finally {
1187
+ waiter.cancel();
1188
+ }
1189
+ const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx), {
1190
+ timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
1191
+ });
1192
+ const applied = dialect.parseDrive(reply.args, trx)?.value;
1193
+ if (applied === void 0) throw new TciError("protocol-error", `Invalid DRIVE readback: ${reply.raw}`);
1194
+ return writeResult(requested, applied, "readback");
1195
+ }
1196
+ async getDrive(trx = this.options.trx) {
1197
+ const dialect = this.requireDialect();
1198
+ const reply = await this.request("DRIVE", dialect.buildDriveReadArgs(trx));
1199
+ return dialect.parseDrive(reply.args, trx)?.value ?? this.state.drive[String(trx)];
1200
+ }
1201
+ async setTuneDrive(value, trx = this.options.trx, options = {}) {
1202
+ const requested = normalizePercent(value);
1203
+ const dialect = this.requireDialect();
1204
+ if (this.state.tuneDrive[String(trx)] === requested) return writeResult(requested, requested, "state");
1205
+ const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
1206
+ const waiter = this.waitForCommand(
1207
+ (command) => command.name === "tune_drive" && dialect.parseTuneDrive(command.args, trx)?.trx === trx,
1208
+ Math.min(500, timeoutMs),
1209
+ `TUNE_DRIVE state for TRX ${trx}`
1210
+ );
1211
+ await this.sendCommand("TUNE_DRIVE", dialect.buildTuneDriveSetArgs(trx, requested), { waitForReply: false });
1212
+ try {
1213
+ const command = await waiter.promise;
1214
+ const applied2 = dialect.parseTuneDrive(command.args, trx)?.value;
1215
+ if (applied2 !== void 0) return writeResult(requested, applied2, "state");
1216
+ } catch (error) {
1217
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1218
+ } finally {
1219
+ waiter.cancel();
1220
+ }
1221
+ const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx), {
1222
+ timeoutMs: Math.max(1, timeoutMs - Math.min(500, timeoutMs))
1223
+ });
1224
+ const applied = dialect.parseTuneDrive(reply.args, trx)?.value;
1225
+ if (applied === void 0) throw new TciError("protocol-error", `Invalid TUNE_DRIVE readback: ${reply.raw}`);
1226
+ return writeResult(requested, applied, "readback");
1227
+ }
1228
+ async getTuneDrive(trx = this.options.trx) {
1229
+ const dialect = this.requireDialect();
1230
+ const reply = await this.request("TUNE_DRIVE", dialect.buildTuneDriveReadArgs(trx));
1231
+ return dialect.parseTuneDrive(reply.args, trx)?.value ?? this.state.tuneDrive[String(trx)];
1232
+ }
1233
+ async setSplit(enabled, trx = this.options.trx, options = {}) {
1234
+ try {
1235
+ await this.sendStateWrite(
1236
+ "SPLIT_ENABLE",
1237
+ [trx, enabled],
1238
+ (state) => state.split[String(trx)] === enabled,
1239
+ `SPLIT_ENABLE:${trx},${enabled}`,
1240
+ options
1241
+ );
1242
+ } catch (error) {
1243
+ if (!(error instanceof TciError) || error.code !== "command-timeout") throw error;
1244
+ await this.request("SPLIT_ENABLE", [trx], { timeoutMs: options.timeoutMs });
1245
+ if (this.state.split[String(trx)] !== enabled) throw error;
1246
+ }
744
1247
  }
745
1248
  async configureAudio(config) {
1249
+ const dialect = this.requireDialect();
746
1250
  const audio = {
747
1251
  sampleRate: config.sampleRate,
748
1252
  sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
@@ -753,11 +1257,13 @@ var TciClient = class extends EventEmitter {
753
1257
  };
754
1258
  this.state.audio = audio;
755
1259
  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 });
1260
+ if (dialect.supportsStreamChannels) {
1261
+ await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
1262
+ await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
1263
+ await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
1264
+ if (audio.txBufferingMs !== void 0) {
1265
+ await this.sendCommand("TX_STREAM_AUDIO_BUFFERING", [audio.txBufferingMs], { waitForReply: false });
1266
+ }
761
1267
  }
762
1268
  this.emitState();
763
1269
  }
@@ -776,12 +1282,16 @@ var TciClient = class extends EventEmitter {
776
1282
  }
777
1283
  }
778
1284
  sendTxAudio(options) {
779
- const frame = buildTxAudioFrame({ receiver: this.options.receiver, ...options });
1285
+ const frame = buildTxAudioFrame({
1286
+ receiver: this.options.receiver,
1287
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1288
+ ...options
1289
+ });
780
1290
  this.sendRawBinary(frame);
781
1291
  }
782
1292
  sendTxAudioForChrono(request, samples) {
783
1293
  const channels = Math.max(1, Math.floor(request.channels || 1));
784
- const targetSampleLength = Math.max(0, Math.floor(request.sampleCount) * channels);
1294
+ const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
785
1295
  const output = new Float32Array(targetSampleLength);
786
1296
  const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
787
1297
  output.set(source.subarray(0, output.length));
@@ -879,78 +1389,147 @@ var TciClient = class extends EventEmitter {
879
1389
  cancel: cleanup
880
1390
  };
881
1391
  }
882
- waitForOpen(ws) {
1392
+ waitForCommand(predicate, timeoutMs, description) {
1393
+ let timer;
1394
+ let resolvePromise;
1395
+ let rejectPromise;
1396
+ const cleanup = () => {
1397
+ if (timer) clearTimeout(timer);
1398
+ timer = void 0;
1399
+ this.off("command", onCommand);
1400
+ this.off("disconnected", onDisconnected);
1401
+ };
1402
+ const onCommand = (command) => {
1403
+ if (!predicate(command)) return;
1404
+ cleanup();
1405
+ resolvePromise(command);
1406
+ };
1407
+ const onDisconnected = () => {
1408
+ cleanup();
1409
+ rejectPromise(new TciError("disconnected", `Disconnected while waiting for ${description}`));
1410
+ };
1411
+ const promise = new Promise((resolve, reject) => {
1412
+ resolvePromise = resolve;
1413
+ rejectPromise = reject;
1414
+ timer = setTimeout(() => {
1415
+ cleanup();
1416
+ reject(new TciError("command-timeout", `Timed out waiting for ${description}`));
1417
+ }, timeoutMs);
1418
+ this.on("command", onCommand);
1419
+ this.on("disconnected", onDisconnected);
1420
+ });
1421
+ return { promise, cancel: cleanup };
1422
+ }
1423
+ resetHandshake() {
1424
+ this.rejectHandshake(new TciError("cancelled", "TCI handshake replaced by a new connection"));
1425
+ this.handshakeResult = void 0;
1426
+ this.handshakeError = void 0;
1427
+ this.activeDialect = void 0;
1428
+ this.initializationCommands = [];
1429
+ this.state.ready = false;
1430
+ this.state.protocol = void 0;
1431
+ this.state.protocolName = void 0;
1432
+ this.state.protocolVersion = void 0;
1433
+ this.state.dialectId = void 0;
1434
+ this.state.dialectConfidence = void 0;
1435
+ this.state.dialectWarnings = [];
1436
+ }
1437
+ waitForHandshake() {
1438
+ if (this.handshakeResult) return Promise.resolve(cloneHandshake(this.handshakeResult));
1439
+ if (this.handshakeError) return Promise.reject(this.handshakeError);
1440
+ if (this.handshakeWaiter) {
1441
+ return new Promise((resolve, reject) => {
1442
+ const onHandshake = (result) => {
1443
+ cleanup();
1444
+ resolve(result);
1445
+ };
1446
+ const onError = (error) => {
1447
+ cleanup();
1448
+ reject(error);
1449
+ };
1450
+ const cleanup = () => {
1451
+ this.off("handshake", onHandshake);
1452
+ this.off("error", onError);
1453
+ };
1454
+ this.once("handshake", onHandshake);
1455
+ this.once("error", onError);
1456
+ });
1457
+ }
883
1458
  return new Promise((resolve, reject) => {
884
1459
  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);
1460
+ const error = new TciError("handshake-timeout", `Timed out waiting for TCI READY from ${this.options.url}`);
1461
+ this.handshakeWaiter = void 0;
1462
+ reject(error);
1463
+ }, this.options.handshakeTimeoutMs);
1464
+ this.handshakeWaiter = { resolve, reject, timer };
920
1465
  });
921
1466
  }
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));
1467
+ finalizeHandshake() {
1468
+ if (this.handshakeResult) return this.handshakeResult;
1469
+ assertValidTciHandshake(this.initializationCommands);
1470
+ const identity = parseProtocolIdentity(this.initializationCommands);
1471
+ const commandNames = [...new Set(this.initializationCommands.map((command) => command.name))];
1472
+ const dialect = this.dialectRegistry.select(
1473
+ { identity, commands: this.initializationCommands, commandNames: new Set(commandNames) },
1474
+ this.options.dialect
1475
+ );
1476
+ const result = { identity, dialect, ready: true, commandNames };
1477
+ this.handshakeResult = result;
1478
+ this.activeDialect = dialect.dialect;
1479
+ this.state.protocolName = identity.programName;
1480
+ this.state.protocolVersion = identity.protocolVersion;
1481
+ this.state.protocol = identity.protocolVersion ?? identity.programName;
1482
+ this.state.device = identity.device ?? this.state.device;
1483
+ this.state.dialectId = dialect.dialect.id;
1484
+ this.state.dialectConfidence = dialect.confidence;
1485
+ this.state.dialectWarnings = [...dialect.warnings];
1486
+ const waiter = this.handshakeWaiter;
1487
+ this.handshakeWaiter = void 0;
1488
+ if (waiter) {
1489
+ clearTimeout(waiter.timer);
1490
+ waiter.resolve(cloneHandshake(result));
1491
+ }
1492
+ this.emit("handshake", cloneHandshake(result));
1493
+ return result;
1494
+ }
1495
+ rejectHandshake(error) {
1496
+ const waiter = this.handshakeWaiter;
1497
+ this.handshakeWaiter = void 0;
1498
+ if (!waiter) return;
1499
+ clearTimeout(waiter.timer);
1500
+ waiter.reject(error);
1501
+ }
1502
+ requireDialect() {
1503
+ if (!this.activeDialect) throw new TciError("invalid-handshake", "TCI dialect is not available before READY");
1504
+ return this.activeDialect;
1505
+ }
1506
+ attachTransport(transport) {
1507
+ transport.on("text", (raw) => this.handleText(raw));
1508
+ transport.on("binary", (raw) => this.handleBinary(raw));
1509
+ transport.on("disconnected", (reason) => this.handleClose(reason));
1510
+ transport.on("error", (error) => this.handleError(error));
926
1511
  }
927
1512
  async sendRaw(raw) {
928
- const ws = this.ws;
929
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1513
+ const transport = this.transport;
1514
+ if (!transport?.isConnected()) {
930
1515
  throw new TciError("not-connected", "TCI socket is not connected");
931
1516
  }
932
1517
  this.emit("tci:tx", raw);
933
- await new Promise((resolve, reject) => {
934
- ws.send(raw, (error) => error ? reject(error) : resolve());
935
- });
1518
+ await transport.sendText(raw);
936
1519
  }
937
1520
  sendRawBinary(raw) {
938
- const ws = this.ws;
939
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1521
+ const transport = this.transport;
1522
+ if (!transport?.isConnected()) {
940
1523
  throw new TciError("not-connected", "TCI socket is not connected");
941
1524
  }
942
- ws.send(raw, { binary: true });
1525
+ void transport.sendBinary(raw).catch((error) => this.handleError(error));
943
1526
  }
944
- handleMessage(data, isBinary) {
1527
+ handleText(raw) {
945
1528
  try {
946
- if (isBinary) {
947
- this.handleBinary(data);
948
- return;
949
- }
950
- const raw = dataToBuffer(data).toString("utf8");
951
1529
  const commands = parseTciText(raw);
952
1530
  this.emit("tci:rx", raw, commands);
953
1531
  for (const command of commands) {
1532
+ if (!this.handshakeResult) this.initializationCommands.push(command);
954
1533
  this.queue.handleCommand(command);
955
1534
  this.applyCommand(command);
956
1535
  this.emit("command", command);
@@ -960,7 +1539,10 @@ var TciClient = class extends EventEmitter {
960
1539
  }
961
1540
  }
962
1541
  handleBinary(data) {
963
- const frame = parseStreamFrame(dataToBuffer(data));
1542
+ const frame = parseStreamFrame(data, {
1543
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1544
+ negotiatedChannels: this.state.audio?.channels
1545
+ });
964
1546
  this.emit("tci:binary", frame);
965
1547
  this.emit("binary", frame);
966
1548
  switch (frame.streamType) {
@@ -974,7 +1556,8 @@ var TciClient = class extends EventEmitter {
974
1556
  sampleRate: frame.sampleRate,
975
1557
  channels: frame.channels,
976
1558
  sampleType: frame.sampleType,
977
- sampleCount: frame.sampleCount
1559
+ sampleCount: frame.sampleCount,
1560
+ frameCount: frame.frameCount
978
1561
  });
979
1562
  break;
980
1563
  case 4 /* LINEOUT_STREAM */:
@@ -986,80 +1569,78 @@ var TciClient = class extends EventEmitter {
986
1569
  }
987
1570
  applyCommand(command) {
988
1571
  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
- }
1572
+ this.stateReducers.get(command.name)?.(command.args);
1058
1573
  if (!readyBefore && this.state.ready) {
1059
- this.emit("ready", this.getState());
1574
+ try {
1575
+ this.finalizeHandshake();
1576
+ this.emit("ready", this.getState());
1577
+ } catch (error) {
1578
+ const tciError = toTciError(error, "invalid-handshake");
1579
+ this.handshakeError = tciError;
1580
+ this.state.ready = false;
1581
+ this.rejectHandshake(tciError);
1582
+ this.handleError(tciError);
1583
+ }
1060
1584
  }
1061
1585
  this.emitState();
1062
1586
  }
1587
+ createStateReducers() {
1588
+ const reducers = /* @__PURE__ */ new Map();
1589
+ reducers.set("ready", (args) => {
1590
+ this.state.ready = args.length === 0 ? true : parseBoolean(args[0]) ?? true;
1591
+ });
1592
+ reducers.set("protocol", (args) => {
1593
+ if (/^\d+(?:\.\d+){0,2}/.test(args[0] ?? "")) {
1594
+ this.state.protocol = args[0];
1595
+ this.state.protocolVersion = args[0];
1596
+ } else {
1597
+ this.state.protocolName = args[0];
1598
+ this.state.protocolVersion = args[1];
1599
+ this.state.protocol = args[1] ?? args[0];
1600
+ }
1601
+ });
1602
+ reducers.set("device", (args) => {
1603
+ this.state.device = args.join(",");
1604
+ });
1605
+ reducers.set("receive_only", (args) => {
1606
+ this.state.receiveOnly = parseBoolean(args[0]);
1607
+ });
1608
+ reducers.set("trx_count", (args) => {
1609
+ this.state.trxCount = parseNumber(args[0]);
1610
+ });
1611
+ const channelCount = (args) => {
1612
+ this.state.channelCount = parseNumber(args[0]);
1613
+ };
1614
+ reducers.set("channels_count", channelCount);
1615
+ reducers.set("channel_count", channelCount);
1616
+ reducers.set("vfo_limits", (args) => {
1617
+ this.state.vfoLimits = parseNumberPair(args);
1618
+ });
1619
+ reducers.set("if_limits", (args) => {
1620
+ this.state.ifLimits = parseNumberPair(args);
1621
+ });
1622
+ reducers.set("modulations_list", (args) => {
1623
+ this.state.modulations = args.map((mode) => mode.toLowerCase());
1624
+ });
1625
+ reducers.set("vfo", (args) => this.applyVfo(args));
1626
+ reducers.set("modulation", (args) => this.applyModulation(args));
1627
+ reducers.set("trx", (args) => this.applyTrx(args));
1628
+ reducers.set("tune", (args) => this.applyBooleanByFirstArg(this.state.tune, args));
1629
+ reducers.set("drive", (args) => this.applyDrive(args));
1630
+ reducers.set("tune_drive", (args) => this.applyTuneDrive(args));
1631
+ reducers.set("split_enable", (args) => this.applyBooleanByFirstArg(this.state.split, args));
1632
+ reducers.set("rx_channel_sensors", (args) => this.applyRxChannelSensors(args));
1633
+ reducers.set("rx_sensors", (args) => this.applyRxSensors(args));
1634
+ reducers.set("tx_sensors", (args) => this.applyTxSensors(args));
1635
+ reducers.set("audio_samplerate", (args) => this.updateAudioState({ sampleRate: parseNumber(args[0]) }));
1636
+ reducers.set("audio_stream_sample_type", (args) => this.updateAudioState({ sampleType: parseSampleType(args[0]) }));
1637
+ reducers.set("audio_stream_channels", (args) => this.updateAudioState({ channels: parseNumber(args[0]) }));
1638
+ reducers.set("audio_stream_samples", (args) => this.updateAudioState({ samplesPerFrame: parseNumber(args[0]) }));
1639
+ reducers.set("tx_stream_audio_buffering", (args) => this.updateAudioState({ txBufferingMs: parseNumber(args[0]) }));
1640
+ reducers.set("audio_start", () => this.updateAudioState({ running: true }));
1641
+ reducers.set("audio_stop", () => this.updateAudioState({ running: false }));
1642
+ return reducers;
1643
+ }
1063
1644
  applyVfo(args) {
1064
1645
  if (args.length < 3) {
1065
1646
  return;
@@ -1106,18 +1687,22 @@ var TciClient = class extends EventEmitter {
1106
1687
  }
1107
1688
  }
1108
1689
  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
- }
1690
+ const parsed = this.activeDialect?.parseDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
1691
+ if (parsed) this.state.drive[String(parsed.trx)] = parsed.value;
1692
+ }
1693
+ applyTuneDrive(args) {
1694
+ const parsed = this.activeDialect?.parseTuneDrive(args, this.options.trx) ?? parseObservedDrive(args, this.options.trx);
1695
+ if (parsed) this.state.tuneDrive[String(parsed.trx)] = parsed.value;
1696
+ }
1697
+ updateAudioState(update) {
1698
+ this.state.audio = {
1699
+ sampleRate: update.sampleRate ?? this.state.audio?.sampleRate ?? 12e3,
1700
+ sampleType: update.sampleType ?? this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
1701
+ channels: update.channels ?? this.state.audio?.channels ?? 1,
1702
+ samplesPerFrame: update.samplesPerFrame ?? this.state.audio?.samplesPerFrame ?? 512,
1703
+ txBufferingMs: update.txBufferingMs ?? this.state.audio?.txBufferingMs,
1704
+ running: update.running ?? this.state.audio?.running ?? false
1705
+ };
1121
1706
  }
1122
1707
  applyRxChannelSensors(args) {
1123
1708
  if (args.length < 3) {
@@ -1153,11 +1738,14 @@ var TciClient = class extends EventEmitter {
1153
1738
  };
1154
1739
  }
1155
1740
  handleClose(reason) {
1156
- this.ws = void 0;
1741
+ const transport = this.transport;
1742
+ this.transport = void 0;
1743
+ transport?.removeAllListeners();
1157
1744
  const wasConnected = this.state.connected;
1158
1745
  this.state.connected = false;
1159
1746
  this.state.ready = false;
1160
1747
  this.queue.setConnected(false);
1748
+ this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
1161
1749
  if (wasConnected) {
1162
1750
  this.emit("disconnected", reason);
1163
1751
  this.emitState();
@@ -1174,18 +1762,6 @@ var TciClient = class extends EventEmitter {
1174
1762
  function createTciClient(options) {
1175
1763
  return new TciClient(options);
1176
1764
  }
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
1765
  function rxVfoKey(receiver, vfo) {
1190
1766
  return `${receiver}:${vfo}`;
1191
1767
  }
@@ -1209,6 +1785,27 @@ function parseBoolean(value) {
1209
1785
  }
1210
1786
  return void 0;
1211
1787
  }
1788
+ function parseSampleType(value) {
1789
+ if (!value) return void 0;
1790
+ try {
1791
+ return normalizeSampleType(value.toLowerCase());
1792
+ } catch {
1793
+ return void 0;
1794
+ }
1795
+ }
1796
+ function parseObservedDrive(args, defaultTrx) {
1797
+ const hasTrx = args.length >= 2;
1798
+ const trx = hasTrx ? parseNumber(args[0]) : defaultTrx;
1799
+ const value = parseNumber(args[hasTrx ? 1 : 0]);
1800
+ return trx === void 0 || value === void 0 ? void 0 : { trx, value };
1801
+ }
1802
+ function normalizePercent(value) {
1803
+ if (!Number.isFinite(value)) throw new TciError("protocol-error", `Invalid TCI percentage: ${value}`);
1804
+ return Math.round(Math.max(0, Math.min(100, value)));
1805
+ }
1806
+ function writeResult(requested, applied, acknowledgement) {
1807
+ return { requested, applied, outcome: requested === applied ? "applied" : "clamped", acknowledgement };
1808
+ }
1212
1809
  function parseNumberPair(args) {
1213
1810
  const first = parseNumber(args[0]);
1214
1811
  const second = parseNumber(args[1]);
@@ -1224,12 +1821,26 @@ function cloneState(state) {
1224
1821
  pttSource: { ...state.pttSource },
1225
1822
  tune: { ...state.tune },
1226
1823
  drive: { ...state.drive },
1824
+ tuneDrive: { ...state.tuneDrive },
1227
1825
  split: { ...state.split },
1826
+ dialectWarnings: [...state.dialectWarnings],
1228
1827
  rxSensors: cloneNested(state.rxSensors),
1229
1828
  txSensors: cloneNested(state.txSensors),
1230
1829
  audio: state.audio ? { ...state.audio } : void 0
1231
1830
  };
1232
1831
  }
1832
+ function cloneHandshake(result) {
1833
+ return {
1834
+ identity: { ...result.identity, rawProtocolArgs: [...result.identity.rawProtocolArgs] },
1835
+ dialect: {
1836
+ ...result.dialect,
1837
+ evidence: [...result.dialect.evidence],
1838
+ warnings: [...result.dialect.warnings]
1839
+ },
1840
+ ready: true,
1841
+ commandNames: [...result.commandNames]
1842
+ };
1843
+ }
1233
1844
  function cloneNested(value) {
1234
1845
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, { ...item }]));
1235
1846
  }
@@ -1237,30 +1848,44 @@ export {
1237
1848
  TCI_STREAM_HEADER_BYTES,
1238
1849
  TciClient,
1239
1850
  TciCommandQueue,
1851
+ TciDialectRegistry,
1240
1852
  TciError,
1241
1853
  TciSampleType,
1242
1854
  TciStreamType,
1855
+ WebSocketTciTransport,
1856
+ aetherSdrDialect,
1857
+ assertValidTciHandshake,
1243
1858
  buildStreamFrame,
1244
1859
  buildTxAudioFrame,
1860
+ builtInDialects,
1245
1861
  commandKey,
1862
+ compareTciVersion,
1246
1863
  createTciClient,
1864
+ defaultTciDialectRegistry,
1247
1865
  deinterleaveChannels,
1248
1866
  escapeTciText,
1867
+ expertSdr14Dialect,
1868
+ expertSdrLegacyDialect,
1869
+ expertSdrModernDialect,
1249
1870
  float32ToPcm16,
1250
1871
  formatTciCommand,
1872
+ genericObservedDialect,
1251
1873
  isCommandReplyTo,
1252
1874
  mixToMono,
1253
1875
  normalizeCommandName,
1254
1876
  normalizeSampleType,
1255
1877
  normalizeStreamType,
1878
+ parseProtocolIdentity,
1256
1879
  parseStreamFrame,
1257
1880
  parseTciCommand,
1258
1881
  parseTciText,
1882
+ parseTciVersion,
1259
1883
  payloadToFloat32,
1260
1884
  pcm16ToFloat32,
1261
1885
  sampleTypeBytes,
1262
1886
  sampleTypeName,
1263
1887
  samplesToPayload,
1888
+ thetisDialect,
1264
1889
  toTciError,
1265
1890
  unescapeTciText
1266
1891
  };