Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ main service ──emit──▶ push:batch ──▶ preload ──▶ ipc-adap

When the renderer loads (or reloads in dev while main keeps running) the adapter also pulls the
current picture: `relay:status`, `nodes:get`, `config:get`, `config:backups-get`, `events:get`,
`hid:availability`.
`hid:availability`. A reload loses nothing main holds: the `relay:status` bundle hands back the
recording, the loaded capture, the serial monitor's flag, the aircraft, the gauges and the link's
last-known time, and the Cockpit snapshot keeps the session's peak W. Only view state starts over:
the tab, open dialogs, and a calibration dialog, which never resumes across a reload.

## `push:batch`: one frame per main turn

Expand Down
34 changes: 34 additions & 0 deletions src/main/app/app-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,40 @@ describe('device link is independent of the relay (#69)', () => {
session.shutdown()
})

it.each([
[
'Disconnect then Connect',
(s: InstanceType<typeof AppServices>) => {
s.connection.disconnect()
s.connection.connect()
}
],
[
'an error then Retry',
(s: InstanceType<typeof AppServices>, orphan: FakeSerial) => {
orphan.errorCb(new Error('Access denied'))
s.connection.connect()
}
]
])('after %s, the released bridge no longer drives the link (M4)', (_, release) => {
const { session, serial: orphan, of } = launched()
release(session, orphan)
const current = FakeSerial.last!
expect(current, 'a fresh bridge').not.toBe(orphan)
const pushed = of('device:status').length
// The released bridge had an open in flight: it completes, then the port errors and closes.
orphan.openCb('/dev/fake')
orphan.dataCb(ascii('_NODE_STATUS_END\n'))
orphan.errorCb(new Error('Access denied'))
orphan.closeCb()
expect(session.status().device.state, 'still looking with the fresh bridge').toBe('scanning')
expect(of('device:status'), 'no status from the orphan').toHaveLength(pushed)
expect(orphan.written, 'no roster request through the orphan').toEqual([])
current.openCb('/dev/fake') // the current bridge still drives it
expect(session.status().device.state).toBe('connected')
session.shutdown()
})

