@tx5dr/plugin-api 1.7.12 → 2.5.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/README.md +475 -17
- package/dist/__tests__/adif.test.js +107 -0
- package/dist/__tests__/adif.test.js.map +1 -1
- package/dist/__tests__/capability-context.test.d.ts +2 -0
- package/dist/__tests__/capability-context.test.d.ts.map +1 -0
- package/dist/__tests__/capability-context.test.js +99 -0
- package/dist/__tests__/capability-context.test.js.map +1 -0
- package/dist/__tests__/testing-utils.test.js +234 -15
- package/dist/__tests__/testing-utils.test.js.map +1 -1
- package/dist/capabilities.d.ts +38 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +50 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/compatibility.d.ts +15 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +71 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/contest-logbook-ui/assets/contest-log.css +1 -0
- package/dist/contest-logbook-ui/assets/contest-log.js +40 -0
- package/dist/contest-logbook-ui/contest-log.html +13 -0
- package/dist/context.d.ts +125 -54
- package/dist/context.d.ts.map +1 -1
- package/dist/definition.d.ts +98 -21
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js +8 -1
- package/dist/definition.js.map +1 -1
- package/dist/ft8/StandardQSOPluginRuntime.d.ts +142 -0
- package/dist/ft8/StandardQSOPluginRuntime.d.ts.map +1 -0
- package/dist/ft8/StandardQSOPluginRuntime.js +1592 -0
- package/dist/ft8/StandardQSOPluginRuntime.js.map +1 -0
- package/dist/ft8/index.d.ts +22 -0
- package/dist/ft8/index.d.ts.map +1 -0
- package/dist/ft8/index.js +176 -0
- package/dist/ft8/index.js.map +1 -0
- package/dist/helpers.d.ts +417 -132
- package/dist/helpers.d.ts.map +1 -1
- package/dist/hooks.d.ts +56 -34
- package/dist/hooks.d.ts.map +1 -1
- package/dist/host-dependencies.d.ts +101 -0
- package/dist/host-dependencies.d.ts.map +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +539 -13
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -1
- package/dist/runtime.js.map +1 -1
- package/dist/settings.d.ts +31 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/simulation.d.ts +60 -0
- package/dist/simulation.d.ts.map +1 -0
- package/dist/simulation.js +2 -0
- package/dist/simulation.js.map +1 -0
- package/dist/sync.d.ts +98 -6
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +6 -0
- package/dist/sync.js.map +1 -1
- package/dist/testing/index.d.ts +74 -12
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +372 -105
- package/dist/testing/index.js.map +1 -1
- package/dist/toolkit/contest/CabrilloBuilder.d.ts +7 -0
- package/dist/toolkit/contest/CabrilloBuilder.d.ts.map +1 -0
- package/dist/toolkit/contest/CabrilloBuilder.js +11 -0
- package/dist/toolkit/contest/CabrilloBuilder.js.map +1 -0
- package/dist/toolkit/contest/ContestCategorySchema.d.ts +11 -0
- package/dist/toolkit/contest/ContestCategorySchema.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestCategorySchema.js +6 -0
- package/dist/toolkit/contest/ContestCategorySchema.js.map +1 -0
- package/dist/toolkit/contest/ContestLogbook.d.ts +223 -0
- package/dist/toolkit/contest/ContestLogbook.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestLogbook.js +845 -0
- package/dist/toolkit/contest/ContestLogbook.js.map +1 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts +28 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js +79 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js.map +1 -0
- package/dist/toolkit/contest/ContestQsoProjector.d.ts +12 -0
- package/dist/toolkit/contest/ContestQsoProjector.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestQsoProjector.js +13 -0
- package/dist/toolkit/contest/ContestQsoProjector.js.map +1 -0
- package/dist/toolkit/contest/ContestSessionNotifier.d.ts +9 -0
- package/dist/toolkit/contest/ContestSessionNotifier.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestSessionNotifier.js +13 -0
- package/dist/toolkit/contest/ContestSessionNotifier.js.map +1 -0
- package/dist/toolkit/contest/ContestSessionRepository.d.ts +21 -0
- package/dist/toolkit/contest/ContestSessionRepository.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestSessionRepository.js +27 -0
- package/dist/toolkit/contest/ContestSessionRepository.js.map +1 -0
- package/dist/toolkit/contest/DefaultContestSession.d.ts +105 -0
- package/dist/toolkit/contest/DefaultContestSession.d.ts.map +1 -0
- package/dist/toolkit/contest/DefaultContestSession.js +387 -0
- package/dist/toolkit/contest/DefaultContestSession.js.map +1 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.d.ts +100 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.d.ts.map +1 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.js +33 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestDefinition.d.ts +83 -0
- package/dist/toolkit/contest/FT8ContestDefinition.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestDefinition.js +167 -0
- package/dist/toolkit/contest/FT8ContestDefinition.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestModules.d.ts +178 -0
- package/dist/toolkit/contest/FT8ContestModules.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestModules.js +247 -0
- package/dist/toolkit/contest/FT8ContestModules.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestPlugin.d.ts +55 -0
- package/dist/toolkit/contest/FT8ContestPlugin.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestPlugin.js +322 -0
- package/dist/toolkit/contest/FT8ContestPlugin.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestTestKit.d.ts +12 -0
- package/dist/toolkit/contest/FT8ContestTestKit.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestTestKit.js +60 -0
- package/dist/toolkit/contest/FT8ContestTestKit.js.map +1 -0
- package/dist/toolkit/contest/contest-toolkit.test.d.ts +2 -0
- package/dist/toolkit/contest/contest-toolkit.test.d.ts.map +1 -0
- package/dist/toolkit/contest/contest-toolkit.test.js +1196 -0
- package/dist/toolkit/contest/contest-toolkit.test.js.map +1 -0
- package/dist/toolkit/contest/index.d.ts +14 -0
- package/dist/toolkit/contest/index.d.ts.map +1 -0
- package/dist/toolkit/contest/index.js +14 -0
- package/dist/toolkit/contest/index.js.map +1 -0
- package/dist/toolkit/index.d.ts +3 -0
- package/dist/toolkit/index.d.ts.map +1 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/index.js.map +1 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts +15 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.js +25 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.js.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts +40 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.js +81 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.js.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js +31 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.d.ts +20 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.js +20 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.js.map +1 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts +19 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.js +23 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.js.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts +134 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js +554 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js +470 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts +20 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js +29 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js.map +1 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.d.ts +50 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.js +2 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.js.map +1 -0
- package/dist/toolkit/parallel-qso/controllers.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/controllers.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/controllers.test.js +36 -0
- package/dist/toolkit/parallel-qso/controllers.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/index.d.ts +8 -0
- package/dist/toolkit/parallel-qso/index.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/index.js +8 -0
- package/dist/toolkit/parallel-qso/index.js.map +1 -0
- package/dist/utils/adif.d.ts +1 -1
- package/dist/utils/adif.d.ts.map +1 -1
- package/dist/utils/adif.js +96 -51
- package/dist/utils/adif.js.map +1 -1
- package/dist/utils/qso-text-fields.d.ts +7 -1
- package/dist/utils/qso-text-fields.d.ts.map +1 -1
- package/dist/utils/qso-text-fields.js +74 -6
- package/dist/utils/qso-text-fields.js.map +1 -1
- package/package.json +28 -6
- package/src/bridge.d.ts +3 -0
- package/tokens.css +51 -0
|
@@ -0,0 +1,1592 @@
|
|
|
1
|
+
import { FT8MessageType } from '../ft8-message-type.js';
|
|
2
|
+
import { normalizeCallsign } from '../utils/callsign.js';
|
|
3
|
+
import { FT8MessageParser, isUndecodedCallsignPlaceholder } from '@tx5dr/core';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
export const STANDARD_QSO_TX6_MESSAGE_OVERRIDE_SETTING = 'tx6MessageOverride';
|
|
6
|
+
export function normalizeStandardQSOTx6MessageOverride(content, defaultMessage) {
|
|
7
|
+
if (typeof content !== 'string') {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
const trimmed = content.trim();
|
|
11
|
+
if (!trimmed || trimmed === defaultMessage) {
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
return trimmed;
|
|
15
|
+
}
|
|
16
|
+
export function buildStandardQSODefaultTx6Message(config) {
|
|
17
|
+
return FT8MessageParser.generateMessage({
|
|
18
|
+
type: FT8MessageType.CQ,
|
|
19
|
+
senderCallsign: config.myCallsign,
|
|
20
|
+
grid: config.myGrid,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** No-op fallback for direct unit construction without a Host logger. */
|
|
24
|
+
const fallbackLogger = {
|
|
25
|
+
debug() { },
|
|
26
|
+
info() { },
|
|
27
|
+
warn() { },
|
|
28
|
+
error() { },
|
|
29
|
+
};
|
|
30
|
+
function callsignMatches(left, right) {
|
|
31
|
+
if (!left || !right)
|
|
32
|
+
return false;
|
|
33
|
+
const normalizedLeft = left.trim().toUpperCase();
|
|
34
|
+
const normalizedRight = right.trim().toUpperCase();
|
|
35
|
+
return normalizedLeft === normalizedRight
|
|
36
|
+
|| normalizeCallsign(normalizedLeft) === normalizeCallsign(normalizedRight);
|
|
37
|
+
}
|
|
38
|
+
function getCandidatePriorityTuple(strategy, candidate) {
|
|
39
|
+
const analysis = candidate.logbookAnalysis;
|
|
40
|
+
const isNewDxcc = analysis?.isNewDxccEntity && analysis.dxccStatus !== 'deleted' ? 1 : 0;
|
|
41
|
+
const isNewGrid = analysis?.isNewGrid ? 1 : 0;
|
|
42
|
+
const isNewCallsign = analysis?.isNewCallsign ? 1 : 0;
|
|
43
|
+
switch (strategy.operator.config.targetSelectionPriorityMode) {
|
|
44
|
+
case 'new_callsign_first':
|
|
45
|
+
return [isNewCallsign, isNewGrid, isNewDxcc];
|
|
46
|
+
case 'balanced':
|
|
47
|
+
return [isNewGrid, isNewDxcc, isNewCallsign];
|
|
48
|
+
case 'dxcc_first':
|
|
49
|
+
default:
|
|
50
|
+
return [isNewDxcc, isNewGrid, isNewCallsign];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function getCandidateScore(candidate) {
|
|
54
|
+
const score = candidate.score;
|
|
55
|
+
return typeof score === 'number' && Number.isFinite(score) ? score : undefined;
|
|
56
|
+
}
|
|
57
|
+
function compareCandidates(strategy, left, right) {
|
|
58
|
+
const leftScore = getCandidateScore(left);
|
|
59
|
+
const rightScore = getCandidateScore(right);
|
|
60
|
+
if (leftScore !== undefined && rightScore !== undefined && leftScore !== rightScore) {
|
|
61
|
+
return rightScore - leftScore;
|
|
62
|
+
}
|
|
63
|
+
if (strategy.operator.config.targetSelectionPriorityMode === 'balanced') {
|
|
64
|
+
const snrDiff = right.snr - left.snr;
|
|
65
|
+
if (Math.abs(snrDiff) > 3) {
|
|
66
|
+
return snrDiff;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const leftTuple = getCandidatePriorityTuple(strategy, left);
|
|
70
|
+
const rightTuple = getCandidatePriorityTuple(strategy, right);
|
|
71
|
+
for (let index = 0; index < leftTuple.length; index += 1) {
|
|
72
|
+
if (leftTuple[index] !== rightTuple[index]) {
|
|
73
|
+
return rightTuple[index] - leftTuple[index];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (left.snr !== right.snr) {
|
|
77
|
+
return right.snr - left.snr;
|
|
78
|
+
}
|
|
79
|
+
const leftDf = Math.abs(left.df);
|
|
80
|
+
const rightDf = Math.abs(right.df);
|
|
81
|
+
if (leftDf !== rightDf) {
|
|
82
|
+
return leftDf - rightDf;
|
|
83
|
+
}
|
|
84
|
+
return right.timestamp - left.timestamp;
|
|
85
|
+
}
|
|
86
|
+
function buildSuccessSilentListen(completedCallsign) {
|
|
87
|
+
return {
|
|
88
|
+
reason: 'qso-success',
|
|
89
|
+
acceptDirectedCalls: true,
|
|
90
|
+
graceSlots: 2,
|
|
91
|
+
excludeCallsigns: completedCallsign ? [completedCallsign] : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function trySwitchToDirectedCall(strategy, messages, options) {
|
|
95
|
+
return trySwitchToDirectedProtocol(strategy, messages, options);
|
|
96
|
+
}
|
|
97
|
+
async function trySwitchToDirectedProtocol(strategy, messages, options) {
|
|
98
|
+
if (strategy.hasUnsettledQSOCompletion()) {
|
|
99
|
+
strategy.logger.debug(`${options?.logPrefix ?? 'direct call'}: waiting for QSO durability before starting another lifecycle`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const excluded = (options?.excludeCallsigns ?? [])
|
|
103
|
+
.filter((callsign) => typeof callsign === 'string' && callsign.trim().length > 0);
|
|
104
|
+
const myCallsign = strategy.context.config.myCallsign;
|
|
105
|
+
const directCalls = messages
|
|
106
|
+
.filter((msg) => {
|
|
107
|
+
if (msg.isPartialDecode) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const message = msg.message;
|
|
111
|
+
const isInitialDirectCall = message.type === FT8MessageType.CALL
|
|
112
|
+
|| message.type === FT8MessageType.SIGNAL_REPORT;
|
|
113
|
+
const isFollowUpProtocol = options?.acceptFollowUpProtocol === true
|
|
114
|
+
&& (message.type === FT8MessageType.ROGER_REPORT || message.type === FT8MessageType.RRR);
|
|
115
|
+
if (!isInitialDirectCall && !isFollowUpProtocol) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return callsignMatches(message.targetCallsign, myCallsign)
|
|
119
|
+
&& !excluded.some((callsign) => callsignMatches(message.senderCallsign, callsign));
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => compareCandidates(strategy, a, b));
|
|
122
|
+
for (const directCall of directCalls) {
|
|
123
|
+
const msg = directCall.message;
|
|
124
|
+
if (msg.type !== FT8MessageType.CALL
|
|
125
|
+
&& msg.type !== FT8MessageType.SIGNAL_REPORT
|
|
126
|
+
&& msg.type !== FT8MessageType.ROGER_REPORT
|
|
127
|
+
&& msg.type !== FT8MessageType.RRR) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const callsign = msg.senderCallsign;
|
|
131
|
+
if (isUndecodedCallsignPlaceholder(callsign)) {
|
|
132
|
+
strategy.logger.debug(`${options?.logPrefix ?? 'direct call'}: skipping undecoded placeholder sender ${callsign}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const hasWorked = await strategy.operator.hasWorkedCallsign(callsign);
|
|
136
|
+
const prefix = options?.logPrefix ?? 'direct call';
|
|
137
|
+
if (hasWorked && !strategy.operator.config.replyToWorkedStations) {
|
|
138
|
+
strategy.logger.debug(`${prefix}: skipping ${callsign} - already worked and replyToWorkedStations=false (SNR: ${directCall.snr})`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const hasConflict = strategy.operator.isTargetBeingWorkedByOthers(callsign);
|
|
142
|
+
if (hasConflict) {
|
|
143
|
+
strategy.logger.debug(`${prefix}: skipping ${callsign} - other operator conflict (SNR: ${directCall.snr})`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (options?.clearPost73Reason) {
|
|
147
|
+
strategy.clearPost73RetryContext(options.clearPost73Reason);
|
|
148
|
+
}
|
|
149
|
+
strategy.clearQSOContext();
|
|
150
|
+
strategy.beginQsoWithTarget(callsign, `${prefix}: directed protocol handoff`);
|
|
151
|
+
if (msg.type === FT8MessageType.CALL) {
|
|
152
|
+
strategy.logger.debug(`${prefix}: switching to direct call ${callsign} (SNR: ${directCall.snr})`);
|
|
153
|
+
const callMsg = msg;
|
|
154
|
+
// Restore grid/frequency cache, but always refresh SNR and clear stale received reports.
|
|
155
|
+
strategy.restoreContext(callsign);
|
|
156
|
+
if (callMsg.grid) {
|
|
157
|
+
strategy.context.targetGrid = callMsg.grid;
|
|
158
|
+
}
|
|
159
|
+
strategy.context.reportSent = directCall.snr;
|
|
160
|
+
strategy.context.reportReceived = undefined;
|
|
161
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
162
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + directCall.df;
|
|
163
|
+
}
|
|
164
|
+
strategy.updateSlots();
|
|
165
|
+
return { changeState: 'TX2' };
|
|
166
|
+
}
|
|
167
|
+
if (msg.type === FT8MessageType.SIGNAL_REPORT) {
|
|
168
|
+
strategy.logger.debug(`${prefix}: switching to direct signal report ${callsign} (SNR: ${directCall.snr})`);
|
|
169
|
+
// Always apply the fresh report from the air message after restore.
|
|
170
|
+
// Cached context may still hold a UI/default sentinel of 0.
|
|
171
|
+
strategy.restoreContext(callsign);
|
|
172
|
+
strategy.context.reportReceived = msg.report;
|
|
173
|
+
strategy.context.reportSent = directCall.snr;
|
|
174
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
175
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + directCall.df;
|
|
176
|
+
}
|
|
177
|
+
strategy.updateSlots();
|
|
178
|
+
return { changeState: 'TX3' };
|
|
179
|
+
}
|
|
180
|
+
if (msg.type === FT8MessageType.ROGER_REPORT) {
|
|
181
|
+
strategy.logger.debug(`${prefix}: resuming from direct R-report ${callsign} (SNR: ${directCall.snr})`);
|
|
182
|
+
strategy.restoreContext(callsign);
|
|
183
|
+
strategy.context.reportReceived = msg.report;
|
|
184
|
+
strategy.context.reportSent = directCall.snr;
|
|
185
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
186
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + directCall.df;
|
|
187
|
+
}
|
|
188
|
+
strategy.updateSlots();
|
|
189
|
+
return { changeState: 'TX4' };
|
|
190
|
+
}
|
|
191
|
+
strategy.logger.debug(`${prefix}: resuming from direct RRR/RR73 ${callsign} (SNR: ${directCall.snr})`);
|
|
192
|
+
strategy.restoreContext(callsign);
|
|
193
|
+
strategy.updateSlots();
|
|
194
|
+
return { changeState: 'TX5' };
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const states = {
|
|
199
|
+
TX1: {
|
|
200
|
+
async handle(strategy, messages) {
|
|
201
|
+
// 【修复】优先检查当前目标呼号是否回复了,而不是先检查新呼叫
|
|
202
|
+
// 这样可以确保当前QSO的连续性,避免在对方已回复时错误切换到新呼叫者
|
|
203
|
+
const msgSignalReport = messages
|
|
204
|
+
.filter((msg) => msg.message.type === FT8MessageType.SIGNAL_REPORT &&
|
|
205
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
206
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
207
|
+
.sort((a, b) => a.snr - b.snr)
|
|
208
|
+
.pop();
|
|
209
|
+
if (msgSignalReport) {
|
|
210
|
+
const msg = msgSignalReport.message;
|
|
211
|
+
// 对方发来的 SIGNAL_REPORT 表示对我方的报告,应记录为我方"接收的信号报告"
|
|
212
|
+
strategy.context.reportReceived = msg.report;
|
|
213
|
+
// 同时预设我方准备回送给对方的报告值(常以我方测得的SNR为准)
|
|
214
|
+
strategy.context.reportSent = msgSignalReport.snr;
|
|
215
|
+
strategy.context.targetCallsign = msg.senderCallsign;
|
|
216
|
+
// 记录实际通联频率 (基础频率 + 对方信号的频率偏移)
|
|
217
|
+
// 只有当基础频率有效时(大于1MHz)才计算actualFrequency
|
|
218
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
219
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + msgSignalReport.df;
|
|
220
|
+
}
|
|
221
|
+
strategy.updateSlots();
|
|
222
|
+
return {
|
|
223
|
+
changeState: 'TX3'
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const msgRogerReport = messages
|
|
227
|
+
.filter((msg) => msg.message.type === FT8MessageType.ROGER_REPORT &&
|
|
228
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
229
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
230
|
+
.sort((a, b) => a.snr - b.snr)
|
|
231
|
+
.pop();
|
|
232
|
+
if (msgRogerReport) {
|
|
233
|
+
const msg = msgRogerReport.message;
|
|
234
|
+
strategy.logger.debug('TX1: received ROGER_REPORT, moving to TX4');
|
|
235
|
+
strategy.context.reportReceived = msg.report;
|
|
236
|
+
strategy.context.reportSent = msgRogerReport.snr;
|
|
237
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
238
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + msgRogerReport.df;
|
|
239
|
+
}
|
|
240
|
+
strategy.updateSlots();
|
|
241
|
+
return {
|
|
242
|
+
changeState: 'TX4'
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// Fox/Hound 模式:Fox 发出邀请 (PREVHOUND RR73; MYCALL <FOXHASH>)
|
|
246
|
+
// 表示 Fox 已有我们之前的信号报告,邀请我们直接发 R-report
|
|
247
|
+
const foxInvite = messages
|
|
248
|
+
.filter((msg) => msg.message.type === FT8MessageType.FOX_RR73 &&
|
|
249
|
+
callsignMatches(msg.message.nextCallsign, strategy.context.config.myCallsign))
|
|
250
|
+
.sort((a, b) => a.snr - b.snr)
|
|
251
|
+
.pop();
|
|
252
|
+
if (foxInvite) {
|
|
253
|
+
const foxMsg = foxInvite.message;
|
|
254
|
+
strategy.logger.debug(`TX1: Fox/Hound invite received (myCallsign=${strategy.context.config.myCallsign}, foxHash=${foxMsg.foxHash}, snrForNext=${foxMsg.snrForNext})`);
|
|
255
|
+
// Fox告知的SNR作为R-report基础;降级使用本机解码SNR
|
|
256
|
+
strategy.context.reportSent = foxMsg.snrForNext ?? foxInvite.snr;
|
|
257
|
+
// 本机解码Fox的SNR记为reportReceived(QSO日志用)
|
|
258
|
+
strategy.context.reportReceived = foxInvite.snr;
|
|
259
|
+
// 记录 Fox 哈希备用
|
|
260
|
+
strategy.foxHash = foxMsg.foxHash;
|
|
261
|
+
strategy.updateSlots();
|
|
262
|
+
return { changeState: 'TX3' };
|
|
263
|
+
}
|
|
264
|
+
// 【智能切换逻辑】只有当前目标没有回复时,才考虑切换到新的直接呼叫
|
|
265
|
+
// 在TX1状态(刚发出呼叫,等待信号报告)时,如果收到其他人的直接呼叫,可以切换
|
|
266
|
+
const directCalls = messages
|
|
267
|
+
.filter((msg) => (msg.message.type === FT8MessageType.CALL ||
|
|
268
|
+
msg.message.type === FT8MessageType.SIGNAL_REPORT) &&
|
|
269
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign) &&
|
|
270
|
+
!callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign)) // 排除当前目标
|
|
271
|
+
.sort((a, b) => compareCandidates(strategy, a, b));
|
|
272
|
+
if (directCalls.length > 0) {
|
|
273
|
+
const newCall = directCalls[0];
|
|
274
|
+
const msg = newCall.message;
|
|
275
|
+
// 由于filter已确保类型,这里可以安全处理
|
|
276
|
+
if (msg.type === FT8MessageType.CALL) {
|
|
277
|
+
const callMsg = msg;
|
|
278
|
+
const newCallsign = callMsg.senderCallsign;
|
|
279
|
+
// 检查是否已经通联过
|
|
280
|
+
const hasWorked = await strategy.operator.hasWorkedCallsign(newCallsign);
|
|
281
|
+
// 根据配置决定是否切换到新呼叫
|
|
282
|
+
if (!hasWorked || strategy.operator.config.replyToWorkedStations) {
|
|
283
|
+
// 检查是否有其他同呼号操作者正在通联该目标
|
|
284
|
+
const hasConflict = strategy.operator.isTargetBeingWorkedByOthers(newCallsign);
|
|
285
|
+
if (hasConflict) {
|
|
286
|
+
strategy.logger.debug(`TX1: new call ${newCallsign} conflicts with other operator working same callsign, continuing to wait for ${strategy.context.targetCallsign}`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
strategy.logger.debug(`TX1: target not replying, switching to new direct call ${newCallsign} (SNR: ${newCall.snr}dB), dropping ${strategy.context.targetCallsign}`);
|
|
290
|
+
const droppedCallsign = strategy.context.targetCallsign;
|
|
291
|
+
const qsoFailure = strategy.buildNoReplyFailure('tx1_switched_to_direct_call', Math.max(1, strategy.callAttempts + 1), droppedCallsign);
|
|
292
|
+
// 清空旧上下文(自动保存到缓存)
|
|
293
|
+
strategy.clearQSOContext();
|
|
294
|
+
// 切换到新呼号
|
|
295
|
+
strategy.beginQsoWithTarget(newCallsign, 'TX1 direct-call handoff');
|
|
296
|
+
// Restore grid/frequency cache, but always refresh SNR and clear stale received reports.
|
|
297
|
+
strategy.restoreContext(newCallsign);
|
|
298
|
+
if (callMsg.grid) {
|
|
299
|
+
strategy.context.targetGrid = callMsg.grid;
|
|
300
|
+
}
|
|
301
|
+
strategy.context.reportSent = newCall.snr;
|
|
302
|
+
strategy.context.reportReceived = undefined;
|
|
303
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
304
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + newCall.df;
|
|
305
|
+
}
|
|
306
|
+
strategy.updateSlots();
|
|
307
|
+
return { changeState: 'TX2', qsoFailure };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
strategy.logger.debug(`TX1: new call ${newCallsign} already worked and replyToWorkedStations=false, continuing to wait for ${strategy.context.targetCallsign}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (msg.type === FT8MessageType.SIGNAL_REPORT) {
|
|
315
|
+
const reportMsg = msg;
|
|
316
|
+
const newCallsign = reportMsg.senderCallsign;
|
|
317
|
+
// 检查是否已经通联过
|
|
318
|
+
const hasWorked = await strategy.operator.hasWorkedCallsign(newCallsign);
|
|
319
|
+
// 根据配置决定是否切换到新呼叫
|
|
320
|
+
if (!hasWorked || strategy.operator.config.replyToWorkedStations) {
|
|
321
|
+
// 检查是否有其他同呼号操作者正在通联该目标
|
|
322
|
+
const hasConflict = strategy.operator.isTargetBeingWorkedByOthers(newCallsign);
|
|
323
|
+
if (hasConflict) {
|
|
324
|
+
strategy.logger.debug(`TX1: new signal report ${newCallsign} conflicts with other operator, continuing to wait for ${strategy.context.targetCallsign}`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
strategy.logger.debug(`TX1: target not replying, switching to new direct signal report ${newCallsign} (SNR: ${newCall.snr}dB), dropping ${strategy.context.targetCallsign}`);
|
|
328
|
+
const droppedCallsign = strategy.context.targetCallsign;
|
|
329
|
+
const qsoFailure = strategy.buildNoReplyFailure('tx1_switched_to_direct_signal_report', Math.max(1, strategy.callAttempts + 1), droppedCallsign);
|
|
330
|
+
// 清空旧上下文(自动保存到缓存)
|
|
331
|
+
strategy.clearQSOContext();
|
|
332
|
+
// 切换到新呼号
|
|
333
|
+
strategy.beginQsoWithTarget(newCallsign, 'TX1 signal-report handoff');
|
|
334
|
+
// Restore grid/frequency cache, but always take the fresh report from this message.
|
|
335
|
+
strategy.restoreContext(newCallsign);
|
|
336
|
+
strategy.context.reportReceived = reportMsg.report;
|
|
337
|
+
strategy.context.reportSent = newCall.snr;
|
|
338
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
339
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + newCall.df;
|
|
340
|
+
}
|
|
341
|
+
strategy.updateSlots();
|
|
342
|
+
return { changeState: 'TX3', qsoFailure };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
strategy.logger.debug(`TX1: new signal report ${newCallsign} already worked and replyToWorkedStations=false, continuing to wait for ${strategy.context.targetCallsign}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {};
|
|
351
|
+
},
|
|
352
|
+
onEnter(strategy) {
|
|
353
|
+
// 每次进入TX1时重置呼叫计数器(新的呼叫开始)
|
|
354
|
+
strategy.callAttempts = 0;
|
|
355
|
+
// 记录QSO开始时间
|
|
356
|
+
strategy.qsoStartTime = Date.now();
|
|
357
|
+
},
|
|
358
|
+
onTimeout(strategy) {
|
|
359
|
+
// 增加呼叫尝试次数
|
|
360
|
+
strategy.callAttempts++;
|
|
361
|
+
strategy.logger.debug(`TX1 timeout: target=${strategy.context.targetCallsign}, attempts=${strategy.callAttempts}/${strategy.operator.config.maxCallAttempts}`);
|
|
362
|
+
// 检查是否达到最大呼叫次数
|
|
363
|
+
if (strategy.callAttempts >= strategy.operator.config.maxCallAttempts) {
|
|
364
|
+
strategy.logger.debug(`TX1 timeout: max attempts (${strategy.operator.config.maxCallAttempts}) reached, giving up on ${strategy.context.targetCallsign}`);
|
|
365
|
+
const qsoFailure = strategy.buildNoReplyFailure('tx1_max_call_attempts', Math.max(8, strategy.operator.config.maxCallAttempts + 3, strategy.callAttempts + 3));
|
|
366
|
+
// 清理QSO开始时间
|
|
367
|
+
strategy.qsoStartTime = undefined;
|
|
368
|
+
// 清理QSO上下文
|
|
369
|
+
strategy.clearQSOContext();
|
|
370
|
+
// 重置呼叫计数器
|
|
371
|
+
strategy.callAttempts = 0;
|
|
372
|
+
// QSO失败,检查是否自动恢复CQ
|
|
373
|
+
if (strategy.operator.config.autoResumeCQAfterFail) {
|
|
374
|
+
strategy.logger.debug('TX1 timeout: autoResumeCQAfterFail=true, switching to TX6');
|
|
375
|
+
return { changeState: 'TX6', qsoFailure };
|
|
376
|
+
}
|
|
377
|
+
strategy.logger.debug('TX1 timeout: autoResumeCQAfterFail=false, stopping');
|
|
378
|
+
return { changeState: 'TX6', stop: true, qsoFailure };
|
|
379
|
+
}
|
|
380
|
+
// haven't reached max attempts, continue calling (keep TX1)
|
|
381
|
+
strategy.logger.debug('TX1 timeout: max not reached, continuing call, staying in TX1');
|
|
382
|
+
return {}; // return empty object to stay in current state, continue calling
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
TX2: {
|
|
386
|
+
async handle(strategy, messages) {
|
|
387
|
+
// first wait for standard ROGER_REPORT (R-XX)
|
|
388
|
+
const msgRogerReport = messages
|
|
389
|
+
.filter((msg) => msg.message.type === FT8MessageType.ROGER_REPORT &&
|
|
390
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign) &&
|
|
391
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign))
|
|
392
|
+
.sort((a, b) => a.snr - b.snr)
|
|
393
|
+
.pop();
|
|
394
|
+
if (msgRogerReport) {
|
|
395
|
+
const msg = msgRogerReport.message;
|
|
396
|
+
strategy.logger.debug('TX2: received ROGER_REPORT, moving to TX4');
|
|
397
|
+
// Always take the report from the air message. A prior UI/default
|
|
398
|
+
// sentinel of 0 must not block updating to the real value.
|
|
399
|
+
strategy.context.reportReceived = msg.report;
|
|
400
|
+
strategy.context.reportSent = msgRogerReport.snr;
|
|
401
|
+
// 记录或更新实际通联频率 (基础频率 + 对方信号的频率偏移)
|
|
402
|
+
// 只有当基础频率有效时(大于1MHz)才计算actualFrequency
|
|
403
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
404
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + msgRogerReport.df;
|
|
405
|
+
}
|
|
406
|
+
strategy.updateSlots();
|
|
407
|
+
return {
|
|
408
|
+
changeState: 'TX4'
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
// 【容错处理】如果对方误发送了SIGNAL_REPORT而非ROGER_REPORT,也视为确认
|
|
412
|
+
// 这种情况在实际操作中可能发生(操作员误操作、软件bug等)
|
|
413
|
+
const msgSignalReport = messages
|
|
414
|
+
.filter((msg) => msg.message.type === FT8MessageType.SIGNAL_REPORT &&
|
|
415
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign) &&
|
|
416
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign))
|
|
417
|
+
.sort((a, b) => a.snr - b.snr)
|
|
418
|
+
.pop();
|
|
419
|
+
if (msgSignalReport) {
|
|
420
|
+
const msg = msgSignalReport.message;
|
|
421
|
+
strategy.logger.debug('TX2: fallback - received SIGNAL_REPORT instead of ROGER_REPORT, treating as confirmation, moving to TX4');
|
|
422
|
+
strategy.context.reportReceived = msg.report;
|
|
423
|
+
strategy.context.reportSent = msgSignalReport.snr;
|
|
424
|
+
// 记录或更新实际通联频率
|
|
425
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
426
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + msgSignalReport.df;
|
|
427
|
+
}
|
|
428
|
+
strategy.updateSlots();
|
|
429
|
+
return {
|
|
430
|
+
changeState: 'TX4'
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return {};
|
|
434
|
+
},
|
|
435
|
+
onEnter(strategy) {
|
|
436
|
+
// 如果是直接从回复开始的QSO,记录开始时间
|
|
437
|
+
if (!strategy.qsoStartTime) {
|
|
438
|
+
strategy.qsoStartTime = Date.now();
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
onTimeout(strategy) {
|
|
442
|
+
strategy.logger.debug(`TX2 timeout: target=${strategy.context.targetCallsign}, autoResumeCQAfterFail=${strategy.operator.config.autoResumeCQAfterFail}`);
|
|
443
|
+
const qsoFailure = strategy.buildProtocolTimeoutFailure('TX2');
|
|
444
|
+
// 清理QSO开始时间
|
|
445
|
+
strategy.qsoStartTime = undefined;
|
|
446
|
+
// 清理QSO上下文(会调用updateSlots,清空TX1-TX5)
|
|
447
|
+
strategy.clearQSOContext();
|
|
448
|
+
// QSO失败,检查是否自动恢复CQ
|
|
449
|
+
if (strategy.operator.config.autoResumeCQAfterFail) {
|
|
450
|
+
strategy.logger.debug('TX2 timeout: autoResumeCQAfterFail=true, switching to TX6');
|
|
451
|
+
return { changeState: 'TX6', qsoFailure };
|
|
452
|
+
}
|
|
453
|
+
strategy.logger.debug('TX2 timeout: autoResumeCQAfterFail=false, stopping');
|
|
454
|
+
return { changeState: 'TX6', stop: true, qsoFailure };
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
TX3: {
|
|
458
|
+
async handle(strategy, messages) {
|
|
459
|
+
// 等待对方发送RRR或73
|
|
460
|
+
const msgRRR = messages
|
|
461
|
+
.filter((msg) => msg.message.type === FT8MessageType.RRR &&
|
|
462
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
463
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
464
|
+
.sort((a, b) => a.snr - b.snr)
|
|
465
|
+
.pop();
|
|
466
|
+
if (msgRRR) {
|
|
467
|
+
const msg = msgRRR.message;
|
|
468
|
+
if (msg.type === FT8MessageType.RRR) {
|
|
469
|
+
// 如果是RRR消息,直接转换到TX5
|
|
470
|
+
strategy.updateSlots();
|
|
471
|
+
return {
|
|
472
|
+
changeState: 'TX5'
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
strategy.context.reportReceived = msg.report;
|
|
477
|
+
strategy.context.reportSent = msgRRR.snr;
|
|
478
|
+
strategy.updateSlots();
|
|
479
|
+
return {
|
|
480
|
+
changeState: 'TX5'
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const msg73 = messages
|
|
485
|
+
.filter((msg) => msg.message.type === FT8MessageType.SEVENTY_THREE &&
|
|
486
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
487
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
488
|
+
.sort((a, b) => a.snr - b.snr)
|
|
489
|
+
.pop();
|
|
490
|
+
if (msg73) {
|
|
491
|
+
strategy.logger.debug('TX3: received 73, moving to TX5');
|
|
492
|
+
strategy.updateSlots();
|
|
493
|
+
return {
|
|
494
|
+
changeState: 'TX5'
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// Fox/Hound 模式:Fox 发送 FOX_RR73 确认我们的 QSO 完成
|
|
498
|
+
// 格式:MYCALL RR73; NEXTHOUND <FOXHASH>
|
|
499
|
+
const foxRR73Confirm = messages
|
|
500
|
+
.filter((msg) => msg.message.type === FT8MessageType.FOX_RR73 &&
|
|
501
|
+
callsignMatches(msg.message.completedCallsign, strategy.context.config.myCallsign))
|
|
502
|
+
.sort((a, b) => a.snr - b.snr)
|
|
503
|
+
.pop();
|
|
504
|
+
if (foxRR73Confirm) {
|
|
505
|
+
strategy.logger.debug(`TX3: Fox/Hound QSO confirmed via FOX_RR73 (myCallsign=${strategy.context.config.myCallsign})`);
|
|
506
|
+
strategy.updateSlots();
|
|
507
|
+
return { changeState: 'TX5' };
|
|
508
|
+
}
|
|
509
|
+
// 【新增】容错处理:如果对方继续发送SIGNAL_REPORT,更新信号报告
|
|
510
|
+
const msgSignalReport = messages
|
|
511
|
+
.filter((msg) => msg.message.type === FT8MessageType.SIGNAL_REPORT &&
|
|
512
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign) &&
|
|
513
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign))
|
|
514
|
+
.sort((a, b) => compareCandidates(strategy, a, b))
|
|
515
|
+
.shift(); // 取第一个(SNR最高的)
|
|
516
|
+
if (msgSignalReport) {
|
|
517
|
+
const msg = msgSignalReport.message;
|
|
518
|
+
strategy.logger.debug(`TX3: fallback - received repeated SIGNAL_REPORT (SNR: ${msgSignalReport.snr}dB), updating report`);
|
|
519
|
+
strategy.context.reportReceived = msg.report;
|
|
520
|
+
// 更新我方准备发送的报告(使用最新的SNR)
|
|
521
|
+
strategy.context.reportSent = msgSignalReport.snr;
|
|
522
|
+
// 更新实际通联频率
|
|
523
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
524
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + msgSignalReport.df;
|
|
525
|
+
}
|
|
526
|
+
strategy.updateSlots();
|
|
527
|
+
}
|
|
528
|
+
return {};
|
|
529
|
+
},
|
|
530
|
+
onTimeout(strategy) {
|
|
531
|
+
strategy.logger.debug(`TX3 timeout: target=${strategy.context.targetCallsign}, autoResumeCQAfterFail=${strategy.operator.config.autoResumeCQAfterFail}`);
|
|
532
|
+
const qsoFailure = strategy.buildProtocolTimeoutFailure('TX3');
|
|
533
|
+
// 清理QSO开始时间
|
|
534
|
+
strategy.qsoStartTime = undefined;
|
|
535
|
+
// 清理QSO上下文
|
|
536
|
+
strategy.clearQSOContext();
|
|
537
|
+
// QSO失败,检查是否自动恢复CQ
|
|
538
|
+
if (strategy.operator.config.autoResumeCQAfterFail) {
|
|
539
|
+
strategy.logger.debug('TX3 timeout: autoResumeCQAfterFail=true, switching to TX6');
|
|
540
|
+
return { changeState: 'TX6', qsoFailure };
|
|
541
|
+
}
|
|
542
|
+
strategy.logger.debug('TX3 timeout: autoResumeCQAfterFail=false, stopping');
|
|
543
|
+
return { changeState: 'TX6', stop: true, qsoFailure };
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
TX4: {
|
|
547
|
+
onEnter(strategy) {
|
|
548
|
+
strategy.prepareCompletedQSOEffect();
|
|
549
|
+
},
|
|
550
|
+
async handle(strategy, messages) {
|
|
551
|
+
// 首先检查是否收到对方的73
|
|
552
|
+
const msg73 = messages
|
|
553
|
+
.filter((msg) => msg.message.type === FT8MessageType.SEVENTY_THREE &&
|
|
554
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
555
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
556
|
+
.sort((a, b) => a.snr - b.snr)
|
|
557
|
+
.pop();
|
|
558
|
+
if (msg73) {
|
|
559
|
+
// 对方发送了73,QSO已完成
|
|
560
|
+
strategy.logger.debug('TX4: received 73, QSO complete');
|
|
561
|
+
const completedCallsign = strategy.context.targetCallsign;
|
|
562
|
+
const handoff = await trySwitchToDirectedCall(strategy, messages, {
|
|
563
|
+
excludeCallsigns: [completedCallsign],
|
|
564
|
+
logPrefix: 'TX4: QSO done',
|
|
565
|
+
});
|
|
566
|
+
if (handoff) {
|
|
567
|
+
return handoff;
|
|
568
|
+
}
|
|
569
|
+
// 没有新的直接呼叫,转到TX6
|
|
570
|
+
strategy.logger.debug('TX4: no new direct calls, switching to TX6');
|
|
571
|
+
strategy.clearQSOContext();
|
|
572
|
+
if (strategy.operator.config.autoResumeCQAfterSuccess) {
|
|
573
|
+
return { changeState: 'TX6' };
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
changeState: 'TX6',
|
|
577
|
+
stop: true,
|
|
578
|
+
silentListen: buildSuccessSilentListen(completedCallsign),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
// 其次检查是否收到对方的 RRR/RR73
|
|
582
|
+
const msgRRR = messages
|
|
583
|
+
.filter((msg) => msg.message.type === FT8MessageType.RRR &&
|
|
584
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
585
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
586
|
+
.sort((a, b) => a.snr - b.snr)
|
|
587
|
+
.pop();
|
|
588
|
+
if (msgRRR) {
|
|
589
|
+
// 对方也发送了 RR73,我们应该发送 73 结束通联
|
|
590
|
+
strategy.updateSlots();
|
|
591
|
+
return {
|
|
592
|
+
changeState: 'TX5'
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
// 不处理新消息,等待超时后再转到TX6
|
|
596
|
+
// 这样可以确保优先完成当前QSO
|
|
597
|
+
return {};
|
|
598
|
+
},
|
|
599
|
+
onTimeout(strategy) {
|
|
600
|
+
strategy.logger.debug(`TX4 timeout: target=${strategy.context.targetCallsign}, autoResumeCQAfterFail=${strategy.operator.config.autoResumeCQAfterFail}`);
|
|
601
|
+
const qsoFailure = strategy.buildProtocolTimeoutFailure('TX4');
|
|
602
|
+
// 清理QSO开始时间
|
|
603
|
+
strategy.qsoStartTime = undefined;
|
|
604
|
+
// 清理QSO上下文
|
|
605
|
+
strategy.clearQSOContext();
|
|
606
|
+
// QSO失败,检查是否自动恢复CQ
|
|
607
|
+
if (strategy.operator.config.autoResumeCQAfterFail) {
|
|
608
|
+
strategy.logger.debug('TX4 timeout: autoResumeCQAfterFail=true, switching to TX6');
|
|
609
|
+
return { changeState: 'TX6', qsoFailure };
|
|
610
|
+
}
|
|
611
|
+
strategy.logger.debug('TX4 timeout: autoResumeCQAfterFail=false, stopping');
|
|
612
|
+
return { changeState: 'TX6', stop: true, qsoFailure };
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
TX5: {
|
|
616
|
+
onEnter(strategy) {
|
|
617
|
+
strategy.prepareCompletedQSOEffect();
|
|
618
|
+
},
|
|
619
|
+
async handle(strategy, messages) {
|
|
620
|
+
// 【修复】首先检查是否收到对方重发的RRR/RR73
|
|
621
|
+
// 如果对方没收到我们的73,会重新发送RRR,我们应该保持在TX5状态继续发送73
|
|
622
|
+
const msgRRR = messages
|
|
623
|
+
.filter((msg) => msg.message.type === FT8MessageType.RRR &&
|
|
624
|
+
callsignMatches(msg.message.senderCallsign, strategy.context.targetCallsign) &&
|
|
625
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
626
|
+
.sort((a, b) => a.snr - b.snr)
|
|
627
|
+
.pop();
|
|
628
|
+
if (msgRRR) {
|
|
629
|
+
// 对方没收到我们的73,重新发送了RRR
|
|
630
|
+
// 保持在TX5状态,下个周期再次发送73
|
|
631
|
+
strategy.logger.debug('TX5: received retransmitted RRR, staying in TX5 to resend 73');
|
|
632
|
+
return {}; // 保持当前状态,不转换
|
|
633
|
+
}
|
|
634
|
+
if (!strategy.tx5TransmissionQueued) {
|
|
635
|
+
strategy.logger.debug('TX5: 73 has not been queued yet, staying in TX5');
|
|
636
|
+
return {};
|
|
637
|
+
}
|
|
638
|
+
// 发送1次73后,检查是否有新的直接呼叫;否则转到TX6或静默停止。
|
|
639
|
+
const completedCallsign = strategy.context.targetCallsign;
|
|
640
|
+
const handoff = await trySwitchToDirectedCall(strategy, messages, {
|
|
641
|
+
excludeCallsigns: [completedCallsign],
|
|
642
|
+
logPrefix: 'TX5',
|
|
643
|
+
clearPost73Reason: 'switching to new direct call from TX5',
|
|
644
|
+
});
|
|
645
|
+
if (handoff) {
|
|
646
|
+
return handoff;
|
|
647
|
+
}
|
|
648
|
+
// 没有新的直接呼叫,按配置决定是否恢复到CQ或停止
|
|
649
|
+
if (strategy.operator.config.autoResumeCQAfterSuccess) {
|
|
650
|
+
strategy.armPost73RetryContext();
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
strategy.clearPost73RetryContext('success stop after single 73');
|
|
654
|
+
}
|
|
655
|
+
strategy.clearQSOContext();
|
|
656
|
+
if (strategy.operator.config.autoResumeCQAfterSuccess) {
|
|
657
|
+
return { changeState: 'TX6' };
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
changeState: 'TX6',
|
|
661
|
+
stop: true,
|
|
662
|
+
silentListen: buildSuccessSilentListen(completedCallsign),
|
|
663
|
+
};
|
|
664
|
+
},
|
|
665
|
+
onTimeout(strategy) {
|
|
666
|
+
strategy.logger.debug(`TX5 timeout: target=${strategy.context.targetCallsign}, autoResumeCQAfterSuccess=${strategy.operator.config.autoResumeCQAfterSuccess}`);
|
|
667
|
+
if (!strategy.tx5TransmissionQueued) {
|
|
668
|
+
strategy.logger.debug('TX5 timeout: 73 has not been queued yet, staying in TX5');
|
|
669
|
+
return {};
|
|
670
|
+
}
|
|
671
|
+
const completedCallsign = strategy.context.targetCallsign;
|
|
672
|
+
// 清理QSO上下文
|
|
673
|
+
if (strategy.operator.config.autoResumeCQAfterSuccess) {
|
|
674
|
+
strategy.armPost73RetryContext();
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
strategy.clearPost73RetryContext('success timeout stop');
|
|
678
|
+
}
|
|
679
|
+
strategy.clearQSOContext();
|
|
680
|
+
// TX5超时表示QSO已成功(已记录日志),对方没有回复73
|
|
681
|
+
// 检查是否自动恢复CQ
|
|
682
|
+
if (strategy.operator.config.autoResumeCQAfterSuccess) {
|
|
683
|
+
strategy.logger.debug('TX5 timeout: autoResumeCQAfterSuccess=true, switching to TX6');
|
|
684
|
+
return { changeState: 'TX6' };
|
|
685
|
+
}
|
|
686
|
+
strategy.logger.debug('TX5 timeout: autoResumeCQAfterSuccess=false, stopping');
|
|
687
|
+
return {
|
|
688
|
+
changeState: 'TX6',
|
|
689
|
+
stop: true,
|
|
690
|
+
silentListen: buildSuccessSilentListen(completedCallsign),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
TX6: {
|
|
695
|
+
async handle(strategy, messages) {
|
|
696
|
+
const retryContext = strategy.getActivePost73RetryContext();
|
|
697
|
+
if (retryContext) {
|
|
698
|
+
const retryRRR = messages
|
|
699
|
+
.filter((msg) => msg.message.type === FT8MessageType.RRR &&
|
|
700
|
+
callsignMatches(msg.message.senderCallsign, retryContext.targetCallsign) &&
|
|
701
|
+
callsignMatches(msg.message.targetCallsign, strategy.context.config.myCallsign))
|
|
702
|
+
.sort((a, b) => a.snr - b.snr)
|
|
703
|
+
.pop();
|
|
704
|
+
if (retryRRR) {
|
|
705
|
+
strategy.logger.debug(`TX6: resuming post-73 retry for ${retryContext.targetCallsign}`);
|
|
706
|
+
strategy.restorePost73RetryContext(retryContext);
|
|
707
|
+
strategy.clearPost73RetryContext('resumed into TX5');
|
|
708
|
+
return { changeState: 'TX5' };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// 收集所有CQ消息(部分解码的 `CQ <...>` 不参与回复)
|
|
712
|
+
const cqCalls = messages
|
|
713
|
+
.filter((msg) => !msg.isPartialDecode &&
|
|
714
|
+
msg.message.type === FT8MessageType.CQ &&
|
|
715
|
+
strategy.operator.config.autoReplyToCQ)
|
|
716
|
+
.sort((a, b) => a.snr - b.snr);
|
|
717
|
+
// 优先处理直接呼叫 - 遍历所有直接呼叫,找到第一个没有冲突的
|
|
718
|
+
const directHandoff = await trySwitchToDirectedProtocol(strategy, messages, {
|
|
719
|
+
logPrefix: 'TX6',
|
|
720
|
+
acceptFollowUpProtocol: true,
|
|
721
|
+
});
|
|
722
|
+
if (directHandoff) {
|
|
723
|
+
return directHandoff;
|
|
724
|
+
}
|
|
725
|
+
// 其次处理CQ呼叫
|
|
726
|
+
if (cqCalls.length > 0) {
|
|
727
|
+
// 始终按信号强度从高到低排序,遍历找到第一个未通联过的电台
|
|
728
|
+
const sortedCalls = cqCalls.sort((a, b) => compareCandidates(strategy, a, b));
|
|
729
|
+
for (const cqCall of sortedCalls) {
|
|
730
|
+
const msg = cqCall.message;
|
|
731
|
+
const callsign = msg.senderCallsign;
|
|
732
|
+
if (isUndecodedCallsignPlaceholder(callsign)) {
|
|
733
|
+
strategy.logger.debug(`TX6: skipping CQ from undecoded placeholder ${callsign}`);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
// 检查是否已经通联过
|
|
738
|
+
const hasWorked = await strategy.operator.hasWorkedCallsign(callsign);
|
|
739
|
+
// CQ呼叫只回复未通联过的电台(不受replyToWorkedStations配置影响)
|
|
740
|
+
if (!hasWorked) {
|
|
741
|
+
// 检查是否有其他同呼号操作者正在通联该目标
|
|
742
|
+
const hasConflict = strategy.operator.isTargetBeingWorkedByOthers(callsign);
|
|
743
|
+
if (hasConflict) {
|
|
744
|
+
strategy.logger.debug(`TX6: skipping CQ from ${callsign} - other operator conflict (SNR: ${cqCall.snr}dB)`);
|
|
745
|
+
continue; // 跳过这个CQ,继续遍历下一个
|
|
746
|
+
}
|
|
747
|
+
strategy.logger.debug(`TX6: replying to CQ from ${callsign} (not worked, SNR: ${cqCall.snr}dB, by signal strength)`);
|
|
748
|
+
if (strategy.hasUnsettledQSOCompletion()) {
|
|
749
|
+
strategy.logger.debug(`TX6: delaying CQ reply to ${callsign} until QSO durability settles`);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
strategy.beginQsoWithTarget(callsign, 'TX6 CQ reply');
|
|
753
|
+
// Restore grid/frequency cache, but always refresh SNR and clear stale received reports.
|
|
754
|
+
strategy.restoreContext(callsign);
|
|
755
|
+
if (msg.grid) {
|
|
756
|
+
strategy.context.targetGrid = msg.grid;
|
|
757
|
+
}
|
|
758
|
+
strategy.context.reportSent = cqCall.snr;
|
|
759
|
+
strategy.context.reportReceived = undefined;
|
|
760
|
+
if (strategy.context.config.frequency && strategy.context.config.frequency > 1000000) {
|
|
761
|
+
strategy.context.actualFrequency = strategy.context.config.frequency + cqCall.df;
|
|
762
|
+
}
|
|
763
|
+
strategy.updateSlots();
|
|
764
|
+
return { changeState: strategy.getInitialOutboundCallState() };
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
strategy.logger.debug(`TX6: skipping CQ from ${callsign} - already worked (SNR: ${cqCall.snr})`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
strategy.logger.error(`TX6: failed to check callsign ${callsign}:`, error);
|
|
772
|
+
// 如果检查失败,跳过这个呼号
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return {};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
export class StandardQSOPluginRuntime {
|
|
782
|
+
constructor(operator, logger) {
|
|
783
|
+
this.state = 'TX6';
|
|
784
|
+
this.slots = {
|
|
785
|
+
TX1: '',
|
|
786
|
+
TX2: '',
|
|
787
|
+
TX3: '',
|
|
788
|
+
TX4: '',
|
|
789
|
+
TX5: '',
|
|
790
|
+
TX6: '',
|
|
791
|
+
};
|
|
792
|
+
this.tx6MessageOverride = '';
|
|
793
|
+
this.timeoutCycles = 0;
|
|
794
|
+
this.callAttempts = 0; // 呼叫尝试次数计数器(TX1状态专用)
|
|
795
|
+
this.tx5TransmissionQueued = false;
|
|
796
|
+
this.qsoLifecycleEpoch = 0;
|
|
797
|
+
this.speculativeDecisionDepth = 0;
|
|
798
|
+
// QSO上下文历史缓存(呼号 -> 上下文)
|
|
799
|
+
this.qsoContextHistory = new Map();
|
|
800
|
+
// 最大缓存数量,避免内存泄漏
|
|
801
|
+
this.MAX_CONTEXT_CACHE = 100;
|
|
802
|
+
this.operator = operator;
|
|
803
|
+
this.logger = logger ?? fallbackLogger;
|
|
804
|
+
this._context = {
|
|
805
|
+
config: operator.config
|
|
806
|
+
};
|
|
807
|
+
this.updateSlots();
|
|
808
|
+
}
|
|
809
|
+
syncOperatorConfig() {
|
|
810
|
+
const nextConfig = this.operator.config;
|
|
811
|
+
const previousConfig = this._context.config;
|
|
812
|
+
const nextDefaultTx6Message = buildStandardQSODefaultTx6Message(nextConfig);
|
|
813
|
+
const hasConfigTx6MessageOverride = Object.prototype.hasOwnProperty.call(nextConfig, STANDARD_QSO_TX6_MESSAGE_OVERRIDE_SETTING);
|
|
814
|
+
const nextConfigTx6MessageOverride = hasConfigTx6MessageOverride
|
|
815
|
+
? normalizeStandardQSOTx6MessageOverride(nextConfig.tx6MessageOverride, nextDefaultTx6Message)
|
|
816
|
+
: undefined;
|
|
817
|
+
const nextTx6MessageOverride = hasConfigTx6MessageOverride && nextConfigTx6MessageOverride !== this.lastConfigTx6MessageOverride
|
|
818
|
+
? nextConfigTx6MessageOverride ?? ''
|
|
819
|
+
: this.tx6MessageOverride;
|
|
820
|
+
const shouldRegenerateSlots = previousConfig.myCallsign !== nextConfig.myCallsign ||
|
|
821
|
+
previousConfig.myGrid !== nextConfig.myGrid ||
|
|
822
|
+
this.tx6MessageOverride !== nextTx6MessageOverride;
|
|
823
|
+
this._context = {
|
|
824
|
+
...this._context,
|
|
825
|
+
config: nextConfig,
|
|
826
|
+
};
|
|
827
|
+
this.tx6MessageOverride = nextTx6MessageOverride;
|
|
828
|
+
if (hasConfigTx6MessageOverride) {
|
|
829
|
+
this.lastConfigTx6MessageOverride = nextConfigTx6MessageOverride ?? '';
|
|
830
|
+
}
|
|
831
|
+
if (shouldRegenerateSlots) {
|
|
832
|
+
this.updateSlots();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
get context() {
|
|
836
|
+
this.syncOperatorConfig();
|
|
837
|
+
return this._context;
|
|
838
|
+
}
|
|
839
|
+
buildNoReplyFailure(reason, unansweredTransmissions, targetCallsign = this.context.targetCallsign) {
|
|
840
|
+
if (!targetCallsign) {
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
targetCallsign,
|
|
845
|
+
reason,
|
|
846
|
+
stage: 'TX1',
|
|
847
|
+
unansweredTransmissions,
|
|
848
|
+
hadTargetReply: false,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
buildProtocolTimeoutFailure(stage) {
|
|
852
|
+
const targetCallsign = this.context.targetCallsign;
|
|
853
|
+
if (!targetCallsign)
|
|
854
|
+
return undefined;
|
|
855
|
+
return {
|
|
856
|
+
targetCallsign,
|
|
857
|
+
reason: `${stage.toLowerCase()}_timeout`,
|
|
858
|
+
stage,
|
|
859
|
+
hadTargetReply: true,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
async changeState(state) {
|
|
863
|
+
const oldState = this.state;
|
|
864
|
+
this.state = state;
|
|
865
|
+
this.timeoutCycles = 0;
|
|
866
|
+
this.tx5TransmissionQueued = false;
|
|
867
|
+
if (state === 'TX1' || state === 'TX2' || state === 'TX3' || state === 'TX4') {
|
|
868
|
+
this.clearPost73RetryContext(`state changed to ${state}`);
|
|
869
|
+
}
|
|
870
|
+
// 从TX1转换到其他状态时,重置呼叫计数器(表示已成功建立通联)
|
|
871
|
+
if (oldState === 'TX1' && state !== 'TX1') {
|
|
872
|
+
this.callAttempts = 0;
|
|
873
|
+
}
|
|
874
|
+
// 状态变化时通知槽位更新
|
|
875
|
+
if (oldState !== this.state) {
|
|
876
|
+
this.notifyStateChanged();
|
|
877
|
+
}
|
|
878
|
+
// 调用新状态的onEnter
|
|
879
|
+
const newState = states[this.state];
|
|
880
|
+
if (newState.onEnter) {
|
|
881
|
+
await newState.onEnter(this);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
prepareCompletedQSOEffect() {
|
|
885
|
+
if (this.completedQSORecord || !this.context.targetCallsign) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
const frequency = (this.context.actualFrequency && this.context.actualFrequency > 1_000_000)
|
|
890
|
+
? this.context.actualFrequency
|
|
891
|
+
: (this.context.config.frequency || 0);
|
|
892
|
+
const record = {
|
|
893
|
+
id: randomUUID(),
|
|
894
|
+
callsign: this.context.targetCallsign,
|
|
895
|
+
grid: this.context.targetGrid,
|
|
896
|
+
frequency,
|
|
897
|
+
mode: this.context.config.mode.name,
|
|
898
|
+
startTime: this.qsoStartTime || now,
|
|
899
|
+
endTime: now,
|
|
900
|
+
reportSent: this.context.reportSent?.toString(),
|
|
901
|
+
reportReceived: this.context.reportReceived?.toString(),
|
|
902
|
+
messageHistory: [],
|
|
903
|
+
comment: undefined,
|
|
904
|
+
myCallsign: this.context.config.myCallsign,
|
|
905
|
+
myGrid: this.context.config.myGrid,
|
|
906
|
+
};
|
|
907
|
+
this.completedQSORecord = record;
|
|
908
|
+
this.pendingQSOCompletion = {
|
|
909
|
+
record,
|
|
910
|
+
lifecycleEpoch: this.qsoLifecycleEpoch,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
beginQsoLifecycle(reason) {
|
|
914
|
+
this.qsoLifecycleEpoch += 1;
|
|
915
|
+
this.completedQSORecord = undefined;
|
|
916
|
+
this.logger.debug('Started QSO lifecycle', {
|
|
917
|
+
epoch: this.qsoLifecycleEpoch,
|
|
918
|
+
reason,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
hasUnsettledQSOCompletion() {
|
|
922
|
+
return this.completedQSORecord !== undefined;
|
|
923
|
+
}
|
|
924
|
+
beginQsoWithTarget(callsign, reason) {
|
|
925
|
+
if (!callsignMatches(callsign, this.context.targetCallsign)) {
|
|
926
|
+
this.saveCurrentContext();
|
|
927
|
+
this.beginQsoLifecycle(reason);
|
|
928
|
+
this.context.targetGrid = undefined;
|
|
929
|
+
this.context.reportSent = undefined;
|
|
930
|
+
this.context.reportReceived = undefined;
|
|
931
|
+
this.context.actualFrequency = undefined;
|
|
932
|
+
}
|
|
933
|
+
this.context.targetCallsign = callsign;
|
|
934
|
+
}
|
|
935
|
+
settleQSOCompletion(settlement) {
|
|
936
|
+
if (settlement.lifecycleEpoch !== this.qsoLifecycleEpoch
|
|
937
|
+
|| settlement.recordId !== this.completedQSORecord?.id) {
|
|
938
|
+
this.logger.debug('Ignored stale QSO completion settlement', {
|
|
939
|
+
currentEpoch: this.qsoLifecycleEpoch,
|
|
940
|
+
settledEpoch: settlement.lifecycleEpoch,
|
|
941
|
+
recordId: settlement.recordId,
|
|
942
|
+
status: settlement.status,
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (settlement.status === 'committed') {
|
|
947
|
+
this.completedQSORecord = undefined;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async handleReceivedAndDicideNext(messages, options) {
|
|
951
|
+
const currentState = states[this.state];
|
|
952
|
+
// 过滤掉发送者是我自己的消息;部分解码消息(含 `<...>` 占位符)绝不参与 QSO 决策
|
|
953
|
+
const filteredMessages = messages.filter((msg) => !msg.isPartialDecode && (msg.message.type == FT8MessageType.CUSTOM || msg.message.type == FT8MessageType.UNKNOWN || msg.message.type == FT8MessageType.FOX_RR73 || !callsignMatches(msg.message.senderCallsign, this.operator.config.myCallsign)));
|
|
954
|
+
// 处理接收到的消息
|
|
955
|
+
const result = await currentState.handle(this, filteredMessages);
|
|
956
|
+
// 如果状态需要改变
|
|
957
|
+
if (result.changeState) {
|
|
958
|
+
/* if (result.changeState !== 'TX6') {
|
|
959
|
+
this.operator.start(); // 启动发射
|
|
960
|
+
} */
|
|
961
|
+
try {
|
|
962
|
+
await this.changeState(result.changeState);
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
return { stop: true };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
else if (!options?.isReDecision) {
|
|
969
|
+
// 增加超时计数(重决策时跳过,避免虚假超时累加)
|
|
970
|
+
this.timeoutCycles++;
|
|
971
|
+
// 检查是否超时
|
|
972
|
+
if (this.timeoutCycles >= this.operator.config.maxQSOTimeoutCycles) {
|
|
973
|
+
this.logger.debug(`Timeout count reached limit (${this.timeoutCycles}/${this.operator.config.maxQSOTimeoutCycles}), state=${this.state}, triggering timeout handler`);
|
|
974
|
+
if (currentState.onTimeout) {
|
|
975
|
+
const timeoutResult = currentState.onTimeout(this);
|
|
976
|
+
this.logger.debug('Timeout handler result:', { ...timeoutResult });
|
|
977
|
+
// 使用 changeState() 方法进行状态转换,确保完整的状态转换流程
|
|
978
|
+
if (timeoutResult.changeState) {
|
|
979
|
+
this.logger.debug(`State transition after timeout: ${this.state} -> ${timeoutResult.changeState}`);
|
|
980
|
+
try {
|
|
981
|
+
await this.changeState(timeoutResult.changeState);
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
return { stop: true };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (timeoutResult.stop) {
|
|
988
|
+
this.logger.debug('Stopping operator after timeout');
|
|
989
|
+
return {
|
|
990
|
+
stop: true,
|
|
991
|
+
silentListen: timeoutResult.silentListen,
|
|
992
|
+
qsoFailure: timeoutResult.qsoFailure,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (timeoutResult.qsoFailure) {
|
|
996
|
+
return {
|
|
997
|
+
qsoFailure: timeoutResult.qsoFailure,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
stop: result.stop,
|
|
1005
|
+
silentListen: result.silentListen,
|
|
1006
|
+
qsoFailure: result.qsoFailure,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
handleTransmitSlot() {
|
|
1010
|
+
return this.slots[this.state];
|
|
1011
|
+
}
|
|
1012
|
+
onTransmissionQueued(transmission) {
|
|
1013
|
+
// The host invokes this compatibility hook only after the physical
|
|
1014
|
+
// frame reaches terminal success, never when encoding is merely queued.
|
|
1015
|
+
if (this.state === 'TX5' && transmission === this.slots.TX5) {
|
|
1016
|
+
this.tx5TransmissionQueued = true;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
getInitialOutboundCallState() {
|
|
1020
|
+
return this.operator.config.skipTx1 === true ? 'TX2' : 'TX1';
|
|
1021
|
+
}
|
|
1022
|
+
activateTarget(request) {
|
|
1023
|
+
const accepted = this.requestCallInternal(request.callsign, request.lastMessage);
|
|
1024
|
+
return {
|
|
1025
|
+
accepted,
|
|
1026
|
+
lifecycleEpoch: this.qsoLifecycleEpoch,
|
|
1027
|
+
initialSlot: accepted ? this.state : undefined,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
requestCall(callsign, lastMessage) {
|
|
1031
|
+
return this.activateTarget({ callsign, lastMessage }).accepted;
|
|
1032
|
+
}
|
|
1033
|
+
requestCallInternal(callsign, lastMessage) {
|
|
1034
|
+
if (this.hasUnsettledQSOCompletion()) {
|
|
1035
|
+
this.logger.warn('requestCall ignored while QSO durability is unsettled', { callsign });
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
this.syncOperatorConfig();
|
|
1039
|
+
if (!this.canAcceptTargetCallsign(callsign, 'requestCall')) {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
this.logger.debug(`requestCall: myCallsign=${this.operator.config.myCallsign}, target=${callsign}`, lastMessage);
|
|
1043
|
+
this.clearPost73RetryContext('manual requestCall');
|
|
1044
|
+
if (!lastMessage) {
|
|
1045
|
+
this.beginQsoWithTarget(callsign, 'manual requestCall');
|
|
1046
|
+
this.updateSlots();
|
|
1047
|
+
this.changeStateSafely(this.getInitialOutboundCallState()); // 呼叫他
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
this.beginQsoWithTarget(callsign, 'manual requestCall with decoded message');
|
|
1051
|
+
this.context.reportSent = lastMessage.message.snr;
|
|
1052
|
+
const msg = FT8MessageParser.parseMessage(lastMessage.message.message);
|
|
1053
|
+
const parsedMessage = {
|
|
1054
|
+
message: msg,
|
|
1055
|
+
snr: lastMessage.message.snr,
|
|
1056
|
+
dt: lastMessage.message.dt,
|
|
1057
|
+
df: lastMessage.message.freq,
|
|
1058
|
+
rawMessage: lastMessage.message.message,
|
|
1059
|
+
slotId: lastMessage.slotInfo.id,
|
|
1060
|
+
timestamp: lastMessage.slotInfo.startMs
|
|
1061
|
+
};
|
|
1062
|
+
if ((msg.type === FT8MessageType.CQ || msg.type === FT8MessageType.CALL)
|
|
1063
|
+
&& callsignMatches(msg.senderCallsign, callsign)) {
|
|
1064
|
+
this.context.targetGrid = msg.grid;
|
|
1065
|
+
}
|
|
1066
|
+
if (msg.type === FT8MessageType.UNKNOWN || msg.type === FT8MessageType.CUSTOM) {
|
|
1067
|
+
this.updateSlots();
|
|
1068
|
+
this.changeStateSafely(this.getInitialOutboundCallState()); // 呼叫他
|
|
1069
|
+
return true;
|
|
1070
|
+
}
|
|
1071
|
+
// 包含 targetCallsign 的消息
|
|
1072
|
+
if (msg.type === FT8MessageType.SIGNAL_REPORT || msg.type === FT8MessageType.CALL || msg.type === FT8MessageType.ROGER_REPORT || msg.type === FT8MessageType.RRR || msg.type === FT8MessageType.SEVENTY_THREE) {
|
|
1073
|
+
if (callsignMatches(msg.targetCallsign, this._context.config.myCallsign)) {
|
|
1074
|
+
// 消息是发给我的,直接转到对应的回复状态
|
|
1075
|
+
if (msg.type === FT8MessageType.CALL) {
|
|
1076
|
+
// 对方呼叫我,回复信号报告
|
|
1077
|
+
// 记录实际通联频率
|
|
1078
|
+
if (this.context.config.frequency && this.context.config.frequency > 1000000) {
|
|
1079
|
+
this.context.actualFrequency = this.context.config.frequency + parsedMessage.df;
|
|
1080
|
+
}
|
|
1081
|
+
this.updateSlots();
|
|
1082
|
+
this.changeStateSafely('TX2'); // 下周期发送 SIGNAL_REPORT
|
|
1083
|
+
this.logger.debug('requestCall: received CALL, switching to TX2');
|
|
1084
|
+
}
|
|
1085
|
+
else if (msg.type === FT8MessageType.SIGNAL_REPORT) {
|
|
1086
|
+
// 对方发了信号报告,回复 ROGER_REPORT
|
|
1087
|
+
const reportMsg = msg;
|
|
1088
|
+
this.context.reportReceived = reportMsg.report;
|
|
1089
|
+
// 记录实际通联频率
|
|
1090
|
+
if (this.context.config.frequency && this.context.config.frequency > 1000000) {
|
|
1091
|
+
this.context.actualFrequency = this.context.config.frequency + parsedMessage.df;
|
|
1092
|
+
}
|
|
1093
|
+
this.updateSlots();
|
|
1094
|
+
this.changeStateSafely('TX3'); // 下周期发送 ROGER_REPORT
|
|
1095
|
+
this.logger.debug('requestCall: received SIGNAL_REPORT, switching to TX3');
|
|
1096
|
+
}
|
|
1097
|
+
else if (msg.type === FT8MessageType.ROGER_REPORT) {
|
|
1098
|
+
// 对方发了 ROGER_REPORT,回复 RR73
|
|
1099
|
+
const rogerMsg = msg;
|
|
1100
|
+
// Always take the report from the air message.
|
|
1101
|
+
this.context.reportReceived = rogerMsg.report;
|
|
1102
|
+
// 记录实际通联频率
|
|
1103
|
+
if (this.context.config.frequency && this.context.config.frequency > 1000000) {
|
|
1104
|
+
this.context.actualFrequency = this.context.config.frequency + parsedMessage.df;
|
|
1105
|
+
}
|
|
1106
|
+
this.updateSlots();
|
|
1107
|
+
this.changeStateSafely('TX4'); // 下周期发送 RR73
|
|
1108
|
+
this.logger.debug('requestCall: received ROGER_REPORT, switching to TX4');
|
|
1109
|
+
}
|
|
1110
|
+
else if (msg.type === FT8MessageType.RRR) {
|
|
1111
|
+
// 对方发了 RRR,回复 73
|
|
1112
|
+
this.updateSlots();
|
|
1113
|
+
this.changeStateSafely('TX5'); // 下周期发送 73
|
|
1114
|
+
this.logger.debug('requestCall: received RRR, switching to TX5');
|
|
1115
|
+
}
|
|
1116
|
+
else if (msg.type === FT8MessageType.SEVENTY_THREE) {
|
|
1117
|
+
// 对方发了 73,QSO 完成,转到待机
|
|
1118
|
+
this.updateSlots();
|
|
1119
|
+
this.changeStateSafely('TX6'); // 待机
|
|
1120
|
+
this.logger.debug('requestCall: received 73, switching to TX6 (standby)');
|
|
1121
|
+
}
|
|
1122
|
+
// 不再调用 handleReceivedAndDicideNext,避免状态机二次处理
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
// 和我无关,那么就正常CQ他
|
|
1127
|
+
this.updateSlots();
|
|
1128
|
+
this.changeStateSafely(this.getInitialOutboundCallState()); // 呼叫他
|
|
1129
|
+
}
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
// 不包含 targetCallsign 的消息
|
|
1133
|
+
this.updateSlots();
|
|
1134
|
+
this.changeStateSafely(this.getInitialOutboundCallState()); // 呼叫他
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
checkpoint() {
|
|
1138
|
+
const { config: _config, ...context } = this._context;
|
|
1139
|
+
const checkpoint = {
|
|
1140
|
+
state: this.state,
|
|
1141
|
+
slots: { ...this.slots },
|
|
1142
|
+
context: { ...context },
|
|
1143
|
+
tx6MessageOverride: this.tx6MessageOverride,
|
|
1144
|
+
lastConfigTx6MessageOverride: this.lastConfigTx6MessageOverride,
|
|
1145
|
+
timeoutCycles: this.timeoutCycles,
|
|
1146
|
+
callAttempts: this.callAttempts,
|
|
1147
|
+
qsoStartTime: this.qsoStartTime,
|
|
1148
|
+
tx5TransmissionQueued: this.tx5TransmissionQueued,
|
|
1149
|
+
completedQSORecord: this.completedQSORecord ? { ...this.completedQSORecord } : undefined,
|
|
1150
|
+
pendingQSOCompletion: this.pendingQSOCompletion ? {
|
|
1151
|
+
lifecycleEpoch: this.pendingQSOCompletion.lifecycleEpoch,
|
|
1152
|
+
record: { ...this.pendingQSOCompletion.record },
|
|
1153
|
+
} : undefined,
|
|
1154
|
+
qsoLifecycleEpoch: this.qsoLifecycleEpoch,
|
|
1155
|
+
post73RetryContext: this.post73RetryContext ? { ...this.post73RetryContext } : undefined,
|
|
1156
|
+
qsoContextHistory: Array.from(this.qsoContextHistory.entries(), ([callsign, cached]) => [
|
|
1157
|
+
callsign,
|
|
1158
|
+
{ ...cached },
|
|
1159
|
+
]),
|
|
1160
|
+
foxHash: this.foxHash,
|
|
1161
|
+
};
|
|
1162
|
+
return checkpoint;
|
|
1163
|
+
}
|
|
1164
|
+
restore(checkpoint) {
|
|
1165
|
+
if (!checkpoint || typeof checkpoint !== 'object') {
|
|
1166
|
+
throw new Error('Invalid Standard QSO strategy checkpoint');
|
|
1167
|
+
}
|
|
1168
|
+
const state = checkpoint;
|
|
1169
|
+
if (!Object.prototype.hasOwnProperty.call(states, state.state)) {
|
|
1170
|
+
throw new Error(`Invalid Standard QSO strategy checkpoint state: ${String(state.state)}`);
|
|
1171
|
+
}
|
|
1172
|
+
this.state = state.state;
|
|
1173
|
+
this.slots = { ...state.slots };
|
|
1174
|
+
this._context = {
|
|
1175
|
+
config: this.operator.config,
|
|
1176
|
+
...state.context,
|
|
1177
|
+
};
|
|
1178
|
+
this.tx6MessageOverride = state.tx6MessageOverride;
|
|
1179
|
+
this.lastConfigTx6MessageOverride = state.lastConfigTx6MessageOverride;
|
|
1180
|
+
this.timeoutCycles = state.timeoutCycles;
|
|
1181
|
+
this.callAttempts = state.callAttempts;
|
|
1182
|
+
this.qsoStartTime = state.qsoStartTime;
|
|
1183
|
+
this.tx5TransmissionQueued = state.tx5TransmissionQueued;
|
|
1184
|
+
this.completedQSORecord = state.completedQSORecord ? { ...state.completedQSORecord } : undefined;
|
|
1185
|
+
this.pendingQSOCompletion = state.pendingQSOCompletion ? {
|
|
1186
|
+
lifecycleEpoch: state.pendingQSOCompletion.lifecycleEpoch,
|
|
1187
|
+
record: { ...state.pendingQSOCompletion.record },
|
|
1188
|
+
} : undefined;
|
|
1189
|
+
this.qsoLifecycleEpoch = state.qsoLifecycleEpoch;
|
|
1190
|
+
this.post73RetryContext = state.post73RetryContext ? { ...state.post73RetryContext } : undefined;
|
|
1191
|
+
this.qsoContextHistory = new Map(state.qsoContextHistory.map(([callsign, cached]) => [callsign, { ...cached }]));
|
|
1192
|
+
this.foxHash = state.foxHash;
|
|
1193
|
+
}
|
|
1194
|
+
async decide(messages, meta) {
|
|
1195
|
+
this.syncOperatorConfig();
|
|
1196
|
+
if (meta.signal.aborted) {
|
|
1197
|
+
throw new DOMException('Strategy decision aborted', 'AbortError');
|
|
1198
|
+
}
|
|
1199
|
+
this.speculativeDecisionDepth += 1;
|
|
1200
|
+
try {
|
|
1201
|
+
const decision = await this.handleReceivedAndDicideNext(messages, {
|
|
1202
|
+
isReDecision: meta.isReDecision,
|
|
1203
|
+
});
|
|
1204
|
+
if (meta.signal.aborted) {
|
|
1205
|
+
throw new DOMException('Strategy decision aborted', 'AbortError');
|
|
1206
|
+
}
|
|
1207
|
+
const qsoCompletion = this.pendingQSOCompletion
|
|
1208
|
+
? {
|
|
1209
|
+
lifecycleEpoch: this.pendingQSOCompletion.lifecycleEpoch,
|
|
1210
|
+
record: { ...this.pendingQSOCompletion.record },
|
|
1211
|
+
}
|
|
1212
|
+
: undefined;
|
|
1213
|
+
this.pendingQSOCompletion = undefined;
|
|
1214
|
+
return {
|
|
1215
|
+
...decision,
|
|
1216
|
+
transmission: this.handleTransmitSlot(),
|
|
1217
|
+
snapshot: this.getSnapshot(),
|
|
1218
|
+
qsoCompletion,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
finally {
|
|
1222
|
+
this.speculativeDecisionDepth -= 1;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
getTransmitText() {
|
|
1226
|
+
this.syncOperatorConfig();
|
|
1227
|
+
return this.handleTransmitSlot();
|
|
1228
|
+
}
|
|
1229
|
+
getIdleCQText() {
|
|
1230
|
+
this.syncOperatorConfig();
|
|
1231
|
+
if (this.state !== 'TX6'
|
|
1232
|
+
|| this.context.targetCallsign
|
|
1233
|
+
|| this.hasUnsettledQSOCompletion()) {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
return this.slots.TX6 || null;
|
|
1237
|
+
}
|
|
1238
|
+
getSnapshot() {
|
|
1239
|
+
this.syncOperatorConfig();
|
|
1240
|
+
return {
|
|
1241
|
+
currentState: this.state,
|
|
1242
|
+
slots: this.getSlots(),
|
|
1243
|
+
context: {
|
|
1244
|
+
targetCallsign: this.context.targetCallsign,
|
|
1245
|
+
targetGrid: this.context.targetGrid,
|
|
1246
|
+
reportSent: this.context.reportSent,
|
|
1247
|
+
reportReceived: this.context.reportReceived,
|
|
1248
|
+
actualFrequency: this.context.actualFrequency,
|
|
1249
|
+
},
|
|
1250
|
+
availableSlots: ['TX1', 'TX2', 'TX3', 'TX4', 'TX5', 'TX6'],
|
|
1251
|
+
qsoLifecycleEpoch: this.qsoLifecycleEpoch,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
patchContext(patch) {
|
|
1255
|
+
this.syncOperatorConfig();
|
|
1256
|
+
if (patch.targetCallsign && !this.canAcceptTargetCallsign(patch.targetCallsign, 'patchContext')) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (patch.targetCallsign
|
|
1260
|
+
&& !callsignMatches(patch.targetCallsign, this._context.targetCallsign)) {
|
|
1261
|
+
this.beginQsoLifecycle('context target changed');
|
|
1262
|
+
}
|
|
1263
|
+
const next = {
|
|
1264
|
+
...this._context,
|
|
1265
|
+
};
|
|
1266
|
+
if (patch.targetCallsign !== undefined)
|
|
1267
|
+
next.targetCallsign = patch.targetCallsign;
|
|
1268
|
+
if (patch.targetGrid !== undefined)
|
|
1269
|
+
next.targetGrid = patch.targetGrid;
|
|
1270
|
+
if (patch.actualFrequency !== undefined) {
|
|
1271
|
+
next.actualFrequency = patch.actualFrequency ?? undefined;
|
|
1272
|
+
}
|
|
1273
|
+
// The Host preserves an explicit undefined property to mean "clear".
|
|
1274
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'reportSent')) {
|
|
1275
|
+
next.reportSent = patch.reportSent;
|
|
1276
|
+
}
|
|
1277
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'reportReceived')) {
|
|
1278
|
+
next.reportReceived = patch.reportReceived;
|
|
1279
|
+
}
|
|
1280
|
+
this._context = next;
|
|
1281
|
+
const needsSlotUpdate = patch.targetCallsign !== undefined ||
|
|
1282
|
+
patch.targetGrid !== undefined ||
|
|
1283
|
+
Object.prototype.hasOwnProperty.call(patch, 'reportSent') ||
|
|
1284
|
+
Object.prototype.hasOwnProperty.call(patch, 'reportReceived');
|
|
1285
|
+
if (needsSlotUpdate) {
|
|
1286
|
+
this.updateSlots();
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
canAcceptTargetCallsign(callsign, operation) {
|
|
1290
|
+
if (isUndecodedCallsignPlaceholder(callsign)) {
|
|
1291
|
+
this.logger.warn(`${operation}: refusing undecoded placeholder callsign ${callsign}`);
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
if (callsignMatches(callsign, this.operator.config.myCallsign)) {
|
|
1295
|
+
this.logger.warn(`${operation}: refusing own callsign as target ${callsign}`);
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
setState(state) {
|
|
1301
|
+
this.syncOperatorConfig();
|
|
1302
|
+
const oldState = this.state;
|
|
1303
|
+
// Audit anomaly: external setState that aborts an active QSO by jumping to TX6.
|
|
1304
|
+
// Triggered intentionally only when:
|
|
1305
|
+
// - state actually changes
|
|
1306
|
+
// - we were in an active QSO state (TX1..TX5)
|
|
1307
|
+
// - we have a target callsign (i.e. mid-conversation)
|
|
1308
|
+
// - the new state is TX6 (the abort destination)
|
|
1309
|
+
// This narrowly targets the "BG5DRB premature CQ" incident pattern and
|
|
1310
|
+
// avoids noise from normal slot dropdown moves between TX1..TX5.
|
|
1311
|
+
if (oldState !== state &&
|
|
1312
|
+
oldState !== 'TX6' &&
|
|
1313
|
+
this.context.targetCallsign &&
|
|
1314
|
+
state === 'TX6') {
|
|
1315
|
+
this.logger.warn('External setState aborts active QSO', {
|
|
1316
|
+
from: oldState,
|
|
1317
|
+
to: state,
|
|
1318
|
+
targetCallsign: this.context.targetCallsign,
|
|
1319
|
+
timeoutCycles: this.timeoutCycles,
|
|
1320
|
+
reportSent: this.context.reportSent ?? null,
|
|
1321
|
+
reportReceived: this.context.reportReceived ?? null,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
this.state = state;
|
|
1325
|
+
this.tx5TransmissionQueued = false;
|
|
1326
|
+
if (state !== 'TX6') {
|
|
1327
|
+
this.clearPost73RetryContext(`manual set_state ${state}`);
|
|
1328
|
+
}
|
|
1329
|
+
if (oldState !== this.state) {
|
|
1330
|
+
this.notifyStateChanged();
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
setSlotContent(update) {
|
|
1334
|
+
const { slot, content } = update;
|
|
1335
|
+
if (!Object.prototype.hasOwnProperty.call(this.slots, slot)) {
|
|
1336
|
+
throw new Error(`Invalid slot: ${slot}`);
|
|
1337
|
+
}
|
|
1338
|
+
if (slot === 'TX6') {
|
|
1339
|
+
const defaultMessage = buildStandardQSODefaultTx6Message(this.operator.config);
|
|
1340
|
+
this.tx6MessageOverride = normalizeStandardQSOTx6MessageOverride(content, defaultMessage);
|
|
1341
|
+
this.slots.TX6 = this.tx6MessageOverride || defaultMessage;
|
|
1342
|
+
this.notifySlotsUpdated();
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
this.slots[slot] = content || '';
|
|
1346
|
+
this.notifySlotsUpdated();
|
|
1347
|
+
}
|
|
1348
|
+
reset(reason) {
|
|
1349
|
+
this.resetRuntime(reason);
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* 获取当前所有时隙的内容
|
|
1353
|
+
*/
|
|
1354
|
+
getSlots() {
|
|
1355
|
+
return { ...this.slots };
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* 获取当前状态
|
|
1359
|
+
*/
|
|
1360
|
+
getCurrentState() {
|
|
1361
|
+
return this.state;
|
|
1362
|
+
}
|
|
1363
|
+
updateTargetSlotsForSpecialCallsign(targetCallsign) {
|
|
1364
|
+
const report = this.context.reportSent || 0;
|
|
1365
|
+
const wrappedTarget = `<${targetCallsign}>`;
|
|
1366
|
+
const myCallsign = this.operator.config.myCallsign;
|
|
1367
|
+
const reportText = FT8MessageParser.generateSignalReport(report);
|
|
1368
|
+
const gridCall = {
|
|
1369
|
+
type: FT8MessageType.CALL,
|
|
1370
|
+
senderCallsign: myCallsign,
|
|
1371
|
+
targetCallsign,
|
|
1372
|
+
grid: this.context.config.myGrid,
|
|
1373
|
+
};
|
|
1374
|
+
const signalReport = {
|
|
1375
|
+
type: FT8MessageType.SIGNAL_REPORT,
|
|
1376
|
+
senderCallsign: myCallsign,
|
|
1377
|
+
targetCallsign,
|
|
1378
|
+
report,
|
|
1379
|
+
};
|
|
1380
|
+
this.slots.TX1 = FT8MessageParser.generateMessage(gridCall);
|
|
1381
|
+
this.slots.TX2 = FT8MessageParser.generateMessage(signalReport);
|
|
1382
|
+
// WSJT-X pack77 accepts hashed nonstandard-call structured replies, including RR73.
|
|
1383
|
+
this.slots.TX3 = `${wrappedTarget} ${myCallsign} R${reportText}`;
|
|
1384
|
+
this.slots.TX4 = `${wrappedTarget} ${myCallsign} RR73`;
|
|
1385
|
+
this.slots.TX5 = `${wrappedTarget} ${myCallsign} 73`;
|
|
1386
|
+
}
|
|
1387
|
+
updateSlots() {
|
|
1388
|
+
// 防御:占位符目标绝不生成发射槽位(避免把 `...` 包成 `<...>` 上射频)
|
|
1389
|
+
if (this.context.targetCallsign && isUndecodedCallsignPlaceholder(this.context.targetCallsign)) {
|
|
1390
|
+
this.slots.TX1 = '';
|
|
1391
|
+
this.slots.TX2 = '';
|
|
1392
|
+
this.slots.TX3 = '';
|
|
1393
|
+
this.slots.TX4 = '';
|
|
1394
|
+
this.slots.TX5 = '';
|
|
1395
|
+
this.notifySlotsUpdated();
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (this.context.targetCallsign) {
|
|
1399
|
+
if (FT8MessageParser.isStandardCallsign(this.context.targetCallsign)) {
|
|
1400
|
+
this.slots.TX1 = FT8MessageParser.generateMessage({
|
|
1401
|
+
type: FT8MessageType.CALL,
|
|
1402
|
+
senderCallsign: this.operator.config.myCallsign,
|
|
1403
|
+
targetCallsign: this.context.targetCallsign,
|
|
1404
|
+
grid: this.context.config.myGrid,
|
|
1405
|
+
});
|
|
1406
|
+
this.slots.TX2 = FT8MessageParser.generateMessage({
|
|
1407
|
+
type: FT8MessageType.SIGNAL_REPORT,
|
|
1408
|
+
senderCallsign: this.operator.config.myCallsign,
|
|
1409
|
+
targetCallsign: this.context.targetCallsign,
|
|
1410
|
+
report: this.context.reportSent || 0,
|
|
1411
|
+
});
|
|
1412
|
+
this.slots.TX3 = FT8MessageParser.generateMessage({
|
|
1413
|
+
type: FT8MessageType.ROGER_REPORT,
|
|
1414
|
+
senderCallsign: this.operator.config.myCallsign,
|
|
1415
|
+
targetCallsign: this.context.targetCallsign,
|
|
1416
|
+
report: this.context.reportSent || 0,
|
|
1417
|
+
});
|
|
1418
|
+
this.slots.TX4 = FT8MessageParser.generateMessage({
|
|
1419
|
+
type: FT8MessageType.RRR,
|
|
1420
|
+
senderCallsign: this.operator.config.myCallsign,
|
|
1421
|
+
targetCallsign: this.context.targetCallsign,
|
|
1422
|
+
});
|
|
1423
|
+
this.slots.TX5 = FT8MessageParser.generateMessage({
|
|
1424
|
+
type: FT8MessageType.SEVENTY_THREE,
|
|
1425
|
+
senderCallsign: this.operator.config.myCallsign,
|
|
1426
|
+
targetCallsign: this.context.targetCallsign,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
else {
|
|
1430
|
+
this.updateTargetSlotsForSpecialCallsign(this.context.targetCallsign);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
else {
|
|
1434
|
+
this.slots.TX1 = '';
|
|
1435
|
+
this.slots.TX2 = '';
|
|
1436
|
+
this.slots.TX3 = '';
|
|
1437
|
+
this.slots.TX4 = '';
|
|
1438
|
+
this.slots.TX5 = '';
|
|
1439
|
+
}
|
|
1440
|
+
const defaultTx6Message = buildStandardQSODefaultTx6Message(this.operator.config);
|
|
1441
|
+
this.slots.TX6 = this.tx6MessageOverride || defaultTx6Message;
|
|
1442
|
+
// 通知操作员slots已更新
|
|
1443
|
+
this.notifySlotsUpdated();
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* 保存当前QSO上下文到历史缓存
|
|
1447
|
+
*/
|
|
1448
|
+
saveCurrentContext() {
|
|
1449
|
+
if (!this.context.targetCallsign) {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
this.qsoContextHistory.set(this.context.targetCallsign, {
|
|
1453
|
+
targetGrid: this.context.targetGrid,
|
|
1454
|
+
reportSent: this.context.reportSent,
|
|
1455
|
+
reportReceived: this.context.reportReceived,
|
|
1456
|
+
actualFrequency: this.context.actualFrequency,
|
|
1457
|
+
lastUpdated: Date.now()
|
|
1458
|
+
});
|
|
1459
|
+
// 限制缓存大小,删除最老的条目
|
|
1460
|
+
if (this.qsoContextHistory.size > this.MAX_CONTEXT_CACHE) {
|
|
1461
|
+
const entries = Array.from(this.qsoContextHistory.entries());
|
|
1462
|
+
entries.sort((a, b) => a[1].lastUpdated - b[1].lastUpdated);
|
|
1463
|
+
const oldestKey = entries[0][0];
|
|
1464
|
+
this.qsoContextHistory.delete(oldestKey);
|
|
1465
|
+
this.logger.debug(`Context cache full, evicting oldest entry: ${oldestKey}`);
|
|
1466
|
+
}
|
|
1467
|
+
this.logger.debug(`Saving context to cache: ${this.context.targetCallsign}`, {
|
|
1468
|
+
grid: this.context.targetGrid,
|
|
1469
|
+
reportSent: this.context.reportSent,
|
|
1470
|
+
reportReceived: this.context.reportReceived
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* 从历史缓存恢复QSO上下文
|
|
1475
|
+
*/
|
|
1476
|
+
restoreContext(targetCallsign) {
|
|
1477
|
+
const cached = this.qsoContextHistory.get(targetCallsign);
|
|
1478
|
+
if (cached) {
|
|
1479
|
+
this.context.targetGrid = cached.targetGrid;
|
|
1480
|
+
this.context.reportSent = cached.reportSent;
|
|
1481
|
+
this.context.reportReceived = cached.reportReceived;
|
|
1482
|
+
this.context.actualFrequency = cached.actualFrequency;
|
|
1483
|
+
this.logger.debug(`Restored context from cache: ${targetCallsign}`, {
|
|
1484
|
+
grid: cached.targetGrid,
|
|
1485
|
+
reportSent: cached.reportSent,
|
|
1486
|
+
reportReceived: cached.reportReceived
|
|
1487
|
+
});
|
|
1488
|
+
return true;
|
|
1489
|
+
}
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
armPost73RetryContext() {
|
|
1493
|
+
if (!this.context.targetCallsign) {
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const retryWindowMs = this.context.config.mode.slotMs * 2;
|
|
1497
|
+
this.post73RetryContext = {
|
|
1498
|
+
targetCallsign: this.context.targetCallsign,
|
|
1499
|
+
targetGrid: this.context.targetGrid,
|
|
1500
|
+
reportSent: this.context.reportSent,
|
|
1501
|
+
reportReceived: this.context.reportReceived,
|
|
1502
|
+
actualFrequency: this.context.actualFrequency,
|
|
1503
|
+
completedQSO: this.completedQSORecord,
|
|
1504
|
+
expiresAt: Date.now() + retryWindowMs,
|
|
1505
|
+
};
|
|
1506
|
+
this.logger.debug(`Armed post-73 retry context for ${this.context.targetCallsign} (window=${retryWindowMs}ms)`);
|
|
1507
|
+
}
|
|
1508
|
+
clearPost73RetryContext(reason) {
|
|
1509
|
+
if (!this.post73RetryContext) {
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
this.logger.debug(`Clearing post-73 retry context for ${this.post73RetryContext.targetCallsign}: ${reason}`);
|
|
1513
|
+
this.post73RetryContext = undefined;
|
|
1514
|
+
}
|
|
1515
|
+
getActivePost73RetryContext() {
|
|
1516
|
+
if (!this.post73RetryContext) {
|
|
1517
|
+
return undefined;
|
|
1518
|
+
}
|
|
1519
|
+
if (this.post73RetryContext.expiresAt < Date.now()) {
|
|
1520
|
+
this.clearPost73RetryContext('expired');
|
|
1521
|
+
return undefined;
|
|
1522
|
+
}
|
|
1523
|
+
return this.post73RetryContext;
|
|
1524
|
+
}
|
|
1525
|
+
restorePost73RetryContext(context) {
|
|
1526
|
+
this.context.targetCallsign = context.targetCallsign;
|
|
1527
|
+
this.context.targetGrid = context.targetGrid;
|
|
1528
|
+
this.context.reportSent = context.reportSent;
|
|
1529
|
+
this.context.reportReceived = context.reportReceived;
|
|
1530
|
+
this.context.actualFrequency = context.actualFrequency;
|
|
1531
|
+
this.completedQSORecord = context.completedQSO;
|
|
1532
|
+
this.tx5TransmissionQueued = false;
|
|
1533
|
+
this.updateSlots();
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* 清空QSO上下文
|
|
1537
|
+
* 在QSO结束时调用,确保干净的下一次通联
|
|
1538
|
+
*/
|
|
1539
|
+
clearQSOContext() {
|
|
1540
|
+
const previousTarget = this.context.targetCallsign;
|
|
1541
|
+
const currentState = this.state;
|
|
1542
|
+
// 先保存当前上下文到历史缓存
|
|
1543
|
+
this.saveCurrentContext();
|
|
1544
|
+
this.context.targetCallsign = undefined;
|
|
1545
|
+
this.context.targetGrid = undefined;
|
|
1546
|
+
this.context.reportSent = undefined;
|
|
1547
|
+
this.context.reportReceived = undefined;
|
|
1548
|
+
this.context.actualFrequency = undefined;
|
|
1549
|
+
this.foxHash = undefined;
|
|
1550
|
+
// 更新slots(TX1-TX5会变为空,只保留TX6的CQ)
|
|
1551
|
+
this.updateSlots();
|
|
1552
|
+
this.logger.debug(`QSO context cleared (previousTarget=${previousTarget}, state=${currentState}, timeoutCycles=${this.timeoutCycles})`);
|
|
1553
|
+
}
|
|
1554
|
+
resetRuntime(reason) {
|
|
1555
|
+
this.qsoLifecycleEpoch += 1;
|
|
1556
|
+
this.timeoutCycles = 0;
|
|
1557
|
+
this.callAttempts = 0;
|
|
1558
|
+
this.qsoStartTime = undefined;
|
|
1559
|
+
this.tx5TransmissionQueued = false;
|
|
1560
|
+
this.completedQSORecord = undefined;
|
|
1561
|
+
this.pendingQSOCompletion = undefined;
|
|
1562
|
+
this.clearPost73RetryContext(`runtime reset${reason ? `: ${reason}` : ''}`);
|
|
1563
|
+
this.clearQSOContext();
|
|
1564
|
+
this.changeStateSafely('TX6');
|
|
1565
|
+
}
|
|
1566
|
+
changeStateSafely(state) {
|
|
1567
|
+
void this.changeState(state).catch((error) => {
|
|
1568
|
+
this.logger.error(`Asynchronous state transition to ${state} failed`, error);
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* 通知slots更新
|
|
1573
|
+
*/
|
|
1574
|
+
notifySlotsUpdated() {
|
|
1575
|
+
if (this.speculativeDecisionDepth > 0) {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
// 通过operator通知slots更新
|
|
1579
|
+
this.operator.notifySlotsUpdated?.(this.getSlots());
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* 通知状态变化
|
|
1583
|
+
*/
|
|
1584
|
+
notifyStateChanged() {
|
|
1585
|
+
if (this.speculativeDecisionDepth > 0) {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
// 通过operator通知状态变化
|
|
1589
|
+
this.operator.notifyStateChanged?.(this.state);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
//# sourceMappingURL=StandardQSOPluginRuntime.js.map
|