Skip to content

Commit 48a7eba

Browse files
committed
feat(ft8): add multistream WW Digi contest support
1 parent 5dc7172 commit 48a7eba

84 files changed

Lines changed: 8344 additions & 1190 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/builtin-plugins/src/_shared/parallel-qso/ParallelQSOCoordinator.test.ts

Lines changed: 510 additions & 0 deletions
Large diffs are not rendered by default.

packages/builtin-plugins/src/_shared/parallel-qso/ParallelQSOCoordinator.ts

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type {
2+
ParsedFT8Message,
3+
QSOFailureInfo,
4+
StrategyDecisionMetaV2,
5+
StrategyQSOCompletionEffect,
6+
StrategyQSOCompletionSettlement,
7+
StrategyStreamSnapshot,
8+
StreamPhysicalReceipt,
9+
QueuedStrategyObservationMeta,
10+
} from '@tx5dr/plugin-api';
11+
12+
export interface ParallelQSOQueueEntry<TData> {
13+
entryId: string;
14+
targetKey: string;
15+
callsign: string;
16+
requestedTransmitCycle?: 0 | 1;
17+
data: TData;
18+
}
19+
20+
export interface ProtocolLaneIdentity {
21+
streamId: string;
22+
laneIndex: number;
23+
}
24+
25+
export interface ProtocolLaneActivation {
26+
accepted: boolean;
27+
}
28+
29+
export interface ProtocolLaneRelease {
30+
disposition: 'remove-entry' | 'retain-entry';
31+
reason: string;
32+
}
33+
34+
export interface ProtocolLaneDecision<TData = unknown> {
35+
qsoCompletion?: StrategyQSOCompletionEffect;
36+
qsoFailure?: QSOFailureInfo;
37+
release?: ProtocolLaneRelease;
38+
entryData?: TData;
39+
queueChanged?: boolean;
40+
}
41+
42+
export type ProtocolLaneSnapshot = Omit<
43+
StrategyStreamSnapshot,
44+
'streamId' | 'audioFrequencyHz'
45+
>;
46+
47+
/** One stable RF stream that can host successive protocol sessions. */
48+
export interface ProtocolLane<TData> {
49+
readonly streamId: string;
50+
readonly audioFrequencyHz: number;
51+
52+
activate(entry: Readonly<ParallelQSOQueueEntry<TData>>): ProtocolLaneActivation;
53+
deactivate(reason: string): void;
54+
hasPendingWork(): boolean;
55+
shouldObserve?(): boolean;
56+
57+
observe?(messages: ParsedFT8Message[], meta: QueuedStrategyObservationMeta): boolean;
58+
decide(
59+
messages: ParsedFT8Message[],
60+
meta: StrategyDecisionMetaV2,
61+
): ProtocolLaneDecision<TData> | Promise<ProtocolLaneDecision<TData>>;
62+
63+
getTransmitText(): string | null;
64+
getSnapshot(): ProtocolLaneSnapshot | null;
65+
66+
checkpoint(): unknown;
67+
restore(checkpoint: unknown): void;
68+
onPhysicalSuccess?(receipt: StreamPhysicalReceipt): void;
69+
settleQSOCompletion?(settlement: StrategyQSOCompletionSettlement): boolean | void;
70+
reset(reason?: string): void;
71+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './ProtocolLane.js';
2+
export * from './ParallelQSOCoordinator.js';

packages/builtin-plugins/src/assisted-qso-queue/AssistedQSOQueueRuntime.test.ts

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,20 @@ function createLogger(): PluginLogger {
6060
function createRuntime(options: {
6161
transmitting?: boolean;
6262
operator?: StandardQSOPluginOperator;
63+
maxStreams?: number;
6364
} = {}) {
6465
let transmitting = options.transmitting ?? false;
66+
let maxStreams = options.maxStreams ?? 1;
6567
const runtimeOptions: AssistedQSOQueueRuntimeOptions = {
6668
operator: options.operator ?? createOperator(),
6769
isTransmitting: () => transmitting,
6870
logger: createLogger(),
71+
getMaxStreams: () => maxStreams,
6972
};
7073
return {
7174
runtime: new AssistedQSOQueueRuntime(runtimeOptions),
7275
setTransmitting(value: boolean) { transmitting = value; },
76+
setMaxStreams(value: number) { maxStreams = value; },
7377
logger: runtimeOptions.logger,
7478
};
7579
}
@@ -181,6 +185,112 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
181185
expect(runtime.getQueueSnapshot().version).toBe(activeVersion);
182186
});
183187

