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