tci-client-node 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +33 -13
  3. package/dist/audio/index.cjs +63 -20
  4. package/dist/audio/index.cjs.map +1 -1
  5. package/dist/audio/index.d.cts +18 -3
  6. package/dist/audio/index.d.ts +18 -3
  7. package/dist/audio/index.js +63 -20
  8. package/dist/audio/index.js.map +1 -1
  9. package/dist/dialect/index.cjs +331 -0
  10. package/dist/dialect/index.cjs.map +1 -0
  11. package/dist/dialect/index.d.cts +29 -0
  12. package/dist/dialect/index.d.ts +29 -0
  13. package/dist/dialect/index.js +292 -0
  14. package/dist/dialect/index.js.map +1 -0
  15. package/dist/{index-CK3XdXP3.d.cts → index-lbK6NGY4.d.cts} +1 -1
  16. package/dist/{index-Dfmrk2MR.d.ts → index-paj1AOJY.d.ts} +1 -1
  17. package/dist/index.cjs +890 -224
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +54 -12
  20. package/dist/index.d.ts +54 -12
  21. package/dist/index.js +876 -224
  22. package/dist/index.js.map +1 -1
  23. package/dist/protocol/index.cjs.map +1 -1
  24. package/dist/protocol/index.d.cts +1 -1
  25. package/dist/protocol/index.d.ts +1 -1
  26. package/dist/protocol/index.js.map +1 -1
  27. package/dist/testing/index.cjs +77 -25
  28. package/dist/testing/index.cjs.map +1 -1
  29. package/dist/testing/index.d.cts +2 -0
  30. package/dist/testing/index.d.ts +2 -0
  31. package/dist/testing/index.js +77 -25
  32. package/dist/testing/index.js.map +1 -1
  33. package/dist/transport/index.cjs +175 -0
  34. package/dist/transport/index.cjs.map +1 -0
  35. package/dist/transport/index.d.cts +37 -0
  36. package/dist/transport/index.d.ts +37 -0
  37. package/dist/transport/index.js +138 -0
  38. package/dist/transport/index.js.map +1 -0
  39. package/dist/types-9seY9Th-.d.ts +63 -0
  40. package/dist/types-xRotf9gW.d.cts +63 -0
  41. package/package.json +15 -1