188+
it('runs two or three stable protocol lanes when configured while defaulting to one', async () => {
189+
const defaultRuntime = createRuntime({ transmitting: true }).runtime;
190+
defaultRuntime.enqueueTarget({ callsign: 'JA1AAA' });
191+
defaultRuntime.enqueueTarget({ callsign: 'JA2BBB' });
192+
const defaultDecision = await defaultRuntime.decide([], decision());
193+
expect(defaultDecision.transmissions).toHaveLength(1);
194+
expect(defaultRuntime.getQueueSnapshot()).toMatchObject({
195+
maxActiveStreams: 1,
196+
activeEntryIds: [defaultRuntime.getQueueSnapshot().rows[0]?.entryId],
197+
});
198+
199+
const parallel = createRuntime({ transmitting: true, maxStreams: 3 }).runtime;
200+
parallel.enqueueTarget({ callsign: 'JA1AAA' });
201+
parallel.enqueueTarget({ callsign: 'JA2BBB' });
202+
parallel.enqueueTarget({ callsign: 'JA3CCC' });
203+
const parallelDecision = await parallel.decide([], decision());
204+
205+
expect(parallelDecision.transmissions).toMatchObject([
206+
{ streamId: 'stream-1', text: expect.stringContaining('JA1AAA') },
207+
{ streamId: 'stream-2', text: expect.stringContaining('JA2BBB') },
208+
{ streamId: 'stream-3', text: expect.stringContaining('JA3CCC') },
209+
]);
210+
expect(parallelDecision.transmissions!.map((item) => item.audioFrequencyHz)).toEqual([
211+
1_500,
212+
1_560,
213+
1_620,
214+
]);
215+
expect(parallelDecision.snapshot.streams).toHaveLength(3);
216+
expect(parallel.getQueueSnapshot().activeEntryIds).toHaveLength(3);
217+
});
218+
219+
it('applies non-preemptive dynamic shrink and uses a newly raised limit on the next decision', async () => {
220+
const { runtime, setMaxStreams } = createRuntime({ transmitting: true, maxStreams: 2 });
221+
runtime.enqueueTarget({ callsign: 'JA1AAA' });
222+
runtime.enqueueTarget({ callsign: 'JA2BBB' });
223+
runtime.enqueueTarget({ callsign: 'JA3CCC' });
224+
await runtime.decide([], decision());
225+
expect(runtime.getQueueSnapshot().activeEntryIds).toHaveLength(2);
226+
227+
setMaxStreams(3);
228+
await runtime.decide([], decision(2));
229+
expect(runtime.getQueueSnapshot()).toMatchObject({ maxActiveStreams: 3 });
230+
expect(runtime.getQueueSnapshot().activeEntryIds).toHaveLength(3);
231+
232+
setMaxStreams(1);
233+
await runtime.decide([], decision(3));
234+
expect(runtime.getQueueSnapshot()).toMatchObject({ maxActiveStreams: 1 });
235+
expect(runtime.getQueueSnapshot().activeEntryIds).toHaveLength(3);
236+
});
237+
238+
it('routes decoded replies to one standard protocol lane without advancing its peers', async () => {
239+
const { runtime } = createRuntime({ transmitting: true, maxStreams: 3 });
240+
runtime.enqueueTarget({ callsign: 'JA1AAA', lastMessage: selected('CQ JA1AAA PM95') });
241+
runtime.enqueueTarget({ callsign: 'JA2BBB', lastMessage: selected('CQ JA2BBB PM95') });
242+
runtime.enqueueTarget({ callsign: 'JA3CCC', lastMessage: selected('CQ JA3CCC PM95') });
243+
const initial = await runtime.decide([], decision());
244+
runtime.onTransmissionsCompleted(initial.transmissions!.map((transmission) => ({
245+
...transmission,
246+
frameId: 'frame-1',
247+
revision: 1,
248+
physicalConfirmed: true as const,
249+
})));
250+
251+
const replyAt = BASE_TIME + MODES.FT8.slotMs;
252+
const reply = parsed('BG5DRB JA1AAA -05', replyAt, { snr: -7 });
253+
runtime.observeDecodedMessages([reply], observation(replyAt));
254+
const advanced = await runtime.decide([reply], decision(2));
255+
256+
expect(advanced.transmissions).toMatchObject([
257+
{ streamId: 'stream-1', text: expect.stringContaining('R-07') },
258+
{ streamId: 'stream-2', text: expect.stringContaining('JA2BBB') },
259+
{ streamId: 'stream-3', text: expect.stringContaining('JA3CCC') },
260+
]);
261+
expect(advanced.snapshot.streams).toMatchObject([
262+
{ streamId: 'stream-1', currentState: 'TX3', targetCallsign: 'JA1AAA' },
263+
{ streamId: 'stream-2', currentState: 'TX1', targetCallsign: 'JA2BBB' },
264+
{ streamId: 'stream-3', currentState: 'TX1', targetCallsign: 'JA3CCC' },
265+
]);
266+
});
267+
268+
it('hot-updates protocol and physical lane frequencies through one bounded resolver', async () => {
269+
const source = createOperator();
270+
const config = { ...source.config, frequency: 299 };
271+
const operator: StandardQSOPluginOperator = {
272+
get config() { return config; },
273+
hasWorkedCallsign: (callsign, options) => source.hasWorkedCallsign(callsign, options),
274+
isTargetBeingWorkedByOthers: (callsign) => source.isTargetBeingWorkedByOthers(callsign),
275+
};
276+
const { runtime } = createRuntime({ transmitting: true, maxStreams: 3, operator });
277+
runtime.enqueueTarget({ callsign: 'JA1AAA' });
278+
runtime.enqueueTarget({ callsign: 'JA2BBB' });
279+
runtime.enqueueTarget({ callsign: 'JA3CCC' });
280+
await runtime.decide([], decision());
281+
expect(runtime.getTransmissions().map((item) => item.audioFrequencyHz)).toEqual([1_500, 1_560, 1_620]);
282+
283+
config.frequency = 4_700;
284+
expect(runtime.getTransmissions().map((item) => item.audioFrequencyHz)).toEqual([4_700, 4_760, 4_820]);
285+
expect(runtime.getSnapshot().context?.actualFrequency).toBeUndefined();
286+
287+
config.frequency = 4_701;
288+
expect(runtime.getTransmissions().map((item) => item.audioFrequencyHz)).toEqual([1_500, 1_560, 1_620]);
289+
290+
config.mode = MODES.FT4;
291+
expect(runtime.getTransmissions().map((item) => item.audioFrequencyHz)).toEqual([1_500, 1_600, 1_700]);
292+
});
293+
184294
it('uses standard TX6 CQ only when no queue target is executable', async () => {
185295
const { runtime, setTransmitting } = createRuntime();
186296
expect(runtime.getTransmitText()).toBeNull();
@@ -454,6 +564,35 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
454564
.toEqual(['JA1AAA', 'JA2BBB', 'JA3CCC']);
455565
});
456566

