diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index bd642da..f291df3 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -5,6 +5,7 @@ // switch (config:edit). import { describe, expect, it } from 'vitest' import { CTRL } from '@shared/app' +import { serializeCapture } from '../dcs/capture-file' import { makeServices } from '../test-harness/services' const monitor = { paths: { files: { 'config.json': { sourceMode: 'monitor' } } } } @@ -78,19 +79,92 @@ describe('relay:facts: what the relay’s surfaces show', () => { expect(app.lastPush('relay:state')).toEqual({ running: false }) }) - it('a form’s edit: built over the settings held, the port as typed; the facts follow', () => { + it('a form’s edit: built over the settings held, a typed port checked (N5); the facts follow', () => { const app = makeServices(monitor) app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) - expect(app.lastPush('relay:facts')?.endpoint).toEqual({ - pill: { text: 'unicast listen' }, - overview: { text: '239.255.50.10:5010 · local' } + expect(app.lastPush('relay:facts')?.endpoint).toBe('this PC:5010') + expect(app.invoke(CTRL.configEdit, { port: '7781' })).toMatchObject({ + ok: true, + config: { commandPort: 7781 } + }) + // N5: a port that is not one is refused with why; nothing is saved, nothing pushed. + const changes = app.pushesOf('config:changed').length + expect(app.invoke(CTRL.configEdit, { port: '77a' })).toEqual({ + ok: false, + error: 'Not a port. Enter a whole number from 1 to 65535.' }) - app.invoke(CTRL.configEdit, { port: '77a' }) expect(app.files.json('config.json')).toMatchObject({ transport: 'unicast-listen', - commandPort: 7778 + commandPort: 7781 + }) + expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7781) + expect(app.pushesOf('config:changed')).toHaveLength(changes) + }) +}) + +describe('fix track #134: the relay endpoint, Record and the waiting wording', () => { + it('X9: one endpoint, from the settings held, right for each transport', () => { + const app = makeServices(monitor) + expect(app.lastPush('relay:facts')?.endpoint).toBe('127.0.0.1:7778') + app.invoke(CTRL.configEdit, { host: '10.0.0.7', port: '7779' }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('10.0.0.7:7779') + app.invoke(CTRL.configEdit, { transport: 'loopback-multicast' }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('239.255.50.10:5010') + app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('this PC:5010') + }) + + it('X12: Record refused while stopped, before any dialog; allowed while relaying', async () => { + const app = makeServices(monitor) + expect(app.lastPush('relay:facts')).toMatchObject({ + running: { canRecord: true }, + stopped: { canRecord: false } + }) + app.dialogs.saveCapture('session.skyhawk-capture') + expect(await app.invoke(CTRL.captureToggle)).toEqual({ recording: false }) + expect(app.lastPush('relay:facts')?.recording).toEqual({ on: false }) + app.invoke(CTRL.relayStart) + expect(await app.invoke(CTRL.captureToggle)).toMatchObject({ recording: true }) + // Stopped mid-recording: the recording can still be stopped. + app.invoke(CTRL.relayStop) + expect(app.lastPush('relay:facts')?.stopped.canRecord).toBe(true) + expect(await app.invoke(CTRL.captureToggle)).toMatchObject({ recording: false }) + expect(app.lastPush('relay:facts')?.stopped.canRecord).toBe(false) + }) + + it('N2: Monitor relaying with no Gateway is running, not waiting for it', () => { + const app = makeServices(monitor) + app.invoke(CTRL.relayStart) + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + overviewLink: 'running', + tip: 'waiting-dcs', + waiting: 'dcs' + }) + }) + + it('N3: a driven Replay that loses the Gateway waits for it, as Bridge does', async () => { + const app = makeServices({ + paths: { + files: { + 'config.json': { sourceMode: 'replay', replayDriveSerial: true }, + 'flight.skyhawk-capture': serializeCapture([ + { t: 0, dir: 'in', hex: '55555555' }, + { t: 1500, dir: 'in', hex: '55555555' } + ]) + } + } + }) + app.dialogs.openCapture('flight.skyhawk-capture') + await app.invoke(CTRL.replayOpen) + app.serial.open() + expect(app.invoke(CTRL.relayStart)).toEqual({ ok: true }) + expect(app.lastPush('relay:facts')?.running.tip).toBe('replay-playing') + app.serial.close() + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + waiting: 'device', + tip: 'waiting-device', + overviewLink: 'waiting' }) - expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7778) }) }) diff --git a/src/main/config/settings-edit.test.ts b/src/main/config/settings-edit.test.ts index ad0fdc6..19cb848 100644 --- a/src/main/config/settings-edit.test.ts +++ b/src/main/config/settings-edit.test.ts @@ -12,16 +12,21 @@ import { } from '@shared/config' import { storePaths } from '../app/user-data' import { withSectionNotes } from './config-file' -import { buildConfig, editedConfig, formOf, savedEdit, withPsuLimit } from './settings-form' +import { + buildConfig, + COMMAND_PORT_ERROR, + commandPortOf, + editedConfig, + formOf, + savedEdit, + withPsuLimit +} from './settings-form' import { SettingsService } from './settings-service' describe('the forms’ save (buildConfig)', () => { - it('saves every field the forms edit; a port that isn’t a number saves as 7778 (N5)', () => { + it('saves every field the forms edit', () => { const form = formOf(DEFAULT_CONFIG) - expect(buildConfig({ ...form, port: '7779' }).commandPort).toBe(7779) - for (const junk of ['', '77a', 'abc', '0']) { - expect(buildConfig({ ...form, port: junk }).commandPort, junk).toBe(7778) - } + expect(buildConfig({ ...form, commandPort: 7779 }).commandPort).toBe(7779) expect(Object.keys(buildConfig(form)).sort()).toEqual( [ 'attentionBanner', @@ -44,10 +49,37 @@ describe('the forms’ save (buildConfig)', () => { host: '10.0.0.7', commandPort: 7790 }) - expect(editedConfig(current, { port: '77a' }).commandPort).toBe(7778) expect(editedConfig(current, {})).toEqual(buildConfig(formOf(current))) }) + it('N5: the command port as typed is a port only as whole digits, 1–65535', () => { + expect(commandPortOf('7779')).toBe(7779) + expect(commandPortOf(' 7779 ')).toBe(7779) + expect(commandPortOf('1')).toBe(1) + expect(commandPortOf('65535')).toBe(65535) + for (const junk of ['', ' ', '77a', 'abc', '0', '65536', '-1', '7778.5', '1e3', '0x1f']) { + expect(commandPortOf(junk), junk).toBeUndefined() + } + }) + + it('N5: a junk port is refused with its error, never saved as 7778', () => { + const current = { ...DEFAULT_CONFIG, host: '10.0.0.7', commandPort: 7790 } + for (const junk of ['', '77a', 'abc', '0', '70000']) { + expect(savedEdit(current, { port: junk }), junk).toEqual({ + ok: false, + error: COMMAND_PORT_ERROR + }) + } + expect(savedEdit(current, { port: '7781' })).toEqual({ + ok: true, + save: editedConfig(current, { commandPort: 7781 }) + }) + expect(savedEdit(current, { host: '10.0.0.9' })).toMatchObject({ + ok: true, + save: { host: '10.0.0.9', commandPort: 7790 } + }) + }) + it('a PSU mode switch keeps the preset and the custom limits (R3.4a: `{ ...psu, mode }`)', () => { const psu = { mode: 'preset' as const, preset: 'nx650' as const, custom: { a12: 40, w: 500 } } const current = { ...DEFAULT_CONFIG, psu } @@ -67,15 +99,18 @@ describe('the forms’ save (buildConfig)', () => { } expect(psu.custom, 'the setting held is never changed in place').toEqual({ a12: 40, w: 500 }) const current = { ...DEFAULT_CONFIG, psu } - expect(savedEdit(current, { psuLimit: { key: 'w', text: '400' } })?.psu).toEqual({ - ...psu, - custom: { a12: 40, w: 400 } + expect(savedEdit(current, { psuLimit: { key: 'w', text: '400' } })).toMatchObject({ + ok: true, + save: { psu: { ...psu, custom: { a12: 40, w: 400 } } } }) expect( savedEdit(current, { psuLimit: { key: 'w', text: 'x' } }), - 'nothing to save' - ).toBeUndefined() - expect(savedEdit(current, { host: 'h' })).toEqual(editedConfig(current, { host: 'h' })) + 'nothing to save, quietly' + ).toEqual({ ok: false }) + expect(savedEdit(current, { host: 'h' })).toEqual({ + ok: true, + save: editedConfig(current, { host: 'h' }) + }) }) }) @@ -99,8 +134,9 @@ describe('SettingsService: edits saved and pushed back (config:changed)', () => it('an edit is built over the settings, saved, and comes back as an edit', () => { const { s, pushed } = service() s.update({ host: '10.0.0.7' }) - const cfg = s.edit({ port: '7781' }) - expect(cfg).toMatchObject({ host: '10.0.0.7', commandPort: 7781 }) + const r = s.edit({ port: '7781' }) + expect(r).toMatchObject({ ok: true, config: { host: '10.0.0.7', commandPort: 7781 } }) + const cfg = s.get() expect(JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'))).toMatchObject({ commandPort: 7781 }) @@ -111,13 +147,23 @@ describe('SettingsService: edits saved and pushed back (config:changed)', () => const { s, pushed } = service() s.edit({ psu: { mode: 'custom', preset: 'nx650', custom: { w: 400 } } }) const before = readFileSync(join(dir, 'config.json'), 'utf8') - expect(s.edit({ psuLimit: { key: 'w', text: '40x' } })).toBe(s.get()) + expect(s.edit({ psuLimit: { key: 'w', text: '40x' } })).toEqual({ ok: false }) expect(pushed).toHaveLength(1) expect(readFileSync(join(dir, 'config.json'), 'utf8')).toBe(before) s.edit({ psuLimit: { key: 'w', text: '' } }) expect(pushed.at(-1)?.config.psu.custom).toEqual({}) }) + it('N5: a junk port answers why, and saves and pushes nothing', () => { + const { s, pushed } = service() + s.edit({ port: '7781' }) + const before = readFileSync(join(dir, 'config.json'), 'utf8') + expect(s.edit({ port: '77a' })).toEqual({ ok: false, error: COMMAND_PORT_ERROR }) + expect(s.get().commandPort).toBe(7781) + expect(pushed).toHaveLength(1) + expect(readFileSync(join(dir, 'config.json'), 'utf8')).toBe(before) + }) + it('the power supply comes back resolved; an import says so, once it is applied', () => { const { s, pushed } = service() s.edit({ psu: { mode: 'custom', preset: 'nx650', custom: { w: 400 } } }) diff --git a/src/main/config/settings-form.ts b/src/main/config/settings-form.ts index 68f2dfd..693d457 100644 --- a/src/main/config/settings-form.ts +++ b/src/main/config/settings-form.ts @@ -4,16 +4,35 @@ import type { AppConfig, ConfigEdit } from '@shared/config' import { parsePsuLimit, type PsuLimits, type PsuSetting } from '@shared/nodes' -/** The edited fields, with the command port as text (what the Connection forms show). */ -export type ConfigForm = Required> +/** The edited fields as the settings hold them (the command port as its number). */ +export type ConfigForm = Required> & { + commandPort: number +} + +/** A form's change once its typed port is read (commandPortOf): the fields, and the PSU switch. */ +export type FormEdit = Partial & Pick + +/** What a refused command port says, under the field (N5). */ +export const COMMAND_PORT_ERROR = 'Not a port. Enter a whole number from 1 to 65535.' -/** The forms' fields as the settings hold them: the port as its digits. */ +/** + * The command port as typed, if it is one: whole digits (spaces around them ignored), 1–65535. + * Anything else is no port (undefined), and nothing is saved for it (N5). + */ +export function commandPortOf(text: string): number | undefined { + const t = text.trim() + if (!/^\d{1,5}$/.test(t)) return undefined + const n = Number(t) + return n >= 1 && n <= 65535 ? n : undefined +} + +/** The forms' fields as the settings hold them. */ export function formOf(cfg: AppConfig): ConfigForm { return { sourceMode: cfg.sourceMode, transport: cfg.transport, host: cfg.host, - port: String(cfg.commandPort), + commandPort: cfg.commandPort, autoConnect: cfg.autoConnect, replayDriveSerial: cfg.replayDriveSerial, debugMode: cfg.debugMode, @@ -22,16 +41,13 @@ export function formOf(cfg: AppConfig): ConfigForm { } } -/** - * The settings save the forms make: every field they edit, saved whole. A port that isn't a - * number, or is empty, saves as 7778 — while the field keeps showing what was typed (N5). - */ +/** The settings save the forms make: every field they edit, saved whole. */ export function buildConfig(s: ConfigForm): Partial { return { sourceMode: s.sourceMode, transport: s.transport, host: s.host, - commandPort: Number(s.port) || 7778, + commandPort: s.commandPort, autoConnect: s.autoConnect, replayDriveSerial: s.replayDriveSerial, debugMode: s.debugMode, @@ -45,8 +61,8 @@ export function buildConfig(s: ConfigForm): Partial { * them, built into the save (buildConfig). A PSU mode switch keeps the rest of the setting — the * preset and the custom limits — as it is (R3.4a: Settings › Cockpit's `{ ...psu, mode }`). */ -export function editedConfig(current: AppConfig, edit: ConfigEdit): Partial { - const { psuMode: mode, psuLimit: _limit, ...fields } = edit +export function editedConfig(current: AppConfig, edit: FormEdit): Partial { + const { psuMode: mode, ...fields } = edit const form = { ...formOf(current), ...fields } return buildConfig(mode === undefined ? form : { ...form, psu: { ...form.psu, mode } }) } @@ -69,14 +85,24 @@ export function withPsuLimit( return { ...psu, custom } } +/** What a form's change comes to: the save, or why nothing is saved (`error`, for the form). */ +export type SavedEdit = { ok: true; save: Partial } | { ok: false; error?: string } + /** - * A form's change as SettingsService saves it (`config:edit`): editedConfig, with a typed PSU limit - * applied over the setting held. undefined = refused, nothing to save — as the form sent nothing - * for it before. + * A form's change as SettingsService saves it (`config:edit`): editedConfig, with the typed port + * read (commandPortOf) and a typed PSU limit applied over the setting held. A port that is not one + * is refused with its error (N5); a refused PSU limit is refused quietly, as the form sent nothing + * for it before (its field judges itself). Refused = nothing to save. */ -export function savedEdit(current: AppConfig, edit: ConfigEdit): Partial | undefined { - const { psuLimit: limit, ...rest } = edit - if (limit === undefined) return editedConfig(current, rest) +export function savedEdit(current: AppConfig, edit: ConfigEdit): SavedEdit { + const { psuLimit: limit, port, ...rest } = edit + const fields: FormEdit = rest + if (port !== undefined) { + const commandPort = commandPortOf(port) + if (commandPort === undefined) return { ok: false, error: COMMAND_PORT_ERROR } + fields.commandPort = commandPort + } + if (limit === undefined) return { ok: true, save: editedConfig(current, fields) } const psu = withPsuLimit(current.psu, limit.key, limit.text) - return psu && editedConfig(current, { ...rest, psu }) + return psu ? { ok: true, save: editedConfig(current, { ...fields, psu }) } : { ok: false } } diff --git a/src/main/config/settings-service.ts b/src/main/config/settings-service.ts index 74519d8..e5e3bed 100644 --- a/src/main/config/settings-service.ts +++ b/src/main/config/settings-service.ts @@ -17,6 +17,7 @@ import { type AppConfig, type ConfigChanged, type ConfigEdit, + type ConfigEditResult, type ConfigView, type SettingsStore } from '@shared/config' @@ -157,10 +158,12 @@ export class SettingsService { * A form's change (`config:edit`, R3.3): what the renderer's store rebuilt into a save before — * read-modify-write over the settings held here, the port as typed (settings-form.ts). */ - edit(edit: ConfigEdit): AppConfig { - const save = savedEdit(this.get(), edit) - // A refused PSU limit saves and pushes nothing: the form sent nothing for it before (R3.4a). - return save === undefined ? this.get() : this.apply(save) + edit(edit: ConfigEdit): ConfigEditResult { + const built = savedEdit(this.get(), edit) + // A refused change saves and pushes nothing: a port that is not one answers why (N5); a + // refused PSU limit, quietly, as the form sent nothing for it before (R3.4a). + if (!built.ok) return built + return { ok: true, config: this.apply(built.save) } } /** diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts index cd4e312..d6a6b18 100644 --- a/src/main/dcs/dcs-relay.ts +++ b/src/main/dcs/dcs-relay.ts @@ -25,6 +25,7 @@ import { DcsBiosProtocol } from './bios-protocol' import type { DcsConnection } from './dcs-connection' import { relayContext, relayEvents } from './relay-events' import { + canRecord, captureView, deviceToDcs, exportToDevice, @@ -254,6 +255,14 @@ export class DcsRelay { }) } + /** + * May the Record control act now (X12, relay-rules canRecord): start a recording only while + * relaying; stop one already on whenever. The rule the surfaces' `canRecord` shows. + */ + mayRecord(): boolean { + return canRecord(this.running, this.recorder.isRecording()) + } + /** The link or the settings changed (AppServices): push the facts if they changed with them. */ inputsChanged(): void { this.pushFacts() diff --git a/src/main/dcs/ipc.ts b/src/main/dcs/ipc.ts index e272559..7b55318 100644 --- a/src/main/dcs/ipc.ts +++ b/src/main/dcs/ipc.ts @@ -32,8 +32,10 @@ export function registerDcsIpc(ipc: IpcRegistrar, deps: DcsIpcDeps): void { return { path: filePath } }) - // The Record controls and the Capture card show the answer (relay:facts, R3.3). + // The Record controls and the Capture card show the answer (relay:facts, R3.3). A recording + // starts only while relaying (X12): refused before the save dialog, nothing changes. ipc.handle(DCS_CTRL.captureToggle, async (): Promise => { + if (!relay.mayRecord()) return { recording: false } const state = await toggleCapture() relay.showCapture(state) return state diff --git a/src/main/dcs/relay-events.ts b/src/main/dcs/relay-events.ts index fb592d0..2b1397f 100644 --- a/src/main/dcs/relay-events.ts +++ b/src/main/dcs/relay-events.ts @@ -1,12 +1,13 @@ // Relay events (pure, no Node/Electron deps): the event-log rows the relay and the DCS-BIOS stream // make as they start, stop, come up and drop. import type { AppConfig, SourceMode } from '@shared/config' -import { EXPORT_MULTICAST_ADDR, EXPORT_PORT, type RelayState } from '@shared/dcs' +import type { RelayState } from '@shared/dcs' import type { LogEventInput } from '@shared/events' +import { relayEndpoint } from './relay-rules' export interface RelayContext { mode: SourceMode - /** Where the DCS-BIOS stream comes from, e.g. `127.0.0.1:7778`. */ + /** Where the DCS-BIOS stream comes from, e.g. `127.0.0.1:7778` (relay-rules relayEndpoint). */ endpoint: string /** TCP connects; UDP only binds, so "connected" there is "listening". */ tcp: boolean @@ -16,13 +17,12 @@ export interface RelayContext { export function relayContext( cfg: Pick ): RelayContext { - const tcp = cfg.transport === 'tcp-to-host' - const endpoint = tcp - ? `${cfg.host}:${cfg.commandPort}` - : cfg.transport === 'loopback-multicast' - ? `${EXPORT_MULTICAST_ADDR}:${EXPORT_PORT}` - : `:${cfg.listenPort}` - return { mode: cfg.sourceMode, endpoint, tcp } + // The one endpoint label every surface shows (X9). + return { + mode: cfg.sourceMode, + endpoint: relayEndpoint(cfg), + tcp: cfg.transport === 'tcp-to-host' + } } const MODE_LABEL: Record = { diff --git a/src/main/dcs/relay-rules.test.ts b/src/main/dcs/relay-rules.test.ts index 967f0ba..cb9832a 100644 --- a/src/main/dcs/relay-rules.test.ts +++ b/src/main/dcs/relay-rules.test.ts @@ -7,14 +7,14 @@ import { RELAY_FACTS_IDLE } from '@shared/dcs' import { canRecord, captureView, - overviewEndpoint, overviewLink, captureShown, - pillEndpoint, recordShown, + relayEndpoint, relayFacts, relayNote, relayTip, + relayWaiting, replayView } from './relay-rules' @@ -41,7 +41,9 @@ describe('relayFacts', () => { config: { ...DEFAULT_CONFIG, sourceMode: 'replay', replayDriveSerial: true } }) expect(replay.stopped.startBlocked).toBe('replay') - expect(replay.running.tip).toBe('replay-playing') + 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') }) it('carries the mode and the transport it was judged against (R3.4c)', () => { @@ -49,50 +51,105 @@ describe('relayFacts', () => { config: { ...DEFAULT_CONFIG, sourceMode: 'replay', transport: 'unicast-listen' } }) expect(f).toMatchObject({ mode: 'replay', transport: 'unicast-listen', captureShown: true }) - expect(f.endpoint.pill).toEqual(pillEndpoint('unicast-listen')) + expect(f.endpoint).toBe(relayEndpoint({ ...DEFAULT_CONFIG, transport: 'unicast-listen' })) }) }) -describe('N3: waiting for the Gateway is Bridge only; a driven Replay is blocked, never waiting', () => { - it.each(['monitor', 'replay'] as const)('%s, no board, running', (mode) => { - const o = { - mode, - relaying: true, - stream: 'connected' as const, - deviceState: 'no-device' as const - } +describe('N3: waiting for the Gateway — Bridge and a driven Replay', () => { + const run = (mode: 'bridge' | 'monitor' | 'replay', replayDriveSerial: boolean) => ({ + mode, + replayDriveSerial, + relaying: true, + stream: 'connected' as const, + deviceState: 'no-device' as const + }) + + it.each([ + ['bridge', false], + ['replay', true] + ] as const)('%s (drive %s), no board, running: the waiting reason', (mode, drive) => { + const o = run(mode, drive) + expect(relayNote(o)).toBe('device') + expect(relayTip(o)).toBe('waiting-device') + expect(relayWaiting(o)).toBe(true) + }) + + it.each([ + ['monitor', false], + ['monitor', true], + ['replay', false] + ] as const)('%s (drive %s), no board, running: never waiting for it', (mode, drive) => { + const o = run(mode, drive) expect(relayNote(o)).toBe('none') expect(relayTip(o)).not.toBe('waiting-device') + expect(relayWaiting(o)).toBe(false) + }) + + it('a driven Replay with the board up plays on', () => { + expect(relayTip({ ...run('replay', true), deviceState: 'connected' })).toBe('replay-playing') }) }) -describe('N2: the Overview says "relay waiting" whenever a relay runs with the link down', () => { - it('in every mode, Monitor included', () => { - expect(overviewLink(true, false)).toBe('waiting') - expect(overviewLink(true, true)).toBe('gateway-relaying') - expect(overviewLink(false, true)).toBe('gateway-stopped') - expect(overviewLink(false, false)).toBe('stopped') +describe('N2: the Overview says "relay waiting" only while the relay waits for the board', () => { + const o = ( + mode: 'bridge' | 'monitor' | 'replay', + relaying: boolean, + replayDriveSerial = false + ) => ({ + mode, + replayDriveSerial, + relaying, + stream: 'connected' as const, + deviceState: 'no-device' as const + }) + + it('Bridge and a driven Replay wait; Monitor and an undriven Replay just run', () => { + expect(overviewLink(o('bridge', true), false)).toBe('waiting') + expect(overviewLink(o('replay', true, true), false)).toBe('waiting') + expect(overviewLink(o('monitor', true), false)).toBe('running') + expect(overviewLink(o('replay', true), false)).toBe('running') + }) + + it('stopped, or with the link up, the same in every mode', () => { + for (const mode of ['bridge', 'monitor', 'replay'] as const) { + expect(overviewLink(o(mode, false), false), mode).toBe('stopped') + expect(overviewLink(o(mode, false), true), mode).toBe('gateway-stopped') + expect(overviewLink(o(mode, true), true), mode).toBe('gateway-relaying') + } + }) + + it('pushed in the facts: Monitor running with no board is not waiting', () => { + expect(facts().running.overviewLink).toBe('running') + const bridge = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'bridge' } }) + expect(bridge.running.overviewLink).toBe('waiting') }) }) -describe('X9: each surface’s own endpoint label', () => { - it('the pill: host and port as typed, the multicast group, or "unicast listen"', () => { - expect(pillEndpoint('tcp-to-host')).toEqual({ hostPort: { suffix: '' } }) - expect(pillEndpoint('loopback-multicast')).toEqual({ text: '239.255.50.10:5010' }) - expect(pillEndpoint('unicast-listen')).toEqual({ text: 'unicast listen' }) +describe('X9: one endpoint label, right for each transport', () => { + const at = (transport: (typeof DEFAULT_CONFIG)['transport']) => + relayEndpoint({ transport, host: '10.0.0.7', commandPort: 7779, listenPort: 5011 }) + + it('TCP: the host and command port; Multicast: the group; Unicast: the port it listens on', () => { + expect(at('tcp-to-host')).toBe('10.0.0.7:7779') + expect(at('loopback-multicast')).toBe('239.255.50.10:5010') + expect(at('unicast-listen')).toBe('this PC:5011') }) - it('the Overview: host and port as typed, else the multicast group — for Unicast too', () => { - expect(overviewEndpoint('tcp-to-host')).toEqual({ hostPort: { suffix: ' · remote' } }) - expect(overviewEndpoint('loopback-multicast')).toEqual({ text: '239.255.50.10:5010 · local' }) - expect(overviewEndpoint('unicast-listen')).toEqual({ text: '239.255.50.10:5010 · local' }) + it('pushed once in the facts, from the settings held', () => { + const f = facts({ config: { ...DEFAULT_CONFIG, host: '10.0.0.7', commandPort: 7779 } }) + expect(f.endpoint).toBe('10.0.0.7:7779') }) }) -describe('X12: each surface’s own Record rule', () => { - it('the Connection card only while relaying; Settings › Connection whenever', () => { - expect(canRecord(true)).toEqual({ connection: true, settings: true }) - expect(canRecord(false)).toEqual({ connection: false, settings: true }) +describe('X12: Record only while relaying, on every surface', () => { + it('a recording starts only while relaying; one already on can always be stopped', () => { + expect(canRecord(true, false)).toBe(true) + expect(canRecord(false, false)).toBe(false) + expect(canRecord(false, true)).toBe(true) + const f = facts() + expect([f.running.canRecord, f.stopped.canRecord]).toEqual([true, false]) + const on = facts({ recording: { on: true } }) + expect(on.stopped.canRecord, 'a relay stopped mid-recording can still stop it').toBe(true) }) }) diff --git a/src/main/dcs/relay-rules.ts b/src/main/dcs/relay-rules.ts index beddc1c..1698eb3 100644 --- a/src/main/dcs/relay-rules.ts +++ b/src/main/dcs/relay-rules.ts @@ -1,16 +1,17 @@ // Relay rules (pure, no Node/Electron deps): which way the stream goes in each source mode, which // settings a running relay is built from, and what the relay's surfaces show (R3.3). -import type { AppConfig, DcsTransport, SourceMode } from '@shared/config' -import type { - RecordState, - EndpointLabel, - OverviewLink, - RelayFacts, - RelaySurfaces, - RelayTip, - RelayWaiting, - ReplayLoad, - StreamState +import type { AppConfig, SourceMode } from '@shared/config' +import { + EXPORT_MULTICAST_ADDR, + EXPORT_PORT, + type RecordState, + type OverviewLink, + type RelayFacts, + type RelaySurfaces, + type RelayTip, + type RelayWaiting, + type ReplayLoad, + type StreamState } from '@shared/dcs' import type { DeviceState } from '@shared/gateway' @@ -57,14 +58,21 @@ export function relayConfigChanged(prev: T, next: T): bo interface RelayOpts { mode: SourceMode + /** Replay's "Drive Gateway": a Replay that delivers to the board (N3). */ + replayDriveSerial: boolean relaying: boolean stream?: StreamState deviceState: DeviceState } -/** Running, but Bridge has no device to deliver to. */ +/** + * Running, but a mode that delivers to the board has no board to deliver to: Bridge, and a Replay + * driving the Gateway (N3). Monitor and an undriven Replay never wait for it (N2). + */ const waitingForDevice = (o: RelayOpts): boolean => - o.relaying && o.mode === 'bridge' && o.deviceState !== 'connected' + o.relaying && + exportToDevice({ sourceMode: o.mode, replayDriveSerial: o.replayDriveSerial }) && + o.deviceState !== 'connected' /** Running, but the DCS-BIOS stream is not up. Replay has no DCS to wait for. */ const waitingForDcs = (o: RelayOpts): boolean => @@ -77,6 +85,7 @@ const waitingForDcs = (o: RelayOpts): boolean => export function relayTip(opts: RelayOpts): RelayTip { if (opts.mode === 'replay') { if (!opts.relaying) return 'replay-stopped' + if (waitingForDevice(opts)) return 'waiting-device' if (opts.stream === 'ended') return 'replay-ended' return 'replay-playing' } @@ -104,14 +113,17 @@ export function startBlocked(opts: { replayDriveSerial: boolean deviceState: DeviceState }): boolean { - const needsBoard = opts.mode === 'bridge' || (opts.mode === 'replay' && opts.replayDriveSerial) + 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. - * Bridge also needs the device; Monitor and Replay never use it. + * Bridge and a driven Replay also need the device; Monitor and an undriven Replay never use it. */ export function relayWaiting(opts: RelayOpts): boolean { if (!opts.relaying) return false @@ -123,51 +135,46 @@ export function relayWaiting(opts: RelayOpts): boolean { } /** - * The Overview Link tile's sub-line. "Waiting" whenever a relay runs with the link down, whatever - * the mode — Monitor included, which never uses the link (N2). - */ -export function overviewLink(relaying: boolean, linkUp: boolean): OverviewLink { - return linkUp - ? relaying - ? 'gateway-relaying' - : 'gateway-stopped' - : relaying - ? 'waiting' - : 'stopped' -} - -/** - * The Relay pill's endpoint (X9, its own variant): the host and port as typed for TCP, the - * multicast group for loopback, `unicast listen` for unicast. + * The Overview Link tile's sub-line. "Waiting" only while a running relay waits for the board — the + * same rule as the pill and the note under Start, so Monitor and an undriven Replay, which never + * use the link, never say it (N2). Otherwise the relay is simply running. */ -export function pillEndpoint(transport: DcsTransport): EndpointLabel { - return transport === 'tcp-to-host' - ? { hostPort: { suffix: '' } } - : transport === 'loopback-multicast' - ? { text: '239.255.50.10:5010' } - : { text: 'unicast listen' } +export function overviewLink(opts: RelayOpts, linkUp: boolean): OverviewLink { + if (linkUp) return opts.relaying ? 'gateway-relaying' : 'gateway-stopped' + if (waitingForDevice(opts)) return 'waiting' + return opts.relaying ? 'running' : 'stopped' } /** - * The Overview DCS Link tile's endpoint (X9, its own variant): the multicast group for Unicast - * too, as it reads today. + * Where the relay's stream comes from (X9): the one endpoint label every surface shows — the Relay + * pill, the Overview's DCS Link tile and the event log. The host and command port the TCP link + * connects to; the multicast group and port loopback joins; "this PC" and the port Unicast binds + * on every address (transports.ts binds it with none). */ -export function overviewEndpoint(transport: DcsTransport): EndpointLabel { - return transport === 'tcp-to-host' - ? { hostPort: { suffix: ' · remote' } } - : { text: '239.255.50.10:5010 · local' } +export function relayEndpoint( + cfg: Pick +): string { + switch (cfg.transport) { + case 'tcp-to-host': + return `${cfg.host}:${cfg.commandPort}` + case 'loopback-multicast': + return `${EXPORT_MULTICAST_ADDR}:${EXPORT_PORT}` + case 'unicast-listen': + return `this PC:${cfg.listenPort}` + } } /** - * May a recording start (X12, each surface its own variant): the Connection tab's Record card - * only while relaying; Settings › Connection's Record whenever. + * May a recording start (X12): only while relaying, on every surface that offers it — the + * Connection tab's Record card and Settings › Connection's Record row. A recording already on can + * always be stopped, so a relay stopped mid-recording never strands it. */ -export function canRecord(relaying: boolean): RelaySurfaces['canRecord'] { - return { connection: relaying, settings: true } +export function canRecord(relaying: boolean, recording: boolean): boolean { + return relaying || recording } /** - * Is a Record control shown at all (R3.4a, each surface its own variant like canRecord): the + * Is a Record control shown at all (R3.4a, each surface its own variant): the * Connection tab's Record card is hidden in Replay (the renderer's rule before); Settings › * Connection's Record row always shows. */ @@ -196,7 +203,10 @@ export function replayView(load: ReplayLoad, prev: RelayFacts['replay']): RelayF /** What DcsRelay judges the surfaces against. */ export interface RelayInputs { - config: Pick + config: Pick< + AppConfig, + 'sourceMode' | 'replayDriveSerial' | 'transport' | 'host' | 'commandPort' | 'listenPort' + > stream?: StreamState deviceState: DeviceState /** The link is up (the gateway's isLinkUp). */ @@ -207,7 +217,13 @@ export interface RelayInputs { /** The surfaces for one state of the on/off. */ function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces { - const o = { mode: i.config.sourceMode, relaying, stream: i.stream, deviceState: i.deviceState } + const o = { + mode: i.config.sourceMode, + replayDriveSerial: i.config.replayDriveSerial, + relaying, + stream: i.stream, + deviceState: i.deviceState + } const blocked = !relaying && startBlocked({ @@ -219,12 +235,12 @@ function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces { waiting: relayNote(o), tip: relayTip(o), ...(blocked ? { startBlocked: i.config.sourceMode === 'replay' ? 'replay' : 'bridge' } : {}), - overviewLink: overviewLink(relaying, i.linkUp), - canRecord: canRecord(relaying) + overviewLink: overviewLink(o, i.linkUp), + canRecord: canRecord(relaying, i.recording.on) } } -/** `relay:facts`: both sides of the on/off, the endpoints, the recording and the capture. */ +/** `relay:facts`: both sides of the on/off, the endpoint, the recording and the capture. */ export function relayFacts(i: RelayInputs): RelayFacts { return { mode: i.config.sourceMode, @@ -233,10 +249,7 @@ export function relayFacts(i: RelayInputs): RelayFacts { stopped: surfaces(i, false), ...(i.stream !== undefined ? { stream: i.stream } : {}), deviceState: i.deviceState, - endpoint: { - pill: pillEndpoint(i.config.transport), - overview: overviewEndpoint(i.config.transport) - }, + endpoint: relayEndpoint(i.config), recording: i.recording, recordShown: recordShown(i.config.sourceMode), captureShown: captureShown(i.config.sourceMode), diff --git a/src/main/dcs/sim-telemetry-decoder.test.ts b/src/main/dcs/sim-telemetry-decoder.test.ts index e0d509a..248f1e8 100644 --- a/src/main/dcs/sim-telemetry-decoder.test.ts +++ b/src/main/dcs/sim-telemetry-decoder.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { A4EC_OUTPUTS } from '@shared/firmware' import { TELEMETRY_IDLE } from '@shared/dcs' import { Decoder, extractValue, gaugeTone, idleTelemetry } from './sim-telemetry-decoder' @@ -55,12 +56,21 @@ describe('the gauges’ alert level (Overview.tsx gaugeColor before R3.3)', () = expect(gaugeTone('RPM', 0.9)).toBe('primary') }) - it('X10: the idle readouts are their own spec, and the renderer starts from them', () => { + it('X10: one spec — the idle readouts are the live ones with nothing decoded', () => { expect(idleTelemetry()).toEqual(TELEMETRY_IDLE) const live = new Decoder().telemetrySnapshot() - expect(idleTelemetry().map((r) => r.id)).not.toEqual(live.map((r) => r.id)) - // The idle Press Alt keeps the plain ring; the live one is a secondary ring. - expect(idleTelemetry()[3]).toMatchObject({ id: 'D_ALT_NEEDLE', tone: 'primary' }) - expect(live[3]).toMatchObject({ id: 'ALT_FT', tone: 'secondary' }) + expect(idleTelemetry()).toEqual(live) + // Real units from the start: IAS in kt, Press Alt in ft (its secondary ring), never % FS. + expect(TELEMETRY_IDLE.map((r) => r.unit)).toEqual(['% RPM', 'kt', '% DN', 'ft', '% QTY']) + expect(TELEMETRY_IDLE[3]).toMatchObject({ id: 'ALT_FT', tone: 'secondary' }) + }) + + it('X10: the first frame keeps the idle ids, labels and units', () => { + const d = new Decoder() + d.handle(A4EC_OUTPUTS.find((o) => o.id === 'RPM')!.address, 0x8000, 0) + const first = d.telemetrySnapshot() + const spec = (r: { id: string; label: string; unit: string }) => [r.id, r.label, r.unit] + expect(first.map(spec)).toEqual(TELEMETRY_IDLE.map(spec)) + expect(first[0]!.value).not.toBeNaN() }) }) diff --git a/src/main/dcs/sim-telemetry-decoder.ts b/src/main/dcs/sim-telemetry-decoder.ts index 0417d38..f36804e 100644 --- a/src/main/dcs/sim-telemetry-decoder.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -18,34 +18,14 @@ interface TelemetrySpec { /** * A gauge ring's alert level (the Overview's gaugeColor before R3.3): fuel goes to its alert under - * 25 %; the flap and altitude rings are the secondary ones. Keyed on the readout's id, so the idle - * placeholders (X10: `D_ALT_NEEDLE`, not `ALT_FT`) keep the plain ring they had. + * 25 %; the flap and altitude rings are the secondary ones. Keyed on the readout's id, the idle + * placeholders' and the live readouts' alike: both come from the one spec (TELEMETRY, X10). */ export function gaugeTone(id: string, pct: number): GaugeTone { if (id === 'D_FUEL') return pct < 0.25 ? 'fuel-low' : 'fuel' return id === 'D_FLAPS_IND' || id === 'ALT_FT' ? 'secondary' : 'primary' } -/** - * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives — the - * decoder's idle variant, moved from the renderer's store (X10). Its ids, labels and units - * drifted from the live readouts below (TELEMETRY): IAS and Press Alt read `% FS`, and Press Alt - * keys on `D_ALT_NEEDLE`. Each surface keeps its own until the X10 fix: the renderer starts from - * the contract's TELEMETRY_IDLE, which is this (main/dcs/sim-telemetry-decoder.test.ts). - */ -const IDLE_TELEMETRY: TelemetrySpec[] = [ - { id: 'RPM', label: 'RPM', unit: '% RPM' }, - { id: 'D_IAS_DEG', label: 'IAS', unit: '% FS' }, - { id: 'D_FLAPS_IND', label: 'Flap', unit: '% DN' }, - { id: 'D_ALT_NEEDLE', label: 'Press Alt', unit: '% FS' }, - { id: 'D_FUEL', label: 'Fuel', unit: '% QTY' } -] - -/** The idle readouts: NaN values ("—"), empty rings, each with its ring's level. */ -export function idleTelemetry(): TelemetryReadout[] { - return IDLE_TELEMETRY.map((t) => ({ ...t, value: NaN, pct: 0, tone: gaugeTone(t.id, 0) })) -} - // Three altimeter drum addresses encode digit positions 0–9.999 as 0–65535. // ALT_FT is a virtual telemetry id computed from them; it has no DCS-BIOS address. const ALT_DRUM_IDS = new Set(['D_ALT_10K', 'D_ALT_1K', 'D_ALT_100S']) @@ -81,6 +61,7 @@ function iasRawToKnots(raw: number): number { return NaN } +/** The one telemetry spec: the five gauges, idle and live (X10). */ const TELEMETRY: TelemetrySpec[] = [ { id: 'RPM', label: 'RPM', unit: '% RPM' }, { id: 'D_IAS_DEG', label: 'IAS', unit: 'kt' }, @@ -205,6 +186,16 @@ export class Decoder { } } +/** + * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives: the live + * readouts with nothing decoded yet, so the same ids, labels and units — kt and ft from the start + * (X10). The contract's TELEMETRY_IDLE, the renderer's starting value, is this + * (main/dcs/sim-telemetry-decoder.test.ts). + */ +export function idleTelemetry(): TelemetryReadout[] { + return new Decoder().telemetrySnapshot() +} + /** The two pushes the telemetry tick makes. */ interface TelemetryEmit { (channel: 'telemetry:tick', payload: TelemetryReadout[]): void diff --git a/src/preload/index.ts b/src/preload/index.ts index 15223e4..06ef318 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import { import type { AppConfig, BackupList, + ConfigEditResult, ConfigExportResult, ConfigImportApplyResult, ConfigImportOpen, @@ -53,7 +54,7 @@ const api: SkyhawkApi = { }, getConfig: () => ipcRenderer.invoke(CTRL.configGet) as Promise, setConfig: (patch) => ipcRenderer.invoke(CTRL.configSet, patch) as Promise, - editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise, + editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise, exportConfig: () => ipcRenderer.invoke(CTRL.configExport) as Promise, openConfigImport: () => ipcRenderer.invoke(CTRL.configImportOpen) as Promise, applyConfigImport: (token, sections, backup) => diff --git a/src/renderer/src/app/connection/Connection.tsx b/src/renderer/src/app/connection/Connection.tsx index 800d2e1..85de12f 100644 --- a/src/renderer/src/app/connection/Connection.tsx +++ b/src/renderer/src/app/connection/Connection.tsx @@ -25,6 +25,8 @@ const TRANSPORTS: { id: TransportId; title: string; detail: string }[] = [ export function Connection() { const s = useConfigStore() const setConfigField = useConfigStore((x) => x.setConfigField) + const typeLinkField = useConfigStore((x) => x.typeLinkField) + const commitLinkField = useConfigStore((x) => x.commitLinkField) // The mode and the transport the relay's facts were judged against (R3.4c): the segment, its // hint and the radios flip in the same push as the cards, Start and the pill, once main has // taken the click. @@ -75,18 +77,28 @@ export function Connection() { setConfigField({ host: e.target.value })} + onChange={(e) => typeLinkField('host', e.target.value)} + onBlur={() => commitLinkField('host')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('host')} />
Port
setConfigField({ port: e.target.value })} + onChange={(e) => typeLinkField('port', e.target.value)} + onBlur={() => commitLinkField('port')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('port')} />
+ {s.portError && ( +
+ {s.portError} +
+ )}
Transport
{TRANSPORTS.map((t) => ( diff --git a/src/renderer/src/app/overview/Overview.tsx b/src/renderer/src/app/overview/Overview.tsx index 9c160fd..c3e979d 100644 --- a/src/renderer/src/app/overview/Overview.tsx +++ b/src/renderer/src/app/overview/Overview.tsx @@ -2,7 +2,7 @@ import { fmtBytes, fmtUptime } from '@shared/format' import type { DeviceState } from '@shared/gateway' import type { GaugeTone, OverviewLink } from '@shared/dcs' import { useConfigStore, viewTransport } from '../../config' -import { endpointText, relaySide, useDcsStore } from '../../dcs' +import { relaySide, useDcsStore } from '../../dcs' import { gatewayView, useGatewayStore } from '../../gateway' import { Dot, useNodesStore } from '../../nodes' import { useNav, type TabId } from '../kit' @@ -31,6 +31,7 @@ const LINK_SUB: Record = { 'gateway-relaying': 'Gateway · relaying', 'gateway-stopped': 'Gateway · stream stopped', waiting: 'relay waiting — resumes when it connects', + running: 'stream running', stopped: 'stream stopped' } /** A gauge ring's colour, for its level as main judges it (R3.3). */ @@ -100,8 +101,9 @@ export function Overview() { // The transport the relay's facts were judged against (R3.4c), as its endpoint below is. const transport = viewTransport(dcs.relayFacts.transport) const link = transport === 'tcp' ? 'TCP' : transport === 'loop' ? 'Multicast' : 'Unicast' - // The DCS Link tile's endpoint and the Link tile's sub-line are main's (relay:facts, R3.3). - const linkSub = endpointText(dcs.relayFacts.endpoint.overview, cfg.host, cfg.port) + // The DCS Link tile's endpoint (the pill's too, X9) and the Link tile's sub-line are main's + // (relay:facts, R3.3). + const linkSub = dcs.relayFacts.endpoint const relaySub = LINK_SUB[relaySide(dcs.relayFacts, dcs.relaying).overviewLink] const acft = dcs.aircraft.name && dcs.aircraft.name !== 'NONE' ? dcs.aircraft.name : 'NONE' diff --git a/src/renderer/src/app/settings/CockpitSection.tsx b/src/renderer/src/app/settings/CockpitSection.tsx index ca7b194..72d5620 100644 --- a/src/renderer/src/app/settings/CockpitSection.tsx +++ b/src/renderer/src/app/settings/CockpitSection.tsx @@ -19,7 +19,8 @@ function PowerSupply() { // The preset as main resolves it (`config:changed`, R3.3). const preset = useConfigStore((s) => s.psuView.preset) const isPreset = psu.mode === 'preset' - // What is typed, per field: a refused value stays visible (and unsaved) until corrected. + // What is typed, per field: a refused value stays visible (and unsaved) until corrected, or until + // main's settings replace the forms (CockpitSection keys this on them, N8). const [typed, setTyped] = useState>(() => ({ a12: text(psu.custom.a12), a5: text(psu.custom.a5), @@ -163,6 +164,8 @@ export function CockpitSection() { const previewKey = useNodesStore((s) => resetPreviewKey(s.cockpit)) const attentionBanner = useConfigStore((s) => s.attentionBanner) const setConfigField = useConfigStore((s) => s.setConfigField) + // Main's settings replaced the forms (an import): the PSU limits start over from them (N8). + const formsReset = useConfigStore((s) => s.formsReset) const [resetOpen, setResetOpen] = useState(false) const differing = pulled ? nodes.differing : 0 @@ -193,7 +196,7 @@ export function CockpitSection() {
- + {resetOpen && setResetOpen(false)} />} diff --git a/src/renderer/src/app/settings/ConnectionSection.tsx b/src/renderer/src/app/settings/ConnectionSection.tsx index 8251519..61581f7 100644 --- a/src/renderer/src/app/settings/ConnectionSection.tsx +++ b/src/renderer/src/app/settings/ConnectionSection.tsx @@ -6,11 +6,13 @@ export function ConnectionSection() { const s = useConfigStore() const dcs = useDcsStore() const setConfigField = useConfigStore((x) => x.setConfigField) + const typeLinkField = useConfigStore((x) => x.typeLinkField) + const commitLinkField = useConfigStore((x) => x.commitLinkField) const toggleCapture = useDcsStore((x) => x.toggleCapture) - // The recording and whether this row may start one (X12: whenever), as main decides (R3.3), and - // whether the row shows (always; R3.4a). + // The recording and whether this row may start one (X12: only while relaying, as the Connection + // tab's card), as main decides (R3.3), and whether the row shows (always; R3.4a). const recording = dcs.relayFacts.recording - const canRecord = relaySide(dcs.relayFacts, dcs.relaying).canRecord.settings + const canRecord = relaySide(dcs.relayFacts, dcs.relaying).canRecord const recordShown = dcs.relayFacts.recordShown.settings return ( @@ -27,15 +29,20 @@ export function ConnectionSection() { className="input" style={{ width: 160 }} value={s.host} - onChange={(e) => setConfigField({ host: e.target.value })} + onChange={(e) => typeLinkField('host', e.target.value)} + onBlur={() => commitLinkField('host')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('host')} /> setConfigField({ port: e.target.value })} + onChange={(e) => typeLinkField('port', e.target.value)} + onBlur={() => commitLinkField('port')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('port')} /> )} + {s.portError && ( +
+ {s.portError} +
+ )} ) } diff --git a/src/renderer/src/app/titlebar/RelayPill.tsx b/src/renderer/src/app/titlebar/RelayPill.tsx index 27728e8..fb830a6 100644 --- a/src/renderer/src/app/titlebar/RelayPill.tsx +++ b/src/renderer/src/app/titlebar/RelayPill.tsx @@ -1,10 +1,8 @@ import { useConfigStore } from '../../config' import { RelayPill as Pill } from '../../dcs' -/** The title-bar Relay pill (dcs), with the settings its tooltip names: auto-connect, host, port. */ +/** The title-bar Relay pill (dcs), with the setting its tooltip names: auto-connect. */ export function RelayPill() { const autoConnect = useConfigStore((s) => s.autoConnect) - const host = useConfigStore((s) => s.host) - const port = useConfigStore((s) => s.port) - return + return } diff --git a/src/renderer/src/config/store.ts b/src/renderer/src/config/store.ts index 36377c3..0122940 100644 --- a/src/renderer/src/config/store.ts +++ b/src/renderer/src/config/store.ts @@ -20,6 +20,8 @@ import type { PsuLimits, PsuSetting } from '@shared/nodes' export type SourceMode = 'bridge' | 'monitor' | 'replay' export type TransportId = 'tcp' | 'loop' | 'uni' +/** The typed DCS link fields, saved on commit (blur / Enter, N5). */ +export type LinkField = 'host' | 'port' /** A call to main as the view made it: undefined outside Electron (no `window.skyhawk`). */ type Ask> = ( @@ -31,6 +33,15 @@ export interface ConfigState { transport: TransportId host: string port: string + /** Why main refused the last committed port (N5); nothing was saved for it. */ + portError?: string + /** The link fields typed since their last commit: what a commit sends (N5). */ + linkTyped: Partial> + /** + * How many times main's settings replaced the forms (`config:get`, an import): a form that + * keeps its own typed text (Settings › Cockpit's PSU limits) starts over from them on each (N8). + */ + formsReset: number autoConnect: boolean // replay @@ -47,6 +58,10 @@ export interface ConfigState { debugDump?: { path: string; count: number } setConfigField: (patch: Partial) => void + /** A DCS link field as typed: held here, not saved until it is committed (N5). */ + typeLinkField: (field: LinkField, text: string) => void + /** A DCS link field committed (blur / Enter): main checks and saves it, or answers why not (N5). */ + commitLinkField: (field: LinkField) => void /** Settings › Cockpit's PSU switch: main switches the mode over the setting it holds (R3.4a). */ setPsuMode: (mode: PsuSetting['mode']) => void /** Settings › Cockpit's PSU limit, as typed: main refuses, deletes or sets it (R3.4a). */ @@ -90,9 +105,14 @@ export function viewTransport(transport: DcsTransport): TransportId { return t ?? 'tcp' } -/** The store fields an AppConfig sets — on load, and after a config import replaces it. */ -function configState(cfg: AppConfig): ConfigFields { +/** + * The store fields an AppConfig sets — on load, and after a config import replaces it: the forms + * start over, nothing typed left to commit and no refused port shown. + */ +function configState(cfg: AppConfig): ConfigFields & Pick { return { + portError: undefined, + linkTyped: {}, sourceMode: cfg.sourceMode, transport: viewTransport(cfg.transport), host: cfg.host, @@ -128,8 +148,9 @@ function configEdit(patch: Partial): ConfigEdit { /** A form's change, to main (`config:edit`): main builds the settings and saves them (R3.3). */ const editConfig = (edit: ConfigEdit): void => void api()?.editConfig(edit) -export const useConfigStore = create((set) => ({ +export const useConfigStore = create((set, get) => ({ ...configState(DEFAULT_CONFIG), + formsReset: 0, psuView: PSU_VIEW_DEFAULT, // What is typed stays as typed; main builds and saves the settings (`config:edit`, R3.3). @@ -138,6 +159,28 @@ export const useConfigStore = create((set) => ({ editConfig(configEdit(patch)) }, + // Typing saves nothing (N5): the text waits for its commit. + typeLinkField: (field, text) => { + set((s) => ({ [field]: text, linkTyped: { ...s.linkTyped, [field]: true } })) + }, + + // Main checks the port and saves, or saves nothing and answers why: the form shows that answer + // under the port, unless the field has changed since (N5). A commit with nothing typed sends + // nothing (an Enter, then the blur). + commitLinkField: (field) => { + const s = get() + if (!s.linkTyped[field]) return + const text = s[field] + const { [field]: _sent, ...linkTyped } = s.linkTyped + set({ linkTyped }) + const answer = api()?.editConfig({ [field]: text }) + if (field !== 'port' || !answer) return + void answer.then((r) => { + if (get().port !== text) return + set({ portError: r.ok ? undefined : r.error }) + }) + }, + // The switched setting comes back on `config:changed` (R3.4a). setPsuMode: (mode) => { editConfig({ psuMode: mode }) @@ -178,19 +221,26 @@ export const useConfigStore = create((set) => ({ export const configFromMain = { /** `config:get`. */ config: (v: ConfigView): void => - useConfigStore.setState({ ...configState(v.config), psuView: v.psu }), + useConfigStore.setState((s) => ({ + ...configState(v.config), + psuView: v.psu, + formsReset: s.formsReset + 1 + })), /** - * `config:changed`. An import replaced the settings: the forms start over from them. A form's own - * change leaves what is typed alone (the port as typed, N5); the PSU setting is main's either way, - * since main switches its mode (R3.4a). + * `config:changed`. An import replaced the settings: the forms start over from them, the PSU + * limits' typed text too (N8). A form's own change leaves what is typed alone (the port as typed, + * N5); the PSU setting is main's either way, since main switches its mode (R3.4a). */ changed: (c: ConfigChanged): void => - useConfigStore.setState({ + useConfigStore.setState((s) => ({ psuView: c.psu, psu: c.config.psu, - ...(c.source === 'import' ? configState(c.config) : {}) - }), + ...(c.source === 'import' && { + ...configState(c.config), + formsReset: s.formsReset + 1 + }) + })), /** `config:backups`, and `config:backups-get`'s answer. */ backups: (list: BackupList): void => useConfigStore.setState({ backups: list }) diff --git a/src/renderer/src/dcs/RelayControls.tsx b/src/renderer/src/dcs/RelayControls.tsx index c7f9b8d..c730b65 100644 --- a/src/renderer/src/dcs/RelayControls.tsx +++ b/src/renderer/src/dcs/RelayControls.tsx @@ -92,11 +92,7 @@ export function RecordCard() {
Record
-