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
25 changes: 16 additions & 9 deletions src/main/app/app-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,35 +570,42 @@ describe('device link is independent of the relay (#69)', () => {
session.shutdown()
})

it('refuses Start until the board is connected when the mode delivers to it', () => {
// #159: it used to be refused ('Gateway is not connected'), the pill silently flipping back.
it('starts with no board when the mode delivers to it, and waits for the board', () => {
const { session, serial } = launched({ sourceMode: 'bridge' })
expect(session.relay.start()).toEqual({ ok: false, error: 'Gateway is not connected' })
expect(session.status().running).toBe(false)
expect(FakeTransport.last, 'no DCS transport is opened').toBeUndefined()
expect(session.relay.start()).toEqual({ ok: true })
expect(session.status().running).toBe(true)
expect(FakeTransport.last, 'the DCS transport is opened').toBeDefined()
expect(session.relay.facts().running.waiting, 'waiting for the Gateway').toBe('device')
// The board arrives: delivery begins with no further Start.
serial.openCb('/dev/fake')
expect(session.relay.start().ok).toBe(true)
expect(session.relay.facts().running.waiting).not.toBe('device')
serial.written.length = 0
FakeTransport.last!.exportCb(Buffer.from([0x55, 0x55]))
expect(Buffer.concat(serial.written)).toEqual(Buffer.from([0x55, 0x55]))
expect(session.status().running).toBe(true)
session.shutdown()
})

it('does not reconnect a released link on Start', () => {
const { session, serial } = launched({ sourceMode: 'bridge' })
serial.openCb('/dev/fake')
session.connection.disconnect()
expect(session.relay.start().ok).toBe(false)
expect(session.relay.start().ok, 'it starts, and waits for the board').toBe(true)
expect(FakeSerial.last, 'Start leaves the link alone').toBe(serial)
expect(session.status().device.state).toBe('disconnected')
session.shutdown()
})

it('lets Monitor and an undriven Replay start with no board', () => {
it('lets Monitor start with no board; Replay is refused only for having no capture', () => {
const monitor = launched({ sourceMode: 'monitor' })
expect(monitor.session.relay.start().ok).toBe(true)
monitor.session.shutdown()

// Replay, driven or not, is only refused for having no capture, never for the board (#159).
const driven = launched({ sourceMode: 'replay', replayDriveSerial: true })
expect(driven.session.relay.start()).toEqual({ ok: false, error: 'Gateway is not connected' })
expect(driven.session.relay.start()).toEqual({ ok: false, error: 'No capture loaded' })
driven.session.shutdown()
// Undriven Replay is only refused for having no capture, never for the board.
const plain = launched({ sourceMode: 'replay', replayDriveSerial: false })
expect(plain.session.relay.start()).toEqual({ ok: false, error: 'No capture loaded' })
plain.session.shutdown()
Expand Down
4 changes: 2 additions & 2 deletions src/main/app/app-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export class AppServices {
// The DCS side keeps its order from before the split too (R2.3a). None of its constructors has
// a side effect.
// open the stream log's flush (33) first, then the connection's start
// relay Start Bridge / driven Replay refused unless the link is up → a running relay
// relay Start never gated on the link (#159) → a running relay
// stopped → decoder, parser, stats reset → DCS error forgotten → Replay:
// `connected`, playback; else the transport built and subscribed →
// `connecting` → transport start; then the telemetry (200) and stats
Expand Down Expand Up @@ -167,7 +167,7 @@ export class AppServices {
// The Cockpit snapshot follows the link too (strays and Attention only while it is up), after
// `device:status` has gone out, as the Cockpit re-rendered on it (R3.2).
this.connection.onStatus(() => this.nodes.linkStatus())
// So do the relay's facts (blocked, waiting for the Gateway, the Link tile), R3.3.
// So do the relay's facts (waiting for the Gateway, the Link tile), R3.3.
this.connection.onStatus(() => this.relay.inputsChanged())
// A settings change, in one listener so its order is explicit: auto-connect, then the relay.
this.settings.store.subscribe((config, prev) => {
Expand Down
59 changes: 48 additions & 11 deletions src/main/app/pushed-rules.seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,25 +54,62 @@ describe('device:status carries the link’s facts', () => {
})

describe('relay:facts: what the relay’s surfaces show', () => {
it('Bridge with no board: Start blocked; the board arriving lifts it', () => {
// #159: Start with no board used to be refused ('Gateway is not connected'): the pill flipped
// back to Stopped with no word why. Now it starts and waits, and the board arriving delivers.
it('Bridge with no board: Start starts and waits for the board; the board arriving delivers', () => {
const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } })
expect(app.lastPush('relay:facts')?.stopped.startBlocked).toBe('bridge')
expect(app.lastPush('relay:facts')?.stopped).not.toHaveProperty('startBlocked')
expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true })
expect(app.lastPush('relay:state')).toEqual({ running: true, stream: 'connecting' })
expect(app.lastPush('relay:facts')?.running).toMatchObject({
waiting: 'device',
tip: 'waiting-device',
overviewLink: 'waiting'
})
app.transport.connected(true)
// The board arrives: no further click, the relay delivers to it.
app.serial.open()
expect(app.lastPush('relay:facts')?.stopped.startBlocked).toBeUndefined()
expect(app.lastPush('relay:facts')?.running.waiting).toBe('dcs')
expect(app.lastPush('relay:facts')?.running).toMatchObject({
waiting: 'none',
tip: 'delivering'
})
app.serial.clearWritten()
app.transport.export('UUUU')
expect(app.serial.writtenText()).toBe('UUUU')
expect(app.lastPush('relay:state')?.running, 'still the one relay').toBe(true)
})

it('a driven Replay with no board: Start plays and waits for the board (#159)', async () => {
const app = makeServices({
paths: {
files: {
'config.json': { sourceMode: 'replay', replayDriveSerial: true },
'flight.skyhawk-capture': serializeCapture([{ t: 0, dir: 'in', hex: '55555555' }])
}
}
})
app.dialogs.openCapture('flight.skyhawk-capture')
await app.invoke(CTRL.replayOpen)
expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true })
expect(app.lastPush('relay:facts')?.running).toMatchObject({
waiting: 'device',
tip: 'waiting-device',
overviewLink: 'waiting'
})
app.serial.open()
expect(app.lastPush('relay:facts')?.running.tip).toBe('replay-playing')
})

it('toggle: Start saves the settings first; a refused Start pushes no relay state', () => {
const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } })
// Replay with no capture loaded: the one Start main still refuses.
const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'replay' } } } })
app.serial.open()
const states = app.pushesOf('relay:state').length
expect(app.invoke(CTRL.relayToggle, false)).toEqual({
ok: false,
error: 'Gateway is not connected'
})
expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: false, error: 'No capture loaded' })
expect(app.pushesOf('relay:state')).toHaveLength(states)
expect(app.files.json('config.json')).toMatchObject({ sourceMode: 'bridge', commandPort: 7778 })
expect(app.files.json('config.json')).toMatchObject({ sourceMode: 'replay', commandPort: 7778 })
expect(app.lastPush('config:changed')?.source).toBe('edit')
app.serial.open()
app.invoke(CTRL.configEdit, { sourceMode: 'bridge' })
expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true })
expect(app.lastPush('relay:state')).toEqual({ running: true, stream: 'connecting' })
expect(app.invoke(CTRL.relayToggle, true)).toEqual({ ok: true })
Expand Down
12 changes: 4 additions & 8 deletions src/main/dcs/dcs-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,22 +125,18 @@ export class DcsRelay {
/**
* Start relaying. Attaches the stream to the device link; never opens or closes the port.
*
* Refused while a mode that delivers to the board has no board to deliver to: nothing can be
* relayed before the link is up. Once running, a drop does not end the relay — it waits, and
* resumes when the link returns.
* Never gated on the board (#159): a mode that delivers to it starts with no board and waits,
* as it does through a drop (relay-rules waitingForDevice), and delivers once the link is up.
*/
start(): RelayResult {
if (exportToDevice(this.config) && !isLinkUp(this.device.deviceStatus().state)) {
return { ok: false, error: 'Gateway is not connected' }
}
return this.run()
}

/** A settings change: a running relay is rebuilt if a setting it is built from changed. */
reconfigure(prev: RelayOptions, next: RelayOptions): void {
if (this.running && relayConfigChanged(prev, next)) {
// A rebuild of a relay already running, not a new Start: it is not gated on the device,
// or editing a setting during a drop would end a relay that only Stop may end.
// A rebuild of a relay already running: only Stop may end it, so a rebuild during a drop
// keeps it running, waiting for the board.
this.stop()
this.run()
}
Expand Down
11 changes: 8 additions & 3 deletions src/main/dcs/relay-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@ describe('relayFacts', () => {

it('each side of the on/off: a pending Start reads as running, with the stream not up yet', () => {
const f = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'bridge' } })
expect(f.stopped).toMatchObject({ tip: 'stopped', startBlocked: 'bridge', waiting: 'none' })
// #159: Start is never blocked for want of the board — the Start it shows waits for it.
expect(f.stopped).toEqual({
tip: 'stopped',
waiting: 'none',
overviewLink: 'stopped',
canRecord: false
})
expect(f.running).toMatchObject({ tip: 'waiting-device', waiting: 'device' })
expect(f.running.startBlocked, 'a running relay is never blocked').toBeUndefined()
const replay = facts({
config: { ...DEFAULT_CONFIG, sourceMode: 'replay', replayDriveSerial: true }
})
expect(replay.stopped.startBlocked).toBe('replay')
expect(replay.stopped).not.toHaveProperty('startBlocked')
expect(replay.running.tip, 'a driven Replay waits for the board (N3)').toBe('waiting-device')
const undriven = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'replay' } })
expect(undriven.running.tip).toBe('replay-playing')
Expand Down
28 changes: 2 additions & 26 deletions src/main/dcs/relay-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export type RelayOptions = Pick<AppConfig, (typeof RELAY_KEYS)[number]>
type ForwardConfig = Pick<RelayOptions, 'sourceMode' | 'replayDriveSerial'>