567+
it('gives a preempted lane to the direct opportunity that triggered preemption', async () => {
568+
const { runtime } = createRuntime({ transmitting: true, maxStreams: 3 });
569+
runtime.enqueueTarget({ callsign: 'JA1AAA' });
570+
runtime.enqueueTarget({ callsign: 'JA2BBB' });
571+
runtime.enqueueTarget({ callsign: 'JA3CCC' });
572+
await runtime.decide([], decision());
573+
runtime.enqueueTarget({ callsign: 'JA4DDD' });
574+
const directAt = BASE_TIME;
575+
runtime.observeDecodedMessages([
576+
parsed('BG5DRB JA5EEE PM95', directAt),
577+
], observation(directAt));
578+
const promoted = runtime.getQueueSnapshot();
579+
const direct = promoted.rows.find((row) => row.callsign === 'JA5EEE')!;
580+
expect(runtime.reorderTarget(direct.entryId, null, promoted.version).snapshot.rows.map((row) => row.callsign))
581+
.toEqual(['JA1AAA', 'JA2BBB', 'JA3CCC', 'JA4DDD', 'JA5EEE']);
582+
583+
const preempted = await runtime.decide([], decision(2));
584+
585+
expect(preempted.transmissions).toMatchObject([
586+
{ streamId: 'stream-1', text: expect.stringContaining('JA5EEE') },
587+
{ streamId: 'stream-2', text: expect.stringContaining('JA2BBB') },
588+
{ streamId: 'stream-3', text: expect.stringContaining('JA3CCC') },
589+
]);
590+
expect(runtime.getQueueSnapshot().rows.slice(0, 3).map((row) => row.callsign))
591+
.toEqual(['JA5EEE', 'JA2BBB', 'JA3CCC']);
592+
expect(runtime.getQueueSnapshot().rows.slice(3).map((row) => row.callsign))
593+
.toEqual(['JA4DDD', 'JA1AAA']);
594+
});
595+
457596
it('releases an unengaged active target when it starts working another station', async () => {
458597
const { runtime } = createRuntime({ transmitting: true });
459598
runtime.enqueueTarget({ callsign: 'JA1AAA', lastMessage: selected('CQ JA1AAA PM95') });
@@ -687,6 +826,11 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
687826
currentState: 'TX5',
688827
context: { targetCallsign: 'JA1AAA' },
689828
});
829+
expect(retry.snapshot.streams).toMatchObject([{
830+
streamId: 'stream-1',
831+
currentState: 'TX1',
832+
targetCallsign: 'JA2BBB',
833+
}]);
690834
expect(retry.qsoCompletion).toBeUndefined();
691835
expect(runtime.getQueueSnapshot()).toMatchObject({
692836
activeEntryId: nextEntryId,
@@ -701,6 +845,38 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
701845
expect(runtime.getQueueSnapshot().activeEntryId).toBe(nextEntryId);
702846
});
703847

