tci-client-node 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +50 -6
  3. package/dist/audio/index.cjs +11 -0
  4. package/dist/audio/index.cjs.map +1 -1
  5. package/dist/audio/index.d.cts +4 -2
  6. package/dist/audio/index.d.ts +4 -2
  7. package/dist/audio/index.js +10 -0
  8. package/dist/audio/index.js.map +1 -1
  9. package/dist/dialect/index.cjs +193 -12
  10. package/dist/dialect/index.cjs.map +1 -1
  11. package/dist/dialect/index.d.cts +3 -2
  12. package/dist/dialect/index.d.ts +3 -2
  13. package/dist/dialect/index.js +193 -12
  14. package/dist/dialect/index.js.map +1 -1
  15. package/dist/errors-CT3b8LLw.d.cts +9 -0
  16. package/dist/errors-CT3b8LLw.d.ts +9 -0
  17. package/dist/index.cjs +606 -6
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +68 -6
  20. package/dist/index.d.ts +68 -6
  21. package/dist/index.js +599 -6
  22. package/dist/index.js.map +1 -1
  23. package/dist/meter/index.cjs +348 -0
  24. package/dist/meter/index.cjs.map +1 -0
  25. package/dist/meter/index.d.cts +78 -0
  26. package/dist/meter/index.d.ts +78 -0
  27. package/dist/meter/index.js +317 -0
  28. package/dist/meter/index.js.map +1 -0
  29. package/dist/protocol/index.d.cts +36 -2
  30. package/dist/protocol/index.d.ts +36 -2
  31. package/dist/testing/index.cjs +58 -0
  32. package/dist/testing/index.cjs.map +1 -1
  33. package/dist/testing/index.d.cts +23 -1
  34. package/dist/testing/index.d.ts +23 -1
  35. package/dist/testing/index.js +58 -0
  36. package/dist/testing/index.js.map +1 -1
  37. package/dist/types-C0qVJVSO.d.ts +74 -0
  38. package/dist/types-C4MtLKoy.d.cts +74 -0
  39. package/dist/{types-xRotf9gW.d.cts → types-CYK_NOTz.d.cts} +5 -1
  40. package/dist/{types-9seY9Th-.d.ts → types-DVIPU-VR.d.ts} +5 -1
  41. package/package.json +11 -2
  42. package/dist/index-lbK6NGY4.d.cts +0 -42
  43. package/dist/index-paj1AOJY.d.ts +0 -42
package/dist/index.cjs CHANGED
@@ -30,22 +30,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ StandardTciMeterAdapter: () => StandardTciMeterAdapter,
33
34
  TCI_STREAM_HEADER_BYTES: () => TCI_STREAM_HEADER_BYTES,
34
35
  TciClient: () => TciClient,
35
36
  TciCommandQueue: () => TciCommandQueue,
36
37
  TciDialectRegistry: () => TciDialectRegistry,
37
38
  TciError: () => TciError,
39
+ TciIqStreamSession: () => TciIqStreamSession,
40
+ TciMeterStreamSession: () => TciMeterStreamSession,
38
41
  TciSampleType: () => TciSampleType,
39
42
  TciStreamType: () => TciStreamType,
43
+ UNKNOWN_TCI_METER_CAPABILITIES: () => UNKNOWN_TCI_METER_CAPABILITIES,
40
44
  WebSocketTciTransport: () => WebSocketTciTransport,
41
45
  aetherSdrDialect: () => aetherSdrDialect,
42
46
  assertValidTciHandshake: () => assertValidTciHandshake,
43
47
  buildStreamFrame: () => buildStreamFrame,
44
48
  buildTxAudioFrame: () => buildTxAudioFrame,
45
49
  builtInDialects: () => builtInDialects,
50
+ cloneMeterCapabilities: () => cloneMeterCapabilities,
46
51
  commandKey: () => commandKey,
47
52
  compareTciVersion: () => compareTciVersion,
48
53
  createTciClient: () => createTciClient,
54
+ createUnknownTciMeterAdapter: () => createUnknownTciMeterAdapter,
55
+ decodeInterleavedIq: () => decodeInterleavedIq,
49
56
  defaultTciDialectRegistry: () => defaultTciDialectRegistry,
50
57
  deinterleaveChannels: () => deinterleaveChannels,
51
58
  escapeTciText: () => escapeTciText,
@@ -98,7 +105,7 @@ function toTciError(error, fallbackCode = "protocol-error") {
98
105
  }
99
106
 
100
107
  // src/client/TciClient.ts
101
- var import_eventemitter32 = require("eventemitter3");
108
+ var import_eventemitter33 = require("eventemitter3");
102
109
  var import_ws2 = __toESM(require("ws"), 1);
103
110
 
104
111
  // src/audio/streamFrame.ts
@@ -319,6 +326,15 @@ function payloadToFloat32(frameOrPayload, sampleType) {
319
326
  }
320
327
  return output;
321
328
  }