/**
* Whether the export stream is written to the SimGateway — and so whether Start needs the board.
* Whether the export stream is written to the SimGateway — and so whether a running relay waits
* for the board (#159: Start never needs it; with no board the relay runs and waits).
*
* Bridge always; Replay only with "Drive SimGateway"; Monitor never. Monitor has to say so
* explicitly now that the port is open in every mode — before #69 it was read-only only because
Expand Down Expand Up @@ -103,23 +104,6 @@ export function relayNote(opts: RelayOpts): RelayWaiting {
return 'none'
}

/**
* Start is refused while a mode that delivers to the board has no board to deliver to: Bridge, and
* Replay driving the SimGateway. Monitor and an undriven Replay never touch it. The same rule as
* DcsRelay.start(), which is the one that actually enforces it.
*/
export function startBlocked(opts: {
mode: SourceMode
replayDriveSerial: boolean
deviceState: DeviceState
}): boolean {
const needsBoard = exportToDevice({
sourceMode: opts.mode,
replayDriveSerial: opts.replayDriveSerial
})
return needsBoard && opts.deviceState !== 'connected'
}

/**
* A running relay that is not delivering end to end — shown degraded rather than green. It keeps
* running and resumes on its own; this only stops the pill claiming data is flowing when it is not.
Expand Down Expand Up @@ -224,17 +208,9 @@ function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces {
stream: i.stream,
deviceState: i.deviceState
}
const blocked =
!relaying &&
startBlocked({
mode: i.config.sourceMode,
replayDriveSerial: i.config.replayDriveSerial,
deviceState: i.deviceState
})
return {
waiting: relayNote(o),
tip: relayTip(o),
...(blocked ? { startBlocked: i.config.sourceMode === 'replay' ? 'replay' : 'bridge' } : {}),
overviewLink: overviewLink(o, i.linkUp),
canRecord: canRecord(relaying, i.recording.on)
}
Expand Down
20 changes: 20 additions & 0 deletions src/main/nodes/cockpit-snapshot/connection-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,23 @@ describe('nodesSummary rows', () => {
expect([s.rows, s.more, s.footer]).toEqual([[], 0, 'healthy nodes hidden'])
})
})

describe('nodesSummary with no board of your cockpit (#160)', () => {
const panelNew = (nodeId: number) => node(nodeId, { source: 'new', identified: 'panel' })

it('points to the new boards on the bus — the Overview notice’s count — and where to add them', () => {
const one = summary([panelNew(41)])
expect([one.known, one.newCount]).toEqual([0, 1])
expect(one.empty).toBe('1 new board on the bus — add it from the Cockpit.')
const two = summary([panelNew(41), pdu(42, 'Left', { source: 'new', identified: 'pdu' })])
expect(two.newCount).toBe(2)
expect(two.empty).toBe('2 new boards on the bus — add them from the Cockpit.')
})

it('says the bus reports nothing only when it does: none on it, or none identified yet', () => {
expect(summary([]).empty).toBe('No nodes reported. Power on a PanelGroup.')
// Still identifying: not news yet (isReportableNew), so not counted.
const s = summary([node(41, { source: 'new' })])
expect([s.newCount, s.empty]).toEqual([0, 'No nodes reported. Power on a PanelGroup.'])
})
})
2 changes: 1 addition & 1 deletion src/main/nodes/cockpit-snapshot/panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ describe('nodeStatusRows', () => {
{ k: 'Uptime', v: '01:02:05' },
{ k: 'CAN frames rx', v: '12,345' },
{ k: 'CAN TEC / REC', v: '8 / 1' },
{ k: 'CAN state', v: 'error-active' },
{ k: 'CAN state', v: 'OK' },
{ k: 'MCU temp', v: '33 °C' }
])
})
Expand Down
2 changes: 1 addition & 1 deletion src/main/nodes/cockpit-snapshot/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export function nodeStatusRows(v: NodeView): Row[] {
{ k: 'CAN TEC / REC', v: live ? `${live.tec} / ${live.rec}` : DASH },
{
k: 'CAN state',
v: live ? (live.boff ? 'bus-off' : live.epvf ? 'error-passive' : 'error-active') : DASH
v: live ? (live.boff ? 'bus-off' : live.epvf ? 'error-passive' : 'OK') : DASH
},
{ k: 'MCU temp', v: live && live.dieTempC !== null ? `${live.dieTempC} °C` : DASH }
]
Expand Down
19 changes: 18 additions & 1 deletion src/main/nodes/cockpit-snapshot/summaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
// adopted (node-model.ts newState), each one a board up and a warning, and an Attention item.
// What is on the bus is one rule (node-model.ts onBusNow, X2): while the link is down the tile says
// "no live node status", and the banner, the notice and Connected nodes say nothing.
// With none of your cockpit's boards to count, Connected nodes points to the new ones on the bus
// (#160): the same count as the Overview's notice and the Cockpit's "New on the bus".
// A Connected nodes row names its board's resolved power zone (D7, the same one the Cockpit reads):
// `PDU_Right`, `PDU_Right (hidden)`, else "no power zone".
import { hhmmss, lastSeenLabel, plural } from '@shared/format'
import {
COCKPIT_IDLE,
NO_NODES,
type AttentionItem,
type AttentionSummary,
type CockpitTile,
Expand Down Expand Up @@ -139,6 +142,16 @@ export function overviewSummary(

// ── Connection › Connected nodes ────────────────────────────────────────────

/**
* The card's line with no board of your cockpit to count: the new boards on the bus and where to
* add them (#160) — `1 new board on the bus — add it from the Cockpit.` — else that the bus
* reports nothing.
*/
function emptyLine(newCount: number): string {
if (newCount === 0) return NO_NODES
return `${plural(newCount, 'new board')} on the bus — add ${newCount === 1 ? 'it' : 'them'} from the Cockpit.`
}

