tci-client-node 0.1.0 → 0.1.2
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/README.md +3 -9
- package/dist/audio/index.cjs +28 -15
- package/dist/audio/index.cjs.map +1 -1
- package/dist/audio/index.d.cts +2 -0
- package/dist/audio/index.d.ts +2 -0
- package/dist/audio/index.js +28 -15
- package/dist/audio/index.js.map +1 -1
- package/dist/index.cjs +172 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -4
- package/dist/index.d.ts +23 -4
- package/dist/index.js +172 -22
- package/dist/index.js.map +1 -1
- package/dist/testing/index.cjs +30 -17
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.js +30 -17
- package/dist/testing/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -48,28 +48,37 @@ 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
|
+
const streamType = normalizeStreamType(header[6]);
|
|
51
52
|
let channels = header[7];
|
|
52
53
|
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
53
54
|
const sampleCount = header[5];
|
|
54
55
|
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
55
56
|
if (channels <= 0) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
|
|
58
|
+
channels = 1;
|
|
59
|
+
} else {
|
|
60
|
+
const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
|
|
61
|
+
if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
|
|
62
|
+
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
63
|
+
}
|
|
64
|
+
channels = inferredChannels;
|
|
59
65
|
}
|
|
60
|
-
channels = inferredChannels;
|
|
61
|
-
}
|
|
62
|
-
const payloadLength = sampleCount * bytesPerSample * channels;
|
|
63
|
-
const expectedLength = TCI_STREAM_HEADER_BYTES + payloadLength;
|
|
64
|
-
if (buffer.byteLength !== expectedLength) {
|
|
65
|
-
throw new TciError(
|
|
66
|
-
"invalid-frame",
|
|
67
|
-
`TCI stream frame length mismatch: header says ${sampleCount} samples (${payloadLength} payload bytes), got ${buffer.byteLength - TCI_STREAM_HEADER_BYTES}`
|
|
68
|
-
);
|
|
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
|
+
if (streamType !== 3 /* TX_CHRONO */) {
|
|
73
|
+
const expectedPerChannelPayloadLength = sampleCount * bytesPerSample * channels;
|
|
74
|
+
const expectedScalarPayloadLength = sampleCount * bytesPerSample;
|
|
75
|
+
if (payloadLength !== expectedPerChannelPayloadLength && payloadLength !== expectedScalarPayloadLength) {
|
|
76
|
+
throw new TciError(
|
|
77
|
+
"invalid-frame",
|
|
78
|
+
`TCI stream frame length mismatch: header says ${sampleCount} samples (${expectedPerChannelPayloadLength} payload bytes), got ${payloadLength}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
73
82
|
return {
|
|
74
83
|
receiver: header[0],
|
|
75
84
|
sampleRate: header[1],
|
|
@@ -77,7 +86,7 @@ function parseStreamFrame(input) {
|
|
|
77
86
|
codec: header[3],
|
|
78
87
|
crc: header[4],
|
|
79
88
|
payloadLength,
|
|
80
|
-
streamType
|
|
89
|
+
streamType,
|
|
81
90
|
channels,
|
|
82
91
|
reserved: header.slice(8),
|
|
83
92
|
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
@@ -95,7 +104,11 @@ function buildStreamFrame(options) {
|
|
|
95
104
|
if (payload.byteLength % (bytesPerSample * channels) !== 0) {
|
|
96
105
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
97
106
|
}
|
|
98
|
-
const
|
|
107
|
+
const derivedSampleCount = payload.byteLength / bytesPerSample / channels;
|
|
108
|
+
const sampleCount = options.sampleCount ?? derivedSampleCount;
|
|
109
|
+
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
|
110
|
+
throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
|
|
111
|
+
}
|
|
99
112
|
const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
|
|
100
113
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
101
114
|
const reserved = options.reserved ?? [];
|
|
@@ -560,7 +573,11 @@ var TciClient = class extends EventEmitter {
|
|
|
560
573
|
trx: options.trx ?? 0,
|
|
561
574
|
vfo: options.vfo ?? 0,
|
|
562
575
|
connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
|
|
563
|
-
commandTimeoutMs: options.commandTimeoutMs ?? 1e3
|
|
576
|
+
commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
|
|
577
|
+
writeAckMode: options.writeAckMode ?? "state",
|
|
578
|
+
writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
|
|
579
|
+
writeSettleMs: options.writeSettleMs ?? 0,
|
|
580
|
+
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
|
|
564
581
|
};
|
|
565
582
|
this.WebSocketImpl = options.WebSocketImpl ?? WebSocket;
|
|
566
583
|
this.queue = new TciCommandQueue({
|
|
@@ -646,15 +663,55 @@ var TciClient = class extends EventEmitter {
|
|
|
646
663
|
}
|
|
647
664
|
return reply;
|
|
648
665
|
}
|
|
649
|
-
async
|
|
650
|
-
|
|
666
|
+
async sendStateWrite(name, args, isApplied, description = formatTciCommand(name, args).replace(/;$/, ""), options = {}) {
|
|
667
|
+
const ackMode = options.ackMode ?? this.options.writeAckMode;
|
|
668
|
+
if (ackMode === "reply") {
|
|
669
|
+
await this.sendCommand(name, args, { timeoutMs: options.timeoutMs });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (isApplied(this.getState())) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (ackMode === "optimistic") {
|
|
676
|
+
await this.sendCommand(name, args, { waitForReply: false });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
|
|
680
|
+
const settleMs = options.settleMs ?? this.options.writeSettleMs;
|
|
681
|
+
const waiter = this.waitForState(isApplied, timeoutMs, settleMs, description);
|
|
682
|
+
try {
|
|
683
|
+
await this.sendCommand(name, args, { waitForReply: false });
|
|
684
|
+
await waiter.promise;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
waiter.cancel();
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async setFrequency(frequencyHz, receiver = this.options.receiver, vfo = this.options.vfo, options = {}) {
|
|
691
|
+
const frequency = Math.round(frequencyHz);
|
|
692
|
+
const key = rxVfoKey(receiver, vfo);
|
|
693
|
+
await this.sendStateWrite(
|
|
694
|
+
"VFO",
|
|
695
|
+
[receiver, vfo, frequency],
|
|
696
|
+
(state) => state.frequencies[key] === frequency,
|
|
697
|
+
`VFO:${receiver},${vfo},${frequency}`,
|
|
698
|
+
{ settleMs: this.options.frequencyWriteSettleMs, ...options }
|
|
699
|
+
);
|
|
651
700
|
}
|
|
652
701
|
async getFrequency(receiver = this.options.receiver, vfo = this.options.vfo) {
|
|
653
702
|
const reply = await this.request("VFO", [receiver, vfo]);
|
|
654
703
|
return parseNumber(reply.args[2]) ?? this.state.frequencies[rxVfoKey(receiver, vfo)];
|
|
655
704
|
}
|
|
656
|
-
async setMode(mode, receiver = this.options.receiver) {
|
|
657
|
-
|
|
705
|
+
async setMode(mode, receiver = this.options.receiver, options = {}) {
|
|
706
|
+
const normalizedMode = mode.toUpperCase();
|
|
707
|
+
const key = rxVfoKey(receiver, this.options.vfo);
|
|
708
|
+
await this.sendStateWrite(
|
|
709
|
+
"MODULATION",
|
|
710
|
+
[receiver, normalizedMode],
|
|
711
|
+
(state) => state.modes[key]?.toLowerCase() === normalizedMode.toLowerCase(),
|
|
712
|
+
`MODULATION:${receiver},${normalizedMode}`,
|
|
713
|
+
options
|
|
714
|
+
);
|
|
658
715
|
}
|
|
659
716
|
async getMode(receiver = this.options.receiver) {
|
|
660
717
|
const reply = await this.request("MODULATION", [receiver]);
|
|
@@ -664,7 +721,13 @@ var TciClient = class extends EventEmitter {
|
|
|
664
721
|
async setPtt(enabled, options = {}) {
|
|
665
722
|
const trx = options.trx ?? this.options.trx;
|
|
666
723
|
const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
|
|
667
|
-
await this.
|
|
724
|
+
await this.sendStateWrite(
|
|
725
|
+
"TRX",
|
|
726
|
+
args,
|
|
727
|
+
(state) => state.ptt[String(trx)] === enabled,
|
|
728
|
+
`TRX:${trx},${enabled}`,
|
|
729
|
+
options
|
|
730
|
+
);
|
|
668
731
|
}
|
|
669
732
|
async getPtt(trx = this.options.trx) {
|
|
670
733
|
const reply = await this.request("TRX", [trx]);
|
|
@@ -716,6 +779,20 @@ var TciClient = class extends EventEmitter {
|
|
|
716
779
|
const frame = buildTxAudioFrame({ receiver: this.options.receiver, ...options });
|
|
717
780
|
this.sendRawBinary(frame);
|
|
718
781
|
}
|
|
782
|
+
sendTxAudioForChrono(request, samples) {
|
|
783
|
+
const channels = Math.max(1, Math.floor(request.channels || 1));
|
|
784
|
+
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount) * channels);
|
|
785
|
+
const output = new Float32Array(targetSampleLength);
|
|
786
|
+
const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
|
|
787
|
+
output.set(source.subarray(0, output.length));
|
|
788
|
+
this.sendTxAudio({
|
|
789
|
+
receiver: request.receiver,
|
|
790
|
+
sampleRate: request.sampleRate,
|
|
791
|
+
sampleType: request.sampleType,
|
|
792
|
+
channels,
|
|
793
|
+
samples: output
|
|
794
|
+
});
|
|
795
|
+
}
|
|
719
796
|
async setRxSensorsEnabled(enabled, intervalMs) {
|
|
720
797
|
const args = intervalMs === void 0 ? [enabled] : [enabled, intervalMs];
|
|
721
798
|
await this.sendCommand("RX_SENSORS_ENABLE", args, { waitForReply: false });
|
|
@@ -733,6 +810,75 @@ var TciClient = class extends EventEmitter {
|
|
|
733
810
|
async stopCw() {
|
|
734
811
|
await this.sendCommand("CW_MACROS_STOP");
|
|
735
812
|
}
|
|
813
|
+
waitForState(predicate, timeoutMs, settleMs, description) {
|
|
814
|
+
let timeout;
|
|
815
|
+
let settleTimeout;
|
|
816
|
+
let resolved = false;
|
|
817
|
+
let resolvePromise;
|
|
818
|
+
let rejectPromise;
|
|
819
|
+
const cleanup = () => {
|
|
820
|
+
if (timeout) {
|
|
821
|
+
clearTimeout(timeout);
|
|
822
|
+
timeout = void 0;
|
|
823
|
+
}
|
|
824
|
+
if (settleTimeout) {
|
|
825
|
+
clearTimeout(settleTimeout);
|
|
826
|
+
settleTimeout = void 0;
|
|
827
|
+
}
|
|
828
|
+
this.off("state", onState);
|
|
829
|
+
this.off("disconnected", onDisconnected);
|
|
830
|
+
};
|
|
831
|
+
const resolveNow = () => {
|
|
832
|
+
resolved = true;
|
|
833
|
+
cleanup();
|
|
834
|
+
resolvePromise();
|
|
835
|
+
};
|
|
836
|
+
const check = () => {
|
|
837
|
+
if (!predicate(this.getState())) {
|
|
838
|
+
if (settleTimeout) {
|
|
839
|
+
clearTimeout(settleTimeout);
|
|
840
|
+
settleTimeout = void 0;
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (settleMs <= 0) {
|
|
845
|
+
resolveNow();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (settleTimeout) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
settleTimeout = setTimeout(() => {
|
|
852
|
+
settleTimeout = void 0;
|
|
853
|
+
if (predicate(this.getState())) {
|
|
854
|
+
resolveNow();
|
|
855
|
+
}
|
|
856
|
+
}, settleMs);
|
|
857
|
+
};
|
|
858
|
+
const onState = () => check();
|
|
859
|
+
const onDisconnected = () => {
|
|
860
|
+
if (resolved) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
cleanup();
|
|
864
|
+
rejectPromise(new TciError("disconnected", `Disconnected while waiting for TCI state ${description}`));
|
|
865
|
+
};
|
|
866
|
+
const promise = new Promise((resolve, reject) => {
|
|
867
|
+
resolvePromise = resolve;
|
|
868
|
+
rejectPromise = reject;
|
|
869
|
+
timeout = setTimeout(() => {
|
|
870
|
+
cleanup();
|
|
871
|
+
reject(new TciError("command-timeout", `Timed out waiting for TCI state ${description}`));
|
|
872
|
+
}, timeoutMs);
|
|
873
|
+
this.on("state", onState);
|
|
874
|
+
this.on("disconnected", onDisconnected);
|
|
875
|
+
check();
|
|
876
|
+
});
|
|
877
|
+
return {
|
|
878
|
+
promise,
|
|
879
|
+
cancel: cleanup
|
|
880
|
+
};
|
|
881
|
+
}
|
|
736
882
|
waitForOpen(ws) {
|
|
737
883
|
return new Promise((resolve, reject) => {
|
|
738
884
|
const timer = setTimeout(() => {
|
|
@@ -783,6 +929,7 @@ var TciClient = class extends EventEmitter {
|
|
|
783
929
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
784
930
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
785
931
|
}
|
|
932
|
+
this.emit("tci:tx", raw);
|
|
786
933
|
await new Promise((resolve, reject) => {
|
|
787
934
|
ws.send(raw, (error) => error ? reject(error) : resolve());
|
|
788
935
|
});
|
|
@@ -800,7 +947,9 @@ var TciClient = class extends EventEmitter {
|
|
|
800
947
|
this.handleBinary(data);
|
|
801
948
|
return;
|
|
802
949
|
}
|
|
803
|
-
const
|
|
950
|
+
const raw = dataToBuffer(data).toString("utf8");
|
|
951
|
+
const commands = parseTciText(raw);
|
|
952
|
+
this.emit("tci:rx", raw, commands);
|
|
804
953
|
for (const command of commands) {
|
|
805
954
|
this.queue.handleCommand(command);
|
|
806
955
|
this.applyCommand(command);
|
|
@@ -812,6 +961,7 @@ var TciClient = class extends EventEmitter {
|
|
|
812
961
|
}
|
|
813
962
|
handleBinary(data) {
|
|
814
963
|
const frame = parseStreamFrame(dataToBuffer(data));
|
|
964
|
+
this.emit("tci:binary", frame);
|
|
815
965
|
this.emit("binary", frame);
|
|
816
966
|
switch (frame.streamType) {
|
|
817
967
|
case 1 /* RX_AUDIO_STREAM */:
|