329
+ function decodeInterleavedIq(frame) {
330
+ if (frame.streamType !== 0 /* IQ_STREAM */) {
331
+ throw new TciError("invalid-frame", "Expected a TCI IQ stream frame");
332
+ }
333
+ if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
334
+ throw new TciError("invalid-frame", `TCI IQ frame must contain interleaved I/Q pairs, got ${frame.channels} channels`);
335
+ }
336
+ return payloadToFloat32(frame);
337
+ }
322
338
  function samplesToPayload(samples, sampleType) {
323
339
  const type = normalizeSampleType(sampleType);
324
340
  const bytes = sampleTypeBytes(type);
@@ -666,6 +682,302 @@ function ensureSemicolon(command) {
666
682
  return command.trim().endsWith(";") ? command.trim() : `${command.trim()};`;
667
683
  }
668
684
 
685
+ // src/meter/types.ts
686
+ var UNKNOWN_TCI_METER_CAPABILITIES = {
687
+ rxLevel: "unknown",
688
+ rxAverageLevel: "unknown",
689
+ rxPeakBin: "unknown",
690
+ txMicLevel: "unknown",
691
+ txRmsPower: "unknown",
692
+ txPeakPower: "unknown",
693
+ txSwr: "unknown",
694
+ txAlcDbfs: "unknown"
695
+ };
696
+ function cloneMeterCapabilities(capabilities) {
697
+ return { ...capabilities };
698
+ }
699
+
700
+ // src/meter/adapters.ts
701
+ var StandardTciMeterAdapter = class {
702
+ declaredCapabilities;
703
+ options;
704
+ constructor(options = {}) {
705
+ this.options = options;
706
+ this.declaredCapabilities = {
707
+ ...UNKNOWN_TCI_METER_CAPABILITIES,
708
+ rxLevel: "declared",
709
+ txMicLevel: "declared",
710
+ txRmsPower: "declared",
711
+ txPeakPower: "declared",
712
+ txSwr: "declared",
713
+ rxAverageLevel: options.supportsRxExtended ? "declared" : "unknown",
714
+ rxPeakBin: options.supportsRxExtended ? "declared" : "unknown",
715
+ txAlcDbfs: options.txAlcUnit === "dbfs" ? "declared" : "unknown",
716
+ ...options.capabilities
717
+ };
718
+ }
719
+ normalizeInterval(intervalMs) {
720
+ const requestedMs = Math.round(intervalMs);
721
+ if (this.options.interval?.fixedMs !== void 0) {
722
+ return { requestedMs, appliedMs: this.options.interval.fixedMs };
723
+ }
724
+ const minMs = this.options.interval?.minMs ?? requestedMs;
725
+ const maxMs = this.options.interval?.maxMs ?? requestedMs;
726
+ const normalized = Math.max(minMs, Math.min(maxMs, requestedMs));
727
+ return {
728
+ requestedMs,
729
+ appliedMs: this.options.interval?.reportsApplied === false ? void 0 : normalized
730
+ };
731
+ }
732
+ buildEnableCommand(kind, enabled, intervalMs) {
733
+ return {
734
+ name: kind === "rx" ? "RX_SENSORS_ENABLE" : "TX_SENSORS_ENABLE",
735
+ args: enabled ? [true, intervalMs] : [false]
736
+ };
737
+ }
738
+ decode(command, receivedAtMs) {
739
+ switch (command.name) {
740
+ case "rx_sensors":
741
+ return decodeRx(command, receivedAtMs, "rx_sensors");
742
+ case "rx_channel_sensors":
743
+ return decodeRx(command, receivedAtMs, "rx_channel_sensors");
744
+ case "rx_channel_sensors_ex":
745
+ return decodeRx(command, receivedAtMs, "rx_channel_sensors_ex");
746
+ case "tx_sensors":
747
+ return decodeTx(command, receivedAtMs, this.options.txAlcUnit);
748
+ default:
749
+ return void 0;
750
+ }
751
+ }
752
+ };
753
+ function createUnknownTciMeterAdapter() {
754
+ return new StandardTciMeterAdapter({
755
+ capabilities: cloneMeterCapabilities(UNKNOWN_TCI_METER_CAPABILITIES),
756
+ interval: { reportsApplied: false }
757
+ });
758
+ }
759
+ function decodeRx(command, receivedAtMs, source) {
760
+ const receiver = integer(command.args[0]);
761
+ const hasChannel = source !== "rx_sensors";
762
+ const channel = hasChannel ? integer(command.args[1]) : 0;
763
+ const levelIndex = hasChannel ? 2 : 1;
764
+ const levelDbm = finite(command.args[levelIndex]);
765
+ if (receiver === void 0 || receiver < 0 || channel === void 0 || channel < 0 || levelDbm === void 0) {
766
+ return { issue: `Invalid ${command.originalName} meter frame: ${command.raw}` };
767
+ }
768
+ const frame = { receiver, channel, levelDbm, source, receivedAtMs };
769
+ if (source === "rx_channel_sensors_ex") {
770
+ const averageLevelDbm = finite(command.args[3]);
771
+ const peakBinDbm = finite(command.args[4]);
772
+ if (averageLevelDbm === void 0 || peakBinDbm === void 0) {
773
+ return { issue: `Invalid ${command.originalName} extended meter frame: ${command.raw}` };
774
+ }
775
+ frame.averageLevelDbm = averageLevelDbm;
776
+ frame.peakBinDbm = peakBinDbm;
777
+ if (command.args.length > 5) frame.extraArgs = command.args.slice(5);
778
+ } else if (command.args.length > levelIndex + 1) {
779
+ frame.extraArgs = command.args.slice(levelIndex + 1);
780
+ }
781
+ return { decoded: { kind: "rx", frame } };
782
+ }
783
+ function decodeTx(command, receivedAtMs, alcUnit) {
784
+ const trx = integer(command.args[0]);
785
+ if (trx === void 0 || trx < 0) {
786
+ return { issue: `Invalid ${command.originalName} transmitter index: ${command.raw}` };
787
+ }
788
+ const micLevelDbm = optionalFinite(command.args[1]);
789
+ const rmsPowerWatts = optionalFinite(command.args[2]);
790
+ const peakPowerWatts = optionalFinite(command.args[3]);
791
+ const swr = optionalFinite(command.args[4]);
792
+ const alcValue = alcUnit ? optionalFinite(command.args[5]) : void 0;
793
+ if (micLevelDbm.invalid || rmsPowerWatts.invalid || peakPowerWatts.invalid || swr.invalid || alcValue?.invalid) {
794
+ return { issue: `Invalid ${command.originalName} numeric meter frame: ${command.raw}` };
795
+ }
796
+ if (rmsPowerWatts.value !== void 0 && rmsPowerWatts.value < 0 || peakPowerWatts.value !== void 0 && peakPowerWatts.value < 0 || swr.value !== void 0 && swr.value < 1) {
797
+ return { issue: `Out-of-range ${command.originalName} meter frame: ${command.raw}` };
798
+ }
799
+ if (micLevelDbm.value === void 0 && rmsPowerWatts.value === void 0 && peakPowerWatts.value === void 0 && swr.value === void 0 && alcValue?.value === void 0) {
800
+ return { issue: `Empty ${command.originalName} meter frame: ${command.raw}` };
801
+ }
802
+ const frame = {
803
+ trx,
804
+ micLevelDbm: micLevelDbm.value,
805
+ rmsPowerWatts: rmsPowerWatts.value,
806
+ peakPowerWatts: peakPowerWatts.value,
807
+ swr: swr.value,
808
+ receivedAtMs
809
+ };
810
+ if (alcUnit && alcValue?.value !== void 0) frame.alc = { value: alcValue.value, unit: alcUnit };
811
+ const knownArgs = alcUnit ? 6 : 5;
812
+ if (command.args.length > knownArgs) frame.extraArgs = command.args.slice(knownArgs);
813
+ return { decoded: { kind: "tx", frame } };
814
+ }
815
+ function finite(value) {
816
+ if (value === void 0 || value === "") return void 0;
817
+ const parsed = Number(value);
818
+ return Number.isFinite(parsed) ? parsed : void 0;
819
+ }
820
+ function integer(value) {
821
+ const parsed = finite(value);
822
+ return parsed !== void 0 && Number.isInteger(parsed) ? parsed : void 0;
823
+ }
824
+ function optionalFinite(value) {
825
+ if (value === void 0 || value === "") return { invalid: false };
826
+ const parsed = Number(value);
827
+ return Number.isFinite(parsed) ? { value: parsed, invalid: false } : { invalid: true };
828
+ }
829
+
830
+ // src/meter/TciMeterStreamSession.ts
831
+ var import_eventemitter3 = require("eventemitter3");
832
+ var RX_COALESCE_MS = 20;
833
+ var TciMeterStreamSession = class extends import_eventemitter3.EventEmitter {
834
+ receiver;
835
+ channel;
836
+ trx;
837
+ requestedIntervalMs;
838
+ appliedIntervalMs;
839
+ rxEnabled;
840
+ txEnabled;
841
+ adapter;
842
+ callbacks;
843
+ capabilities;
844
+ pendingRx = /* @__PURE__ */ new Map();
845
+ closed = false;
846
+ constructor(options) {
847
+ super();
848
+ this.receiver = options.receiver;
849
+ this.channel = options.channel;
850
+ this.trx = options.trx;
851
+ this.requestedIntervalMs = options.requestedIntervalMs;
852
+ this.appliedIntervalMs = options.appliedIntervalMs;
853
+ this.rxEnabled = options.rxEnabled;
854
+ this.txEnabled = options.txEnabled;
855
+ this.adapter = options.adapter;
856
+ this.callbacks = options.callbacks;
857
+ this.capabilities = cloneMeterCapabilities(options.adapter.declaredCapabilities);
858
+ }
859
+ getCapabilities() {
860
+ return cloneMeterCapabilities(this.capabilities);
861
+ }
862
+ async close() {
863
+ if (this.closed) return;
864
+ this.closed = true;
865
+ this.clearPendingRx();
866
+ try {
867
+ await this.callbacks.close();
868
+ } finally {
869
+ this.emit("closed");
870
+ this.removeAllListeners();
871
+ }
872
+ }
873
+ _acceptCommand(command, receivedAtMs) {
874
+ if (this.closed) return;
875
+ this.acceptEnableAcknowledgement(command);
876
+ const result = this.adapter.decode(command, receivedAtMs);
877
+ if (!result) return;
878
+ if (result.issue) {
879
+ this.emit("error", new TciError("protocol-error", result.issue));
880
+ return;
881
+ }
882
+ if (result.decoded?.kind === "rx") this.acceptRxFrame(result.decoded.frame);
883
+ if (result.decoded?.kind === "tx") this.acceptTxFrame(result.decoded.frame);
884
+ }
885
+ _fail(error) {
886
+ if (this.closed) return;
887
+ this.closed = true;
888
+ this.clearPendingRx();
889
+ this.emit("error", error);
890
+ this.emit("closed");
891
+ this.removeAllListeners();
892
+ }
893
+ acceptRxFrame(frame) {
894
+ if (!this.rxEnabled || frame.receiver !== this.receiver || frame.channel !== this.channel) return;
895
+ this.observe("rxLevel");
896
+ if (frame.averageLevelDbm !== void 0) this.observe("rxAverageLevel");
897
+ if (frame.peakBinDbm !== void 0) this.observe("rxPeakBin");
898
+ const key = `${frame.receiver}:${frame.channel}`;
899
+ const pending = this.pendingRx.get(key);
900
+ if (pending) {
901
+ const sameReading = Math.abs(pending.frame.levelDbm - frame.levelDbm) < 0.05;
902
+ if (sameReading && rxPriority(frame) >= rxPriority(pending.frame)) {
903
+ clearTimeout(pending.timer);
904
+ this.pendingRx.delete(key);
905
+ } else if (sameReading) {
906
+ return;
907
+ } else {
908
+ this.flushRx(key, pending);
909
+ }
910
+ }
911
+ const timer = setTimeout(() => {
912
+ const current = this.pendingRx.get(key);
913
+ if (current?.frame === frame) this.flushRx(key, current);
914
+ }, RX_COALESCE_MS);
915
+ this.pendingRx.set(key, { frame, timer });
916
+ }
917
+ acceptTxFrame(frame) {
918
+ if (!this.txEnabled || frame.trx !== this.trx) return;
919
+ if (frame.micLevelDbm !== void 0) this.observe("txMicLevel");
920
+ if (frame.rmsPowerWatts !== void 0) this.observe("txRmsPower");
921
+ if (frame.peakPowerWatts !== void 0) this.observe("txPeakPower");
922
+ if (frame.swr !== void 0) this.observe("txSwr");
923
+ if (frame.alc?.unit === "dbfs") this.observe("txAlcDbfs");
924
+ this.emit("txFrame", frame);
925
+ }
926
+ acceptEnableAcknowledgement(command) {
927
+ const enabled = command.args[0]?.toLowerCase();
928
+ if (enabled !== "true" && enabled !== "1" && enabled !== "on") return;
929
+ if (command.name === "rx_sensors_enable" && this.rxEnabled) {
930
+ this.acknowledge(["rxLevel"]);
931
+ }
932
+ if (command.name === "tx_sensors_enable" && this.txEnabled) {
933
+ this.acknowledge(["txMicLevel", "txRmsPower", "txPeakPower", "txSwr"]);
934
+ }
935
+ }
936
+ acknowledge(keys) {
937
+ let changed = false;
938
+ for (const key of keys) {
939
+ if (supportRank(this.capabilities[key]) < supportRank("acknowledged")) {
940
+ this.capabilities[key] = "acknowledged";
941
+ changed = true;
942
+ }
943
+ }
944
+ if (changed) this.emit("capabilitiesChanged", this.getCapabilities());
945
+ }
946
+ observe(key) {
947
+ if (this.capabilities[key] === "observed") return;
948
+ this.capabilities[key] = "observed";
949
+ this.emit("capabilitiesChanged", this.getCapabilities());
950
+ }
951
+ flushRx(key, pending) {
952
+ clearTimeout(pending.timer);
953
+ if (this.pendingRx.get(key) === pending) this.pendingRx.delete(key);
954
+ this.emit("rxFrame", pending.frame);
955
+ }
956
+ clearPendingRx() {
957
+ for (const pending of this.pendingRx.values()) clearTimeout(pending.timer);
958
+ this.pendingRx.clear();
959
+ }
960
+ };
961
+ function rxPriority(frame) {
962
+ if (frame.source === "rx_channel_sensors_ex") return 3;
963
+ if (frame.source === "rx_channel_sensors") return 2;
964
+ return 1;
965
+ }
966
+ function supportRank(value) {
967
+ switch (value) {
968
+ case "unsupported":
969
+ return -1;
970
+ case "unknown":
971
+ return 0;
972
+ case "declared":
973
+ return 1;
974
+ case "acknowledged":
975
+ return 2;
976
+ case "observed":
977
+ return 3;
978
+ }
979
+ }
980
+
669
981
  // src/dialect/builtins.ts
670
982
  var StandardTciDialect = class {
671
983
  id;
@@ -673,6 +985,9 @@ var StandardTciDialect = class {
673
985
  streamLengthSemantics;
674
986
  supportsStreamChannels;
675
987
  supportsTxAudioSource;
988
+ supportsIqStream;
989
+ iqSampleRates;
990
+ meterAdapter;
676
991
  driveHasTrx;
677
992
  detector;
678
993
  resolver;
@@ -682,6 +997,9 @@ var StandardTciDialect = class {
682
997
  this.streamLengthSemantics = options.streamLengthSemantics;
683
998
  this.supportsStreamChannels = options.supportsStreamChannels;
684
999
  this.supportsTxAudioSource = options.supportsTxAudioSource;
1000
+ this.supportsIqStream = options.supportsIqStream;
1001
+ this.iqSampleRates = [...options.iqSampleRates];
1002
+ this.meterAdapter = options.meterAdapter;
685
1003
  this.driveHasTrx = options.driveHasTrx;
686
1004
  this.detector = options.detect;
687
1005
  this.resolver = options.resolve;
@@ -729,7 +1047,10 @@ var expertSdr14Dialect = new StandardTciDialect({
729
1047
  streamLengthSemantics: "per-channel",
730
1048
  supportsStreamChannels: false,
731
1049
  supportsTxAudioSource: false,
1050
+ supportsIqStream: true,
1051
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
732
1052
  driveHasTrx: false,
1053
+ meterAdapter: new StandardTciMeterAdapter({ interval: { reportsApplied: false } }),
733
1054
  detect: (context) => {
734
1055
  const parsed = version(context);
735
1056
  if (!parsed || compareTciVersion(parsed, [1, 4]) > 0) return { score: 0, evidence: [] };
@@ -742,7 +1063,10 @@ var expertSdrLegacyDialect = new StandardTciDialect({
742
1063
  streamLengthSemantics: "per-channel",
743
1064
  supportsStreamChannels: false,
744
1065
  supportsTxAudioSource: false,
1066
+ supportsIqStream: true,
1067
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
745
1068
  driveHasTrx: true,
1069
+ meterAdapter: new StandardTciMeterAdapter({ interval: { reportsApplied: false } }),
746
1070
  detect: (context) => {
747
1071
  const parsed = version(context);
748
1072
  if (!parsed || compareTciVersion(parsed, [1, 5]) < 0 || compareTciVersion(parsed, [1, 9]) >= 0) return { score: 0, evidence: [] };
@@ -755,7 +1079,10 @@ var expertSdrModernDialect = new StandardTciDialect({
755
1079
  streamLengthSemantics: "scalar",
756
1080
  supportsStreamChannels: true,
757
1081
  supportsTxAudioSource: true,
1082
+ supportsIqStream: true,
1083
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
758
1084
  driveHasTrx: true,
1085
+ meterAdapter: new StandardTciMeterAdapter({ interval: { reportsApplied: false } }),
759
1086
  detect: (context) => {
760
1087
  const parsed = version(context);
761
1088
  if (!parsed || compareTciVersion(parsed, [1, 9]) < 0) return { score: 0, evidence: [] };
@@ -773,7 +1100,13 @@ var thetisDialect = new StandardTciDialect({
773
1100
  streamLengthSemantics: "scalar",
774
1101
  supportsStreamChannels: true,
775
1102
  supportsTxAudioSource: true,
1103
+ supportsIqStream: true,
1104
+ iqSampleRates: [48e3, 96e3, 192e3, 384e3],
776
1105
  driveHasTrx: true,
1106
+ meterAdapter: new StandardTciMeterAdapter({
1107
+ interval: { minMs: 30, maxMs: 1e3 },
1108
+ supportsRxExtended: true
1109
+ }),
777
1110
  detect: (context) => {
778
1111
  const evidence = [];
779
1112
  let score = 0;
@@ -799,7 +1132,13 @@ var aetherSdrDialect = new StandardTciDialect({
799
1132
  streamLengthSemantics: "scalar",
800
1133
  supportsStreamChannels: true,
801
1134
  supportsTxAudioSource: true,
1135
+ supportsIqStream: true,
1136
+ iqSampleRates: [24e3, 48e3, 96e3, 192e3],
802
1137
  driveHasTrx: true,
1138
+ meterAdapter: new StandardTciMeterAdapter({
1139
+ interval: { fixedMs: 200 },
1140
+ txAlcUnit: "dbfs"
1141
+ }),
803
1142
  detect: (context) => {
804
1143
  if (!/^aethersdr$/i.test(context.identity.device ?? "")) return { score: 0, evidence: [] };
805
1144
  const evidence = [`AetherSDR device identity: ${context.identity.device}`];
@@ -818,7 +1157,10 @@ var genericObservedDialect = new StandardTciDialect({
818
1157
  streamLengthSemantics: "auto",
819
1158
  supportsStreamChannels: true,
820
1159
  supportsTxAudioSource: true,
1160
+ supportsIqStream: false,
1161
+ iqSampleRates: [],
821
1162
  driveHasTrx: true,
1163
+ meterAdapter: createUnknownTciMeterAdapter(),
822
1164
  detect: (context) => ({
823
1165
  score: context.commandNames.has("ready") ? 10 : 0,
824
1166
  evidence: ["No vendor-specific match; using observed command shapes"],
@@ -833,7 +1175,10 @@ var genericObservedDialect = new StandardTciDialect({
833
1175
  streamLengthSemantics: "auto",
834
1176
  supportsStreamChannels: context.commandNames.has("audio_stream_channels"),
835
1177
  supportsTxAudioSource: compareTciVersion(version(context) ?? [0], [2, 0]) >= 0,
1178
+ supportsIqStream: context.commandNames.has("iq_samplerate"),
1179
+ iqSampleRates: context.commandNames.has("iq_samplerate") ? [48e3] : [],
836
1180
  driveHasTrx,
1181
+ meterAdapter: createUnknownTciMeterAdapter(),
837
1182
  detect: genericObservedDialect.detect.bind(genericObservedDialect)
838
1183
  });
839
1184
  }
@@ -932,9 +1277,9 @@ function assertValidTciHandshake(commands) {
932
1277
  }
933
1278
 
934
1279
  // src/transport/WebSocketTransport.ts
935
- var import_eventemitter3 = require("eventemitter3");
1280
+ var import_eventemitter32 = require("eventemitter3");
936
1281
  var import_ws = __toESM(require("ws"), 1);
937
- var WebSocketTciTransport = class extends import_eventemitter3.EventEmitter {
1282
+ var WebSocketTciTransport = class extends import_eventemitter32.EventEmitter {
938
1283
  constructor(url, WebSocketImpl = import_ws.default) {
939
1284
  super();
940
1285
  this.url = url;
@@ -1044,7 +1389,78 @@ function dataToBuffer(data) {
1044
1389
  }
1045
1390
 
1046
1391
  // src/client/TciClient.ts
1047
- var TciClient = class extends import_eventemitter32.EventEmitter {
1392
+ var TciIqStreamSession = class extends import_eventemitter33.EventEmitter {
1393
+ receiver;
1394
+ _appliedSampleRate;
1395
+ callbacks;
1396
+ closed = false;
1397
+ firstFrame;
1398
+ constructor(receiver, appliedSampleRate, callbacks) {
1399
+ super();
1400
+ this.receiver = receiver;
1401
+ this._appliedSampleRate = appliedSampleRate;
1402
+ this.callbacks = callbacks;
1403
+ }
1404
+ get appliedSampleRate() {
1405
+ return this._appliedSampleRate;
1406
+ }
1407
+ async setSampleRate(sampleRate) {
1408
+ if (this.closed) throw new TciError("cancelled", "TCI IQ stream is closed");
1409
+ const result = await this.callbacks.setSampleRate(sampleRate);
1410
+ this._appliedSampleRate = result.applied;
1411
+ return result;
1412
+ }
1413
+ async close() {
1414
+ if (this.closed) return;
1415
+ this.closed = true;
1416
+ this.rejectFirstFrame(new TciError("cancelled", "TCI IQ stream closed before its first frame"));
1417
+ try {
1418
+ await this.callbacks.close();
1419
+ } finally {
1420
+ this.emit("closed");
1421
+ this.removeAllListeners();
1422
+ }
1423
+ }
1424
+ waitForFirstFrame(timeoutMs) {
1425
+ if (this.firstFrame) return this.firstFrame.promise;
1426
+ let resolvePromise;
1427
+ let rejectPromise;
1428
+ const promise = new Promise((resolve, reject) => {
1429
+ resolvePromise = resolve;
1430
+ rejectPromise = reject;
1431
+ });
1432
+ const timer = setTimeout(() => {
1433
+ this.firstFrame = void 0;
1434
+ rejectPromise(new TciError("command-timeout", `Timed out waiting for TCI IQ frame for receiver ${this.receiver}`));
1435
+ }, timeoutMs);
1436
+ this.firstFrame = { promise, resolve: resolvePromise, reject: rejectPromise, timer };
1437
+ return promise;
1438
+ }
1439
+ _acceptFrame(frame) {
1440
+ if (this.closed || frame.receiver !== this.receiver) return;
1441
+ this._appliedSampleRate = frame.sampleRate;
1442
+ const firstFrame = this.firstFrame;
1443
+ if (firstFrame) {
1444
+ clearTimeout(firstFrame.timer);
1445
+ this.firstFrame = void 0;
1446
+ firstFrame.resolve(frame);
1447
+ }
1448
+ this.emit("frame", frame);
1449
+ }
1450
+ _fail(error) {
1451
+ if (this.closed) return;
1452
+ this.rejectFirstFrame(error);
1453
+ this.emit("error", error);
1454
+ }
1455
+ rejectFirstFrame(error) {
1456
+ const firstFrame = this.firstFrame;
1457
+ if (!firstFrame) return;
1458
+ clearTimeout(firstFrame.timer);
1459
+ this.firstFrame = void 0;
1460
+ firstFrame.reject(error);
1461
+ }
1462
+ };
1463
+ var TciClient = class extends import_eventemitter33.EventEmitter {
1048
1464
  options;
1049
1465
  WebSocketImpl;
1050
1466
  transportFactory;
@@ -1058,6 +1474,8 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1058
1474
  handshakeError;
1059
1475
  initializationCommands = [];
1060
1476
  handshakeWaiter;
1477
+ activeIqSession;
1478
+ activeMeterSession;
1061
1479
  constructor(options) {
1062
1480
  super();
1063
1481
  this.options = {
@@ -1096,7 +1514,9 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1096
1514
  split: {},
1097
1515
  dialectWarnings: [],
1098
1516
  rxSensors: {},
1099
- txSensors: {}
1517
+ txSensors: {},
1518
+ iq: { activeReceivers: {} },
1519
+ dds: {}
1100
1520
  };
1101
1521
  this.stateReducers = this.createStateReducers();
1102
1522
  }
@@ -1121,6 +1541,7 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1121
1541
  }
1122
1542
  }
1123
1543
  async disconnect(code = 1e3, reason = "client disconnect") {
1544
+ await this.activeMeterSession?.close().catch(() => void 0);
1124
1545
  const transport = this.transport;
1125
1546
  if (!transport) return;
1126
1547
  await transport.disconnect(code, reason);
@@ -1135,6 +1556,133 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1135
1556
  getHandshakeResult() {
1136
1557
  return this.handshakeResult ? cloneHandshake(this.handshakeResult) : void 0;
1137
1558
  }
1559
+ getIqCapabilities() {
1560
+ const dialect = this.requireDialect();
1561
+ return {
1562
+ supported: dialect.supportsIqStream,
1563
+ currentSampleRate: this.state.iq.sampleRate,
1564
+ supportedSampleRates: [...dialect.iqSampleRates]
1565
+ };
1566
+ }
1567
+ getMeterCapabilities() {
1568
+ if (this.activeMeterSession) return this.activeMeterSession.getCapabilities();
1569
+ const adapter = this.activeDialect?.meterAdapter;
1570
+ return adapter ? cloneMeterCapabilities(adapter.declaredCapabilities) : cloneMeterCapabilities(UNKNOWN_TCI_METER_CAPABILITIES);
1571
+ }
1572
+ async openMeterStream(options = {}) {
1573
+ if (this.activeMeterSession) throw new TciError("protocol-error", "This TCI client already has an active meter stream session");
1574
+ this.requireDialect();
1575
+ const receiver = normalizeNonNegativeInteger(options.receiver ?? this.options.receiver, "meter receiver");
1576
+ const channel = normalizeNonNegativeInteger(options.channel ?? this.options.vfo, "meter channel");
1577
+ const trx = normalizeNonNegativeInteger(options.trx ?? this.options.trx, "meter transmitter");
1578
+ const rxEnabled = options.rx ?? true;
1579
+ const txEnabled = options.tx ?? true;
1580
+ if (!rxEnabled && !txEnabled) throw new TciError("protocol-error", "TCI meter stream must enable RX, TX, or both");
1581
+ const requestedIntervalMs = Math.round(options.intervalMs ?? 300);
1582
+ if (!Number.isFinite(requestedIntervalMs) || requestedIntervalMs <= 0) {
1583
+ throw new TciError("protocol-error", `Invalid TCI meter interval: ${options.intervalMs}`);
1584
+ }
1585
+ const adapter = this.activeDialect?.meterAdapter ?? createUnknownTciMeterAdapter();
1586
+ const interval = adapter.normalizeInterval(requestedIntervalMs);
1587
+ let session;
1588
+ session = new TciMeterStreamSession({
1589
+ receiver,
1590
+ channel,
1591
+ trx,
1592
+ requestedIntervalMs: interval.requestedMs,
1593
+ appliedIntervalMs: interval.appliedMs,
1594
+ rxEnabled,
1595
+ txEnabled,
1596
+ adapter,
1597
+ callbacks: {
1598
+ close: async () => {
1599
+ try {
1600
+ if (this.isConnected()) {
1601
+ if (rxEnabled) {
1602
+ const command = adapter.buildEnableCommand("rx", false, interval.appliedMs ?? interval.requestedMs);
1603
+ await this.sendCommand(command.name, command.args, { waitForReply: false }).catch(() => void 0);
1604
+ }
1605
+ if (txEnabled) {
1606
+ const command = adapter.buildEnableCommand("tx", false, interval.appliedMs ?? interval.requestedMs);
1607
+ await this.sendCommand(command.name, command.args, { waitForReply: false }).catch(() => void 0);
1608
+ }
1609
+ }
1610
+ } finally {
1611
+ if (this.activeMeterSession === session) this.activeMeterSession = void 0;
1612
+ }
1613
+ }
1614
+ }
1615
+ });
1616
+ this.activeMeterSession = session;
1617
+ try {
1618
+ if (rxEnabled) {
1619
+ const command = adapter.buildEnableCommand("rx", true, interval.appliedMs ?? interval.requestedMs);
1620
+ await this.sendCommand(command.name, command.args, { waitForReply: false });
1621
+ }
1622
+ if (txEnabled) {
1623
+ const command = adapter.buildEnableCommand("tx", true, interval.appliedMs ?? interval.requestedMs);
1624
+ await this.sendCommand(command.name, command.args, { waitForReply: false });
1625
+ }
1626
+ return session;
1627
+ } catch (error) {
1628
+ await session.close().catch(() => void 0);
1629
+ throw error;
1630
+ }
1631
+ }
1632
+ async setIqSampleRate(sampleRate, timeoutMs = this.options.commandTimeoutMs) {
1633
+ const capabilities = this.getIqCapabilities();
1634
+ const requested = Math.round(sampleRate);
1635
+ if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
1636
+ if (!Number.isFinite(requested) || requested <= 0) throw new TciError("protocol-error", `Invalid TCI IQ sample rate: ${sampleRate}`);
1637
+ if (capabilities.supportedSampleRates.length > 0 && !capabilities.supportedSampleRates.includes(requested)) {
1638
+ throw new TciError("protocol-error", `TCI IQ sample rate ${requested} is not supported by dialect ${this.requireDialect().id}`);
1639
+ }
1640
+ if (this.state.iq.sampleRate === requested) return writeResult(requested, requested, "state");
1641
+ const waiter = this.waitForCommand((command) => command.name === "iq_samplerate", timeoutMs, "IQ_SAMPLERATE readback");
1642
+ try {
1643
+ await this.sendCommand("IQ_SAMPLERATE", [requested], { waitForReply: false });
1644
+ const reply = await waiter.promise;
1645
+ const applied = parseNumber(reply.args[0]);
1646
+ if (applied === void 0 || applied <= 0) throw new TciError("protocol-error", `Invalid IQ_SAMPLERATE readback: ${reply.raw}`);
1647
+ return writeResult(requested, applied, "reply");
1648
+ } finally {
1649
+ waiter.cancel();
1650
+ }
1651
+ }
1652
+ async openIqStream(options = {}) {
1653
+ if (this.activeIqSession) throw new TciError("protocol-error", "This TCI client already has an active IQ stream session");
1654
+ const capabilities = this.getIqCapabilities();
1655
+ if (!capabilities.supported) throw new TciError("protocol-error", `TCI dialect ${this.requireDialect().id} does not support IQ streaming`);
1656
+ const receiver = Math.floor(options.receiver ?? this.options.receiver);
1657
+ if (!Number.isInteger(receiver) || receiver < 0) throw new TciError("protocol-error", `Invalid TCI IQ receiver: ${receiver}`);
1658
+ const requestedRate = options.sampleRate ?? capabilities.currentSampleRate ?? capabilities.supportedSampleRates[0] ?? 48e3;
1659
+ const rateResult = await this.setIqSampleRate(requestedRate);
1660
+ let session;
1661
+ session = new TciIqStreamSession(receiver, rateResult.applied, {
1662
+ setSampleRate: (rate) => this.setIqSampleRate(rate),
1663
+ close: async () => {
1664
+ try {
1665
+ if (this.isConnected()) await this.sendCommand("IQ_STOP", [receiver], { waitForReply: false });
1666
+ } finally {
1667
+ this.state.iq.activeReceivers[String(receiver)] = false;
1668
+ if (this.activeIqSession === session) this.activeIqSession = void 0;
1669
+ this.emitState();
1670
+ }
1671
+ }
1672
+ });
1673
+ this.activeIqSession = session;
1674
+ const firstFrame = session.waitForFirstFrame(options.firstFrameTimeoutMs ?? 5e3);
1675
+ try {
1676
+ await this.sendCommand("IQ_START", [receiver], { waitForReply: false });
1677
+ this.state.iq.activeReceivers[String(receiver)] = true;
1678
+ this.emitState();
1679
+ await firstFrame;
1680
+ return session;
1681
+ } catch (error) {
1682
+ await session.close().catch(() => void 0);
1683
+ throw error;
1684
+ }
1685
+ }
1138
1686
  async sendCommand(name, args = [], options = {}) {
1139
1687
  const raw = formatTciCommand(name, args);
1140
1688
  if (options.waitForReply === false) {
@@ -1605,11 +2153,13 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1605
2153
  handleText(raw) {
1606
2154
  try {
1607
2155
  const commands = parseTciText(raw);
2156
+ const receivedAtMs = Date.now();
1608
2157
  this.emit("tci:rx", raw, commands);
1609
2158
  for (const command of commands) {
1610
2159
  if (!this.handshakeResult) this.initializationCommands.push(command);
1611
2160
  this.queue.handleCommand(command);
1612
2161
  this.applyCommand(command);
2162
+ this.activeMeterSession?._acceptCommand(command, receivedAtMs);
1613
2163
  this.emit("command", command);
1614
2164
  }
1615
2165
  } catch (error) {
@@ -1624,6 +2174,22 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1624
2174
  this.emit("tci:binary", frame);
1625
2175
  this.emit("binary", frame);
1626
2176
  switch (frame.streamType) {
2177
+ case 0 /* IQ_STREAM */: {
2178
+ if (frame.channels !== 2 || frame.sampleCount % 2 !== 0) {
2179
+ throw new TciError("invalid-frame", "TCI IQ stream frame must contain interleaved I/Q pairs");
2180
+ }
2181
+ this.state.iq.sampleRate = frame.sampleRate;
2182
+ const iqFrame = {
2183
+ frame,
2184
+ receiver: frame.receiver,
2185
+ sampleRate: frame.sampleRate,
2186
+ centerFrequency: this.state.dds[String(frame.receiver)],
2187
+ complexSampleCount: frame.frameCount
2188
+ };
2189
+ this.emit("iqFrame", iqFrame);
2190
+ this.activeIqSession?._acceptFrame(iqFrame);
2191
+ break;
2192
+ }
1627
2193
  case 1 /* RX_AUDIO_STREAM */:
1628
2194
  this.emit("rxAudioFrame", frame);
1629
2195
  break;
@@ -1701,6 +2267,11 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1701
2267
  this.state.modulations = args.map((mode) => mode.toLowerCase());
1702
2268
  });
1703
2269
  reducers.set("vfo", (args) => this.applyVfo(args));
2270
+ reducers.set("dds", (args) => {
2271
+ const receiver = parseNumber(args[0]);
2272
+ const frequency = parseNumber(args[1]);
2273
+ if (receiver !== void 0 && frequency !== void 0 && frequency >= 0) this.state.dds[String(receiver)] = frequency;
2274
+ });
1704
2275
  reducers.set("modulation", (args) => this.applyModulation(args));
1705
2276
  reducers.set("trx", (args) => this.applyTrx(args));
1706
2277
  reducers.set("tune", (args) => this.applyBooleanByFirstArg(this.state.tune, args));
@@ -1708,6 +2279,7 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1708
2279
  reducers.set("tune_drive", (args) => this.applyTuneDrive(args));
1709
2280
  reducers.set("split_enable", (args) => this.applyBooleanByFirstArg(this.state.split, args));
1710
2281
  reducers.set("rx_channel_sensors", (args) => this.applyRxChannelSensors(args));
2282
+ reducers.set("rx_channel_sensors_ex", (args) => this.applyRxChannelSensors(args));
1711
2283
  reducers.set("rx_sensors", (args) => this.applyRxSensors(args));
1712
2284
  reducers.set("tx_sensors", (args) => this.applyTxSensors(args));
1713
2285
  reducers.set("audio_samplerate", (args) => this.updateAudioState({ sampleRate: parseNumber(args[0]) }));
@@ -1717,6 +2289,15 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1717
2289
  reducers.set("tx_stream_audio_buffering", (args) => this.updateAudioState({ txBufferingMs: parseNumber(args[0]) }));
1718
2290
  reducers.set("audio_start", () => this.updateAudioState({ running: true }));
1719
2291
  reducers.set("audio_stop", () => this.updateAudioState({ running: false }));
2292
+ reducers.set("iq_samplerate", (args) => {
2293
+ this.state.iq.sampleRate = parseNumber(args[0]);
2294
+ });
2295
+ reducers.set("iq_start", (args) => {
2296
+ this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = true;
2297
+ });
2298
+ reducers.set("iq_stop", (args) => {
2299
+ this.state.iq.activeReceivers[String(args[0] ?? this.options.receiver)] = false;
2300
+ });
1720
2301
  return reducers;
1721
2302
  }
1722
2303
  applyVfo(args) {
@@ -1824,6 +2405,10 @@ var TciClient = class extends import_eventemitter32.EventEmitter {
1824
2405
  this.state.ready = false;
1825
2406
  this.queue.setConnected(false);
1826
2407
  this.rejectHandshake(new TciError("disconnected", "TCI connection closed during handshake", reason));
2408
+ this.activeIqSession?._fail(new TciError("disconnected", "TCI connection closed while IQ stream was active", reason));
2409
+ this.activeIqSession = void 0;
2410
+ this.activeMeterSession?._fail(new TciError("disconnected", "TCI connection closed while meter stream was active", reason));
2411
+ this.activeMeterSession = void 0;
1827
2412
  if (wasConnected) {
1828
2413
  this.emit("disconnected", reason);
1829
2414
  this.emitState();
@@ -1850,6 +2435,12 @@ function parseNumber(value) {
1850
2435
  const number = Number(value);
1851
2436
  return Number.isFinite(number) ? number : void 0;
1852
2437
  }
2438
+ function normalizeNonNegativeInteger(value, description) {
2439
+ if (!Number.isInteger(value) || value < 0) {
2440
+ throw new TciError("protocol-error", `Invalid TCI ${description}: ${value}`);
2441
+ }
2442
+ return value;
2443
+ }
1853
2444
  function parseBoolean(value) {
1854
2445
  if (value === void 0) {
1855
2446
  return void 0;
@@ -1904,7 +2495,9 @@ function cloneState(state) {
1904
2495
  dialectWarnings: [...state.dialectWarnings],
1905
2496
  rxSensors: cloneNested(state.rxSensors),
1906
2497
  txSensors: cloneNested(state.txSensors),
1907
- audio: state.audio ? { ...state.audio } : void 0
2498
+ audio: state.audio ? { ...state.audio } : void 0,
2499
+ iq: { ...state.iq, activeReceivers: { ...state.iq.activeReceivers } },
2500
+ dds: { ...state.dds }
1908
2501
  };
1909
2502
  }
1910
2503
  function cloneHandshake(result) {
@@ -1924,22 +2517,29 @@ function cloneNested(value) {
1924
2517
  }
1925
2518
  // Annotate the CommonJS export names for ESM import in node:
1926
2519
  0 && (module.exports = {
2520
+ StandardTciMeterAdapter,
1927
2521
  TCI_STREAM_HEADER_BYTES,
1928
2522
  TciClient,
1929
2523
  TciCommandQueue,
1930
2524
  TciDialectRegistry,
1931
2525
  TciError,
2526
+ TciIqStreamSession,
2527
+ TciMeterStreamSession,
1932
2528
  TciSampleType,
1933
2529
  TciStreamType,
2530
+ UNKNOWN_TCI_METER_CAPABILITIES,
1934
2531
  WebSocketTciTransport,
1935
2532
  aetherSdrDialect,
1936
2533
  assertValidTciHandshake,
1937
2534
  buildStreamFrame,
1938
2535
  buildTxAudioFrame,
1939
2536
  builtInDialects,
2537
+ cloneMeterCapabilities,
1940
2538
  commandKey,
1941
2539
  compareTciVersion,
1942
2540
  createTciClient,
2541
+ createUnknownTciMeterAdapter,
2542
+ decodeInterleavedIq,
1943
2543
  defaultTciDialectRegistry,
1944
2544
  deinterleaveChannels,
1945
2545
  escapeTciText,