it('ignores connect while the link is already open or looking', () => {
const { session, serial } = launched()
session.connection.connect()
Expand Down
23 changes: 11 additions & 12 deletions src/main/app/app-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export class AppServices {
log: debugLog,
frames: this.calibration,
health: () => this.nodes.gatewayHealth(),
serialMonitor: () => this.serialMonitor.shown,
canRefresh: () => this.nodes.canRefresh()
serialMonitor: () => this.serialMonitor.shown
})
this.nodes = new NodeHub(emit, new NodeRoster(), (events) => this.journal.report(events), {
mappingFile,
Expand Down Expand Up @@ -106,7 +105,8 @@ export class AppServices {
// `connected`: roster request → `opened`: calibration read, HID reopen
// port closes `closing`: nodes' last seen → `closed`: calibration → reconnect/disconnect
// teardown `release`: calibration → port stop → `released`: the Gateway's health
// 200 ms tick `flush`: HID → (link up) `tick`: node pushes, Gateway health
// 200 ms tick `flush`: HID → (link up) `tick`: node pushes, Gateway health, re-ask
// Retry `closing`: nodes' last seen → teardown → a fresh port (M7)
// shutdown `closing`: nodes → timers cleared → teardown → `stop`: HID
// The DCS side keeps its order from before the split too (R2.3a). None of its constructors has
// a side effect.
Expand Down Expand Up @@ -195,26 +195,25 @@ export class AppServices {
}

/**
* A renderer (re)loaded (`relay:status`, its first pull): what the page held for itself starts
* over, as its store did (N1) — the link's "last known" time and the serial monitor's flag.
* A renderer (re)loaded (`relay:status`, its first pull): it has the HID report and its live
* axes now, as it has its catalogue from a pull. What main holds for the screen — the link's
* "last known" time, the serial monitor's flag, the recording, the capture, the aircraft and
* the gauges — is kept, and the bundle hands it over (N1).
*/
rendererLoaded(): void {
this.connection.rendererLoaded()
this.serialMonitor.rendererLoaded()
// …and it has the HID report and its live axes now, as it had its catalogue from a pull.
this.hid.rendererLoaded()
// …and the recording and the capture it shows.
this.relay.rendererLoaded()
}

/** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */
/** Snapshot so a (re)loaded renderer can rehydrate what main holds for the screen (N1). */
status(): RelayStatus {
const device = this.connection.statusPush()
const { running, relay } = this.relay.status()
const { running, relay, aircraft, telemetry } = this.relay.status()
return {
running,
relay,
relayFacts: this.relay.facts(),
aircraft,
telemetry,
device,
cal: this.calibration.workflow.state()
}
Expand Down
3 changes: 1 addition & 2 deletions src/main/app/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ const IPC_ORDER = [
'device:connect',
'device:disconnect',
'hid:availability',
'nodes:refresh',
'nodes:get',
'node-mapping:set',
'node-mapping:adopt',
Expand Down Expand Up @@ -116,7 +115,7 @@ export function registerIpc(ipcMain: IpcRegistrar, deps: IpcDeps): void {
services.calibration.workflow.rendererLoaded()
// …and the Cockpit snapshot starts over from what main holds, as the old page's store did (R3.2).
services.nodes.rendererLoaded()
// …and what the old page's store held for itself goes with it (R3.3, N1).
// …and the HID report goes out; what main holds for the screen is kept for the bundle (N1).
services.rendererLoaded()
return services.status()
})
Expand Down
22 changes: 10 additions & 12 deletions src/main/app/pushed-rules.seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,18 @@ describe('device:status carries the link’s facts', () => {
})
})

it('the serial monitor’s flag comes back on device:status; a reloaded page starts over (N1)', () => {
it('the serial monitor’s flag comes back on device:status; a reloaded page keeps it (N1)', () => {
const app = makeServices(monitor)
app.serial.open()
const n = app.pushesOf('device:status').length
app.invoke(CTRL.serialMonitor, true)
expect(app.pushesOf('device:status')).toHaveLength(n + 1)
expect(app.lastPush('device:status')).toMatchObject({ state: 'connected', serialMonitor: true })
const at = app.clock.now()
app.serial.close()
const reloaded = app.invoke(CTRL.relayStatus)
expect(reloaded.device).toMatchObject({ serialMonitor: false })
expect(reloaded.device.linkDownAt, 'no last-known time for a reloaded page').toBeUndefined()
expect(reloaded.device).toMatchObject({ serialMonitor: true })
expect(reloaded.device.linkDownAt, 'the last-known time, as main holds it').toBe(at)
})
})

Expand Down Expand Up @@ -94,32 +95,29 @@ describe('relay:facts: what the relay’s surfaces show', () => {
})

describe('R3.4a: the last renderer decisions, pushed by their owners', () => {
it('device:status: the link’s action, and Refresh only while a roster request can go', () => {
it('device:status: the link’s action, and no Refresh fact (#132: the button is gone)', () => {
const app = makeServices(monitor)
expect(app.lastPush('device:status')).toMatchObject({
state: 'scanning',
action: { offer: 'disconnect', intent: 'disconnect', disabled: true },
canRefresh: false
action: { offer: 'disconnect', intent: 'disconnect', disabled: true }
})
app.serial.open()
expect(app.lastPush('device:status')).toMatchObject({
state: 'connected',
action: { offer: 'disconnect', intent: 'disconnect' },
canRefresh: true
action: { offer: 'disconnect', intent: 'disconnect' }
})
app.serial.close()
app.serial.fail('EIO')
expect(app.lastPush('device:status')).toMatchObject({
state: 'error',
action: { offer: 'retry', intent: 'connect' },
canRefresh: false
action: { offer: 'retry', intent: 'connect' }
})
app.invoke(CTRL.deviceDisconnect)
expect(app.lastPush('device:status')).toMatchObject({
state: 'disconnected',
action: { offer: 'connect', intent: 'connect' },
canRefresh: false
action: { offer: 'connect', intent: 'connect' }
})
for (const push of app.pushesOf('device:status')) expect(push).not.toHaveProperty('canRefresh')
})

it('relay:facts: Record hidden and Capture shown in Replay, together; Settings’ row always', () => {
Expand Down
116 changes: 116 additions & 0 deletions src/main/app/reload.seam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// N1 SESSION-seam test: a renderer that (re)loads is handed what main still holds for the screen —
// the recording, the loaded capture, the serial monitor's flag, the aircraft, the gauges, the
// session's peak W and the link's last-known time — in the rehydrate bundle (`relay:status`) and
// the pushes its load sends. Before, main cleared its page-held copies on `relay:status`.
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { CTRL } from '@shared/app'
import { AIRCRAFT_NONE, TELEMETRY_IDLE } from '@shared/dcs'
import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware'
import { serializeCapture } from '../dcs/capture-file'
import { makeServices } from '../test-harness/services'
import { statusLine } from '../../test-fixtures/nodes'

/** The A-4E-C address carrying RPM, a gauge the telemetry tick reads. */
const [RPM_ADDRESS] = [...A4EC_OUTPUTS_BY_ADDRESS].find(([, o]) => o.some((x) => x.id === 'RPM'))!

/** One DCS-BIOS export frame: sync, then 16-bit writes from `address` on. */
function exportFrame(address: number, words: number[]): Buffer {
const head = [0x55, 0x55, 0x55, 0x55, address & 0xff, address >> 8, words.length * 2, 0]
return Buffer.from([...head, ...words.flatMap((w) => [w & 0xff, w >> 8])])
}

/** `_ACFT_NAME` (address 0, two characters per word), as DCS writes it. */
function aircraftFrame(name: string): Buffer {
const bytes = Buffer.from(name.padEnd(24, '\0'), 'latin1')
const words = Array.from(
{ length: bytes.length / 2 },
(_, i) => bytes[2 * i]! | (bytes[2 * i + 1]! << 8)
)
return exportFrame(0, words)
}

/** PDU_Left (node 3), 12 V rail page: 12.00 V average, 11.80 V minimum, `mA` average. */
const railPage = (mA: number) =>
`_NODE_TELEM 030100E02E182E${(mA & 0xff).toString(16).padStart(2, '0')}${(mA >> 8)
.toString(16)
.padStart(2, '0')}\n`

const monitor = {
paths: {
files: {
'config.json': { sourceMode: 'monitor' },
'flight.skyhawk-capture': serializeCapture([
{ t: 0, dir: 'in', hex: '55555555' },
{ t: 1500, dir: 'in', hex: '55555555' }
])
}
}
}

describe('a renderer reload keeps what main holds for the screen (N1)', () => {
it('the recording, the capture, the monitor flag, the aircraft, the gauges, peak W, last known', async () => {
const app = makeServices(monitor)
app.serial.open()

// The Serial tab's monitor on; the Cockpit's PDU drawing 8.4 W, then 1.2 W.
app.invoke(CTRL.serialMonitor, true)
app.serial.receive(statusLine(3, true, 2) + '_NODE_STATUS_END\n')
app.invoke(CTRL.nodeMappingAdopt, 3, {
name: 'PDU_Left',
console: 'Left',
pduId: null,
kind: 'pdu'
})
app.serial.receive(railPage(700))
app.clock.advance(1000)
app.serial.receive(railPage(100))
app.clock.advance(1000)
const peak = app.lastPush('cockpit:snapshot')!.band.peakW
expect(peak).toBeCloseTo(8.4)

// Relaying: DCS names the aircraft and moves RPM; a recording runs; a capture is loaded.
app.invoke(CTRL.relayStart)
app.transport.export(aircraftFrame('A-4E-C'))
app.transport.export(exportFrame(RPM_ADDRESS, [0x8000]))
app.clock.advance(200)
const aircraft = app.lastPush('aircraft:changed')!
const gauges = app.lastPush('telemetry:tick')!
expect(aircraft).toMatchObject({ name: 'A-4E-C', supported: true })
expect(gauges).not.toEqual(TELEMETRY_IDLE)
app.dialogs.saveCapture('session.skyhawk-capture')
await app.invoke(CTRL.captureToggle)
app.dialogs.openCapture('flight.skyhawk-capture')
await app.invoke(CTRL.replayOpen)
const facts = app.lastPush('relay:facts')!
expect(facts.recording).toEqual({ on: true })
expect(facts.replay).toEqual({
file: join(app.files.dir, 'flight.skyhawk-capture'),
events: 2,
durationMs: 1500
})

// Stopped, and the Gateway unplugged: the gauges and the aircraft stay as last shown.
app.invoke(CTRL.relayStop)
const at = app.clock.now()
app.serial.close()

// The window reloads.
const before = app.pushes.length
const bundle = app.invoke(CTRL.relayStatus)
expect(bundle.relayFacts.recording, 'the recording').toEqual({ on: true })
expect(bundle.relayFacts.replay, 'the loaded capture').toEqual(facts.replay)
expect(bundle.device.serialMonitor, 'the monitor flag').toBe(true)
expect(bundle.aircraft, 'the aircraft').toEqual(aircraft)
expect(bundle.aircraft).not.toEqual(AIRCRAFT_NONE)
expect(bundle.telemetry, 'the gauges').toEqual(gauges)
expect(bundle.device.linkDownAt, 'the last-known time').toBe(at)
const reloaded = app.pushes.slice(before)
expect(reloaded.map((p) => p.channel)).toContain('cockpit:snapshot')
expect(app.lastPush('cockpit:snapshot')?.band.peakW, 'peak W').toBe(peak)
expect(
reloaded.some((p) => p.channel === 'relay:facts' || p.channel === 'device:status'),
'nothing main holds changed, so nothing of it is pushed again'
).toBe(false)
})
})
Loading
Loading