package/dist/index.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`);
@@ -48,28 +48,49 @@ function parseStreamFrame(input) {
48
48
  const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
49
49
  const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
50
50
  const sampleType = normalizeSampleType(header[2]);
51
- let channels = header[7];
51
+ const streamType = normalizeStreamType(header[6]);
52
+ let channels = header[7] || options.negotiatedChannels || 0;
52
53
  const bytesPerSample = sampleTypeBytes(sampleType);
53
- const sampleCount = header[5];
54
+ const headerSampleCount = header[5];
54
55
  const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
55
56
  if (channels <= 0) {
56
- const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
57
- if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
58
- throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
59
- }
60
- channels = inferredChannels;
61
- }
62
- const payloadLength = sampleCount * bytesPerSample * channels;
63
- const expectedLength = TCI_STREAM_HEADER_BYTES + payloadLength;
64
- if (buffer.byteLength !== expectedLength) {
65
- throw new TciError(
66
- "invalid-frame",
67
- `TCI stream frame length mismatch: header says ${sampleCount} samples (${payloadLength} payload bytes), got ${buffer.byteLength - TCI_STREAM_HEADER_BYTES}`
68
- );
57
+ if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
58
+ channels = 1;
59
+ } else {
60
+ const inferredChannels = headerSampleCount > 0 ? actualPayloadLength / headerSampleCount / bytesPerSample : 1;
61
+ if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
62
+ throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
63
+ }
64
+ channels = inferredChannels;
65
+ }
69
66
  }
70
- if (payloadLength % (bytesPerSample * channels) !== 0) {
67
+ const payloadLength = actualPayloadLength;
68
+ const alignedFrameBytes = bytesPerSample * channels;
69
+ if (payloadLength % alignedFrameBytes !== 0) {
71
70
  throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
72
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;
82
+ if (streamType !== 3 /* TX_CHRONO */) {
83
+ const expectedPayloadLength = sampleCount * bytesPerSample;
84
+ if (payloadLength !== expectedPayloadLength) {
85
+ throw new TciError(
86
+ "invalid-frame",
87
+ `TCI stream frame length mismatch: header says ${headerSampleCount} samples using ${lengthSemantics} semantics (${expectedPayloadLength} payload bytes), got ${payloadLength}`
88
+ );
89
+ }
90
+ }
91
+ if (sampleCount % channels !== 0) {
92
+ throw new TciError("invalid-frame", `TCI scalar sample count ${sampleCount} is not divisible by ${channels} channels`);
93
+ }
73
94
  return {
74
95
  receiver: header[0],
75
96
  sampleRate: header[1],
@@ -77,11 +98,14 @@ function parseStreamFrame(input) {
77
98
  codec: header[3],
78
99
  crc: header[4],
79
100
  payloadLength,
80
- streamType: normalizeStreamType(header[6]),
101
+ streamType,
81
102
  channels,
82
103
  reserved: header.slice(8),
83
104
  payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
84
- sampleCount
105
+ headerSampleCount,
106
+ sampleCount,
107
+ frameCount: sampleCount / channels,
108
+ lengthSemantics
85
109
  };
86
110
  }
87
111
  function buildStreamFrame(options) {
@@ -95,7 +119,19 @@ function buildStreamFrame(options) {
95
119
  if (payload.byteLength % (bytesPerSample * channels) !== 0) {
96
120
  throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
97
121
  }
98
- const sampleCount = payload.byteLength / bytesPerSample / channels;
122
+ const derivedSampleCount = payload.byteLength / bytesPerSample;
123
+ const sampleCount = options.sampleCount ?? derivedSampleCount;
124
+ if (!Number.isInteger(sampleCount) || sampleCount < 0) {
125
+ throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
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;
99
135
  const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
100
136
  const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
101
137
  const reserved = options.reserved ?? [];
@@ -105,7 +141,7 @@ function buildStreamFrame(options) {
105
141
  sampleType,
106
142
  options.codec ?? 0,
107
143
  options.crc ?? 0,
108
- sampleCount,
144
+ headerSampleCount,
109
145
  options.streamType,
110
146
  channels,
111
147
  ...Array.from({ length: 8 }, (_, index) => reserved[index] ?? 0)
@@ -114,6 +150,13 @@ function buildStreamFrame(options) {
114
150
  payload.copy(frame, TCI_STREAM_HEADER_BYTES);
115
151
  return frame;
116
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
+ }
117
160
  function buildTxAudioFrame(options) {
118
161
  return buildStreamFrame({ ...options, streamType: 2 /* TX_AUDIO_STREAM */ });
119
162
  }
@@ -545,13 +588,398 @@ function ensureSemicolon(command) {
545
588
  return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
546
589
  }
547
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
+
548
968
  // src/client/TciClient.ts
549
- var TciClient = class extends EventEmitter {
969
+ var TciClient = class extends EventEmitter2 {
550
970
  options;
551
971
  WebSocketImpl;
552
- ws;
972
+ transportFactory;
973
+ transport;
553
974
  queue;
554
975
  state;
976
+ stateReducers;
977
+ dialectRegistry;
978
+ activeDialect;
979
+ handshakeResult;
980
+ handshakeError;
981
+ initializationCommands = [];
982
+ handshakeWaiter;
555
983
  constructor(options) {
556
984
  super();
557
985
  this.options = {
@@ -560,13 +988,17 @@ var TciClient = class extends EventEmitter {
560
988
  trx: options.trx ?? 0,
561
989
  vfo: options.vfo ?? 0,
562
990
  connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
991
+ handshakeTimeoutMs: options.handshakeTimeoutMs ?? 1e4,
563
992
  commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
564
993
  writeAckMode: options.writeAckMode ?? "state",
565
994
  writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
566
995
  writeSettleMs: options.writeSettleMs ?? 0,
567
- frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
996
+ frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250,
997
+ dialect: options.dialect ?? "auto"
568
998
  };
569
- 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));
570
1002
  this.queue = new TciCommandQueue({
571
1003
  timeoutMs: this.options.commandTimeoutMs,
572
1004
  send: (raw) => this.sendRaw(raw)
@@ -582,58 +1014,49 @@ var TciClient = class extends EventEmitter {
582
1014
  pttSource: {},
583
1015
  tune: {},
584
1016
  drive: {},
1017
+ tuneDrive: {},
585
1018
  split: {},
1019
+ dialectWarnings: [],
586
1020
  rxSensors: {},
587
1021
  txSensors: {}
588
1022
  };
1023
+ this.stateReducers = this.createStateReducers();
589
1024
  }
590
1025
  async connect() {
591
- if (this.ws?.readyState === WebSocket.OPEN) {
592
- return;
1026
+ if (this.transport?.isConnected()) {
1027
+ return this.handshakeResult ?? this.waitForHandshake();
593
1028
  }
594
- if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
595
- await this.waitForOpen(this.ws);
596
- 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;
597
1043
  }
598
- const ws = new this.WebSocketImpl(this.options.url);
599
- this.ws = ws;
600
- await this.waitForOpen(ws);
601
1044
  }
602
1045
  async disconnect(code = 1e3, reason = "client disconnect") {
603
- const ws = this.ws;
604
- if (!ws) {
605
- return;
606
- }
607
- if (ws.readyState === WebSocket.CLOSED) {
608
- this.handleClose();
609
- return;
610
- }
611
- await new Promise((resolve) => {
612
- const cleanup = () => {
613
- ws.off("close", onClose);
614
- ws.off("error", onError);
615
- };
616
- const onClose = () => {
617
- cleanup();
618
- resolve();
619
- };
620
- const onError = () => {
621
- cleanup();
622
- resolve();
623
- };
624
- ws.once("close", onClose);
625
- ws.once("error", onError);
626
- ws.close(code, reason);
627
- setTimeout(() => resolve(), 1e3).unref?.();
628
- });
1046
+ const transport = this.transport;
1047
+ if (!transport) return;
1048
+ await transport.disconnect(code, reason);
629
1049
  this.handleClose();
630
1050
  }
631
1051
  isConnected() {
632
- return this.ws?.readyState === WebSocket.OPEN;
1052
+ return this.transport?.isConnected() ?? false;
633
1053
  }
634
1054
  getState() {
635
1055
  return cloneState(this.state);
636
1056
  }
1057
+ getHandshakeResult() {
1058
+ return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
1059
+ }
637
1060
  async sendCommand(name, args = [], options = {}) {
638
1061
  const raw = formatTciCommand(name, args);
639
1062
  if (options.waitForReply === false) {
@@ -707,6 +1130,9 @@ var TciClient = class extends EventEmitter {
707
1130
  }
708
1131
  async setPtt(enabled, options = {}) {
709
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
+ }
710
1136
  const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
711
1137
  await this.sendStateWrite(
712
1138
  "TRX",
@@ -720,16 +1146,107 @@ var TciClient = class extends EventEmitter {
720
1146
  const reply = await this.request("TRX", [trx]);
721
1147
  return parseBoolean(reply.args[1]) ?? this.state.ptt[String(trx)];
722
1148
  }
723
- async setTune(enabled, trx = this.options.trx) {
724
- 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
+ }
725
1163
  }
726
1164
  async setDrive(value, trx = this.options.trx) {
727
- await this.sendCommand("DRIVE", [trx, value]);
1165
+ await this.setDriveWithResult(value, trx);
1166
+ }
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");
728
1227
  }
729
- async setSplit(enabled, trx = this.options.trx) {
730
- await this.sendCommand("SPLIT_ENABLE", [trx, enabled]);
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
+ }
731
1247
  }
732
1248
  async configureAudio(config) {
1249
+ const dialect = this.requireDialect();
733
1250
  const audio = {
734
1251
  sampleRate: config.sampleRate,
735
1252
  sampleType: normalizeSampleType(config.sampleType ?? 3 /* FLOAT32 */),
@@ -740,11 +1257,13 @@ var TciClient = class extends EventEmitter {
740
1257
  };
741
1258
  this.state.audio = audio;
742
1259
  await this.sendCommand("AUDIO_SAMPLERATE", [audio.sampleRate], { waitForReply: false });
743
- await this.sendCommand("AUDIO_STREAM_SAMPLE_TYPE", [sampleTypeName(audio.sampleType)], { waitForReply: false });
744
- await this.sendCommand("AUDIO_STREAM_CHANNELS", [audio.channels], { waitForReply: false });
745
- await this.sendCommand("AUDIO_STREAM_SAMPLES", [audio.samplesPerFrame], { waitForReply: false });
746
- if (audio.txBufferingMs !== void 0) {
747
- 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
+ }
748
1267
  }
749
1268
  this.emitState();
750
1269
  }
@@ -763,9 +1282,27 @@ var TciClient = class extends EventEmitter {
763
1282
  }
764
1283
  }
765
1284
  sendTxAudio(options) {
766
- 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
+ });
767
1290
  this.sendRawBinary(frame);
768
1291
  }
1292
+ sendTxAudioForChrono(request, samples) {
1293
+ const channels = Math.max(1, Math.floor(request.channels || 1));
1294
+ const targetSampleLength = Math.max(0, Math.floor(request.sampleCount));
1295
+ const output = new Float32Array(targetSampleLength);
1296
+ const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
1297
+ output.set(source.subarray(0, output.length));
1298
+ this.sendTxAudio({
1299
+ receiver: request.receiver,
1300
+ sampleRate: request.sampleRate,
1301
+ sampleType: request.sampleType,
1302
+ channels,
1303
+ samples: output
1304
+ });
1305
+ }
769
1306
  async setRxSensorsEnabled(enabled, intervalMs) {
770
1307
  const args = intervalMs === void 0 ? [enabled] : [enabled, intervalMs];
771
1308
  await this.sendCommand("RX_SENSORS_ENABLE", args, { waitForReply: false });
@@ -852,78 +1389,147 @@ var TciClient = class extends EventEmitter {
852
1389
  cancel: cleanup
853
1390
  };
854
1391
  }
855
- 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
+ }
856
1458
  return new Promise((resolve, reject) => {
857
1459
  const timer = setTimeout(() => {
858
- cleanup();
859
- try {
860
- ws.terminate();
861
- } catch {
862
- }
863
- reject(new TciError("connect-timeout", `Timed out connecting to ${this.options.url}`));
864
- }, this.options.connectTimeoutMs);
865
- const cleanup = () => {
866
- clearTimeout(timer);
867
- ws.off("open", onOpen);
868
- ws.off("close", onClose);
869
- ws.off("error", onError);
870
- };
871
- const onOpen = () => {
872
- cleanup();
873
- this.attachSocket(ws);
874
- this.state.connected = true;
875
- this.queue.setConnected(true);
876
- this.emit("connected");
877
- this.emitState();
878
- resolve();
879
- };
880
- const onClose = () => {
881
- cleanup();
882
- this.handleClose();
883
- reject(new TciError("disconnected", `Disconnected while connecting to ${this.options.url}`));
884
- };
885
- const onError = (error) => {
886
- cleanup();
887
- this.handleError(error);
888
- reject(toTciError(error, "disconnected"));
889
- };
890
- ws.once("open", onOpen);
891
- ws.once("close", onClose);
892
- 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 };
893
1465
  });
894
1466
  }
895
- attachSocket(ws) {
896
- ws.on("message", (data, isBinary) => this.handleMessage(data, isBinary));
897
- ws.on("close", () => this.handleClose());
898
- 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));
899
1511
  }
900
1512
  async sendRaw(raw) {
901
- const ws = this.ws;
902
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1513
+ const transport = this.transport;
1514
+ if (!transport?.isConnected()) {
903
1515
  throw new TciError("not-connected", "TCI socket is not connected");
904
1516
  }
905
1517
  this.emit("tci:tx", raw);
906
- await new Promise((resolve, reject) => {
907
- ws.send(raw, (error) => error ? reject(error) : resolve());
908
- });
1518
+ await transport.sendText(raw);
909
1519
  }
910
1520
  sendRawBinary(raw) {
911
- const ws = this.ws;
912
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1521
+ const transport = this.transport;
1522
+ if (!transport?.isConnected()) {
913
1523
  throw new TciError("not-connected", "TCI socket is not connected");
914
1524
  }
915
- ws.send(raw, { binary: true });
1525
+ void transport.sendBinary(raw).catch((error) => this.handleError(error));
916
1526
  }
917
- handleMessage(data, isBinary) {
1527
+ handleText(raw) {
918
1528
  try {
919
- if (isBinary) {
920
- this.handleBinary(data);
921
- return;
922
- }
923
- const raw = dataToBuffer(data).toString("utf8");
924
1529
  const commands = parseTciText(raw);
925
1530
  this.emit("tci:rx", raw, commands);
926
1531
  for (const command of commands) {
1532
+ if (!this.handshakeResult) this.initializationCommands.push(command);
927
1533
  this.queue.handleCommand(command);
928
1534
  this.applyCommand(command);
929
1535
  this.emit("command", command);
@@ -933,7 +1539,10 @@ var TciClient = class extends EventEmitter {
933
1539
  }
934
1540
  }
935
1541
  handleBinary(data) {
936
- const frame = parseStreamFrame(dataToBuffer(data));
1542
+ const frame = parseStreamFrame(data, {
1543
+ lengthSemantics: this.requireDialect().streamLengthSemantics,
1544
+ negotiatedChannels: this.state.audio?.channels
1545
+ });
937
1546
  this.emit("tci:binary", frame);
938
1547
  this.emit("binary", frame);
939
1548
  switch (frame.streamType) {
@@ -947,7 +1556,8 @@ var TciClient = class extends EventEmitter {
947
1556
  sampleRate: frame.sampleRate,
948
1557
  channels: frame.channels,
949
1558
  sampleType: frame.sampleType,
950
- sampleCount: frame.sampleCount
1559
+ sampleCount: frame.sampleCount,
1560
+ frameCount: frame.frameCount
951
1561
  });
952
1562
  break;
953
1563
  case 4 /* LINEOUT_STREAM */:
@@ -959,80 +1569,78 @@ var TciClient = class extends EventEmitter {
959
1569
  }
960
1570
  applyCommand(command) {
961
1571
  const readyBefore = this.state.ready;
962
- switch (command.name) {
963
- case "ready":
964
- this.state.ready = command.args.length === 0 ? true : parseBoolean(command.args[0]) ?? true;
965
- break;
966
- case "protocol":
967
- this.state.protocol = command.args[0];
968
- break;
969
- case "device":
970
- this.state.device = command.args.join(",");
971
- break;
972
- case "receive_only":
973
- this.state.receiveOnly = parseBoolean(command.args[0]);
974
- break;
975
- case "trx_count":
976
- this.state.trxCount = parseNumber(command.args[0]);
977
- break;
978
- case "channels_count":
979
- case "channel_count":
980
- this.state.channelCount = parseNumber(command.args[0]);
981
- break;
982
- case "vfo_limits":
983
- this.state.vfoLimits = parseNumberPair(command.args);
984
- break;
985
- case "if_limits":
986
- this.state.ifLimits = parseNumberPair(command.args);
987
- break;
988
- case "modulations_list":
989
- this.state.modulations = command.args.map((mode) => mode.toLowerCase());
990
- break;
991
- case "vfo":
992
- this.applyVfo(command.args);
993
- break;
994
- case "modulation":
995
- this.applyModulation(command.args);
996
- break;
997
- case "trx":
998
- this.applyTrx(command.args);
999
- break;
1000
- case "tune":
1001
- this.applyBooleanByFirstArg(this.state.tune, command.args);
1002
- break;
1003
- case "drive":
1004
- this.applyDrive(command.args);
1005
- break;
1006
- case "split_enable":
1007
- this.applyBooleanByFirstArg(this.state.split, command.args);
1008
- break;
1009
- case "rx_channel_sensors":
1010
- this.applyRxChannelSensors(command.args);
1011
- break;
1012
- case "rx_sensors":
1013
- this.applyRxSensors(command.args);
1014
- break;
1015
- case "tx_sensors":
1016
- this.applyTxSensors(command.args);
1017
- break;
1018
- case "audio_samplerate":
1019
- this.state.audio = {
1020
- sampleRate: parseNumber(command.args[0]) ?? this.state.audio?.sampleRate ?? 12e3,
1021
- sampleType: this.state.audio?.sampleType ?? 3 /* FLOAT32 */,
1022
- channels: this.state.audio?.channels ?? 1,
1023
- samplesPerFrame: this.state.audio?.samplesPerFrame ?? 512,
1024
- txBufferingMs: this.state.audio?.txBufferingMs,
1025
- running: this.state.audio?.running ?? false
1026
- };
1027
- break;
1028
- default:
1029
- break;
1030
- }
1572
+ this.stateReducers.get(command.name)?.(command.args);
1031
1573
  if (!readyBefore && this.state.ready) {
1032
- 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
+ }
1033
1584
  }
1034
1585
  this.emitState();
1035
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
+ }
1036
1644
  applyVfo(args) {
1037
1645
  if (args.length < 3) {
1038
1646
  return;
@@ -1079,18 +1687,22 @@ var TciClient = class extends EventEmitter {
1079
1687
  }
1080
1688
  }
1081
1689
  applyDrive(args) {
1082
- if (args.length === 1) {
1083
- const value2 = parseNumber(args[0]);
1084
- if (value2 !== void 0) {
1085
- this.state.drive[String(this.options.trx)] = value2;
1086
- }
1087
- return;
1088
- }
1089
- const trx = args[0] ?? String(this.options.trx);
1090
- const value = parseNumber(args[1]);
1091
- if (value !== void 0) {
1092
- this.state.drive[trx] = value;
1093
- }
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
+ };
1094
1706
  }
1095
1707
  applyRxChannelSensors(args) {
1096
1708
  if (args.length < 3) {
@@ -1126,11 +1738,14 @@ var TciClient = class extends EventEmitter {
1126
1738
  };
1127
1739
  }
1128
1740
  handleClose(reason) {
1129
- this.ws = void 0;
1741
+ const transport = this.transport;
1742
+ this.transport = void 0;
1743
+ transport?.removeAllListeners();
1130
1744
  const wasConnected = this.state.connected;
1131
1745
  this.state.connected = false;
1132
1746
  this.state.ready = false;
1133
1747
  this.queue.setConnected(false);
1748
+ this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
1134
1749
  if (wasConnected) {
1135
1750
  this.emit("disconnected", reason);
1136
1751
  this.emitState();
@@ -1147,18 +1762,6 @@ var TciClient = class extends EventEmitter {
1147
1762
  function createTciClient(options) {
1148
1763
  return new TciClient(options);
1149
1764
  }
1150
- function dataToBuffer(data) {
1151
- if (Buffer.isBuffer(data)) {
1152
- return data;
1153
- }
1154
- if (data instanceof ArrayBuffer) {
1155
- return Buffer.from(data);
1156
- }
1157
- if (Array.isArray(data)) {
1158
- return Buffer.concat(data.map((item) => dataToBuffer(item)));
1159
- }
1160
- throw new TciError("protocol-error", "Unsupported WebSocket data type");
1161
- }
1162
1765
  function rxVfoKey(receiver, vfo) {
1163
1766
  return `${receiver}:${vfo}`;
1164
1767
  }
@@ -1182,6 +1785,27 @@ function parseBoolean(value) {
1182
1785
  }
1183
1786
  return void 0;
1184
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
+ }
1185
1809
  function parseNumberPair(args) {
1186
1810
  const first = parseNumber(args[0]);
1187
1811
  const second = parseNumber(args[1]);
@@ -1197,12 +1821,26 @@ function cloneState(state) {
1197
1821
  pttSource: { ...state.pttSource },
1198
1822
  tune: { ...state.tune },
1199
1823
  drive: { ...state.drive },
1824
+ tuneDrive: { ...state.tuneDrive },
1200
1825
  split: { ...state.split },
1826
+ dialectWarnings: [...state.dialectWarnings],
1201
1827
  rxSensors: cloneNested(state.rxSensors),
1202
1828
  txSensors: cloneNested(state.txSensors),
1203
1829
  audio: state.audio ? { ...state.audio } : void 0
1204
1830
  };
1205
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
+ }
1206
1844
  function cloneNested(value) {
1207
1845
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, { ...item }]));
1208
1846
  }
@@ -1210,30 +1848,44 @@ export {
1210
1848
  TCI_STREAM_HEADER_BYTES,
1211
1849
  TciClient,
1212
1850
  TciCommandQueue,
1851
+ TciDialectRegistry,
1213
1852
  TciError,
1214
1853
  TciSampleType,
1215
1854
  TciStreamType,
1855
+ WebSocketTciTransport,
1856
+ aetherSdrDialect,
1857
+ assertValidTciHandshake,
1216
1858
  buildStreamFrame,
1217
1859
  buildTxAudioFrame,
1860
+ builtInDialects,
1218
1861
  commandKey,
1862
+ compareTciVersion,
1219
1863
  createTciClient,
1864
+ defaultTciDialectRegistry,
1220
1865
  deinterleaveChannels,
1221
1866
  escapeTciText,
1867
+ expertSdr14Dialect,
1868
+ expertSdrLegacyDialect,
1869
+ expertSdrModernDialect,
1222
1870
  float32ToPcm16,
1223
1871
  formatTciCommand,
1872
+ genericObservedDialect,
1224
1873
  isCommandReplyTo,
1225
1874
  mixToMono,
1226
1875
  normalizeCommandName,
1227
1876
  normalizeSampleType,
1228
1877
  normalizeStreamType,
1878
+ parseProtocolIdentity,
1229
1879
  parseStreamFrame,
1230
1880
  parseTciCommand,
1231
1881
  parseTciText,
1882
+ parseTciVersion,
1232
1883
  payloadToFloat32,
1233
1884
  pcm16ToFloat32,
1234
1885
  sampleTypeBytes,
1235
1886
  sampleTypeName,
1236
1887
  samplesToPayload,
1888
+ thetisDialect,
1237
1889
  toTciError,
1238
1890
  unescapeTciText
1239
1891
  };