/**
* Connected nodes: the counts and the rows that need attention, the Cockpit's strays included (X3:
* each one a board up and a warning, with its Attention row). Quiet while the link is down — it
Expand All @@ -157,6 +170,8 @@ export function nodesSummary(
const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry]))
const items = attentionItems(views, outages, strays)
const healthy = band.online - band.warn
// The Overview's notice's count (the Cockpit's "New on the bus · N"): one rule, isReportableNew.
const newCount = newDevices(views, live)?.count ?? 0

const rows = items.slice(0, ATTENTION_SHOWN).map((i): SummaryRow => {
const e = byId.get(i.nodeId)!
Expand Down Expand Up @@ -202,6 +217,8 @@ export function nodesSummary(
watts: band.watts,
rows,
more,
footer
footer,
newCount,
empty: emptyLine(newCount)
}
}
12 changes: 4 additions & 8 deletions src/renderer/src/dcs/RelayControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useDcsStore } from './store'
import { relayNote, relaySide, startBlockedReason } from './relay-view'
import { relayNote, relaySide } from './relay-view'

// The Connection tab's relay controls (R3.4b: cut from Connection.tsx). The tab puts its settings
// cards (source mode, DCS link) between them, so each is its own component.
Expand Down Expand Up @@ -112,28 +112,24 @@ export function RecordCard() {
)
}

/** Start / Stop relay, and what a running relay is waiting for or why Start is blocked. */
/** Start / Stop relay, and what a running relay is waiting for. Start always starts (#159). */
export function RelayStart() {
const facts = useDcsStore((x) => x.relayFacts)
const relaying = useDcsStore((x) => x.relaying)
const toggleRelay = useDcsStore((x) => x.toggleRelay)
// The relay controls as main decides them (relay:facts, R3.3), for the on/off as shown.
const side = relaySide(facts, relaying)
const blocked = side.startBlocked
const note = blocked
? { text: startBlockedReason(blocked), info: true }
: relayNote(side.waiting, facts.stream)
const note = relayNote(side.waiting, facts.stream)
return (
<div>
<button
className={`bigbtn${relaying ? ' bigbtn--stop' : ''}`}
style={{ width: '100%' }}
disabled={!!blocked}
onClick={() => toggleRelay()}
>
{relaying ? 'Stop relay' : 'Start relay'}
</button>
{note && <div className={`waitnote${note.info ? ' waitnote--info' : ''}`}>{note.text}</div>}
{note && <div className="waitnote">{note}</div>}
</div>
)
}
Loading
Loading