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.
- package/CHANGELOG.md +9 -0
- package/README.md +33 -13
- package/dist/audio/index.cjs +63 -20
- package/dist/audio/index.cjs.map +1 -1
- package/dist/audio/index.d.cts +18 -3
- package/dist/audio/index.d.ts +18 -3
- package/dist/audio/index.js +63 -20
- package/dist/audio/index.js.map +1 -1
- package/dist/dialect/index.cjs +331 -0
- package/dist/dialect/index.cjs.map +1 -0
- package/dist/dialect/index.d.cts +29 -0
- package/dist/dialect/index.d.ts +29 -0
- package/dist/dialect/index.js +292 -0
- package/dist/dialect/index.js.map +1 -0
- package/dist/{index-CK3XdXP3.d.cts → index-lbK6NGY4.d.cts} +1 -1
- package/dist/{index-Dfmrk2MR.d.ts → index-paj1AOJY.d.ts} +1 -1
- package/dist/index.cjs +890 -224
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -12
- package/dist/index.d.ts +54 -12
- package/dist/index.js +876 -224
- package/dist/index.js.map +1 -1
- package/dist/protocol/index.cjs.map +1 -1
- package/dist/protocol/index.d.cts +1 -1
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/index.js.map +1 -1
- package/dist/testing/index.cjs +77 -25
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +2 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +77 -25
- package/dist/testing/index.js.map +1 -1
- package/dist/transport/index.cjs +175 -0
- package/dist/transport/index.cjs.map +1 -0
- package/dist/transport/index.d.cts +37 -0
- package/dist/transport/index.d.ts +37 -0
- package/dist/transport/index.js +138 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/types-9seY9Th-.d.ts +63 -0
- package/dist/types-xRotf9gW.d.cts +63 -0
- 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
|
|
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
|
-
|
|
51
|
+
const streamType = normalizeStreamType(header[6]);
|
|
52
|
+
let channels = header[7] || options.negotiatedChannels || 0;
|
|
52
53
|
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
53
|
-
const
|
|
54
|
+
const headerSampleCount = header[5];
|
|
54
55
|
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
55
56
|
if (channels <= 0) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
streamType,
|
|
81
102
|
channels,
|
|
82
103
|
reserved: header.slice(8),
|
|
83
104
|
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
969
|
+
var TciClient = class extends EventEmitter2 {
|
|
550
970
|
options;
|
|
551
971
|
WebSocketImpl;
|
|
552
|
-
|
|
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.
|
|
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.
|
|
592
|
-
return;
|
|
1026
|
+
if (this.transport?.isConnected()) {
|
|
1027
|
+
return this.handshakeResult ?? this.waitForHandshake();
|
|
593
1028
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
604
|
-
if (!
|
|
605
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
730
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
902
|
-
if (!
|
|
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
|
|
907
|
-
ws.send(raw, (error) => error ? reject(error) : resolve());
|
|
908
|
-
});
|
|
1518
|
+
await transport.sendText(raw);
|
|
909
1519
|
}
|
|
910
1520
|
sendRawBinary(raw) {
|
|
911
|
-
const
|
|
912
|
-
if (!
|
|
1521
|
+
const transport = this.transport;
|
|
1522
|
+
if (!transport?.isConnected()) {
|
|
913
1523
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
914
1524
|
}
|
|
915
|
-
|
|
1525
|
+
void transport.sendBinary(raw).catch((error) => this.handleError(error));
|
|
916
1526
|
}
|
|
917
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
this.state.
|
|
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
|
-
|
|
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
|
};
|