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.cjs
CHANGED
|
@@ -112,28 +112,37 @@ function parseStreamFrame(input) {
|
|
|
112
112
|
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
113
113
|
const header = Array.from({ length: 16 }, (_, index) => view.getUint32(index * 4, true));
|
|
114
114
|
const sampleType = normalizeSampleType(header[2]);
|
|
115
|
+
const streamType = normalizeStreamType(header[6]);
|
|
115
116
|
let channels = header[7];
|
|
116
117
|
const bytesPerSample = sampleTypeBytes(sampleType);
|
|
117
118
|
const sampleCount = header[5];
|
|
118
119
|
const actualPayloadLength = buffer.byteLength - TCI_STREAM_HEADER_BYTES;
|
|
119
120
|
if (channels <= 0) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if (streamType === 3 /* TX_CHRONO */ && actualPayloadLength === 0) {
|
|
122
|
+
channels = 1;
|
|
123
|
+
} else {
|
|
124
|
+
const inferredChannels = sampleCount > 0 ? actualPayloadLength / sampleCount / bytesPerSample : 1;
|
|
125
|
+
if (!Number.isInteger(inferredChannels) || inferredChannels <= 0) {
|
|
126
|
+
throw new TciError("invalid-frame", `Invalid TCI channel count: ${channels}`);
|
|
127
|
+
}
|
|
128
|
+
channels = inferredChannels;
|
|
123
129
|
}
|
|
124
|
-
channels = inferredChannels;
|
|
125
|
-
}
|
|
126
|
-
const payloadLength = sampleCount * bytesPerSample * channels;
|
|
127
|
-
const expectedLength = TCI_STREAM_HEADER_BYTES + payloadLength;
|
|
128
|
-
if (buffer.byteLength !== expectedLength) {
|
|
129
|
-
throw new TciError(
|
|
130
|
-
"invalid-frame",
|
|
131
|
-
`TCI stream frame length mismatch: header says ${sampleCount} samples (${payloadLength} payload bytes), got ${buffer.byteLength - TCI_STREAM_HEADER_BYTES}`
|
|
132
|
-
);
|
|
133
130
|
}
|
|
134
|
-
|
|
131
|
+
const payloadLength = actualPayloadLength;
|
|
132
|
+
const alignedFrameBytes = bytesPerSample * channels;
|
|
133
|
+
if (payloadLength % alignedFrameBytes !== 0) {
|
|
135
134
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
136
135
|
}
|
|
136
|
+
if (streamType !== 3 /* TX_CHRONO */) {
|
|
137
|
+
const expectedPerChannelPayloadLength = sampleCount * bytesPerSample * channels;
|
|
138
|
+
const expectedScalarPayloadLength = sampleCount * bytesPerSample;
|
|
139
|
+
if (payloadLength !== expectedPerChannelPayloadLength && payloadLength !== expectedScalarPayloadLength) {
|
|
140
|
+
throw new TciError(
|
|
141
|
+
"invalid-frame",
|
|
142
|
+
`TCI stream frame length mismatch: header says ${sampleCount} samples (${expectedPerChannelPayloadLength} payload bytes), got ${payloadLength}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
137
146
|
return {
|
|
138
147
|
receiver: header[0],
|
|
139
148
|
sampleRate: header[1],
|
|
@@ -141,7 +150,7 @@ function parseStreamFrame(input) {
|
|
|
141
150
|
codec: header[3],
|
|
142
151
|
crc: header[4],
|
|
143
152
|
payloadLength,
|
|
144
|
-
streamType
|
|
153
|
+
streamType,
|
|
145
154
|
channels,
|
|
146
155
|
reserved: header.slice(8),
|
|
147
156
|
payload: buffer.subarray(TCI_STREAM_HEADER_BYTES),
|
|
@@ -159,7 +168,11 @@ function buildStreamFrame(options) {
|
|
|
159
168
|
if (payload.byteLength % (bytesPerSample * channels) !== 0) {
|
|
160
169
|
throw new TciError("invalid-frame", "TCI payload length is not aligned to sample type and channel count");
|
|
161
170
|
}
|
|
162
|
-
const
|
|
171
|
+
const derivedSampleCount = payload.byteLength / bytesPerSample / channels;
|
|
172
|
+
const sampleCount = options.sampleCount ?? derivedSampleCount;
|
|
173
|
+
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
|
174
|
+
throw new TciError("invalid-frame", `Invalid TCI sample count: ${sampleCount}`);
|
|
175
|
+
}
|
|
163
176
|
const frame = Buffer.alloc(TCI_STREAM_HEADER_BYTES + payload.byteLength);
|
|
164
177
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
165
178
|
const reserved = options.reserved ?? [];
|
|
@@ -624,7 +637,11 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
624
637
|
trx: options.trx ?? 0,
|
|
625
638
|
vfo: options.vfo ?? 0,
|
|
626
639
|
connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
|
|
627
|
-
commandTimeoutMs: options.commandTimeoutMs ?? 1e3
|
|
640
|
+
commandTimeoutMs: options.commandTimeoutMs ?? 1e3,
|
|
641
|
+
writeAckMode: options.writeAckMode ?? "state",
|
|
642
|
+
writeTimeoutMs: options.writeTimeoutMs ?? 3e3,
|
|
643
|
+
writeSettleMs: options.writeSettleMs ?? 0,
|
|
644
|
+
frequencyWriteSettleMs: options.frequencyWriteSettleMs ?? 250
|
|
628
645
|
};
|
|
629
646
|
this.WebSocketImpl = options.WebSocketImpl ?? import_ws.default;
|
|
630
647
|
this.queue = new TciCommandQueue({
|
|
@@ -710,15 +727,55 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
710
727
|
}
|
|
711
728
|
return reply;
|
|
712
729
|
}
|
|
713
|
-
async
|
|
714
|
-
|
|
730
|
+
async sendStateWrite(name, args, isApplied, description = formatTciCommand(name, args).replace(/;$/, ""), options = {}) {
|
|
731
|
+
const ackMode = options.ackMode ?? this.options.writeAckMode;
|
|
732
|
+
if (ackMode === "reply") {
|
|
733
|
+
await this.sendCommand(name, args, { timeoutMs: options.timeoutMs });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (isApplied(this.getState())) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (ackMode === "optimistic") {
|
|
740
|
+
await this.sendCommand(name, args, { waitForReply: false });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const timeoutMs = options.timeoutMs ?? this.options.writeTimeoutMs;
|
|
744
|
+
const settleMs = options.settleMs ?? this.options.writeSettleMs;
|
|
745
|
+
const waiter = this.waitForState(isApplied, timeoutMs, settleMs, description);
|
|
746
|
+
try {
|
|
747
|
+
await this.sendCommand(name, args, { waitForReply: false });
|
|
748
|
+
await waiter.promise;
|
|
749
|
+
} catch (error) {
|
|
750
|
+
waiter.cancel();
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async setFrequency(frequencyHz, receiver = this.options.receiver, vfo = this.options.vfo, options = {}) {
|
|
755
|
+
const frequency = Math.round(frequencyHz);
|
|
756
|
+
const key = rxVfoKey(receiver, vfo);
|
|
757
|
+
await this.sendStateWrite(
|
|
758
|
+
"VFO",
|
|
759
|
+
[receiver, vfo, frequency],
|
|
760
|
+
(state) => state.frequencies[key] === frequency,
|
|
761
|
+
`VFO:${receiver},${vfo},${frequency}`,
|
|
762
|
+
{ settleMs: this.options.frequencyWriteSettleMs, ...options }
|
|
763
|
+
);
|
|
715
764
|
}
|
|
716
765
|
async getFrequency(receiver = this.options.receiver, vfo = this.options.vfo) {
|
|
717
766
|
const reply = await this.request("VFO", [receiver, vfo]);
|
|
718
767
|
return parseNumber(reply.args[2]) ?? this.state.frequencies[rxVfoKey(receiver, vfo)];
|
|
719
768
|
}
|
|
720
|
-
async setMode(mode, receiver = this.options.receiver) {
|
|
721
|
-
|
|
769
|
+
async setMode(mode, receiver = this.options.receiver, options = {}) {
|
|
770
|
+
const normalizedMode = mode.toUpperCase();
|
|
771
|
+
const key = rxVfoKey(receiver, this.options.vfo);
|
|
772
|
+
await this.sendStateWrite(
|
|
773
|
+
"MODULATION",
|
|
774
|
+
[receiver, normalizedMode],
|
|
775
|
+
(state) => state.modes[key]?.toLowerCase() === normalizedMode.toLowerCase(),
|
|
776
|
+
`MODULATION:${receiver},${normalizedMode}`,
|
|
777
|
+
options
|
|
778
|
+
);
|
|
722
779
|
}
|
|
723
780
|
async getMode(receiver = this.options.receiver) {
|
|
724
781
|
const reply = await this.request("MODULATION", [receiver]);
|
|
@@ -728,7 +785,13 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
728
785
|
async setPtt(enabled, options = {}) {
|
|
729
786
|
const trx = options.trx ?? this.options.trx;
|
|
730
787
|
const args = options.source ? [trx, enabled, options.source] : [trx, enabled];
|
|
731
|
-
await this.
|
|
788
|
+
await this.sendStateWrite(
|
|
789
|
+
"TRX",
|
|
790
|
+
args,
|
|
791
|
+
(state) => state.ptt[String(trx)] === enabled,
|
|
792
|
+
`TRX:${trx},${enabled}`,
|
|
793
|
+
options
|
|
794
|
+
);
|
|
732
795
|
}
|
|
733
796
|
async getPtt(trx = this.options.trx) {
|
|
734
797
|
const reply = await this.request("TRX", [trx]);
|
|
@@ -780,6 +843,20 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
780
843
|
const frame = buildTxAudioFrame({ receiver: this.options.receiver, ...options });
|
|
781
844
|
this.sendRawBinary(frame);
|
|
782
845
|
}
|
|
846
|
+
sendTxAudioForChrono(request, samples) {
|
|
847
|
+
const channels = Math.max(1, Math.floor(request.channels || 1));
|
|
848
|
+
const targetSampleLength = Math.max(0, Math.floor(request.sampleCount) * channels);
|
|
849
|
+
const output = new Float32Array(targetSampleLength);
|
|
850
|
+
const source = samples instanceof Float32Array ? samples : Float32Array.from(samples);
|
|
851
|
+
output.set(source.subarray(0, output.length));
|
|
852
|
+
this.sendTxAudio({
|
|
853
|
+
receiver: request.receiver,
|
|
854
|
+
sampleRate: request.sampleRate,
|
|
855
|
+
sampleType: request.sampleType,
|
|
856
|
+
channels,
|
|
857
|
+
samples: output
|
|
858
|
+
});
|
|
859
|
+
}
|
|
783
860
|
async setRxSensorsEnabled(enabled, intervalMs) {
|
|
784
861
|
const args = intervalMs === void 0 ? [enabled] : [enabled, intervalMs];
|
|
785
862
|
await this.sendCommand("RX_SENSORS_ENABLE", args, { waitForReply: false });
|
|
@@ -797,6 +874,75 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
797
874
|
async stopCw() {
|
|
798
875
|
await this.sendCommand("CW_MACROS_STOP");
|
|
799
876
|
}
|
|
877
|
+
waitForState(predicate, timeoutMs, settleMs, description) {
|
|
878
|
+
let timeout;
|
|
879
|
+
let settleTimeout;
|
|
880
|
+
let resolved = false;
|
|
881
|
+
let resolvePromise;
|
|
882
|
+
let rejectPromise;
|
|
883
|
+
const cleanup = () => {
|
|
884
|
+
if (timeout) {
|
|
885
|
+
clearTimeout(timeout);
|
|
886
|
+
timeout = void 0;
|
|
887
|
+
}
|
|
888
|
+
if (settleTimeout) {
|
|
889
|
+
clearTimeout(settleTimeout);
|
|
890
|
+
settleTimeout = void 0;
|
|
891
|
+
}
|
|
892
|
+
this.off("state", onState);
|
|
893
|
+
this.off("disconnected", onDisconnected);
|
|
894
|
+
};
|
|
895
|
+
const resolveNow = () => {
|
|
896
|
+
resolved = true;
|
|
897
|
+
cleanup();
|
|
898
|
+
resolvePromise();
|
|
899
|
+
};
|
|
900
|
+
const check = () => {
|
|
901
|
+
if (!predicate(this.getState())) {
|
|
902
|
+
if (settleTimeout) {
|
|
903
|
+
clearTimeout(settleTimeout);
|
|
904
|
+
settleTimeout = void 0;
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (settleMs <= 0) {
|
|
909
|
+
resolveNow();
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (settleTimeout) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
settleTimeout = setTimeout(() => {
|
|
916
|
+
settleTimeout = void 0;
|
|
917
|
+
if (predicate(this.getState())) {
|
|
918
|
+
resolveNow();
|
|
919
|
+
}
|
|
920
|
+
}, settleMs);
|
|
921
|
+
};
|
|
922
|
+
const onState = () => check();
|
|
923
|
+
const onDisconnected = () => {
|
|
924
|
+
if (resolved) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
cleanup();
|
|
928
|
+
rejectPromise(new TciError("disconnected", `Disconnected while waiting for TCI state ${description}`));
|
|
929
|
+
};
|
|
930
|
+
const promise = new Promise((resolve, reject) => {
|
|
931
|
+
resolvePromise = resolve;
|
|
932
|
+
rejectPromise = reject;
|
|
933
|
+
timeout = setTimeout(() => {
|
|
934
|
+
cleanup();
|
|
935
|
+
reject(new TciError("command-timeout", `Timed out waiting for TCI state ${description}`));
|
|
936
|
+
}, timeoutMs);
|
|
937
|
+
this.on("state", onState);
|
|
938
|
+
this.on("disconnected", onDisconnected);
|
|
939
|
+
check();
|
|
940
|
+
});
|
|
941
|
+
return {
|
|
942
|
+
promise,
|
|
943
|
+
cancel: cleanup
|
|
944
|
+
};
|
|
945
|
+
}
|
|
800
946
|
waitForOpen(ws) {
|
|
801
947
|
return new Promise((resolve, reject) => {
|
|
802
948
|
const timer = setTimeout(() => {
|
|
@@ -847,6 +993,7 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
847
993
|
if (!ws || ws.readyState !== import_ws.default.OPEN) {
|
|
848
994
|
throw new TciError("not-connected", "TCI socket is not connected");
|
|
849
995
|
}
|
|
996
|
+
this.emit("tci:tx", raw);
|
|
850
997
|
await new Promise((resolve, reject) => {
|
|
851
998
|
ws.send(raw, (error) => error ? reject(error) : resolve());
|
|
852
999
|
});
|
|
@@ -864,7 +1011,9 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
864
1011
|
this.handleBinary(data);
|
|
865
1012
|
return;
|
|
866
1013
|
}
|
|
867
|
-
const
|
|
1014
|
+
const raw = dataToBuffer(data).toString("utf8");
|
|
1015
|
+
const commands = parseTciText(raw);
|
|
1016
|
+
this.emit("tci:rx", raw, commands);
|
|
868
1017
|
for (const command of commands) {
|
|
869
1018
|
this.queue.handleCommand(command);
|
|
870
1019
|
this.applyCommand(command);
|
|
@@ -876,6 +1025,7 @@ var TciClient = class extends import_eventemitter3.EventEmitter {
|
|
|
876
1025
|
}
|
|
877
1026
|
handleBinary(data) {
|
|
878
1027
|
const frame = parseStreamFrame(dataToBuffer(data));
|
|
1028
|
+
this.emit("tci:binary", frame);
|
|
879
1029
|
this.emit("binary", frame);
|
|
880
1030
|
switch (frame.streamType) {
|
|
881
1031
|
case 1 /* RX_AUDIO_STREAM */:
|