848+
it('clears an unscheduled final-73 lease after the completed row is gone', async () => {
849+
const { runtime } = createRuntime({ transmitting: true });
850+
runtime.enqueueTarget({
851+
callsign: 'JA1AAA',
852+
lastMessage: selected('BG5DRB JA1AAA -08'),
853+
});
854+
const first = await runtime.decide([], decision());
855+
runtime.onTransmissionQueued(first.transmission!);
856+
const rrr = parsed('BG5DRB JA1AAA RR73', BASE_TIME + MODES.FT8.slotMs);
857+
runtime.observeDecodedMessages([rrr], observation(rrr.timestamp));
858+
const completion = await runtime.decide([rrr], decision(2));
859+
runtime.settleQSOCompletion({
860+
lifecycleEpoch: completion.qsoCompletion!.lifecycleEpoch,
861+
recordId: completion.qsoCompletion!.record.id,
862+
status: 'committed',
863+
});
864+
runtime.onTransmissionQueued(completion.transmission!);
865+
const releaseAt = BASE_TIME + MODES.FT8.slotMs * 2;
866+
runtime.observeDecodedMessages([], observation(releaseAt));
867+
await runtime.decide([], decision(3));
868+
expect(runtime.getQueueSnapshot().rows).toHaveLength(0);
869+
870+
const beforeClear = runtime.getQueueSnapshot();
871+
const cleared = runtime.clearTargets(beforeClear.version);
872+
expect(cleared.snapshot.version).toBe(beforeClear.version + 1);
873+
const repeated = parsed('BG5DRB JA1AAA RR73', releaseAt);
874+
runtime.observeDecodedMessages([repeated], observation(releaseAt));
875+
const afterClear = await runtime.decide([repeated], decision(4));
876+
expect(afterClear.transmission).toBe('CQ BG5DRB OL32');
877+
expect(afterClear.transmission).not.toBe('JA1AAA BG5DRB 73');
878+
});
879+
704880
it('does not let a completed target retry preempt an engaged next QSO', async () => {
705881
const { runtime } = createRuntime({ transmitting: true });
706882
const { releaseSlotStartMs } = await prepareFinal73Lease(runtime);
@@ -720,7 +896,7 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
720896
});
721897
});
722898

723-
it('holds a delegate-released completion until asynchronous log settlement arrives', async () => {
899+
it('releases a delegate completion when durable persistence merged to a different record id', async () => {
724900
const { runtime } = createRuntime({ transmitting: true });
725901
runtime.enqueueTarget({
726902
callsign: 'JA1AAA',
@@ -742,6 +918,7 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
742918
runtime.settleQSOCompletion({
743919
lifecycleEpoch: completion.qsoCompletion!.lifecycleEpoch,
744920
recordId: completion.qsoCompletion!.record.id,
921+
persistedRecordId: 'existing-merged-qso',
745922
status: 'committed',
746923
});
747924
expect(runtime.getQueueSnapshot()).toMatchObject({

0 commit comments

Comments
 (0)