From 714525b673a2f91655d23a9b83c3560637e68d8b Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 19:12:51 -0400 Subject: [PATCH 01/28] ci: node 24 pin and boundary lint (warn) (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R0.1 of the structure rewrite (#101). Tooling only: no production code, UI or behaviour changes. ## What it adds - **`.nvmrc`** = `24`. Consistent with `engines.node >=24` and `node-version: 24` in CI. - **`dependency-cruiser@18.5.0`** (exact), started from `depcruise --init oneshot`. The default rule set is replaced by the boundary rules; the scaffold's options are kept. - **`.dependency-cruiser.cjs`**: the arch-phase0-v2 §5 rules, written for the **target** layout (`src/main//`, `src/renderer/src//`, `src/shared//{index,ipc}.ts`, leaves `src/shared/firmware/`, `src/shared/format/`, `src/renderer/src/app/kit/`): - process: `P1-renderer-not-main`, `P2-main-not-renderer`, `P3-shared-is-a-leaf`, `P4-renderer-imports-contracts-only`, `P6-electron-only-in-app` - contracts and leaves: `C1-contracts-import-contracts`, `L-firmware-leaf`, `L-format-leaf`, `L-kit-leaf` - topic directions, one rule per topic and per process (`topic-main-`, `topic-renderer-`): app→all; nodes→gateway, events; dcs→gateway, events; hid→gateway; hid/calibration→hid, gateway; gateway→events; config→nodes, hid/calibration; events→nothing. Every topic may also import firmware, format, kit and the shared contracts. - deep imports: `T2-main-no-deep-topic-import`, `T2-renderer-no-deep-topic-import` (across topics, only `/index.ts`) - cycles: `C2-no-circular`, type-only imports included (`tsPreCompilationDeps: true`) - tests, `src/test-fixtures/` and `src/renderer/test/` are excluded. The generated `src/main/reference/*.generated.ts` files stay in: they import nothing, so they can't originate a violation, and excluding them would hide edges *into* them. - aliases resolve through `tsconfig.json`, which carries both `@shared/*` and `@renderer/*`. Main and preload use only `@shared`, which resolves the same way. - **`.dependency-cruiser.severity.json`**: the only place a rule family flips from `warn` to `error`. Every key is `warn` in this PR. - **ESLint P5** in `eslint.config.mjs`: `no-restricted-syntax` on `window.skyhawk` member access in `src/renderer/**`, ignoring `src/renderer/src/app/ipc-adapter.ts` and `src/renderer/test/**`, severity `warn`. `npm run lint` has no `--max-warnings`, so warnings don't fail CI. - **`npm run lint:deps`** (`depcruise src --config .dependency-cruiser.cjs --output-type err-long`), run as a new step in the existing **Lint · Typecheck · Test** job (job name unchanged). Warn-severity findings exit 0. ## Severity keys and flip plan | Key | Rules | Flips to `error` in | |---|---|---| | `firmwareLeaf` | L-firmware-leaf | R1.1 | | `process` | P3 | R1.3 | | `contracts` | C1 | R1.3 | | `cycles` | C2 | R1.3 | | `formatLeaf` | L-format-leaf | R1.3 | | `crossProcess` | P1, P2 | R1.4 | | `electron` | P6 | R1.4 | | `topicsMain` | topic-main-* | R2.3 | | `deepMain` | T2-main | R2.3 | | `rendererShared` | P4 | R3.4 | | `kitLeaf` | L-kit-leaf | R3.4 | | `topicsRenderer` | topic-renderer-* | R3.4 | | `deepRenderer` | T2-renderer | R3.4 | | ESLint P5 | `window.skyhawk` | R3.4 (in `eslint.config.mjs`) | ## Today's counts (warn-only) The rules target folders that don't exist yet, so today's counts are not the §5.4 planning estimate (that estimate mapped flat files to their future topics). `npm run lint:deps`: **65 warnings, 0 errors**, 97 modules, 298 dependencies. | Rule | Today | Notes | |---|---|---| | P1, P2, P3, C1 | 0 | P3/C1 match on `src/shared//` paths, which don't exist yet | | P4-renderer-imports-contracts-only | 53 edges from 28 files | 36 to model files (node-rules 11, psu 4, node-telem 4, nodes 3, node-mapping 3, events 3, node-kind 2, gateway 2, axis-capture 2, config-file 1, cal-cache 1), plus 17 to the flat `shared/ipc.ts`, which isn't at a contract path yet | | P6-electron-only-in-app | 5 files | main/index.ts, debug.ts, settings.ts, node-mapping.ts, cal-cache.ts | | C2-no-circular | 6 | node-kind ↔ node-mapping; events ↔ ipc; config-file ↔ ipc; cal-cache ↔ ipc; config-file → cal-cache → ipc → config-file; gateway → events → ipc → gateway | | T2-renderer-no-deep-topic-import | 1 | tabs/Overview.tsx → assets/logo.png (today's `tabs/` and `assets/` folders read as topics) | | topic-*, T2-main, L-* | 0 | no topic or leaf folders yet | | ESLint P5 | 32 in 10 files | store.ts 16, SidePanels 4, CalibrationDialog 2, AdoptDialog 2, ConfigurationSection 2, ResetDialog 2, AboutSection 1, CockpitSection 1, ImportDialog 1, useNodeModel 1 | ## Proof each rule family fires Throwaway files in the target layout (deleted before commit) produced: ``` warn P1-renderer-not-main: src/renderer/src/zzP1.ts → src/main/debug.ts warn P2-main-not-renderer: src/main/zzP2.ts → src/renderer/src/freshness.ts warn P3-shared-is-a-leaf: src/shared/zz/ipc.ts → src/main/debug.ts warn C1-contracts-import-contracts: src/shared/zz/ipc.ts → src/main/debug.ts warn C1-contracts-import-contracts: src/shared/zz/index.ts → src/shared/psu.ts warn L-firmware-leaf: src/shared/firmware/zz.ts → src/shared/format/zz.ts warn L-format-leaf: src/shared/format/zz.ts → src/shared/events.ts warn L-kit-leaf: src/renderer/src/app/kit/zz.ts → src/renderer/src/freshness.ts warn topic-main-events: src/main/events/zz.ts → src/main/nodes/zz.ts warn topic-main-hid: src/main/hid/zz.ts → src/main/hid/calibration/index.ts warn topic-renderer-gateway: src/renderer/src/gateway/zz.ts → src/renderer/src/app/zz.ts warn T2-main-no-deep-topic-import: src/main/nodes/zz.ts → src/main/gateway/deep.ts warn T2-renderer-no-deep-topic-import: src/renderer/src/app/zz.ts → src/renderer/src/nodes/deep.ts warn C2-no-circular: src/main/zzcycleA.ts → src/main/zzcycleB.ts → src/main/zzcycleA.ts ``` P4 and P6 fire on today's tree (above). Checks that should stay silent did: nodes → `gateway/index.ts` (allowed direction, entry import) and hid/calibration → hid. The `src/renderer/src/app/zz.ts` import was written as `@renderer/nodes/deep`, and it resolved to `src/renderer/src/nodes/deep.ts`. - **Aliases resolve:** on today's tree, every `@shared/...` import resolves to `src/shared/...` (main 37 edges, preload 4, renderer 54), with 0 unresolvable modules. - **Severity wiring:** with `firmwareLeaf` set to `error`, the L-firmware-leaf finding was reported as `error` and `lint:deps` exited 1. The change was reverted before commit. ## Checks `npm run lint` (0 errors, 32 P5 warnings), `npm run typecheck`, `npx vitest run` (40 files, 698 tests) and `npm run lint:deps` all exit 0. Also in the lockfile: adding dependency-cruiser bumped three hoisted dev-only transitive packages (acorn 8.17→8.18, is-core-module 2.16.2→2.17.0, semver 7.8.4→7.8.5). Refs #97 --- .dependency-cruiser.cjs | 178 ++++++++++++++++ .dependency-cruiser.severity.json | 15 ++ .github/workflows/ci.yml | 2 + .nvmrc | 1 + eslint.config.mjs | 15 ++ package-lock.json | 323 +++++++++++++++++++++++++++++- package.json | 2 + 7 files changed, 526 insertions(+), 10 deletions(-) create mode 100644 .dependency-cruiser.cjs create mode 100644 .dependency-cruiser.severity.json create mode 100644 .nvmrc diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..dee6eac --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,178 @@ +// Process + topic boundaries for the structure rewrite (SkyHawkClient#97, arch-phase0-v2 §5). +// +// Seeded from `depcruise --init oneshot` (dependency-cruiser 18.5.0); its default rule set is +// replaced by the boundary rules below, its options are kept. +// +// The rules describe the TARGET layout: src/main//, src/renderer/src// and +// src/shared//{index,ipc}.ts contracts, plus the leaves src/shared/firmware/, +// src/shared/format/ and src/renderer/src/app/kit/. Until files move there, the topic and +// deep-import rules have little to match. +// +// Severities live ONLY in .dependency-cruiser.severity.json, one key per rule family, so each +// phase flips its own families from "warn" to "error" without touching this file. +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS config file +const SEV = require('./.dependency-cruiser.severity.json') + +const MAIN = 'src/main' +const RENDERER = 'src/renderer/src' + +const FIRMWARE = '^src/shared/firmware/' +const FORMAT = '^src/shared/format/' +const KIT = `^${RENDERER}/app/kit/` +const CONTRACT = '^src/shared/[a-z-]+(/calibration)?/(index|ipc)\\.ts$' + +// Topic directions, the same inside main and inside the renderer. `app` may import everything, +// so it has no rule. Every topic may also import the leaves, the kit and the shared contracts. +const ALLOW = { + nodes: ['gateway', 'events'], + dcs: ['gateway', 'events'], + hid: ['gateway'], + 'hid/calibration': ['hid', 'gateway'], + gateway: ['events'], + config: ['nodes', 'hid/calibration'], + events: [] +} + +// A topic's folder; `hid` must not swallow `hid/calibration`. +const dir = (root, topic) => + topic === 'hid' ? `^${root}/hid/(?!calibration/)` : `^${root}/${topic}/` + +const topicRules = (side, root, severity, extraAllowed) => + Object.entries(ALLOW).map(([topic, allowed]) => ({ + name: `topic-${side}-${topic.replace('/', '-')}`, + severity, + comment: `${side} ${topic} may import only ${allowed.join(', ') || 'no other topic'} (+ firmware, format, kit, contracts)`, + from: { path: dir(root, topic) }, + to: { + path: `^${root}/`, + pathNot: [dir(root, topic), ...allowed.map((t) => dir(root, t)), ...extraAllowed] + } + })) + +// T2: across topics, import only a topic's index.ts. $1 is the importing topic. +const deepRule = (side, root, severity, extraAllowed) => ({ + name: `T2-${side}-no-deep-topic-import`, + severity, + comment: `outside a ${side} topic, import only its index.ts`, + from: { path: `^${root}/([^/]+)/` }, + to: { + path: `^${root}/[^/]+/`, + pathNot: [ + `^${root}/$1/`, + `^${root}/[^/]+/index\\.ts$`, + `^${root}/hid/calibration/index\\.ts$`, + ...extraAllowed + ] + } +}) + +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + // ── process (hard boundary) ───────────────────────────────────────────── + { + name: 'P1-renderer-not-main', + severity: SEV.crossProcess, + comment: 'the renderer reaches main only over IPC', + from: { path: '^src/renderer/' }, + to: { path: '^src/(main|preload)/' } + }, + { + name: 'P2-main-not-renderer', + severity: SEV.crossProcess, + comment: 'main never imports renderer code', + from: { path: '^src/main/' }, + to: { path: '^src/renderer/' } + }, + { + name: 'P3-shared-is-a-leaf', + severity: SEV.process, + comment: 'shared code imports nothing from a process', + from: { path: '^src/shared/' }, + to: { path: '^src/(main|renderer|preload)/' } + }, + { + name: 'P4-renderer-imports-contracts-only', + severity: SEV.rendererShared, + comment: 'the renderer imports only shared contracts and the firmware/format leaves', + from: { path: '^src/renderer/' }, + to: { path: '^src/shared/', pathNot: [CONTRACT, FIRMWARE, FORMAT] } + }, + { + name: 'P6-electron-only-in-app', + severity: SEV.electron, + comment: 'electron is imported only by main/app and preload', + from: { pathNot: '^src/(main/app|preload)/' }, + to: { path: '^node_modules/electron/' } + }, + // ── contracts + leaves ────────────────────────────────────────────────── + { + name: 'C1-contracts-import-contracts', + severity: SEV.contracts, + comment: 'a contract imports only other contracts and the leaves', + from: { path: CONTRACT }, + to: { path: '^src/', pathNot: [CONTRACT, FIRMWARE, FORMAT] } + }, + { + name: 'L-firmware-leaf', + severity: SEV.firmwareLeaf, + comment: 'shared/firmware imports nothing else in src', + from: { path: FIRMWARE }, + to: { path: '^src/', pathNot: FIRMWARE } + }, + { + name: 'L-format-leaf', + severity: SEV.formatLeaf, + comment: 'shared/format imports nothing else in src', + from: { path: FORMAT }, + to: { path: '^src/', pathNot: FORMAT } + }, + { + name: 'L-kit-leaf', + severity: SEV.kitLeaf, + comment: 'app/kit imports no other renderer code', + from: { path: KIT }, + to: { path: '^src/renderer/', pathNot: KIT } + }, + // ── topics (soft boundary) ────────────────────────────────────────────── + ...topicRules('main', MAIN, SEV.topicsMain, []), + ...topicRules('renderer', RENDERER, SEV.topicsRenderer, [KIT]), + deepRule('main', MAIN, SEV.deepMain, []), + deepRule('renderer', RENDERER, SEV.deepRenderer, [KIT]), + // ── cycles ────────────────────────────────────────────────────────────── + { + name: 'C2-no-circular', + severity: SEV.cycles, + comment: 'no circular dependencies anywhere (type-only imports included)', + from: {}, + to: { circular: true } + } + ], + options: { + doNotFollow: { path: ['node_modules'] }, + exclude: { + path: ['\\.test\\.tsx?$', '^src/test-fixtures/', '^src/renderer/test/'] + }, + // Type-only imports count: today's model <-> contract cycles are type cycles. + tsPreCompilationDeps: true, + // tsconfig.json carries both the @shared and @renderer paths; main and preload use only + // @shared, which resolves the same way. + tsConfig: { fileName: 'tsconfig.json' }, + detectProcessBuiltinModuleCalls: true, + enhancedResolveOptions: { + exportsFields: ['exports'], + conditionNames: ['import', 'require', 'node', 'default', 'types'], + extensions: ['.ts', '.tsx', '.d.ts'], + mainFields: ['main', 'types', 'typings'] + }, + skipAnalysisNotInRules: true, + reporterOptions: { + dot: { collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)' }, + archi: { + collapsePattern: + '^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)' + }, + text: { highlightFocused: true } + } + } +} diff --git a/.dependency-cruiser.severity.json b/.dependency-cruiser.severity.json new file mode 100644 index 0000000..2d945f5 --- /dev/null +++ b/.dependency-cruiser.severity.json @@ -0,0 +1,15 @@ +{ + "process": "warn", + "crossProcess": "warn", + "electron": "warn", + "rendererShared": "warn", + "contracts": "warn", + "cycles": "warn", + "firmwareLeaf": "warn", + "formatLeaf": "warn", + "kitLeaf": "warn", + "topicsMain": "warn", + "topicsRenderer": "warn", + "deepMain": "warn", + "deepRenderer": "warn" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac69d93..ee5d010 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: git diff --exit-code -- src/main/reference \ || (echo "::error::generated reference files are stale — run 'npm run sync' and commit" && exit 1) - run: npm run lint + - name: Boundary lint (warn-only until each phase flips its rules) + run: npm run lint:deps - run: npm run typecheck - run: npm test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/eslint.config.mjs b/eslint.config.mjs index 91539a2..f233566 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -33,5 +33,20 @@ export default tseslint.config( { files: ['**/*.{js,mjs,cjs}'], languageOptions: { globals: globals.node } + }, + { + // P5 (SkyHawkClient#97): only the IPC adapter talks to main. A dependency graph can't see a + // global, so this lives here rather than in .dependency-cruiser.cjs. Warn until R3.4. + files: ['src/renderer/**/*.{ts,tsx}'], + ignores: ['src/renderer/src/app/ipc-adapter.ts', 'src/renderer/test/**'], + rules: { + 'no-restricted-syntax': [ + 'warn', + { + selector: "MemberExpression[object.name='window'][property.name='skyhawk']", + message: 'Only app/ipc-adapter.ts talks to main; use your topic store (P5).' + } + ] + } } ) diff --git a/package-lock.json b/package-lock.json index 9e73592..20fc1b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.2.0", + "dependency-cruiser": "18.5.0", "electron": "^42.10.0", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", @@ -2953,10 +2954,11 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -2973,6 +2975,39 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-jsx-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz", + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4208,6 +4243,64 @@ "node": ">=0.4.0" } }, + "node_modules/dependency-cruiser": { + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-18.5.0.tgz", + "integrity": "sha512-LFvJOvufjQ7XTucSI40NHqse0gcQBevDGkZabLAKcGwO+VWOywTqZ5EyFRYWSokNxvLOO7v6EXyNi+9I72oWUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "8.18.0", + "acorn-jsx": "5.3.2", + "acorn-jsx-walk": "2.0.0", + "acorn-loose": "8.5.2", + "acorn-walk": "8.3.5", + "commander": "15.0.0", + "enhanced-resolve": "5.26.0", + "ignore": "7.0.11", + "interpret": "3.1.1", + "is-installed-globally": "1.0.1", + "json5": "2.2.3", + "picomatch": "4.0.7", + "prompts": "2.4.2", + "rechoir": "0.8.0", + "safe-regex": "2.1.1", + "semver": "7.8.5", + "tsconfig-paths-webpack-plugin": "4.2.0", + "watskeburt": "6.0.0" + }, + "bin": { + "depcruise": "bin/dependency-cruiser.mjs", + "depcruise-baseline": "bin/depcruise-baseline.mjs", + "depcruise-fmt": "bin/depcruise-fmt.mjs", + "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs", + "dependency-cruise": "bin/dependency-cruiser.mjs", + "dependency-cruiser": "bin/dependency-cruiser.mjs" + }, + "engines": { + "node": "^22||^24||>=26" + } + }, + "node_modules/dependency-cruiser/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/dependency-cruiser/node_modules/ignore": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", + "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -4569,6 +4662,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -6023,6 +6130,16 @@ "node": ">= 0.4" } }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6110,12 +6227,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -6236,6 +6354,23 @@ "node": ">=0.10.0" } }, + "node_modules/is-installed-globally": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.1.tgz", + "integrity": "sha512-li88H4QGRoNMX27jEmY8nR515oJVspAgC/WFMlhHfFV8RJ4QyGpHhXnwAe9gH5RIgKmdYRUmrOhQobcgEjIdSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -6295,6 +6430,19 @@ "node": ">=8" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6671,6 +6819,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -8128,6 +8286,20 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8252,6 +8424,41 @@ "read-binary-file-arch": "cli.js" } }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rechoir/node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8274,6 +8481,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -8536,6 +8753,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -8578,10 +8805,11 @@ "dev": true }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -8826,6 +9054,13 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9036,6 +9271,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -9096,6 +9341,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -9271,6 +9530,37 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9741,6 +10031,19 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/watskeburt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-6.0.0.tgz", + "integrity": "sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==", + "dev": true, + "license": "MIT", + "bin": { + "watskeburt": "dist/run-cli.js" + }, + "engines": { + "node": "^22.13||^24||>=26" + } + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", diff --git a/package.json b/package.json index 138f5cf..2fc4de2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "lint": "eslint .", + "lint:deps": "depcruise src --config .dependency-cruiser.cjs --output-type err-long", "format": "prettier --write .", "test": "vitest run", "sync": "tsx scripts/sync-a4ec.ts", @@ -36,6 +37,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.2.0", + "dependency-cruiser": "18.5.0", "electron": "^42.10.0", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", From c010d40c5ca4fe46e84f31dc59b18cc8eb3cfaf2 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 19:13:26 -0400 Subject: [PATCH 02/28] test: tier-1 stable-seam tests, fixtures, services harness (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0, R0.4 of the structure rewrite (#101). Tests only: no behaviour or UI change. The one production edit removes the test-only export `DEFAULT_APP_SETTINGS` from `shared/config-file.ts`. Its only importer was `config-file.test.ts`, and it now lives in the fixtures. ## Seam tests (new files) | Row | Seam | File | What it pins | |---|---|---|---| | B8 | HUB | `src/main/node-hub.seam.test.ts` | Forget removes the whole entry (name, console, power zone, hidden flag, last seen). If the board reports again, it is `new`: a registry board comes back pre-filled from NODE_IDS.md, an adopted one comes back blank. It is never written. | | B10 (HUB half) | HUB | `src/main/node-hub.seam.test.ts` | Hiding a PDU keeps its boards' links, both the explicit `pduId` in the file and the default link in the model. When the PDU reports, its hidden flag clears and the group is whole again. | | B33 | HUB | `src/main/node-hub.seam.test.ts` | A board identified only by PDU telemetry gets no "new on the bus" row when the pages arrive. It gets one row, stamped with that time, at its next status row, and only once. | | B13 | SESSION | `src/main/session.seam.test.ts` | Disconnect, unplug (port close) and app quit each write `lastSeen` = the time of the close to node-mapping.json. | | B29 | SESSION | `src/main/session.seam.test.ts` | The v2-Gateway warning is raised once per link, and raised again after a reopen. **See the discrepancy below.** | | B17 | FILE | `src/main/node-mapping.seam.test.ts` | v2 / v1 / unversioned files are upgraded on read. The ignore list is dropped. Seen, placed, renamed and hidden boards become `added`. Adopted entries are kept but not marked `added`. The save writes v3, and from then on the file is read as written. | | B94 | XFER | `src/main/config-transfer.seam.test.ts` | Non-object and unknown sections are skipped and listed. A file with no readable section is refused. Each known section goes through its own store's sanitizer: bad fields are dropped and good ones kept. | **Brittle test (T16):** the `session.test.ts:439–454` test spied on `session.link.roster.reset` / `session.link.nodes.reset`. It is replaced by B31 in `session.seam.test.ts`, which goes through the harness and checks only outcomes: - After relay Start / Stop / Start, the stray node is still in the `nodes:model` push and in `nodes:get`, and so is its telemetry. - After a port reopen, the next push and the snapshot have dropped them. No test spies on `session.link.*` any more. ## Services harness: `src/main/test-harness/services.ts` `makeServices({ serial, transport, clock, paths })` launches main the way `index.ts` does: settings from `config.json`, then `session.open()`. It runs against fakes for the serial port, the DCS transport, HID and the clock (vitest fake timers), with a temporary ``. `node-mapping`, `settings` and `cal-cache` are real, so they write files there. Options: - `serial: { portPath, serialNumber }` - `transport: { connectOnStart }` - `clock: { start }` - `paths: { files }`: seeds `` before launch. Tests only see: - `pushes` / `pushesOf(ch)` / `lastPush(ch)` - `invoke(CTRL.x, ...args)`: the same handlers `registerIpc()` wires, typed per channel - `serial.open() / close() / receive() / notFound() / fail() / written()` - `transport.export() / connected() / fail() / sent()` - `clock.advance() / now()` - `files.json() / text() / list()` - `closeWindow()`, `quit()`, `dispose()`. Cleanup is automatic via `onTestFinished`. Only the harness touches `Session` / `session.link`. When Session becomes `AppServices` in R2.3, only this file changes. ## Fixtures: `src/test-fixtures/` - `nodes.ts` holds the shared builders: - `nodeStatus()`, `modelNode()` and `nodeModel()` (T11) - `statusRow()` / `statusLine()` for v3 `_NODE_STATUS` rows - These builders replace the copies in ten test files: connection-summary, overview, cockpit, cockpit-panels, cockpit-adopt, gateway-view, nodes, events, node-rules and gateway. `session.test.ts` now uses `statusLine` from the fixtures too. Where a file's defaults differed, it passes those fields explicitly, so every test gets exactly the inputs it had before. No assertion changed. - `config.ts` holds `DEFAULT_APP_SETTINGS`. - `src/test-fixtures` is added to `include` in both `tsconfig.json` and `tsconfig.node.json`. Imports are relative, so no new path alias. ## Contract vs code: discrepancies (pinned as the code behaves today) - **B29.** The contract says the v2 warning repeats once after a port reopen (2 rows in total). Today, `DeviceLink` does reset `legacyNodesWarned` on open and raises the warning again: the stream-stats error count goes 1 → 2. But `Session.logError` dedupes per tag (`lastErr`), and nothing ever clears `'NODES'`, so the identical second message never reaches the event log. **The event log keeps 1 row.** The test pins both halves and carries a `// contract B29 says …; today the code does …` comment. - **B17 (not pinned).** The legacy `added` upgrade marks *any* non-adopted entry with an override, not only registry entries (M9 → F28, already pinned by `node-mapping.test.ts:109`). It's a FIX-track item, so B17 deliberately sticks to registry ids and adopted entries. ## Fail-check Each seam test was seen to go red against a deliberately broken production line, then restored: | Test | What was broken | |---|---| | B8 | `forgetNode` made a no-op | | B10 | `clearReportedHidden` made a no-op; separately, `setHidden` made to unlink the PDU's boards | | B33 | telemetry made to emit the status events itself | | B13 | `linkClosed()` removed from `disconnect`, `onClose` and `shutdown` (one at a time) | | B29 | the `legacyNodesWarned` reset removed from `onOpen` | | B31 | `roster` / `nodes` reset added to relay Start; separately, removed from `onOpen` | | B17 | the legacy `added` derivation removed; separately, v3 treated as legacy | | B94 | the non-object skip removed; separately, the `nodeMapping` sanitizer bypassed | ## Checks `npm run lint`, `npm run typecheck` and `npx vitest run` all pass: **44 files, 713 tests**. The baseline was 40 / 698: one brittle test removed, 16 tests added. Refs #97 --- src/main/config-transfer.seam.test.ts | 155 ++++++++ src/main/node-hub.seam.test.ts | 167 +++++++++ src/main/node-mapping.seam.test.ts | 84 +++++ src/main/session.seam.test.ts | 106 ++++++ src/main/session.test.ts | 26 +- src/main/test-harness/services.ts | 392 ++++++++++++++++++++ src/renderer/src/cockpit-adopt.test.ts | 54 +-- src/renderer/src/cockpit-panels.test.ts | 44 +-- src/renderer/src/cockpit.test.ts | 47 +-- src/renderer/src/connection-summary.test.ts | 47 +-- src/renderer/src/gateway-view.test.ts | 19 +- src/renderer/src/overview.test.ts | 47 +-- src/shared/config-file.test.ts | 2 +- src/shared/config-file.ts | 5 +- src/shared/events.test.ts | 19 +- src/shared/gateway.test.ts | 19 +- src/shared/node-rules.test.ts | 18 +- src/shared/nodes.test.ts | 19 +- src/test-fixtures/config.ts | 6 + src/test-fixtures/nodes.ts | 62 ++++ tsconfig.json | 2 +- tsconfig.node.json | 1 + 22 files changed, 1030 insertions(+), 311 deletions(-) create mode 100644 src/main/config-transfer.seam.test.ts create mode 100644 src/main/node-hub.seam.test.ts create mode 100644 src/main/node-mapping.seam.test.ts create mode 100644 src/main/session.seam.test.ts create mode 100644 src/main/test-harness/services.ts create mode 100644 src/test-fixtures/config.ts create mode 100644 src/test-fixtures/nodes.ts diff --git a/src/main/config-transfer.seam.test.ts b/src/main/config-transfer.seam.test.ts new file mode 100644 index 0000000..5ea42f0 --- /dev/null +++ b/src/main/config-transfer.seam.test.ts @@ -0,0 +1,155 @@ +// XFER-seam contract test (Phase 0, row B94): how ConfigTransfer reads an import file's sections — +// open → preview → apply — with the four stores held in memory. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { CONFIG_FORMAT } from '@shared/config-file' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/ipc' +import { registryDefaults, type NodeMappingFile } from '@shared/node-mapping' +import type { CalCache } from '@shared/cal-cache' +import { ConfigTransfer } from './config-transfer' + +const axis = (min: number, centre: number, max: number) => ({ + min, + centre, + max, + selfCentring: true +}) + +let dir: string +let stores: { config: AppConfig; mapping: NodeMappingFile; cache: CalCache } + +function transfer() { + return new ConfigTransfer({ + getConfig: () => stores.config, + setConfig: (c) => (stores.config = c), + nodes: { + mappingFile: () => stores.mapping, + registryDefaults: () => + registryDefaults({ + 1: { name: 'Center_Armament', console: 'Center' }, + 2: { name: 'Right_Navigation', console: 'Right' }, + 4: { name: 'PDU_Right', console: 'Right' } + }), + onlineIds: () => [], + importMapping: (f) => { + stores.mapping = f + return { ok: true, model: {} as never } + } + }, + loadCalCache: () => stores.cache, + saveCalCache: (c) => (stores.cache = c), + backupDir: () => join(dir, 'backups'), + clientVersion: () => '0.2.0', + now: () => new Date('2026-10-16T10:00:00Z') + }) +} + +const file = (sections: unknown) => + JSON.stringify({ + format: CONFIG_FORMAT, + version: 1, + exportedAt: '2026-10-16T09:00:00Z', + sections + }) + +describe('B94 an import reads each section on its own', () => { + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skyhawk-xfer-seam-')) + stores = { + config: DEFAULT_CONFIG, + mapping: { version: 3, nodes: {} }, + cache: { version: 1, boards: {} } + } + }) + afterEach(() => rmSync(dir, { recursive: true, force: true })) + + it('a section that is not an object, or that this client does not know, is skipped', () => { + const t = transfer() + const opened = t.preview( + file({ + app: 'not an object', + nodeMapping: [1, 2], + macros: { x: 1 }, + powerSupply: { mode: 'custom', custom: { a12: 40 } } + }), + '/x/a.json' + ) + expect(opened.status).toBe('ready') + if (opened.status !== 'ready') return + expect(opened.preview.skipped).toEqual(['app', 'nodeMapping', 'macros']) + expect(opened.preview.sections.map((s) => s.id)).toEqual(['powerSupply']) + + // A skipped section can't be applied; the readable one can. + expect(t.apply(opened.preview.token, ['app'], false)).toEqual({ + ok: false, + error: 'No sections picked' + }) + expect(t.apply(opened.preview.token, ['app', 'powerSupply'], false)).toMatchObject({ + ok: true, + applied: ['powerSupply'] + }) + expect(stores.config.psu).toEqual({ mode: 'custom', preset: 'nx650', custom: { a12: 40 } }) + expect(stores.config.host).toBe(DEFAULT_CONFIG.host) + }) + + it('a file with no section this client can read is refused, and nothing can be applied', () => { + const t = transfer() + expect(t.preview(file({ app: null, macros: { x: 1 } }), '/x/a.json')).toEqual({ + status: 'error', + error: 'This file has no settings this client can read.' + }) + expect(t.apply('any', ['macros'], false)).toMatchObject({ ok: false }) + expect(stores.config).toBe(DEFAULT_CONFIG) + }) + + it("every known section goes through its own store's tolerant sanitizer: bad fields are dropped", () => { + const t = transfer() + const opened = t.preview( + file({ + app: { + host: '', + commandPort: 'x', + sourceMode: 'nonsense', + debugMode: true, + psu: { mode: 'custom' } + }, + nodeMapping: { + version: 3, + nodes: { 99: { name: 'out of range' }, 2: { name: 'Nav', pduId: 'x' } } + }, + powerSupply: { mode: 'custom', preset: 'no-such-psu', custom: { a12: 40, a5: 'x' } }, + calibration: { + version: 1, + boards: { + A7: { + confirmedAt: '2026-10-12T00:00:00Z', + axes: { 0: axis(1, 2, 3), 1: axis(5, 4, 3) } + } + } + } + }), + '/x/a.json' + ) + if (opened.status !== 'ready') throw new Error(JSON.stringify(opened)) + expect(opened.preview.skipped).toEqual([]) + const r = t.apply( + opened.preview.token, + ['app', 'nodeMapping', 'powerSupply', 'calibration'], + false + ) + expect(r).toMatchObject({ ok: true }) + + // app: junk fields fall back to their defaults, the good one is kept, its psu is ignored. + expect(stores.config).toEqual({ + ...DEFAULT_CONFIG, + debugMode: true, + psu: { mode: 'custom', preset: 'nx650', custom: { a12: 40 } } + }) + // nodeMapping: an out-of-range NODE_ID and a bad power zone are dropped. + expect(stores.mapping).toEqual({ version: 3, nodes: { 2: { name: 'Nav' } } }) + // calibration: an axis that is not min < centre < max is dropped. + expect(Object.keys(stores.cache.boards.A7!.axes)).toEqual(['0']) + }) +}) diff --git a/src/main/node-hub.seam.test.ts b/src/main/node-hub.seam.test.ts new file mode 100644 index 0000000..2bb62f8 --- /dev/null +++ b/src/main/node-hub.seam.test.ts @@ -0,0 +1,167 @@ +// HUB-seam contract tests (Phase 0, rows B8, B10, B33): NodeHub's public API — statusLine, +// telemetry, flush, snapshot and the mutators — with the mapping store kept in memory. They pin +// what the app does today, so the refactor can show it still does. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LogEventInput } from '@shared/events' +import type { NodeMappingFile } from '@shared/node-mapping' +import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' +import { statusRow } from '../test-fixtures/nodes' + +const disk: { file: NodeMappingFile; saves: number } = { file: { version: 3, nodes: {} }, saves: 0 } +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path +vi.mock('./node-mapping', () => ({ + MappingSaveError: class MappingSaveError extends Error {}, + loadNodeMapping: () => ({ file: disk.file }), + saveNodeMapping: (f: NodeMappingFile) => { + disk.file = f + disk.saves++ + } +})) + +const { NodeHub } = await import('./node-hub') + +const T0 = '2026-10-14T21:14:00.000Z' +const PANEL = 0x01 +const PDU = 0x02 +const NOT_REPORTED = 0x00 + +/** A hub on a roster, recording its pushes and its event-log rows. */ +function hub() { + const roster = new NodeRoster() + const events: LogEventInput[] = [] + const h = new NodeHub( + () => {}, + roster, + (e) => events.push(...e) + ) + /** One `_NODE_STATUS` row reaching the roster and the hub, as the device link feeds them. */ + const status = (id: number, kind: number, t: number, present = true) => { + const row = statusRow(id, present, kind) + roster.applyMessage(NODE_MSG, row, t) + h.statusLine(NODE_MSG, `_NODE_STATUS ${row}`, t) + } + /** `_NODE_STATUS_END`: the roster reply is complete. */ + const end = (count: number, t: number) => { + roster.applyMessage(NODE_END_MSG, String(count), t) + h.statusLine(NODE_END_MSG, `_NODE_STATUS_END ${count}`, t) + } + /** The 200 ms link flush. */ + const flush = (t: number) => h.flush(roster.takeDirty(), t) + const node = (id: number, t: number) => h.snapshot(t).model.nodes.find((n) => n.nodeId === id) + return { h, roster, events, status, end, flush, node } +} + +beforeEach(() => { + disk.file = { version: 3, nodes: {} } + disk.saves = 0 +}) + +describe('B8 Forget removes everything; a forgotten board that reports is new again', () => { + it('a registry board: its entry is gone, and back on the bus it is new, pre-filled from NODE_IDS.md', () => { + // Added, renamed, placed, given a power zone, hidden, and seen before. Offline now. + disk.file = { + version: 3, + nodes: { + 1: { added: true }, + 2: { added: true, name: 'Nav', console: 'Left', pduId: 3, hidden: true, lastSeen: T0 } + } + } + const { h, status, end, flush, node } = hub() + status(1, PANEL, 0) // a completed roster without board 2: it is offline, not unknown + end(1, 0) + flush(0) + expect(h.snapshot(0).model).toMatchObject({ rosterComplete: true, hidden: [2] }) + + expect(h.forget(2).ok).toBe(true) + expect(disk.file.nodes[2], 'name, placement, hidden flag and last seen all go').toBeUndefined() + + status(2, PANEL, 5000) // it reports again, as a panel group + flush(5000) + expect(node(2, 5000)).toMatchObject({ + source: 'new', + name: 'Right_Navigation', + console: null, + registry: { name: 'Right_Navigation', console: 'Right', pduId: 4 } + }) + expect(h.snapshot(5000).model.hidden, 'not hidden any more').toEqual([]) + expect(disk.file.nodes[2], 'a new board is never written').toBeUndefined() + }) + + it('an adopted board: its entry is gone, and back on the bus it is new, with nothing pre-filled', () => { + disk.file = { + version: 3, + nodes: { + 1: { added: true }, + 41: { name: 'Left_Trim', console: 'Left', kind: 'panel', pduId: 3, lastSeen: T0 } + } + } + const { h, status, end, flush, node } = hub() + status(1, PANEL, 0) + end(1, 0) + flush(0) + + expect(h.forget(41).ok).toBe(true) + expect(disk.file.nodes[41]).toBeUndefined() + + status(41, PANEL, 5000) + flush(5000) + const back = node(41, 5000) + expect(back).toMatchObject({ source: 'new', name: 'Node 41', console: null, pduId: null }) + expect(back?.registry).toBeUndefined() + expect(disk.file.nodes[41]).toBeUndefined() + }) +}) + +describe('B10 Hide keeps links: boards fed by a hidden PDU keep their power zone (HUB half)', () => { + it('hiding a PDU leaves its boards linked to it, and when it reports the group is whole again', () => { + // Board 1 is placed under PDU 4 explicitly (its NODE_IDS.md default is PDU 5); board 2 is fed + // by PDU 4 by default (Right console). + disk.file = { + version: 3, + nodes: { 1: { added: true, pduId: 4 }, 2: { added: true }, 4: { added: true } } + } + const { h, status, end, flush, node } = hub() + status(1, PANEL, 0) + status(2, PANEL, 0) + end(2, 0) // PDU 4 is not on the bus: it can be hidden + flush(0) + + expect(h.hide(4, true).ok).toBe(true) + expect(disk.file.nodes[4]?.hidden).toBe(true) + expect(disk.file.nodes[1]?.pduId, 'the explicit link is kept in the file').toBe(4) + expect(disk.file.nodes[2]?.pduId, 'the default link stays the default').toBeUndefined() + expect(h.snapshot(0).model.hidden).toEqual([4]) + expect([node(1, 0)?.pduId, node(2, 0)?.pduId], 'both still name PDU 4').toEqual([4, 4]) + + status(4, PDU, 5000) // the PDU reports: its hidden flag clears, and its boards are still its own + flush(5000) + expect(disk.file.nodes[4]?.hidden).toBeUndefined() + expect(h.snapshot(5000).model.hidden).toEqual([]) + expect(disk.file.nodes[1]?.pduId).toBe(4) + expect([node(1, 5000)?.pduId, node(2, 5000)?.pduId]).toEqual([4, 4]) + expect(node(4, 5000)).toMatchObject({ kind: 'pdu', availability: 'online' }) + }) +}) + +describe('B33 a board identified only by PDU telemetry is "new on the bus" at its next status row', () => { + it('no row when the pages arrive; one row, at that time, when its next status row comes', () => { + const { h, events, status, end, node } = hub() + status(1, PANEL, 0) + end(1, 0) // the baseline is closed: what appears from here on is news + const news = () => events.filter((e) => e.nodeId === 9 && e.event === 'new on the bus') + + status(9, NOT_REPORTED, 1000) // first sight: it has not said what it is + expect(news()).toEqual([]) + + const page = '090100E02E182EBC02' // node 9, 12 V rail page: it is a PDU + h.telemetry(page, 1500, `_NODE_TELEM ${page}`) + expect(node(9, 1500)?.identified).toBe('pdu') + expect(news(), 'the pages identify it, but write no row').toEqual([]) + + status(9, NOT_REPORTED, 5000) // the next roster poll + expect(news()).toEqual([expect.objectContaining({ nodeId: 9, t: 5000, severity: 'new' })]) + + status(9, NOT_REPORTED, 10_000) + expect(news(), 'once per board').toHaveLength(1) + }) +}) diff --git a/src/main/node-mapping.seam.test.ts b/src/main/node-mapping.seam.test.ts new file mode 100644 index 0000000..2b0d1d8 --- /dev/null +++ b/src/main/node-mapping.seam.test.ts @@ -0,0 +1,84 @@ +// FILE-seam contract test (Phase 0, row B17): node-mapping.json as it is read from and written to +// . Older files are upgraded once, on read; the next save writes v3. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const userData = vi.hoisted(() => ({ dir: '' })) +vi.mock('electron', () => ({ app: { getPath: () => userData.dir } })) + +const { loadNodeMapping, saveNodeMapping } = await import('./node-mapping') + +const path = () => join(userData.dir, 'node-mapping.json') +const write = (raw: unknown) => writeFileSync(path(), JSON.stringify(raw)) +const onDisk = () => JSON.parse(readFileSync(path(), 'utf8')) as unknown +const T0 = '2026-10-14T21:14:00.000Z' + +/** Load, save what was loaded, and load again: what an upgrade leaves behind. */ +function upgrade(raw: unknown) { + write(raw) + const first = loadNodeMapping() + saveNodeMapping(first.file) + return { first, saved: onDisk(), second: loadNodeMapping() } +} + +describe('B17 older node-mapping.json files are upgraded once, on read', () => { + beforeEach(() => { + userData.dir = mkdtempSync(join(tmpdir(), 'skyhawk-mapping-seam-')) + }) + afterEach(() => rmSync(userData.dir, { recursive: true, force: true })) + + it('v2: a renamed registry board is in your cockpit; the save writes v3', () => { + const { first, saved, second } = upgrade({ version: 2, nodes: { 2: { name: 'Nav' } } }) + expect(first).toEqual({ file: { version: 3, nodes: { 2: { name: 'Nav', added: true } } } }) + expect(saved).toEqual({ version: 3, nodes: { 2: { name: 'Nav', added: true } } }) + expect(second).toEqual(first) + }) + + it('v1: the ignore list is dropped; seen, placed, renamed and hidden boards are added', () => { + const { first, saved } = upgrade({ + version: 1, + ignored: [50, 51], + nodes: { + 1: { lastSeen: T0 }, // seen + 2: { console: 'Left' }, // placed + 3: { name: 'PDU_L' }, // renamed + 4: { hidden: true }, // hidden + 5: { pduId: null } // power zone set to none: placed + } + }) + const v3 = { + version: 3, + nodes: { + 1: { lastSeen: T0, added: true }, + 2: { console: 'Left', added: true }, + 3: { name: 'PDU_L', added: true }, + 4: { hidden: true, added: true }, + 5: { pduId: null, added: true } + } + } + expect(first).toEqual({ file: v3 }) + expect(saved).toEqual(v3) + }) + + it('no version at all reads as an old file; an adopted board keeps its own entry, not added', () => { + const adopted = { name: 'Left_Trim', console: 'Left', kind: 'panel', pduId: 3 } + const { first, saved } = upgrade({ + nodes: { 2: { name: 'Nav' }, 41: adopted, 5: { bogus: true } } + }) + // Registry board 5 had nothing worth keeping: it is dropped, not added. + const v3 = { version: 3, nodes: { 2: { name: 'Nav', added: true }, 41: adopted } } + expect(first).toEqual({ file: v3 }) + expect(saved).toEqual(v3) + }) + + it('once upgraded the file is read as written: a v3 entry without `added` stays out', () => { + upgrade({ version: 2, nodes: { 2: { name: 'Nav' } } }) + write({ version: 3, nodes: { 2: { name: 'Nav', added: true }, 1: { name: 'Arm' } } }) + expect(loadNodeMapping().file.nodes).toEqual({ + 2: { name: 'Nav', added: true }, + 1: { name: 'Arm' } + }) + }) +}) diff --git a/src/main/session.seam.test.ts b/src/main/session.seam.test.ts new file mode 100644 index 0000000..c446dcd --- /dev/null +++ b/src/main/session.seam.test.ts @@ -0,0 +1,106 @@ +// SESSION-seam contract tests (Phase 0, rows B13, B29, and the re-expressed B31): the main process +// as the outside world sees it — fake serial port and DCS socket in, renderer pushes, IPC replies +// and files out — through the services harness. Nothing here reaches inside main. +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/ipc' +import { eventText } from '@shared/events' +import type { NodeMappingFile } from '@shared/node-mapping' +import { statusLine } from '../test-fixtures/nodes' +import { makeServices, type Services } from './test-harness/services' + +const iso = (ms: number) => new Date(ms).toISOString() +const lastSeen = (app: Services, id: number) => + app.files.json('node-mapping.json')?.nodes[id]?.lastSeen + +describe('B13 last seen is saved when the link closes, however it closes', () => { + /** Board 1 is in your cockpit and on the bus; 3 s pass after it came online. */ + function onTheBus(): Services { + const app = makeServices({ + paths: { files: { 'node-mapping.json': { version: 3, nodes: { 1: { added: true } } } } } + }) + app.serial.open() + app.serial.receive(statusLine(1) + '_NODE_STATUS_END 1\n') + app.clock.advance(200) // the link flush: it came online + expect(lastSeen(app, 1)).toBe(iso(app.clock.now())) + app.clock.advance(3000) // before the next roster poll + return app + } + + it.each([ + ['Disconnect', (app: Services) => app.invoke(CTRL.deviceDisconnect)], + ['an unplug (the port closes)', (app: Services) => app.serial.close()], + ['app quit', (app: Services) => app.quit()] + ])('%s writes the time the board was last on the bus', (_, close) => { + const app = onTheBus() + const at = app.clock.now() + close(app) + expect(lastSeen(app, 1)).toBe(iso(at)) + }) +}) + +describe('B29 a v2 Gateway is reported once per link, and raised again after a reopen', () => { + it('raised once per port open, however many v2 rows arrive; the event log keeps one row', () => { + // A Monitor relay runs only so the stream stats (stats:tick, 1/s) show each raise. + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'monitor' } } } }) + expect(app.invoke(CTRL.relayStart)).toEqual({ ok: true }) + const v2 = '_NODE_STATUS 01010000000000000080000000\n' // 26 hex: no nodeKind + const warnings = () => + app.invoke(CTRL.eventsGet).filter((e) => eventText(e).includes('needs v3')) + const raised = () => { + app.clock.advance(1000) + return app.lastPush('stats:tick')?.errors + } + + app.serial.open() + app.serial.receive(v2 + v2 + '_NODE_STATUS_END 1\n') + app.serial.receive(v2) + expect(warnings()).toHaveLength(1) + expect(warnings()[0]).toMatchObject({ severity: 'error', name: 'NODES' }) + expect(raised(), 'once for the link, not once per row').toBe(1) + + app.serial.close() // unplugged, and plugged back in + app.serial.open() + app.serial.receive(v2 + v2 + '_NODE_STATUS_END 1\n') + expect(raised(), 'raised again on the new link').toBe(2) + // contract B29 says the warning repeats once after a reopen (2 rows total); today the code + // does raise it again (the error count above), but the event log keeps 1 row: Session's + // per-tag dedupe (lastErr) never clears 'NODES', so the identical second message is dropped. + expect(warnings()).toHaveLength(1) + }) +}) + +describe('B31 node state starts over when the link opens, not when the relay starts (#71 finding 6)', () => { + it('a stale node leaves the model on a reopen, and stays through relay Start and Stop', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) + app.serial.open() + // Node 41 is not in your cockpit: it is listed only while the roster has it (B4). + app.serial.receive(statusLine(41) + '_NODE_STATUS_END 1\n') + app.serial.receive('_NODE_TELEM 030100E02E182EBC02\n') // PDU 3's 12 V page + app.clock.advance(200) + const pushed = () => app.lastPush('nodes:model')?.nodes.map((n) => n.nodeId) + const snapshot = () => { + const s = app.invoke(CTRL.nodesGet) + return { + nodes: s.model.nodes.map((n) => n.nodeId), + telemetry: s.telemetry.map((t) => t.nodeId) + } + } + expect(pushed()).toContain(41) + expect(snapshot()).toMatchObject({ telemetry: [3] }) + expect(snapshot().nodes).toContain(41) + + expect(app.invoke(CTRL.relayStart)).toEqual({ ok: true }) + app.invoke(CTRL.relayStop) + expect(app.invoke(CTRL.relayStart)).toEqual({ ok: true }) + app.clock.advance(200) + expect(pushed(), 'relay Start must not wipe the roster').toContain(41) + expect(snapshot().nodes).toContain(41) + expect(snapshot().telemetry, 'relay Start must not wipe the node data').toEqual([3]) + + app.serial.close() // the reconnect + app.serial.open() + app.clock.advance(200) + expect(pushed(), 'the next push after an open starts over').not.toContain(41) + expect(snapshot()).toEqual({ nodes: [], telemetry: [] }) + }) +}) diff --git a/src/main/session.test.ts b/src/main/session.test.ts index d9405c6..5bc1a39 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -3,6 +3,7 @@ import { CAL_TYPE, buildFrame } from '@shared/calibration' import type { LogRow } from '@shared/ipc' import { eventText, type LogEvent } from '@shared/events' import type { NodeModel } from '@shared/node-rules' +import { statusLine } from '../test-fixtures/nodes' // Session pulls in electron (debug log path), the serial port, the DCS transport and node-hid. // Only the seams matter here, so each is replaced with something a test can drive directly. @@ -312,13 +313,6 @@ describe('HID recovery after an unplug', () => { type Push = [string, unknown] -/** A `_NODE_STATUS` line: present, no flags, die temp not yet seen. */ -/** A v3 `_NODE_STATUS` row (28 hex): kind 01 panel group unless given. */ -const statusLine = (nodeId: number, present = true, kind = 1): string => - `_NODE_STATUS ${nodeId.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind - .toString(16) - .padStart(2, '0')}\n` - /** A session with the link open (app launched), the relay not started, and a recorder of pushes. */ function launched(config: Parameters['setConfig']>[0] = {}) { const pushes: Push[] = [] @@ -436,24 +430,6 @@ describe('device link is independent of the relay (#69)', () => { } }) - it('resets node state on link open, not on relay start (#71 finding 6)', () => { - const { session, serial } = launched({ sourceMode: 'bridge' }) - const roster = vi.spyOn(session.link.roster, 'reset') - const nodes = vi.spyOn(session.link.nodes, 'reset') - serial.openCb('/dev/fake') - expect(roster).toHaveBeenCalledTimes(1) - expect(nodes).toHaveBeenCalledTimes(1) - session.start() - session.stop() - session.start() - expect(roster, 'relay Start must not wipe the roster').toHaveBeenCalledTimes(1) - expect(nodes, 'relay Start must not wipe the node model').toHaveBeenCalledTimes(1) - serial.openCb('/dev/fake') // the reconnect - expect(roster).toHaveBeenCalledTimes(2) - expect(nodes).toHaveBeenCalledTimes(2) - session.shutdown() - }) - it('reports an absent board as no-device, not as an error', () => { vi.useFakeTimers() try { diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts new file mode 100644 index 0000000..899178d --- /dev/null +++ b/src/main/test-harness/services.ts @@ -0,0 +1,392 @@ +// Services harness for SESSION-seam tests (test-only: production code never imports this). +// +// `makeServices()` launches the main process the way index.ts does — settings loaded from +// config.json, then the device link opened — with fakes standing in for the hardware and the +// outside world: the SimGateway serial port, the DCS transport, HID, the clock and . +// +// A test sees only what the app shows the outside world: +// - pushes to the renderer (channel + payload), +// - IPC intents (renderer → main requests, by their CTRL channel name), +// - the files main writes under , +// - bytes on the fake serial port and the fake DCS socket, +// - a clock it advances. +// Never the objects inside. Today the harness wraps `Session`; once Session becomes `AppServices` +// (R2.3) only this file changes, and the tests written against it keep passing as they are. +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { onTestFinished, vi } from 'vitest' +import { CTRL, type AppConfig, type PushChannel, type PushChannels } from '@shared/ipc' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/node-mapping' + +// ── the outside world, faked ───────────────────────────────────────────────── + +/** What the fakes share with the harness instance currently running. */ +const world = { + userData: '', + portPath: '/dev/fake', + serialNumber: 'TESTSERIAL', + /** Every byte written to any serial port, in order: the wire, across reconnects. */ + serialOut: [] as Buffer[], + /** Every byte sent to DCS, in order, across relay restarts. */ + dcsOut: [] as Buffer[], + transportConnectsOnStart: false +} + +/** Stand-in for SerialBridge: one instance per port open, the latest is the board. */ +class FakeSerial { + static last?: FakeSerial + dataCb: (c: Buffer) => void = () => {} + closeCb: () => void = () => {} + openCb: (p: string) => void = () => {} + errorCb: (e: Error) => void = () => {} + stopped = false + constructor() { + FakeSerial.last = this + } + onData(cb: (c: Buffer) => void) { + this.dataCb = cb + } + onMonitor() {} + onError(cb: (e: Error) => void) { + this.errorCb = cb + } + onOpen(cb: (p: string) => void) { + this.openCb = cb + } + onClose(cb: () => void) { + this.closeCb = cb + } + start() {} + stop() { + this.stopped = true + } + write(d: Buffer) { + world.serialOut.push(Buffer.from(d)) + } + portPath() { + return world.portPath + } +} + +/** Stand-in for the DCS transport (UDP / TCP): captures what would go to DCS. */ +class FakeTransport { + static last?: FakeTransport + exportCb: (c: Buffer) => void = () => {} + connectedCb: (c: boolean) => void = () => {} + errorCb: (e: Error) => void = () => {} + constructor() { + FakeTransport.last = this + } + start() { + if (world.transportConnectsOnStart) this.connectedCb(true) + } + stop() { + this.connectedCb(false) // as the real UDP transport does + } + send(d: Buffer) { + world.dcsOut.push(Buffer.from(d)) + } + onExport(cb: (c: Buffer) => void) { + this.exportCb = cb + } + onError(cb: (e: Error) => void) { + this.errorCb = cb + } + onConnected(cb: (c: boolean) => void) { + this.connectedCb = cb + } +} + +/** Stand-in for HidReader (node-hid is optional, and the device is fake): a silent joystick. */ +class FakeHid { + onError() {} + onReport() {} + start() {} + reopen() {} + stop() {} + sampleRate() {} + snapshot() { + return { axes: [], buttons: [], hats: [], ageMs: 0, rateHz: 0 } + } +} + +// Registered before main is imported (below), so main is built against the fakes. Paths are +// relative to this file. node-mapping, settings and cal-cache are real: they write . +vi.doMock('electron', () => ({ + app: { + getPath: () => world.userData, + getVersion: () => '0.0.0-test', + isPackaged: false + } +})) +vi.doMock('../serial', () => ({ + SerialBridge: FakeSerial, + PortNotFoundError: class PortNotFoundError extends Error {}, + SIMGATEWAY_VID: 0x2e8a, + SIMGATEWAY_PID: 0x4134, + findSimGatewayPort: () => Promise.resolve(world.portPath), + listSerialPorts: () => + Promise.resolve([{ path: world.portPath, serialNumber: world.serialNumber }]) +})) +vi.doMock('../net', () => ({ createTransport: () => new FakeTransport() })) +vi.doMock('../hid', () => ({ HidReader: FakeHid })) + +const { Session } = await import('../session') +const { loadConfig, saveConfig } = await import('../settings') +const { setDebugEnabled } = await import('../debug') +const { PortNotFoundError } = await import('../serial') + +// ── the harness ────────────────────────────────────────────────────────────── + +export interface ServicesOptions { + /** The SimGateway's serial port: where it enumerates, and its USB serial number. */ + serial?: { portPath?: string; serialNumber?: string } + /** The DCS end of the relay. `connectOnStart`: DCS is running, so a started relay links up. */ + transport?: { connectOnStart?: boolean } + /** The fake clock, ms since the epoch. Every timer in main runs on it. */ + clock?: { start?: number } + /** + * : a fresh temporary folder, seeded with `files` before launch (a string is written + * as is, anything else as JSON). `config.json` is what the app launches with. + */ + paths?: { files?: Record } +} + +type Push = { [C in PushChannel]: { channel: C; payload: PushChannels[C] } }[PushChannel] + +/** The default clock: 2026-10-16 12:00:00 UTC. */ +export const CLOCK_START = Date.UTC(2026, 9, 16, 12, 0, 0) + +const live = new Set() + +export type Services = ReturnType + +/** + * Launch the app's main-process services against fakes. Cleaned up after the test (timers back to + * real, the app quit, deleted); `dispose()` does it sooner. + */ +export function makeServices(opts: ServicesOptions = {}): Services { + const svc = launch(opts) + live.add(svc) + try { + onTestFinished(() => svc.dispose()) + } catch { + // outside a running test (a hook): the caller disposes + } + return svc +} + +function launch(opts: ServicesOptions) { + for (const other of live) other.dispose() // one app at a time: the fakes are shared + vi.useFakeTimers({ now: opts.clock?.start ?? CLOCK_START }) + + const userData = mkdtempSync(join(tmpdir(), 'skyhawk-services-')) + for (const [name, value] of Object.entries(opts.paths?.files ?? {})) { + writeFileSync( + join(userData, name), + typeof value === 'string' ? value : JSON.stringify(value, null, 2) + ) + } + Object.assign(world, { + userData, + portPath: opts.serial?.portPath ?? '/dev/fake', + serialNumber: opts.serial?.serialNumber ?? 'TESTSERIAL', + serialOut: [], + dcsOut: [], + transportConnectsOnStart: opts.transport?.connectOnStart ?? false + }) + FakeSerial.last = undefined + FakeTransport.last = undefined + + const pushes: Push[] = [] + const session = new Session((channel, payload) => { + pushes.push({ channel, payload } as Push) + }) + + // index.ts: applyConfig(), the one path for config:set and a config import. + const applyConfig = (patch: Partial): AppConfig => { + const config = session.setConfig(patch) + setDebugEnabled(config.debugMode) + saveConfig(config) + return config + } + + // index.ts registerIpc(): the renderer's requests main answers, by channel. + const intents = { + [CTRL.configGet]: () => session.getConfig(), + [CTRL.configSet]: (patch: Partial) => applyConfig(patch), + [CTRL.relayStart]: () => session.start(), + [CTRL.relayStop]: () => session.stop(), + [CTRL.relayStatus]: () => session.status(), + [CTRL.deviceConnect]: () => session.link.connect(), + [CTRL.deviceDisconnect]: () => session.link.disconnect(), + [CTRL.nodesRefresh]: () => session.link.requestNodes(), + [CTRL.nodesGet]: () => session.link.nodes.snapshot(), + [CTRL.nodeMappingSet]: (id: number, patch: MappingPatch) => + session.link.nodes.setMapping(id, patch), + [CTRL.nodeMappingAdopt]: (id: number, spec: AdoptSpec) => session.link.nodes.adopt(id, spec), + [CTRL.nodeMappingForget]: (id: number) => session.link.nodes.forget(id), + [CTRL.nodeMappingResetNode]: (id: number) => session.link.nodes.resetBoard(id), + [CTRL.nodeMappingReset]: (opts?: ResetOptions) => session.link.nodes.resetAll(opts), + [CTRL.nodeMappingResetPreview]: () => session.link.nodes.previewReset(), + [CTRL.nodeRegistry]: () => session.link.nodes.registry(), + [CTRL.nodeMappingHide]: (id: number, on: boolean) => session.link.nodes.hide(id, on), + [CTRL.serialMonitor]: (on: boolean) => session.link.setSerialMonitor(on), + [CTRL.eventsGet]: () => session.events.snapshot(), + [CTRL.eventsClear]: () => session.events.clear() + } + type Intents = typeof intents + + // index.ts app.whenReady(): seed from persisted settings, then bring the device link up. + const cfg = session.setConfig(loadConfig()) + setDebugEnabled(cfg.debugMode) + session.open() + + let quit = false + let disposed = false + + const port = (): FakeSerial => { + const s = FakeSerial.last + if (!s || s.stopped) throw new Error('no serial port is open: the link is not looking for one') + return s + } + const dcs = (): FakeTransport => { + const t = FakeTransport.last + if (!t) throw new Error('no DCS transport: the relay has not been started') + return t + } + const bytes = (data: string | Buffer | Uint8Array): Buffer => + typeof data === 'string' ? Buffer.from(data, 'latin1') : Buffer.from(data) + + return { + /** Every push to the renderer, in order. */ + pushes: pushes as readonly Push[], + + /** The payloads pushed on one channel, in order. */ + pushesOf(channel: C): PushChannels[C][] { + return pushes.filter((p) => p.channel === channel).map((p) => p.payload as PushChannels[C]) + }, + + /** The most recent payload pushed on one channel. */ + lastPush(channel: C): PushChannels[C] | undefined { + return this.pushesOf(channel).at(-1) + }, + + /** A renderer → main request, as `ipcRenderer.invoke(channel, ...args)` makes it. */ + invoke( + channel: C, + ...args: Parameters + ): ReturnType { + const handler = intents[channel] as (...a: unknown[]) => ReturnType + return handler(...args) + }, + + /** The SimGateway end of the USB cable. */ + serial: { + /** The port opens: the board is plugged in (or replugged) and found. */ + open(): void { + port().openCb(world.portPath) + }, + /** The port closes under the app: the board is unplugged. */ + close(): void { + port().closeCb() + }, + /** The board sends these bytes (a string is taken byte for byte). */ + receive(data: string | Buffer | Uint8Array): void { + port().dataCb(bytes(data)) + }, + /** No board is plugged in. */ + notFound(): void { + port().errorCb(new PortNotFoundError()) + }, + /** The port fails to open, or fails while open. */ + fail(message: string): void { + port().errorCb(new Error(message)) + }, + /** Everything the app wrote to the board, across reconnects. */ + written(): Buffer[] { + return [...world.serialOut] + }, + /** `written()` as one latin1 string. */ + writtenText(): string { + return Buffer.concat(world.serialOut).toString('latin1') + }, + clearWritten(): void { + world.serialOut.length = 0 + } + }, + + /** DCS's end of the relay. */ + transport: { + /** DCS sends a chunk of the export stream. */ + export(data: string | Buffer | Uint8Array): void { + dcs().exportCb(bytes(data)) + }, + /** The DCS link comes up (true) or drops (false). */ + connected(up: boolean): void { + dcs().connectedCb(up) + }, + fail(message: string): void { + dcs().errorCb(new Error(message)) + }, + /** Everything the app sent to DCS, across relay restarts. */ + sent(): Buffer[] { + return [...world.dcsOut] + }, + clearSent(): void { + world.dcsOut.length = 0 + } + }, + + clock: { + now(): number { + return Date.now() + }, + /** Move time on, running every timer that falls due. */ + advance(ms: number): void { + vi.advanceTimersByTime(ms) + } + }, + + /** , as main left it on disk. */ + files: { + dir: userData, + list(): string[] { + return existsSync(userData) ? readdirSync(userData).sort() : [] + }, + /** A file's text, or undefined if main has not written it. */ + text(name: string): string | undefined { + const p = join(userData, name) + return existsSync(p) ? readFileSync(p, 'utf8') : undefined + }, + /** A JSON file, parsed; undefined if main has not written it. */ + json(name: string): T | undefined { + const t = this.text(name) + return t === undefined ? undefined : (JSON.parse(t) as T) + } + }, + + /** The window closes (`window-all-closed`): the relay stops, the app keeps running. */ + closeWindow(): void { + session.stop() + }, + + /** The app quits (`will-quit`). */ + quit(): void { + if (quit) return + quit = true + session.shutdown() + }, + + /** Quit if still running, put real timers back and delete . Idempotent. */ + dispose(): void { + if (disposed) return + disposed = true + this.quit() + vi.useRealTimers() + rmSync(userData, { recursive: true, force: true }) + live.delete(this as Services) + } + } +} diff --git a/src/renderer/src/cockpit-adopt.test.ts b/src/renderer/src/cockpit-adopt.test.ts index 1603a69..1d57d88 100644 --- a/src/renderer/src/cockpit-adopt.test.ts +++ b/src/renderer/src/cockpit-adopt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' +import type { NodeModelEntry } from '@shared/node-rules' import type { NodeStatus } from '@shared/nodes' +import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' import { nodeViews } from './cockpit' import { @@ -19,45 +20,18 @@ import { strayNew } from './cockpit-adopt' -const status = (nodeId: number, over: Partial = {}): NodeStatus => ({ - nodeId, - present: true, - boff: false, - epvf: false, - uptimeSec: 130, - rxCount: 10, - tec: 0, - rec: 0, - dieTempC: 30, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) - -const fresh = (nodeId: number, over: Partial = {}): NodeModelEntry => ({ - nodeId, - name: `Node ${nodeId}`, - console: null, - pduId: null, - kind: 'panel', - source: 'new', - availability: 'online', - status: status(nodeId), - firstSeenAt: 1000, - differs: false, - ...over -}) - -const model = (nodes: NodeModelEntry[]): NodeModel => ({ - nodes, - outages: [], - differingCount: 0, - hidden: [], - hiddenNodes: [], - rosterComplete: true -}) +const status = (nodeId: number, over: Partial = {}): NodeStatus => + nodeStatus(nodeId, { uptimeSec: 130, ...over }) + +const fresh = (nodeId: number, over: Partial = {}): NodeModelEntry => + modelNode(nodeId, { + name: `Node ${nodeId}`, + console: null, + source: 'new', + status: status(nodeId), + firstSeenAt: 1000, + ...over + }) const pduTelem = (nodeId: number): NodeTelemSnapshot => ({ nodeId, diff --git a/src/renderer/src/cockpit-panels.test.ts b/src/renderer/src/cockpit-panels.test.ts index e677a37..508221d 100644 --- a/src/renderer/src/cockpit-panels.test.ts +++ b/src/renderer/src/cockpit-panels.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' +import type { NodeModelEntry } from '@shared/node-rules' import type { NodeStatus } from '@shared/nodes' +import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' import { lastSeenLabel, nodeViews } from './cockpit' import { @@ -18,35 +19,11 @@ import { reportAge } from './cockpit-panels' -const status = (nodeId: number, over: Partial = {}): NodeStatus => ({ - nodeId, - present: true, - boff: false, - epvf: false, - uptimeSec: 3725, - rxCount: 12345, - tec: 8, - rec: 1, - dieTempC: 33, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) +const status = (nodeId: number, over: Partial = {}): NodeStatus => + nodeStatus(nodeId, { uptimeSec: 3725, rxCount: 12345, tec: 8, rec: 1, dieTempC: 33, ...over }) -const node = (nodeId: number, over: Partial = {}): NodeModelEntry => ({ - nodeId, - name: `Node_${nodeId}`, - console: 'Center', - pduId: null, - kind: 'panel', - source: 'registry', - availability: 'online', - status: status(nodeId), - differs: false, - ...over -}) +const node = (nodeId: number, over: Partial = {}): NodeModelEntry => + modelNode(nodeId, { status: status(nodeId), ...over }) const pdu = (nodeId: number, console: string, over: Partial = {}) => node(nodeId, { @@ -98,15 +75,6 @@ const telem = ( live: true }) -const model = (nodes: NodeModelEntry[]): NodeModel => ({ - nodes, - outages: [], - differingCount: 0, - hidden: [], - hiddenNodes: [], - rosterComplete: true -}) - const view = (e: NodeModelEntry, t: NodeTelemSnapshot[] = []) => nodeViews(model([e]), t)[0]! describe('reportAge', () => { diff --git a/src/renderer/src/cockpit.test.ts b/src/renderer/src/cockpit.test.ts index 2f5a126..85c038b 100644 --- a/src/renderer/src/cockpit.test.ts +++ b/src/renderer/src/cockpit.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' +import type { NodeModelEntry, PduOutage } from '@shared/node-rules' +import { + modelNode as node, + nodeModel as model, + nodeStatus as status +} from '../../test-fixtures/nodes' import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' import { STATE_SYMBOL, @@ -26,36 +30,6 @@ import { totalWatts } from './cockpit' -const status = (nodeId: number, over: Partial = {}): NodeStatus => ({ - nodeId, - present: true, - boff: false, - epvf: false, - uptimeSec: 100, - rxCount: 10, - tec: 0, - rec: 0, - dieTempC: 30, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) - -const node = (nodeId: number, over: Partial = {}): NodeModelEntry => ({ - nodeId, - name: `Node_${nodeId}`, - console: 'Center', - pduId: null, - kind: 'panel', - source: 'registry', - availability: 'online', - status: status(nodeId), - differs: false, - ...over -}) - const pdu = (nodeId: number, console: string, over: Partial = {}) => node(nodeId, { name: `PDU_${console}`, console, kind: 'pdu', readings: 'live', ...over }) @@ -101,15 +75,6 @@ const telem = ( live: !o.stale }) -const model = (nodes: NodeModelEntry[], outages: PduOutage[] = []): NodeModel => ({ - nodes, - outages, - differingCount: 0, - hidden: [], - hiddenNodes: [], - rosterComplete: true -}) - describe('nodeHealth', () => { it('maps every state to a symbol, healthy and unknown to none', () => { expect(STATE_SYMBOL).toEqual({ on: '', warn: '!', off: '✕', new: '+', unknown: '' }) diff --git a/src/renderer/src/connection-summary.test.ts b/src/renderer/src/connection-summary.test.ts index e90ac63..a312b3e 100644 --- a/src/renderer/src/connection-summary.test.ts +++ b/src/renderer/src/connection-summary.test.ts @@ -1,41 +1,15 @@ import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' +import type { NodeModelEntry, PduOutage } from '@shared/node-rules' +import { + modelNode as node, + nodeModel as model, + nodeStatus as status +} from '../../test-fixtures/nodes' import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' import { STATE_SYMBOL, attentionItems, lastSeenLabel, nodeViews } from './cockpit' import { nodesSummary } from './connection-summary' import { fmtTime } from './store' -const status = (nodeId: number, over: Partial = {}): NodeStatus => ({ - nodeId, - present: true, - boff: false, - epvf: false, - uptimeSec: 100, - rxCount: 10, - tec: 0, - rec: 0, - dieTempC: 30, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) - -const node = (nodeId: number, over: Partial = {}): NodeModelEntry => ({ - nodeId, - name: `Node_${nodeId}`, - console: 'Center', - pduId: null, - kind: 'panel', - source: 'registry', - availability: 'online', - status: status(nodeId), - differs: false, - ...over -}) - const pdu = (nodeId: number, console: string, over: Partial = {}) => node(nodeId, { name: `PDU_${console}`, console, kind: 'pdu', readings: 'live', ...over }) @@ -59,15 +33,6 @@ const telem = (nodeId: number, i12: number, i5: number): NodeTelemSnapshot => ({ live: true }) -const model = (nodes: NodeModelEntry[], outages: PduOutage[] = []): NodeModel => ({ - nodes, - outages, - differingCount: 0, - hidden: [], - hiddenNodes: [], - rosterComplete: true -}) - const summary = ( nodes: NodeModelEntry[], outages: PduOutage[] = [], diff --git a/src/renderer/src/gateway-view.test.ts b/src/renderer/src/gateway-view.test.ts index 1eede73..abda816 100644 --- a/src/renderer/src/gateway-view.test.ts +++ b/src/renderer/src/gateway-view.test.ts @@ -2,25 +2,12 @@ import { describe, expect, it } from 'vitest' import { gatewayStatus } from '@shared/gateway' import type { NodeStatus } from '@shared/nodes' import { gatewayView } from './gateway-view' +import { nodeStatus } from '../../test-fixtures/nodes' const PORT = '/dev/cu.usbmodem1101' -const node0 = (over: Partial = {}): NodeStatus => ({ - nodeId: 0, - present: true, - boff: false, - epvf: false, - uptimeSec: 0, - rxCount: 0, - tec: 0, - rec: 0, - dieTempC: 42, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 3, - ...over -}) +const node0 = (over: Partial = {}): NodeStatus => + nodeStatus(0, { uptimeSec: 0, rxCount: 0, dieTempC: 42, nodeKind: 3, ...over }) const view = (over: Partial = {}, notResponding = false, port?: string) => gatewayView(gatewayStatus(node0(over), notResponding), true, port) diff --git a/src/renderer/src/overview.test.ts b/src/renderer/src/overview.test.ts index 7b03fcd..4d8d3f7 100644 --- a/src/renderer/src/overview.test.ts +++ b/src/renderer/src/overview.test.ts @@ -1,40 +1,14 @@ import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' +import type { NodeModelEntry, PduOutage } from '@shared/node-rules' +import { + modelNode as node, + nodeModel as model, + nodeStatus as status +} from '../../test-fixtures/nodes' import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' import { attentionItems, healthBand, nodeViews } from './cockpit' import { attentionSummary, cockpitTile, newDevices, newDevicesText, reportedKind } from './overview' -const status = (nodeId: number, over: Partial = {}): NodeStatus => ({ - nodeId, - present: true, - boff: false, - epvf: false, - uptimeSec: 100, - rxCount: 10, - tec: 0, - rec: 0, - dieTempC: 30, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) - -const node = (nodeId: number, over: Partial = {}): NodeModelEntry => ({ - nodeId, - name: `Node_${nodeId}`, - console: 'Center', - pduId: null, - kind: 'panel', - source: 'registry', - availability: 'online', - status: status(nodeId), - differs: false, - ...over -}) - const pdu = (nodeId: number, over: Partial = {}) => node(nodeId, { name: `PDU_${nodeId}`, @@ -68,15 +42,6 @@ const telem = (nodeId: number, i12: number, i5: number): NodeTelemSnapshot => ({ live: true }) -const model = (nodes: NodeModelEntry[], outages: PduOutage[] = []): NodeModel => ({ - nodes, - outages, - differingCount: 0, - hidden: [], - hiddenNodes: [], - rosterComplete: true -}) - const bandOf = (nodes: NodeModelEntry[], telemetry: NodeTelemSnapshot[] = []) => healthBand(nodeViews(model(nodes), telemetry)) diff --git a/src/shared/config-file.test.ts b/src/shared/config-file.test.ts index 9b3135e..93f7404 100644 --- a/src/shared/config-file.test.ts +++ b/src/shared/config-file.test.ts @@ -3,7 +3,6 @@ import { applyToConfig, buildConfigFile, CONFIG_FORMAT, - DEFAULT_APP_SETTINGS, diffImport, mergeCalibration, parseConfigFile, @@ -14,6 +13,7 @@ import { DEFAULT_CONFIG } from './ipc' import { registryDefaults, type NodeMappingFile } from './node-mapping' import { DEFAULT_PSU } from './psu' import type { CalCache } from './cal-cache' +import { DEFAULT_APP_SETTINGS } from '../test-fixtures/config' const defaults = registryDefaults({ 0: { name: 'PanelBridge', console: '—' }, diff --git a/src/shared/config-file.ts b/src/shared/config-file.ts index e2d0631..36a2150 100644 --- a/src/shared/config-file.ts +++ b/src/shared/config-file.ts @@ -9,7 +9,7 @@ // calibration calibration-cache.json sanitizeCache // Calibration only ever refills the client's copy. Writing a device is the HID tab's Review & // restore offer, never an import. -import { DEFAULT_CONFIG, sanitizeConfig, type AppConfig } from './ipc' +import { sanitizeConfig, type AppConfig } from './ipc' import { sanitizeCache, type CachedAxis, type CalCache } from './cal-cache' import { effectiveMapping, @@ -344,6 +344,3 @@ export function applyToConfig( next = { ...next, psu: imported.powerSupply } return next } - -/** Every section DEFAULT_CONFIG knows, for a snapshot from defaults (tests, first run). */ -export const DEFAULT_APP_SETTINGS: AppSettings = appSettings(DEFAULT_CONFIG) diff --git a/src/shared/events.test.ts b/src/shared/events.test.ts index de37754..9f2d2bc 100644 --- a/src/shared/events.test.ts +++ b/src/shared/events.test.ts @@ -14,23 +14,10 @@ import { } from './events' import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS, type NodeStatus } from './nodes' import { PDU_FLAG, RAIL_FLAG, type NodeTelem } from './node-telem' +import { nodeStatus } from '../test-fixtures/nodes' -const st = (nodeId: number, over: Partial = {}): NodeStatus => ({ - nodeId, - present: true, - boff: false, - epvf: false, - uptimeSec: 0, - rxCount: 0, - tec: 0, - rec: 0, - dieTempC: null, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) +const st = (nodeId: number, over: Partial = {}): NodeStatus => + nodeStatus(nodeId, { uptimeSec: 0, rxCount: 0, dieTempC: null, ...over }) const NAMES: Record = { 2: 'Right_Navigation', 4: 'PDU_Right', 7: 'Left_Fuel' } const lookup = (): NodeLookup => ({ diff --git a/src/shared/gateway.test.ts b/src/shared/gateway.test.ts index 7dd6b9e..be19f6b 100644 --- a/src/shared/gateway.test.ts +++ b/src/shared/gateway.test.ts @@ -2,23 +2,10 @@ import { describe, expect, it } from 'vitest' import { GATEWAY_NO_ANSWER_MS, GatewayWatch, gatewayEvents, gatewayStatus } from './gateway' import { eventText, type LogEventInput } from './events' import type { NodeStatus } from './nodes' +import { nodeStatus } from '../test-fixtures/nodes' -const node0 = (over: Partial = {}): NodeStatus => ({ - nodeId: 0, - present: true, - boff: false, - epvf: false, - uptimeSec: 100, - rxCount: 5, - tec: 0, - rec: 0, - dieTempC: 42, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 3, - ...over -}) +const node0 = (over: Partial = {}): NodeStatus => + nodeStatus(0, { rxCount: 5, dieTempC: 42, nodeKind: 3, ...over }) const st = (over: Partial = {}, notResponding = false) => gatewayStatus(node0(over), notResponding)! // a row always has a status diff --git a/src/shared/node-rules.test.ts b/src/shared/node-rules.test.ts index b043f58..2c2deec 100644 --- a/src/shared/node-rules.test.ts +++ b/src/shared/node-rules.test.ts @@ -10,6 +10,7 @@ import { import { EMPTY_MAPPING, registryDefaults, type NodeMappingFile } from './node-mapping' import type { NodeSeen, NodeStatus } from './nodes' import type { NodeTelemSnapshot } from './node-telem' +import { nodeStatus } from '../test-fixtures/nodes' const defaults = registryDefaults({ 0: { name: 'PanelBridge', console: '—' }, @@ -20,21 +21,8 @@ const defaults = registryDefaults({ 5: { name: 'PDU_Center', console: 'Center' } }) -const st = (nodeId: number, present = true): NodeStatus => ({ - nodeId, - present, - boff: false, - epvf: false, - uptimeSec: 0, - rxCount: 0, - tec: 0, - rec: 0, - dieTempC: null, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0 -}) +const st = (nodeId: number, present = true): NodeStatus => + nodeStatus(nodeId, { present, uptimeSec: 0, rxCount: 0, dieTempC: null }) /** A PDU with a fresh 12 V page; pass `stale: true` for one whose pages all went stale. */ const telem = ( nodeId: number, diff --git a/src/shared/nodes.test.ts b/src/shared/nodes.test.ts index 89c4737..39b6f76 100644 --- a/src/shared/nodes.test.ts +++ b/src/shared/nodes.test.ts @@ -11,23 +11,10 @@ import { nodeFaultTag, type NodeStatus } from './nodes' +import { nodeStatus } from '../test-fixtures/nodes' -const node = (over: Partial): NodeStatus => ({ - nodeId: 1, - present: true, - boff: false, - epvf: false, - uptimeSec: 0, - rxCount: 0, - tec: 0, - rec: 0, - dieTempC: 30, - overheat: false, - degraded: false, - faultId: 0, - nodeKind: 0, - ...over -}) +const node = (over: Partial): NodeStatus => + nodeStatus(1, { uptimeSec: 0, rxCount: 0, ...over }) describe('parseNodeStatus', () => { it('decodes a v3 row (28 hex, nodeKind last)', () => { diff --git a/src/test-fixtures/config.ts b/src/test-fixtures/config.ts new file mode 100644 index 0000000..9372e7a --- /dev/null +++ b/src/test-fixtures/config.ts @@ -0,0 +1,6 @@ +// Shared config fixtures for tests (test-only: no production code may import this folder). +import { appSettings, type AppSettings } from '@shared/config-file' +import { DEFAULT_CONFIG } from '@shared/ipc' + +/** The `app` section of a config export taken from defaults: every setting but the PSU. */ +export const DEFAULT_APP_SETTINGS: AppSettings = appSettings(DEFAULT_CONFIG) diff --git a/src/test-fixtures/nodes.ts b/src/test-fixtures/nodes.ts new file mode 100644 index 0000000..fb53fc8 --- /dev/null +++ b/src/test-fixtures/nodes.ts @@ -0,0 +1,62 @@ +// Shared node fixtures for tests (test-only: no production code may import this folder). +// +// One set of builders for the node status rows and node models that test files used to redefine +// with their own defaults. A test that needs different defaults passes those fields explicitly, +// so what each test feeds in stays visible at the call site. +import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' +import type { NodeStatus } from '@shared/nodes' + +/** A parsed `_NODE_STATUS` row: present, healthy, kind not reported (0), 30 °C, 100 s up. */ +export const nodeStatus = (nodeId: number, over: Partial = {}): NodeStatus => ({ + nodeId, + present: true, + boff: false, + epvf: false, + uptimeSec: 100, + rxCount: 10, + tec: 0, + rec: 0, + dieTempC: 30, + overheat: false, + degraded: false, + faultId: 0, + nodeKind: 0, + ...over +}) + +/** A node-model entry: an online registry panel in the Center console, no power zone. */ +export const modelNode = (nodeId: number, over: Partial = {}): NodeModelEntry => ({ + nodeId, + name: `Node_${nodeId}`, + console: 'Center', + pduId: null, + kind: 'panel', + source: 'registry', + availability: 'online', + status: nodeStatus(nodeId), + differs: false, + ...over +}) + +/** The pushed node model around these entries: roster complete, nothing hidden or differing. */ +export const nodeModel = (nodes: NodeModelEntry[], outages: PduOutage[] = []): NodeModel => ({ + nodes, + outages, + differingCount: 0, + hidden: [], + hiddenNodes: [], + rosterComplete: true +}) + +/** + * A v3 `_NODE_STATUS` argument (28 hex, lower case): no flags, die temperature not yet seen, and + * `kind` (default 01, a panel group) as the last byte. + */ +export const statusRow = (nodeId: number, present = true, kind = 1): string => + `${nodeId.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind + .toString(16) + .padStart(2, '0')}` + +/** The whole `_NODE_STATUS` line the Gateway sends for `statusRow`, newline included. */ +export const statusLine = (nodeId: number, present = true, kind = 1): string => + `_NODE_STATUS ${statusRow(nodeId, present, kind)}\n` diff --git a/tsconfig.json b/tsconfig.json index 6d49a41..f0be47c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "@renderer/*": ["./src/renderer/src/*"] } }, - "include": ["src/renderer", "src/shared"] + "include": ["src/renderer", "src/shared", "src/test-fixtures"] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 96eaad7..ec1e1dc 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,6 +22,7 @@ "src/main", "src/preload", "src/shared", + "src/test-fixtures", "scripts", "electron.vite.config.ts", "vitest.config.mts" From 28803d87c7677748fe2100799b3ec88db0360d94 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 19:36:16 -0400 Subject: [PATCH 03/28] test: jsdom component harness + tier-1 component tests (1/2) (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R0.2 of the structure rewrite: a jsdom component harness, plus the first 8 tier-1 component tests and the 15 phase-0 behaviour probes. **Test tooling and tests only.** No production code changes, so no UI or behaviour changes. ## Tooling - devDependencies, pinned exactly: `jsdom@29.1.1` (not 30.x, which needs Node ^24.15), `@testing-library/react@16.3.3`, `@testing-library/dom@10.4.2`, `@testing-library/user-event@14.6.7`, `@testing-library/jest-dom@7.0.1`. - `vitest.config.mts` now uses `test.projects`: - **node**: `src/**/*.test.ts`, in the node environment with the `@shared` alias. Every test that existed before runs here, unchanged. - **dom**: `src/renderer/**/*.test.tsx`, in jsdom with `@vitejs/plugin-react`, the `@shared` and `@renderer` aliases, and the setup file. `__APP_VERSION__` is defined the way `electron.vite.config.ts` defines it, from `package.json`'s version. - `.dependency-cruiser.cjs`: added `'^src/main/test-harness/'` to `options.exclude.path`. The test-only services harness from #103 is no longer counted as a main topic. No rule or severity changed. ## Harness API (`src/renderer/test/`) | File | What it gives a test | |---|---| | `setup.ts` | jest-dom matchers. Before each test, a fresh store (`resetStore()`) and a fresh `window.skyhawk` mock. After each, `cleanup()` and real timers. It also polyfills a no-op `ResizeObserver`, and shims `globalThis.jest.advanceTimersByTime` so that Testing Library's async wrapper advances Vitest fake timers (without it, `user-event` hangs under `vi.useFakeTimers()`). | | `skyhawk-mock.ts` | `installSkyhawkMock()` builds a typed `{ [K in keyof SkyhawkApi]: Mock<…> }`, so an IPC change breaks it at typecheck. `skyhawk()` returns the current mock. Intents are `vi.fn`s with harmless defaults, so a test can assert them (`sky.forgetNode`) or override them (`mockResolvedValueOnce`). The mock also has `sky.scenario` and `sky.emitScenario(s)`. | | `scenarios.ts` | Scenario builders: `link(state)`, `connected(gateway?)`, `gateway(row)` / `gatewayNotResponding()` (these call main's own `gatewayStatus()`), `panel`, `pdu`, `newBoard`, `offline`, `notSeen`, `noStatusYet`, `outage`, `model`. They are built on `src/test-fixtures/nodes.ts`. | | `render.ts` | `startBridge(scenario)` subscribes the store the way `App` does and unsubscribes when the test ends, which removes the "one `initBridge` per file" snag. `renderScenario(ui, scenario)` returns `{ sky, user, …render }`. Under fake timers, `user` runs its delays on the fake clock. | | `store-reset.ts` | `resetStore()` restores the store's initial state. | ### Mock design rule (for R3) Tests never set store fields and never push channels by hand. A test describes main's state as a `Scenario` (device link, relay, node model, telemetry, config, events, cal, HID, aircraft, registry) and feeds it through one path, `emitScenario()`: - it keeps the scenario as main's current state, so the pulls (`getStatus`, `getNodes`, `getConfig`, `getEvents`, `getNodeRegistry`) answer from it, and - it pushes each part on today's channel (`device:status`, `relay:state`, `nodes:model`, `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`). When R3 moves a decision into main and adds a push (`cockpit:snapshot`, `cal:workflow`), `emitScenario` derives that push by calling main's pure builder on the same inputs. The change stays inside the mock, and the T1–T35 scenarios and assertions don't change. The file's header comment says the same. ## Tests T1–T8 | # | File | What it pins | Rows | |---|---|---|---| | T1 | `t1-cockpit-attention-strays` | With fake timers: an unidentified new board is nothing at t0, then "!NODE_ID 9 · UNIDENTIFIED · can’t be adopted · #9" after 5 s, and the band reads "1 warning". SECOND GATEWAY and UNKNOWN BOARD TYPE show at once. With the link down there are no strays and the band shows "/ 0". A stray's cog opens Adopt with that id selected. The grace counts from `firstSeenAt`. The Overview ignores the stray. | B66, B65, X1, F26 | | T2 | `t2-cockpit-attention-order` | Gateway first ("✕ Gateway / Bus-off"), then the outage "OFFLINE · 1 node affected", then offline and warnings by NODE_ID, with "+N more". The "The only node on this PDU…" hint. A Gateway warning shows "!", a healthy Gateway shows no item. The Gateway cog opens the Device popover. The outage cog opens the PDU panel, a board cog opens the board panel. "Show all on map" turns Issues on. The empty states: "Nothing needs attention." and "No live node status — connect the Gateway." | B65, B42, B37 | | T3 | `t3-overview-tile-banners` | Tile "1 / 5" with ✕ and "nodes up · — W · 3 PDUs". The banner "4 offline (PDU_Right + 1 node, Center_Armament, PDU_Center)". `attentionBanner: false` hides the banner. A Gateway bus-off is a red alert placed before the banner, and its Details opens the popover. A Gateway warning gives no alert. A stray isn't counted. The counts match the Cockpit band. | B58, B59, B42, X1 | | T4 | `t4-overview-new-devices` | Registry board: "…from NODE_IDS.md, not added to your cockpit yet." Unregistered board: "…not in NODE_IDS.md." A mix gives "— 1 from NODE_IDS.md". Unidentified and non-adoptable boards aren't mentioned. Hidden while the link is down. "Open Cockpit →" opens the Cockpit tab with Adopt on that board. | B60 | | T5 | `t5-connection-nodes-card` | Counts match the Cockpit band. Rows come in Attention order, with symbol, chip and meta ("#4 · 1 node affected · offline since 21:11:18", "not seen this session · last seen 14 Oct, 21:14", "no power zone"). Footer "+1 more · 1 no status yet · healthy nodes hidden". Refresh calls `refreshNodes`, and is disabled with the "Device link released…" hint while the link is down. "›" opens the Cockpit on that board. | B63, B14, B37, X2 | | T6 | `t6-device-popover` | Each of the 6 `DeviceState`s: label, title, line and button (Connect / Retry / Disconnect call the mock). The port only in the tooltip, plus the "busy" line. Health "OK · 42 °C". A warning shows "!" and its note. "Not responding" replaces the title and line, with no Health row. Nothing about the Gateway while the link is down. Esc and the scrim close it, and focus returns to the indicator. "Connection tab →". | B44, B42, B40 | | T7 | `t7-board-footer-panel` | `offline`: Forget and Hide are enabled, with the "waits in New on the bus…" note. `online` and `unknown` disable them, with their reasons. Reset is greyed at the defaults and calls `resetNode` when the board differs. An adopted board has no Reset and the "shows as new" note. Forget and Hide call main, the panel closes on success, and a refusal shows the error. | B73, X4 | | T8 | `t8-board-footer-pdu` | "Its 2 boards move to No power zone… Hide keeps the links". The fed count covers visible boards only, not the hidden one. The note when the PDU feeds nothing. Online is disabled. Hide calls `hideNode(4, true)` and Forget calls `forgetNode(4)`. Reset covers "name and console" only. | B73, B9, B10, X8 | Each describe title lists its rows. ## Probes The 15 probes from the contract's scratch copy pass on this branch. Their assertions are unchanged; only imports moved. They are split by process, so main never imports renderer code: | File | Project | Probes | |---|---|---| | `src/main/phase0-probes.test.ts` | node | 11 that use main's `NodeHub` or `@shared/*` only: C1, M1/C3, M3, C4, M7, M7 contrast, G1, D7, D3, #95 bug 1, #95 bug 3 | | `src/renderer/src/phase0-probes.test.ts` | node (a `.ts` helper test, like the existing `src/renderer/src/*.test.ts`) | 4 that call the renderer helpers: D1 conflict, S6, C6, #95 bug 2 | `DEFAULT_APP_SETTINGS` now comes from `src/test-fixtures/config` (it moved there in #103). Neither tsconfig changes. ## Discrepancies: contract wording vs today (pinned today, commented in the test) - **T6 port line.** The contract says the popover shows a port line while connected and drops it once the Gateway reports health. Today the popover has no port line while connected, with or without health. The port is in the indicator's tooltip (`Gateway connected · `) and on the Connection tab. Pinned as is. - **T6 gold vs red "!".** The colour is CSS-only, so it isn't asserted (the brief rules out CSS checks). The tests assert the "!" and the tooltip. - **T2 "Gateway · Bus-off".** On screen the item reads "Gateway" over "Bus-off" and has no " · #id". Pinned as rendered. - Deliberate pins of today's conflicts, each with a `// contract says …; today …` comment: X1 (strays only on the Cockpit), F26 (the grace counts from `firstSeenAt`), X2 (Connection meta names a PDU on the bus that isn't added), X4 (the footer needs `offline`, while main accepts `unknown`), X8 (the fed count is visible boards only). ## Fail-checks For 22 mutations, one behaviour was broken in production code locally, the matching test file was run, then the change was reverted with `git checkout`. Every mutation turned at least one test red. Examples: | T | Broken locally | Red | |---|---|---| | T1 | `IDENTIFY_WAIT_MS` 5000→0 · "SECOND GATEWAY" text · strays not gated on the link | t0 / bridge / link-down tests | | T2 | rank order swapped · `ATTENTION_SHOWN` 3→4 · Gateway cog no-op | order (2), cap (3), cog (1) | | T3 | `BANNER_NAMES` 3→2 · banner ignores the setting · "no nodes known" text | 1 each | | T4 | notice shown while the link is down · registry detail text | 1 each | | T5 | "offline since" text · Refresh always enabled · footer separator | 1 each | | T6 | Gateway shown while the link is down · Retry label · Esc doesn't refocus | 1 / 2 / 1 | | T7 | `unknown` treated as offline · Hide sends `false` · adopted note | 1 each | | T8 | fed count +1 · PDU Hide tooltip | 3 / 1 | The first run of the T1 link-down mutation stayed green, because Attention also hides items while the link is down. I added a band-count assertion (`/ 0`), and it now goes red. ## Checks - `npx vitest run`: **54 files / 791 tests**. That's 44 files / 713 tests as before, plus 2 probe files / 15 probes, plus 8 dom files / 63 tests. - `npm run lint`: 0 errors (32 warnings, unchanged). `npm run lint:deps`: 0 errors (65 warnings, unchanged). `npm run typecheck`: clean. `npm run build`: OK locally. Refs #97 --- .dependency-cruiser.cjs | 7 +- package-lock.json | 756 +++++++++++++++++- package.json | 5 + src/main/phase0-probes.test.ts | 206 +++++ src/renderer/src/phase0-probes.test.ts | 111 +++ src/renderer/test/render.ts | 47 ++ src/renderer/test/scenarios.ts | 115 +++ src/renderer/test/setup.ts | 33 + src/renderer/test/skyhawk-mock.ts | 207 +++++ src/renderer/test/store-reset.ts | 9 + .../test/t1-cockpit-attention-strays.test.tsx | 125 +++ .../test/t2-cockpit-attention-order.test.tsx | 139 ++++ .../test/t3-overview-tile-banners.test.tsx | 116 +++ .../test/t4-overview-new-devices.test.tsx | 84 ++ .../test/t5-connection-nodes-card.test.tsx | 128 +++ src/renderer/test/t6-device-popover.test.tsx | 192 +++++ .../test/t7-board-footer-panel.test.tsx | 120 +++ .../test/t8-board-footer-pdu.test.tsx | 111 +++ vitest.config.mts | 37 +- 19 files changed, 2541 insertions(+), 7 deletions(-) create mode 100644 src/main/phase0-probes.test.ts create mode 100644 src/renderer/src/phase0-probes.test.ts create mode 100644 src/renderer/test/render.ts create mode 100644 src/renderer/test/scenarios.ts create mode 100644 src/renderer/test/setup.ts create mode 100644 src/renderer/test/skyhawk-mock.ts create mode 100644 src/renderer/test/store-reset.ts create mode 100644 src/renderer/test/t1-cockpit-attention-strays.test.tsx create mode 100644 src/renderer/test/t2-cockpit-attention-order.test.tsx create mode 100644 src/renderer/test/t3-overview-tile-banners.test.tsx create mode 100644 src/renderer/test/t4-overview-new-devices.test.tsx create mode 100644 src/renderer/test/t5-connection-nodes-card.test.tsx create mode 100644 src/renderer/test/t6-device-popover.test.tsx create mode 100644 src/renderer/test/t7-board-footer-panel.test.tsx create mode 100644 src/renderer/test/t8-board-footer-pdu.test.tsx diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index dee6eac..cb63a11 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -151,7 +151,12 @@ module.exports = { options: { doNotFollow: { path: ['node_modules'] }, exclude: { - path: ['\\.test\\.tsx?$', '^src/test-fixtures/', '^src/renderer/test/'] + path: [ + '\\.test\\.tsx?$', + '^src/test-fixtures/', + '^src/renderer/test/', + '^src/main/test-harness/' + ] }, // Type-only imports count: today's model <-> contract cycles are type cycles. tsPreCompilationDeps: true, diff --git a/package-lock.json b/package-lock.json index 20fc1b6..0d3a57c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,10 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@eslint/js": "^9.39.4", + "@testing-library/dom": "10.4.2", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -32,6 +36,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", "husky": "^9.1.7", + "jsdom": "29.1.1", "lint-staged": "^15.2.10", "prettier": "^3.8.4", "react": "^19.0.0", @@ -49,6 +54,64 @@ "node-hid": "^3.3.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -300,6 +363,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -345,6 +418,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@commitlint/cli": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", @@ -764,6 +850,146 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", @@ -1624,6 +1850,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fontsource-variable/jetbrains-mono": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", @@ -2467,6 +2711,111 @@ "node": ">=10" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3263,6 +3612,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3524,6 +3883,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -4072,6 +4441,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4091,6 +4481,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4158,6 +4562,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4301,6 +4712,16 @@ "node": ">= 4" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -4353,6 +4774,13 @@ "js-yaml": "^4.1.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -4676,6 +5104,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5974,6 +6415,19 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", @@ -6089,6 +6543,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6443,6 +6907,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6700,6 +7171,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7297,6 +7819,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7328,6 +7860,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -7438,6 +7977,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -8021,6 +8570,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8249,6 +8811,41 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -8459,6 +9056,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8798,6 +9409,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9293,6 +9917,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -9341,6 +9978,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -9479,6 +10123,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -9509,6 +10173,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -9744,7 +10434,6 @@ "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=20.18.1" } @@ -10031,6 +10720,19 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watskeburt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-6.0.0.tgz", @@ -10057,6 +10759,41 @@ "tslib": "^2.8.1" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10205,6 +10942,16 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -10214,6 +10961,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 2fc4de2..644dddf 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@eslint/js": "^9.39.4", + "@testing-library/dom": "10.4.2", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -46,6 +50,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", "husky": "^9.1.7", + "jsdom": "29.1.1", "lint-staged": "^15.2.10", "prettier": "^3.8.4", "react": "^19.0.0", diff --git a/src/main/phase0-probes.test.ts b/src/main/phase0-probes.test.ts new file mode 100644 index 0000000..20569c8 --- /dev/null +++ b/src/main/phase0-probes.test.ts @@ -0,0 +1,206 @@ +// Phase-0 behaviour probes (SkyHawkClient#97, arch-phase0-contract PART 1B/1C): each test asserts +// TODAY's behaviour for a fix-track or conflict row, so the structure rewrite can show it changed +// nothing. A fix ticket that changes one of these behaviours on purpose updates its probe. +// Main and shared only; the probes that read renderer helpers are in +// src/renderer/src/phase0-probes.test.ts (main never imports renderer code). +import { describe, expect, it, vi } from 'vitest' +import type { NodeMappingFile } from '@shared/node-mapping' +import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' +import { + registryDefaults, + importMapping, + setNodeMapping, + adoptNode, + effectiveMapping, + EMPTY_MAPPING +} from '@shared/node-mapping' +import { parseConfigFile, CONFIG_FORMAT, diffImport } from '@shared/config-file' +import { buildNodeModel } from '@shared/node-rules' +import { GatewayWatch, GATEWAY_NO_ANSWER_MS } from '@shared/gateway' +import { DEFAULT_PSU } from '@shared/psu' +import { DEFAULT_APP_SETTINGS } from '../test-fixtures/config' + +const disk: { file: NodeMappingFile } = { file: { version: 3, nodes: {} } } +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('./node-mapping', () => { + class MappingSaveError extends Error {} + return { + MappingSaveError, + loadNodeMapping: () => ({ file: disk.file }), + saveNodeMapping: (f: NodeMappingFile) => { + disk.file = f + } + } +}) +const { NodeHub } = await import('./node-hub') + +const REG = { + 0: { name: 'PanelBridge', console: '—' }, + 1: { name: 'Center_Armament', console: 'Center' }, + 2: { name: 'Right_Navigation', console: 'Right' }, + 3: { name: 'PDU_Left', console: 'Left' }, + 4: { name: 'PDU_Right', console: 'Right' }, + 5: { name: 'PDU_Center', console: 'Center' } +} +const defaults = registryDefaults(REG) +const row = (id: number, kind: number, present = true) => + `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind.toString(16).padStart(2, '0')}`.toUpperCase() +const added = (): NodeMappingFile => ({ + version: 3, + nodes: { + 1: { added: true }, + 2: { added: true }, + 3: { added: true }, + 4: { added: true }, + 5: { added: true } + } +}) + +function hubWith(file: NodeMappingFile) { + disk.file = file + const events: { event: string; nodeId?: number; detail?: string }[] = [] + const roster = new NodeRoster() + const h = new NodeHub( + () => {}, + roster, + (e) => events.push(...e) + ) + const status = (id: number, kind: number, t: number, present = true) => { + roster.applyMessage(NODE_MSG, row(id, kind, present), t) + h.statusLine(NODE_MSG, `_NODE_STATUS ${row(id, kind, present)}`, t) + } + return { h, roster, events, status } +} + +describe('fix-track probes (today)', () => { + it('C1: nodeMapping {} parses OK and applying it drops adopted boards + renames', () => { + const r = parseConfigFile( + JSON.stringify({ format: CONFIG_FORMAT, version: 1, sections: { nodeMapping: {}, app: {} } }) + ) + expect(r.ok).toBe(true) + const sec = r.ok ? r.config.sections : {} + expect(sec.nodeMapping).toEqual({ version: 3, nodes: {} }) + expect(sec.app).toEqual(DEFAULT_APP_SETTINGS) // app: {} = reset to defaults + const cur: NodeMappingFile = { + version: 3, + nodes: { + 2: { added: true, name: 'Nav' }, + 41: { name: 'Trim', console: 'Left', pduId: null, kind: 'panel' } + } + } + expect(importMapping(cur, sec.nodeMapping!)).toEqual({ + version: 3, + nodes: { 2: { added: true } } + }) + }) + + it('M1/C3: Forget is accepted while availability is unknown, and for a PDU with fresh telemetry', () => { + const a = hubWith(added()) + expect(a.h.snapshot(0).model.nodes.find((n) => n.nodeId === 2)?.availability).toBe('unknown') + expect(a.h.forget(2).ok).toBe(true) + const b = hubWith(added()) + b.h.telemetry('040100E02E182EBC02', Date.now()) // PDU_Right pages, fresh; not in roster + expect(b.h.forget(4).ok).toBe(true) + }) + + it('M3: a v2 bridge — END completes the roster, added boards read offline, Forget allowed', () => { + const a = hubWith(added()) + a.roster.beginBurst(0) + a.roster.applyMessage(NODE_MSG, '0101000000000000008000', 0) // v2 row (no kind) -> dropped + a.roster.applyMessage(NODE_END_MSG, '2', 0) + const n1 = a.h.snapshot(0).model.nodes.find((n) => n.nodeId === 1) + expect(n1?.availability).toBe('offline') + expect(a.h.forget(1).ok).toBe(true) + }) + + it('C4: a node identified only by PDU telemetry logs "new on the bus" only at its next status row', () => { + const a = hubWith({ version: 3, nodes: {} }) + a.status(1, 1, 0) + a.roster.applyMessage(NODE_END_MSG, '1', 0) + a.h.statusLine(NODE_END_MSG, '_NODE_STATUS_END 1', 0) + a.status(9, 0, 2000) // kind 0, never reports a kind + a.h.telemetry('090100E02E182EBC02', 2500, '_NODE_TELEM 090100E02E182EBC02') + expect(a.h.snapshot(2500).model.nodes.find((n) => n.nodeId === 9)?.identified).toBe('pdu') + expect(a.events.filter((e) => e.nodeId === 9 && e.event === 'new on the bus')).toEqual([]) + a.status(9, 0, 7000) // next roster poll row: only now is it news + expect(a.events.filter((e) => e.nodeId === 9 && e.event === 'new on the bus')).toMatchObject([ + { t: 7000 } + ]) + }) + + it('M7: hub.reset() (port reopen after error) clears wasPresent without writing lastSeen', () => { + const a = hubWith(added()) + a.status(1, 1, 0) + a.h.flush(true, 0) + const seen0 = disk.file.nodes[1]?.lastSeen + a.h.reset() + a.h.linkClosed(60_000) + expect(disk.file.nodes[1]?.lastSeen).toBe(seen0) // not 60 s + }) + + it('M7 contrast: linkClosed() before reset writes lastSeen', () => { + const a = hubWith(added()) + a.status(1, 1, 0) + a.h.flush(true, 0) + a.h.linkClosed(60_000) + expect(disk.file.nodes[1]?.lastSeen).toBe(new Date(60_000).toISOString()) + }) + + it('G1: one unanswered poll trips "not responding" at exactly the poll interval', () => { + const w = new GatewayWatch() + w.answered('_NODE_STATUS_END', 0) + w.asked(5000) // poll n + // reply to poll n lost; poll n+1 at 10000 counts from the oldest unanswered + w.asked(10000) + expect(GATEWAY_NO_ANSWER_MS).toBe(5000) + expect(w.tick(10000).length).toBe(1) + expect(w.status()?.kind).toBe('not-responding') + }) + + it('D7: a zone to a registry PDU that is not in your cockpit is accepted by main', () => { + const f: NodeMappingFile = { version: 3, nodes: { 2: { added: true } } } + const g = setNodeMapping(f, defaults, 2, { pduId: 3 }) // PDU_Left, not added + expect(g.nodes[2]).toEqual({ added: true, pduId: 3 }) + }) + + it("D3: import keeps this machine's added marker; it never adds a registry board", () => { + const cur: NodeMappingFile = { version: 3, nodes: {} } + const imp: NodeMappingFile = { version: 3, nodes: { 2: { added: true, name: 'Nav' } } } + expect(importMapping(cur, imp)).toEqual({ version: 3, nodes: { 2: { name: 'Nav' } } }) + const diff = diffImport( + defaults, + { + app: DEFAULT_APP_SETTINGS, + nodeMapping: cur, + powerSupply: DEFAULT_PSU, + calibration: { version: 1, boards: {} } + }, + { nodeMapping: imp } + ) + expect(diff[0]!.rows.some((r) => /added|cockpit/i.test(`${r.k}${r.to}`))).toBe(false) + }) + + it('#95 bug 1: an adopted board renamed to its later registry name drops out', () => { + const f = adoptNode(EMPTY_MAPPING, defaults, 41, { + name: 'Left_Oxygen', + console: 'Left', + pduId: 3, + kind: 'panel' + }) + const later = registryDefaults({ ...REG, 41: { name: 'Left_Oxy', console: 'Left' } }) + const g = setNodeMapping(f, later, 41, { name: 'Left_Oxy' }) + expect(effectiveMapping(later, g).get(41)?.known).toBe(false) + }) + + it('#95 bug 3: a registry panel ID added while reporting PDU reads panel once silent', () => { + const f = adoptNode( + { version: 3, nodes: {} }, + defaults, + 2, + { name: 'Right_Navigation', console: 'Right', pduId: null, kind: 'pdu' }, + () => 'pdu' + ) + const m = buildNodeModel({ roster: [], seen: new Map(), telemetry: [], defaults, file: f }) + expect(m.nodes.find((x) => x.nodeId === 2)?.kind).toBe('panel') + }) +}) diff --git a/src/renderer/src/phase0-probes.test.ts b/src/renderer/src/phase0-probes.test.ts new file mode 100644 index 0000000..4ef5682 --- /dev/null +++ b/src/renderer/src/phase0-probes.test.ts @@ -0,0 +1,111 @@ +// Phase-0 behaviour probes (SkyHawkClient#97, arch-phase0-contract PART 1B/1C), the renderer half: +// each test asserts TODAY's behaviour for a conflict or fix-track row that the renderer helpers +// decide, so the structure rewrite can show it changed nothing. A fix ticket that changes one of +// these behaviours on purpose updates its probe. The main/shared half is +// src/main/phase0-probes.test.ts. +import { describe, expect, it } from 'vitest' +import type { NodeMappingFile } from '@shared/node-mapping' +import { NODE_MSG, NodeRoster } from '@shared/nodes' +import { registryDefaults, setHidden, EMPTY_MAPPING } from '@shared/node-mapping' +import { buildNodeModel } from '@shared/node-rules' +import { nodeViews, healthBand, attentionItems, nodeHealth } from './cockpit' +import { strayNew, registrySpecs, adoptPduChoices } from './cockpit-adopt' +import { nodeFaultBoxes } from './cockpit-panels' +import { cockpitTile, attentionSummary, newDevices } from './overview' +import { nodesSummary } from './connection-summary' + +const REG = { + 0: { name: 'PanelBridge', console: '—' }, + 1: { name: 'Center_Armament', console: 'Center' }, + 2: { name: 'Right_Navigation', console: 'Right' }, + 3: { name: 'PDU_Left', console: 'Left' }, + 4: { name: 'PDU_Right', console: 'Right' }, + 5: { name: 'PDU_Center', console: 'Center' } +} +const defaults = registryDefaults(REG) +const row = (id: number, kind: number, present = true) => + `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind.toString(16).padStart(2, '0')}`.toUpperCase() + +describe('fix-track probes (today)', () => { + it('D1 conflict: one stray is a Cockpit warning but Overview/Connection see nothing', () => { + const roster = new NodeRoster() + roster.applyMessage(NODE_MSG, row(9, 0), 0) + const model = buildNodeModel({ + roster: roster.snapshot(), + seen: roster.seenInfo(), + rosterCompleteAt: 0, + telemetry: [], + defaults, + file: EMPTY_MAPPING + }) + const views = nodeViews(model, []) + const { strays } = strayNew(views, 6000) + expect(strays).toHaveLength(1) + const cockpitBand = healthBand(views, strays.length) + expect(cockpitBand.warn).toBe(1) + expect( + attentionItems(views, [], [{ nodeId: 9, flag: 'UNIDENTIFIED · can’t be adopted' }]) + ).toHaveLength(1) + // Overview + const ovBand = healthBand(views) + expect(cockpitTile(ovBand, true)).toMatchObject({ value: '—', sub: 'no nodes known' }) + expect(attentionSummary(ovBand, attentionItems(views, []))).toBeNull() + expect(newDevices(views, true)).toBeNull() + // Connection + expect(nodesSummary(views, [])).toMatchObject({ known: 0, warn: 0, rows: [] }) + }) + + it('S6: grace counts from first sighting — a board seen absent then online late is a stray at once', () => { + const roster = new NodeRoster() + roster.applyMessage(NODE_MSG, row(9, 0, false), 0) + roster.applyMessage(NODE_MSG, row(9, 0, true), 60_000) + const model = buildNodeModel({ + roster: roster.snapshot(), + seen: roster.seenInfo(), + rosterCompleteAt: 0, + telemetry: [], + defaults, + file: EMPTY_MAPPING + }) + expect(strayNew(nodeViews(model, []), 60_001).strays).toHaveLength(1) + }) + + it('C6: a registry board waiting to be added is labelled "NOT IN REGISTRY" / "Not in registry"', () => { + const roster = new NodeRoster() + roster.applyMessage(NODE_MSG, row(2, 1), 0) + const model = buildNodeModel({ + roster: roster.snapshot(), + seen: roster.seenInfo(), + rosterCompleteAt: 0, + telemetry: [], + defaults, + file: EMPTY_MAPPING + }) + const e = model.nodes.find((n) => n.nodeId === 2)! + expect(e.registry?.name).toBe('Right_Navigation') + expect(nodeHealth(e).flag).toBe('NOT IN REGISTRY') + expect(nodeFaultBoxes(nodeViews(model, []).find((v) => v.entry.nodeId === 2)!)[0]!.title).toBe( + 'Not in registry' + ) + }) + + it('#95 bug 2: a hidden in-cockpit PDU is "(not added)" to Add all and the PDU choices', () => { + let f: NodeMappingFile = { version: 3, nodes: { 5: { added: true } } } + f = setHidden(f, defaults, 5, true) + const roster = new NodeRoster() + roster.applyMessage(NODE_MSG, row(1, 1), 0) + const m = buildNodeModel({ + roster: roster.snapshot(), + seen: roster.seenInfo(), + rosterCompleteAt: 0, + telemetry: [], + defaults, + file: f + }) + const views = nodeViews(m, []) + expect(registrySpecs(views, views)[0]?.spec.pduId).toBeNull() + expect(adoptPduChoices(views, 'Center', 5, m.nodes.find((n) => n.nodeId === 1)!).pending).toBe( + 'PDU_Center' + ) + }) +}) diff --git a/src/renderer/test/render.ts b/src/renderer/test/render.ts new file mode 100644 index 0000000..52810cb --- /dev/null +++ b/src/renderer/test/render.ts @@ -0,0 +1,47 @@ +// Render a component the way the app runs it: subscribed to main through `window.skyhawk`, with main +// in the state a Scenario describes. See skyhawk-mock.ts for why scenarios go in at the push level. +import type { ReactElement } from 'react' +import { act, render, type RenderResult } from '@testing-library/react' +import userEvent, { type UserEvent } from '@testing-library/user-event' +import { onTestFinished, vi } from 'vitest' +import { useStore } from '../src/store' +import { skyhawk, type Scenario, type SkyhawkMock } from './skyhawk-mock' + +/** Let pending IPC answers (the renderer's pulls) land. */ +export async function settle(): Promise { + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve() + }) +} + +/** + * Main is in `scenario` when the renderer loads: the renderer subscribes (what `App` does on + * mount), its pulls answer from the scenario, then main pushes it. Either way the store ends up + * holding what main pushed. Unsubscribes when the test ends, so the next test can subscribe again. + */ +export async function startBridge(scenario: Scenario = {}): Promise { + const sky = skyhawk() + sky.emitScenario(scenario) // nobody subscribed yet: main's state as the renderer loads + const off = useStore.getState().initBridge() + if (off) onTestFinished(off) + await settle() + sky.emitScenario(scenario) + return sky +} + +/** + * `startBridge(scenario)`, then render `ui`. `user` drives it like a person would; under fake + * timers its delays run on the fake clock. + */ +export async function renderScenario( + ui: ReactElement, + scenario: Scenario = {} +): Promise { + const sky = await startBridge(scenario) + const user = userEvent.setup( + vi.isFakeTimers() ? { advanceTimers: (ms) => vi.advanceTimersByTime(ms) } : {} + ) + const result = render(ui) + await settle() + return { ...result, sky, user } +} diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts new file mode 100644 index 0000000..071380e --- /dev/null +++ b/src/renderer/test/scenarios.ts @@ -0,0 +1,115 @@ +// Scenario builders for component tests: main-side state at today's push level (skyhawk-mock.ts). +// One place for them, on top of the shared node fixtures, so every component test describes the +// world the same way. Each builder states its defaults; a test passes whatever differs. +import { gatewayStatus, type GatewayStatus } from '@shared/gateway' +import type { DeviceState, DeviceStatus } from '@shared/ipc' +import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' +import { NODE_KIND, type NodeStatus } from '@shared/nodes' +import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' + +export { modelNode, nodeModel, nodeStatus } + +// ── device link (`device:status`) ─────────────────────────────────────────── + +export const PORT = '/dev/cu.usbmodem1' + +/** The device link in `state`; connected and error carry the port, as main sends them. */ +export function link(state: DeviceState, over: Partial = {}): DeviceStatus { + const port = state === 'connected' || state === 'error' ? { portPath: PORT } : {} + return { state, ...port, ...over } +} + +/** Connected, with the Gateway's own health when given. */ +export const connected = (gateway?: GatewayStatus): DeviceStatus => + link('connected', gateway ? { gateway } : {}) + +// ── the Gateway's own health (`device:status.gateway`), from main's own builder ───────────── + +/** + * What main computes from the Gateway's node-0 status row (shared/gateway.ts gatewayStatus): its + * health frame has arrived (kind 03), 42 °C, plus `row` (e.g. `{ boff: true }`). + */ +export function gateway(row: Partial = {}): GatewayStatus { + const st = nodeStatus(0, { nodeKind: NODE_KIND.BRIDGE, dieTempC: 42, ...row }) + return gatewayStatus(st, false)! +} + +/** A roster request went unanswered for 5 s while the port was open. */ +export const gatewayNotResponding = (): GatewayStatus => gatewayStatus(undefined, true)! + +// ── node model entries (`nodes:model`) ────────────────────────────────────── + +/** NODE_IDS.md consoles come from the name prefix: `Right_Navigation` → `Right`. */ +const consoleOf = (name: string) => /^(Left|Center|Right)/.exec(name)?.[1] ?? 'Center' + +/** A registry panel group in your cockpit: online, healthy, reported kind 01, no power zone. */ +export const panel = (nodeId: number, name: string, over: Partial = {}) => + modelNode(nodeId, { + name, + console: consoleOf(name), + identified: 'panel', + status: nodeStatus(nodeId, { nodeKind: NODE_KIND.PANEL_GROUP }), + ...over + }) + +/** A registry PDU in your cockpit: online, healthy, reported kind 02, no fresh pages. */ +export const pdu = (nodeId: number, name: string, over: Partial = {}) => + modelNode(nodeId, { + name, + console: consoleOf(name), + kind: 'pdu', + identified: 'pdu', + readings: 'none', + status: nodeStatus(nodeId, { nodeKind: NODE_KIND.PDU }), + ...over + }) + +/** The roster lists it gone, at `at`. */ +export const offline = (at?: number): Partial => ({ + availability: 'offline', + offlineVia: 'roster', + ...(at !== undefined && { offlineAt: at }) +}) + +/** Remembered from an earlier session, missing from this session's first complete roster. */ +export const notSeen = (lastSeenAt?: number): Partial => ({ + availability: 'offline', + notSeen: true, + status: undefined, + ...(lastSeenAt !== undefined && { lastSeenAt }) +}) + +/** No complete roster yet and it hasn't reported: availability unknown. */ +export const noStatusYet = (): Partial => ({ + availability: 'unknown', + status: undefined +}) + +/** + * A board on the bus that isn't in your cockpit: online since `firstSeenAt`, nothing reported + * about its kind (0) unless `over` says so. + */ +export const newBoard = (nodeId: number, firstSeenAt: number, over: Partial = {}) => + modelNode(nodeId, { + name: `Node ${nodeId}`, + console: null, + source: 'new', + firstSeenAt, + reportedAt: firstSeenAt, + ...over + }) + +/** A PDU outage: the PDU and the fed boards that dropped with it. */ +export const outage = (pduId: number, nodeIds: number[], fedCount: number, at = 0): PduOutage => ({ + pduId, + nodeIds, + fedCount, + at +}) + +/** The model main pushes around these entries; roster complete unless `over` says otherwise. */ +export const model = ( + nodes: NodeModelEntry[], + outages: PduOutage[] = [], + over: Partial = {} +): NodeModel => ({ ...nodeModel(nodes, outages), ...over }) diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts new file mode 100644 index 0000000..ebd7e1a --- /dev/null +++ b/src/renderer/test/setup.ts @@ -0,0 +1,33 @@ +// Setup for the jsdom project (`*.test.tsx`): jest-dom matchers, a fresh store and a fresh +// `window.skyhawk` mock before every test, and the DOM torn down after it. +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { installSkyhawkMock } from './skyhawk-mock' +import { resetStore } from './store-reset' + +// jsdom has no layout: components that measure (the Cockpit plan, BoardEvents) need a +// ResizeObserver to exist. It never fires; jsdom reports every size as 0. +class NoResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +;(globalThis as { ResizeObserver?: unknown }).ResizeObserver ??= NoResizeObserver + +// Testing Library drains each user-event call with a setTimeout(0), and advances it only when it +// sees Jest's fake timers. Under `vi.useFakeTimers()` that timeout never fires and the call hangs; +// this shim lets it advance Vitest's fake clock instead. Real timers are unaffected. +;(globalThis as { jest?: unknown }).jest ??= { + advanceTimersByTime: (ms: number) => vi.advanceTimersByTime(ms) +} + +beforeEach(() => { + resetStore() + installSkyhawkMock() +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts new file mode 100644 index 0000000..ac07500 --- /dev/null +++ b/src/renderer/test/skyhawk-mock.ts @@ -0,0 +1,207 @@ +// A typed stand-in for the preload bridge (`window.skyhawk`), the renderer's whole boundary to main. +// +// ── Mock design rule: scenarios go in at main's push level ───────────────────────────────────── +// +// A component test describes the world as main sees it — a `Scenario`: the device link, the relay, +// the node model and PDU telemetry, the config, the session's events, … — and feeds it through ONE +// path, `emitScenario(scenario)`. That path does exactly what main does today: +// - it keeps the scenario as main's current state, so the pulls a (re)loaded renderer makes +// (`getStatus`, `getNodes`, `getConfig`, `getEvents`, `getNodeRegistry`) answer from it, and +// - it pushes each part on today's channel: `device:status`, `relay:state`, `nodes:model`, +// `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`. +// Tests never set renderer store fields and never push channels by hand, so they assert what the +// user sees for a given main-side situation — not how the renderer stores it. +// +// When the rewrite moves a decision into main and adds a push for it (R3: `cockpit:snapshot`, +// `cal:workflow`, …), main gets a pure builder for that push. `emitScenario` then derives the new +// push by calling that builder on the same scenario inputs and sends it alongside (or instead of) +// today's channels. The change stays inside this file: every component test keeps its scenarios +// and its assertions unchanged. +// +// Intents (renderer → main requests) are `vi.fn`s with harmless defaults, so a test can assert the +// call (`expect(sky.forgetNode).toHaveBeenCalledWith(4)`) or override the answer +// (`sky.forgetNode.mockResolvedValueOnce({ ok: false, error: '…' })`). The type follows +// `SkyhawkApi`, so an IPC change breaks the mock at typecheck, not at run time. +import { act } from '@testing-library/react' +import { vi, type Mock } from 'vitest' +import { + DEFAULT_CONFIG, + type AircraftStatus, + type AppConfig, + type CalSnapshot, + type DeviceStatus, + type HidSnapshot, + type NodeRegistryInfo, + type PushChannel, + type PushChannels, + type RelayState, + type SkyhawkApi +} from '@shared/ipc' +import type { LogEvent } from '@shared/events' +import type { NodeModel } from '@shared/node-rules' +import type { NodeTelemSnapshot } from '@shared/node-telem' + +/** What main knows right now. Every field is optional: absent = main's state at launch. */ +export interface Scenario { + /** The device link, Gateway health included (`device:status`). */ + device?: DeviceStatus + /** The relay (`relay:state`). */ + relay?: RelayState + /** Roster + telemetry + mapping, combined (`nodes:model`). */ + model?: NodeModel + /** Latest PDU pages (`nodes:telemetry`). */ + telemetry?: NodeTelemSnapshot[] + /** Saved settings, over the defaults (`getConfig`; main has no config push). */ + config?: Partial + /** The session's event log rows, oldest first (`events:batch`). */ + events?: LogEvent[] + /** Calibration the device confirmed (`cal:data`). */ + cal?: CalSnapshot + /** The latest HID report (`hid:report`). */ + hid?: HidSnapshot + /** The aircraft DCS reports (`aircraft:changed`). */ + aircraft?: AircraftStatus + /** The bundled NODE_IDS.md (`getNodeRegistry`; never pushed). */ + registry?: NodeRegistryInfo +} + +type Fns = { [K in keyof SkyhawkApi]: Mock } + +export type SkyhawkMock = Fns & { + /** Main's current state: what the pulls answer from. Grows with each `emitScenario`. */ + readonly scenario: Scenario + /** + * Main's state changes: merge `s` into the scenario and push each part it holds, in the order + * main's session would (device link first). Wrapped in `act`, so the UI has caught up on return. + */ + emitScenario(s: Scenario): void +} + +const EMPTY_MODEL: NodeModel = { + nodes: [], + outages: [], + differingCount: 0, + hidden: [], + hiddenNodes: [], + rosterComplete: false +} + +const EMPTY_REGISTRY: NodeRegistryInfo = { + file: 'Firmware/NODE_IDS.md', + commit: '0000000', + date: '2026-01-01', + nodes: [] +} + +/** Today's push for each scenario part, in the order emitScenario sends them. */ +const PUSHES: { [K in keyof Scenario]?: PushChannel } = { + device: 'device:status', + relay: 'relay:state', + aircraft: 'aircraft:changed', + model: 'nodes:model', + telemetry: 'nodes:telemetry', + events: 'events:batch', + cal: 'cal:data', + hid: 'hid:report' +} + +/** Install a fresh mock as `window.skyhawk` (the setup file does this before every test). */ +export function installSkyhawkMock(): SkyhawkMock { + const listeners = new Map void>>() + let state: Scenario = {} + const resolve = (v: T) => Promise.resolve(v) + const calFail = { ok: false, kind: 'offline', message: 'mock' } as const + + const fns: Fns = { + on: vi.fn((ch, cb) => { + const set = listeners.get(ch) ?? new Set() + set.add(cb as (d: never) => void) + listeners.set(ch, set) + return () => { + set.delete(cb as (d: never) => void) + } + }), + + // ── pulls: answered from the scenario ── + getConfig: vi.fn(() => resolve({ ...DEFAULT_CONFIG, ...state.config })), + getStatus: vi.fn(() => + resolve({ + running: state.relay?.running ?? false, + relay: state.relay ?? { running: false }, + device: state.device ?? { state: 'no-device' }, + ...(state.cal && { cal: state.cal }) + }) + ), + getNodes: vi.fn(() => + resolve({ model: state.model ?? EMPTY_MODEL, telemetry: state.telemetry ?? [] }) + ), + getEvents: vi.fn(() => resolve(state.events ?? [])), + getNodeRegistry: vi.fn(() => resolve(state.registry ?? EMPTY_REGISTRY)), + getHidAvailability: vi.fn(() => resolve({ axes: [], hats: [], buttons: [] })), + + // ── intents: harmless defaults ── + setConfig: vi.fn((p: Partial) => + resolve({ ...DEFAULT_CONFIG, ...state.config, ...p }) + ), + exportConfig: vi.fn(() => resolve({})), + openConfigImport: vi.fn(() => resolve({ status: 'canceled' as const })), + applyConfigImport: vi.fn(() => resolve({ ok: false as const, error: 'mock' })), + startRelay: vi.fn(() => resolve({ ok: true })), + stopRelay: vi.fn(() => resolve({ ok: true })), + connectDevice: vi.fn(() => resolve(undefined)), + disconnectDevice: vi.fn(() => resolve(undefined)), + exportLog: vi.fn(() => resolve({})), + clearEvents: vi.fn(() => resolve(undefined)), + exportEvents: vi.fn(() => resolve({})), + toggleCapture: vi.fn(() => resolve({ recording: false })), + openReplay: vi.fn(() => resolve({ loaded: false })), + refreshNodes: vi.fn(() => resolve(undefined)), + setNodeMapping: vi.fn(() => resolve(mapped())), + adoptNode: vi.fn(() => resolve(mapped())), + forgetNode: vi.fn(() => resolve(mapped())), + resetNode: vi.fn(() => resolve(mapped())), + resetNodeMapping: vi.fn(() => resolve(mapped())), + previewNodeReset: vi.fn(() => resolve({ rows: [], nodeCount: 0, adopted: [] })), + hideNode: vi.fn(() => resolve(mapped())), + setSerialMonitor: vi.fn(() => resolve(undefined)), + dumpSerialPorts: vi.fn(() => resolve({ path: '/tmp/serial-ports.txt', count: 0 })), + revealDebugLog: vi.fn(() => resolve(undefined)), + calHello: vi.fn(() => resolve(calFail)), + calRead: vi.fn(() => resolve(calFail)), + calSessionOpen: vi.fn(() => resolve(calFail)), + calStreamSelect: vi.fn(() => resolve(calFail)), + calCommit: vi.fn(() => resolve(calFail)), + calReset: vi.fn(() => resolve(calFail)), + calSessionClose: vi.fn(() => resolve({ ok: true as const, value: null })), + calCacheRead: vi.fn(() => resolve({ ok: true as const, value: { regressed: [] } })), + calCacheStore: vi.fn(() => resolve({ ok: true as const, value: null })), + calCacheDrop: vi.fn(() => resolve({ ok: true as const, value: null })) + } + + /** A mapping intent main accepted: it answers with the model (and would push it). */ + const mapped = () => ({ ok: true as const, model: state.model ?? EMPTY_MODEL }) + + const push = (ch: C, data: PushChannels[C]): void => { + for (const cb of listeners.get(ch) ?? []) (cb as (d: PushChannels[C]) => void)(data) + } + + const mock = Object.defineProperty(fns, 'scenario', { get: () => state }) as SkyhawkMock + Object.assign(mock, { + emitScenario(s: Scenario) { + state = { ...state, ...s } + act(() => { + for (const [key, ch] of Object.entries(PUSHES) as [keyof Scenario, PushChannel][]) { + const data = s[key] + if (data !== undefined) push(ch, data as never) + } + }) + } + }) + ;(window as unknown as { skyhawk: SkyhawkApi }).skyhawk = mock + return mock +} + +/** The mock the setup file installed for this test. */ +export function skyhawk(): SkyhawkMock { + return (window as unknown as { skyhawk: SkyhawkMock }).skyhawk +} diff --git a/src/renderer/test/store-reset.ts b/src/renderer/test/store-reset.ts new file mode 100644 index 0000000..83d6fdf --- /dev/null +++ b/src/renderer/test/store-reset.ts @@ -0,0 +1,9 @@ +// The zustand store is a module singleton: snapshot its initial state once, restore it per test +// (the setup file does this before every test). +import { useStore, type AppState } from '../src/store' + +const initial: AppState = useStore.getState() + +export function resetStore(): void { + useStore.setState(initial, true) +} diff --git a/src/renderer/test/t1-cockpit-attention-strays.test.tsx b/src/renderer/test/t1-cockpit-attention-strays.test.tsx new file mode 100644 index 0000000..f08983c --- /dev/null +++ b/src/renderer/test/t1-cockpit-attention-strays.test.tsx @@ -0,0 +1,125 @@ +// T1 — Cockpit › Attention: strays (new boards on the bus that can't be adopted). +import { describe, expect, it, vi } from 'vitest' +import { act, screen, within } from '@testing-library/react' +import { Cockpit } from '../src/tabs/Cockpit' +import { Overview } from '../src/tabs/Overview' +import { renderScenario } from './render' +import { connected, link, model, newBoard, panel } from './scenarios' + +const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + +/** The Attention card's rows, in order (the only list on the Cockpit with no board events). */ +const attentionRows = () => screen.queryAllByRole('listitem').map((li) => li.textContent) + +describe('T1 Cockpit › Attention: strays (B66, B65, X1, F26)', () => { + it('an unidentified new board is nothing at first sight and a warning 5 s later', async () => { + vi.useFakeTimers({ now: T0 }) + await renderScenario(, { + device: connected(), + model: model([newBoard(9, T0)]) + }) + expect(screen.queryByText(/UNIDENTIFIED/)).toBeNull() + expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + expect(screen.getByText('0 warning · 0 offline')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(5100) + }) + expect(attentionRows()).toEqual(['!NODE_ID 9UNIDENTIFIED · can’t be adopted · #9']) + expect(screen.getByText('1 item')).toBeInTheDocument() + // The health band counts it: one more board, online, warning. + expect(screen.getByText('1 warning · 0 offline')).toBeInTheDocument() + }) + + it('a second Gateway (bridge kind) and an unknown board type are strays at once', async () => { + vi.useFakeTimers({ now: T0 }) + await renderScenario(, { + device: connected(), + model: model([ + newBoard(9, T0, { identified: 'bridge' }), + newBoard(12, T0, { identified: 'other' }) + ]) + }) + expect(attentionRows()).toEqual([ + '!NODE_ID 9SECOND GATEWAY · can’t be adopted · #9', + '!NODE_ID 12UNKNOWN BOARD TYPE · can’t be adopted · #12' + ]) + expect(screen.getByText('2 warning · 0 offline')).toBeInTheDocument() + }) + + it('no strays while the link is down', async () => { + vi.useFakeTimers({ now: T0 }) + await renderScenario(, { + device: link('no-device'), + model: model([newBoard(9, T0, { identified: 'bridge' })]) + }) + expect(screen.queryByText(/SECOND GATEWAY/)).toBeNull() + expect(screen.getByText('No live node status — connect the Gateway.')).toBeInTheDocument() + expect(screen.getByText('0 items')).toBeInTheDocument() + // Nor in the band's board count. + expect(screen.getByText('/ 0')).toBeInTheDocument() + }) + + it('a stray’s cog opens the Adopt dialog on that board', async () => { + vi.useFakeTimers({ now: T0 }) + const { user } = await renderScenario(, { + device: connected(), + model: model([ + // First in the Adopt queue (seen earlier), so the dialog would open on it by default. + newBoard(8, T0 - 60_000, { + identified: 'panel', + registry: { name: 'Left_Oxygen', console: 'Left', pduId: null } + }), + newBoard(9, T0, { identified: 'bridge' }) + ]) + }) + await user.click(screen.getByRole('button', { name: 'NODE_ID 9 review' })) + const dlg = screen.getByRole('dialog', { name: 'Adopt new devices' }) + const selected = within(within(dlg).getByRole('listbox', { name: 'New NODE_IDs' })).getByRole( + 'option', + { selected: true } + ) + expect(selected).toHaveTextContent('NODE_ID 9') + expect(selected).toHaveTextContent('BRIDGE') + expect( + within(within(dlg).getByRole('listbox', { name: 'New NODE_IDs' })).getByRole('option', { + selected: false + }) + ).toHaveTextContent('NODE_ID 8') + }) + + it('the 5 s grace counts from firstSeenAt, not from when it came online (F26 today)', async () => { + // contract F26 says the grace should count from `onlineSince` (reset on each offline → online); + // today the UI counts from `firstSeenAt`, which an absent roster row also sets — so a board + // first listed absent a minute ago that has just come online is a stray at once. + vi.useFakeTimers({ now: T0 }) + await renderScenario(, { + device: connected(), + model: model([newBoard(9, T0 - 60_000, { reportedAt: T0 })]) + }) + expect(attentionRows()).toEqual(['!NODE_ID 9UNIDENTIFIED · can’t be adopted · #9']) + }) + + it('the Overview does not count the same stray (X1 today)', async () => { + // contract D1 says strays show on every tab; today only the Cockpit shows them. + vi.useFakeTimers({ now: T0 }) + await renderScenario(, { + device: connected(), + model: model([newBoard(9, T0 - 60_000, { identified: 'bridge' })]) + }) + expect(screen.getByText('no nodes known')).toBeInTheDocument() + expect(screen.queryByText(/need.? attention/)).toBeNull() + }) + + it('a healthy board in your cockpit is not a stray', async () => { + vi.useFakeTimers({ now: T0 }) + await renderScenario(, { + device: connected(), + model: model([panel(1, 'Center_Armament')]) + }) + act(() => { + vi.advanceTimersByTime(10_000) + }) + expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t2-cockpit-attention-order.test.tsx b/src/renderer/test/t2-cockpit-attention-order.test.tsx new file mode 100644 index 0000000..27324af --- /dev/null +++ b/src/renderer/test/t2-cockpit-attention-order.test.tsx @@ -0,0 +1,139 @@ +// T2 — Cockpit › Attention: order, the Gateway item, "+N more", empty states. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { Cockpit } from '../src/tabs/Cockpit' +import { DevicePopover } from '../src/DevicePopover' +import { renderScenario } from './render' +import { + connected, + gateway, + link, + model, + nodeStatus, + offline, + outage, + panel, + pdu +} from './scenarios' + +/** The Attention card's rows, in order (the only list on the Cockpit with no board events). */ +const attentionRows = () => screen.queryAllByRole('listitem').map((li) => li.textContent) + +/** + * PDU_Right (#4) went down with the one board it feeds (#2); #1 is offline on its own; #3 and #6 + * raise warnings. Listed out of order, so the order on screen is the UI's own. + */ +const troubled = () => + model( + [ + panel(6, 'Right_TACAN', { status: nodeStatus(6, { nodeKind: 1, epvf: true }) }), + panel(3, 'Left_Oxygen', { status: nodeStatus(3, { nodeKind: 1, overheat: true }) }), + panel(2, 'Right_Navigation', { pduId: 4, ...offline() }), + panel(1, 'Center_Armament', offline()), + pdu(4, 'PDU_Right', offline()) + ], + [outage(4, [2], 1)] + ) + +describe('T2 Cockpit › Attention: order and Gateway (B65, B42, B37)', () => { + it('Gateway first, then the outage, offline and warnings by NODE_ID, capped with "+N more"', async () => { + await renderScenario(, { + device: connected(gateway({ boff: true })), + model: troubled() + }) + // 4 board items + the Gateway; 3 rows shown, the Gateway's included. + expect(screen.getByText('5 items')).toBeInTheDocument() + expect(attentionRows()).toEqual([ + '✕GatewayBus-off', + '✕PDU_RightOFFLINE · 1 node affected · #4', + '✕Center_ArmamentOFFLINE · #1' + ]) + expect(screen.getByText('+2 more')).toBeInTheDocument() + // The board that dropped with its PDU is folded into the outage, and the plan explains it. + expect(screen.queryByText(/Right_Navigation/)).toBeNull() + expect( + screen.getByText( + 'The only node on this PDU lost power with it — a blown 5 V fuse at the PDU looks like this.' + ) + ).toBeInTheDocument() + }) + + it('without a Gateway problem the warnings follow, by NODE_ID', async () => { + await renderScenario(, { device: connected(), model: troubled() }) + expect(screen.getByText('4 items')).toBeInTheDocument() + expect(attentionRows()).toEqual([ + '✕PDU_RightOFFLINE · 1 node affected · #4', + '✕Center_ArmamentOFFLINE · #1', + '!Left_OxygenOVERHEAT · #3' + ]) + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + + it('a Gateway warning is a "!" item; a healthy Gateway is no item', async () => { + const { sky } = await renderScenario(, { + device: connected(gateway({ epvf: true })), + model: model([panel(1, 'Center_Armament')]) + }) + expect(attentionRows()).toEqual(['!GatewayError-passive']) + sky.emitScenario({ device: connected(gateway()) }) + expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + expect(screen.getByText('0 items')).toBeInTheDocument() + }) + + it('the Gateway cog opens the Device popover', async () => { + const { user } = await renderScenario( + <> + + + , + { device: connected(gateway({ boff: true })), model: troubled() } + ) + expect(screen.queryByRole('dialog', { name: 'Device link' })).toBeNull() + await user.click(screen.getByRole('button', { name: 'Gateway details' })) + const pop = screen.getByRole('dialog', { name: 'Device link' }) + expect(within(pop).getByText('Bus-off')).toBeInTheDocument() + }) + + it('an outage cog opens the PDU’s panel; a board cog opens the board’s', async () => { + const { user } = await renderScenario(, { + device: connected(), + model: troubled() + }) + await user.click(screen.getByRole('button', { name: 'PDU_Right details' })) + expect(screen.getByRole('dialog', { name: 'PDU PDU_Right' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Close' })) + await user.click(screen.getByRole('button', { name: 'Center_Armament details' })) + expect(screen.getByRole('dialog', { name: 'Panel group Center_Armament' })).toBeInTheDocument() + }) + + it('"Show all on map" switches the plan to Issues', async () => { + const { user } = await renderScenario(, { + device: connected(), + model: troubled() + }) + expect(screen.getByRole('button', { name: 'Issues' })).toHaveAttribute('aria-pressed', 'false') + await user.click(screen.getByRole('button', { name: 'Show all on map' })) + expect(screen.getByRole('button', { name: 'Issues' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('"Nothing needs attention." with every board healthy', async () => { + await renderScenario(, { + device: connected(), + model: model([panel(1, 'Center_Armament'), pdu(4, 'PDU_Right')]) + }) + expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + expect(attentionRows()).toEqual([]) + }) + + it('"No live node status — connect the Gateway." while the link is down, problems or not', async () => { + await renderScenario(, { + device: link('reconnecting', { gateway: gateway({ boff: true }) }), + model: troubled() + }) + expect(screen.getByText('No live node status — connect the Gateway.')).toBeInTheDocument() + expect(attentionRows()).toEqual([]) + expect(screen.queryByText('+2 more')).toBeNull() + // Nothing is counted either: the board items and the Gateway's own problem go with the link. + expect(screen.getByText('0 items')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t3-overview-tile-banners.test.tsx b/src/renderer/test/t3-overview-tile-banners.test.tsx new file mode 100644 index 0000000..9698fed --- /dev/null +++ b/src/renderer/test/t3-overview-tile-banners.test.tsx @@ -0,0 +1,116 @@ +// T3 — Overview: the Cockpit tile, the attention banner and the Gateway alert. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { Overview } from '../src/tabs/Overview' +import { Cockpit } from '../src/tabs/Cockpit' +import { DevicePopover } from '../src/DevicePopover' +import { renderScenario } from './render' +import { connected, gateway, model, newBoard, offline, outage, panel, pdu } from './scenarios' + +const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + +/** + * Five boards in your cockpit, one up: PDU_Left. PDU_Right (#4) went down with Right_Navigation + * (#2); Center_Armament (#1) and PDU_Center (#5) are offline on their own. + */ +const fourDown = () => + model( + [ + panel(1, 'Center_Armament', offline()), + panel(2, 'Right_Navigation', { pduId: 4, ...offline() }), + pdu(3, 'PDU_Left'), + pdu(4, 'PDU_Right', offline()), + pdu(5, 'PDU_Center', offline()) + ], + [outage(4, [2], 1)] + ) + +/** The Overview's Cockpit tile. */ +const cockpitTile = () => screen.getByRole('button', { name: /^Cockpit/ }) + +describe('T3 Overview: Cockpit tile and banners (B58, B59, B42, X1)', () => { + it('the tile counts boards up out of your cockpit, with the worst symbol, W and PDUs', async () => { + await renderScenario(, { device: connected(), model: fourDown() }) + const tile = cockpitTile() + expect(within(tile).getByText('1 / 5')).toBeInTheDocument() + expect(within(tile).getByText('✕')).toBeInTheDocument() + expect(within(tile).getByText('nodes up · — W · 3 PDUs')).toBeInTheDocument() + }) + + it('the banner names the offline boards in Attention order, an outage as "PDU + N node"', async () => { + await renderScenario(, { device: connected(), model: fourDown() }) + const banner = screen.getByRole('status') + expect(within(banner).getByText('4 nodes need attention')).toBeInTheDocument() + expect(banner).toHaveTextContent( + '⚠ 4 nodes need attention — 4 offline (PDU_Right + 1 node, Center_Armament, PDU_Center).' + ) + expect(within(banner).getByRole('button', { name: 'Open Cockpit' })).toBeInTheDocument() + }) + + it('the banner is hidden while Settings › Cockpit turns it off', async () => { + await renderScenario(, { + device: connected(), + model: fourDown(), + config: { attentionBanner: false } + }) + expect(screen.queryByText(/need.? attention/)).toBeNull() + // The tile still says it. + expect(within(cockpitTile()).getByText('1 / 5')).toBeInTheDocument() + }) + + it('a Gateway bus-off is a red alert, first in the group; Details opens the Device popover', async () => { + const { user } = await renderScenario( + <> + + + , + { device: connected(gateway({ boff: true })), model: fourDown() } + ) + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent( + 'The Gateway can’t reach your panels — its cockpit (CAN) side went bus-off, so panel inputs aren’t reaching DCS.' + ) + // Before the board banner. + const banner = screen.getByRole('status') + expect(alert.compareDocumentPosition(banner) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + expect(screen.queryByRole('dialog', { name: 'Device link' })).toBeNull() + await user.click(within(alert).getByRole('button', { name: 'Details' })) + expect(screen.getByRole('dialog', { name: 'Device link' })).toBeInTheDocument() + }) + + it('a Gateway that only warns gives no alert', async () => { + await renderScenario(, { + device: connected(gateway({ epvf: true })), + model: model([panel(1, 'Center_Armament')]) + }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('the Overview doesn’t count a stray (X1 today)', async () => { + // contract D1 says strays show on every tab; today the Overview ignores them: the Cockpit + // tile reads "no nodes known" and no banner shows, while the Cockpit lists it as a warning. + await renderScenario(, { + device: connected(), + model: model([newBoard(9, T0 - 60_000, { identified: 'bridge' })]) + }) + const tile = cockpitTile() + expect(within(tile).getByText('—')).toBeInTheDocument() + expect(within(tile).getByText('no nodes known')).toBeInTheDocument() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('its counts equal the Cockpit band for the same model', async () => { + const { unmount } = await renderScenario(, { + device: connected(), + model: fourDown() + }) + expect(within(cockpitTile()).getByText('1 / 5')).toBeInTheDocument() + unmount() + await renderScenario(, { device: connected(), model: fourDown() }) + expect(screen.getByText('Nodes online').parentElement).toHaveTextContent( + '0 warning · 4 offline' + ) + expect(screen.getByText('/ 5')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t4-overview-new-devices.test.tsx b/src/renderer/test/t4-overview-new-devices.test.tsx new file mode 100644 index 0000000..ca25ce1 --- /dev/null +++ b/src/renderer/test/t4-overview-new-devices.test.tsx @@ -0,0 +1,84 @@ +// T4 — Overview: the new-devices notice. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { Overview } from '../src/tabs/Overview' +import { App } from '../src/App' +import { renderScenario } from './render' +import { connected, link, model, newBoard, panel } from './scenarios' + +const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + +/** Right_Navigation from NODE_IDS.md, on the bus and reporting as a panel group, not added yet. */ +const registryPanel = () => + newBoard(2, T0, { + name: 'Right_Navigation', + identified: 'panel', + registry: { name: 'Right_Navigation', console: 'Right', pduId: null } + }) + +/** A PDU on NODE_ID 41, which NODE_IDS.md doesn't list. */ +const unregisteredPdu = () => newBoard(41, T0, { kind: 'pdu', identified: 'pdu' }) + +const notice = () => screen.queryByText(/new devices? on the bus/)?.closest('[role="status"]') + +describe('T4 Overview: new-devices notice (B60)', () => { + it('an identified registry board waits "from NODE_IDS.md, not added to your cockpit yet"', async () => { + await renderScenario(, { + device: connected(), + model: model([panel(1, 'Center_Armament'), registryPanel()]) + }) + expect(notice()).toHaveTextContent( + '1 new device on the bus — 1 panel group from NODE_IDS.md, not added to your cockpit yet.' + ) + expect( + within(notice() as HTMLElement).getByRole('button', { name: 'Open Cockpit →' }) + ).toBeInTheDocument() + }) + + it('a board NODE_IDS.md doesn’t list is "not in NODE_IDS.md"; a mix says how many are', async () => { + const { sky } = await renderScenario(, { + device: connected(), + model: model([unregisteredPdu()]) + }) + expect(notice()).toHaveTextContent('1 new device on the bus — 1 PDU, not in NODE_IDS.md.') + sky.emitScenario({ model: model([registryPanel(), unregisteredPdu()]) }) + expect(notice()).toHaveTextContent( + '2 new devices on the bus — 1 panel group, 1 PDU, not added to your cockpit yet — 1 from NODE_IDS.md.' + ) + }) + + it('a board still identifying, or one that can’t be adopted, is not mentioned', async () => { + await renderScenario(, { + device: connected(), + model: model([newBoard(9, T0), newBoard(10, T0, { identified: 'bridge' })]) + }) + expect(notice()).toBeUndefined() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('it is hidden while the link is down', async () => { + const { sky } = await renderScenario(, { + device: connected(), + model: model([registryPanel()]) + }) + expect(notice()).toBeTruthy() + sky.emitScenario({ device: link('no-device') }) + expect(notice()).toBeUndefined() + }) + + it('"Open Cockpit →" opens the Cockpit with the Adopt dialog', async () => { + const { user } = await renderScenario(, { + device: connected(), + model: model([registryPanel()]) + }) + expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true') + await user.click(screen.getByRole('button', { name: 'Open Cockpit →' })) + expect(screen.getByRole('tab', { name: 'Cockpit' })).toHaveAttribute('aria-selected', 'true') + const dlg = screen.getByRole('dialog', { name: 'Adopt new devices' }) + expect( + within(within(dlg).getByRole('listbox', { name: 'New NODE_IDs' })).getByRole('option', { + selected: true + }) + ).toHaveTextContent('NODE_ID 2') + }) +}) diff --git a/src/renderer/test/t5-connection-nodes-card.test.tsx b/src/renderer/test/t5-connection-nodes-card.test.tsx new file mode 100644 index 0000000..91b4ebc --- /dev/null +++ b/src/renderer/test/t5-connection-nodes-card.test.tsx @@ -0,0 +1,128 @@ +// T5 — Connection › Connected nodes: the summary card. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Connection } from '../src/tabs/Connection' +import { Cockpit } from '../src/tabs/Cockpit' +import { App } from '../src/App' +import { renderScenario } from './render' +import { + connected, + link, + model, + newBoard, + noStatusYet, + nodeStatus, + notSeen, + offline, + outage, + panel, + pdu +} from './scenarios' + +// Local times: the card prints them in the computer's time zone. +const OFF_AT = new Date(2026, 9, 16, 21, 11, 18).getTime() +const LAST_SEEN = new Date(2026, 9, 14, 21, 14).getTime() + +/** + * PDU_Right (#4) went down with Right_Navigation (#2); Center_Armament (#1) is remembered but not + * seen this session; Left_Oxygen (#7) overheats, fed by a PDU on the bus that isn't added yet + * (#6); Left_Trim (#8) is error-passive; Center_Clock (#9) has no status yet; PDU_Left is fine. + */ +const scene = () => + model( + [ + panel(1, 'Center_Armament', notSeen(LAST_SEEN)), + panel(2, 'Right_Navigation', { pduId: 4, ...offline(OFF_AT) }), + pdu(3, 'PDU_Left'), + pdu(4, 'PDU_Right', offline(OFF_AT)), + newBoard(6, OFF_AT, { kind: 'pdu', identified: 'pdu' }), + panel(7, 'Left_Oxygen', { pduId: 6, status: nodeStatus(7, { nodeKind: 1, overheat: true }) }), + panel(8, 'Left_Trim', { pduId: 3, status: nodeStatus(8, { nodeKind: 1, epvf: true }) }), + panel(9, 'Center_Clock', noStatusYet()) + ], + [outage(4, [2], 1)] + ) + +/** A count in the card's header row: the number next to its label. */ +const count = (label: string) => screen.getByText(label).parentElement?.textContent + +/** The rows' "›" buttons, in order. */ +const rowButtons = () => + screen + .queryAllByRole('button', { name: /^Open .* in Cockpit$/ }) + .map((b) => b.getAttribute('aria-label')) + +describe('T5 Connection › nodes summary card (B63, B14, B37, X2)', () => { + it('counts your cockpit’s boards the way the Cockpit band does', async () => { + const { unmount } = await renderScenario(, { + device: connected(), + model: scene() + }) + expect(count('healthy')).toBe('1healthy') + expect(count('warning')).toBe('2warning') + expect(count('offline')).toBe('3offline') + expect(screen.getByText('PDUs · — W').parentElement).toHaveTextContent('2PDUs · — W') + unmount() + // The Cockpit band for the same model: 3 online = 1 healthy + 2 warning, 3 offline. + await renderScenario(, { device: connected(), model: scene() }) + expect(screen.getByText('Nodes online').parentElement).toHaveTextContent( + '2 warning · 3 offline · 1 no status yet' + ) + expect(screen.getByText('/ 7').parentElement).toHaveTextContent('3/ 7') + }) + + it('lists the 3 most severe boards in Attention order, with symbol, meta and chip', async () => { + await renderScenario(, { device: connected(), model: scene() }) + expect(rowButtons()).toEqual([ + 'Open PDU_Right in Cockpit', + 'Open Center_Armament in Cockpit', + 'Open Left_Oxygen in Cockpit' + ]) + const row = (meta: string) => screen.getByText(meta).parentElement + expect(row('#4 · 1 node affected · offline since 21:11:18')).toHaveTextContent( + '✕PDU_Right#4 · 1 node affected · offline since 21:11:18OFFLINE' + ) + expect( + row('#1 · no power zone · not seen this session · last seen 14 Oct, 21:14') + ).toHaveTextContent( + '✕Center_Armament#1 · no power zone · not seen this session · last seen 14 Oct, 21:14OFFLINE' + ) + // contract X2: the plan puts #7 in No power zone (its PDU isn't in your cockpit); today the + // card's meta names that PDU anyway, because it is on the bus. + expect(row('#7 · Node 6')).toHaveTextContent('!Left_Oxygen#7 · Node 6OVERHEAT') + }) + + it('the footer counts the rest: "+N more · N no status yet · healthy nodes hidden"', async () => { + await renderScenario(, { device: connected(), model: scene() }) + expect(screen.getByText('+1 more · 1 no status yet · healthy nodes hidden')).toBeInTheDocument() + }) + + it('Refresh asks main for the roster while the link is up', async () => { + const { sky, user } = await renderScenario(, { + device: connected(), + model: scene() + }) + await user.click(screen.getByRole('button', { name: 'Refresh' })) + expect(sky.refreshNodes).toHaveBeenCalledTimes(1) + }) + + it('Refresh is disabled and the rows give way to a hint while the link is down', async () => { + await renderScenario(, { + device: link('disconnected'), + model: scene() + }) + expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled() + expect( + screen.getByText('Device link released — press Connect to see node status.') + ).toBeInTheDocument() + expect(rowButtons()).toEqual([]) + }) + + it('"›" opens the Cockpit on that board', async () => { + const { user } = await renderScenario(, { device: connected(), model: scene() }) + await user.click(screen.getByRole('tab', { name: 'Connection' })) + await user.click(screen.getByRole('button', { name: 'Open Center_Armament in Cockpit' })) + expect(screen.getByRole('tab', { name: 'Cockpit' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('dialog', { name: 'Panel group Center_Armament' })).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t6-device-popover.test.tsx b/src/renderer/test/t6-device-popover.test.tsx new file mode 100644 index 0000000..973950c --- /dev/null +++ b/src/renderer/test/t6-device-popover.test.tsx @@ -0,0 +1,192 @@ +// T6 — the title-bar Device indicator and its popover: link states and the Gateway's Health. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { DeviceState } from '@shared/ipc' +import { DevicePopover } from '../src/DevicePopover' +import { App } from '../src/App' +import { renderScenario } from './render' +import { PORT, connected, gateway, gatewayNotResponding, link } from './scenarios' + +/** The indicator: the button that opens the Device link popover. */ +const pill = () => screen.getByRole('button', { expanded: false }) +const popover = () => screen.getByRole('dialog', { name: 'Device link' }) + +describe('T6 DevicePopover: link states and Gateway Health (B44, B42, B40)', () => { + // Each state: the indicator's label, the popover's title, its line, and its button. + const STATES: [DeviceState, string, string, string | null, string, boolean][] = [ + ['connected', 'Gateway', 'Connected', null, 'Disconnect', false], + ['scanning', 'Scanning', 'Scanning…', 'Looking for the Gateway…', 'Disconnect', true], + [ + 'reconnecting', + 'Reconnecting', + 'Reconnecting', + 'Link dropped — reopening the port. A running relay keeps going.', + 'Disconnect', + false + ], + [ + 'no-device', + 'No device', + 'No device', + 'Nothing plugged in — it connects as soon as you plug it in.', + 'Disconnect', + false + ], + [ + 'disconnected', + 'Disconnected', + 'Disconnected', + 'Port released — other tools can use it until you press Connect.', + 'Connect', + false + ], + [ + 'error', + 'Error', + 'Error', + `${PORT} would not open: boom. Retry, or replug the board.`, + 'Retry', + false + ] + ] + + it.each(STATES)( + '%s: indicator "%s", popover "%s"', + async (state, label, title, line, btn, off) => { + const { user } = await renderScenario(, { + device: link(state, state === 'error' ? { detail: 'boom' } : {}) + }) + expect(pill()).toHaveTextContent(label) + await user.click(pill()) + const dlg = popover() + expect(within(dlg).getByText(title)).toBeInTheDocument() + if (line) expect(within(dlg).getByText(line)).toBeInTheDocument() + // The port shows in the popover only in the error line; connected keeps it in the tooltip. + if (state !== 'error') expect(within(dlg).queryByText(new RegExp(PORT))).toBeNull() + const action = within(dlg).getByRole('button', { name: btn }) + if (off) expect(action).toBeDisabled() + else expect(action).toBeEnabled() + // No Health row without a Gateway report. + expect(within(dlg).queryByText('Health')).toBeNull() + } + ) + + it('connected: the port is in the indicator’s tooltip', async () => { + // contract (T6) says the popover has a port line while connected, dropped once the Gateway + // reports health; today the popover has no port line while connected, with or without health + // (#94 moved it to this tooltip and the Connection tab). + await renderScenario(, { device: connected() }) + expect(pill()).toHaveAttribute('title', `Gateway connected · ${PORT}`) + }) + + it('another program holding the port reads "busy"', async () => { + const { user } = await renderScenario(, { + device: link('error', { detail: 'Resource busy' }) + }) + await user.click(pill()) + expect( + within(popover()).getByText(`Port busy — another app has ${PORT} open. Close it, then Retry.`) + ).toBeInTheDocument() + }) + + it('Connect / Retry / Disconnect ask main for the link', async () => { + const { sky, user } = await renderScenario(, { + device: link('disconnected') + }) + await user.click(pill()) + await user.click(within(popover()).getByRole('button', { name: 'Connect' })) + expect(sky.connectDevice).toHaveBeenCalledTimes(1) + + sky.emitScenario({ device: link('error', { detail: 'boom' }) }) + await user.click(within(popover()).getByRole('button', { name: 'Retry' })) + expect(sky.connectDevice).toHaveBeenCalledTimes(2) + + sky.emitScenario({ device: connected() }) + await user.click(within(popover()).getByRole('button', { name: 'Disconnect' })) + expect(sky.disconnectDevice).toHaveBeenCalledTimes(1) + }) + + it('a healthy Gateway: Health "OK · 42 °C", no "!"', async () => { + const { user } = await renderScenario(, { + device: connected(gateway()) + }) + expect(pill()).toHaveTextContent(/^Gateway$/) + await user.click(pill()) + const dlg = popover() + expect(within(dlg).getByText('Health')).toBeInTheDocument() + expect(within(dlg).getByText('OK · 42 °C')).toBeInTheDocument() + expect(within(dlg).getByText('Connected')).toBeInTheDocument() + }) + + it('a warning: "!" on the indicator, the Health row with its note', async () => { + const { user } = await renderScenario(, { + device: connected(gateway({ epvf: true })) + }) + expect(pill()).toHaveTextContent('!Gateway') + expect(pill()).toHaveAttribute( + 'title', + `Gateway reports a problem · ${PORT} — click for details` + ) + await user.click(pill()) + const dlg = popover() + expect(within(dlg).getByText('Error-passive')).toBeInTheDocument() + expect(within(dlg).getByText(/^Its CAN error count is high/)).toBeInTheDocument() + }) + + it('not responding: the title and line replace the link’s own; no Health row', async () => { + const { user } = await renderScenario(, { + device: connected(gatewayNotResponding()) + }) + expect(pill()).toHaveTextContent('!Gateway') + await user.click(pill()) + const dlg = popover() + expect(within(dlg).getByText('Not responding')).toBeInTheDocument() + expect( + within(dlg).getByText( + 'No answer for 5 s while the port is open. Unplug its USB cable and plug it back in.' + ) + ).toBeInTheDocument() + expect(within(dlg).queryByText('Connected')).toBeNull() + expect(within(dlg).queryByText('Health')).toBeNull() + }) + + it('nothing about the Gateway while the link is down', async () => { + const { user } = await renderScenario(, { + device: link('reconnecting', { gateway: gateway({ boff: true }) }) + }) + expect(pill()).toHaveTextContent(/^Reconnecting$/) + await user.click(pill()) + expect(within(popover()).queryByText('Health')).toBeNull() + expect(within(popover()).queryByText('Bus-off')).toBeNull() + }) + + it('Esc closes it and focus returns to the indicator', async () => { + const { user } = await renderScenario(, { device: connected(gateway()) }) + await user.click(pill()) + expect(popover()).toBeInTheDocument() + await user.keyboard('{Escape}') + expect(screen.queryByRole('dialog')).toBeNull() + expect(pill()).toHaveFocus() + }) + + it('a click outside (the scrim) closes it and focus returns to the indicator', async () => { + const { container, user } = await renderScenario(, { + device: connected(gateway()) + }) + await user.click(pill()) + // The scrim has no role or text: it is the page behind the popover. + const scrim = container.querySelector('.devpop__scrim') + expect(scrim).not.toBeNull() + await user.click(scrim!) + expect(screen.queryByRole('dialog')).toBeNull() + expect(pill()).toHaveFocus() + }) + + it('"Connection tab →" opens the Connection tab and closes the popover', async () => { + const { user } = await renderScenario(, { device: connected() }) + await user.click(screen.getByRole('button', { name: 'Gateway' })) + await user.click(within(popover()).getByRole('button', { name: 'Connection tab →' })) + expect(screen.getByRole('tab', { name: 'Connection' })).toHaveAttribute('aria-selected', 'true') + expect(screen.queryByRole('dialog', { name: 'Device link' })).toBeNull() + }) +}) diff --git a/src/renderer/test/t7-board-footer-panel.test.tsx b/src/renderer/test/t7-board-footer-panel.test.tsx new file mode 100644 index 0000000..fa4d64e --- /dev/null +++ b/src/renderer/test/t7-board-footer-panel.test.tsx @@ -0,0 +1,120 @@ +// T7 — the board panel footer, for a panel group: Save · Reset to default | Forget · Hide. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeModelEntry } from '@shared/node-rules' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { connected, model, noStatusYet, offline, panel } from './scenarios' + +const ONLINE = + 'Online — it’s active on the bus, so it can’t be removed. Forget and Hide work once it’s offline.' +const NO_ROSTER = + 'No roster yet — connect the Gateway. Forget and Hide work once the board shows offline.' + +/** Right_Navigation (#2) on the Cockpit; its panel open on the Settings tab. */ +async function openSettings(over: Partial) { + const r = await renderScenario(, { + device: connected(), + model: model([panel(2, 'Right_Navigation', over)]) + }) + await r.user.click(screen.getByTitle(/^#2 Right_Navigation/)) + const sheet = screen.getByRole('dialog', { name: 'Panel group Right_Navigation' }) + await r.user.click(within(sheet).getByRole('tab', { name: 'Settings' })) + const button = (name: string) => within(sheet).queryByRole('button', { name }) + return { ...r, sheet, button } +} + +describe('T7 Board panel footer: panel group (B73, X4)', () => { + it('offline: Forget and Hide are enabled, and the note says where a forgotten board comes back', async () => { + const { sheet, button } = await openSettings(offline()) + expect(button('Save')).toBeEnabled() + expect(button('Forget')).toBeEnabled() + expect(button('Forget')).toHaveAttribute( + 'title', + 'Remove it from your cockpit and the saved mapping' + ) + expect(button('Hide')).toBeEnabled() + expect(button('Hide')).toHaveAttribute('title', 'Hide until it reports again') + expect( + within(sheet).getByText( + 'Forget removes it from your cockpit and the saved mapping; if it reports again it waits in New on the bus, pre-filled from NODE_IDS.md. Hide takes it off the Cockpit until it reports again.' + ) + ).toBeInTheDocument() + }) + + it('online: Forget and Hide are greyed out, with the reason', async () => { + const { sheet, button } = await openSettings({}) + expect(button('Forget')).toBeDisabled() + expect(button('Forget')).toHaveAttribute('title', ONLINE) + expect(button('Hide')).toBeDisabled() + expect(button('Hide')).toHaveAttribute('title', ONLINE) + expect(within(sheet).getByText(ONLINE)).toBeInTheDocument() + }) + + it('no roster yet: Forget and Hide are greyed out until it shows offline (X4 today)', async () => { + // contract X4: main accepts Forget/Hide whenever the roster doesn't list the board present, + // `unknown` included (F3); today the footer allows them only once it is `offline`. + const { sheet, button } = await openSettings(noStatusYet()) + expect(button('Forget')).toBeDisabled() + expect(button('Forget')).toHaveAttribute('title', NO_ROSTER) + expect(button('Hide')).toBeDisabled() + expect(within(sheet).getByText(NO_ROSTER)).toBeInTheDocument() + }) + + it('Reset to default is greyed out at the NODE_IDS.md defaults, and resets when it differs', async () => { + const atDefaults = await openSettings(offline()) + expect(atDefaults.button('Reset to default')).toBeDisabled() + expect(atDefaults.button('Reset to default')).toHaveAttribute( + 'title', + 'Already at the NODE_IDS.md defaults' + ) + atDefaults.unmount() + + const { sky, user, button } = await openSettings({ ...offline(), differs: true }) + expect(button('Reset to default')).toBeEnabled() + expect(button('Reset to default')).toHaveAttribute( + 'title', + 'Restore the name, console and power zone from NODE_IDS.md' + ) + await user.click(button('Reset to default')!) + expect(sky.resetNode).toHaveBeenCalledWith(2) + // Main accepted it: the panel closes. + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('an adopted board has no Reset, and comes back as new', async () => { + const { sheet, button } = await openSettings({ ...offline(), source: 'adopted' }) + expect(button('Reset to default')).toBeNull() + expect( + within(sheet).getByText( + 'Forget removes it from your cockpit and the saved mapping; if it reports again it shows as new. Hide takes it off the Cockpit until it reports again.' + ) + ).toBeInTheDocument() + }) + + it('Forget asks main to forget it; the panel closes when main accepts', async () => { + const { sky, user, button } = await openSettings(offline()) + await user.click(button('Forget')!) + expect(sky.forgetNode).toHaveBeenCalledWith(2) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('a refused Forget shows main’s error and keeps the panel open', async () => { + const { sky, sheet, user, button } = await openSettings(offline()) + sky.forgetNode.mockResolvedValueOnce({ + ok: false, + error: 'It is online — a board on the bus can’t be forgotten' + }) + await user.click(button('Forget')!) + expect( + within(sheet).getByText('It is online — a board on the bus can’t be forgotten') + ).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Panel group Right_Navigation' })).toBeInTheDocument() + }) + + it('Hide asks main to hide it until it reports again', async () => { + const { sky, user, button } = await openSettings(offline()) + await user.click(button('Hide')!) + expect(sky.hideNode).toHaveBeenCalledWith(2, true) + }) +}) diff --git a/src/renderer/test/t8-board-footer-pdu.test.tsx b/src/renderer/test/t8-board-footer-pdu.test.tsx new file mode 100644 index 0000000..b23d5bf --- /dev/null +++ b/src/renderer/test/t8-board-footer-pdu.test.tsx @@ -0,0 +1,111 @@ +// T8 — the board panel footer, for a PDU: what Forget and Hide do to the boards it feeds. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeModel } from '@shared/node-rules' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { connected, model, offline, panel, pdu } from './scenarios' + +/** + * PDU_Right (#4), offline, feeds Right_Navigation (#2, offline) and Right_TACAN (#7, online) on the + * plan, and Right_Radar (#8), which is hidden — so not in the model's boards. + */ +const scene = (over: Partial = {}) => + model( + [ + panel(2, 'Right_Navigation', { pduId: 4, ...offline() }), + pdu(4, 'PDU_Right', offline()), + panel(7, 'Right_TACAN', { pduId: 4 }) + ], + [], + { + hidden: [8], + hiddenNodes: [{ nodeId: 8, name: 'Right_Radar', console: 'Right' }], + ...over + } + ) + +/** PDU_Right's panel, open on its Settings tab. */ +async function openSettings(m: NodeModel) { + const r = await renderScenario(, { device: connected(), model: m }) + await r.user.click(screen.getByTitle('PDU_Right — power zone details')) + const sheet = screen.getByRole('dialog', { name: 'PDU PDU_Right' }) + await r.user.click(within(sheet).getByRole('tab', { name: 'Settings' })) + const button = (name: string) => within(sheet).getByRole('button', { name }) + return { ...r, sheet, button } +} + +describe('T8 Board panel footer: PDU (B73, B9, B10, X8)', () => { + it('offline: Forget unlinks "Its 2 boards", Hide keeps the links', async () => { + const { sheet, button } = await openSettings(scene()) + expect(button('Forget')).toBeEnabled() + expect(button('Forget')).toHaveAttribute( + 'title', + 'Remove it from your cockpit; its boards lose the link' + ) + expect(button('Hide')).toBeEnabled() + expect(button('Hide')).toHaveAttribute( + 'title', + 'Hide until it reports again; its boards keep the link' + ) + expect( + within(sheet).getByText( + 'Forget removes this PDU from your cockpit. Its 2 boards move to No power zone and keep no link to it, even if it comes back. Hide keeps the links: they return under it when it reports.' + ) + ).toBeInTheDocument() + }) + + it('the fed count is the boards on the plan, not the hidden one (X8 today)', async () => { + // contract X8: main's Forget unlinks every board the PDU feeds, hidden ones included (here 3); + // today the footer and Feeds count only the visible boards in your cockpit (2). + const { sheet, user } = await openSettings(scene()) + expect(within(sheet).getByText(/^Forget removes this PDU/)).toHaveTextContent('Its 2 boards') + const feeds = within(sheet).getByRole('tab', { name: 'Feeds · 2' }) + await user.click(feeds) + expect(within(sheet).getByRole('button', { name: 'Open Right_Navigation' })).toBeInTheDocument() + expect(within(sheet).getByRole('button', { name: 'Open Right_TACAN' })).toBeInTheDocument() + expect(within(sheet).queryByText('Right_Radar')).toBeNull() + }) + + it('a PDU feeding nothing: the note leaves the boards out', async () => { + const { sheet } = await openSettings(model([pdu(4, 'PDU_Right', offline())])) + expect( + within(sheet).getByText( + 'Forget removes this PDU from your cockpit. Hide keeps the links: they return under it when it reports.' + ) + ).toBeInTheDocument() + }) + + it('online: Forget and Hide are greyed out, with the reason', async () => { + const online = + 'Online — it’s active on the bus, so it can’t be removed. Forget and Hide work once it’s offline.' + const { sheet, button } = await openSettings(model([pdu(4, 'PDU_Right')])) + expect(button('Forget')).toBeDisabled() + expect(button('Hide')).toBeDisabled() + expect(button('Hide')).toHaveAttribute('title', online) + expect(within(sheet).getByText(online)).toBeInTheDocument() + }) + + it('Hide asks main to hide the PDU; Forget asks main to forget it', async () => { + const hide = await openSettings(scene()) + await hide.user.click(hide.button('Hide')) + expect(hide.sky.hideNode).toHaveBeenCalledWith(4, true) + expect(screen.queryByRole('dialog')).toBeNull() + hide.unmount() + + const forget = await openSettings(scene()) + await forget.user.click(forget.button('Forget')) + expect(forget.sky.forgetNode).toHaveBeenCalledWith(4) + }) + + it('Reset to default restores the name and console only', async () => { + const { button } = await openSettings( + model([pdu(4, 'PDU_Right', { ...offline(), differs: true })]) + ) + expect(button('Reset to default')).toBeEnabled() + expect(button('Reset to default')).toHaveAttribute( + 'title', + 'Restore the name and console from NODE_IDS.md' + ) + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index cac9c38..85e97ed 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,13 +1,40 @@ +import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +// The renderer's titlebar version, defined the way the app build defines it (electron.vite.config.ts). +const { version } = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) as { version: string } + +// Two projects, split by file extension: `*.test.ts` run on node (every test that existed before +// the component harness, unchanged), `*.test.tsx` are renderer component tests in jsdom. export default defineConfig({ test: { globals: true, - environment: 'node', - include: ['src/**/*.test.ts'] - }, - resolve: { - alias: { '@shared': resolve('src/shared') } + projects: [ + { + extends: true, + resolve: { alias: { '@shared': resolve('src/shared') } }, + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'] + } + }, + { + extends: true, + plugins: [react()], + define: { __APP_VERSION__: JSON.stringify(version) }, + resolve: { + alias: { '@shared': resolve('src/shared'), '@renderer': resolve('src/renderer/src') } + }, + test: { + name: 'dom', + environment: 'jsdom', + include: ['src/renderer/**/*.test.tsx'], + setupFiles: ['src/renderer/test/setup.ts'] + } + } + ] } }) From 2598104b15fc8714ebee9c38510d633bff6ca13d Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 19:49:05 -0400 Subject: [PATCH 04/28] test: tier-1 component tests (2/2) (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the tier-1 component tests from the phase-0 plan: T9–T16. Tests only, no production change. Uses the R0.2 harness as is (`renderScenario` / `startBridge`, scenarios through `emitScenario`, intents checked on the mock). **Total: 849 tests in 62 files** (was 791 in 54; +58). Lint 0 errors (32 warnings, unchanged), lint:deps 0 errors (65 warnings, unchanged), typecheck clean. ## What each test pins | # | File | What it pins | Rows | |---|---|---|---| | T9 | `t9-cockpit-new-on-bus` | "New on the bus · N" + "not linked yet" + Adopt… for identified new boards (PDUs counted); none for identifying/bridge; **still shown after the link drops (R5 today)**; Adopt… opens the dialog on the first in its queue | B68, F12, X7 | | T10 | `t10-adopt-dialog-queue-form` | Queue order (first seen, then NODE_ID) and badges PANEL GROUP / PDU / IDENTIFYING / BRIDGE / UNKNOWN TYPE; registry pre-fill + "Add to cockpit" → `adoptNode` spec; unlisted panel: Adopt disabled until name + console + Powered by; PDU form has no Powered by; non-adoptable rows show their note, no form; Later moves on / closes past the end; after an add the selection moves to the next; a refused adopt shows main's error | B74, B21, B22 | | T11 | `t11-adopt-dialog-add-all` | "Add all 2 panel groups from NODE_IDS.md" never adds a PDU; an untouched form keeps its default PDU only if it's in your cockpit, else None; an edited form is taken as written (None stays None); **an edited form keeps a "(not added)" PDU (D7 today)**; Powered by lists "(other console)" and "(not added)" + its hint; **a hidden PDU reads "(not added)" and Add all sends None (#95 bug 2 / X3 today)**; a row that drops while open stays listed, ✕, form kept, and turns green when back | B74, F15, F24, X3 | | T12 | `t12-settings-hidden-boards` | Rows "#id · name · console · returns when it reports again" from `model.hiddenNodes`; Un-hide → `hideNode(id,false)`; Un-hide all un-hides each; a refusal shows main's error; empty state + Un-hide all disabled | B88 | | T13 | `t13-settings-import-preview` | Subtitle; rows "key from → to"; "no change" sections can't be picked; "This file matches your current setup" + no Apply; **`"nodeMapping": {}` previews as a normal, pre-picked section offering to drop every adopted board (F1 today)**; unknown section → "Skipped — written by a newer client…"; Apply disabled with nothing picked; `applyConfigImport(token, sections, backup)`; done line with the backup name; refused apply / refused file | B90, B94, F1 | | T14 | `t14-app-shell` | Wordmark, `v${package.json version}`, Device indicator, Relay pill; 7 tabs with `aria-selected`, click switches tab and page; "SOURCE "; `App` itself subscribes on mount (rendered without `startBridge`), takes main's pulled state, and a `device:status` push changes the pill | B43 | | T15 | `t15-relay-pill` | Stopped / Relaying; Start saves settings then calls `startRelay`, optimistic Relaying; Stop calls `stopRelay`; the waiting tooltips (DCS-BIOS connecting; Bridge waiting for the Gateway); Bridge without a board → "!", `aria-disabled`, alertdialog "Can't start the relay", Connect only connects; the board arriving lifts the block; Esc returns focus; Monitor starts with no board; a Start main refuses goes back to Stopped | B45, B47 | | T16 | `t16-board-panel-settings` | Display name + "Registry: …, Right console (NODE_IDS.md)."; Power zone = that console's placed PDUs + None (not a new PDU); "(other console)"; a console change picks its first PDU; Save sends only what changed (trimmed name; console + pduId); no-change Save just closes; empty name → main's error inline, panel stays open; a PDU has no power zone and Save sends `{ console }` only | B72, X2 | `scenarios.ts` gains `setup()`, `configFileText()` and `importPreview()`: `openConfigImport`'s answer built the way main's `ConfigTransfer.preview` builds it (shared `parseConfigFile` + `diffImport`), so T13 — F1 especially — pins what main would actually preview, not a hand-written preview. ## Discrepancies (pinned as today, marked in the tests) 1. **T9 / R5, F12** — the CTA stays from the last model after the link drops (as the plan expected). 2. **T11 / D7, F24** — an edited Adopt form keeps a "(not added)" PDU and Add all sends it; the form offers "(not added)" at all. 3. **T11 / X3, F15** — a hidden PDU in your cockpit reads "(not added)", and Add all sends `pduId: null` for its boards. 4. **T13 / F1** — `"nodeMapping": {}` previews as a normal section, pre-picked, offering to take every adopted board out of the cockpit. 5. **T16 / B72** — "an empty name gives an inline error": today the panel has no check of its own. It sends `setNodeMapping(id, { name: '' })` and shows main's refusal ("Name cannot be empty") inline. Same user-visible result, but the gate is main's. 6. **T16 / B72 wording** — the Settings tab's field is labelled "Power zone"; "Powered by" is the Status tab section and the Adopt form label. 7. **T15** — when main refuses Start, the pill goes back to Stopped but shows no reason (main's error text is dropped). ## Fail-check (each break applied locally, test file run, restored with `git checkout`; production tree clean after) | T | Break | Result | |---|---|---| | T9 | CTA gated on `live` (the R5 fix) | 1 failed | | T9 | `newCount` counts unidentified new boards | 1 failed | | T10 | queue tie-break by NODE_ID reversed | 3 failed | | T10 | panel spec complete without Powered by | 1 failed | | T11 | Add all includes PDUs | 1 failed | | T11 | untouched default PDU kept even if not in cockpit | 2 failed | | T11 | rows that drop are not kept | 1 failed | | T11 | identify dot never turns red | 1 failed | | T12 | Un-hide sends `hideNode(id, true)` | 2 failed | | T13 | empty section skipped in `parseConfigFile` (the F1 fix) | 1 failed | | T13 | Apply enabled with nothing picked | 1 failed | | T14 | `App` doesn't call `initBridge` on mount | 1 failed | | T14 | `aria-selected` stuck on Overview | 1 failed | | T15 | refused Start not taken back | 1 failed | | T15 | blocked pill starts anyway | 3 failed | | T16 | console change keeps the old PDU | 2 failed | | T16 | Save always sends the name | 3 failed | Refs #97 --- src/renderer/test/scenarios.ts | 59 ++++- .../test/t10-adopt-dialog-queue-form.test.tsx | 206 ++++++++++++++++++ .../test/t11-adopt-dialog-add-all.test.tsx | 196 +++++++++++++++++ .../test/t12-settings-hidden-boards.test.tsx | 75 +++++++ .../test/t13-settings-import-preview.test.tsx | 195 +++++++++++++++++ src/renderer/test/t14-app-shell.test.tsx | 72 ++++++ src/renderer/test/t15-relay-pill.test.tsx | 147 +++++++++++++ .../test/t16-board-panel-settings.test.tsx | 144 ++++++++++++ .../test/t9-cockpit-new-on-bus.test.tsx | 78 +++++++ 9 files changed, 1171 insertions(+), 1 deletion(-) create mode 100644 src/renderer/test/t10-adopt-dialog-queue-form.test.tsx create mode 100644 src/renderer/test/t11-adopt-dialog-add-all.test.tsx create mode 100644 src/renderer/test/t12-settings-hidden-boards.test.tsx create mode 100644 src/renderer/test/t13-settings-import-preview.test.tsx create mode 100644 src/renderer/test/t14-app-shell.test.tsx create mode 100644 src/renderer/test/t15-relay-pill.test.tsx create mode 100644 src/renderer/test/t16-board-panel-settings.test.tsx create mode 100644 src/renderer/test/t9-cockpit-new-on-bus.test.tsx diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts index 071380e..ca7495e 100644 --- a/src/renderer/test/scenarios.ts +++ b/src/renderer/test/scenarios.ts @@ -1,8 +1,22 @@ // Scenario builders for component tests: main-side state at today's push level (skyhawk-mock.ts). // One place for them, on top of the shared node fixtures, so every component test describes the // world the same way. Each builder states its defaults; a test passes whatever differs. +import { emptyCache } from '@shared/cal-cache' +import { + appSettings, + buildConfigFile, + diffImport, + parseConfigFile, + type ConfigSnapshot +} from '@shared/config-file' import { gatewayStatus, type GatewayStatus } from '@shared/gateway' -import type { DeviceState, DeviceStatus } from '@shared/ipc' +import { + DEFAULT_CONFIG, + type DeviceState, + type DeviceStatus, + type ImportPreview +} from '@shared/ipc' +import type { NodeDefaults } from '@shared/node-mapping' import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' import { NODE_KIND, type NodeStatus } from '@shared/nodes' import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' @@ -113,3 +127,46 @@ export const model = ( outages: PduOutage[] = [], over: Partial = {} ): NodeModel => ({ ...nodeModel(nodes, outages), ...over }) + +// ── config import (`openConfigImport`'s answer) ───────────────────────────── + +/** This computer's setup as main snapshots it: the default config, no mapping, no calibration. */ +export const setup = (over: Partial = {}): ConfigSnapshot => ({ + app: appSettings(DEFAULT_CONFIG), + nodeMapping: { version: 3, nodes: {} }, + powerSupply: DEFAULT_CONFIG.psu, + calibration: emptyCache(), + ...over +}) + +/** An exported file's text holding `sections` as written (not sanitized: what's on disk). */ +export const configFileText = ( + sections: Record, + exportedAt = '2026-10-12T21:02:00.000Z', + client = '0.2.0' +): string => JSON.stringify({ ...buildConfigFile(setup(), exportedAt, client), sections }, null, 2) + +/** + * The preview main answers `openConfigImport` with for `text` against `current`, built the way + * main's ConfigTransfer.preview builds it (parseConfigFile + diffImport); the token is fixed. + */ +export function importPreview( + text: string, + current: ConfigSnapshot = setup(), + defaults: Map = new Map(), + fileName = 'skyhawk-config.json' +): ImportPreview { + const parsed = parseConfigFile(text) + if (!parsed.ok) throw new Error(`importPreview: main would refuse this file: ${parsed.error}`) + const { config } = parsed + const sections = diffImport(defaults, current, config.sections) + return { + token: 'import-token-1', + fileName, + ...(config.exportedAt ? { exportedAt: config.exportedAt } : {}), + ...(config.client ? { client: config.client } : {}), + sections, + skipped: config.skipped, + identical: sections.every((s) => s.rows.length === 0) + } +} diff --git a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx new file mode 100644 index 0000000..280ff2e --- /dev/null +++ b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx @@ -0,0 +1,206 @@ +// T10 — the Adopt dialog: its queue, the badges, and the form for each kind of new board. +import { describe, expect, it, vi } from 'vitest' +import { screen, within } from '@testing-library/react' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario, settle } from './render' +import { connected, model, newBoard, pdu } from './scenarios' + +const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + +/** + * PDU_Left (#3) is in your cockpit. On the bus, not added yet, by when this session first saw them: + * Left_Oxygen (#8, NODE_IDS.md, a panel group fed by PDU_Left) 2 min ago; an unlisted panel group + * (#12) and an unlisted PDU (#41) 1 min ago; one still identifying (#9), a second Gateway (#10) and + * an unknown board type (#13) just now. + */ +const scene = () => + model([ + pdu(3, 'PDU_Left', { console: 'Left' }), + newBoard(13, T0, { identified: 'other' }), + newBoard(41, T0 - 60_000, { kind: 'pdu', identified: 'pdu' }), + newBoard(9, T0), + newBoard(12, T0 - 60_000, { identified: 'panel' }), + newBoard(10, T0, { identified: 'bridge' }), + newBoard(8, T0 - 120_000, { + name: 'Left_Oxygen', + identified: 'panel', + registry: { name: 'Left_Oxygen', console: 'Left', pduId: 3 } + }) + ]) + +/** The Cockpit with the Adopt dialog opened from "New on the bus · N". */ +async function openAdopt() { + vi.useFakeTimers({ now: T0 }) + const r = await renderScenario(, { device: connected(), model: scene() }) + await r.user.click(screen.getByRole('button', { name: 'Adopt…' })) + const dlg = screen.getByRole('dialog', { name: 'Adopt new devices' }) + const queue = within(dlg).getByRole('listbox', { name: 'New NODE_IDs' }) + const options = () => + within(queue) + .getAllByRole('option') + .map((o) => o.textContent) + const selected = () => within(queue).getByRole('option', { selected: true }) + const pick = (id: number) => + r.user.click(within(queue).getByRole('option', { name: new RegExp(`^NODE_ID ${id}(?!\\d)`) })) + const button = (name: string) => within(dlg).getByRole('button', { name }) + return { ...r, dlg, options, selected, pick, button } +} + +describe('T10 Adopt dialog: queue and form (B74, B21, B22)', () => { + it('lists every new board, oldest first seen first, then by NODE_ID, with its badge', async () => { + const { dlg, options } = await openAdopt() + expect(options()).toEqual([ + 'NODE_ID 8PANEL GROUPLeft_Oxygen · in NODE_IDS.md', + 'NODE_ID 12PANEL GROUPfirst seen 1 min ago', + 'NODE_ID 41PDUfirst seen 1 min ago', + 'NODE_ID 9IDENTIFYINGfirst seen just now', + 'NODE_ID 10BRIDGEfirst seen just now', + 'NODE_ID 13UNKNOWN TYPEfirst seen just now' + ]) + expect( + within(dlg).getByText( + /^6 boards this computer hasn’t added yet\. Boards in NODE_IDS\.md come pre-filled/ + ) + ).toBeInTheDocument() + }) + + it('a registry board comes pre-filled from NODE_IDS.md and is one click to add', async () => { + const { dlg, sky, user, selected, button } = await openAdopt() + expect(selected()).toHaveTextContent('NODE_ID 8') + expect( + within(dlg).getByText( + 'Pre-filled from NODE_IDS.md. Change anything before adding — it’s saved on this computer.' + ) + ).toBeInTheDocument() + expect(within(dlg).getByLabelText('Name')).toHaveValue('Left_Oxygen') + expect(within(dlg).getByLabelText('Console')).toHaveDisplayValue('Left console') + expect(within(dlg).getByLabelText('Powered by')).toHaveDisplayValue('PDU_Left') + expect(within(dlg).getByText('1 of 6')).toBeInTheDocument() + await user.click(button('Add to cockpit')) + await settle() + expect(sky.adoptNode).toHaveBeenCalledWith(8, { + name: 'Left_Oxygen', + console: 'Left', + pduId: 3, + kind: 'panel' + }) + }) + + it('after an add the selection moves to the next board, and the added one leaves the queue', async () => { + const { user, options, selected, button } = await openAdopt() + await user.click(button('Add to cockpit')) + await settle() + expect(selected()).toHaveTextContent('NODE_ID 12') + expect(options()).toHaveLength(5) + expect(options()[0]).toMatch(/^NODE_ID 12/) + }) + + it('an unlisted panel group starts blank; Adopt waits for name, console and Powered by', async () => { + const { dlg, sky, user, pick, button } = await openAdopt() + await pick(12) + const adopt = button('Adopt') + const name = within(dlg).getByLabelText('Name') + const con = within(dlg).getByLabelText('Console') + const zone = within(dlg).getByLabelText('Powered by') + expect(name).toHaveValue('') + expect(zone).toBeDisabled() + expect(adopt).toBeDisabled() + expect( + within(dlg).getByText( + 'Saved on this computer. To make it permanent for everyone, add NODE_ID 12 to NODE_IDS.md.' + ) + ).toBeInTheDocument() + + await user.type(name, 'Left_Lights') + expect(adopt).toBeDisabled() + await user.selectOptions(con, 'Left') + expect(zone).toBeEnabled() + expect( + within(zone) + .getAllByRole('option') + .map((o) => o.textContent) + ).toEqual(['Choose…', 'None', 'PDU_Left']) + expect(adopt).toBeDisabled() + await user.selectOptions(zone, 'None') + expect(adopt).toBeEnabled() + await user.click(adopt) + await settle() + expect(sky.adoptNode).toHaveBeenCalledWith(12, { + name: 'Left_Lights', + console: 'Left', + pduId: null, + kind: 'panel' + }) + }) + + it('a PDU asks for a name and a console only — no Powered by', async () => { + const { dlg, sky, user, pick, button } = await openAdopt() + await pick(41) + expect(within(dlg).queryByLabelText('Powered by')).toBeNull() + await user.type(within(dlg).getByLabelText('Name'), 'PDU_Aft') + expect(button('Adopt')).toBeDisabled() + await user.selectOptions(within(dlg).getByLabelText('Console'), 'Right') + await user.click(button('Adopt')) + await settle() + expect(sky.adoptNode).toHaveBeenCalledWith(41, { + name: 'PDU_Aft', + console: 'Right', + pduId: null, + kind: 'pdu' + }) + }) + + it('a board still identifying shows the note, not a form', async () => { + const { dlg, pick, button } = await openAdopt() + await pick(9) + expect( + within(dlg).getByText( + 'Identifying… waiting for the board to report its type. If it doesn’t within a few seconds, it shows in Attention as an issue.' + ) + ).toHaveAttribute('role', 'status') + expect(within(dlg).queryByLabelText('Name')).toBeNull() + expect(button('Adopt')).toBeDisabled() + }) + + it('a second Gateway and an unknown board type show why they can’t be adopted', async () => { + const { dlg, pick, button } = await openAdopt() + await pick(10) + expect( + within(dlg).getByText( + 'It reports as a Gateway. A cockpit has one, on NODE_ID 0, so this one can’t be adopted. It stays in Attention as an issue while it’s on the bus.' + ) + ).toBeInTheDocument() + expect(within(dlg).queryByLabelText('Name')).toBeNull() + expect(button('Adopt')).toBeDisabled() + + await pick(13) + expect( + within(dlg).getByText( + 'It reports a board type this client doesn’t know (0), so it can’t be adopted. A newer client may know it. It stays in Attention as an issue while it’s on the bus.' + ) + ).toBeInTheDocument() + expect(within(dlg).queryByLabelText('Name')).toBeNull() + }) + + it('Later keeps a board queued and moves on; Later on the last one closes the dialog', async () => { + const { sky, user, pick, selected, options, button } = await openAdopt() + await pick(9) + await user.click(button('Later')) + expect(selected()).toHaveTextContent('NODE_ID 10') + expect(options()).toHaveLength(6) + await user.click(button('Later')) + expect(selected()).toHaveTextContent('NODE_ID 13') + await user.click(button('Later')) + expect(screen.queryByRole('dialog')).toBeNull() + expect(sky.adoptNode).not.toHaveBeenCalled() + }) + + it('a refused adopt shows main’s error and keeps the board selected', async () => { + const { dlg, sky, user, selected, button } = await openAdopt() + sky.adoptNode.mockResolvedValueOnce({ ok: false, error: 'It reports as a PDU' }) + await user.click(button('Add to cockpit')) + await settle() + expect(within(dlg).getByText('It reports as a PDU')).toBeInTheDocument() + expect(selected()).toHaveTextContent('NODE_ID 8') + }) +}) diff --git a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx new file mode 100644 index 0000000..2d61172 --- /dev/null +++ b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx @@ -0,0 +1,196 @@ +// T11 — the Adopt dialog: "Add all", the Powered by choices, and a board power-cycled to identify it. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeModel, NodeModelEntry } from '@shared/node-rules' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario, settle } from './render' +import { connected, model, newBoard, pdu } from './scenarios' + +const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + +/** A NODE_IDS.md board on the bus, not added yet, identified as `kind`. */ +const waiting = ( + nodeId: number, + name: string, + console: string, + pduId: number | null, + over: Partial = {} +) => + newBoard(nodeId, T0 + nodeId, { + name, + identified: 'panel', + registry: { name, console, pduId }, + ...over + }) + +/** PDU_Left (#3) and PDU_Right (#4) are in your cockpit; PDU_Right_Aft (#5) is not. */ +const PDU_LEFT = pdu(3, 'PDU_Left', { console: 'Left' }) +const PDU_RIGHT = pdu(4, 'PDU_Right', { console: 'Right' }) +const NAV = waiting(2, 'Right_Navigation', 'Right', 4) +const TACAN = waiting(7, 'Right_TACAN', 'Right', 5, { + registry: { name: 'Right_TACAN', console: 'Right', pduId: 5, pduName: 'PDU_Right_Aft' } +}) +const PDU_CENTER = waiting(6, 'PDU_Center', 'Center', null, { kind: 'pdu', identified: 'pdu' }) + +/** The Cockpit, the Adopt dialog open from "New on the bus · N". */ +async function openAdopt(m: NodeModel) { + const r = await renderScenario(, { device: connected(), model: m }) + await r.user.click(screen.getByRole('button', { name: 'Adopt…' })) + const dlg = screen.getByRole('dialog', { name: 'Adopt new devices' }) + const queue = within(dlg).getByRole('listbox', { name: 'New NODE_IDs' }) + const pick = (id: number) => + r.user.click(within(queue).getByRole('option', { name: new RegExp(`^NODE_ID ${id}(?!\\d)`) })) + const options = () => within(queue).getAllByRole('option') + const zoneChoices = () => + within(within(dlg).getByLabelText('Powered by')) + .getAllByRole('option') + .map((o) => o.textContent) + const addAll = async (n: number) => { + await r.user.click( + within(dlg).getByRole('button', { name: `Add all ${n} panel groups from NODE_IDS.md` }) + ) + await settle() + } + /** The identify row: its dot's symbol (+ reporting, ✕ dropped, none = back) and its text. */ + const ident = () => + within(dlg) + .getByText(/^Not sure which board this is\?/) + .closest('[role]') + return { ...r, dlg, pick, options, zoneChoices, addAll, ident } +} + +describe('T11 Adopt dialog: Add all, PDU choices, power-cycle (B74, F15, F24, X3)', () => { + it('Add all takes the NODE_IDS.md panel groups only; a PDU is added on its own', async () => { + const { sky, addAll, options } = await openAdopt( + model([PDU_LEFT, PDU_RIGHT, NAV, TACAN, PDU_CENTER]) + ) + await addAll(2) + expect(sky.adoptNode).toHaveBeenCalledTimes(2) + expect(sky.adoptNode.mock.calls.map(([id]) => id)).toEqual([2, 7]) + expect(options().map((o) => o.textContent)).toEqual(['NODE_ID 6PDUPDU_Center · in NODE_IDS.md']) + }) + + it('an untouched form keeps its default PDU only when that PDU is in your cockpit, else None', async () => { + const { sky, addAll } = await openAdopt(model([PDU_LEFT, PDU_RIGHT, NAV, TACAN])) + await addAll(2) + expect(sky.adoptNode).toHaveBeenCalledWith(2, { + name: 'Right_Navigation', + console: 'Right', + pduId: 4, + kind: 'panel' + }) + expect(sky.adoptNode).toHaveBeenCalledWith(7, { + name: 'Right_TACAN', + console: 'Right', + pduId: null, + kind: 'panel' + }) + }) + + it('an edited form is taken as written: None stays None', async () => { + const { dlg, sky, user, addAll } = await openAdopt(model([PDU_LEFT, PDU_RIGHT, NAV, TACAN])) + // Right_Navigation (first in the queue) is selected: pick None for it. + await user.selectOptions(within(dlg).getByLabelText('Powered by'), 'None') + await addAll(2) + expect(sky.adoptNode).toHaveBeenCalledWith(2, { + name: 'Right_Navigation', + console: 'Right', + pduId: null, + kind: 'panel' + }) + }) + + it('an edited form keeps a PDU that isn’t in your cockpit (D7 today)', async () => { + // contract D7 / F24: zones go only to PDUs in your cockpit, so an out-of-cockpit default should + // become None; today an edited form keeps the "(not added)" PDU and Add all sends it as is. + const { dlg, sky, user, pick, addAll } = await openAdopt( + model([PDU_LEFT, PDU_RIGHT, NAV, TACAN]) + ) + await pick(7) + const name = within(dlg).getByLabelText('Name') + await user.clear(name) + await user.type(name, 'Right_TACAN_2') + await addAll(2) + expect(sky.adoptNode).toHaveBeenCalledWith(7, { + name: 'Right_TACAN_2', + console: 'Right', + pduId: 5, + kind: 'panel' + }) + }) + + it('Powered by: the console’s PDUs, "(other console)" and "(not added)" (D7 today)', async () => { + // contract D7: only PDUs in your cockpit are offered; today a default PDU that isn't added yet + // is offered as "(not added)", and the form says the board moves under it once it's added. + const RADAR = waiting(8, 'Right_Radar', 'Right', 3) + const { dlg, pick, zoneChoices } = await openAdopt( + model([PDU_LEFT, PDU_RIGHT, NAV, TACAN, RADAR]) + ) + await pick(8) + expect(within(dlg).getByLabelText('Powered by')).toHaveDisplayValue('PDU_Left (other console)') + expect(zoneChoices()).toEqual(['Choose…', 'None', 'PDU_Right', 'PDU_Left (other console)']) + + await pick(7) + expect(within(dlg).getByLabelText('Powered by')).toHaveDisplayValue('PDU_Right_Aft (not added)') + expect(zoneChoices()).toEqual(['Choose…', 'None', 'PDU_Right', 'PDU_Right_Aft (not added)']) + expect( + within(dlg).getByText( + 'PDU_Right_Aft isn’t in your cockpit yet. Keep it and this board moves under it once it’s added, or pick None.' + ) + ).toBeInTheDocument() + }) + + it('a hidden PDU in your cockpit reads "(not added)", and Add all drops its link (#95 bug 2 today)', async () => { + // contract X3 / F15: a hidden board is still in your cockpit, so its boards should keep the + // link; today the dialog sees only the boards on the plan, so a hidden PDU is "(not added)" + // and Add all sends None for it. + const nav = waiting(2, 'Right_Navigation', 'Right', 4, { + registry: { name: 'Right_Navigation', console: 'Right', pduId: 4, pduName: 'PDU_Right' } + }) + const radio = waiting(9, 'Right_Radio', 'Right', 4, { + registry: { name: 'Right_Radio', console: 'Right', pduId: 4, pduName: 'PDU_Right' } + }) + const { dlg, sky, zoneChoices, addAll } = await openAdopt( + model([PDU_LEFT, nav, radio], [], { + hidden: [4], + hiddenNodes: [{ nodeId: 4, name: 'PDU_Right', console: 'Right' }] + }) + ) + expect(within(dlg).getByLabelText('Powered by')).toHaveDisplayValue('PDU_Right (not added)') + expect(zoneChoices()).toEqual(['Choose…', 'None', 'PDU_Right (not added)']) + await addAll(2) + expect(sky.adoptNode).toHaveBeenCalledWith(2, { + name: 'Right_Navigation', + console: 'Right', + pduId: null, + kind: 'panel' + }) + expect(sky.adoptNode).toHaveBeenCalledWith(9, expect.objectContaining({ pduId: null })) + }) + + it('a board power-cycled while the dialog is open stays listed, red, keeping its form; green when back', async () => { + const { dlg, sky, user, options, ident } = await openAdopt( + model([PDU_LEFT, PDU_RIGHT, NAV, TACAN]) + ) + const name = within(dlg).getByLabelText('Name') + await user.clear(name) + await user.type(name, 'Nav_Test') + // Reporting: the identify dot is blue (+). + expect(ident()).toHaveTextContent(/^\+Not sure which board this is\?/) + expect(within(dlg).getByText(/^status only · up /)).toBeInTheDocument() + + // It drops off the bus: a registry board not added yet leaves the model. + sky.emitScenario({ model: model([PDU_LEFT, PDU_RIGHT, TACAN]) }) + expect(options()).toHaveLength(2) + expect(options()[0]).toHaveTextContent(/^NODE_ID 2(?!\d)/) + expect(options()[0]).toHaveAttribute('aria-selected', 'true') + expect(ident()).toHaveTextContent(/^✕Not sure which board this is\?/) + expect(within(dlg).getByText('not reporting right now')).toBeInTheDocument() + expect(within(dlg).getByLabelText('Name')).toHaveValue('Nav_Test') + + // Back on the bus: green (no symbol), the typed name still there. + sky.emitScenario({ model: model([PDU_LEFT, PDU_RIGHT, NAV, TACAN]) }) + expect(ident()).toHaveTextContent(/^Not sure which board this is\?/) + expect(within(dlg).getByLabelText('Name')).toHaveValue('Nav_Test') + }) +}) diff --git a/src/renderer/test/t12-settings-hidden-boards.test.tsx b/src/renderer/test/t12-settings-hidden-boards.test.tsx new file mode 100644 index 0000000..420e1b9 --- /dev/null +++ b/src/renderer/test/t12-settings-hidden-boards.test.tsx @@ -0,0 +1,75 @@ +// T12 — Settings › Cockpit › Hidden boards. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import type { NodeModel } from '@shared/node-rules' +import { Settings } from '../src/tabs/Settings' +import { renderScenario, settle } from './render' +import { connected, model, panel } from './scenarios' + +const EMPTY = 'No hidden boards. Hide in an offline board’s Cockpit panel lists it here.' + +/** Right_Radar (#8) and an adopted board placed on no console (#30, console '') are hidden. */ +const twoHidden = () => + model([panel(1, 'Center_Armament')], [], { + hidden: [8, 30], + hiddenNodes: [ + { nodeId: 8, name: 'Right_Radar', console: 'Right' }, + { nodeId: 30, name: 'Left_Spare', console: '' } + ] + }) + +/** Settings, on its Cockpit section. */ +async function openCockpitSection(m: NodeModel) { + const r = await renderScenario(, { device: connected(), model: m }) + await r.user.click(screen.getByRole('button', { name: /^Cockpit/ })) + await settle() + const unhideAll = () => screen.getByRole('button', { name: 'Un-hide all' }) + const unhide = () => screen.queryAllByRole('button', { name: 'Un-hide' }) + return { ...r, unhideAll, unhide } +} + +/** A Hidden boards row's text, from its "#id". */ +const row = (id: number) => screen.getByText(`#${id}`).parentElement + +describe('T12 Settings › Cockpit › Hidden boards (B88)', () => { + it('lists each hidden board: #id, the name and console the Cockpit showed', async () => { + const { unhide, unhideAll } = await openCockpitSection(twoHidden()) + expect(row(8)).toHaveTextContent('#8Right_RadarRight console · returns when it reports again') + expect(row(30)).toHaveTextContent('#30Left_Sparereturns when it reports again') + expect(unhide()).toHaveLength(2) + expect(unhideAll()).toBeEnabled() + expect(screen.queryByText(EMPTY)).toBeNull() + }) + + it('Un-hide asks main to show that board again', async () => { + const { sky, user, unhide } = await openCockpitSection(twoHidden()) + await user.click(unhide()[0]!) + expect(sky.hideNode).toHaveBeenCalledTimes(1) + expect(sky.hideNode).toHaveBeenCalledWith(8, false) + }) + + it('Un-hide all un-hides every one', async () => { + const { sky, user, unhideAll } = await openCockpitSection(twoHidden()) + await user.click(unhideAll()) + await settle() + expect(sky.hideNode.mock.calls).toEqual([ + [8, false], + [30, false] + ]) + }) + + it('a refused un-hide shows main’s error', async () => { + const { sky, user, unhide } = await openCockpitSection(twoHidden()) + sky.hideNode.mockResolvedValueOnce({ ok: false, error: 'Node mapping is read-only' }) + await user.click(unhide()[0]!) + await settle() + expect(screen.getByText('Node mapping is read-only')).toBeInTheDocument() + }) + + it('nothing hidden: the empty-state text, and Un-hide all is greyed out', async () => { + const { unhide, unhideAll } = await openCockpitSection(model([panel(1, 'Center_Armament')])) + expect(screen.getByText(EMPTY)).toBeInTheDocument() + expect(unhide()).toHaveLength(0) + expect(unhideAll()).toBeDisabled() + }) +}) diff --git a/src/renderer/test/t13-settings-import-preview.test.tsx b/src/renderer/test/t13-settings-import-preview.test.tsx new file mode 100644 index 0000000..fee80aa --- /dev/null +++ b/src/renderer/test/t13-settings-import-preview.test.tsx @@ -0,0 +1,195 @@ +// T13 — Settings › Configuration › Import: the review of what a file would change, and Apply. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { DEFAULT_CONFIG, type ImportPreview } from '@shared/ipc' +import { appSettings } from '@shared/config-file' +import { Settings } from '../src/tabs/Settings' +import { renderScenario, settle } from './render' +import { configFileText, importPreview, setup } from './scenarios' + +/** Exported 12 Oct 2026, 21:02 local time — what the subtitle shows. */ +const EXPORTED_AT = new Date(2026, 9, 12, 21, 2).toISOString() + +/** A file from another computer: a different DCS host, autoconnect off, a custom 12 V limit. */ +const otherSetup = () => + configFileText( + { + app: appSettings({ ...DEFAULT_CONFIG, host: '10.0.0.2', autoConnect: false }), + powerSupply: { mode: 'custom', preset: 'nx650', custom: { a12: 40 } } + }, + EXPORTED_AT + ) + +/** Settings › Configuration, Import… pressed with main answering `preview`. */ +async function openImport(preview: ImportPreview) { + const r = await renderScenario() + r.sky.openConfigImport.mockResolvedValueOnce({ status: 'ready', preview }) + await r.user.click(screen.getByRole('button', { name: /^Configuration/ })) + await r.user.click(screen.getByRole('button', { name: 'Import…' })) + await settle() + const dlg = screen.getByRole('dialog', { name: 'Import configuration' }) + /** A section's tick box, by its title. */ + const tick = (title: string) => within(dlg).getByRole('checkbox', { name: title }) + /** A review row ("key from → to") by its key. */ + const diffRow = (k: string) => within(dlg).getByText(k).parentElement + return { ...r, dlg, tick, diffRow } +} + +describe('T13 Settings › Configuration › Import preview (B90, B94, F1)', () => { + it('lists each section’s changes as "key from → to", every changing section picked', async () => { + const { dlg, tick, diffRow } = await openImport(importPreview(otherSetup())) + expect( + within(dlg).getByText('skyhawk-config.json · exported 2026-10-12 21:02 · client v0.2.0') + ).toBeInTheDocument() + expect(within(dlg).getByText('App settings').parentElement).toHaveTextContent( + 'App settings2 changes' + ) + expect(diffRow('DCS host')).toHaveTextContent('DCS host127.0.0.1→10.0.0.2') + expect(diffRow('Connect automatically')).toHaveTextContent('Connect automaticallyOn→Off') + expect(diffRow('PSU')).toHaveTextContent('PSUNX650 preset→Custom') + expect(diffRow('Custom 12 V rail limit')).toHaveTextContent('Custom 12 V rail limit—→40 A') + expect(tick('App settings')).toHaveAttribute('aria-checked', 'true') + expect(tick('Power supply')).toHaveAttribute('aria-checked', 'true') + expect(within(dlg).getByRole('checkbox', { name: /backup/ })).toHaveAttribute( + 'aria-checked', + 'true' + ) + expect(within(dlg).getByRole('button', { name: 'Apply 2 sections' })).toBeEnabled() + }) + + it('a section that changes nothing reads "no change" and can’t be picked', async () => { + const text = configFileText( + { + app: appSettings({ ...DEFAULT_CONFIG, host: '10.0.0.2' }), + powerSupply: DEFAULT_CONFIG.psu + }, + EXPORTED_AT + ) + const { dlg, tick } = await openImport(importPreview(text)) + expect(within(dlg).getByText('Power supply').parentElement).toHaveTextContent( + 'Power supplyno change' + ) + expect(tick('Power supply')).toBeDisabled() + expect(within(dlg).getByRole('button', { name: 'Apply 1 section' })).toBeEnabled() + }) + + it('a file that matches this computer: "This file matches your current setup", no Apply', async () => { + const same = configFileText( + { + app: appSettings(DEFAULT_CONFIG), + nodeMapping: { version: 3, nodes: {} }, + powerSupply: DEFAULT_CONFIG.psu + }, + EXPORTED_AT + ) + const { dlg } = await openImport(importPreview(same)) + expect(within(dlg).getByText('This file matches your current setup')).toBeInTheDocument() + expect(within(dlg).getByText('Nothing to apply.')).toBeInTheDocument() + expect(within(dlg).queryByRole('button', { name: /^Apply/ })).toBeNull() + expect(within(dlg).queryByRole('checkbox')).toBeNull() + }) + + it('an empty "nodeMapping": {} previews as a normal section (F1 today)', async () => { + // contract F1: a structurally empty section should be refused and listed as skipped; today it + // reads as an empty mapping, so the review offers to take every adopted board out of your + // cockpit, picked by default. + const current = setup({ + nodeMapping: { + version: 3, + nodes: { 12: { name: 'Left_Lights', console: 'Left', kind: 'panel', pduId: null } } + } + }) + const { dlg, tick, diffRow } = await openImport( + importPreview(configFileText({ nodeMapping: {} }, EXPORTED_AT), current) + ) + expect(within(dlg).getByText('Node mapping').parentElement).toHaveTextContent( + 'Node mapping1 node' + ) + expect(diffRow('#12')).toHaveTextContent('#12Left_Lights · Left · —→not in your cockpit') + expect(tick('Node mapping')).toHaveAttribute('aria-checked', 'true') + expect(within(dlg).queryByText(/^Skipped/)).toBeNull() + expect(within(dlg).getByRole('button', { name: 'Apply 1 section' })).toBeEnabled() + }) + + it('a section this client doesn’t know is listed as skipped', async () => { + const text = configFileText( + { + app: appSettings({ ...DEFAULT_CONFIG, host: '10.0.0.2' }), + panelLayouts: { v: 1 } + }, + EXPORTED_AT + ) + const { dlg } = await openImport(importPreview(text)) + expect( + within(dlg).getByText( + 'Skipped — written by a newer client: panelLayouts. Update this client to import it.' + ) + ).toBeInTheDocument() + }) + + it('Apply is greyed out with nothing picked', async () => { + const { dlg, user, tick } = await openImport(importPreview(otherSetup())) + await user.click(tick('App settings')) + await user.click(tick('Power supply')) + expect(tick('App settings')).toHaveAttribute('aria-checked', 'false') + expect(within(dlg).getByRole('button', { name: 'Apply 0 sections' })).toBeDisabled() + }) + + it('Apply sends the file’s token, the picked sections and the backup choice', async () => { + const { dlg, sky, user, tick } = await openImport(importPreview(otherSetup())) + await user.click(tick('Power supply')) + await user.click(within(dlg).getByRole('checkbox', { name: /backup/ })) + await user.click(within(dlg).getByRole('button', { name: 'Apply 1 section' })) + await settle() + expect(sky.applyConfigImport).toHaveBeenCalledWith('import-token-1', ['app'], false) + }) + + it('applied: the dialog closes and the Import row says what was done', async () => { + const { dlg, sky, user } = await openImport(importPreview(otherSetup())) + sky.applyConfigImport.mockResolvedValueOnce({ + ok: true, + applied: ['app', 'powerSupply'], + backupPath: '/Users/me/Library/backups/skyhawk-config-backup-2026-10-16.json', + config: { ...DEFAULT_CONFIG, host: '10.0.0.2', autoConnect: false } + }) + await user.click(within(dlg).getByRole('button', { name: 'Apply 2 sections' })) + await settle() + expect(sky.applyConfigImport).toHaveBeenCalledWith( + 'import-token-1', + ['app', 'powerSupply'], + true + ) + expect(screen.queryByRole('dialog')).toBeNull() + expect( + screen.getByText('Applied 2 sections · backup saved as skyhawk-config-backup-2026-10-16.json') + ).toBeInTheDocument() + }) + + it('a refused apply shows main’s error in the dialog', async () => { + const { dlg, sky, user } = await openImport(importPreview(otherSetup())) + sky.applyConfigImport.mockResolvedValueOnce({ + ok: false, + error: 'That file is no longer open — import it again.' + }) + await user.click(within(dlg).getByRole('button', { name: 'Apply 2 sections' })) + await settle() + expect( + within(dlg).getByText('That file is no longer open — import it again.') + ).toBeInTheDocument() + }) + + it('a file main refuses: its reason, and "Nothing was changed."', async () => { + const { sky, user } = await renderScenario() + sky.openConfigImport.mockResolvedValueOnce({ + status: 'error', + error: 'This is not an OpenSkyhawk Client configuration file.' + }) + await user.click(screen.getByRole('button', { name: /^Configuration/ })) + await user.click(screen.getByRole('button', { name: 'Import…' })) + await settle() + expect( + screen.getByText('This is not an OpenSkyhawk Client configuration file. Nothing was changed.') + ).toBeInTheDocument() + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) diff --git a/src/renderer/test/t14-app-shell.test.tsx b/src/renderer/test/t14-app-shell.test.tsx new file mode 100644 index 0000000..284f534 --- /dev/null +++ b/src/renderer/test/t14-app-shell.test.tsx @@ -0,0 +1,72 @@ +// T14 — the app shell: title bar, tab bar, and the subscription to main that App starts on mount. +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { App } from '../src/App' +import pkg from '../../../package.json' +import { renderScenario, settle } from './render' +import { skyhawk } from './skyhawk-mock' +import { connected, link } from './scenarios' + +const TABS = ['Overview', 'Connection', 'Cockpit', 'Log', 'Serial', 'HID', 'Settings'] + +/** The tab bar's tabs, by name. */ +const tabs = () => screen.getAllByRole('tab').map((t) => t.textContent) +const selectedTabs = () => + screen + .getAllByRole('tab') + .filter((t) => t.getAttribute('aria-selected') === 'true') + .map((t) => t.textContent) + +describe('T14 App shell (B43)', () => { + it('the title bar: wordmark, the version, the Device indicator and the Relay pill', async () => { + await renderScenario(, { device: connected() }) + expect(screen.getByRole('banner')).toHaveTextContent(/^OPENSKYHAWKCLIENT/) + expect(screen.getByText(`v${pkg.version}`)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Gateway' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Stopped' })).toBeInTheDocument() + }) + + it('seven tabs, Overview selected; a click switches the tab and its page', async () => { + const { user } = await renderScenario(, { device: connected() }) + expect(screen.getByRole('tablist', { name: 'Sections' })).toBeInTheDocument() + expect(tabs()).toEqual(TABS) + expect(selectedTabs()).toEqual(['Overview']) + + await user.click(screen.getByRole('tab', { name: 'Settings' })) + expect(selectedTabs()).toEqual(['Settings']) + expect(screen.getByRole('navigation', { name: 'Settings sections' })).toBeInTheDocument() + + await user.click(screen.getByRole('tab', { name: 'Cockpit' })) + expect(selectedTabs()).toEqual(['Cockpit']) + expect(screen.getByText('Attention')).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Settings sections' })).toBeNull() + }) + + it('"SOURCE " shows the saved source mode', async () => { + await renderScenario(, { config: { sourceMode: 'bridge' } }) + expect(screen.getByText('SOURCE')).toHaveTextContent('SOURCE bridge') + }) + + it('on mount App subscribes to main, takes main’s state, and follows its pushes', async () => { + // Main is already up with the board connected when the window loads; nothing subscribed yet. + const sky = skyhawk() + sky.emitScenario({ device: connected(), config: { sourceMode: 'replay' } }) + expect(sky.on).not.toHaveBeenCalled() + + render() + await settle() + const channels = sky.on.mock.calls.map(([ch]) => ch) + expect(channels).toEqual( + expect.arrayContaining(['device:status', 'relay:state', 'nodes:model', 'events:batch']) + ) + expect(sky.getStatus).toHaveBeenCalled() + // The pulls answered: the pill and the source mode show main's state. + expect(screen.getByRole('button', { name: 'Gateway' })).toBeInTheDocument() + expect(screen.getByText('SOURCE')).toHaveTextContent('SOURCE replay') + + // A `device:status` push changes the pill. + sky.emitScenario({ device: link('reconnecting') }) + expect(screen.getByRole('button', { name: 'Reconnecting' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Gateway' })).toBeNull() + }) +}) diff --git a/src/renderer/test/t15-relay-pill.test.tsx b/src/renderer/test/t15-relay-pill.test.tsx new file mode 100644 index 0000000..16f7dbe --- /dev/null +++ b/src/renderer/test/t15-relay-pill.test.tsx @@ -0,0 +1,147 @@ +// T15 — the title-bar Relay pill: the one on/off for the DCS-BIOS stream. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { RelayPill } from '../src/RelayPill' +import { renderScenario, settle } from './render' +import { connected, link } from './scenarios' + +/** The pill: the only button until the blocked callout opens. */ +const pill = () => screen.getByRole('button', { name: /^!?(Stopped|Relaying)$/ }) + +describe('T15 RelayPill (B45, B47)', () => { + it('stopped: "Stopped", and a click asks main to start', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + expect(pill()).toHaveTextContent(/^Stopped$/) + expect(pill()).toHaveAttribute('title', 'DCS-BIOS stream stopped — click to start') + expect(pill()).toHaveAttribute('aria-disabled', 'false') + await user.click(pill()) + await settle() + // Settings are saved first, then the relay starts. + expect(sky.setConfig).toHaveBeenCalledTimes(1) + expect(sky.startRelay).toHaveBeenCalledTimes(1) + expect(sky.setConfig.mock.invocationCallOrder[0]!).toBeLessThan( + sky.startRelay.mock.invocationCallOrder[0]! + ) + // It reads Relaying at once, before main's relay:state arrives. + expect(pill()).toHaveTextContent(/^Relaying$/) + }) + + it('relaying and delivering: "Relaying"; a click asks main to stop', async () => { + const { sky, user } = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' } + }) + expect(pill()).toHaveTextContent(/^Relaying$/) + expect(pill()).toHaveAttribute( + 'title', + 'DCS-BIOS stream · connected · 127.0.0.1:7778 — click to stop' + ) + await user.click(pill()) + expect(sky.stopRelay).toHaveBeenCalledTimes(1) + expect(pill()).toHaveTextContent(/^Stopped$/) + }) + + it('relaying while the stream is still connecting: it says what it waits for', async () => { + const { sky } = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connecting' } + }) + expect(pill()).toHaveTextContent(/^Relaying$/) + expect(pill()).toHaveAttribute( + 'title', + 'Relaying — waiting for DCS-BIOS (connecting) · 127.0.0.1:7778 — click to stop' + ) + // The stream connects: the wait is over. + sky.emitScenario({ relay: { running: true, stream: 'connected' } }) + expect(pill()).toHaveAttribute( + 'title', + 'DCS-BIOS stream · connected · 127.0.0.1:7778 — click to stop' + ) + }) + + it('Bridge relaying while the board is gone: waiting for the Gateway', async () => { + await renderScenario(, { + config: { sourceMode: 'bridge' }, + device: link('reconnecting'), + relay: { running: true, stream: 'connected' } + }) + expect(pill()).toHaveTextContent(/^Relaying$/) + expect(pill()).toHaveAttribute( + 'title', + 'Relaying — waiting for the Gateway (device link reconnecting). Resumes when it connects — click to stop' + ) + }) + + it('Bridge without a board: blocked with "!", and a click explains instead of starting', async () => { + const { sky, user } = await renderScenario(, { + config: { sourceMode: 'bridge' }, + device: link('no-device') + }) + expect(pill()).toHaveTextContent(/^!Stopped$/) + expect(pill()).toHaveAttribute('aria-disabled', 'true') + expect(pill()).toHaveAttribute('title', 'Connect the Gateway to start the relay.') + expect(screen.queryByRole('alertdialog')).toBeNull() + + await user.click(pill()) + const callout = screen.getByRole('alertdialog', { name: "Can't start the relay" }) + expect( + within(callout).getByText( + "The Gateway isn't connected, and the relay delivers to it. Connect first." + ) + ).toBeInTheDocument() + expect(sky.startRelay).not.toHaveBeenCalled() + + // Connect only connects: Start stays a second step. + await user.click(within(callout).getByRole('button', { name: 'Connect' })) + expect(sky.connectDevice).toHaveBeenCalledTimes(1) + expect(sky.startRelay).not.toHaveBeenCalled() + expect(screen.queryByRole('alertdialog')).toBeNull() + }) + + it('the board arriving lifts the block and closes the callout', async () => { + const { sky, user } = await renderScenario(, { + config: { sourceMode: 'bridge' }, + device: link('no-device') + }) + await user.click(pill()) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + sky.emitScenario({ device: connected() }) + expect(screen.queryByRole('alertdialog')).toBeNull() + expect(pill()).toHaveTextContent(/^Stopped$/) + expect(pill()).toHaveAttribute('aria-disabled', 'false') + }) + + it('Esc closes the callout and focus returns to the pill', async () => { + const { user } = await renderScenario(, { + config: { sourceMode: 'bridge' }, + device: link('no-device') + }) + await user.click(pill()) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + await user.keyboard('{Escape}') + expect(screen.queryByRole('alertdialog')).toBeNull() + expect(pill()).toHaveFocus() + }) + + it('Monitor starts without a board', async () => { + const { sky, user } = await renderScenario(, { device: link('no-device') }) + expect(pill()).toHaveAttribute('aria-disabled', 'false') + await user.click(pill()) + await settle() + expect(sky.startRelay).toHaveBeenCalledTimes(1) + }) + + it('when main refuses Start, the pill goes back to Stopped', async () => { + // Replay with nothing loaded: the pill lets Start through, main refuses it. + const { sky, user } = await renderScenario(, { + config: { sourceMode: 'replay' }, + device: connected() + }) + expect(pill()).toHaveAttribute('title', 'Replay stopped — click to play') + sky.startRelay.mockResolvedValueOnce({ ok: false, error: 'No capture loaded' }) + await user.click(pill()) + await settle() + expect(sky.startRelay).toHaveBeenCalledTimes(1) + expect(pill()).toHaveTextContent(/^Stopped$/) + }) +}) diff --git a/src/renderer/test/t16-board-panel-settings.test.tsx b/src/renderer/test/t16-board-panel-settings.test.tsx new file mode 100644 index 0000000..21bb279 --- /dev/null +++ b/src/renderer/test/t16-board-panel-settings.test.tsx @@ -0,0 +1,144 @@ +// T16 — the board panel's Settings tab: display name, console and power zone, and what Save sends. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeRegistryInfo } from '@shared/ipc' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario, settle } from './render' +import { connected, model, newBoard, panel, pdu } from './scenarios' + +/** + * In your cockpit: PDU_Left (#3, Left), PDU_Right (#4) and PDU_Right_Aft (#5) on the Right, + * PDU_Center (#6). Right_Navigation (#2) is fed by PDU_Right; Right_Radar (#8) by PDU_Left, on + * another console. A PDU on the bus not added yet (#41) is on the Right too. + */ +const scene = () => + model([ + pdu(3, 'PDU_Left', { console: 'Left' }), + pdu(4, 'PDU_Right', { console: 'Right' }), + pdu(5, 'PDU_Right_Aft', { console: 'Right' }), + pdu(6, 'PDU_Center', { console: 'Center' }), + panel(2, 'Right_Navigation', { pduId: 4 }), + panel(8, 'Right_Radar', { pduId: 3 }), + newBoard(41, 0, { name: 'PDU_Right_Fwd', kind: 'pdu', identified: 'pdu', console: 'Right' }) + ]) + +const registry: NodeRegistryInfo = { + file: 'Firmware/NODE_IDS.md', + commit: 'abc1234', + date: '2026-10-01', + nodes: [ + { nodeId: 2, name: 'Right_Navigation', console: 'Right', kind: 'panel' }, + { nodeId: 4, name: 'PDU_Right', console: 'Right', kind: 'pdu' } + ] +} + +/** The Cockpit with a board's panel open on its Settings tab. */ +async function openSettings(open: () => HTMLElement, sheetName: string, m = scene()) { + const r = await renderScenario(, { device: connected(), model: m, registry }) + await r.user.click(open()) + const sheet = screen.getByRole('dialog', { name: sheetName }) + await r.user.click(within(sheet).getByRole('tab', { name: 'Settings' })) + await settle() + const field = (label: string) => within(sheet).queryByLabelText(label) + const choices = (label: string) => + within(field(label)!) + .getAllByRole('option') + .map((o) => o.textContent) + const save = async () => { + await r.user.click(within(sheet).getByRole('button', { name: 'Save' })) + await settle() + } + return { ...r, sheet, field, choices, save } +} + +const panelGroup = (id: number, name: string, m = scene()) => + openSettings(() => screen.getByTitle(new RegExp(`^#${id} ${name}`)), `Panel group ${name}`, m) + +describe('T16 Board panel Settings tab (B72, X2)', () => { + it('the display name, where its defaults come from, and the placement', async () => { + const { sheet, field } = await panelGroup(2, 'Right_Navigation') + expect(field('Display name')).toHaveValue('Right_Navigation') + expect( + within(sheet).getByText('Registry: Right_Navigation, Right console (NODE_IDS.md).') + ).toBeInTheDocument() + expect(field('Console')).toHaveDisplayValue('Right console') + expect(field('Power zone')).toHaveDisplayValue('PDU_Right') + }) + + it('Power zone offers the console’s PDUs in your cockpit and None — not a new PDU', async () => { + const { choices } = await panelGroup(2, 'Right_Navigation') + expect(choices('Power zone')).toEqual(['None', 'PDU_Right', 'PDU_Right_Aft']) + }) + + it('a zone on another console is listed "(other console)" until changed', async () => { + const { field, choices } = await panelGroup(8, 'Right_Radar') + expect(field('Power zone')).toHaveDisplayValue('PDU_Left (other console)') + expect(choices('Power zone')).toEqual([ + 'None', + 'PDU_Right', + 'PDU_Right_Aft', + 'PDU_Left (other console)' + ]) + }) + + it('picking a console picks its first PDU', async () => { + const { user, field, choices } = await panelGroup(2, 'Right_Navigation') + await user.selectOptions(field('Console')!, 'Left') + expect(field('Power zone')).toHaveDisplayValue('PDU_Left') + expect(choices('Power zone')).toEqual(['None', 'PDU_Left']) + await user.selectOptions(field('Console')!, 'Center') + expect(field('Power zone')).toHaveDisplayValue('PDU_Center') + }) + + it('Save sends only what changed, and the panel closes', async () => { + const { sky, user, field, save } = await panelGroup(2, 'Right_Navigation') + await user.selectOptions(field('Console')!, 'Left') + await save() + expect(sky.setNodeMapping).toHaveBeenCalledWith(2, { console: 'Left', pduId: 3 }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('a new name alone is all Save sends', async () => { + const { sky, user, field, save } = await panelGroup(2, 'Right_Navigation') + await user.clear(field('Display name')!) + await user.type(field('Display name')!, ' Nav ') + await save() + expect(sky.setNodeMapping).toHaveBeenCalledWith(2, { name: 'Nav' }) + }) + + it('Save with no change just closes', async () => { + const { sky, save } = await panelGroup(2, 'Right_Navigation') + await save() + expect(sky.setNodeMapping).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('an empty name: main refuses it, the error shows inline and the panel stays open', async () => { + // contract B72: an empty name gives an inline error; today the panel has no check of its own — + // it sends the empty name and shows main's refusal. + const { sky, sheet, user, field, save } = await panelGroup(2, 'Right_Navigation') + sky.setNodeMapping.mockResolvedValueOnce({ ok: false, error: 'Name cannot be empty' }) + await user.clear(field('Display name')!) + await save() + expect(sky.setNodeMapping).toHaveBeenCalledWith(2, { name: '' }) + expect(within(sheet).getByText('Name cannot be empty')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Panel group Right_Navigation' })).toBeInTheDocument() + }) + + it('a PDU renames and moves console, and never takes a power zone', async () => { + const { sky, sheet, user, field, save } = await openSettings( + () => screen.getByTitle('PDU_Right — power zone details'), + 'PDU PDU_Right' + ) + expect(field('Power zone')).toBeNull() + expect(field('Powered by')).toBeNull() + expect( + within(sheet).getByText( + 'A PDU has no power zone. Moving it to another console moves its group, and the boards it powers, with it on the plan.' + ) + ).toBeInTheDocument() + await user.selectOptions(field('Console')!, 'Left') + await save() + expect(sky.setNodeMapping).toHaveBeenCalledWith(4, { console: 'Left' }) + }) +}) diff --git a/src/renderer/test/t9-cockpit-new-on-bus.test.tsx b/src/renderer/test/t9-cockpit-new-on-bus.test.tsx new file mode 100644 index 0000000..b8c0c58 --- /dev/null +++ b/src/renderer/test/t9-cockpit-new-on-bus.test.tsx @@ -0,0 +1,78 @@ +// T9 — Cockpit › Attention: the "New on the bus · N / Adopt…" call to action. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { connected, link, model, newBoard, panel } from './scenarios' + +const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + +/** Right_Navigation from NODE_IDS.md, on the bus and reporting as a panel group, not added yet. */ +const registryPanel = () => + newBoard(2, T0, { + name: 'Right_Navigation', + identified: 'panel', + registry: { name: 'Right_Navigation', console: 'Right', pduId: null } + }) + +/** The CTA's line, or null when it isn't shown. */ +const cta = () => screen.queryByText(/^New on the bus · \d+$/) +const adoptButton = () => screen.queryByRole('button', { name: 'Adopt…' }) + +describe('T9 Cockpit "New on the bus" CTA (B68, F12, X7)', () => { + it('an identified new board: "New on the bus · 1", "not linked yet" and Adopt…', async () => { + await renderScenario(, { + device: connected(), + model: model([panel(1, 'Center_Armament'), registryPanel()]) + }) + expect(cta()).toHaveTextContent('New on the bus · 1') + expect(screen.getByText('not linked yet')).toBeInTheDocument() + expect(adoptButton()).toBeEnabled() + }) + + it('counts every identified new board, PDUs included', async () => { + await renderScenario(, { + device: connected(), + model: model([registryPanel(), newBoard(41, T0, { kind: 'pdu', identified: 'pdu' })]) + }) + expect(cta()).toHaveTextContent('New on the bus · 2') + }) + + it('a board still identifying, or one that can’t be adopted, is not counted', async () => { + await renderScenario(, { + device: connected(), + model: model([newBoard(9, T0), newBoard(10, T0, { identified: 'bridge' })]) + }) + expect(cta()).toBeNull() + expect(adoptButton()).toBeNull() + }) + + it('stays from the last model while the link is down (R5 today)', async () => { + // contract R5 / F12: the CTA should count only boards on the bus now (`onBusNow(live)`); + // today it keeps showing the last model's new boards after the link drops. + const { sky } = await renderScenario(, { + device: connected(), + model: model([registryPanel()]) + }) + expect(cta()).toHaveTextContent('New on the bus · 1') + sky.emitScenario({ device: link('no-device') }) + expect(screen.getByText('No live node status — connect the Gateway.')).toBeInTheDocument() + expect(cta()).toHaveTextContent('New on the bus · 1') + expect(adoptButton()).toBeEnabled() + }) + + it('Adopt… opens the Adopt dialog on the first board in its queue', async () => { + const { user } = await renderScenario(, { + device: connected(), + model: model([registryPanel()]) + }) + expect(screen.queryByRole('dialog')).toBeNull() + await user.click(adoptButton()!) + const dlg = screen.getByRole('dialog', { name: 'Adopt new devices' }) + expect( + within(within(dlg).getByRole('listbox', { name: 'New NODE_IDs' })).getByRole('option', { + selected: true + }) + ).toHaveTextContent('NODE_ID 2') + }) +}) From f833939ca32a3406eea2780414c24107ae1b76f7 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 20:02:25 -0400 Subject: [PATCH 05/28] refactor(firmware): generated reference to shared/firmware, imports replace hand-typed copies (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.1 of the structure rewrite (#101, phase #98). This PR is mechanical: behaviour, UI and wording stay the same. ## What changed - **The generated reference moves to `src/shared/firmware/`.** `scripts/sync-a4ec.ts` now writes there (`OUT_DIR`). The six `*.generated.ts` files were moved with `git mv`. `npm run sync` at the same pin (d29433ab) regenerates them byte-for-byte: `git diff --exit-code -- src/shared/firmware` is clean after the sync. A hand-written barrel, `src/shared/firmware/index.ts`, is now the only import path. CI's freshness gate (`ci.yml`) now diffs the new folder. README and PRD path mentions are updated to match. - `src/main/reference/dcsbios-metadata.ts` stays where it is. It is hand-curated DCS-BIOS data and moves in R1.4. - **All 35 hand-typed copies (§3.3 A1–A35) are now imports.** Callers still use the same names, so call sites barely change. For example, `CAL_TYPE` / `CAL_NACK` are built from the generated tables (`byName(CAL_TYPES)`), and `RAIL_FLAG` / `PDU_FLAG` / `TELEM_PAGE` / `NODE_KIND` are aliases of the generated objects. The cockpit / cockpit-panels / events re-key sites are unchanged because they already key by `RAIL_FLAG.X` / `PDU_FLAG.X`. - **`L-firmware-leaf` is now `error`.** `lint:deps` exits 0 with 0 errors and 65 warnings, the same baseline as before. I checked that the rule can fail: a deliberate `export * from '../hid'` in the barrel produced `error L-firmware-leaf`, and I reverted it. - **`reference.test.ts`** moves next to the generated files and becomes a version and shape guard (see below). ## A1–A35 How each value was checked: - **(R)** the existing `reference.test` copy-equality assertions. The full suite ran against the new imports with those assertions still in place: 849/849 at commit ffe0a2d. - **(P)** a one-off proof test, not committed. It holds the old literals copied verbatim from 2598104 and compares them with the replacements. 8/8 passed. - **(S)** the existing suite (849 at ffe0a2d), whose assertions are unchanged. - **(H)** a one-off render proof, also not committed. In jsdom it rendered `` twice (default store, then a store with calibration, axis/hat/button values and availability) and `` once (its controller fed a device snapshot). The `innerHTML` and `textContent` were dumped once with the base (2598104) `Hid.tsx`, `CalibrationDialog.tsx` and `store.ts` checked out, and once with this branch. The two 28 KB dumps are **identical** (same SHA-1). | # | Was | Now | Verified | |---|---|---|---| | A1 | `CAL_PROTO_VERSION = 1` | `CAL_PROTO.version` | R, P | | A2 | `CAL_MAGIC` AA 53 4B 43 | `Uint8Array.from(CAL_PROTO.magic)` (still a `Uint8Array`) | R, P (incl. instanceof) | | A3 | `CAL_ENVELOPE_BYTES = 10` | `CAL_PROTO.envelopeBytes` | R, P | | A4 | `CAL_MAX_PAYLOAD = 82` | `CAL_PROTO.maxPayload` (`CAL_MAX_FRAME` still 92) | R, P | | A5 | `AXIS_CAL_SLOTS = 8` | `CAL_PROTO.axisSlots` | R, P | | A6 | `CAL_AXIS_NONE = 0xff` | `CAL_PROTO.axisNone` | R, P | | A7 | `CAL_TYPE` (14) | `byName(CAL_TYPES)` | R (both directions), P (entries + key order) | | A8 | `CAL_NACK` (8) | `byName(CAL_NACK_REASONS)` | R (both directions), P (entries + key order) | | A9 | `CAL_PAYLOAD_LEN` (14) | generated `CAL_PAYLOAD_LEN`, re-exported from the codec | R (both directions), P (entries + key order) | | A10 | callink `NACK_NAME` (5) | `nackRefusalName(reason)` in shared/calibration.ts (X11) | P: about 610 inputs (−100…499, fractions, ±0, NaN, ±Infinity, 1e21) against the old expression | | A11 | device-link reverse lookup over `CAL_NACK` | `nackReasonName(reason)` in shared/calibration.ts (X11) | P: same inputs against the old `Object.entries(…).find` | | A12 | `NODE_MSG` | `NODE_STATUS.msgName` | R, P | | A13 | `NODE_END_MSG` | `NODE_STATUS.endMsgName` | R, P | | A14 | `NODE_REQ_ADDR = 0x86fe` | `NODE_STATUS.reqAddress` | R, P | | A15 | `HFLAG_OVERHEAT` | `NODE_HEALTH_FLAGS.OVERHEAT` | R, P | | A16 | `HFLAG_DEGRADED` | `NODE_HEALTH_FLAGS.DEGRADED` | R, P | | A17 | `NODE_KIND` | `= NODE_KINDS` | R, P (entries + key order) | | A18 | `NODE_TELEM_MSG` | `NODE_TELEM.msgName` | R, P | | A19 | `TELEM_PAGE` | `= TELEM_PAGES` | R, P | | A20 | `RAIL_FLAG` | `= PDU_RAIL_FLAGS` | R, P | | A21 | `PDU_FLAG` | `= PDU_STATUS_FLAGS` | R, P | | A22 | `HID_REPORT_SIZE = 34` | `HID_REPORT_LAYOUT.size` | P, S (shared/hid.test) | | A23 | `HID_AXIS_COUNT = 8` | `.axes.count` | P, S | | A24 | `HID_BUTTON_COUNT = 128` | `.buttons.count` | P, S | | A25 | `HID_HAT_COUNT = 4` | `.hats.count` | P, S | | A26 | offsets 0/16/18, `subarray(…, +16)` | `.buttons/.hats/.axes.offset`, `.buttons.bytes` | P, S | | A27 | `nibble <= 7` | `nibble <= hats.centeredAtOrAbove - 1` (written as `<= … - 1`, not `< …`, so non-integer input also behaves the same) | P: about 610 inputs against the old expression | | A28 | `hidAvailability` `< 8`, `< 4`, `< 128` | `axes/hats/buttons.count` (stays in main/index.ts; the move is a later PR) | P | | A29 | `AXIS_LABELS` Roll…Zoom, Spare | `HID_CONTROLS_BY_ID.get(HID_ID.AXIS_MIN + i)?.label ?? 'Spare'` over `axes.count` | P (jsdom: imported the real `AXIS_LABELS` and `toEqual`'d the old 8 strings), H. **All 8 labels identical** | | A30 | `Array(8).fill(0)`, `[0,0,0,0]` | `Array(axes.count).fill(0)`, `Array(hats.count).fill(0)` | P (`toStrictEqual` on the real store initial state), H | | A31 | Hid.tsx "of 8 slots", "8-way POV", "128 × 1-bit", `length: 128` | `axes.count`, `hats.centeredAtOrAbove`, `buttons.count` | P, H | | A32 | Hid.tsx `/ 32768`, "int16 · ±32768" | `-HID_REPORT_LAYOUT.axes.min` | P, H | | A33 | CalibrationDialog `− 32768`, `/ 65535` (21, 25, 39-40, 318, 326) | `AXIS_OFFSET = -axes.min`, `AXIS_SPAN = axes.max - axes.min` | P (numeric identity on sample values), H | | A34 | CalibrationDialog "of 8 slots" | `of {CAL_PROTO.axisSlots} slots` | P, H | | A35 | `FAULT_NOTE` 0x05/0x09, `FAULT_TEXT` 0x09 | keyed by `NODE_FAULT_CODES` name (`HOST_LINK_LOST`, `DUPLICATE_NODE_ID`); wording unchanged | P: `gatewayStatus(…).note` and `withFaultInfo(…)` (`toStrictEqual`) for faultId 0–299 against the old tables | No value differed, so every one was swapped. ## X11 (A10/A11) The two disagreeing NACK namers are now **separate functions side by side in `shared/calibration.ts`**. Both are built on `CAL_NACK_REASONS`, and they are deliberately not unified; unifying them is the fix track. - `nackRefusalName(reason)`: CalLink's `CalNackError` message. Names only the 5 reachable reasons (`BAD_INDEX`, `BAD_ORDER`, `NO_SESSION`, `NO_STORAGE`, `BAD_DEADZONE`). Anything else becomes `reason 0x…`. - `nackReasonName(reason)`: device-link's `CalFailure.reasonName`. Names all 8, else `0x…`. ## reference.test: deleted assertions These assertions compared a hand-typed copy with its generated value. Each copy is now the generated value, so the comparisons were deleted. Five whole `it` blocks went with them, so the suite goes from 849 to 844 tests. - `node-status.generated › matches the shared decoder contract`: `NODE_STATUS.reqAddress` = `NODE_REQ_ADDR`, `.msgName` = `NODE_MSG`, `.endMsgName` = `NODE_END_MSG`. The `protoVersion` = `SUPPORTED_NODE_PROTO` guard is kept. - `node-status.generated › health-flag masks match the decoder` (whole `it`): `OVERHEAT` = `HFLAG_OVERHEAT`, `DEGRADED` = `HFLAG_DEGRADED`. - `node-telem contract › matches the shared decoder`: `NODE_TELEM.msgName` = `NODE_TELEM_MSG`. The `protoVersion` = `SUPPORTED_TELEM_PROTO` guard is kept. - `node-telem contract › page ids and flag bits match the decoder exactly` (whole `it`): `TELEM_PAGES`/`TELEM_PAGE`, `PDU_RAIL_FLAGS`/`RAIL_FLAG`, `PDU_STATUS_FLAGS`/`PDU_FLAG`, `NODE_KINDS`/`NODE_KIND`. - `axis-cal.generated › scalar contract matches the codec`: `axisSlots`, `axisNone`, `envelopeBytes`, `maxPayload` and `magic` against the codec copies. - `axis-cal.generated › every message type agrees, in both directions` (whole `it`). - `axis-cal.generated › every payload length agrees, for every type` (whole `it`). - `axis-cal.generated › every NACK reason agrees, in both directions` (whole `it`). **The calibration version gate became a pin.** `expect(CAL_PROTO.version).toBe(cal.CAL_PROTO_VERSION)` would now compare a value with itself, so it is `expect(CAL_PROTO.version).toBe(1)`, in an `it` renamed "is the protocol version the codec is written for". A firmware protocol bump still fails loudly. This is the only assertion whose content changed. Kept unchanged: the a4ec / hid-controls / hid-report-layout / node-names / dcsbios-metadata shape checks, the fault-code pins and unique-badge check, "marks device-originated types by the high bit", and "COMMIT carries exactly one axis". ## Grep proof Each pattern was run with `git grep -nE -- src ':!*.generated.ts' ':!*.test.ts' ':!*.test.tsx'`, with comment lines filtered out. At the base (2598104) **every pattern hits**. At this head every pattern has 0 hits, except the one noted below. ``` CAL_PROTO_VERSION = [0-9] CAL_ENVELOPE_BYTES = [0-9] 0xaa, 0x53, 0x4b, 0x43 CAL_MAX_PAYLOAD = [0-9] AXIS_CAL_SLOTS = [0-9] CAL_AXIS_NONE = 0x (HELLO|SESSION_OPEN|STREAM_SELECT|HELLO_ACK|RAW): 0x (BAD_CRC|BAD_INDEX|BAD_DEADZONE|NO_SESSION): 0x \[CAL_TYPE\.[A-Z_]+\]: [0-9] NACK_NAME|\[CAL_NACK\.|Object\.entries\(CAL_NACK\) '_NODE_STATUS'|'_NODE_STATUS_END'|'_NODE_TELEM' 0x86fe HFLAG_(OVERHEAT|DEGRADED) = 0x (PANEL_GROUP|BRIDGE): 0x (PDU_RAIL_12V|PDU_STATUS): 0x (UNDER_V|OVER_CURRENT|FUSE_OPEN|SENSOR_FAULT): 0x (UNCAL_VDDA|TEMP_CRIT|NTC_FAULT): 0x HID_(REPORT_SIZE|AXIS_COUNT|BUTTON_COUNT|HAT_COUNT) = [0-9] (BUTTONS|HATS|AXES)_OFFSET = [0-9] BUTTONS_OFFSET \+ 16 nibble <= 7 AXIS_MIN < 8|HAT_MIN < 4|BUTTON_MIN < 128 'Brake L'|'Throttle'|'Spare', Array\(8\)|\[0, 0, 0, 0\] of 8 slots|8-way|128 ×|length: 128 0x0[59]: ['{] / 32768|±32768'|- 32768|/ 65535|>−32768< ``` The one exception is `/ 65535` in `main/decode.ts:143-145`. That code decodes the A-4E-C altimeter drums: it is DCS module data, not firmware, and it is out of scope (§3.3 "Not firmware"). ## Not done here, on purpose - `CalibrationDialog.tsx:202` "signed ±32767" and `:328` `32767` are still literals. They equal `axes.max`, but A33's line list (21, 25, 39-40, 318, 326) does not include them. - The generated banner in `axis-cal.generated.ts` still says "The codec in shared/calibration.ts restates these…", and that is now stale. The text comes from `sync-a4ec.ts`, and changing it would break the byte-identical regeneration this PR has to prove. It belongs with R1.2's sync changes. - §3.3 group B is untouched. ## Checks `npm run typecheck` is clean. `npm run lint` has 0 errors (32 warnings). `npm run lint:deps` has 0 errors (65 warnings). `npx vitest run` passes 62 files: 849 tests with the old assertions at ffe0a2d, 844 after the guard rewrite. `npm run build` succeeds. Refs #98 --- .dependency-cruiser.severity.json | 2 +- .github/workflows/ci.yml | 2 +- PRD.md | 2 +- README.md | 2 +- scripts/sync-a4ec.ts | 6 +- src/main/callink.ts | 15 +-- src/main/decode.ts | 2 +- src/main/device-link.ts | 6 +- src/main/index.ts | 16 ++- src/main/node-hub.ts | 11 +- src/renderer/src/CalibrationDialog.tsx | 22 ++-- src/renderer/src/store.ts | 20 ++- src/renderer/src/tabs/Hid.tsx | 13 +- src/shared/calibration.ts | 105 +++++++--------- .../firmware}/a4ec-controls.generated.ts | 0 .../firmware}/axis-cal.generated.ts | 0 .../firmware}/hid-controls.generated.ts | 0 .../firmware}/hid-report-layout.generated.ts | 0 src/shared/firmware/index.ts | 11 ++ .../firmware}/node-names.generated.ts | 0 .../firmware}/node-status.generated.ts | 0 .../firmware}/reference.test.ts | 115 +++--------------- src/shared/gateway.ts | 21 +++- src/shared/hid.ts | 25 ++-- src/shared/node-telem.ts | 43 +++---- src/shared/nodes.ts | 32 +++-- 26 files changed, 196 insertions(+), 275 deletions(-) rename src/{main/reference => shared/firmware}/a4ec-controls.generated.ts (100%) rename src/{main/reference => shared/firmware}/axis-cal.generated.ts (100%) rename src/{main/reference => shared/firmware}/hid-controls.generated.ts (100%) rename src/{main/reference => shared/firmware}/hid-report-layout.generated.ts (100%) create mode 100644 src/shared/firmware/index.ts rename src/{main/reference => shared/firmware}/node-names.generated.ts (100%) rename src/{main/reference => shared/firmware}/node-status.generated.ts (100%) rename src/{main/reference => shared/firmware}/reference.test.ts (55%) diff --git a/.dependency-cruiser.severity.json b/.dependency-cruiser.severity.json index 2d945f5..9f1efd8 100644 --- a/.dependency-cruiser.severity.json +++ b/.dependency-cruiser.severity.json @@ -5,7 +5,7 @@ "rendererShared": "warn", "contracts": "warn", "cycles": "warn", - "firmwareLeaf": "warn", + "firmwareLeaf": "error", "formatLeaf": "warn", "kitLeaf": "warn", "topicsMain": "warn", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee5d010..a9f82f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Sync freshness check run: | npm run sync - git diff --exit-code -- src/main/reference \ + git diff --exit-code -- src/shared/firmware \ || (echo "::error::generated reference files are stale — run 'npm run sync' and commit" && exit 1) - run: npm run lint - name: Boundary lint (warn-only until each phase flips its rules) diff --git a/PRD.md b/PRD.md index 962919a..7cab6ec 100644 --- a/PRD.md +++ b/PRD.md @@ -101,7 +101,7 @@ Settings expose: source mode, DCS host + transport (`loopback-multicast | unicas ## 7. Firmware sync (build-script, copies from the OpenSkyhawk repo) -A committed `scripts/sync-a4ec.ts` (run via `npm run sync`, and as a `prebuild` step) **sparse-fetches a pinned git commit** of the OpenSkyhawk repo and emits typed modules under `src/main/reference/`: +A committed `scripts/sync-a4ec.ts` (run via `npm run sync`, and as a `prebuild` step) **sparse-fetches a pinned git commit** of the OpenSkyhawk repo and emits typed modules under `src/shared/firmware/`: - `tools/gen_a4ec/data/A-4E-C.jsonp` → `a4ec-controls.generated.ts` (address→{name,mask,type,range,description}; both decode-by-address and command lookup). - `Firmware/Libraries/HIDControls/HIDControls.h` → `hid-controls.generated.ts` (controlId→name, range sentinels). diff --git a/README.md b/README.md index 682ee74..e521a51 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ scratch folder. Packaged builds without it use the normal OS app-data folder. ## Sync script `scripts/sync-a4ec.ts` (`npm run sync`) sparse-fetches a **pinned OpenSkyhawk commit** -and regenerates `src/main/reference/*.generated.ts` — the A-4E-C control map, HID layout, +and regenerates `src/shared/firmware/*.generated.ts` — the A-4E-C control map, HID layout, and SimGateway constants. Generated files are committed so normal builds need no network. CI runs a freshness check on every PR. diff --git a/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index c2ef6f1..cf4323a 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -1,5 +1,5 @@ /** - * Firmware sync — regenerates `src/main/reference/*.generated.ts` from a PINNED + * Firmware sync — regenerates `src/shared/firmware/*.generated.ts` from a PINNED * commit of the OpenSkyhawk firmware repo. See PRD.md §7 / §12. * * Sources (read from the pinned commit): @@ -42,7 +42,7 @@ const SRC = { } const REPO_ROOT = join(__dirname, '..') -const OUT_DIR = join(REPO_ROOT, 'src', 'main', 'reference') +const OUT_DIR = join(REPO_ROOT, 'src', 'shared', 'firmware') const PRETTIER_OPTS = { parser: 'typescript' as const, @@ -794,7 +794,7 @@ export const NODE_REGISTRY_SOURCE = { async function writeGenerated(file: string, code: string): Promise { const formatted = await format(code, PRETTIER_OPTS) writeFileSync(join(OUT_DIR, file), formatted) - console.log(`[sync] wrote src/main/reference/${file}`) + console.log(`[sync] wrote src/shared/firmware/${file}`) } async function main(): Promise { diff --git a/src/main/callink.ts b/src/main/callink.ts index 6503ff9..8a662a3 100644 --- a/src/main/callink.ts +++ b/src/main/callink.ts @@ -8,7 +8,6 @@ // what should carry on to DCS and the line assembler. import { CAL_AXIS_NONE, - CAL_NACK, CAL_PROTO_VERSION, CAL_TYPE, CalDemux, @@ -21,6 +20,7 @@ import { decodeSessionAck, encodeCommit, encodeReset, + nackRefusalName, type CalData, type CalFrame, type HelloAck, @@ -34,15 +34,6 @@ const REPLY_TIMEOUT_MS = 2000 /** The device closes an idle session after 30 s; a user reading a dialog step sends nothing. */ const KEEPALIVE_MS = 10_000 -/** Names for the five NACK reasons that can actually reach us; see CAL_NACK in the codec. */ -const NACK_NAME: Record = { - [CAL_NACK.BAD_INDEX]: 'BAD_INDEX', - [CAL_NACK.BAD_ORDER]: 'BAD_ORDER', - [CAL_NACK.NO_SESSION]: 'NO_SESSION', - [CAL_NACK.NO_STORAGE]: 'NO_STORAGE', - [CAL_NACK.BAD_DEADZONE]: 'BAD_DEADZONE' -} - /** The device answered and refused. Distinct from a timeout, and needs different messaging. */ export class CalNackError extends Error { constructor( @@ -51,7 +42,7 @@ export class CalNackError extends Error { /** Axis index where one applies, else CAL_AXIS_NONE. */ readonly detail: number ) { - super(`calibration: device refused (${NACK_NAME[reason] ?? `reason 0x${reason.toString(16)}`})`) + super(`calibration: device refused (${nackRefusalName(reason)})`) this.name = 'CalNackError' } } @@ -88,7 +79,7 @@ export class CalLink { private seq = 0 private session = false private keepalive?: ReturnType - private streamAxis = CAL_AXIS_NONE + private streamAxis: number = CAL_AXIS_NONE /** Fallback timer that reopens the gate after a close we never got an answer to. */ private expiry?: ReturnType /** The device's idle expiry, as it reported in SESSION_ACK. Default until it tells us. */ diff --git a/src/main/decode.ts b/src/main/decode.ts index df86063..5cb03ef 100644 --- a/src/main/decode.ts +++ b/src/main/decode.ts @@ -1,6 +1,6 @@ // Turns raw DCS-BIOS writes into named log rows, aircraft state, and the five // sim-telemetry readouts — using the generated A-4E-C reference. -import { A4EC_OUTPUTS_BY_ADDRESS } from './reference/a4ec-controls.generated' +import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' import { A4EC_ADDRESS_RANGE } from './reference/dcsbios-metadata' import { StringRegion, ACFT_NAME_ADDRESS, ACFT_NAME_MAX } from '@shared/dcsbios' import type { AircraftStatus, LogRow, TelemetryReadout } from '@shared/ipc' diff --git a/src/main/device-link.ts b/src/main/device-link.ts index 9c62823..6912d7c 100644 --- a/src/main/device-link.ts +++ b/src/main/device-link.ts @@ -33,7 +33,7 @@ import { type PushChannels, type SerialFrame } from '@shared/ipc' -import { CAL_NACK, CAL_AXIS_NONE } from '@shared/calibration' +import { CAL_AXIS_NONE, nackReasonName } from '@shared/calibration' import { boardFor, dropAxis, @@ -563,9 +563,7 @@ export class DeviceLink { return { ok: false, kind: 'timeout', message: err.message } } if (err instanceof CalNackError) { - const name = - Object.entries(CAL_NACK).find(([, v]) => v === err.reason)?.[0] ?? - `0x${err.reason.toString(16)}` + const name = nackReasonName(err.reason) return { ok: false, kind: 'nack', diff --git a/src/main/index.ts b/src/main/index.ts index d947d1b..5cefe96 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,7 +16,7 @@ import { } from '@shared/ipc' import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/node-mapping' import { eventsToTsv } from '@shared/events' -import { HID_CONTROLS, HID_ID } from './reference/hid-controls.generated' +import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' import { Session } from './session' import { loadConfig, saveConfig } from './settings' import { loadCalCache, saveCalCache } from './cal-cache' @@ -43,14 +43,22 @@ function hidAvailability(): HidAvailability { const hats: number[] = [] const buttons: number[] = [] for (const c of HID_CONTROLS) { - if (c.id >= HID_ID.AXIS_MIN && c.id <= HID_ID.AXIS_MAX && c.id - HID_ID.AXIS_MIN < 8) { + if ( + c.id >= HID_ID.AXIS_MIN && + c.id <= HID_ID.AXIS_MAX && + c.id - HID_ID.AXIS_MIN < HID_REPORT_LAYOUT.axes.count + ) { axes.push(c.id - HID_ID.AXIS_MIN) - } else if (c.id >= HID_ID.HAT_MIN && c.id <= HID_ID.HAT_MAX && c.id - HID_ID.HAT_MIN < 4) { + } else if ( + c.id >= HID_ID.HAT_MIN && + c.id <= HID_ID.HAT_MAX && + c.id - HID_ID.HAT_MIN < HID_REPORT_LAYOUT.hats.count + ) { hats.push(c.id - HID_ID.HAT_MIN) } else if ( c.id >= HID_ID.BUTTON_MIN && c.id <= HID_ID.BUTTON_MAX && - c.id - HID_ID.BUTTON_MIN < 128 + c.id - HID_ID.BUTTON_MIN < HID_REPORT_LAYOUT.buttons.count ) { buttons.push(c.id - HID_ID.BUTTON_MIN) } diff --git a/src/main/node-hub.ts b/src/main/node-hub.ts index d352628..c559221 100644 --- a/src/main/node-hub.ts +++ b/src/main/node-hub.ts @@ -34,8 +34,7 @@ import { type ResetPreview } from '@shared/node-mapping' import { buildNodeModel, type NodeModel } from '@shared/node-rules' -import { NODE_NAMES, NODE_REGISTRY_SOURCE } from './reference/node-names.generated' -import { NODE_FAULT_CODES } from './reference/node-status.generated' +import { NODE_FAULT_CODES, NODE_NAMES, NODE_REGISTRY_SOURCE } from '@shared/firmware' import { loadNodeMapping, MappingSaveError, @@ -52,10 +51,10 @@ const NODE_TELEM_PUSH_MS = 1000 /** Fault-dictionary enrichment: the abbr, label and description for the node's faultId. */ /** * Client wording where the generated text reads like firmware notes (the label comes from the enum - * name, the description from the header comment). + * name, the description from the header comment). Keyed by NODE_FAULT_CODES name. */ -const FAULT_TEXT: Partial> = { - 0x09: { +const FAULT_TEXT: Partial> = { + DUPLICATE_NODE_ID: { label: 'Duplicate NODE_ID', description: 'another board uses this NODE_ID, so the two are reported as one and this row mixes both' @@ -64,7 +63,7 @@ const FAULT_TEXT: Partial export function withFaultInfo(n: NodeStatus): NodeStatus { const fc = n.faultId ? NODE_FAULT_CODES[n.faultId] : undefined - const text = n.faultId ? FAULT_TEXT[n.faultId] : undefined + const text = fc ? FAULT_TEXT[fc.name] : undefined return { ...n, faultAbbr: fc?.abbr, diff --git a/src/renderer/src/CalibrationDialog.tsx b/src/renderer/src/CalibrationDialog.tsx index 2a1e185..b781d36 100644 --- a/src/renderer/src/CalibrationDialog.tsx +++ b/src/renderer/src/CalibrationDialog.tsx @@ -6,10 +6,16 @@ import { holdRemainingMs, sweepsRemaining } from '@shared/axis-capture' +import { CAL_PROTO, HID_REPORT_LAYOUT } from '@shared/firmware' import type { CalSnapshot } from '@shared/ipc' import { CalibrationController, type CalibrationState } from './calibration-controller' import { AXIS_LABELS, useStore } from './store' +/** 32768: the unsigned -> signed offset, from the HID report's axis range. */ +const AXIS_OFFSET = -HID_REPORT_LAYOUT.axes.min +/** 65535: the full unsigned span, from the HID report's axis range. */ +const AXIS_SPAN = HID_REPORT_LAYOUT.axes.max - HID_REPORT_LAYOUT.axes.min + /** * Display units: signed ±32767, matching what DCS shows and what the HID tab reads off the * report. The wire, storage, capture and COMMIT all stay unsigned 0–65535 — that is the device's @@ -18,11 +24,11 @@ import { AXIS_LABELS, useStore } from './store' * **Display only. Never call this on a value being committed.** The offset applied twice, or * zero times, still yields plausible in-range numbers and fails silently. */ -const toDisplay = (unsigned: number) => unsigned - 32768 +const toDisplay = (unsigned: number) => unsigned - AXIS_OFFSET const fmt = (n: number) => toDisplay(n).toLocaleString('en-US') /** A span, not a position — no offset applies. */ const fmtSpan = (n: number) => n.toLocaleString('en-US') -const pct = (v: number) => Math.max(0, Math.min(100, (v / 65535) * 100)).toFixed(2) + '%' +const pct = (v: number) => Math.max(0, Math.min(100, (v / AXIS_SPAN) * 100)).toFixed(2) + '%' type Point = 'stopA' | 'stopB' | 'centre' @@ -36,8 +42,8 @@ const TRAVEL_BANDS = [ function travelQuality(min?: number, max?: number) { if (min === undefined || max === undefined) return null const counts = max - min - const band = TRAVEL_BANDS.find((b) => counts / 65535 >= b.at)! - return { counts, pct: Math.round((counts / 65535) * 100), ...band } + const band = TRAVEL_BANDS.find((b) => counts / AXIS_SPAN >= b.at)! + return { counts, pct: Math.round((counts / AXIS_SPAN) * 100), ...band } } /** @@ -260,8 +266,8 @@ export function CalibrationDialog({ ))}
- {present.length} of 8 slots · device-reported. Edits are kept when you switch axes and - written together. + {present.length} of {CAL_PROTO.axisSlots} slots · device-reported. Edits are kept when + you switch axes and written together.
@@ -315,7 +321,7 @@ export function CalibrationDialog({ className="cd__band" style={{ left: pct(shown.min), - width: (((shown.max - shown.min) / 65535) * 100).toFixed(2) + '%' + width: (((shown.max - shown.min) / AXIS_SPAN) * 100).toFixed(2) + '%' }} /> )} @@ -323,7 +329,7 @@ export function CalibrationDialog({ {s.live && }
- −32768 + −{AXIS_OFFSET} 0 32767
diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 360940f..fb7be80 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { isLinkUp } from '@shared/ipc' +import { HID_CONTROLS_BY_ID, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' import type { AircraftStatus, AppConfig, @@ -38,16 +39,11 @@ export interface LogRow { raw: string } -export const AXIS_LABELS = [ - 'Roll', - 'Pitch', - 'Throttle', - 'Rudder', - 'Brake L', - 'Brake R', - 'Zoom', - 'Spare' -] +/** Per HID axis slot: its HIDControls.h label, or "Spare" for a slot the firmware doesn't catalogue. */ +export const AXIS_LABELS = Array.from( + { length: HID_REPORT_LAYOUT.axes.count }, + (_, i) => HID_CONTROLS_BY_ID.get(HID_ID.AXIS_MIN + i)?.label ?? 'Spare' +) // ── renderer <-> main transport mapping ────────────────────────────────────── const TRANSPORT_TO_IPC: Record = { @@ -305,9 +301,9 @@ export const useStore = create((set, get) => ({ cmdsTotal: 0, lastCmd: '—', - axes: Array(8).fill(0), + axes: Array(HID_REPORT_LAYOUT.axes.count).fill(0), buttons: [], - hats: [0, 0, 0, 0], + hats: Array(HID_REPORT_LAYOUT.hats.count).fill(0), availAxes: [], availHats: [], availButtons: [], diff --git a/src/renderer/src/tabs/Hid.tsx b/src/renderer/src/tabs/Hid.tsx index 78816ad..1ef9b6a 100644 --- a/src/renderer/src/tabs/Hid.tsx +++ b/src/renderer/src/tabs/Hid.tsx @@ -1,3 +1,4 @@ +import { HID_REPORT_LAYOUT } from '@shared/firmware' import { isLinkUp } from '@shared/ipc' import { useStore, AXIS_LABELS, HAT_DIRS } from '../store' import { freshness } from '../freshness' @@ -56,7 +57,7 @@ function Axis({ cal: CalState onCalibrate?: () => void }) { - const pct = Math.max(-1, Math.min(1, raw / 32768)) // signed ±32768 + const pct = Math.max(-1, Math.min(1, raw / -HID_REPORT_LAYOUT.axes.min)) // signed ±32768 const fillW = Math.abs(pct) * 50 // % of half-track const left = pct >= 0 ? 50 : 50 - fillW return ( @@ -160,7 +161,9 @@ export function Hid() {
Axes - {s.cal ? `${present.length} of 8 slots · device-reported` : 'int16 · ±32768'} + {s.cal + ? `${present.length} of ${HID_REPORT_LAYOUT.axes.count} slots · device-reported` + : `int16 · ±${-HID_REPORT_LAYOUT.axes.min}`} {s.cal && ( @@ -222,7 +225,7 @@ export function Hid() {
Hats - 8-way POV + {HID_REPORT_LAYOUT.hats.centeredAtOrAbove}-way POV
{s.hats.map((d, i) => ( @@ -249,12 +252,12 @@ export function Hid() {
Buttons - 128 × 1-bit · {availButtons.size} mapped ·{' '} + {HID_REPORT_LAYOUT.buttons.count} × 1-bit · {availButtons.size} mapped ·{' '} {lit.size} lit
- {Array.from({ length: 128 }, (_, i) => { + {Array.from({ length: HID_REPORT_LAYOUT.buttons.count }, (_, i) => { const cls = lit.has(i) ? ' lit' : availButtons.has(i) ? '' : ' btn--off' return ( diff --git a/src/shared/calibration.ts b/src/shared/calibration.ts index a4c1545..d6dfd67 100644 --- a/src/shared/calibration.ts +++ b/src/shared/calibration.ts @@ -12,51 +12,41 @@ // that way throughout the client. The ±32767 seen on the HID tab comes from the HID report, // where the firmware applied the offset; nothing here re-derives it. +import { CAL_NACK_REASONS, CAL_PAYLOAD_LEN, CAL_PROTO, CAL_TYPES } from './firmware' + /** - * Constants restated from src/main/reference/axis-cal.generated.ts. - * - * They are duplicated rather than imported because `src/shared` is aliased everywhere while - * `src/main/reference` is not, so importing across that boundary inverts the layering — the - * same reason nodes.ts restates SUPPORTED_NODE_PROTO and HFLAG_*. reference.test.ts asserts - * the two agree on every entry, so a firmware bump fails loudly rather than drifting. + * Constants from the generated firmware reference (shared/firmware, AxisCal.h), under the names + * the codec and its callers use. Nothing here is restated: a firmware bump changes them at sync. */ -export const CAL_PROTO_VERSION = 1 +export const CAL_PROTO_VERSION = CAL_PROTO.version /** Frame lead-in, "\xAA S K C". */ -export const CAL_MAGIC = Uint8Array.from([0xaa, 0x53, 0x4b, 0x43]) +export const CAL_MAGIC = Uint8Array.from(CAL_PROTO.magic) /** magic 4 + type 1 + seq 1 + len 2 + crc 2. */ -export const CAL_ENVELOPE_BYTES = 10 +export const CAL_ENVELOPE_BYTES = CAL_PROTO.envelopeBytes /** CAL_DATA, the largest legal payload — and the bound on how much a receiver may buffer. */ -export const CAL_MAX_PAYLOAD = 82 +export const CAL_MAX_PAYLOAD = CAL_PROTO.maxPayload /** HID report axis slots. Fixed by the descriptor, not by how many a cockpit populates. */ -export const AXIS_CAL_SLOTS = 8 +export const AXIS_CAL_SLOTS = CAL_PROTO.axisSlots /** Axis sentinel: "all" for RESET, "none" for SESSION_OPEN / STREAM_SELECT. */ -export const CAL_AXIS_NONE = 0xff - -/** Message types. High bit set = device -> client. */ -export const CAL_TYPE = { - HELLO: 0x01, - GET_CAL: 0x02, - SESSION_OPEN: 0x03, - SESSION_CLOSE: 0x04, - COMMIT: 0x05, - RESET: 0x06, - KEEPALIVE: 0x07, - STREAM_SELECT: 0x08, - HELLO_ACK: 0x81, - CAL_DATA: 0x82, - SESSION_ACK: 0x83, - ACK: 0x84, - NACK: 0x85, - RAW: 0x86 -} as const +export const CAL_AXIS_NONE = CAL_PROTO.axisNone + +/** A generated code-keyed table, turned around: name -> wire code. */ +function byName( + table: Partial> +): Readonly> { + return Object.fromEntries(Object.entries(table).map(([code, info]) => [info!.name, Number(code)])) +} + +/** Message types, by name (CAL_TYPES). High bit set = device -> client. */ +export const CAL_TYPE = byName(CAL_TYPES) /** - * NACK reasons. + * NACK reasons, by name (CAL_NACK_REASONS). * * Only five can ever reach a client. BAD_CRC, BAD_LENGTH and BAD_TYPE are all rejected at the * gateway's framing layer, where a failing candidate is not a frame at all — its bytes are @@ -64,19 +54,33 @@ export const CAL_TYPE = { * timeout on HELLO, never as a NACK**, so the no-answer path has to cover no-device, * wrong-firmware and newer-protocol alike. */ -export const CAL_NACK = { - BAD_CRC: 0x01, - BAD_LENGTH: 0x02, - BAD_TYPE: 0x03, - BAD_INDEX: 0x04, - BAD_ORDER: 0x05, - NO_SESSION: 0x06, - NO_STORAGE: 0x07, - BAD_DEADZONE: 0x08 -} as const +export const CAL_NACK = byName(CAL_NACK_REASONS) + +/** The five NACK reasons that can actually reach a client (see CAL_NACK). */ +const REACHABLE_NACK: ReadonlySet = new Set([ + 'BAD_INDEX', + 'BAD_ORDER', + 'NO_SESSION', + 'NO_STORAGE', + 'BAD_DEADZONE' +]) /** - * The one legal payload length per type. + * A NACK reason as CalLink's refusal message names it: one of the five reachable reasons by + * name, anything else as `reason 0x…`. + */ +export function nackRefusalName(reason: number): string { + const name = CAL_NACK_REASONS[reason]?.name + return name !== undefined && REACHABLE_NACK.has(name) ? name : `reason 0x${reason.toString(16)}` +} + +/** A NACK reason as a calibration failure reports it: any known reason by name, else `0x…`. */ +export function nackReasonName(reason: number): string { + return CAL_NACK_REASONS[reason]?.name ?? `0x${reason.toString(16)}` +} + +/** + * The one legal payload length per type (generated CAL_PAYLOAD_LEN). * * `LEN` is not merely bounded, it is fixed by `TYPE`, and it is checked **before the payload is * buffered**. `LEN` is read before the CRC can be verified, so on a false frame it is noise: a @@ -84,22 +88,7 @@ export const CAL_NACK = { * that many bytes stalls its line assembly. There are no variable-length types — COMMIT carries * exactly one axis — so there is no exception to state or to get wrong. */ -export const CAL_PAYLOAD_LEN: Readonly> = { - [CAL_TYPE.HELLO]: 0, - [CAL_TYPE.GET_CAL]: 0, - [CAL_TYPE.SESSION_OPEN]: 1, - [CAL_TYPE.SESSION_CLOSE]: 0, - [CAL_TYPE.COMMIT]: 9, - [CAL_TYPE.RESET]: 1, - [CAL_TYPE.KEEPALIVE]: 0, - [CAL_TYPE.STREAM_SELECT]: 1, - [CAL_TYPE.HELLO_ACK]: 6, - [CAL_TYPE.CAL_DATA]: 82, - [CAL_TYPE.SESSION_ACK]: 5, - [CAL_TYPE.ACK]: 1, - [CAL_TYPE.NACK]: 3, - [CAL_TYPE.RAW]: 5 -} +export { CAL_PAYLOAD_LEN } /** Largest complete frame, and therefore the hard bound on CalDemux's carry. */ export const CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD diff --git a/src/main/reference/a4ec-controls.generated.ts b/src/shared/firmware/a4ec-controls.generated.ts similarity index 100% rename from src/main/reference/a4ec-controls.generated.ts rename to src/shared/firmware/a4ec-controls.generated.ts diff --git a/src/main/reference/axis-cal.generated.ts b/src/shared/firmware/axis-cal.generated.ts similarity index 100% rename from src/main/reference/axis-cal.generated.ts rename to src/shared/firmware/axis-cal.generated.ts diff --git a/src/main/reference/hid-controls.generated.ts b/src/shared/firmware/hid-controls.generated.ts similarity index 100% rename from src/main/reference/hid-controls.generated.ts rename to src/shared/firmware/hid-controls.generated.ts diff --git a/src/main/reference/hid-report-layout.generated.ts b/src/shared/firmware/hid-report-layout.generated.ts similarity index 100% rename from src/main/reference/hid-report-layout.generated.ts rename to src/shared/firmware/hid-report-layout.generated.ts diff --git a/src/shared/firmware/index.ts b/src/shared/firmware/index.ts new file mode 100644 index 0000000..08db85b --- /dev/null +++ b/src/shared/firmware/index.ts @@ -0,0 +1,11 @@ +// Hand-written barrel over the firmware reference that `npm run sync` (scripts/sync-a4ec.ts) +// generates from a pinned OpenSkyhawk commit. This is the only import path: import from +// '@shared/firmware' (or './firmware' inside src/shared), never from a *.generated.ts file. +// +// A leaf: nothing in this folder imports anything else in src (L-firmware-leaf). +export * from './a4ec-controls.generated' +export * from './axis-cal.generated' +export * from './hid-controls.generated' +export * from './hid-report-layout.generated' +export * from './node-names.generated' +export * from './node-status.generated' diff --git a/src/main/reference/node-names.generated.ts b/src/shared/firmware/node-names.generated.ts similarity index 100% rename from src/main/reference/node-names.generated.ts rename to src/shared/firmware/node-names.generated.ts diff --git a/src/main/reference/node-status.generated.ts b/src/shared/firmware/node-status.generated.ts similarity index 100% rename from src/main/reference/node-status.generated.ts rename to src/shared/firmware/node-status.generated.ts diff --git a/src/main/reference/reference.test.ts b/src/shared/firmware/reference.test.ts similarity index 55% rename from src/main/reference/reference.test.ts rename to src/shared/firmware/reference.test.ts index 60eb384..6ee9536 100644 --- a/src/main/reference/reference.test.ts +++ b/src/shared/firmware/reference.test.ts @@ -3,39 +3,22 @@ import { A4EC_OUTPUTS, A4EC_OUTPUTS_BY_ADDRESS, A4EC_INPUTS, - A4EC_INPUTS_BY_ID -} from './a4ec-controls.generated' -import { HID_CONTROLS, HID_CONTROLS_BY_ID, HID_ID } from './hid-controls.generated' -import { HID_REPORT_LAYOUT } from './hid-report-layout.generated' -import { + A4EC_INPUTS_BY_ID, + HID_CONTROLS, + HID_CONTROLS_BY_ID, + HID_ID, + HID_REPORT_LAYOUT, NODE_STATUS, - NODE_HEALTH_FLAGS, NODE_FAULT_CODES, - NODE_KINDS, NODE_TELEM, - TELEM_PAGES, - PDU_RAIL_FLAGS, - PDU_STATUS_FLAGS -} from './node-status.generated' -import { NODE_NAMES } from './node-names.generated' -import { CAL_PROTO, CAL_TYPES, CAL_NACK_REASONS, CAL_PAYLOAD_LEN } from './axis-cal.generated' -import { ACFT_NAME } from './dcsbios-metadata' -import { - HFLAG_DEGRADED, - HFLAG_OVERHEAT, - NODE_END_MSG, - NODE_KIND, - NODE_MSG, - NODE_REQ_ADDR, - SUPPORTED_NODE_PROTO -} from '@shared/nodes' -import { - NODE_TELEM_MSG, - PDU_FLAG, - RAIL_FLAG, - SUPPORTED_TELEM_PROTO, - TELEM_PAGE -} from '@shared/node-telem' + NODE_NAMES, + CAL_PROTO, + CAL_TYPES, + CAL_PAYLOAD_LEN +} from '.' +import { ACFT_NAME } from '../../main/reference/dcsbios-metadata' +import { SUPPORTED_NODE_PROTO } from '@shared/nodes' +import { SUPPORTED_TELEM_PROTO } from '@shared/node-telem' import * as cal from '@shared/calibration' describe('a4ec-controls.generated', () => { @@ -102,14 +85,6 @@ describe('dcsbios-metadata', () => { describe('node-status.generated', () => { it('matches the shared decoder contract (fails loudly on a firmware bump)', () => { expect(NODE_STATUS.protoVersion).toBe(SUPPORTED_NODE_PROTO) - expect(NODE_STATUS.reqAddress).toBe(NODE_REQ_ADDR) - expect(NODE_STATUS.msgName).toBe(NODE_MSG) - expect(NODE_STATUS.endMsgName).toBe(NODE_END_MSG) - }) - - it('health-flag masks match the decoder (firmware bit reassignment fails loudly)', () => { - expect(NODE_HEALTH_FLAGS.OVERHEAT).toBe(HFLAG_OVERHEAT) - expect(NODE_HEALTH_FLAGS.DEGRADED).toBe(HFLAG_DEGRADED) }) it('fault-code dictionary pins every known code (append-only; a renumber fails loudly)', () => { @@ -145,70 +120,20 @@ describe('node-status.generated', () => { }) }) -// shared/node-telem.ts restates these so `src/shared` needs no `src/main` import. Compared in both -// directions, so a page or flag added, removed or renumbered firmware-side fails here. +// shared/node-telem.ts imports its page ids and flag bits from here; only the decoder's protocol +// version is its own, so a firmware bump fails here rather than mis-decoding. describe('node-telem contract', () => { it('matches the shared decoder (fails loudly on a firmware bump)', () => { expect(NODE_TELEM.protoVersion).toBe(SUPPORTED_TELEM_PROTO) - expect(NODE_TELEM.msgName).toBe(NODE_TELEM_MSG) - }) - - it('page ids and flag bits match the decoder exactly', () => { - expect(TELEM_PAGES).toEqual(TELEM_PAGE) - expect(PDU_RAIL_FLAGS).toEqual(RAIL_FLAG) - expect(PDU_STATUS_FLAGS).toEqual(PDU_FLAG) - // _NODE_STATUS v3 nodeKind (#344): the parser's names must be the firmware's. - expect(NODE_KINDS).toEqual(NODE_KIND) }) }) -// shared/calibration.ts restates these constants rather than importing them, because `src/shared` -// is aliased everywhere and `src/main/reference` is not. That duplication is only safe because -// these assertions are exhaustive in BOTH directions — a firmware bump that adds, removes or -// renumbers anything has to fail here, not surface as a mis-framed packet on the wire. +// shared/calibration.ts imports these from here. The codec's framing and payload decoders are +// written for one protocol version, so a firmware bump to another has to fail here, not surface as +// a mis-framed packet on the wire. describe('axis-cal.generated', () => { - it('scalar contract matches the codec', () => { - expect(CAL_PROTO.version).toBe(cal.CAL_PROTO_VERSION) - expect(CAL_PROTO.axisSlots).toBe(cal.AXIS_CAL_SLOTS) - expect(CAL_PROTO.axisNone).toBe(cal.CAL_AXIS_NONE) - expect(CAL_PROTO.envelopeBytes).toBe(cal.CAL_ENVELOPE_BYTES) - expect(CAL_PROTO.maxPayload).toBe(cal.CAL_MAX_PAYLOAD) - expect([...CAL_PROTO.magic]).toEqual([...cal.CAL_MAGIC]) - }) - - it('every message type agrees, in both directions', () => { - const generated = new Map( - Object.entries(CAL_TYPES).map(([code, info]) => [info!.name, Number(code)]) - ) - const codec = new Map(Object.entries(cal.CAL_TYPE)) - // Same names on both sides — catches a type added or dropped firmware-side. - expect([...codec.keys()].sort()).toEqual([...generated.keys()].sort()) - for (const [name, code] of codec) expect(generated.get(name), name).toBe(code) - }) - - it('every payload length agrees, for every type', () => { - for (const [name, code] of Object.entries(cal.CAL_TYPE)) { - expect(CAL_PAYLOAD_LEN[code], name).toBe(cal.CAL_PAYLOAD_LEN[code]) - } - // No length in the codec that the firmware does not declare, and vice versa. - expect( - Object.keys(cal.CAL_PAYLOAD_LEN) - .map(Number) - .sort((a, b) => a - b) - ).toEqual( - Object.keys(CAL_PAYLOAD_LEN) - .map(Number) - .sort((a, b) => a - b) - ) - }) - - it('every NACK reason agrees, in both directions', () => { - const generated = new Map( - Object.entries(CAL_NACK_REASONS).map(([code, info]) => [info!.name, Number(code)]) - ) - const codec = new Map(Object.entries(cal.CAL_NACK)) - expect([...codec.keys()].sort()).toEqual([...generated.keys()].sort()) - for (const [name, code] of codec) expect(generated.get(name), name).toBe(code) + it('is the protocol version the codec is written for', () => { + expect(CAL_PROTO.version).toBe(1) }) it('marks device-originated types by the high bit', () => { diff --git a/src/shared/gateway.ts b/src/shared/gateway.ts index 812d176..0ffa289 100644 --- a/src/shared/gateway.ts +++ b/src/shared/gateway.ts @@ -9,6 +9,7 @@ // Gateway raised itself; the one client-side rule is "not responding": once it has answered since // the port opened, a roster request unanswered for GATEWAY_NO_ANSWER_MS while the port is open. import { faultLabel, STATUS_FLAGS, type LogEventInput, type StatusFlag } from './events' +import { NODE_FAULT_CODES } from './firmware' import type { NodeStatus } from './nodes' /** How long a roster request may go unanswered, with the port open, before the Gateway is "not responding". */ @@ -45,12 +46,20 @@ const flag = (key: StatusFlag): Flag => STATUS_FLAGS.find((f) => f.key === key)! const title = (s: string): string => s[0].toUpperCase() + s.slice(1) /** - * Client wording for the faults a Gateway reports about itself (NODE_FAULT_CODES). Others show - * their label alone: the dictionary's descriptions read like firmware notes. + * Client wording for the faults a Gateway reports about itself, keyed by NODE_FAULT_CODES name. + * Others show their label alone: the dictionary's descriptions read like firmware notes. */ -const FAULT_NOTE: Partial> = { - 0x05: 'It reports losing its serial link to this computer. If it keeps happening, check the USB cable.', - 0x09: 'Another board on the bus uses NODE_ID 0, the Gateway’s own. Reflash that board with a different NODE_ID.' +const FAULT_NOTE: Partial> = { + HOST_LINK_LOST: + 'It reports losing its serial link to this computer. If it keeps happening, check the USB cable.', + DUPLICATE_NODE_ID: + 'Another board on the bus uses NODE_ID 0, the Gateway’s own. Reflash that board with a different NODE_ID.' +} + +/** The client note for a fault code, looked up by the code's firmware name. */ +const faultNote = (faultId: number): string | undefined => { + const name = NODE_FAULT_CODES[faultId]?.name + return name === undefined ? undefined : FAULT_NOTE[name] } const withTemp = (label: string, n: NodeStatus): string => @@ -118,7 +127,7 @@ export function gatewayStatus( severity: sev(flag('degraded')), kind: 'fault', label: faultLabel(row), - note: FAULT_NOTE[row.faultId] + note: faultNote(row.faultId) } } if (!health) return undefined diff --git a/src/shared/hid.ts b/src/shared/hid.ts index 61032c5..51b1712 100644 --- a/src/shared/hid.ts +++ b/src/shared/hid.ts @@ -3,16 +3,18 @@ // uint8_t buttons[16] // 128 x 1-bit // uint8_t hats[2] // 4 x 4-bit nibble // int16_t axes[8] // X Y Z Rx Ry Rz Slider Dial (little-endian) -// => 34 bytes, no report id. +// => 34 bytes, no report id. The numbers come from HID_REPORT_LAYOUT (shared/firmware), which the +// sync asserts against that struct. +import { HID_REPORT_LAYOUT } from './firmware' -export const HID_REPORT_SIZE = 34 -export const HID_AXIS_COUNT = 8 -export const HID_BUTTON_COUNT = 128 -export const HID_HAT_COUNT = 4 +export const HID_REPORT_SIZE = HID_REPORT_LAYOUT.size +export const HID_AXIS_COUNT = HID_REPORT_LAYOUT.axes.count +export const HID_BUTTON_COUNT = HID_REPORT_LAYOUT.buttons.count +export const HID_HAT_COUNT = HID_REPORT_LAYOUT.hats.count -const BUTTONS_OFFSET = 0 -const HATS_OFFSET = 16 -const AXES_OFFSET = 18 +const BUTTONS_OFFSET = HID_REPORT_LAYOUT.buttons.offset +const HATS_OFFSET = HID_REPORT_LAYOUT.hats.offset +const AXES_OFFSET = HID_REPORT_LAYOUT.axes.offset /** Axes are signed int16 on the wire (Logical Min −32768; no 0x8000 bias). */ export function decodeAxis(raw: number): number { @@ -30,7 +32,7 @@ export function isButtonPressed(buttons: Uint8Array, n: number): boolean { * Returns a HAT_DIRS index: 0 = centre, 1..8 = N..NW. */ export function decodeHat(nibble: number): number { - return nibble <= 7 ? nibble + 1 : 0 + return nibble <= HID_REPORT_LAYOUT.hats.centeredAtOrAbove - 1 ? nibble + 1 : 0 } /** Decode a full 34-byte HID report into axes/buttons/hats. */ @@ -42,7 +44,10 @@ export function decodeReport(report: Uint8Array): { if (report.length < HID_REPORT_SIZE) { throw new Error(`HID report too short: ${report.length} < ${HID_REPORT_SIZE}`) } - const buttonsBlock = report.subarray(BUTTONS_OFFSET, BUTTONS_OFFSET + 16) + const buttonsBlock = report.subarray( + BUTTONS_OFFSET, + BUTTONS_OFFSET + HID_REPORT_LAYOUT.buttons.bytes + ) const buttons: boolean[] = [] for (let i = 0; i < HID_BUTTON_COUNT; i++) buttons.push(isButtonPressed(buttonsBlock, i)) diff --git a/src/shared/node-telem.ts b/src/shared/node-telem.ts index dc12a90..eab57e6 100644 --- a/src/shared/node-telem.ts +++ b/src/shared/node-telem.ts @@ -1,38 +1,23 @@ // PanelBridge node telemetry decode + latest-value store (pure, no Node/Electron deps). -// Wire contract: NodeStatus.h NODE_TELEM_* / TelemPage / PduRailFlag / PduStatusFlag (mirrored in -// src/main/reference/node-status.generated.ts; reference.test asserts they match). Page layouts: +// Wire contract: NodeStatus.h NODE_TELEM_* / TelemPage / PduRailFlag / PduStatusFlag (generated +// into shared/firmware; reference.test asserts the decoder's protoVersion matches). Page layouts: // FirmwarePlan/02-can-protocol.md § Node Telemetry Payload — TELEM_n (#216). +import { NODE_TELEM, PDU_RAIL_FLAGS, PDU_STATUS_FLAGS, TELEM_PAGES } from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_TELEM_PROTO = 1 -/** Reserved DCS-BIOS identifier (must equal NODE_TELEM.msgName in node-status.generated.ts). */ -export const NODE_TELEM_MSG = '_NODE_TELEM' - -/** TelemPage ids (must equal TELEM_PAGES). Global and append-only; 0x00 is invalid. */ -export const TELEM_PAGE = { - PDU_RAIL_12V: 0x01, - PDU_RAIL_5V: 0x02, - PDU_STATUS: 0x03 -} as const - -/** PDU_RAIL_* byte 1 bits (must equal PDU_RAIL_FLAGS). All zero = healthy. */ -export const RAIL_FLAG = { - UNDER_V: 0x01, - OVER_V: 0x02, - OVER_CURRENT: 0x04, - FUSE_OPEN: 0x08, - SENSOR_FAULT: 0x10 -} as const - -/** PDU_STATUS byte 1 bits (must equal PDU_STATUS_FLAGS). All zero = healthy, calibrated. */ -export const PDU_FLAG = { - UNCAL_VDDA: 0x01, - UNCAL_ZERO: 0x02, - TEMP_WARN: 0x04, - TEMP_CRIT: 0x08, - NTC_FAULT: 0x10 -} as const +/** Reserved DCS-BIOS identifier (NODE_TELEM.msgName in the firmware reference). */ +export const NODE_TELEM_MSG = NODE_TELEM.msgName + +/** TelemPage ids (TELEM_PAGES). Global and append-only; 0x00 is invalid. */ +export const TELEM_PAGE = TELEM_PAGES + +/** PDU_RAIL_* byte 1 bits (PDU_RAIL_FLAGS). All zero = healthy. */ +export const RAIL_FLAG = PDU_RAIL_FLAGS + +/** PDU_STATUS byte 1 bits (PDU_STATUS_FLAGS). All zero = healthy, calibrated. */ +export const PDU_FLAG = PDU_STATUS_FLAGS /** * A page older than this is stale. Each PDU page refreshes every 3 s, so 10 s is three missed diff --git a/src/shared/nodes.ts b/src/shared/nodes.ts index 21cf9f5..617d2ca 100644 --- a/src/shared/nodes.ts +++ b/src/shared/nodes.ts @@ -1,27 +1,23 @@ // PanelBridge node-status decode + roster (pure, no Node/Electron deps). -// Wire contract: NodeStatus.h NODE_STATUS_* (mirrored in -// src/main/reference/node-status.generated.ts; reference.test asserts they match). +// Wire contract: NodeStatus.h NODE_STATUS_* (generated into shared/firmware; reference.test +// asserts the decoder's protoVersion matches). import { encodeExportFrame } from './dcsbios' +import { NODE_HEALTH_FLAGS, NODE_KINDS, NODE_STATUS } from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 -/** Reserved DCS-BIOS identifiers (must equal node-status.generated.ts). */ -export const NODE_MSG = '_NODE_STATUS' -export const NODE_END_MSG = '_NODE_STATUS_END' -export const NODE_REQ_ADDR = 0x86fe - -/** hFlags bit masks (must equal NODE_HEALTH_FLAGS in node-status.generated.ts; reference.test checks). */ -export const HFLAG_OVERHEAT = 0x01 -export const HFLAG_DEGRADED = 0x02 - -/** HEALTH_n board kind, as _NODE_STATUS v3 forwards it (must equal NODE_KINDS; reference.test checks). */ -export const NODE_KIND = { - UNKNOWN: 0x00, - PANEL_GROUP: 0x01, - PDU: 0x02, - BRIDGE: 0x03 -} as const +/** Reserved DCS-BIOS identifiers (NODE_STATUS in the firmware reference). */ +export const NODE_MSG = NODE_STATUS.msgName +export const NODE_END_MSG = NODE_STATUS.endMsgName +export const NODE_REQ_ADDR = NODE_STATUS.reqAddress + +/** hFlags bit masks (NODE_HEALTH_FLAGS in the firmware reference). */ +export const HFLAG_OVERHEAT = NODE_HEALTH_FLAGS.OVERHEAT +export const HFLAG_DEGRADED = NODE_HEALTH_FLAGS.DEGRADED + +/** HEALTH_n board kind, as _NODE_STATUS v3 forwards it (NODE_KINDS in the firmware reference). */ +export const NODE_KIND = NODE_KINDS /** * The bridge's own NODE_ID: PanelBridge is the CAN master at address 0, and since v3 every roster From 087f3c56a160bb2647ea7edc90b16921608a48e9 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 20:13:08 -0400 Subject: [PATCH 06/28] build(sync): sync USB identity, node bus and calibration timing (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.2 of the structure rewrite (#101, phase #98). This PR extends the firmware sync and swaps the matching client copies for imports. Behaviour, UI and wording stay the same. The sync pin is unchanged at d29433ab. ## What changed - **`scripts/sync-a4ec.ts`** now also reads `PanelBridge/PanelBridge.cpp` and `PowerMonitor/TelemSchedule.h` (two new `SRC` entries, same sparse fetch). Two new generated files are exported from the `shared/firmware` barrel: - `node-bus.generated.ts`: `MAX_NODE_ID`, `TELEM_PERIOD_MS` - `usb-identity.generated.ts`: `USB_IDENTITY` (`vid`, `pid`, `manufacturer`, `product`, `cdcInterface`, `baud`) - **`constexprNum(src, name, where)`** now names its source file. It also evaluates a sum of literals and other constants from the same file, which `CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD` needs. Any other expression fails the sync. - **`CAL_PROTO`** gains `maxFrame` (from `AxisCal.h`) and `sessionTimeoutMs` (from `SimGateway.cpp` `CAL_SESSION_TIMEOUT_MS`). - **`HID_REPORT_LAYOUT.hats.directions`** comes from the descriptor's hat Logical Minimum/Maximum bytes (`0x15 00` / `0x25 07`), giving 8. `centeredAtOrAbove` is now derived from the same range (logical max + 1 = 8, the same value as before). The sync fails if the logical minimum is ever not 0. The direction names (`HAT_DIRS`, CTR N … NW) stay client display labels. - **B10, keys only.** `PDU_RAIL_FLAG_INFO` and `PDU_STATUS_FLAG_INFO` map each bit to `{ name, description }`, with the description taken verbatim from the `parseEnum` comment capture, the same way `NODE_FAULT_CODES` does it. The client's flag wording (`cockpit-panels.ts`, `events.ts`) is **not** replaced. It differs from the firmware comments, so replacing it would be a behaviour change. - **The `axis-cal.generated.ts` banner** no longer claims the codec "restates these". It now says the codec imports them through the barrel and that `reference.test.ts` pins the values. ## Values → generated name → client site | # | Value | Firmware source (d29433ab) | Generated | Client site replaced | Verified | |---|---|---|---|---|---| | B1 | 63 | `PanelBridge.cpp` `static constexpr uint8_t MAX_NODE_ID = 63` | `MAX_NODE_ID` | `shared/node-mapping.ts` `isNodeId` `v <= 63`; `shared/nodes.ts` `nodeId > 63` (and the field comment); `shared/node-telem.ts` `nodeId > 63` | P (both decoders' gates for every id 0–255), R | | B12 | 0x2E8A / 0x4134 | `SimGateway.cpp` `TinyUSBDevice.setID(0x2E8A, 0x4134)` | `USB_IDENTITY.vid/.pid` | `main/serial.ts` `SIMGATEWAY_VID/PID` (also feeds `main/hid.ts`); `Connection.tsx` "0x2E8A / 0x4134" (both rows) | P, R, H | | B13 | "A-4E Skyhawk", "OpenSkyhawk", "A-4E Skyhawk DCS-BIOS" | `SimGateway.cpp` `setProductDescriptor` / `setManufacturerDescriptor` / `Serial.setStringDescriptor` | `USB_IDENTITY.product/.manufacturer/.cdcInterface` | `Connection.tsx` Identity row and "A-4E Skyhawk · Gateway" | P, R, H | | B14 | 250000 | `SimGateway.cpp` `Serial.begin(250000)` (the CDC one, not `_uart->begin`) | `USB_IDENTITY.baud` | `main/serial.ts` `BAUD` | P, R | | B15 | 92 | `AxisCal.h` `CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD` | `CAL_PROTO.maxFrame` | `shared/calibration.ts` `CAL_MAX_FRAME` (was the same sum, client-side) | P (= envelope + payload = 92), R | | B16 | 30000 | `SimGateway.cpp` `constexpr uint32_t CAL_SESSION_TIMEOUT_MS = 30000` | `CAL_PROTO.sessionTimeoutMs` | `main/callink.ts` `deviceTimeoutMs` default (`30_000`). `KEEPALIVE_MS = 10_000` stays client policy; only its comment cites the synced timeout. | P (read off a fresh `CalLink`), R | | B20 | 1000 | `TelemSchedule.h` `static constexpr uint32_t TELEM_PERIOD_MS = 1000` | `TELEM_PERIOD_MS` | None in code. `TELEM_STALE_MS = 10_000` stays client policy, and its comment now cites the period. | R | | B11 | 8 | `SimGateway.cpp` HID descriptor, hat Logical Min/Max | `HID_REPORT_LAYOUT.hats.directions` | `tabs/Hid.tsx` "8-way POV" (was built from `hats.centeredAtOrAbove`) | P, R, H | | R1.1 (b) | 32767 | `SimGateway.cpp` axis Logical Maximum (already synced) | `HID_REPORT_LAYOUT.axes.max` | `CalibrationDialog.tsx` "· signed ±32767" and the scale's `32767` | P, R, H | How each value was checked: - **(R)** A new `reference.test.ts` assertion pins the value equal to the old literal, so a firmware change fails loudly. The pinned values are VID `0x2e8a`, PID `0x4134`, the three strings, baud 250000, `MAX_NODE_ID` 63, `TELEM_PERIOD_MS` 1000, `hats.directions` 8 (plus `centeredAtOrAbove` 8 and `axes.max` 32767), `maxFrame` 92 (= envelope + payload) and `sessionTimeoutMs` 30000. - **(P)** A one-off proof test, not committed. It compared every replacement with the old literal: the exported `SIMGATEWAY_VID/PID`, the built `"0x2E8A / 0x4134"` string, `CAL_MAX_FRAME`, a fresh `CalLink`'s `deviceTimeoutMs`, `parseNodeStatus`/`parseNodeTelem` acceptance for node ids 0–255, and `CAL_TYPE`/`CAL_NACK` entries in key order. 5/5 passed. - **(H)** A one-off render proof, also not committed. In jsdom it rendered `` twice (connected, and no-device), `` once (connected), and `` twice (link up, and link down/paused), with its controller fed an 8-axis snapshot. It dumped `innerHTML` and `textContent` once at the base (f833939) and once at this head. The two 27.8 KB dumps are **identical** (SHA-1 `07bd9daca558bfadb75841b4beabd061ee5baecf`). The dumps contain every swapped string: "0x2E8A / 0x4134", "0x2E8A / 0x4134 · target", "A-4E Skyhawk", "A-4E Skyhawk · Gateway", "8-way POV", "· signed ±32767" and ">32767<". Each swapped JSX text is still a single text node (a template literal), so the markup does not split. **No value differed, so every one was swapped.** ## Typed names (R1.1 carry-over) The generator now emits literal name unions next to the code-keyed tables: `CalTypeName`, `CalNackName` and `NodeFaultName`. `PduRailFlagName` / `PduStatusFlagName` are `keyof typeof` the flag objects. The info interfaces now type `name` with these unions. The tables themselves keep `Partial>`, so indexing by a wire byte is still allowed and still yields `| undefined`. - `byName()` in `shared/calibration.ts` now returns `Readonly>`. `CAL_TYPE.HELLO` type-checks and `CAL_TYPE.FOO` does not compile. - `REACHABLE_NACK` is a `ReadonlySet`. - `FAULT_NOTE` (`shared/gateway.ts`) and `FAULT_TEXT` (`main/node-hub.ts`) are keyed by `NodeFaultName`, not `string`. - `reference.test.ts` proves this with `// @ts-expect-error` on `CAL_TYPE.FOO`, `CAL_NACK.BAD_FOO` and a misspelt `NodeFaultName`. I checked that the directives carry weight: with the `CAL_TYPE.FOO` one removed, `tsc` fails with `TS2339: Property 'FOO' does not exist on type 'Readonly>'`. ## Grep proof Each pattern was run with `git grep -nE -- src ':!*.generated.ts' ':!*.test.ts' ':!*.test.tsx'`, with comment lines filtered out. The counts are base (f833939) → head: ``` 3 -> 0 (<=|>) 63([^0-9]|$) 5 -> 2 0x2e8a|0x2E8A|0x4134 1 -> 0 250000 2 -> 0 'A-4E Skyhawk'|>A-4E Skyhawk 1 -> 0 CAL_ENVELOPE_BYTES \+ CAL_MAX_PAYLOAD 1 -> 0 deviceTimeoutMs = 30_000 2 -> 0 signed ±32767|32767 1 -> 0 centeredAtOrAbove\}-way 4 -> 0 Partial>|ReadonlySet ``` The two remaining VID/PID hits are in `src/main/test-harness/services.ts:126-127`, the `vi.doMock('../serial', …)` double. The same double is in `session.test.ts:80-81`. They are test fakes of the module's exports, not production copies, and I left them alone. ## Not done here, on purpose - Out of scope until R1.5 / the pin bump: B2–B9, B17, B18 and B21. B19 is untouched, since it is already derived. - B13 `manufacturer` and `cdcInterface` are synced and pinned, but no client code reads them today, so no site changed. - No client wording was replaced with firmware comments (B10). ## Checks - `npm run sync` at d29433ab regenerates every file. Running it twice gives no diff, and it was run again after the commit hooks: `git diff --exit-code -- src/shared/firmware` is clean, which is the CI freshness gate. - `npm run typecheck` is clean. - `npm run lint` has 0 errors (32 warnings, the same as the baseline). - `npm run lint:deps` has 0 errors (65 warnings, the same as the baseline). - `npx vitest run` passes 62 files: 852 tests, which is 844 plus 8 new `reference.test` cases. No existing assertion changed. - `npm run build` succeeds. Refs #98 --- scripts/sync-a4ec.ts | 208 +++++++++++++++--- src/main/callink.ts | 8 +- src/main/node-hub.ts | 9 +- src/main/serial.ts | 7 +- src/renderer/src/CalibrationDialog.tsx | 4 +- src/renderer/src/tabs/Connection.tsx | 11 +- src/renderer/src/tabs/Hid.tsx | 2 +- src/shared/calibration.ts | 27 ++- src/shared/firmware/axis-cal.generated.ts | 42 +++- .../firmware/hid-report-layout.generated.ts | 4 +- src/shared/firmware/index.ts | 2 + src/shared/firmware/node-bus.generated.ts | 14 ++ src/shared/firmware/node-status.generated.ts | 51 ++++- src/shared/firmware/reference.test.ts | 76 ++++++- src/shared/firmware/usb-identity.generated.ts | 18 ++ src/shared/gateway.ts | 4 +- src/shared/node-mapping.ts | 3 +- src/shared/node-telem.ts | 10 +- src/shared/nodes.ts | 6 +- 19 files changed, 441 insertions(+), 65 deletions(-) create mode 100644 src/shared/firmware/node-bus.generated.ts create mode 100644 src/shared/firmware/usb-identity.generated.ts diff --git a/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index cf4323a..d2f1dc9 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -6,6 +6,12 @@ * tools/gen_a4ec/data/A-4E-C.jsonp -> a4ec-controls.generated.ts * Firmware/Libraries/HIDControls/HIDControls.h -> hid-controls.generated.ts * Firmware/Libraries/SimGateway/SimGateway.cpp -> hid-report-layout.generated.ts (asserted) + * -> usb-identity.generated.ts + * Firmware/Libraries/NodeStatus/NodeStatus.h -> node-status.generated.ts + * Firmware/Libraries/SimGateway/AxisCal.{h,cpp} -> axis-cal.generated.ts + * Firmware/Libraries/PanelBridge/PanelBridge.cpp, + * Firmware/Libraries/PowerMonitor/TelemSchedule.h -> node-bus.generated.ts + * Firmware/NODE_IDS.md -> node-names.generated.ts * * The DCS-BIOS common-metadata address (_ACFT_NAME @ 0x0000) is NOT vendored in * the firmware repo, so it lives in the hand-curated src/main/reference/dcsbios-metadata.ts. @@ -38,7 +44,9 @@ const SRC = { simGateway: 'Firmware/Libraries/SimGateway/SimGateway.cpp', axisCal: 'Firmware/Libraries/SimGateway/AxisCal.h', axisCalCpp: 'Firmware/Libraries/SimGateway/AxisCal.cpp', - nodeIds: 'Firmware/NODE_IDS.md' + nodeIds: 'Firmware/NODE_IDS.md', + panelBridge: 'Firmware/Libraries/PanelBridge/PanelBridge.cpp', + telemSchedule: 'Firmware/Libraries/PowerMonitor/TelemSchedule.h' } const REPO_ROOT = join(__dirname, '..') @@ -191,6 +199,27 @@ const labelize = (name: string): string => }) .join(' ') +/** + * `[static] constexpr NAME = ;` — the scalar form the firmware uses for constants. + * + * `` is a literal, or a sum of literals and other constants from the same source + * (`CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD`), each resolved the same way. Anything + * else (a cast, a product, a call) is not a value this parser can vouch for, so it fails the sync. + */ +function constexprNum(src: string, name: string, where: string): number { + const m = new RegExp(`constexpr\\s+\\w+\\s+${name}\\s*=\\s*([^;]+);`).exec(src) + if (!m) throw new Error(`[sync] constant ${name} not found in ${where}`) + return m[1]!.split('+').reduce((sum, raw) => { + const term = raw.trim() + if (/^(0x[0-9A-Fa-f]+|\d+)$/.test(term)) return sum + Number(term) + if (/^[A-Z_][A-Z0-9_]*$/.test(term)) return sum + constexprNum(src, term, where) + throw new Error(`[sync] ${name} in ${where}: cannot evaluate '${m[1]!.trim()}'`) + }, 0) +} + +/** Enum entry names -> a TS string-literal union, so name-keyed lookups are typo-checked. */ +const nameUnion = (names: string[]): string => names.map(q).join(' | ') + // ── A-4E-C controls ────────────────────────────────────────────────────────── interface RawOutput { @@ -436,6 +465,24 @@ function genHidReportLayout(cpp: string): string { // Axis USB usages, in report order, from the descriptor comments. const usages = [...cpp.matchAll(/Usage \((X|Y|Z|Rx|Ry|Rz|Slider|Dial)\)/g)].map((u) => u[1]!) + // Hat logical range, from the descriptor bytes between the Hat Switch usages and their Input + // item: Logical Minimum (0x15 nn) and Logical Maximum (0x25 nn). Every value in range is a + // direction, clockwise from N at the minimum; anything above it is the null (centered) state. + const hatBlock = /0x09,\s*0x39,[\s\S]*?0x81,/.exec(cpp)?.[0] + if (!hatBlock) + throw new Error('[sync] hat switch block not found in the SimGateway HID descriptor') + const hatLogical = (prefix: '0x15' | '0x25', label: string): number => { + const m = new RegExp(`${prefix},\\s*(0x[0-9A-Fa-f]{2})`).exec(hatBlock) + if (!m) throw new Error(`[sync] hat ${label} not found in the SimGateway HID descriptor`) + return Number(m[1]) + } + const hatMin = hatLogical('0x15', 'Logical Minimum') + const hatMax = hatLogical('0x25', 'Logical Maximum') + if (hatMin !== 0) { + throw new Error(`[sync] hat Logical Minimum is ${hatMin}; the decoder assumes 0 = N`) + } + const hatDirections = hatMax - hatMin + 1 + return ( banner([`${SRC.simGateway} (HIDReport struct + descriptor)`]) + ` @@ -445,7 +492,7 @@ function genHidReportLayout(cpp: string): string { * * Semantics from the USB HID descriptor (NB: differ from the CAN controlId space): * - axes are SIGNED int16, little-endian, logical range −32768..32767 (no 0x8000 bias). - * - hat nibbles encode 0..7 = N/NE/E/SE/S/SW/W/NW; value ≥ 8 (e.g. 0xF) = null/centered. + * - hat nibbles encode ${hatMin}..${hatMax} = N/NE/E/SE/S/SW/W/NW; value ≥ ${hatMax + 1} (e.g. 0xF) = null/centered. */ export const HID_REPORT_LAYOUT = { size: ${offset}, @@ -455,8 +502,10 @@ export const HID_REPORT_LAYOUT = { bytes: 2, count: 4, nibbleBits: 4, - /** Decoded value ≥ this is the centered/null state. */ - centeredAtOrAbove: 8, + /** Directions a hat reports: the descriptor's logical range, clockwise from N. */ + directions: ${hatDirections}, + /** Decoded value ≥ this is the centered/null state: above the logical range. */ + centeredAtOrAbove: ${hatMax + 1}, nullNibble: 0xf }, axes: { @@ -505,6 +554,11 @@ function genNodeStatus(header: string): string { const statusFlags = parseEnum(header, 'PduStatusFlag', 'NodeStatus.h') const bits = (es: { name: string; value: number }[]) => es.map((e) => ` ${e.name}: ${hex(e.value)}`).join(',\n') + // bit -> name + the header's trailing comment, verbatim (keys and firmware notes, not UI text). + const flagInfo = (es: { name: string; value: number; comment: string }[]) => + es + .map((e) => ` ${hex(e.value)}: { name: ${q(e.name)}, description: ${q(e.comment)} }`) + .join(',\n') const faultAbbr = abbrs(faultCodes.map((e) => e.name)) const flagEntries = healthFlags.map((e) => ` ${e.name}: ${hex(e.value)}`).join(',\n') @@ -545,8 +599,11 @@ export const NODE_KINDS = { ${bits(nodeKinds)} } as const +/** NodeFaultCode entry names: the keys client wording tables look a fault up by. */ +export type NodeFaultName = ${nameUnion(faultCodes.map((e) => e.name))} + export interface NodeFaultInfo { - name: string + name: NodeFaultName abbr: string label: string description: string @@ -588,21 +645,34 @@ ${bits(railFlags)} export const PDU_STATUS_FLAGS = { ${bits(statusFlags)} } as const -` - ) + +export type PduRailFlagName = keyof typeof PDU_RAIL_FLAGS +export type PduStatusFlagName = keyof typeof PDU_STATUS_FLAGS + +export interface PduFlagInfo { + name: N + /** The header's comment on the flag, verbatim. A firmware note, not client wording. */ + description: string } -// ── axis-calibration wire contract (OpenSkyhawk#251) ───────────────────────── +/** + * PDU_RAIL_FLAGS bit -> its name and the PduRailFlag comment (NodeStatus.h). The client words its + * own flag meanings; this is the firmware's side, keyed the same way. + */ +export const PDU_RAIL_FLAG_INFO: Partial>> = { +${flagInfo(railFlags)} +} as const -/** `constexpr NAME = ;` — the scalar form AxisCal.h uses for its constants. */ -function constexprNum(header: string, name: string): number { - const m = new RegExp(`constexpr\\s+\\w+\\s+${name}\\s*=\\s*(0x[0-9A-Fa-f]+|\\d+)\\s*;`).exec( - header +/** PDU_STATUS_FLAGS bit -> its name and the PduStatusFlag comment (NodeStatus.h). */ +export const PDU_STATUS_FLAG_INFO: Partial>> = { +${flagInfo(statusFlags)} +} as const +` ) - if (!m) throw new Error(`[sync] constant ${name} not found in AxisCal.h`) - return Number(m[1]) } +// ── axis-calibration wire contract (OpenSkyhawk#251) ───────────────────────── + /** * Payload length per message type, from the `calLenValidForType` switch in AxisCal.cpp. * @@ -641,7 +711,7 @@ function parseLenTable(cpp: string, typeNames: string[]): Record return out } -function genAxisCal(header: string, cpp: string): string { +function genAxisCal(header: string, cpp: string, simGateway: string): string { const types = parseEnum(header, 'CalType', 'AxisCal.h') const nacks = parseEnum(header, 'CalNackReason', 'AxisCal.h') const lens = parseLenTable( @@ -681,36 +751,48 @@ function genAxisCal(header: string, cpp: string): string { return ( banner([ `${SRC.axisCal} (calibration wire contract)`, - `${SRC.axisCalCpp} (payload length table)` + `${SRC.axisCalCpp} (payload length table)`, + `${SRC.simGateway} (calibration session timeout)` ]) + ` /** * SimGateway axis-calibration protocol constants. Wire contract: * FirmwarePlan/03-uart-usb-hid-protocol.md § Calibration Protocol (USB CDC). * - * The codec in shared/calibration.ts restates these so \`src/shared\` stays free of a - * \`src/main\` import; reference.test.ts asserts the two agree, so a firmware bump fails loudly. + * The codec in shared/calibration.ts imports these through the shared/firmware barrel rather + * than restating them; reference.test.ts pins the values the codec is written for, so a firmware + * bump fails loudly. */ export const CAL_PROTO = { /** Protocol version. HELLO_ACK carries this; gate on it, not on the firmware version. */ - version: ${constexprNum(header, 'CAL_PROTO_VERSION')}, + version: ${constexprNum(header, 'CAL_PROTO_VERSION', 'AxisCal.h')}, /** Stored-blob layout version, reported by HELLO_ACK as \`blobVersion\`. */ - blobVersion: ${constexprNum(header, 'CAL_VERSION')}, + blobVersion: ${constexprNum(header, 'CAL_VERSION', 'AxisCal.h')}, /** HID report axis slots. Fixed by the descriptor, not by how many a cockpit populates. */ - axisSlots: ${constexprNum(header, 'AXIS_CAL_SLOTS')}, + axisSlots: ${constexprNum(header, 'AXIS_CAL_SLOTS', 'AxisCal.h')}, /** Axis-selection sentinel: "all" for RESET, "none" for SESSION_OPEN / STREAM_SELECT. */ - axisNone: ${hex(constexprNum(header, 'CAL_AXIS_NONE'))}, + axisNone: ${hex(constexprNum(header, 'CAL_AXIS_NONE', 'AxisCal.h'))}, /** magic 4 + type 1 + seq 1 + len 2 + crc 2. */ - envelopeBytes: ${constexprNum(header, 'CAL_ENVELOPE_BYTES')}, + envelopeBytes: ${constexprNum(header, 'CAL_ENVELOPE_BYTES', 'AxisCal.h')}, /** CAL_DATA, the largest legal payload — the bound on how much a receiver may buffer. */ - maxPayload: ${constexprNum(header, 'CAL_MAX_PAYLOAD')}, + maxPayload: ${constexprNum(header, 'CAL_MAX_PAYLOAD', 'AxisCal.h')}, + /** Envelope + the largest payload: the largest complete frame, and so a receiver's carry bound. */ + maxFrame: ${constexprNum(header, 'CAL_MAX_FRAME', 'AxisCal.h')}, + /** The gateway closes a calibration session idle this long. SESSION_ACK reports it too. */ + sessionTimeoutMs: ${constexprNum(simGateway, 'CAL_SESSION_TIMEOUT_MS', 'SimGateway.cpp')}, /** Frame lead-in "\\xAA S K C". Not collision-proof outbound, which is why the CRC is mandatory. */ magic: [${magic.map(hex).join(', ')}] } as const +/** Message type names, as the protocol doc writes them. */ +export type CalTypeName = ${nameUnion(types.map((t) => short(t.name)))} + +/** NACK reason names, as the protocol doc writes them. */ +export type CalNackName = ${nameUnion(nacks.map((n) => short(n.name)))} + export interface CalTypeInfo { /** Name as the protocol doc writes it, e.g. \`SESSION_OPEN\`. */ - name: string + name: CalTypeName /** The firmware identifier it came from, e.g. \`CAL_T_SESSION_OPEN\`. */ macro: string label: string @@ -726,7 +808,7 @@ ${typeEntries} } as const export interface CalNackInfo { - name: string + name: CalNackName macro: string label: string } @@ -789,6 +871,76 @@ export const NODE_REGISTRY_SOURCE = { ) } +// ── CAN node bus (PanelBridge roster, PDU telemetry schedule) ──────────────── + +function genNodeBus(panelBridge: string, telemSchedule: string): string { + return ( + banner([`${SRC.panelBridge} (MAX_NODE_ID)`, `${SRC.telemSchedule} (TELEM_PERIOD_MS)`]) + + ` +/** Highest CAN NODE_ID PanelBridge tracks. Panel nodes are 1..MAX_NODE_ID. */ +export const MAX_NODE_ID = ${constexprNum(panelBridge, 'MAX_NODE_ID', 'PanelBridge.cpp')} + +/** + * A PDU sends one TELEM_n frame per period, rotating through its pages, so each page refreshes + * every (page count × period). How long the client waits before calling a page stale is its own + * policy, not this. + */ +export const TELEM_PERIOD_MS = ${constexprNum(telemSchedule, 'TELEM_PERIOD_MS', 'TelemSchedule.h')} +` + ) +} + +// ── SimGateway USB identity ────────────────────────────────────────────────── + +function genUsbIdentity(cpp: string): string { + /** The single match of `re` in SimGateway.cpp; zero or several fails the sync. */ + const only = (re: RegExp, label: string): RegExpMatchArray => { + const all = [...cpp.matchAll(re)] + if (all.length !== 1) { + throw new Error(`[sync] USB ${label}: expected one in SimGateway.cpp, found ${all.length}`) + } + return all[0]! + } + const id = only( + /TinyUSBDevice\.setID\(\s*(0x[0-9A-Fa-f]+)\s*,\s*(0x[0-9A-Fa-f]+)\s*\)/g, + 'setID(vid, pid)' + ) + const str = (re: RegExp, label: string) => only(re, label)[1]! + const manufacturer = str( + /TinyUSBDevice\.setManufacturerDescriptor\(\s*"([^"]*)"\s*\)/g, + 'manufacturer descriptor' + ) + const product = str( + /TinyUSBDevice\.setProductDescriptor\(\s*"([^"]*)"\s*\)/g, + 'product descriptor' + ) + const cdcInterface = str( + /\bSerial\.setStringDescriptor\(\s*"([^"]*)"\s*\)/g, + 'CDC interface name' + ) + const baud = Number(only(/\bSerial\.begin\(\s*(\d+)\s*\)/g, 'CDC Serial.begin(baud)')[1]) + + return ( + banner([`${SRC.simGateway} (SimGateway::setup USB identity)`]) + + ` +/** + * How the SimGateway enumerates over USB, as SimGateway::setup() sets it before TinyUSB starts. + * The client finds the Gateway's serial port by vid/pid. + */ +export const USB_IDENTITY = { + vid: ${hex(Number(id[1]))}, + pid: ${hex(Number(id[2]))}, + manufacturer: ${q(manufacturer)}, + product: ${q(product)}, + /** The CDC interface name (iInterface), so the serial port is identifiable by name. */ + cdcInterface: ${q(cdcInterface)}, + /** Nominal CDC baud. USB CDC ignores it, but opening a serial port needs a value. */ + baud: ${baud} +} as const +` + ) +} + // ── main ───────────────────────────────────────────────────────────────────── async function writeGenerated(file: string, code: string): Promise { @@ -806,7 +958,9 @@ async function main(): Promise { await writeGenerated('hid-report-layout.generated.ts', genHidReportLayout(s.simGateway)) await writeGenerated('node-status.generated.ts', genNodeStatus(s.nodeStatus)) await writeGenerated('node-names.generated.ts', genNodeNames(s.nodeIds, s.commitDate)) - await writeGenerated('axis-cal.generated.ts', genAxisCal(s.axisCal, s.axisCalCpp)) + await writeGenerated('axis-cal.generated.ts', genAxisCal(s.axisCal, s.axisCalCpp, s.simGateway)) + await writeGenerated('node-bus.generated.ts', genNodeBus(s.panelBridge, s.telemSchedule)) + await writeGenerated('usb-identity.generated.ts', genUsbIdentity(s.simGateway)) } finally { s.cleanup() } diff --git a/src/main/callink.ts b/src/main/callink.ts index 8a662a3..c37b630 100644 --- a/src/main/callink.ts +++ b/src/main/callink.ts @@ -26,12 +26,16 @@ import { type HelloAck, type RawSample } from '@shared/calibration' +import { CAL_PROTO } from '@shared/firmware' import { debugLog } from './debug' /** Past the device's worst-case sector erase (~28 ms measured) by a wide margin. */ const REPLY_TIMEOUT_MS = 2000 -/** The device closes an idle session after 30 s; a user reading a dialog step sends nothing. */ +/** + * The device closes an idle session after CAL_PROTO.sessionTimeoutMs (30 s); a user reading a + * dialog step sends nothing. The keepalive interval is client policy. + */ const KEEPALIVE_MS = 10_000 /** The device answered and refused. Distinct from a timeout, and needs different messaging. */ @@ -83,7 +87,7 @@ export class CalLink { /** Fallback timer that reopens the gate after a close we never got an answer to. */ private expiry?: ReturnType /** The device's idle expiry, as it reported in SESSION_ACK. Default until it tells us. */ - private deviceTimeoutMs = 30_000 + private deviceTimeoutMs: number = CAL_PROTO.sessionTimeoutMs constructor( private readonly write: (data: Buffer) => void, diff --git a/src/main/node-hub.ts b/src/main/node-hub.ts index c559221..db0af66 100644 --- a/src/main/node-hub.ts +++ b/src/main/node-hub.ts @@ -34,7 +34,12 @@ import { type ResetPreview } from '@shared/node-mapping' import { buildNodeModel, type NodeModel } from '@shared/node-rules' -import { NODE_FAULT_CODES, NODE_NAMES, NODE_REGISTRY_SOURCE } from '@shared/firmware' +import { + NODE_FAULT_CODES, + NODE_NAMES, + NODE_REGISTRY_SOURCE, + type NodeFaultName +} from '@shared/firmware' import { loadNodeMapping, MappingSaveError, @@ -53,7 +58,7 @@ const NODE_TELEM_PUSH_MS = 1000 * Client wording where the generated text reads like firmware notes (the label comes from the enum * name, the description from the header comment). Keyed by NODE_FAULT_CODES name. */ -const FAULT_TEXT: Partial> = { +const FAULT_TEXT: Partial> = { DUPLICATE_NODE_ID: { label: 'Duplicate NODE_ID', description: diff --git a/src/main/serial.ts b/src/main/serial.ts index a9fe4e5..e59ff24 100644 --- a/src/main/serial.ts +++ b/src/main/serial.ts @@ -2,10 +2,11 @@ // the DCS-BIOS byte stream both ways — exactly like the socat relay did. The HID // interface is a separate device the OS claims; it is handled in M5, not here. import { SerialPort } from 'serialport' +import { USB_IDENTITY } from '@shared/firmware' import { debugLog } from './debug' -export const SIMGATEWAY_VID = 0x2e8a -export const SIMGATEWAY_PID = 0x4134 +export const SIMGATEWAY_VID = USB_IDENTITY.vid +export const SIMGATEWAY_PID = USB_IDENTITY.pid const VID_HEX = SIMGATEWAY_VID.toString(16).padStart(4, '0') const PID_HEX = SIMGATEWAY_PID.toString(16).padStart(4, '0') @@ -17,7 +18,7 @@ export function listSerialPorts(): Promise { } /** USB-CDC ignores baud (virtual), but node-serialport requires a value. */ -const BAUD = 250000 +const BAUD = USB_IDENTITY.baud const RECONNECT_MS = 2000 /** diff --git a/src/renderer/src/CalibrationDialog.tsx b/src/renderer/src/CalibrationDialog.tsx index b781d36..fe4b331 100644 --- a/src/renderer/src/CalibrationDialog.tsx +++ b/src/renderer/src/CalibrationDialog.tsx @@ -205,7 +205,7 @@ export function CalibrationDialog({ ) : ( connected )}{' '} - · signed ±32767 + {`· signed ±${HID_REPORT_LAYOUT.axes.max}`}
@@ -331,7 +331,7 @@ export function CalibrationDialog({
−{AXIS_OFFSET} 0 - 32767 + {HID_REPORT_LAYOUT.axes.max}
diff --git a/src/renderer/src/tabs/Connection.tsx b/src/renderer/src/tabs/Connection.tsx index fff87c4..7eff9a1 100644 --- a/src/renderer/src/tabs/Connection.tsx +++ b/src/renderer/src/tabs/Connection.tsx @@ -1,11 +1,16 @@ import { useMemo } from 'react' import { useStore, type SourceMode, type Transport } from '../store' +import { USB_IDENTITY } from '@shared/firmware' import { isLinkUp } from '@shared/ipc' import { nodeViews } from '../cockpit' import { nodesSummary } from '../connection-summary' import { deviceView, relayNote, startBlocked, startBlockedReason } from '../device-view' import { Dot } from './cockpit/Dot' +/** `0x2E8A`: a USB id as the device card prints it, four upper-case hex digits. */ +const usbId = (n: number) => '0x' + n.toString(16).toUpperCase().padStart(4, '0') +const VID_PID = `${usbId(USB_IDENTITY.vid)} / ${usbId(USB_IDENTITY.pid)}` + const REC_DOT = (
-
A-4E Skyhawk · Gateway
+
{`${USB_IDENTITY.product} · Gateway`}
composite USB · CDC + HID
diff --git a/src/renderer/src/tabs/Hid.tsx b/src/renderer/src/tabs/Hid.tsx index 1ef9b6a..05b71c4 100644 --- a/src/renderer/src/tabs/Hid.tsx +++ b/src/renderer/src/tabs/Hid.tsx @@ -225,7 +225,7 @@ export function Hid() {
Hats - {HID_REPORT_LAYOUT.hats.centeredAtOrAbove}-way POV + {HID_REPORT_LAYOUT.hats.directions}-way POV
{s.hats.map((d, i) => ( diff --git a/src/shared/calibration.ts b/src/shared/calibration.ts index d6dfd67..c4272f2 100644 --- a/src/shared/calibration.ts +++ b/src/shared/calibration.ts @@ -12,7 +12,13 @@ // that way throughout the client. The ±32767 seen on the HID tab comes from the HID report, // where the firmware applied the offset; nothing here re-derives it. -import { CAL_NACK_REASONS, CAL_PAYLOAD_LEN, CAL_PROTO, CAL_TYPES } from './firmware' +import { + CAL_NACK_REASONS, + CAL_PAYLOAD_LEN, + CAL_PROTO, + CAL_TYPES, + type CalNackName +} from './firmware' /** * Constants from the generated firmware reference (shared/firmware, AxisCal.h), under the names @@ -35,11 +41,16 @@ export const AXIS_CAL_SLOTS = CAL_PROTO.axisSlots /** Axis sentinel: "all" for RESET, "none" for SESSION_OPEN / STREAM_SELECT. */ export const CAL_AXIS_NONE = CAL_PROTO.axisNone -/** A generated code-keyed table, turned around: name -> wire code. */ -function byName( - table: Partial> -): Readonly> { - return Object.fromEntries(Object.entries(table).map(([code, info]) => [info!.name, Number(code)])) +/** + * A generated code-keyed table, turned around: name -> wire code. Keyed by the table's generated + * name union, so `CAL_TYPE.HELLO` type-checks and a misspelt name does not compile. + */ +function byName( + table: Partial> +): Readonly> { + return Object.fromEntries( + Object.entries(table).map(([code, info]) => [info!.name, Number(code)]) + ) as Record } /** Message types, by name (CAL_TYPES). High bit set = device -> client. */ @@ -57,7 +68,7 @@ export const CAL_TYPE = byName(CAL_TYPES) export const CAL_NACK = byName(CAL_NACK_REASONS) /** The five NACK reasons that can actually reach a client (see CAL_NACK). */ -const REACHABLE_NACK: ReadonlySet = new Set([ +const REACHABLE_NACK: ReadonlySet = new Set([ 'BAD_INDEX', 'BAD_ORDER', 'NO_SESSION', @@ -91,7 +102,7 @@ export function nackReasonName(reason: number): string { export { CAL_PAYLOAD_LEN } /** Largest complete frame, and therefore the hard bound on CalDemux's carry. */ -export const CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD +export const CAL_MAX_FRAME = CAL_PROTO.maxFrame // ── CRC ────────────────────────────────────────────────────────────────────── diff --git a/src/shared/firmware/axis-cal.generated.ts b/src/shared/firmware/axis-cal.generated.ts index 3d37733..0ac4add 100644 --- a/src/shared/firmware/axis-cal.generated.ts +++ b/src/shared/firmware/axis-cal.generated.ts @@ -2,13 +2,15 @@ // Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 // Firmware/Libraries/SimGateway/AxisCal.h (calibration wire contract) // Firmware/Libraries/SimGateway/AxisCal.cpp (payload length table) +// Firmware/Libraries/SimGateway/SimGateway.cpp (calibration session timeout) /** * SimGateway axis-calibration protocol constants. Wire contract: * FirmwarePlan/03-uart-usb-hid-protocol.md § Calibration Protocol (USB CDC). * - * The codec in shared/calibration.ts restates these so `src/shared` stays free of a - * `src/main` import; reference.test.ts asserts the two agree, so a firmware bump fails loudly. + * The codec in shared/calibration.ts imports these through the shared/firmware barrel rather + * than restating them; reference.test.ts pins the values the codec is written for, so a firmware + * bump fails loudly. */ export const CAL_PROTO = { /** Protocol version. HELLO_ACK carries this; gate on it, not on the firmware version. */ @@ -23,13 +25,45 @@ export const CAL_PROTO = { envelopeBytes: 10, /** CAL_DATA, the largest legal payload — the bound on how much a receiver may buffer. */ maxPayload: 82, + /** Envelope + the largest payload: the largest complete frame, and so a receiver's carry bound. */ + maxFrame: 92, + /** The gateway closes a calibration session idle this long. SESSION_ACK reports it too. */ + sessionTimeoutMs: 30000, /** Frame lead-in "\xAA S K C". Not collision-proof outbound, which is why the CRC is mandatory. */ magic: [0xaa, 0x53, 0x4b, 0x43] } as const +/** Message type names, as the protocol doc writes them. */ +export type CalTypeName = + | 'HELLO' + | 'GET_CAL' + | 'SESSION_OPEN' + | 'SESSION_CLOSE' + | 'COMMIT' + | 'RESET' + | 'KEEPALIVE' + | 'STREAM_SELECT' + | 'HELLO_ACK' + | 'CAL_DATA' + | 'SESSION_ACK' + | 'ACK' + | 'NACK' + | 'RAW' + +/** NACK reason names, as the protocol doc writes them. */ +export type CalNackName = + | 'BAD_CRC' + | 'BAD_LENGTH' + | 'BAD_TYPE' + | 'BAD_INDEX' + | 'BAD_ORDER' + | 'NO_SESSION' + | 'NO_STORAGE' + | 'BAD_DEADZONE' + export interface CalTypeInfo { /** Name as the protocol doc writes it, e.g. `SESSION_OPEN`. */ - name: string + name: CalTypeName /** The firmware identifier it came from, e.g. `CAL_T_SESSION_OPEN`. */ macro: string label: string @@ -106,7 +140,7 @@ export const CAL_TYPES: Partial> = { } as const export interface CalNackInfo { - name: string + name: CalNackName macro: string label: string } diff --git a/src/shared/firmware/hid-report-layout.generated.ts b/src/shared/firmware/hid-report-layout.generated.ts index 97528d1..296289c 100644 --- a/src/shared/firmware/hid-report-layout.generated.ts +++ b/src/shared/firmware/hid-report-layout.generated.ts @@ -18,7 +18,9 @@ export const HID_REPORT_LAYOUT = { bytes: 2, count: 4, nibbleBits: 4, - /** Decoded value ≥ this is the centered/null state. */ + /** Directions a hat reports: the descriptor's logical range, clockwise from N. */ + directions: 8, + /** Decoded value ≥ this is the centered/null state: above the logical range. */ centeredAtOrAbove: 8, nullNibble: 0xf }, diff --git a/src/shared/firmware/index.ts b/src/shared/firmware/index.ts index 08db85b..2052738 100644 --- a/src/shared/firmware/index.ts +++ b/src/shared/firmware/index.ts @@ -7,5 +7,7 @@ export * from './a4ec-controls.generated' export * from './axis-cal.generated' export * from './hid-controls.generated' export * from './hid-report-layout.generated' +export * from './node-bus.generated' export * from './node-names.generated' export * from './node-status.generated' +export * from './usb-identity.generated' diff --git a/src/shared/firmware/node-bus.generated.ts b/src/shared/firmware/node-bus.generated.ts new file mode 100644 index 0000000..16282b1 --- /dev/null +++ b/src/shared/firmware/node-bus.generated.ts @@ -0,0 +1,14 @@ +// AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Firmware/Libraries/PanelBridge/PanelBridge.cpp (MAX_NODE_ID) +// Firmware/Libraries/PowerMonitor/TelemSchedule.h (TELEM_PERIOD_MS) + +/** Highest CAN NODE_ID PanelBridge tracks. Panel nodes are 1..MAX_NODE_ID. */ +export const MAX_NODE_ID = 63 + +/** + * A PDU sends one TELEM_n frame per period, rotating through its pages, so each page refreshes + * every (page count × period). How long the client waits before calling a page stale is its own + * policy, not this. + */ +export const TELEM_PERIOD_MS = 1000 diff --git a/src/shared/firmware/node-status.generated.ts b/src/shared/firmware/node-status.generated.ts index a054e0d..0d1cfc6 100644 --- a/src/shared/firmware/node-status.generated.ts +++ b/src/shared/firmware/node-status.generated.ts @@ -31,8 +31,21 @@ export const NODE_KINDS = { BRIDGE: 0x3 } as const +/** NodeFaultCode entry names: the keys client wording tables look a fault up by. */ +export type NodeFaultName = + | 'NONE' + | 'I2C_PERIPHERAL' + | 'OVER_VOLTAGE' + | 'UNDER_VOLTAGE' + | 'SHORT_CIRCUIT' + | 'HOST_LINK_LOST' + | 'FUSE_OPEN' + | 'OVER_TEMPERATURE' + | 'SENSOR_FAULT' + | 'DUPLICATE_NODE_ID' + export interface NodeFaultInfo { - name: string + name: NodeFaultName abbr: string label: string description: string @@ -138,3 +151,39 @@ export const PDU_STATUS_FLAGS = { TEMP_CRIT: 0x8, NTC_FAULT: 0x10 } as const + +export type PduRailFlagName = keyof typeof PDU_RAIL_FLAGS +export type PduStatusFlagName = keyof typeof PDU_STATUS_FLAGS + +export interface PduFlagInfo { + name: N + /** The header's comment on the flag, verbatim. A firmware note, not client wording. */ + description: string +} + +/** + * PDU_RAIL_FLAGS bit -> its name and the PduRailFlag comment (NodeStatus.h). The client words its + * own flag meanings; this is the firmware's side, keyed the same way. + */ +export const PDU_RAIL_FLAG_INFO: Partial>> = { + 0x1: { name: 'UNDER_V', description: 'rail below its under-voltage threshold' }, + 0x2: { name: 'OVER_V', description: 'rail above its over-voltage threshold' }, + 0x4: { name: 'OVER_CURRENT', description: 'sustained current above the over-current threshold' }, + 0x8: { name: 'FUSE_OPEN', description: 'rail read ~0 V while the node is powered — fuse blown' }, + 0x10: { + name: 'SENSOR_FAULT', + description: 'voltage or current channel reads implausibly (railed)' + } +} as const + +/** PDU_STATUS_FLAGS bit -> its name and the PduStatusFlag comment (NodeStatus.h). */ +export const PDU_STATUS_FLAG_INFO: Partial>> = { + 0x1: { + name: 'UNCAL_VDDA', + description: 'no VDDA calibration stored — readings assume a nominal 3.3 V' + }, + 0x2: { name: 'UNCAL_ZERO', description: 'no current zero-offset calibration stored' }, + 0x4: { name: 'TEMP_WARN', description: 'board temperature at the warning threshold' }, + 0x8: { name: 'TEMP_CRIT', description: 'board temperature at the critical threshold' }, + 0x10: { name: 'NTC_FAULT', description: 'NTC channel open or shorted' } +} as const diff --git a/src/shared/firmware/reference.test.ts b/src/shared/firmware/reference.test.ts index 6ee9536..cbf726a 100644 --- a/src/shared/firmware/reference.test.ts +++ b/src/shared/firmware/reference.test.ts @@ -14,7 +14,15 @@ import { NODE_NAMES, CAL_PROTO, CAL_TYPES, - CAL_PAYLOAD_LEN + CAL_PAYLOAD_LEN, + MAX_NODE_ID, + TELEM_PERIOD_MS, + USB_IDENTITY, + PDU_RAIL_FLAGS, + PDU_RAIL_FLAG_INFO, + PDU_STATUS_FLAGS, + PDU_STATUS_FLAG_INFO, + type NodeFaultName } from '.' import { ACFT_NAME } from '../../main/reference/dcsbios-metadata' import { SUPPORTED_NODE_PROTO } from '@shared/nodes' @@ -66,6 +74,36 @@ describe('hid-report-layout.generated', () => { expect(HID_REPORT_LAYOUT.axes.signed).toBe(true) expect(HID_REPORT_LAYOUT.axes.min).toBe(-32768) }) + + it('pins the axis maximum and the hat directions the UI text is built from', () => { + // "signed ±32767" (CalibrationDialog) and "8-way POV" (HID tab) read these. + expect(HID_REPORT_LAYOUT.axes.max).toBe(32767) + expect(HID_REPORT_LAYOUT.hats.directions).toBe(8) + expect(HID_REPORT_LAYOUT.hats.centeredAtOrAbove).toBe(8) + }) +}) + +// The client finds the Gateway by vid/pid and shows its product name; a firmware change to any of +// them must fail here rather than leave the client looking for a board that no longer enumerates. +describe('usb-identity.generated', () => { + it('pins the SimGateway USB identity', () => { + expect(USB_IDENTITY.vid).toBe(0x2e8a) + expect(USB_IDENTITY.pid).toBe(0x4134) + expect(USB_IDENTITY.manufacturer).toBe('OpenSkyhawk') + expect(USB_IDENTITY.product).toBe('A-4E Skyhawk') + expect(USB_IDENTITY.cdcInterface).toBe('A-4E Skyhawk DCS-BIOS') + expect(USB_IDENTITY.baud).toBe(250000) + }) +}) + +describe('node-bus.generated', () => { + it('pins the NODE_ID ceiling the status and telemetry decoders gate on', () => { + expect(MAX_NODE_ID).toBe(63) + }) + + it('pins the PDU telemetry period TELEM_STALE_MS was chosen against', () => { + expect(TELEM_PERIOD_MS).toBe(1000) + }) }) describe('node-names.generated', () => { @@ -114,6 +152,26 @@ describe('node-status.generated', () => { expect(NODE_FAULT_CODES[0x99]).toBeUndefined() }) + it('fault names are typed: a misspelt name does not compile', () => { + const known: NodeFaultName = 'HOST_LINK_LOST' + // @ts-expect-error -- not a NodeFaultCode name + const typo: NodeFaultName = 'HOST_LINK_LOSS' + expect([known, typo]).toHaveLength(2) + }) + + it('PDU flag info names every flag bit, keyed by the bit', () => { + for (const [flags, info] of [ + [PDU_RAIL_FLAGS, PDU_RAIL_FLAG_INFO], + [PDU_STATUS_FLAGS, PDU_STATUS_FLAG_INFO] + ] as const) { + expect(Object.keys(info)).toHaveLength(Object.keys(flags).length) + for (const [name, bit] of Object.entries(flags)) { + expect(info[bit]?.name).toBe(name) + expect(info[bit]?.description.length).toBeGreaterThan(0) + } + } + }) + it('fault badge tags are unique (OVER_VOLTAGE and OVER_TEMPERATURE must not both read OVER)', () => { const tags = Object.values(NODE_FAULT_CODES).map((e) => e?.abbr) expect(new Set(tags).size).toBe(tags.length) @@ -136,6 +194,22 @@ describe('axis-cal.generated', () => { expect(CAL_PROTO.version).toBe(1) }) + it('pins the largest frame and the device session timeout', () => { + expect(CAL_PROTO.maxFrame).toBe(92) + expect(CAL_PROTO.maxFrame).toBe(CAL_PROTO.envelopeBytes + CAL_PROTO.maxPayload) + // KEEPALIVE_MS (main/callink.ts) is client policy chosen to land well inside this. + expect(CAL_PROTO.sessionTimeoutMs).toBe(30000) + }) + + it('name tables are typed by the generated names: a misspelt name does not compile', () => { + expect(cal.CAL_TYPE.HELLO).toBe(0x01) + expect(cal.CAL_NACK.NO_SESSION).toBeDefined() + // @ts-expect-error -- FOO is not a CalType name + expect(cal.CAL_TYPE.FOO).toBeUndefined() + // @ts-expect-error -- BAD_FOO is not a CalNackReason name + expect(cal.CAL_NACK.BAD_FOO).toBeUndefined() + }) + it('marks device-originated types by the high bit', () => { for (const [code, info] of Object.entries(CAL_TYPES)) { expect(info!.fromDevice, info!.name).toBe(Number(code) >= 0x80) diff --git a/src/shared/firmware/usb-identity.generated.ts b/src/shared/firmware/usb-identity.generated.ts new file mode 100644 index 0000000..57a5d41 --- /dev/null +++ b/src/shared/firmware/usb-identity.generated.ts @@ -0,0 +1,18 @@ +// AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Firmware/Libraries/SimGateway/SimGateway.cpp (SimGateway::setup USB identity) + +/** + * How the SimGateway enumerates over USB, as SimGateway::setup() sets it before TinyUSB starts. + * The client finds the Gateway's serial port by vid/pid. + */ +export const USB_IDENTITY = { + vid: 0x2e8a, + pid: 0x4134, + manufacturer: 'OpenSkyhawk', + product: 'A-4E Skyhawk', + /** The CDC interface name (iInterface), so the serial port is identifiable by name. */ + cdcInterface: 'A-4E Skyhawk DCS-BIOS', + /** Nominal CDC baud. USB CDC ignores it, but opening a serial port needs a value. */ + baud: 250000 +} as const diff --git a/src/shared/gateway.ts b/src/shared/gateway.ts index 0ffa289..6e7ff7b 100644 --- a/src/shared/gateway.ts +++ b/src/shared/gateway.ts @@ -9,7 +9,7 @@ // Gateway raised itself; the one client-side rule is "not responding": once it has answered since // the port opened, a roster request unanswered for GATEWAY_NO_ANSWER_MS while the port is open. import { faultLabel, STATUS_FLAGS, type LogEventInput, type StatusFlag } from './events' -import { NODE_FAULT_CODES } from './firmware' +import { NODE_FAULT_CODES, type NodeFaultName } from './firmware' import type { NodeStatus } from './nodes' /** How long a roster request may go unanswered, with the port open, before the Gateway is "not responding". */ @@ -49,7 +49,7 @@ const title = (s: string): string => s[0].toUpperCase() + s.slice(1) * Client wording for the faults a Gateway reports about itself, keyed by NODE_FAULT_CODES name. * Others show their label alone: the dictionary's descriptions read like firmware notes. */ -const FAULT_NOTE: Partial> = { +const FAULT_NOTE: Partial> = { HOST_LINK_LOST: 'It reports losing its serial link to this computer. If it keeps happening, check the USB cable.', DUPLICATE_NODE_ID: diff --git a/src/shared/node-mapping.ts b/src/shared/node-mapping.ts index dd9029c..90a78c6 100644 --- a/src/shared/node-mapping.ts +++ b/src/shared/node-mapping.ts @@ -7,6 +7,7 @@ // added, only what differs, and when it was last seen. Adopted nodes — NODE_IDs the registry does // not know, named by the user — are stored in full, since there is nothing to default from (#93). +import { MAX_NODE_ID } from './firmware' import { isAdoptableKind, type NodeIdentity } from './node-kind' export type NodeKind = 'pdu' | 'panel' @@ -84,7 +85,7 @@ export const EMPTY_MAPPING: NodeMappingFile = { version: 3, nodes: {} } const MAX_TEXT = 64 const isNodeId = (v: unknown): v is number => - typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 63 + typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= MAX_NODE_ID const text = (v: unknown): string | undefined => { if (typeof v !== 'string') return undefined diff --git a/src/shared/node-telem.ts b/src/shared/node-telem.ts index eab57e6..c673d5e 100644 --- a/src/shared/node-telem.ts +++ b/src/shared/node-telem.ts @@ -2,7 +2,7 @@ // Wire contract: NodeStatus.h NODE_TELEM_* / TelemPage / PduRailFlag / PduStatusFlag (generated // into shared/firmware; reference.test asserts the decoder's protoVersion matches). Page layouts: // FirmwarePlan/02-can-protocol.md § Node Telemetry Payload — TELEM_n (#216). -import { NODE_TELEM, PDU_RAIL_FLAGS, PDU_STATUS_FLAGS, TELEM_PAGES } from './firmware' +import { MAX_NODE_ID, NODE_TELEM, PDU_RAIL_FLAGS, PDU_STATUS_FLAGS, TELEM_PAGES } from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_TELEM_PROTO = 1 @@ -20,8 +20,10 @@ export const RAIL_FLAG = PDU_RAIL_FLAGS export const PDU_FLAG = PDU_STATUS_FLAGS /** - * A page older than this is stale. Each PDU page refreshes every 3 s, so 10 s is three missed - * periods — long enough that one dropped frame never flickers a reading to unknown. + * A page older than this is stale. Each PDU page refreshes every 3 s (one TELEM_n frame per + * TELEM_PERIOD_MS in shared/firmware, rotating through three pages), so 10 s is three missed + * periods — long enough that one dropped frame never flickers a reading to unknown. The window is + * client policy; reference.test pins the firmware period it was chosen against. */ export const TELEM_STALE_MS = 10_000 @@ -85,7 +87,7 @@ export function displayCurrentMa(ma: number): number { export function parseNodeTelem(hex: string): NodeTelem | null { if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]{18}$/.test(hex)) return null const nodeId = parseInt(hex.slice(0, 2), 16) - if (nodeId < 1 || nodeId > 63) return null + if (nodeId < 1 || nodeId > MAX_NODE_ID) return null const b = (i: number) => parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16) // frame byte i const u16 = (i: number) => b(i) | (b(i + 1) << 8) // little-endian const i16 = (i: number) => { diff --git a/src/shared/nodes.ts b/src/shared/nodes.ts index 617d2ca..79cab29 100644 --- a/src/shared/nodes.ts +++ b/src/shared/nodes.ts @@ -2,7 +2,7 @@ // Wire contract: NodeStatus.h NODE_STATUS_* (generated into shared/firmware; reference.test // asserts the decoder's protoVersion matches). import { encodeExportFrame } from './dcsbios' -import { NODE_HEALTH_FLAGS, NODE_KINDS, NODE_STATUS } from './firmware' +import { MAX_NODE_ID, NODE_HEALTH_FLAGS, NODE_KINDS, NODE_STATUS } from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 @@ -31,7 +31,7 @@ const HEX_LEN = 28 // nodeId(2) present(2) flags(2) uptime(4) rxCount(4) esr(4) const V2_HEX_LEN = 26 export interface NodeStatus { - nodeId: number // 0 = the bridge's own row (v3), 1..63 CAN nodes + nodeId: number // 0 = the bridge's own row (v3), 1..MAX_NODE_ID CAN nodes present: boolean boff: boolean // CAN bus-off epvf: boolean // CAN error-passive @@ -64,7 +64,7 @@ export function parseNodeStatus(hex: string): NodeStatus | null { if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]{28}$/.test(hex)) return null const f = (i: number, n: number) => parseInt(hex.slice(i, i + n), 16) const nodeId = f(0, 2) - if (nodeId > 63) return null + if (nodeId > MAX_NODE_ID) return null const flags = f(4, 2) const esr = f(14, 4) const traw = f(18, 2) // int8 two's-complement; 0x80 = not-yet-seen sentinel From 552b050217496b8a4bf5e710bc2a8345915b6da3 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 20:26:28 -0400 Subject: [PATCH 07/28] build(sync): sync the newly named firmware protocol values (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.5 of the structure rewrite. Firmware PR OpenSkyHawk/OpenSkyhawk#347 gave names to nine protocol values that used to exist only as comments or bare literals. This PR syncs those names and swaps the client's hand-typed copies for them (plan §3.3 rows B2–B9 and B17). Nothing changes in behaviour: no UI, wording or output differs. ## 1. Pin bump: d29433ab → a78dd902 `PIN.commit` now points at `a78dd90276fffb820b64ba74b82f354ab167348a`, the #347 merge. It is the only firmware commit between the two pins. The pin was bumped and `npm run sync` run in a commit of its own (c150ca8), before any generator change. That commit touches exactly one line in each of the 8 generated files: the `// Source: … @ ` banner. `node-names.generated.ts` also has `NODE_REGISTRY_SOURCE.commit`. Every value is identical, and the registry `date` stays 2026-10-16. ``` scripts/sync-a4ec.ts | 2 +- src/shared/firmware/*.generated.ts (8 files) | 1+/1- each (node-names 2+/2-) ``` ## 2. What the sync now generates These are additions to the existing generators. No new script. `constexprNum` accepts a leading `-` on its first term, which the two signed sentinels need. A new `parseOffsets` reads an offset enum and checks two things: the offsets are strictly ascending, and each lies inside the row or payload it indexes. A relayout the decoders can't read field by field fails the sync instead of mis-decoding. **`node-status.generated.ts`** (from `NodeStatus.h`): - `GATEWAY_NODE_ID` = 0 - `NODE_STATUS_ROW_BYTES` = 14 and `NODE_STATUS_FIELD` {ID 0, PRESENT 1, HB_FLAGS 2, UPTIME 3, RX_COUNT 5, ESR 7, DIE_TEMP_C 9, HEALTH_FLAGS 10, FAULT_MASK 11, FAULT_ID 12, NODE_KIND 13} - `HEARTBEAT_FLAGS` {BOFF 0x1, EPVF 0x2} - `HB_ESR_TEC_SHIFT` = 0 and `HB_ESR_REC_SHIFT` = 8 - `DIE_TEMP_UNAVAILABLE` = -128 - `NODE_TELEM_ROW_BYTES` = 9 and `NODE_TELEM_FIELD` {ID 0, FRAME 1} - `PDU_RAIL_FIELD` {FLAGS 1, V_AVG_MV 2, V_MIN_MV 4, I_AVG_MA 6} - `PDU_STATUS_FIELD` {FLAGS 1, NTC_TEMP_DC 2, I12_PEAK_MA 4, I5_PEAK_MA 6} - `NTC_TEMP_UNAVAILABLE` = -32768 **`axis-cal.generated.ts`** (from `AxisCal.h`): - `CAL_PROTO.dataRecBytes` = 10, from `CAL_DATA_REC_BYTES`. - `CAL_HELLO_ACK_FIELD`, `CAL_DATA_FIELD`, `CAL_DATA_REC_FIELD`, `CAL_SESSION_OPEN_FIELD`, `CAL_SESSION_ACK_FIELD`, `CAL_STREAM_SELECT_FIELD`, `CAL_COMMIT_FIELD`, `CAL_RESET_FIELD`, `CAL_ACK_FIELD`, `CAL_NACK_FIELD`, `CAL_RAW_FIELD`. The type prefix is stripped from each entry, so `CAL_COMMIT_MIN` becomes `CAL_COMMIT_FIELD.MIN`. The sync fails if an entry lacks the expected prefix. Each table is bounded by its type's synced payload length; the per-axis record table is bounded by `CAL_DATA_REC_BYTES`. Every table is an `as const` name-keyed object, so a misspelt field name doesn't compile. Each entry keeps the firmware's trailing comment (`// u16: AxisCal::min`). `reference.test.ts` pins every new value at today's literal, so a future firmware relayout fails there. ## 3. Client sites replaced | Name | Client site replaced | How equality was proven | |---|---|---| | `GATEWAY_NODE_ID` (B2) | `shared/nodes.ts` `BRIDGE_NODE_ID = 0` (export kept, now `= GATEWAY_NODE_ID`); `tabs/settings/settings-view.ts` `nodeId === 0` | 0 === 0. `registryOnBus` base vs head over node IDs 0–70 × 4 models × link up/down. Settings › About › registry render identical | | `NODE_STATUS_ROW_BYTES` + `NODE_STATUS_FIELD` (B3) | `nodes.ts` `HEX_LEN = 28`, the `{28}` regex, and every `f(i, n)` hex slice | `parseNodeStatus` base vs head, `toStrictEqual`, over **89,949 rows** (below) | | `HEARTBEAT_FLAGS` (B4) | `nodes.ts` `flags & 0x01` / `& 0x02` | same corpus, all 256 HB_FLAGS bytes | | `HB_ESR_TEC_SHIFT` / `HB_ESR_REC_SHIFT` (B5) | `nodes.ts` `esr & 0xff` / `(esr >> 8) & 0xff` | same corpus, all 65,536 ESR values | | `DIE_TEMP_UNAVAILABLE` (B6) | `nodes.ts` `traw === 0x80` | all 256 die-temp bytes, base vs head (sign convention below) | | `NODE_TELEM_ROW_BYTES` + `NODE_TELEM_FIELD` (B7) | `node-telem.ts` `HEX_LEN = 18`, the `{18}` regex, `slice(0, 2)`, the frame-byte slice `2 + i*2`, `slice(4)` for the unknown-page body | `parseNodeTelem` base vs head, `toStrictEqual`, over **87,133 rows** (below) | | `PDU_RAIL_FIELD` / `PDU_STATUS_FIELD` (B8) | `node-telem.ts` `b(1)`, `u16(2/4/6)`, `i16(2/6)` | same corpus, both rail pages at the u16/i16 extremes | | `NTC_TEMP_UNAVAILABLE` (B9) | `node-telem.ts` `NTC_UNAVAILABLE = -0x8000` | -0x8000 === -32768. All 65,536 NTC words on the status page, base vs head | | `CAL_HELLO_ACK_FIELD` (B17) | `calibration.ts` `decodeHelloAck` `p[0..5]` | every decoder, base vs head, payload lengths 0–90 (short, exact and long) × 7 payloads each (all-0, all-0xff, all-0x80, all-0x7f, random), plus 2,000 random 82-byte payloads | | `CAL_DATA_FIELD` + `CAL_DATA_REC_FIELD` + `CAL_PROTO.dataRecBytes` (B17) | `decodeCalData` `p[0]`, `p[1]`, `2 + i * 10`, `o + 2/4/6/8` | same | | `CAL_SESSION_ACK_FIELD` (B17) | `decodeSessionAck` `u16(p, 0)`, `u16(p, 2)`, `p[4]` | same | | `CAL_ACK_FIELD` / `CAL_NACK_FIELD` / `CAL_RAW_FIELD` (B17) | `decodeAck` `p[0]`; `decodeNack` `p[0..2]`; `decodeRaw` `p[0]`, `u16(p, 1/3)` | same | | `CAL_COMMIT_FIELD` (B17) | `encodeCommit` `p[0]`, `put(1/3/5/7)`, and `new Uint8Array(9)` → `CAL_PAYLOAD_LEN[COMMIT]` (already synced, already pinned at 9) | base vs head bytes: 6 seqs × 5 idx × 12 values (incl. 0x10000, -1, 1.5, NaN), plus 2,000 random axes per seq. Demux round-trip identical | | `CAL_SESSION_OPEN_FIELD` / `CAL_STREAM_SELECT_FIELD` / `CAL_RESET_FIELD` (B17) | `one(type, seq, byte)` with its implicit offset 0 (`Uint8Array.from([byte])`) | base vs head bytes: 6 seqs × 20 byte values (incl. -1, 256, 1.5, NaN, ±Infinity, 1e9). `one` now sizes the payload `at + 1`, so if the field ever moved off byte 0, buildFrame's length check would throw rather than send a zero | **Status-row corpus (89,949 rows):** - every hex literal in the repo's test files and fixtures - `statusRow()` for node IDs 0–255 × present 0/1 × kinds 0–3, 4 and 0xff - each field at its min/max, with the rest all-min and all-max - every die-temp byte, every ESR value, and every byte of flags, present and hFlags - 20,000 random mixed-case rows - malformed rows: lengths 26/27/29/30, whitespace, non-hex, `0x`, `+`/`-` prefix, Arabic-Indic digit `isLegacyNodeStatus` was compared over the same rows. **Telemetry corpus (87,133 rows):** - every fixture literal - node IDs 0–255 × pages 0–4 - every page byte - every NTC int16 - rail extremes - 20,000 random rows - malformed rows The proof tests were mutation-checked. With `esr >> 7` and `p[F.DETAIL]` swapped in for the reason byte, they fail. They are throwaway files and are not committed. **Render compare:** each view is fed rows decoded through the decoders: a gateway row, panels with bus-off/error-passive, the die-temp sentinel, -40 °C and overheat+fault, a PDU, a removed node, rail/status/unknown TELEM pages including the NTC sentinel and an INT16_MIN current, and decoded CAL_DATA / SESSION_ACK / RAW. I dumped the HTML of 8 views: Cockpit, the Cockpit panel sheet, the Cockpit PDU sheet, Connection, HID, the Calibration dialog, Settings › About, and the Settings registry dialog. Head was dumped twice to confirm the output is deterministic. Then the four changed source files were swapped back to base and the views dumped again. `diff -r` base vs head: **identical**. ## 4. Sign conventions - **B6, die temperature.** The firmware value is signed int8 -128. The row carries its two's-complement byte, 0x80. The decoder already converted the byte to signed (`traw < 128 ? traw : traw - 256`). It now converts first and compares the signed value with `DIE_TEMP_UNAVAILABLE`. That is the same test as the old `traw === 0x80`, because the signed value is -128 exactly when the byte is 0x80. Proven for all 256 byte values. `reference.test` also pins `DIE_TEMP_UNAVAILABLE & 0xff === 0x80`. - **B9, NTC.** `i16()` already produced the signed value, and the old constant was `-0x8000`, so `NTC_TEMP_UNAVAILABLE` (-32768) drops straight in. Proven for all 65,536 words. `reference.test` pins `& 0xffff === 0x8000`. The `i16` sign conversion (`v >= 0x8000 ? v - 0x10000 : v`) is generic int16 handling, not the sentinel, so it stays. ## 5. What stays hand-typed, and why - `V2_HEX_LEN = 26` in `nodes.ts`. The legacy v2 row is detected client-side, only to say "this bridge is too old". The firmware no longer describes it, so there is nothing to sync from. A comment now says so. - Hat direction labels (CTR N NE … NW): client wording. - The TELEM page byte, read as `b(0)`. The firmware names no field for it; `NodeTelemField` says "TelemPage at FRAME + 0". - The calibration envelope offsets in `buildFrame` / `classify` (type @4, seq @5, len @6–7, payload @8). The firmware has no envelope-field enum, and they were outside B17. - Out of scope per the brief: B18 (CRC parameters; the vector test is kept) and B21 (hysteresis text). Cockpit-adopt's Gateway wording is untouched. ## 6. Grep proof Each pattern was grepped over `src/shared/nodes.ts`, `node-telem.ts`, `calibration.ts` and `renderer/src/tabs/settings/settings-view.ts`. Every count is 0: ``` 0 BRIDGE_NODE_ID = 0 0 nodeId === 0 0 HEX_LEN = (28|18) 0 \{28\}|\{18\} 0 f\([0-9]+, [0-9]\) 0 flags & 0x0[12] 0 esr >> 8|esr & 0xff 0 traw === 0x80 0 NTC_UNAVAILABLE|-0x8000 0 hex\.slice\(4\)|slice\(2 \+ i 0 b\([1-7]\)|u16\([2-6]\)|i16\([2-6]\) 0 p\[[0-9]\] 0 u16\(p, [0-9]|o \+ [0-9]|2 \+ i \* 10 0 put\([0-9] 0 new Uint8Array\(9\) 0 Uint8Array\.from\(\[byte\]\) ``` A repo-wide search for `0x80`, `0x8000`, `INT16_MIN`, `=== 0` on node IDs and `>> 8) & 0xff` outside tests and fixtures found only generic byte handling: LE writes, CRC, int16 sign conversion, and dcsbios export frames. ## Verification - `npm run sync` is idempotent at the new pin: the diff is byte-identical before and after a re-run. That is the CI freshness gate. - `npm run typecheck`: clean. - `npm run lint`: 0 errors. No warnings in the changed files. - `npm run lint:deps`: 0 errors. - `npx vitest run`: 62 files, 856 tests. That is 852 plus 4 new reference pins. Existing assertions are unchanged. - `npm run build`: passes. Refs #98 Refs OpenSkyHawk/OpenSkyhawk#346 --- scripts/sync-a4ec.ts | 165 +++++++++++++++++- .../src/tabs/settings/settings-view.ts | 3 +- src/shared/calibration.ts | 85 ++++++--- .../firmware/a4ec-controls.generated.ts | 2 +- src/shared/firmware/axis-cal.generated.ts | 82 ++++++++- src/shared/firmware/hid-controls.generated.ts | 2 +- .../firmware/hid-report-layout.generated.ts | 2 +- src/shared/firmware/node-bus.generated.ts | 2 +- src/shared/firmware/node-names.generated.ts | 4 +- src/shared/firmware/node-status.generated.ts | 82 ++++++++- src/shared/firmware/reference.test.ts | 88 ++++++++++ src/shared/firmware/usb-identity.generated.ts | 2 +- src/shared/node-telem.ts | 51 ++++-- src/shared/nodes.ts | 69 +++++--- 14 files changed, 556 insertions(+), 83 deletions(-) diff --git a/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index d2f1dc9..824f30c 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -34,7 +34,7 @@ import { format } from 'prettier' const PIN = { repo: process.env.OPENSKYHAWK_REPO_URL ?? 'https://github.com/OpenSkyhawk/OpenSkyhawk.git', - commit: 'd29433ab149fcff1695e3d5eb07e2fab4211a603' + commit: 'a78dd90276fffb820b64ba74b82f354ab167348a' } const SRC = { @@ -205,18 +205,53 @@ const labelize = (name: string): string => * `` is a literal, or a sum of literals and other constants from the same source * (`CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD`), each resolved the same way. Anything * else (a cast, a product, a call) is not a value this parser can vouch for, so it fails the sync. + * The first term may carry a leading `-`, for the signed sentinels + * (`DIE_TEMP_UNAVAILABLE = -128`); a `-` anywhere else is a subtraction, which still fails. */ function constexprNum(src: string, name: string, where: string): number { const m = new RegExp(`constexpr\\s+\\w+\\s+${name}\\s*=\\s*([^;]+);`).exec(src) if (!m) throw new Error(`[sync] constant ${name} not found in ${where}`) - return m[1]!.split('+').reduce((sum, raw) => { - const term = raw.trim() - if (/^(0x[0-9A-Fa-f]+|\d+)$/.test(term)) return sum + Number(term) - if (/^[A-Z_][A-Z0-9_]*$/.test(term)) return sum + constexprNum(src, term, where) + return m[1]!.split('+').reduce((sum, raw, i) => { + let term = raw.trim() + let sign = 1 + if (i === 0 && term.startsWith('-')) { + sign = -1 + term = term.slice(1).trim() + } + if (/^(0x[0-9A-Fa-f]+|\d+)$/.test(term)) return sum + sign * Number(term) + if (/^[A-Z_][A-Z0-9_]*$/.test(term)) return sum + sign * constexprNum(src, term, where) throw new Error(`[sync] ${name} in ${where}: cannot evaluate '${m[1]!.trim()}'`) }, 0) } +/** + * A field-offset enum (`NodeStatusField`, `CalCommitField`, …): parseEnum, plus the checks an + * offset table must pass — strictly ascending (a field runs to the next offset) and inside + * `bytes`, the length of the row or payload it indexes. A firmware layout the decoders cannot + * read field-by-field fails the sync here rather than mis-decoding. + */ +function parseOffsets( + header: string, + enumName: string, + where: string, + bytes: number +): { name: string; value: number; comment: string }[] { + const es = parseEnum(header, enumName, where) + es.forEach((e, i) => { + if (i > 0 && e.value <= es[i - 1]!.value) { + throw new Error(`[sync] ${enumName}.${e.name} in ${where}: offsets must ascend`) + } + if (e.value >= bytes) { + throw new Error(`[sync] ${enumName}.${e.name} = ${e.value} in ${where}: past ${bytes} bytes`) + } + }) + return es +} + +/** Offset entries -> `NAME: offset, // firmware comment` lines of a name-keyed object. */ +const offsetLines = (es: { name: string; value: number; comment: string }[]): string => + es.map((e) => ` ${e.name}: ${e.value},${e.comment ? ` // ${e.comment}` : ''}`).join('\n') + /** Enum entry names -> a TS string-literal union, so name-keyed lookups are typo-checked. */ const nameUnion = (names: string[]): string => names.map(q).join(' | ') @@ -560,6 +595,21 @@ function genNodeStatus(header: string): string { .map((e) => ` ${hex(e.value)}: { name: ${q(e.name)}, description: ${q(e.comment)} }`) .join(',\n') + // Wire layout named by #346: the row offsets, flag bits and sentinels the decoders read. + const nsNum = (name: string) => constexprNum(header, name, 'NodeStatus.h') + const statusRowBytes = nsNum('NODE_STATUS_ROW_BYTES') + const statusFields = parseOffsets(header, 'NodeStatusField', 'NodeStatus.h', statusRowBytes) + const telemRowBytes = nsNum('NODE_TELEM_ROW_BYTES') + const telemFields = parseOffsets(header, 'NodeTelemField', 'NodeStatus.h', telemRowBytes) + const hbFlags = parseEnum(header, 'HeartbeatFlag', 'NodeStatus.h') + // The TELEM_n frame a PDU page lives in: the _NODE_TELEM row after its FRAME offset (8 bytes). + const frameAt = telemFields.find((e) => e.name === 'FRAME')?.value + if (frameAt === undefined) + throw new Error('[sync] NodeTelemField.FRAME not found in NodeStatus.h') + const frameBytes = telemRowBytes - frameAt + const railFields = parseOffsets(header, 'PduRailField', 'NodeStatus.h', frameBytes) + const pduStatusFields = parseOffsets(header, 'PduStatusField', 'NodeStatus.h', frameBytes) + const faultAbbr = abbrs(faultCodes.map((e) => e.name)) const flagEntries = healthFlags.map((e) => ` ${e.name}: ${hex(e.value)}`).join(',\n') const faultEntries = faultCodes @@ -586,6 +636,41 @@ export const NODE_STATUS = { endMsgName: ${q(endMsgName)} } as const +/** + * NODE_ID of the Gateway board's PanelBridge MCU, the CAN master. Its own row leads every + * _NODE_STATUS roster reply (#344); CAN nodes are 1..MAX_NODE_ID. + */ +export const GATEWAY_NODE_ID = ${nsNum('GATEWAY_NODE_ID')} + +/** _NODE_STATUS row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_STATUS_ROW_BYTES = ${statusRowBytes} + +/** + * _NODE_STATUS row field offsets, in bytes of the decoded row — from NodeStatusField + * (NodeStatus.h). Hex index = 2 × offset. Each field is fixed-width hex, most-significant byte + * first; a field runs to the next offset, the last to NODE_STATUS_ROW_BYTES. + */ +export const NODE_STATUS_FIELD = { +${offsetLines(statusFields)} +} as const + +/** HB_n \`flags\` bits (the row's HB_FLAGS field) — from HeartbeatFlag (NodeStatus.h). */ +export const HEARTBEAT_FLAGS = { +${bits(hbFlags)} +} as const + +/** HB_n \`esr\` (the row's ESR field): bit position of the transmit error counter, the low byte. */ +export const HB_ESR_TEC_SHIFT = ${nsNum('HB_ESR_TEC_SHIFT')} + +/** HB_n \`esr\`: bit position of the receive error counter, the high byte. */ +export const HB_ESR_REC_SHIFT = ${nsNum('HB_ESR_REC_SHIFT')} + +/** + * HEALTH_n \`dieTempC\` meaning "unavailable / not yet seen". Signed (int8): the row carries its + * two's-complement byte. + */ +export const DIE_TEMP_UNAVAILABLE = ${nsNum('DIE_TEMP_UNAVAILABLE')} + /** HEALTH_n \`flags\` bits — from NodeHealthFlag (NodeStatus.h). Decoder masks against these. */ export const NODE_HEALTH_FLAGS = { ${flagEntries} @@ -631,6 +716,33 @@ export const NODE_TELEM = { msgName: ${q(telemMsgName)} } as const +/** _NODE_TELEM row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_TELEM_ROW_BYTES = ${telemRowBytes} + +/** + * _NODE_TELEM row field offsets, in bytes of the decoded row — from NodeTelemField (NodeStatus.h). + * Hex index = 2 × offset. FRAME is the TELEM_n frame unread, in wire order; its byte 0 is the page. + */ +export const NODE_TELEM_FIELD = { +${offsetLines(telemFields)} +} as const + +/** + * PDU_RAIL_12V / PDU_RAIL_5V field offsets, in bytes of the TELEM_n frame — from PduRailField + * (NodeStatus.h). Little-endian; a field runs to the next offset, the last to the frame's end. + */ +export const PDU_RAIL_FIELD = { +${offsetLines(railFields)} +} as const + +/** PDU_STATUS field offsets, in bytes of the TELEM_n frame — from PduStatusField (NodeStatus.h). */ +export const PDU_STATUS_FIELD = { +${offsetLines(pduStatusFields)} +} as const + +/** PDU_STATUS \`ntcTempDC\` meaning "unavailable": no reading yet, or the NTC is open/shorted. int16. */ +export const NTC_TEMP_UNAVAILABLE = ${nsNum('NTC_TEMP_UNAVAILABLE')} + /** TELEM_n page ids — from TelemPage (NodeStatus.h). Global and append-only; 0x00 is invalid. */ export const TELEM_PAGES = { ${bits(telemPages)} @@ -748,6 +860,42 @@ function genAxisCal(header: string, cpp: string, simGateway: string): string { const lenEntries = types.map((t) => ` ${hex(t.value)}: ${lens[t.name]}`).join(',\n') + // Payload field offsets (#346): one enum per type with a payload, entries prefixed by the type + // (CAL_COMMIT_MIN). Each is bounded by its type's payload length; CAL_DATA's per-axis record by + // CAL_DATA_REC_BYTES. The prefix is stripped so the generated table reads CAL_COMMIT_FIELD.MIN. + const dataRecBytes = constexprNum(header, 'CAL_DATA_REC_BYTES', 'AxisCal.h') + const fieldTables = [ + ['CalHelloAckField', 'CAL_HELLO_ACK', lens.CAL_T_HELLO_ACK], + ['CalDataField', 'CAL_DATA', lens.CAL_T_CAL_DATA], + ['CalDataRecField', 'CAL_DATA_REC', dataRecBytes], + ['CalSessionOpenField', 'CAL_SESSION_OPEN', lens.CAL_T_SESSION_OPEN], + ['CalSessionAckField', 'CAL_SESSION_ACK', lens.CAL_T_SESSION_ACK], + ['CalStreamSelectField', 'CAL_STREAM_SELECT', lens.CAL_T_STREAM_SELECT], + ['CalCommitField', 'CAL_COMMIT', lens.CAL_T_COMMIT], + ['CalResetField', 'CAL_RESET', lens.CAL_T_RESET], + ['CalAckField', 'CAL_ACK', lens.CAL_T_ACK], + ['CalNackField', 'CAL_NACK', lens.CAL_T_NACK], + ['CalRawField', 'CAL_RAW', lens.CAL_T_RAW] + ] as const + const fieldConsts = fieldTables + .map(([enumName, prefix, bytes]) => { + if (bytes === undefined) throw new Error(`[sync] no payload length bounds ${enumName}`) + const es = parseOffsets(header, enumName, 'AxisCal.h', bytes).map((e) => { + if (!e.name.startsWith(`${prefix}_`)) { + throw new Error( + `[sync] ${enumName}.${e.name} in AxisCal.h: expected the ${prefix}_ prefix` + ) + } + return { ...e, name: e.name.slice(prefix.length + 1) } + }) + const into = enumName === 'CalDataRecField' ? 'one CAL_DATA record' : 'the payload' + return `/** ${enumName} (AxisCal.h): byte offsets into ${into}, ${bytes} byte${bytes === 1 ? '' : 's'}. */ +export const ${prefix}_FIELD = { +${offsetLines(es)} +} as const` + }) + .join('\n\n') + return ( banner([ `${SRC.axisCal} (calibration wire contract)`, @@ -780,6 +928,8 @@ export const CAL_PROTO = { maxFrame: ${constexprNum(header, 'CAL_MAX_FRAME', 'AxisCal.h')}, /** The gateway closes a calibration session idle this long. SESSION_ACK reports it too. */ sessionTimeoutMs: ${constexprNum(simGateway, 'CAL_SESSION_TIMEOUT_MS', 'SimGateway.cpp')}, + /** One CAL_DATA per-axis record, in bytes. Record i starts at CAL_DATA_FIELD.RECORDS + i × this. */ + dataRecBytes: ${dataRecBytes}, /** Frame lead-in "\\xAA S K C". Not collision-proof outbound, which is why the CRC is mandatory. */ magic: [${magic.map(hex).join(', ')}] } as const @@ -835,6 +985,11 @@ ${nackEntries} export const CAL_PAYLOAD_LEN: Partial> = { ${lenEntries} } as const + +// Payload field offsets — byte offsets into a type's payload (not the frame). Multi-byte fields +// are little-endian; a field runs to the next offset, the last to the type's payload length. + +${fieldConsts} ` ) } diff --git a/src/renderer/src/tabs/settings/settings-view.ts b/src/renderer/src/tabs/settings/settings-view.ts index d34af5e..2ebfb4c 100644 --- a/src/renderer/src/tabs/settings/settings-view.ts +++ b/src/renderer/src/tabs/settings/settings-view.ts @@ -1,4 +1,5 @@ // Settings text decisions (#67), pure so they are unit-tested: what each Cockpit / About row says. +import { GATEWAY_NODE_ID } from '@shared/firmware' import type { NodeRegistryInfo } from '@shared/ipc' import type { HiddenNode, NodeModel } from '@shared/node-rules' import { consoleLabel } from '../../cockpit-panels' @@ -80,7 +81,7 @@ export function registryOnBus( model: NodeModel | undefined, linkUp: boolean ): OnBus { - if (nodeId === 0) + if (nodeId === GATEWAY_NODE_ID) return linkUp ? { text: 'serial link', dot: 'on' } : { text: 'no device', dot: '' } if (model?.hidden.includes(nodeId)) return { text: 'hidden', dot: '' } const e = model?.nodes.find((n) => n.nodeId === nodeId) diff --git a/src/shared/calibration.ts b/src/shared/calibration.ts index c4272f2..a0ae8e0 100644 --- a/src/shared/calibration.ts +++ b/src/shared/calibration.ts @@ -13,9 +13,20 @@ // where the firmware applied the offset; nothing here re-derives it. import { + CAL_ACK_FIELD, + CAL_COMMIT_FIELD, + CAL_DATA_FIELD, + CAL_DATA_REC_FIELD, + CAL_HELLO_ACK_FIELD, + CAL_NACK_FIELD, CAL_NACK_REASONS, CAL_PAYLOAD_LEN, CAL_PROTO, + CAL_RAW_FIELD, + CAL_RESET_FIELD, + CAL_SESSION_ACK_FIELD, + CAL_SESSION_OPEN_FIELD, + CAL_STREAM_SELECT_FIELD, CAL_TYPES, type CalNackName } from './firmware' @@ -295,6 +306,9 @@ export class CalDemux { } // ── decoders ───────────────────────────────────────────────────────────────── +// +// Payload offsets are the generated CAL_*_FIELD tables (AxisCal.h). Multi-byte fields are +// little-endian. const u16 = (p: Uint8Array, off: number) => p[off]! | (p[off + 1]! << 8) @@ -307,11 +321,12 @@ export interface HelloAck { } export function decodeHelloAck(p: Uint8Array): HelloAck { + const F = CAL_HELLO_ACK_FIELD return { - proto: p[0]!, - blobVersion: p[1]!, - axisSlots: p[2]!, - fw: { major: p[3]!, minor: p[4]!, patch: p[5]! } + proto: p[F.PROTO]!, + blobVersion: p[F.BLOB_VERSION]!, + axisSlots: p[F.AXIS_SLOTS]!, + fw: { major: p[F.FW_MAJOR]!, minor: p[F.FW_MINOR]!, patch: p[F.FW_PATCH]! } } } @@ -339,18 +354,19 @@ export interface CalData { } export function decodeCalData(p: Uint8Array): CalData { - const presentMask = p[0]! - const calibratedMask = p[1]! + const presentMask = p[CAL_DATA_FIELD.PRESENT_MASK]! + const calibratedMask = p[CAL_DATA_FIELD.CALIBRATED_MASK]! + const R = CAL_DATA_REC_FIELD const axes: CalAxis[] = [] for (let i = 0; i < AXIS_CAL_SLOTS; i++) { - const o = 2 + i * 10 + const o = CAL_DATA_FIELD.RECORDS + i * CAL_PROTO.dataRecBytes axes.push({ idx: i, - controlId: u16(p, o), - min: u16(p, o + 2), - centre: u16(p, o + 4), - max: u16(p, o + 6), - deadzone: u16(p, o + 8), + controlId: u16(p, o + R.CONTROL_ID), + min: u16(p, o + R.MIN), + centre: u16(p, o + R.CENTRE), + max: u16(p, o + R.MAX), + deadzone: u16(p, o + R.DEADZONE), present: (presentMask & (1 << i)) !== 0, calibrated: (calibratedMask & (1 << i)) !== 0 }) @@ -365,12 +381,16 @@ export interface SessionAck { } export function decodeSessionAck(p: Uint8Array): SessionAck { - return { timeoutMs: u16(p, 0) | (u16(p, 2) << 16), axisIdx: p[4]! } + const F = CAL_SESSION_ACK_FIELD + return { + timeoutMs: u16(p, F.TIMEOUT_MS) | (u16(p, F.TIMEOUT_MS + 2) << 16), // u32 as two u16 halves + axisIdx: p[F.AXIS_IDX]! + } } /** The type being acknowledged. */ export function decodeAck(p: Uint8Array): { type: number } { - return { type: p[0]! } + return { type: p[CAL_ACK_FIELD.TYPE]! } } export interface Nack { @@ -381,7 +401,8 @@ export interface Nack { } export function decodeNack(p: Uint8Array): Nack { - return { type: p[0]!, reason: p[1]!, detail: p[2]! } + const F = CAL_NACK_FIELD + return { type: p[F.TYPE]!, reason: p[F.REASON]!, detail: p[F.DETAIL]! } } export interface RawSample { @@ -397,13 +418,21 @@ export interface RawSample { } export function decodeRaw(p: Uint8Array): RawSample { - return { idx: p[0]!, raw: u16(p, 1), cal: u16(p, 3) } + const F = CAL_RAW_FIELD + return { idx: p[F.IDX]!, raw: u16(p, F.RAW), cal: u16(p, F.CAL) } } // ── encoders ───────────────────────────────────────────────────────────────── -const one = (type: number, seq: number, byte: number) => - buildFrame(type, seq, Uint8Array.from([byte])) +/** + * A one-field, one-byte payload: `byte` at offset `at`. Sized `at + 1`, so a firmware layout that + * moved the field off byte 0 fails buildFrame's length check instead of sending a zero. + */ +const one = (type: number, seq: number, at: number, byte: number) => { + const p = new Uint8Array(at + 1) + p[at] = byte + return buildFrame(type, seq, p) +} export const encodeHello = (seq: number) => buildFrame(CAL_TYPE.HELLO, seq) export const encodeGetCal = (seq: number) => buildFrame(CAL_TYPE.GET_CAL, seq) @@ -412,14 +441,15 @@ export const encodeKeepalive = (seq: number) => buildFrame(CAL_TYPE.KEEPALIVE, s /** Opens a session and starts RAW for `axisIdx` (CAL_AXIS_NONE for none). */ export const encodeSessionOpen = (seq: number, axisIdx: number) => - one(CAL_TYPE.SESSION_OPEN, seq, axisIdx) + one(CAL_TYPE.SESSION_OPEN, seq, CAL_SESSION_OPEN_FIELD.AXIS_IDX, axisIdx) /** Changes which axis streams mid-session. Requires an open session. */ export const encodeStreamSelect = (seq: number, axisIdx: number) => - one(CAL_TYPE.STREAM_SELECT, seq, axisIdx) + one(CAL_TYPE.STREAM_SELECT, seq, CAL_STREAM_SELECT_FIELD.AXIS_IDX, axisIdx) /** Deletes stored calibration for one axis, or all with CAL_AXIS_NONE. Persists immediately. */ -export const encodeReset = (seq: number, idx: number) => one(CAL_TYPE.RESET, seq, idx) +export const encodeReset = (seq: number, idx: number) => + one(CAL_TYPE.RESET, seq, CAL_RESET_FIELD.IDX, idx) /** * Writes one axis and persists it. Exactly one axis per COMMIT, never a batch — a batch could @@ -432,15 +462,16 @@ export function encodeCommit( seq: number, axis: { idx: number; min: number; centre: number; max: number } ): Uint8Array { - const p = new Uint8Array(9) - p[0] = axis.idx + const F = CAL_COMMIT_FIELD + const p = new Uint8Array(CAL_PAYLOAD_LEN[CAL_TYPE.COMMIT]!) + p[F.IDX] = axis.idx const put = (off: number, v: number) => { p[off] = v & 0xff p[off + 1] = (v >> 8) & 0xff } - put(1, axis.min) - put(3, axis.centre) - put(5, axis.max) - put(7, 0) + put(F.MIN, axis.min) + put(F.CENTRE, axis.centre) + put(F.MAX, axis.max) + put(F.DEADZONE, 0) return buildFrame(CAL_TYPE.COMMIT, seq, p) } diff --git a/src/shared/firmware/a4ec-controls.generated.ts b/src/shared/firmware/a4ec-controls.generated.ts index 43d23d1..b85be50 100644 --- a/src/shared/firmware/a4ec-controls.generated.ts +++ b/src/shared/firmware/a4ec-controls.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // tools/gen_a4ec/data/A-4E-C.jsonp (313 outputs, 358 inputs) export interface A4ecOutput { diff --git a/src/shared/firmware/axis-cal.generated.ts b/src/shared/firmware/axis-cal.generated.ts index 0ac4add..4d92cc3 100644 --- a/src/shared/firmware/axis-cal.generated.ts +++ b/src/shared/firmware/axis-cal.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/SimGateway/AxisCal.h (calibration wire contract) // Firmware/Libraries/SimGateway/AxisCal.cpp (payload length table) // Firmware/Libraries/SimGateway/SimGateway.cpp (calibration session timeout) @@ -29,6 +29,8 @@ export const CAL_PROTO = { maxFrame: 92, /** The gateway closes a calibration session idle this long. SESSION_ACK reports it too. */ sessionTimeoutMs: 30000, + /** One CAL_DATA per-axis record, in bytes. Record i starts at CAL_DATA_FIELD.RECORDS + i × this. */ + dataRecBytes: 10, /** Frame lead-in "\xAA S K C". Not collision-proof outbound, which is why the CRC is mandatory. */ magic: [0xaa, 0x53, 0x4b, 0x43] } as const @@ -187,3 +189,81 @@ export const CAL_PAYLOAD_LEN: Partial> = { 0x85: 3, 0x86: 5 } as const + +// Payload field offsets — byte offsets into a type's payload (not the frame). Multi-byte fields +// are little-endian; a field runs to the next offset, the last to the type's payload length. + +/** CalHelloAckField (AxisCal.h): byte offsets into the payload, 6 bytes. */ +export const CAL_HELLO_ACK_FIELD = { + PROTO: 0, // u8: CAL_PROTO_VERSION + BLOB_VERSION: 1, // u8: CAL_VERSION + AXIS_SLOTS: 2, // u8: AXIS_CAL_SLOTS + FW_MAJOR: 3, // u8: SimGateway firmware version + FW_MINOR: 4, // u8 + FW_PATCH: 5 // u8 +} as const + +/** CalDataField (AxisCal.h): byte offsets into the payload, 82 bytes. */ +export const CAL_DATA_FIELD = { + PRESENT_MASK: 0, // u8: bit i = an HIDAxis uses slot i + CALIBRATED_MASK: 1, // u8: bit i = slot i passes axisCalValid() + RECORDS: 2 // AXIS_CAL_SLOTS records of CAL_DATA_REC_BYTES, slot order +} as const + +/** CalDataRecField (AxisCal.h): byte offsets into one CAL_DATA record, 10 bytes. */ +export const CAL_DATA_REC_FIELD = { + CONTROL_ID: 0, // u16: HID controlId of the axis in this slot, 0 = absent + MIN: 2, // u16: AxisCal::min + CENTRE: 4, // u16: AxisCal::centre + MAX: 6, // u16: AxisCal::max + DEADZONE: 8 // u16: AxisCal::deadzone, always 0 +} as const + +/** CalSessionOpenField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_SESSION_OPEN_FIELD = { + AXIS_IDX: 0 // u8: axis to stream, CAL_AXIS_NONE = none +} as const + +/** CalSessionAckField (AxisCal.h): byte offsets into the payload, 5 bytes. */ +export const CAL_SESSION_ACK_FIELD = { + TIMEOUT_MS: 0, // u32: session inactivity timeout, ms + AXIS_IDX: 4 // u8: echoed streamed-axis selection +} as const + +/** CalStreamSelectField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_STREAM_SELECT_FIELD = { + AXIS_IDX: 0 // u8: axis to stream, CAL_AXIS_NONE = none +} as const + +/** CalCommitField (AxisCal.h): byte offsets into the payload, 9 bytes. */ +export const CAL_COMMIT_FIELD = { + IDX: 0, // u8: axis slot + MIN: 1, // u16 + CENTRE: 3, // u16 + MAX: 5, // u16 + DEADZONE: 7 // u16: must be 0, else NACK BAD_DEADZONE +} as const + +/** CalResetField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_RESET_FIELD = { + IDX: 0 // u8: axis slot, CAL_AXIS_NONE = all +} as const + +/** CalAckField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_ACK_FIELD = { + TYPE: 0 // u8: CalType being acknowledged +} as const + +/** CalNackField (AxisCal.h): byte offsets into the payload, 3 bytes. */ +export const CAL_NACK_FIELD = { + TYPE: 0, // u8: CalType being refused + REASON: 1, // u8: CalNackReason + DETAIL: 2 // u8: offending axis slot, else CAL_AXIS_NONE +} as const + +/** CalRawField (AxisCal.h): byte offsets into the payload, 5 bytes. */ +export const CAL_RAW_FIELD = { + IDX: 0, // u8: streamed axis slot + RAW: 1, // u16: reading before calibration + CAL: 3 // u16: the same reading through axisCalApply() +} as const diff --git a/src/shared/firmware/hid-controls.generated.ts b/src/shared/firmware/hid-controls.generated.ts index 90ad39c..81b3efe 100644 --- a/src/shared/firmware/hid-controls.generated.ts +++ b/src/shared/firmware/hid-controls.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/HIDControls/HIDControls.h (9 controls) export interface HidControl { diff --git a/src/shared/firmware/hid-report-layout.generated.ts b/src/shared/firmware/hid-report-layout.generated.ts index 296289c..ca10210 100644 --- a/src/shared/firmware/hid-report-layout.generated.ts +++ b/src/shared/firmware/hid-report-layout.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/SimGateway/SimGateway.cpp (HIDReport struct + descriptor) /** diff --git a/src/shared/firmware/node-bus.generated.ts b/src/shared/firmware/node-bus.generated.ts index 16282b1..cbb6e4b 100644 --- a/src/shared/firmware/node-bus.generated.ts +++ b/src/shared/firmware/node-bus.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/PanelBridge/PanelBridge.cpp (MAX_NODE_ID) // Firmware/Libraries/PowerMonitor/TelemSchedule.h (TELEM_PERIOD_MS) diff --git a/src/shared/firmware/node-names.generated.ts b/src/shared/firmware/node-names.generated.ts index f8ceea1..9551cc6 100644 --- a/src/shared/firmware/node-names.generated.ts +++ b/src/shared/firmware/node-names.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/NODE_IDS.md (6 entries) /** CAN NODE_ID -> panel group name, from the firmware NODE_IDS.md registry. */ @@ -15,6 +15,6 @@ export const NODE_NAMES: Record = { /** Where NODE_NAMES came from, shown in Settings › About. `date` is the commit's date. */ export const NODE_REGISTRY_SOURCE = { file: 'Firmware/NODE_IDS.md', - commit: 'd29433ab149fcff1695e3d5eb07e2fab4211a603', + commit: 'a78dd90276fffb820b64ba74b82f354ab167348a', date: '2026-10-16' } diff --git a/src/shared/firmware/node-status.generated.ts b/src/shared/firmware/node-status.generated.ts index 0d1cfc6..fab602b 100644 --- a/src/shared/firmware/node-status.generated.ts +++ b/src/shared/firmware/node-status.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/NodeStatus/NodeStatus.h (NODE_STATUS contract v3, NODE_TELEM contract v1) /** @@ -14,6 +14,52 @@ export const NODE_STATUS = { endMsgName: '_NODE_STATUS_END' } as const +/** + * NODE_ID of the Gateway board's PanelBridge MCU, the CAN master. Its own row leads every + * _NODE_STATUS roster reply (#344); CAN nodes are 1..MAX_NODE_ID. + */ +export const GATEWAY_NODE_ID = 0 + +/** _NODE_STATUS row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_STATUS_ROW_BYTES = 14 + +/** + * _NODE_STATUS row field offsets, in bytes of the decoded row — from NodeStatusField + * (NodeStatus.h). Hex index = 2 × offset. Each field is fixed-width hex, most-significant byte + * first; a field runs to the next offset, the last to NODE_STATUS_ROW_BYTES. + */ +export const NODE_STATUS_FIELD = { + ID: 0, // u8: node ID; GATEWAY_NODE_ID = the bridge's own row, 1-63 = CAN nodes + PRESENT: 1, // u8: 01 alive, 00 removed + HB_FLAGS: 2, // u8: HeartbeatFlag bits + UPTIME: 3, // u16: seconds since boot, wraps at 65535 + RX_COUNT: 5, // u16: node-owned accepted RX count, wraps at 65535 + ESR: 7, // u16: TEC at HB_ESR_TEC_SHIFT, REC at HB_ESR_REC_SHIFT + DIE_TEMP_C: 9, // i8: whole degC, DIE_TEMP_UNAVAILABLE = not seen + HEALTH_FLAGS: 10, // u8: NodeHealthFlag bits + FAULT_MASK: 11, // u8: reserved, 00 + FAULT_ID: 12, // u8: NodeFaultCode + NODE_KIND: 13 // u8: NodeKind +} as const + +/** HB_n `flags` bits (the row's HB_FLAGS field) — from HeartbeatFlag (NodeStatus.h). */ +export const HEARTBEAT_FLAGS = { + BOFF: 0x1, + EPVF: 0x2 +} as const + +/** HB_n `esr` (the row's ESR field): bit position of the transmit error counter, the low byte. */ +export const HB_ESR_TEC_SHIFT = 0 + +/** HB_n `esr`: bit position of the receive error counter, the high byte. */ +export const HB_ESR_REC_SHIFT = 8 + +/** + * HEALTH_n `dieTempC` meaning "unavailable / not yet seen". Signed (int8): the row carries its + * two's-complement byte. + */ +export const DIE_TEMP_UNAVAILABLE = -128 + /** HEALTH_n `flags` bits — from NodeHealthFlag (NodeStatus.h). Decoder masks against these. */ export const NODE_HEALTH_FLAGS = { OVERHEAT: 0x1, @@ -127,6 +173,40 @@ export const NODE_TELEM = { msgName: '_NODE_TELEM' } as const +/** _NODE_TELEM row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_TELEM_ROW_BYTES = 9 + +/** + * _NODE_TELEM row field offsets, in bytes of the decoded row — from NodeTelemField (NodeStatus.h). + * Hex index = 2 × offset. FRAME is the TELEM_n frame unread, in wire order; its byte 0 is the page. + */ +export const NODE_TELEM_FIELD = { + ID: 0, // u8: sending node ID (from the CAN ID) + FRAME: 1 // 8 bytes: the TELEM_n frame unread, wire order (TelemPage at FRAME + 0) +} as const + +/** + * PDU_RAIL_12V / PDU_RAIL_5V field offsets, in bytes of the TELEM_n frame — from PduRailField + * (NodeStatus.h). Little-endian; a field runs to the next offset, the last to the frame's end. + */ +export const PDU_RAIL_FIELD = { + FLAGS: 1, // u8: PduRailFlag bits + V_AVG_MV: 2, // u16: window-average rail voltage, mV + V_MIN_MV: 4, // u16: window-minimum rail voltage, mV + I_AVG_MA: 6 // int16: window-average rail current, mA (signed: zero offset stays visible) +} as const + +/** PDU_STATUS field offsets, in bytes of the TELEM_n frame — from PduStatusField (NodeStatus.h). */ +export const PDU_STATUS_FIELD = { + FLAGS: 1, // u8: PduStatusFlag bits + NTC_TEMP_DC: 2, // int16: board temperature, 0.1 degC (NTC_TEMP_UNAVAILABLE = no reading) + I12_PEAK_MA: 4, // u16: 12 V rail peak current since the last page, mA + I5_PEAK_MA: 6 // u16: 5 V rail peak current since the last page, mA +} as const + +/** PDU_STATUS `ntcTempDC` meaning "unavailable": no reading yet, or the NTC is open/shorted. int16. */ +export const NTC_TEMP_UNAVAILABLE = -32768 + /** TELEM_n page ids — from TelemPage (NodeStatus.h). Global and append-only; 0x00 is invalid. */ export const TELEM_PAGES = { PDU_RAIL_12V: 0x1, diff --git a/src/shared/firmware/reference.test.ts b/src/shared/firmware/reference.test.ts index cbf726a..6bf5e06 100644 --- a/src/shared/firmware/reference.test.ts +++ b/src/shared/firmware/reference.test.ts @@ -14,6 +14,29 @@ import { NODE_NAMES, CAL_PROTO, CAL_TYPES, + CAL_ACK_FIELD, + CAL_COMMIT_FIELD, + CAL_DATA_FIELD, + CAL_DATA_REC_FIELD, + CAL_HELLO_ACK_FIELD, + CAL_NACK_FIELD, + CAL_RAW_FIELD, + CAL_RESET_FIELD, + CAL_SESSION_ACK_FIELD, + CAL_SESSION_OPEN_FIELD, + CAL_STREAM_SELECT_FIELD, + DIE_TEMP_UNAVAILABLE, + GATEWAY_NODE_ID, + HB_ESR_REC_SHIFT, + HB_ESR_TEC_SHIFT, + HEARTBEAT_FLAGS, + NODE_STATUS_FIELD, + NODE_STATUS_ROW_BYTES, + NODE_TELEM_FIELD, + NODE_TELEM_ROW_BYTES, + NTC_TEMP_UNAVAILABLE, + PDU_RAIL_FIELD, + PDU_STATUS_FIELD, CAL_PAYLOAD_LEN, MAX_NODE_ID, TELEM_PERIOD_MS, @@ -176,6 +199,35 @@ describe('node-status.generated', () => { const tags = Object.values(NODE_FAULT_CODES).map((e) => e?.abbr) expect(new Set(tags).size).toBe(tags.length) }) + + // shared/nodes.ts decodes the row by these names (#346). Pinned at the values the decoder was + // proven against, so a firmware relayout fails here before it mis-decodes a node. + it('pins the _NODE_STATUS row layout the decoder reads', () => { + expect(GATEWAY_NODE_ID).toBe(0) + expect(NODE_STATUS_ROW_BYTES).toBe(14) + expect(NODE_STATUS_FIELD).toEqual({ + ID: 0, + PRESENT: 1, + HB_FLAGS: 2, + UPTIME: 3, + RX_COUNT: 5, + ESR: 7, + DIE_TEMP_C: 9, + HEALTH_FLAGS: 10, + FAULT_MASK: 11, + FAULT_ID: 12, + NODE_KIND: 13 + }) + }) + + it('pins the heartbeat flag bits, the ESR split and the die-temperature sentinel', () => { + expect(HEARTBEAT_FLAGS).toEqual({ BOFF: 0x01, EPVF: 0x02 }) + expect(HB_ESR_TEC_SHIFT).toBe(0) + expect(HB_ESR_REC_SHIFT).toBe(8) + // Signed int8; the row carries its byte, 0x80. + expect(DIE_TEMP_UNAVAILABLE).toBe(-128) + expect(DIE_TEMP_UNAVAILABLE & 0xff).toBe(0x80) + }) }) // shared/node-telem.ts imports its page ids and flag bits from here; only the decoder's protocol @@ -184,6 +236,16 @@ describe('node-telem contract', () => { it('matches the shared decoder (fails loudly on a firmware bump)', () => { expect(NODE_TELEM.protoVersion).toBe(SUPPORTED_TELEM_PROTO) }) + + it('pins the _NODE_TELEM framing and the PDU page offsets the decoder reads (#346)', () => { + expect(NODE_TELEM_ROW_BYTES).toBe(9) + expect(NODE_TELEM_FIELD).toEqual({ ID: 0, FRAME: 1 }) + expect(PDU_RAIL_FIELD).toEqual({ FLAGS: 1, V_AVG_MV: 2, V_MIN_MV: 4, I_AVG_MA: 6 }) + expect(PDU_STATUS_FIELD).toEqual({ FLAGS: 1, NTC_TEMP_DC: 2, I12_PEAK_MA: 4, I5_PEAK_MA: 6 }) + // Signed int16; the frame carries 0x8000. + expect(NTC_TEMP_UNAVAILABLE).toBe(-32768) + expect(NTC_TEMP_UNAVAILABLE & 0xffff).toBe(0x8000) + }) }) // shared/calibration.ts imports these from here. The codec's framing and payload decoders are @@ -216,6 +278,32 @@ describe('axis-cal.generated', () => { } }) + it('pins the payload field offsets the codec reads and writes (#346)', () => { + expect(CAL_HELLO_ACK_FIELD).toEqual({ + PROTO: 0, + BLOB_VERSION: 1, + AXIS_SLOTS: 2, + FW_MAJOR: 3, + FW_MINOR: 4, + FW_PATCH: 5 + }) + expect(CAL_DATA_FIELD).toEqual({ PRESENT_MASK: 0, CALIBRATED_MASK: 1, RECORDS: 2 }) + expect(CAL_PROTO.dataRecBytes).toBe(10) + expect(CAL_DATA_REC_FIELD).toEqual({ CONTROL_ID: 0, MIN: 2, CENTRE: 4, MAX: 6, DEADZONE: 8 }) + // CAL_DATA is exactly the two masks and the eight records. + expect(CAL_DATA_FIELD.RECORDS + CAL_PROTO.axisSlots * CAL_PROTO.dataRecBytes).toBe( + CAL_PROTO.maxPayload + ) + expect(CAL_SESSION_OPEN_FIELD).toEqual({ AXIS_IDX: 0 }) + expect(CAL_SESSION_ACK_FIELD).toEqual({ TIMEOUT_MS: 0, AXIS_IDX: 4 }) + expect(CAL_STREAM_SELECT_FIELD).toEqual({ AXIS_IDX: 0 }) + expect(CAL_COMMIT_FIELD).toEqual({ IDX: 0, MIN: 1, CENTRE: 3, MAX: 5, DEADZONE: 7 }) + expect(CAL_RESET_FIELD).toEqual({ IDX: 0 }) + expect(CAL_ACK_FIELD).toEqual({ TYPE: 0 }) + expect(CAL_NACK_FIELD).toEqual({ TYPE: 0, REASON: 1, DETAIL: 2 }) + expect(CAL_RAW_FIELD).toEqual({ IDX: 0, RAW: 1, CAL: 3 }) + }) + it('COMMIT carries exactly one axis — 9 bytes, never a batch', () => { // The batch form allowed a message to name the same axis twice and silently apply the last. // If this length ever grows, encodeCommit() is wrong in kind, not merely short. diff --git a/src/shared/firmware/usb-identity.generated.ts b/src/shared/firmware/usb-identity.generated.ts index 57a5d41..4ba5734 100644 --- a/src/shared/firmware/usb-identity.generated.ts +++ b/src/shared/firmware/usb-identity.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/SimGateway/SimGateway.cpp (SimGateway::setup USB identity) /** diff --git a/src/shared/node-telem.ts b/src/shared/node-telem.ts index c673d5e..5460e3b 100644 --- a/src/shared/node-telem.ts +++ b/src/shared/node-telem.ts @@ -2,7 +2,18 @@ // Wire contract: NodeStatus.h NODE_TELEM_* / TelemPage / PduRailFlag / PduStatusFlag (generated // into shared/firmware; reference.test asserts the decoder's protoVersion matches). Page layouts: // FirmwarePlan/02-can-protocol.md § Node Telemetry Payload — TELEM_n (#216). -import { MAX_NODE_ID, NODE_TELEM, PDU_RAIL_FLAGS, PDU_STATUS_FLAGS, TELEM_PAGES } from './firmware' +import { + MAX_NODE_ID, + NODE_TELEM, + NODE_TELEM_FIELD, + NODE_TELEM_ROW_BYTES, + NTC_TEMP_UNAVAILABLE, + PDU_RAIL_FIELD, + PDU_RAIL_FLAGS, + PDU_STATUS_FIELD, + PDU_STATUS_FLAGS, + TELEM_PAGES +} from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_TELEM_PROTO = 1 @@ -27,8 +38,8 @@ export const PDU_FLAG = PDU_STATUS_FLAGS */ export const TELEM_STALE_MS = 10_000 -const HEX_LEN = 18 // nodeId(2) + 8 frame bytes (16), frame bytes in wire order -const NTC_UNAVAILABLE = -0x8000 // INT16_MIN +// NODE_TELEM_ROW_BYTES (9) as hex, 18 chars: nodeId + the 8 frame bytes in wire order. +const HEX_LEN = NODE_TELEM_ROW_BYTES * 2 /** * PDU_RAIL_12V / PDU_RAIL_5V. Averages and the minimum cover the window since this page's @@ -85,10 +96,12 @@ export function displayCurrentMa(ma: number): number { /** Decode the 18-hex `_NODE_TELEM` argument; null if malformed, nodeId out of range or page 0. */ export function parseNodeTelem(hex: string): NodeTelem | null { - if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]{18}$/.test(hex)) return null - const nodeId = parseInt(hex.slice(0, 2), 16) + if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]+$/.test(hex)) return null + // Row byte `at` (NODE_TELEM_FIELD offset): hex index 2 × at. + const row = (at: number) => parseInt(hex.slice(at * 2, at * 2 + 2), 16) + const nodeId = row(NODE_TELEM_FIELD.ID) if (nodeId < 1 || nodeId > MAX_NODE_ID) return null - const b = (i: number) => parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16) // frame byte i + const b = (i: number) => row(NODE_TELEM_FIELD.FRAME + i) // frame byte i; byte 0 is the page const u16 = (i: number) => b(i) | (b(i + 1) << 8) // little-endian const i16 = (i: number) => { const v = u16(i) @@ -101,25 +114,31 @@ export function parseNodeTelem(hex: string): NodeTelem | null { nodeId, kind: 'rail', page, - flags: b(1), - vAvgMv: u16(2), - vMinMv: u16(4), - iAvgMa: i16(6) + flags: b(PDU_RAIL_FIELD.FLAGS), + vAvgMv: u16(PDU_RAIL_FIELD.V_AVG_MV), + vMinMv: u16(PDU_RAIL_FIELD.V_MIN_MV), + iAvgMa: i16(PDU_RAIL_FIELD.I_AVG_MA) } } if (page === TELEM_PAGE.PDU_STATUS) { - const t = i16(2) + const t = i16(PDU_STATUS_FIELD.NTC_TEMP_DC) return { nodeId, kind: 'status', page, - flags: b(1), - ntcTempC: t === NTC_UNAVAILABLE ? null : t / 10, - i12PeakMa: u16(4), - i5PeakMa: u16(6) + flags: b(PDU_STATUS_FIELD.FLAGS), + ntcTempC: t === NTC_TEMP_UNAVAILABLE ? null : t / 10, // the int16 sentinel, 0x8000 on the wire + i12PeakMa: u16(PDU_STATUS_FIELD.I12_PEAK_MA), + i5PeakMa: u16(PDU_STATUS_FIELD.I5_PEAK_MA) } } - return { nodeId, kind: 'unknown', page, body: hex.slice(4).toLowerCase() } + // Frame bytes 1–7: everything after the page byte. + return { + nodeId, + kind: 'unknown', + page, + body: hex.slice((NODE_TELEM_FIELD.FRAME + 1) * 2).toLowerCase() + } } /** diff --git a/src/shared/nodes.ts b/src/shared/nodes.ts index 79cab29..91afbac 100644 --- a/src/shared/nodes.ts +++ b/src/shared/nodes.ts @@ -2,7 +2,19 @@ // Wire contract: NodeStatus.h NODE_STATUS_* (generated into shared/firmware; reference.test // asserts the decoder's protoVersion matches). import { encodeExportFrame } from './dcsbios' -import { MAX_NODE_ID, NODE_HEALTH_FLAGS, NODE_KINDS, NODE_STATUS } from './firmware' +import { + DIE_TEMP_UNAVAILABLE, + GATEWAY_NODE_ID, + HB_ESR_REC_SHIFT, + HB_ESR_TEC_SHIFT, + HEARTBEAT_FLAGS, + MAX_NODE_ID, + NODE_HEALTH_FLAGS, + NODE_KINDS, + NODE_STATUS, + NODE_STATUS_FIELD, + NODE_STATUS_ROW_BYTES +} from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 @@ -20,14 +32,18 @@ export const HFLAG_DEGRADED = NODE_HEALTH_FLAGS.DEGRADED export const NODE_KIND = NODE_KINDS /** - * The bridge's own NODE_ID: PanelBridge is the CAN master at address 0, and since v3 every roster - * reply starts with its row. That row is this client's own link, never a cockpit node. (An address, - * not a board type: a node's type is only ever what it reports.) + * The bridge's own NODE_ID (GATEWAY_NODE_ID in the firmware reference): PanelBridge is the CAN + * master at address 0, and since v3 every roster reply starts with its row. That row is this + * client's own link, never a cockpit node. (An address, not a board type: a node's type is only + * ever what it reports.) */ -export const BRIDGE_NODE_ID = 0 +export const BRIDGE_NODE_ID = GATEWAY_NODE_ID -// proto v3: 28 hex — v2 plus the node's reported board kind (#344). -const HEX_LEN = 28 // nodeId(2) present(2) flags(2) uptime(4) rxCount(4) esr(4) dieTempC(2) hFlags(2) faultMask(2) faultId(2) nodeKind(2) +// proto v3: NODE_STATUS_ROW_BYTES (14) as hex, 28 chars — v2 plus the node's reported board kind +// (#344). Field offsets: NODE_STATUS_FIELD; each byte is two hex chars. +const HEX_LEN = NODE_STATUS_ROW_BYTES * 2 +// The legacy v2 row (26 hex), detected client-side only to say "this bridge is too old". The +// firmware no longer describes it, so it has nothing to sync from: it stays a client constant. const V2_HEX_LEN = 26 export interface NodeStatus { @@ -40,7 +56,7 @@ export interface NodeStatus { tec: number // transmit error counter (esr low byte) rec: number // receive error counter (esr high byte) // HEALTH_n telemetry (proto v2). Uncalibrated internal MCU sensor — die temp, not ambient. - dieTempC: number | null // whole °C; null = not yet seen (sentinel 0x80) + dieTempC: number | null // whole °C; null = not yet seen (DIE_TEMP_UNAVAILABLE, 0x80 on the wire) overheat: boolean // hFlags bit0 (opt-in firmware trip; usually false) degraded: boolean // hFlags bit1 — a registered FaultSource reports non-NONE (rendered by #40, parsed here) faultId: number // fault code, 0 = none; label via NODE_FAULT_CODES (node-status.generated.ts), render #40 @@ -61,28 +77,31 @@ export function nodeFaultTag(n: NodeStatus): string { /** Decode the 28-hex v3 `_NODE_STATUS` argument; null if malformed / nodeId out of range. */ export function parseNodeStatus(hex: string): NodeStatus | null { - if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]{28}$/.test(hex)) return null - const f = (i: number, n: number) => parseInt(hex.slice(i, i + n), 16) - const nodeId = f(0, 2) + if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]+$/.test(hex)) return null + const F = NODE_STATUS_FIELD + // A field at byte offset `at`, `bytes` wide: hex index 2 × at, most-significant byte first. + const f = (at: number, bytes: number) => parseInt(hex.slice(at * 2, (at + bytes) * 2), 16) + const nodeId = f(F.ID, 1) if (nodeId > MAX_NODE_ID) return null - const flags = f(4, 2) - const esr = f(14, 4) - const traw = f(18, 2) // int8 two's-complement; 0x80 = not-yet-seen sentinel - const hf = f(20, 2) // byte 22 (faultMask) reserved/unused; skipped + const flags = f(F.HB_FLAGS, 1) + const esr = f(F.ESR, 2) + const traw = f(F.DIE_TEMP_C, 1) // int8 two's-complement + const temp = traw < 128 ? traw : traw - 256 + const hf = f(F.HEALTH_FLAGS, 1) // FAULT_MASK (the next byte) is reserved/unused; skipped return { nodeId, - present: f(2, 2) === 1, - boff: (flags & 0x01) !== 0, - epvf: (flags & 0x02) !== 0, - uptimeSec: f(6, 4), - rxCount: f(10, 4), - tec: esr & 0xff, - rec: (esr >> 8) & 0xff, - dieTempC: traw === 0x80 ? null : traw < 128 ? traw : traw - 256, + present: f(F.PRESENT, 1) === 1, + boff: (flags & HEARTBEAT_FLAGS.BOFF) !== 0, + epvf: (flags & HEARTBEAT_FLAGS.EPVF) !== 0, + uptimeSec: f(F.UPTIME, 2), + rxCount: f(F.RX_COUNT, 2), + tec: (esr >> HB_ESR_TEC_SHIFT) & 0xff, + rec: (esr >> HB_ESR_REC_SHIFT) & 0xff, + dieTempC: temp === DIE_TEMP_UNAVAILABLE ? null : temp, // the int8 sentinel, -128 (0x80) overheat: (hf & HFLAG_OVERHEAT) !== 0, degraded: (hf & HFLAG_DEGRADED) !== 0, - faultId: f(24, 2), - nodeKind: f(26, 2) + faultId: f(F.FAULT_ID, 1), + nodeKind: f(F.NODE_KIND, 1) } } From ef576889089ecd11b2515bbea5c44c0f51069230 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 20:45:38 -0400 Subject: [PATCH 08/28] =?UTF-8?q?refactor(ipc):=20per-topic=20contracts,?= =?UTF-8?q?=20break=20the=20model=E2=86=94contract=20cycles=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.3 of the structure rewrite (#101, phase #98). Mechanical only: UI, wording and behaviour are unchanged. IPC channel strings and payload shapes are byte-identical. The shared format helpers are not in this PR; they come in R1.3b. ## Topic contracts `src/shared/ipc.ts` is gone. Each topic now has its own `src/shared//ipc.ts` and an `index.ts` that re-exports it. | Topic | What its contract holds | |---|---| | **app** | `SkyhawkApi` (verbatim). `PushChannels`, the union of the topics' push maps. `PushChannel`. `IPC` and `CTRL`, spread from the topics' channel objects. `RelayStatus`, the `relay:status` rehydrate bundle. `DebugDumpResult`. `APP_CTRL` (`relay:status`, `debug:dump-ports`, `debug:reveal`) | | **gateway** | `DeviceState`, `isLinkUp`, `DeviceStatus`, `GatewayKind`, `GatewayStatus`, `SerialFrame`. Pushes `device:status` and `serial:traffic`. Intents `device:connect`, `device:disconnect`, `serial:monitor` | | **dcs** | `EXPORT_MULTICAST_ADDR`, `EXPORT_PORT`, `COMMAND_PORT`. `StreamState`, `RelayState`, `AircraftStatus`, `StatsSnapshot`, `LogRow`, `TelemetryReadout`, `RelayResult`, `ExportResult`, `CaptureState`, `ReplayLoad`, `CaptureEvent`, `CaptureMeta`. Pushes `relay:state`, `aircraft:changed`, `stats:tick`, `log:batch`, `telemetry:tick`. Intents `relay:start`, `relay:stop`, `log:export`, `capture:toggle`, `replay:open` | | **nodes** | `NodeStatus`, `NodeSeen`, `NodeIdentity`, `NodeKind`. The mapping shapes. The telemetry page shapes and `NodeTelemSnapshot`. The model shapes. The PSU shapes and `DEFAULT_PSU`. `NodeRegistryInfo`, `NodeSnapshot`, `NodeMappingResult`. Pushes `nodes:telemetry` and `nodes:model`. Intents `nodes:*` and `node-mapping:*` | | **hid** | `HidSnapshot`, `HidAvailability`. Push `hid:report`. Intent `hid:availability` | | **hid/calibration** | `CalRawSample`, `CalSnapshot`, `CalFailure`, `CalResult`, `CalHello`, `CalCommitAxis`, `CalCacheEntry`. `CachedAxis`, `CachedBoard`, `CalCache`. The capture shapes (`CaptureConfig`, `CaptureStep`, `CapturePhase`, `CapturedSweep`, `CaptureResult`, `CaptureState`). Pushes `cal:data` and `cal:raw`. Intents: the ten `cal:*` | | **events** | `EventSource`, `EventSeverity`, `LogEventInput`, `LogEvent`, `MAX_EVENTS`. Push `events:batch`. Intents `events:get`, `events:clear`, `events:export` | | **config** | `SourceMode`, `DcsTransport`, `SOURCE_MODES`, `TRANSPORTS`, `AppConfig`, `DEFAULT_CONFIG`, **`SettingsStore`** (new, type-only). `CONFIG_FORMAT`, `AppSettings`, `ConfigSnapshot`, `SectionId`, `ConfigFile`, `DiffRow`, `SectionDiff`. `ConfigExportResult`, `ImportPreview`, `ConfigImportOpen`, `ConfigImportApplyResult`. Intents `config:*` | Each topic also exports a `_PUSH` / `_CTRL` channel object and a `PushChannels` map. `IPC` and `CTRL` are spreads of those objects, so their key order differs from before. Nothing iterates either object; the guard below checks their exact types. `DEFAULT_CONFIG` now reads `commandPort: COMMAND_PORT` and `listenPort: EXPORT_PORT` from the dcs contract. The values are the same: 7778 and 5010, checked at runtime. `SettingsStore` is a read-only store over `AppConfig`: `getState()` plus both `subscribe` overloads, shaped like zustand/vanilla's `StoreApi` under `subscribeWithSelector`. Nothing uses it yet; R2.3 wires it. The guard file checks that a real zustand `Mutate, [['zustand/subscribeWithSelector', never]]>` is assignable to it. ## Pushed shapes moved out of the models (the 52 "MOVE (pushed shape)" rows) - `nodes.ts`: `NodeStatus`, `NodeSeen` - `node-telem.ts`: `PduRailPage`, `PduStatusPage`, `UnknownTelemPage`, `TelemPageData`, `NodeTelem`, `TelemPageView`, `NodeTelemSnapshot`. The page literal types read `typeof TELEM_PAGES.X` from `shared/firmware`; `TELEM_PAGE` was an alias of it, so the types are identical. - `node-kind.ts`: `NodeIdentity` - `node-mapping.ts`: `NodeKind`, `NodeOverride`, `NodeMappingFile`, `NodeDefaults`, `MappedNode`, `MappingPatch`, `AdoptSpec`, `ResetOptions`, `ResetRow`, `AdoptedRow`, `ResetPreview` - `node-rules.ts`: `NodeAvailability`, `NodeModelEntry`, `PduOutage`, `HiddenNode`, `NodeModel` - `psu.ts`: `PsuLimits`, `PsuPresetId`, `PsuPreset`, `PsuSetting` - `events.ts`: `EventSource`, `EventSeverity`, `LogEventInput`, `LogEvent` - `gateway.ts`: `GatewayKind`, `GatewayStatus` - `config-file.ts`: `AppSettings`, `ConfigSnapshot`, `SectionId`, `ConfigFile`, `DiffRow`, `SectionDiff` - `cal-cache.ts`: `CachedAxis`, `CachedBoard`, `CalCache` - `axis-capture.ts`: `CaptureStep`, `CapturePhase`, `CapturedSweep`, `CaptureResult`, `CaptureState` - `capture.ts`: `CaptureEvent`, `CaptureMeta` These also had to move, because a contract value or shape is defined by them. Without them, C1 can't reach 0: - `DEFAULT_PSU` → nodes. `DEFAULT_CONFIG.psu` is `DEFAULT_PSU`, and it's still the same object. - `CONFIG_FORMAT` → config (`ConfigFile.format` is `typeof CONFIG_FORMAT`). - `MAX_EVENTS` → events (plan row 236). - `CaptureConfig` → hid/calibration (`CaptureState.config`). - `EXPORT_MULTICAST_ADDR` / `EXPORT_PORT` / `COMMAND_PORT` → dcs (plan rows 1–3). The model files import their types from the contracts. No names changed: `LogRow` → `StreamRow` and `CaptureState` → `RecordState` wait for R3.4. All importers moved to the topic path: 49 files imported `@shared/ipc`, 99 existing files changed in total (imports, plus the moved declarations in the 13 model files). Tests changed import paths only. The one dynamic import in `session.test.ts` now reads `@shared/config/ipc`. ## Cycles broken (C2) | Cycle | How | |---|---| | config-file ↔ ipc (value) | `config-file` imports the config contract. `sanitizeConfig` calls a model (`sanitizePsu`), so it can't stay in a contract. It moves to `shared/settings-schema.ts`, the plan's file name, still in shared until R1.4 moves it to `main/config/` | | config-file → cal-cache → ipc → config-file | `cal-cache` takes `CalSnapshot` from the hid/calibration contract, and the contract no longer imports `config-file` | | events ↔ ipc (type) | `LogEvent` and friends are in the events contract. `events.ts` reads `DeviceStatus`, `RelayState` and `SourceMode` from the gateway, dcs and config contracts | | gateway → events → ipc → gateway | `GatewayStatus` is in the gateway contract | | cal-cache ↔ ipc (type) | The cache shapes are in the hid/calibration contract | | node-kind ↔ node-mapping (model ↔ model) | `NodeKind` and `NodeIdentity` both move into the nodes contract. `node-kind` no longer imports `node-mapping`; `node-mapping` → `node-kind` (`isAdoptableKind`) is the only edge left. No code moved between the models | ## Lint counts | | Before (552b050) | After | |---|---|---| | `lint:deps` | 0 errors, 65 warnings | **0 errors, 23 warnings** | | C2 cycles | 6 | **0** (→ error) | | P3 shared → process | 0* | **0** (→ error) | | C1 contract → non-contract | 0* | **0** (→ error) | | P4 renderer → non-contract shared | 53 | 17 (the rest are value imports that leave in R3.x) | | P6 electron outside app | 5 | 5 (R1.4) | | T2 renderer deep import | 1 | 1 (`logo.png`, R3.4) | | `npm run lint` | 0 errors, 32 warnings | 0 errors, 32 warnings | | `vitest` | 62 files, 856 tests | 62 files, 856 tests | \* Before this PR, no file matched the `shared//(index\|ipc).ts` contract pattern, so P3 and C1 had nothing to report. §5.4 counted the same 8 edges by hand-mapping; those are gone now. I checked that the rules fire: a probe import from `events/ipc.ts` into `events.ts` errors on both C1 and C2. `.dependency-cruiser.severity.json` flips `process`, `contracts`, `cycles` and `formatLeaf` to `"error"`. `formatLeaf` has nothing to match until R1.3b. ## API-equality guard `src/shared/app/ipc.compat.test-d.ts` is type-only; vitest and the build don't pick it up. `npm run typecheck` checks it under both tsconfigs. It holds a frozen copy of the pre-split `SkyhawkApi`, `PushChannels`, `IPC` and `CTRL`, and asserts exact `Equal` for all four. The first commit (8e3830f) added it against the old `@shared/ipc` and typechecked green. It's still green after the split. **Fail-proof.** I made each change temporarily, ran typecheck, then reverted: - `exportLog(text: string, mode?: number)` gives `ipc.compat.test-d.ts(187,10): error TS2344: Type 'false' does not satisfy the constraint 'true'` (SkyhawkApi). - `serialTraffic: 'serial:traffik'` in `GATEWAY_PUSH` gives the same error at (189,10) (IPC). - `SettingsStore.getState(): AppConfig & { extra: 1 }` gives the same error at (199,3) (zustand fit). **Preload runtime.** The preload still keeps its explicit method table. It now imports `CTRL` and the types from `@shared/app/ipc` and the topic contracts. Its exposed API was diffed at runtime, before and after: the preload ran with `electron` stubbed, and every method was called with sentinel args while recording what reached `ipcRenderer`. The result is identical: key `skyhawk` and 41 methods (`on`, `getConfig`, `setConfig`, `exportConfig`, `openConfigImport`, `applyConfigImport`, `startRelay`, `stopRelay`, `getStatus`, `connectDevice`, `disconnectDevice`, `exportLog`, `getEvents`, `clearEvents`, `exportEvents`, `toggleCapture`, `openReplay`, `getHidAvailability`, `refreshNodes`, `getNodes`, `setNodeMapping`, `adoptNode`, `forgetNode`, `resetNode`, `resetNodeMapping`, `previewNodeReset`, `getNodeRegistry`, `hideNode`, `setSerialMonitor`, `dumpSerialPorts`, `revealDebugLog`, `calHello`, `calRead`, `calSessionOpen`, `calStreamSelect`, `calCommit`, `calReset`, `calCacheRead`, `calCacheStore`, `calCacheDrop`, `calSessionClose`). Each one invokes the same channel with the same args. `IPC` and `CTRL` hold the same key→channel pairs as before (also checked at runtime). ## Shim status There is **no shim**. `src/shared/ipc.ts` is deleted, and nothing in `src/` imports `@shared/ipc` or a relative `./ipc` that points at it. Importers use `@shared//ipc`, not the bare `@shared/`. Today `@shared/gateway`, `@shared/events`, `@shared/hid` and `@shared/nodes` resolve to the model files `gateway.ts`, `events.ts`, `hid.ts` and `nodes.ts`, because a file wins over a directory index. The `index.ts` files are in place; once R1.4 moves those models to main, the imports can drop `/ipc`. ## Verification `npm run typecheck`, `npm run lint` (0 errors), `npm run lint:deps` (0 errors), `npx vitest run` (856/856) and `npm run build` all pass. The T14 app-shell component test passes unchanged. Refs #98 --- .dependency-cruiser.severity.json | 8 +- PRD.md | 2 +- src/main/cal-cache.ts | 3 +- src/main/config-transfer.seam.test.ts | 8 +- src/main/config-transfer.test.ts | 7 +- src/main/config-transfer.ts | 15 +- src/main/decode.ts | 2 +- src/main/device-link.ts | 36 +- src/main/hid.ts | 2 +- src/main/index.ts | 20 +- src/main/net.ts | 4 +- src/main/node-hub.seam.test.ts | 4 +- src/main/node-hub.test.ts | 2 +- src/main/node-hub.ts | 33 +- src/main/node-mapping.test.ts | 3 +- src/main/node-mapping.ts | 8 +- src/main/phase0-probes.test.ts | 6 +- src/main/replay.ts | 5 +- src/main/session.seam.test.ts | 4 +- src/main/session.test.ts | 9 +- src/main/session.ts | 25 +- src/main/settings.ts | 3 +- src/main/stats.ts | 2 +- src/main/test-harness/services.ts | 5 +- src/preload/index.ts | 45 +- src/renderer/src/CalibrationDialog.tsx | 2 +- src/renderer/src/DevicePopover.tsx | 2 +- .../src/calibration-controller.test.ts | 10 +- src/renderer/src/calibration-controller.ts | 12 +- src/renderer/src/cockpit-adopt.test.ts | 5 +- src/renderer/src/cockpit-adopt.ts | 3 +- src/renderer/src/cockpit-panels.test.ts | 5 +- src/renderer/src/cockpit-panels.ts | 5 +- src/renderer/src/cockpit.test.ts | 4 +- src/renderer/src/cockpit.ts | 27 +- src/renderer/src/connection-summary.test.ts | 4 +- src/renderer/src/connection-summary.ts | 2 +- src/renderer/src/device-view.test.ts | 2 +- src/renderer/src/device-view.ts | 3 +- src/renderer/src/env.d.ts | 2 +- src/renderer/src/freshness.ts | 2 +- src/renderer/src/gateway-view.test.ts | 2 +- src/renderer/src/gateway-view.ts | 2 +- src/renderer/src/log-view.test.ts | 2 +- src/renderer/src/log-view.ts | 3 +- src/renderer/src/overview.test.ts | 4 +- src/renderer/src/overview.ts | 3 +- src/renderer/src/phase0-probes.test.ts | 2 +- src/renderer/src/store.test.ts | 8 +- src/renderer/src/store.ts | 30 +- src/renderer/src/tabs/Cockpit.tsx | 6 +- src/renderer/src/tabs/Connection.tsx | 2 +- src/renderer/src/tabs/Hid.tsx | 2 +- src/renderer/src/tabs/Log.tsx | 3 +- src/renderer/src/tabs/Overview.tsx | 2 +- src/renderer/src/tabs/cockpit/AdoptDialog.tsx | 2 +- src/renderer/src/tabs/cockpit/SidePanels.tsx | 3 +- .../src/tabs/settings/AboutSection.tsx | 3 +- .../src/tabs/settings/CockpitSection.tsx | 3 +- .../tabs/settings/ConfigurationSection.tsx | 2 +- .../src/tabs/settings/ImportDialog.tsx | 3 +- .../src/tabs/settings/ResetDialog.tsx | 3 +- .../src/tabs/settings/settings-view.test.ts | 3 +- .../src/tabs/settings/settings-view.ts | 3 +- .../src/tabs/settings/useNodeModel.ts | 2 +- src/renderer/test/scenarios.ts | 29 +- src/renderer/test/skyhawk-mock.ts | 24 +- .../test/t11-adopt-dialog-add-all.test.tsx | 2 +- .../test/t12-settings-hidden-boards.test.tsx | 2 +- .../test/t13-settings-import-preview.test.tsx | 2 +- .../test/t16-board-panel-settings.test.tsx | 2 +- src/renderer/test/t6-device-popover.test.tsx | 2 +- .../test/t7-board-footer-panel.test.tsx | 2 +- .../test/t8-board-footer-pdu.test.tsx | 2 +- src/shared/app/index.ts | 1 + src/shared/app/ipc.compat.test-d.ts | 199 ++++++ src/shared/app/ipc.ts | 231 +++++++ src/shared/axis-capture.test.ts | 6 +- src/shared/axis-capture.ts | 193 +----- src/shared/cal-cache.test.ts | 6 +- src/shared/cal-cache.ts | 32 +- src/shared/capture.test.ts | 9 +- src/shared/capture.ts | 14 +- src/shared/config-file.test.ts | 13 +- src/shared/config-file.ts | 62 +- src/shared/config.test.ts | 3 +- src/shared/config/index.ts | 1 + src/shared/config/ipc.ts | 139 +++++ src/shared/dcs/index.ts | 1 + src/shared/dcs/ipc.ts | 121 ++++ src/shared/dcsbios.ts | 4 - src/shared/events.test.ts | 7 +- src/shared/events.ts | 46 +- src/shared/events/index.ts | 1 + src/shared/events/ipc.ts | 49 ++ src/shared/gateway.test.ts | 5 +- src/shared/gateway.ts | 28 +- src/shared/gateway/index.ts | 1 + src/shared/gateway/ipc.ts | 96 +++ src/shared/hid/calibration/index.ts | 1 + src/shared/hid/calibration/ipc.ts | 336 +++++++++++ src/shared/hid/index.ts | 1 + src/shared/hid/ipc.ts | 32 + src/shared/ipc.ts | 570 ------------------ src/shared/node-kind.ts | 8 +- src/shared/node-mapping.test.ts | 6 +- src/shared/node-mapping.ts | 111 +--- src/shared/node-rules.test.ts | 5 +- src/shared/node-rules.ts | 116 +--- src/shared/node-telem.test.ts | 4 +- src/shared/node-telem.ts | 56 +- src/shared/nodes.test.ts | 12 +- src/shared/nodes.ts | 40 +- src/shared/nodes/index.ts | 1 + src/shared/nodes/ipc.ts | 361 +++++++++++ src/shared/psu.test.ts | 3 +- src/shared/psu.ts | 35 +- src/shared/settings-schema.ts | 39 ++ src/test-fixtures/config.ts | 4 +- src/test-fixtures/nodes.ts | 3 +- 120 files changed, 1971 insertions(+), 1557 deletions(-) create mode 100644 src/shared/app/index.ts create mode 100644 src/shared/app/ipc.compat.test-d.ts create mode 100644 src/shared/app/ipc.ts create mode 100644 src/shared/config/index.ts create mode 100644 src/shared/config/ipc.ts create mode 100644 src/shared/dcs/index.ts create mode 100644 src/shared/dcs/ipc.ts create mode 100644 src/shared/events/index.ts create mode 100644 src/shared/events/ipc.ts create mode 100644 src/shared/gateway/index.ts create mode 100644 src/shared/gateway/ipc.ts create mode 100644 src/shared/hid/calibration/index.ts create mode 100644 src/shared/hid/calibration/ipc.ts create mode 100644 src/shared/hid/index.ts create mode 100644 src/shared/hid/ipc.ts delete mode 100644 src/shared/ipc.ts create mode 100644 src/shared/nodes/index.ts create mode 100644 src/shared/nodes/ipc.ts create mode 100644 src/shared/settings-schema.ts diff --git a/.dependency-cruiser.severity.json b/.dependency-cruiser.severity.json index 9f1efd8..f4f562e 100644 --- a/.dependency-cruiser.severity.json +++ b/.dependency-cruiser.severity.json @@ -1,12 +1,12 @@ { - "process": "warn", + "process": "error", "crossProcess": "warn", "electron": "warn", "rendererShared": "warn", - "contracts": "warn", - "cycles": "warn", + "contracts": "error", + "cycles": "error", "firmwareLeaf": "error", - "formatLeaf": "warn", + "formatLeaf": "error", "kitLeaf": "warn", "topicsMain": "warn", "topicsRenderer": "warn", diff --git a/PRD.md b/PRD.md index 7cab6ec..14bdbb7 100644 --- a/PRD.md +++ b/PRD.md @@ -131,7 +131,7 @@ SkyHawkClient/ │ └── release.yml ← tag v*: build Win + Mac Electron artifacts → GitHub Release ├── src/main/{bridge,reference,stats}.ts ... ├── src/renderer/{panels,store}/ ... -├── src/shared/ipc.ts +├── src/shared//ipc.ts ← IPC contract per topic; src/shared/app/ipc.ts assembles them ├── scripts/sync-a4ec.ts ← firmware sync (also a CI step / prebuild) ├── package.json electron.vite.config.ts tsconfig.json .eslintrc .prettierrc └── README.md (data-flow + how it relates to the firmware repo) diff --git a/src/main/cal-cache.ts b/src/main/cal-cache.ts index f6d1abd..1f34af2 100644 --- a/src/main/cal-cache.ts +++ b/src/main/cal-cache.ts @@ -7,7 +7,8 @@ import { app } from 'electron' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { emptyCache, sanitizeCache, type CalCache } from '@shared/cal-cache' +import { emptyCache, sanitizeCache } from '@shared/cal-cache' +import type { CalCache } from '@shared/hid/calibration/ipc' function cachePath(): string { return join(app.getPath('userData'), 'calibration-cache.json') diff --git a/src/main/config-transfer.seam.test.ts b/src/main/config-transfer.seam.test.ts index 5ea42f0..0295e91 100644 --- a/src/main/config-transfer.seam.test.ts +++ b/src/main/config-transfer.seam.test.ts @@ -4,10 +4,10 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { CONFIG_FORMAT } from '@shared/config-file' -import { DEFAULT_CONFIG, type AppConfig } from '@shared/ipc' -import { registryDefaults, type NodeMappingFile } from '@shared/node-mapping' -import type { CalCache } from '@shared/cal-cache' +import { CONFIG_FORMAT, DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' +import { registryDefaults } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' +import type { CalCache } from '@shared/hid/calibration/ipc' import { ConfigTransfer } from './config-transfer' const axis = (min: number, centre: number, max: number) => ({ diff --git a/src/main/config-transfer.test.ts b/src/main/config-transfer.test.ts index de65ecc..1c930b0 100644 --- a/src/main/config-transfer.test.ts +++ b/src/main/config-transfer.test.ts @@ -2,9 +2,10 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { DEFAULT_CONFIG, type AppConfig, type ImportPreview } from '@shared/ipc' -import { registryDefaults, type NodeMappingFile } from '@shared/node-mapping' -import type { CalCache } from '@shared/cal-cache' +import { DEFAULT_CONFIG, type AppConfig, type ImportPreview } from '@shared/config/ipc' +import { registryDefaults } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' +import type { CalCache } from '@shared/hid/calibration/ipc' import { ConfigTransfer } from './config-transfer' const defaults = registryDefaults({ diff --git a/src/main/config-transfer.ts b/src/main/config-transfer.ts index cb9a5c1..e27b114 100644 --- a/src/main/config-transfer.ts +++ b/src/main/config-transfer.ts @@ -11,22 +11,21 @@ import type { AppConfig, ConfigImportApplyResult, ConfigImportOpen, - NodeMappingResult -} from '@shared/ipc' + ConfigSnapshot, + SectionId +} from '@shared/config/ipc' +import type { NodeDefaults, NodeMappingFile, NodeMappingResult } from '@shared/nodes/ipc' import { - appSettings, applyToConfig, + appSettings, buildConfigFile, diffImport, mergeCalibration, parseConfigFile, SECTION_IDS, - type ConfigSnapshot, - type ParsedConfig, - type SectionId + type ParsedConfig } from '@shared/config-file' -import type { CalCache } from '@shared/cal-cache' -import type { NodeDefaults, NodeMappingFile } from '@shared/node-mapping' +import type { CalCache } from '@shared/hid/calibration/ipc' export interface ConfigTransferDeps { getConfig(): AppConfig diff --git a/src/main/decode.ts b/src/main/decode.ts index 5cb03ef..3c8450a 100644 --- a/src/main/decode.ts +++ b/src/main/decode.ts @@ -3,7 +3,7 @@ import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' import { A4EC_ADDRESS_RANGE } from './reference/dcsbios-metadata' import { StringRegion, ACFT_NAME_ADDRESS, ACFT_NAME_MAX } from '@shared/dcsbios' -import type { AircraftStatus, LogRow, TelemetryReadout } from '@shared/ipc' +import type { AircraftStatus, LogRow, TelemetryReadout } from '@shared/dcs/ipc' interface TelemetrySpec { id: string diff --git a/src/main/device-link.ts b/src/main/device-link.ts index 6912d7c..4a9fbf9 100644 --- a/src/main/device-link.ts +++ b/src/main/device-link.ts @@ -19,29 +19,21 @@ import { } from '@shared/nodes' import { GatewayWatch } from '@shared/gateway' import { NODE_TELEM_MSG } from '@shared/node-telem' -import type { LogEventInput } from '@shared/events' -import { - isLinkUp, - type CalCacheEntry, - type CalCommitAxis, - type CalHello, - type CalRawSample, - type CalResult, - type CalSnapshot, - type DeviceStatus, - type PushChannel, - type PushChannels, - type SerialFrame -} from '@shared/ipc' +import type { LogEventInput } from '@shared/events/ipc' +import { isLinkUp, type DeviceStatus, type SerialFrame } from '@shared/gateway/ipc' +import type { + CachedAxis, + CachedBoard, + CalCacheEntry, + CalCommitAxis, + CalHello, + CalRawSample, + CalResult, + CalSnapshot +} from '@shared/hid/calibration/ipc' +import type { PushChannel, PushChannels } from '@shared/app/ipc' import { CAL_AXIS_NONE, nackReasonName } from '@shared/calibration' -import { - boardFor, - dropAxis, - regressedAxes, - storeAxes, - type CachedAxis, - type CachedBoard -} from '@shared/cal-cache' +import { boardFor, dropAxis, regressedAxes, storeAxes } from '@shared/cal-cache' import { CalLink, CalNackError, CalTimeoutError } from './callink' import { findSimGatewayPort, diff --git a/src/main/hid.ts b/src/main/hid.ts index 9acf857..536dd14 100644 --- a/src/main/hid.ts +++ b/src/main/hid.ts @@ -13,7 +13,7 @@ import { HID_HAT_COUNT, HID_REPORT_SIZE } from '@shared/hid' -import type { HidSnapshot } from '@shared/ipc' +import type { HidSnapshot } from '@shared/hid/ipc' import { SIMGATEWAY_PID, SIMGATEWAY_VID } from './serial' /** Matches the serial link's reconnect cadence — the same cable, coming back at the same time. */ diff --git a/src/main/index.ts b/src/main/index.ts index 5cefe96..f189dc2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,20 +1,12 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { - CTRL, - type AppConfig, - type CalCacheEntry, - type CalCommitAxis, - type CaptureState, - type ConfigExportResult, - type ConfigImportOpen, - type HidAvailability, - type PushChannel, - type PushChannels, - type ReplayLoad -} from '@shared/ipc' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/node-mapping' +import { CTRL, type PushChannel, type PushChannels } from '@shared/app/ipc' +import type { AppConfig, ConfigExportResult, ConfigImportOpen } from '@shared/config/ipc' +import type { CalCacheEntry, CalCommitAxis } from '@shared/hid/calibration/ipc' +import type { CaptureState, ReplayLoad } from '@shared/dcs/ipc' +import type { HidAvailability } from '@shared/hid/ipc' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes/ipc' import { eventsToTsv } from '@shared/events' import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' import { Session } from './session' diff --git a/src/main/net.ts b/src/main/net.ts index 1441bbf..96c9aa6 100644 --- a/src/main/net.ts +++ b/src/main/net.ts @@ -3,8 +3,8 @@ // without caring which transport produced the bytes. import dgram from 'node:dgram' import net from 'node:net' -import { EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcsbios' -import type { AppConfig } from '@shared/ipc' +import { EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcs/ipc' +import type { AppConfig } from '@shared/config/ipc' export interface Transport { start(): void diff --git a/src/main/node-hub.seam.test.ts b/src/main/node-hub.seam.test.ts index 2bb62f8..c823a01 100644 --- a/src/main/node-hub.seam.test.ts +++ b/src/main/node-hub.seam.test.ts @@ -2,8 +2,8 @@ // telemetry, flush, snapshot and the mutators — with the mapping store kept in memory. They pin // what the app does today, so the refactor can show it still does. import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LogEventInput } from '@shared/events' -import type { NodeMappingFile } from '@shared/node-mapping' +import type { LogEventInput } from '@shared/events/ipc' +import type { NodeMappingFile } from '@shared/nodes/ipc' import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' import { statusRow } from '../test-fixtures/nodes' diff --git a/src/main/node-hub.test.ts b/src/main/node-hub.test.ts index 94e7894..13c4bf0 100644 --- a/src/main/node-hub.test.ts +++ b/src/main/node-hub.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { NodeMappingFile } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' const disk: { diff --git a/src/main/node-hub.ts b/src/main/node-hub.ts index db0af66..e0fdb84 100644 --- a/src/main/node-hub.ts +++ b/src/main/node-hub.ts @@ -1,16 +1,26 @@ // Node data layer in main: taps `_NODE_TELEM`, owns the node mapping, and pushes the combined // node picture. Kept out of session.ts so the session only feeds it lines and ticks. import type { + AdoptSpec, + MappedNode, + MappingPatch, + NodeDefaults, + NodeIdentity, + NodeMappingFile, NodeMappingResult, + NodeModel, NodeRegistryInfo, NodeSnapshot, - PushChannel, - PushChannels -} from '@shared/ipc' -import { BRIDGE_NODE_ID, type NodeRoster, type NodeStatus } from '@shared/nodes' -import { isAdoptableKind, resolveKind, type NodeIdentity } from '@shared/node-kind' + NodeStatus, + ResetOptions, + ResetPreview +} from '@shared/nodes/ipc' +import type { PushChannel, PushChannels } from '@shared/app/ipc' +import { BRIDGE_NODE_ID, type NodeRoster } from '@shared/nodes' +import { isAdoptableKind, resolveKind } from '@shared/node-kind' import { NodeTelemStore, parseNodeTelem } from '@shared/node-telem' -import { NodeEventTracker, type LogEventInput } from '@shared/events' +import { NodeEventTracker } from '@shared/events' +import type { LogEventInput } from '@shared/events/ipc' import { adoptNode, clearReportedHidden, @@ -24,16 +34,9 @@ import { resetNode, resetPreview, setHidden, - setNodeMapping, - type AdoptSpec, - type MappedNode, - type MappingPatch, - type NodeDefaults, - type NodeMappingFile, - type ResetOptions, - type ResetPreview + setNodeMapping } from '@shared/node-mapping' -import { buildNodeModel, type NodeModel } from '@shared/node-rules' +import { buildNodeModel } from '@shared/node-rules' import { NODE_FAULT_CODES, NODE_NAMES, diff --git a/src/main/node-mapping.test.ts b/src/main/node-mapping.test.ts index 1fb6e04..4ff0e3b 100644 --- a/src/main/node-mapping.test.ts +++ b/src/main/node-mapping.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { EMPTY_MAPPING, importMapping, type NodeMappingFile } from '@shared/node-mapping' +import { EMPTY_MAPPING, importMapping } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' const userData = vi.hoisted(() => ({ dir: '' })) vi.mock('electron', () => ({ app: { getPath: () => userData.dir } })) diff --git a/src/main/node-mapping.ts b/src/main/node-mapping.ts index 4776053..072f83b 100644 --- a/src/main/node-mapping.ts +++ b/src/main/node-mapping.ts @@ -6,12 +6,8 @@ import { app } from 'electron' import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { - EMPTY_MAPPING, - NODE_MAPPING_VERSION, - sanitizeNodeMapping, - type NodeMappingFile -} from '@shared/node-mapping' +import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' /** A save that did not reach the disk. The message is ready to show. */ export class MappingSaveError extends Error {} diff --git a/src/main/phase0-probes.test.ts b/src/main/phase0-probes.test.ts index 20569c8..87d1185 100644 --- a/src/main/phase0-probes.test.ts +++ b/src/main/phase0-probes.test.ts @@ -4,7 +4,7 @@ // Main and shared only; the probes that read renderer helpers are in // src/renderer/src/phase0-probes.test.ts (main never imports renderer code). import { describe, expect, it, vi } from 'vitest' -import type { NodeMappingFile } from '@shared/node-mapping' +import { DEFAULT_PSU, type NodeMappingFile } from '@shared/nodes/ipc' import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' import { registryDefaults, @@ -14,10 +14,10 @@ import { effectiveMapping, EMPTY_MAPPING } from '@shared/node-mapping' -import { parseConfigFile, CONFIG_FORMAT, diffImport } from '@shared/config-file' +import { diffImport, parseConfigFile } from '@shared/config-file' +import { CONFIG_FORMAT } from '@shared/config/ipc' import { buildNodeModel } from '@shared/node-rules' import { GatewayWatch, GATEWAY_NO_ANSWER_MS } from '@shared/gateway' -import { DEFAULT_PSU } from '@shared/psu' import { DEFAULT_APP_SETTINGS } from '../test-fixtures/config' const disk: { file: NodeMappingFile } = { file: { version: 3, nodes: {} } } diff --git a/src/main/replay.ts b/src/main/replay.ts index 8f6f46f..3cd4d4e 100644 --- a/src/main/replay.ts +++ b/src/main/replay.ts @@ -6,10 +6,9 @@ import { captureDurationMs, parseCapture, serializeCapture, - type CaptureEvent, - type CaptureFile, - type CaptureMeta + type CaptureFile } from '@shared/capture' +import type { CaptureEvent, CaptureMeta } from '@shared/dcs/ipc' export class Recorder { private events: CaptureEvent[] = [] diff --git a/src/main/session.seam.test.ts b/src/main/session.seam.test.ts index c446dcd..8244037 100644 --- a/src/main/session.seam.test.ts +++ b/src/main/session.seam.test.ts @@ -2,9 +2,9 @@ // as the outside world sees it — fake serial port and DCS socket in, renderer pushes, IPC replies // and files out — through the services harness. Nothing here reaches inside main. import { describe, expect, it } from 'vitest' -import { CTRL } from '@shared/ipc' +import { CTRL } from '@shared/app/ipc' import { eventText } from '@shared/events' -import type { NodeMappingFile } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' import { statusLine } from '../test-fixtures/nodes' import { makeServices, type Services } from './test-harness/services' diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 5bc1a39..59732d7 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CAL_TYPE, buildFrame } from '@shared/calibration' -import type { LogRow } from '@shared/ipc' -import { eventText, type LogEvent } from '@shared/events' -import type { NodeModel } from '@shared/node-rules' +import type { LogRow } from '@shared/dcs/ipc' +import { eventText } from '@shared/events' +import type { LogEvent } from '@shared/events/ipc' +import type { NodeModel } from '@shared/nodes/ipc' import { statusLine } from '../test-fixtures/nodes' // Session pulls in electron (debug log path), the serial port, the DCS transport and node-hid. @@ -128,7 +129,7 @@ vi.mock('./hid', () => ({ const { Session, deviceToDcs, exportToDevice, relayConfigChanged } = await import('./session') const { PortNotFoundError } = await import('./serial') const { nodeRosterRequest } = await import('@shared/nodes') -const { DEFAULT_CONFIG } = await import('@shared/ipc') +const { DEFAULT_CONFIG } = await import('@shared/config/ipc') const ascii = (s: string) => Buffer.from(s, 'ascii') diff --git a/src/main/session.ts b/src/main/session.ts index f6515ae..fcba9ae 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -6,7 +6,14 @@ // The device link is not started or stopped here. It is up whenever the board is plugged in // (device-link.ts); a source mode only chooses where the stream comes from and whether it // reaches the device. -import { DcsBiosProtocol, EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcsbios' +import { DcsBiosProtocol } from '@shared/dcsbios' +import { + EXPORT_MULTICAST_ADDR, + EXPORT_PORT, + type LogRow, + type RelayResult, + type RelayState +} from '@shared/dcs/ipc' import { clientErrorEvent, deviceEvent, @@ -14,19 +21,9 @@ import { relayEvents, type RelayContext } from '@shared/events' -import { - DEFAULT_CONFIG, - isLinkUp, - type AppConfig, - type DeviceState, - type DeviceStatus, - type LogRow, - type PushChannel, - type PushChannels, - type RelayResult, - type RelayStatus, - type RelayState -} from '@shared/ipc' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' +import { isLinkUp, type DeviceState, type DeviceStatus } from '@shared/gateway/ipc' +import type { PushChannel, PushChannels, RelayStatus } from '@shared/app/ipc' import { createTransport, type Transport } from './net' import { debugLog } from './debug' import { DeviceLink } from './device-link' diff --git a/src/main/settings.ts b/src/main/settings.ts index cffcb0e..ae53b90 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -2,7 +2,8 @@ import { app } from 'electron' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { DEFAULT_CONFIG, sanitizeConfig, type AppConfig } from '@shared/ipc' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' +import { sanitizeConfig } from '@shared/settings-schema' function configPath(): string { return join(app.getPath('userData'), 'config.json') diff --git a/src/main/stats.ts b/src/main/stats.ts index 1f5aa42..323c559 100644 --- a/src/main/stats.ts +++ b/src/main/stats.ts @@ -1,7 +1,7 @@ // Rolling counters for the Stream Health / Command Activity panels. // snapshot() reports per-second rates as deltas since the previous snapshot, // so it is expected to be called on a steady ~1 Hz tick. -import type { StatsSnapshot } from '@shared/ipc' +import type { StatsSnapshot } from '@shared/dcs/ipc' export class Stats { private bytesIn = 0 diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 899178d..dd5d1ff 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -16,8 +16,9 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSy import { tmpdir } from 'node:os' import { join } from 'node:path' import { onTestFinished, vi } from 'vitest' -import { CTRL, type AppConfig, type PushChannel, type PushChannels } from '@shared/ipc' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/node-mapping' +import { CTRL, type PushChannel, type PushChannels } from '@shared/app/ipc' +import type { AppConfig } from '@shared/config/ipc' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes/ipc' // ── the outside world, faked ───────────────────────────────────────────────── diff --git a/src/preload/index.ts b/src/preload/index.ts index 2ba7d40..25b1d0b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,32 +1,35 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { LogEvent } from '@shared/events' +import type { LogEvent } from '@shared/events/ipc' import { CTRL, - type AppConfig, - type CalCacheEntry, - type CalCommitAxis, - type CalHello, - type CalResult, - type CalSnapshot, - type CaptureState, - type ConfigExportResult, - type ConfigImportApplyResult, - type ConfigImportOpen, type DebugDumpResult, - type ExportResult, - type HidAvailability, - type NodeMappingResult, - type NodeRegistryInfo, - type NodeSnapshot, type PushChannel, type PushChannels, - type RelayResult, type RelayStatus, - type ReplayLoad, type SkyhawkApi -} from '@shared/ipc' -import type { CachedBoard } from '@shared/cal-cache' -import type { ResetPreview } from '@shared/node-mapping' +} from '@shared/app/ipc' +import type { + AppConfig, + ConfigExportResult, + ConfigImportApplyResult, + ConfigImportOpen +} from '@shared/config/ipc' +import type { + CachedBoard, + CalCacheEntry, + CalCommitAxis, + CalHello, + CalResult, + CalSnapshot +} from '@shared/hid/calibration/ipc' +import type { CaptureState, ExportResult, RelayResult, ReplayLoad } from '@shared/dcs/ipc' +import type { HidAvailability } from '@shared/hid/ipc' +import type { + NodeMappingResult, + NodeRegistryInfo, + NodeSnapshot, + ResetPreview +} from '@shared/nodes/ipc' // Typed bridge: main -> renderer push subscriptions + renderer -> main control. const api: SkyhawkApi = { diff --git a/src/renderer/src/CalibrationDialog.tsx b/src/renderer/src/CalibrationDialog.tsx index fe4b331..2bfa2e9 100644 --- a/src/renderer/src/CalibrationDialog.tsx +++ b/src/renderer/src/CalibrationDialog.tsx @@ -7,7 +7,7 @@ import { sweepsRemaining } from '@shared/axis-capture' import { CAL_PROTO, HID_REPORT_LAYOUT } from '@shared/firmware' -import type { CalSnapshot } from '@shared/ipc' +import type { CalSnapshot } from '@shared/hid/calibration/ipc' import { CalibrationController, type CalibrationState } from './calibration-controller' import { AXIS_LABELS, useStore } from './store' diff --git a/src/renderer/src/DevicePopover.tsx b/src/renderer/src/DevicePopover.tsx index 0d077ad..3320302 100644 --- a/src/renderer/src/DevicePopover.tsx +++ b/src/renderer/src/DevicePopover.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { isLinkUp } from '@shared/ipc' +import { isLinkUp } from '@shared/gateway/ipc' import { useStore } from './store' import { deviceView } from './device-view' import { gatewayView } from './gateway-view' diff --git a/src/renderer/src/calibration-controller.test.ts b/src/renderer/src/calibration-controller.test.ts index eb1dbf9a..a39af10 100644 --- a/src/renderer/src/calibration-controller.test.ts +++ b/src/renderer/src/calibration-controller.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_CAPTURE_CONFIG, midpoint } from '@shared/axis-capture' -import type { CalRawSample, CalResult, CalSnapshot } from '@shared/ipc' +import type { + CachedBoard, + CalCacheEntry, + CalRawSample, + CalResult, + CalSnapshot +} from '@shared/hid/calibration/ipc' import { CalibrationController, type CalibrationApi } from './calibration-controller' -import type { CachedBoard } from '@shared/cal-cache' -import type { CalCacheEntry } from '@shared/ipc' const C = DEFAULT_CAPTURE_CONFIG diff --git a/src/renderer/src/calibration-controller.ts b/src/renderer/src/calibration-controller.ts index 3c9624f..14de027 100644 --- a/src/renderer/src/calibration-controller.ts +++ b/src/renderer/src/calibration-controller.ts @@ -9,19 +9,19 @@ import { push as pushSample, restartSweep, retrySweep, - tick, - type CaptureResult, - type CaptureState + tick } from '@shared/axis-capture' import type { + CachedBoard, CalCacheEntry, CalCommitAxis, CalFailure, CalRawSample, CalResult, - CalSnapshot -} from '@shared/ipc' -import type { CachedBoard } from '@shared/cal-cache' + CalSnapshot, + CaptureResult, + CaptureState +} from '@shared/hid/calibration/ipc' /** Only the calls this controller makes — narrower than SkyhawkApi, and trivial to fake. */ export interface CalibrationApi { diff --git a/src/renderer/src/cockpit-adopt.test.ts b/src/renderer/src/cockpit-adopt.test.ts index 1d57d88..28f7589 100644 --- a/src/renderer/src/cockpit-adopt.test.ts +++ b/src/renderer/src/cockpit-adopt.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' +import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes/ipc' import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' -import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' +import { TELEM_PAGE } from '@shared/node-telem' import { nodeViews } from './cockpit' import { adoptEvidence, diff --git a/src/renderer/src/cockpit-adopt.ts b/src/renderer/src/cockpit-adopt.ts index 17418cd..35a8d74 100644 --- a/src/renderer/src/cockpit-adopt.ts +++ b/src/renderer/src/cockpit-adopt.ts @@ -1,9 +1,8 @@ // Cockpit Adopt dialog (#77; since #93 the onboarding for every board not in your cockpit): the // pure decisions behind it — queue order, what each new NODE_ID is seen doing, the registry // pre-fill, when the form is complete, the identify row and where the selection goes next. -import type { AdoptSpec, NodeKind } from '@shared/node-mapping' +import type { AdoptSpec, NodeKind, NodeModelEntry } from '@shared/nodes/ipc' import { reportedIdentity } from '@shared/node-kind' -import type { NodeModelEntry } from '@shared/node-rules' import { displayCurrentMa } from '@shared/node-telem' import { nodeFaultTag } from '@shared/nodes' import type { NodeView } from './cockpit' diff --git a/src/renderer/src/cockpit-panels.test.ts b/src/renderer/src/cockpit-panels.test.ts index 508221d..723e05f 100644 --- a/src/renderer/src/cockpit-panels.test.ts +++ b/src/renderer/src/cockpit-panels.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' +import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes/ipc' import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' -import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' +import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from '@shared/node-telem' import { lastSeenLabel, nodeViews } from './cockpit' import { boardActions, diff --git a/src/renderer/src/cockpit-panels.ts b/src/renderer/src/cockpit-panels.ts index ee631e9..324492b 100644 --- a/src/renderer/src/cockpit-panels.ts +++ b/src/renderer/src/cockpit-panels.ts @@ -1,9 +1,8 @@ // Cockpit side panels (#76, one design for every board since #93): the pure decisions behind the // panel group and PDU panels — which actions a board offers, what its fault box says, the rows each panel shows and the mapping patch Save // sends. Every value is something a board reported or the client computed, labelled as such. -import type { MappingPatch } from '@shared/node-mapping' -import type { NodeModelEntry } from '@shared/node-rules' -import { PDU_FLAG, RAIL_FLAG, displayCurrentMa, type PduRailPage } from '@shared/node-telem' +import type { MappingPatch, NodeModelEntry, PduRailPage } from '@shared/nodes/ipc' +import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/node-telem' import { nodeFaultTag } from '@shared/nodes' import { lastSeenLabel, pduAlertFlags, type NodeView } from './cockpit' import { fmtUptime } from './store' diff --git a/src/renderer/src/cockpit.test.ts b/src/renderer/src/cockpit.test.ts index 85c038b..b1954e0 100644 --- a/src/renderer/src/cockpit.test.ts +++ b/src/renderer/src/cockpit.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, PduOutage } from '@shared/node-rules' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes/ipc' import { modelNode as node, nodeModel as model, nodeStatus as status } from '../../test-fixtures/nodes' -import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' +import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from '@shared/node-telem' import { STATE_SYMBOL, affected, diff --git a/src/renderer/src/cockpit.ts b/src/renderer/src/cockpit.ts index aa55499..c83e36e 100644 --- a/src/renderer/src/cockpit.ts +++ b/src/renderer/src/cockpit.ts @@ -1,22 +1,19 @@ // Cockpit tab (#63): the pure decisions behind the health band, the plan and Attention. Everything // here is computed from what main pushes (`nodes:model`, `nodes:telemetry`); the client applies no // thresholds — a warning is always a flag a board raised itself. -import { - isReportableNew, - type NodeModel, - type NodeModelEntry, - type PduOutage -} from '@shared/node-rules' -import { - PDU_FLAG, - RAIL_FLAG, - displayCurrentMa, - type NodeTelemSnapshot, - type PduRailPage, - type PduStatusPage -} from '@shared/node-telem' +import { isReportableNew } from '@shared/node-rules' +import type { + NodeModel, + NodeModelEntry, + NodeTelemSnapshot, + PduOutage, + PduRailPage, + PduStatusPage, + PsuSetting +} from '@shared/nodes/ipc' +import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/node-telem' import { nodeFaultTag } from '@shared/nodes' -import { PSU_PRESETS, type PsuSetting } from '@shared/psu' +import { PSU_PRESETS } from '@shared/psu' /** How a node reads on the plan. 'unknown' = no complete roster yet and it has not reported. */ export type NodeState = 'on' | 'warn' | 'off' | 'new' | 'unknown' diff --git a/src/renderer/src/connection-summary.test.ts b/src/renderer/src/connection-summary.test.ts index a312b3e..991a67c 100644 --- a/src/renderer/src/connection-summary.test.ts +++ b/src/renderer/src/connection-summary.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, PduOutage } from '@shared/node-rules' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes/ipc' import { modelNode as node, nodeModel as model, nodeStatus as status } from '../../test-fixtures/nodes' -import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' +import { TELEM_PAGE } from '@shared/node-telem' import { STATE_SYMBOL, attentionItems, lastSeenLabel, nodeViews } from './cockpit' import { nodesSummary } from './connection-summary' import { fmtTime } from './store' diff --git a/src/renderer/src/connection-summary.ts b/src/renderer/src/connection-summary.ts index c8aea17..d776458 100644 --- a/src/renderer/src/connection-summary.ts +++ b/src/renderer/src/connection-summary.ts @@ -1,7 +1,7 @@ // Connection › Connected nodes (#66): the pure decisions behind the summary card. Counts and the // most severe nodes come from the same helpers as the Cockpit health band and Attention, so the // two tabs always agree; the full picture lives in the Cockpit tab. -import type { PduOutage } from '@shared/node-rules' +import type { PduOutage } from '@shared/nodes/ipc' import { affected, ATTENTION_SHOWN, diff --git a/src/renderer/src/device-view.test.ts b/src/renderer/src/device-view.test.ts index 0741d9e..f9d2afc 100644 --- a/src/renderer/src/device-view.test.ts +++ b/src/renderer/src/device-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DeviceState } from '@shared/ipc' +import type { DeviceState } from '@shared/gateway/ipc' import { deviceView, relayNote, diff --git a/src/renderer/src/device-view.ts b/src/renderer/src/device-view.ts index d08de39..43539b8 100644 --- a/src/renderer/src/device-view.ts +++ b/src/renderer/src/device-view.ts @@ -1,4 +1,5 @@ -import type { DeviceState, StreamState } from '@shared/ipc' +import type { DeviceState } from '@shared/gateway/ipc' +import type { StreamState } from '@shared/dcs/ipc' import type { SourceMode } from './store' /** diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 8b42897..e728e3d 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -1,5 +1,5 @@ /// -import type { SkyhawkApi } from '@shared/ipc' +import type { SkyhawkApi } from '@shared/app/ipc' declare global { interface Window { diff --git a/src/renderer/src/freshness.ts b/src/renderer/src/freshness.ts index e910726..9e31194 100644 --- a/src/renderer/src/freshness.ts +++ b/src/renderer/src/freshness.ts @@ -1,4 +1,4 @@ -import type { DeviceState } from '@shared/ipc' +import type { DeviceState } from '@shared/gateway/ipc' /** * How recently the device last said anything, as a colour. diff --git a/src/renderer/src/gateway-view.test.ts b/src/renderer/src/gateway-view.test.ts index abda816..3549843 100644 --- a/src/renderer/src/gateway-view.test.ts +++ b/src/renderer/src/gateway-view.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { gatewayStatus } from '@shared/gateway' -import type { NodeStatus } from '@shared/nodes' +import type { NodeStatus } from '@shared/nodes/ipc' import { gatewayView } from './gateway-view' import { nodeStatus } from '../../test-fixtures/nodes' diff --git a/src/renderer/src/gateway-view.ts b/src/renderer/src/gateway-view.ts index 77998e7..84749b3 100644 --- a/src/renderer/src/gateway-view.ts +++ b/src/renderer/src/gateway-view.ts @@ -1,7 +1,7 @@ // Where the Gateway's status lands in the UI (#94): the title-bar pill, the Device popover, the // Overview alert and the Cockpit's Attention item. Presentation only: severity, label, note and // alert all come from shared/gateway.ts (gatewayStatus), computed once in main. -import type { GatewayStatus } from '@shared/gateway' +import type { GatewayStatus } from '@shared/gateway/ipc' export interface GatewayView { /** Pill modifier while it reports a problem (replaces `devind--on`); with the "!" symbol. */ diff --git a/src/renderer/src/log-view.test.ts b/src/renderer/src/log-view.test.ts index eb77945..b084a17 100644 --- a/src/renderer/src/log-view.test.ts +++ b/src/renderer/src/log-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { LogEvent } from '@shared/events' +import type { LogEvent } from '@shared/events/ipc' import { boardEventLine, eventSourceLabel, diff --git a/src/renderer/src/log-view.ts b/src/renderer/src/log-view.ts index 99c82b9..3e0dd08 100644 --- a/src/renderer/src/log-view.ts +++ b/src/renderer/src/log-view.ts @@ -1,5 +1,6 @@ // Log tab › Events (#64): what the view shows of the session's events (pure, no React). -import { eventText, isProblem, MAX_EVENTS, type EventSource, type LogEvent } from '@shared/events' +import { eventText, isProblem } from '@shared/events' +import { MAX_EVENTS, type EventSource, type LogEvent } from '@shared/events/ipc' export type LogView = 'stream' | 'events' export type EventSourceFilter = 'all' | EventSource diff --git a/src/renderer/src/overview.test.ts b/src/renderer/src/overview.test.ts index 4d8d3f7..075add8 100644 --- a/src/renderer/src/overview.test.ts +++ b/src/renderer/src/overview.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, PduOutage } from '@shared/node-rules' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes/ipc' import { modelNode as node, nodeModel as model, nodeStatus as status } from '../../test-fixtures/nodes' -import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' +import { TELEM_PAGE } from '@shared/node-telem' import { attentionItems, healthBand, nodeViews } from './cockpit' import { attentionSummary, cockpitTile, newDevices, newDevicesText, reportedKind } from './overview' diff --git a/src/renderer/src/overview.ts b/src/renderer/src/overview.ts index 252ec7a..a4d8254 100644 --- a/src/renderer/src/overview.ts +++ b/src/renderer/src/overview.ts @@ -2,7 +2,8 @@ // new-devices notice. All of it is read off the Cockpit's own picture (`healthBand`, // `attentionItems`); the client adds no thresholds of its own. import { isAdoptableKind } from '@shared/node-kind' -import { isReportableNew, type NodeModelEntry } from '@shared/node-rules' +import { isReportableNew } from '@shared/node-rules' +import type { NodeModelEntry } from '@shared/nodes/ipc' import type { AttentionItem, HealthBand, NodeView } from './cockpit' // ── Cockpit tile ──────────────────────────────────────────────────────────── diff --git a/src/renderer/src/phase0-probes.test.ts b/src/renderer/src/phase0-probes.test.ts index 4ef5682..8a39869 100644 --- a/src/renderer/src/phase0-probes.test.ts +++ b/src/renderer/src/phase0-probes.test.ts @@ -4,7 +4,7 @@ // these behaviours on purpose updates its probe. The main/shared half is // src/main/phase0-probes.test.ts. import { describe, expect, it } from 'vitest' -import type { NodeMappingFile } from '@shared/node-mapping' +import type { NodeMappingFile } from '@shared/nodes/ipc' import { NODE_MSG, NodeRoster } from '@shared/nodes' import { registryDefaults, setHidden, EMPTY_MAPPING } from '@shared/node-mapping' import { buildNodeModel } from '@shared/node-rules' diff --git a/src/renderer/src/store.test.ts b/src/renderer/src/store.test.ts index 38da21e..d2b9dac 100644 --- a/src/renderer/src/store.test.ts +++ b/src/renderer/src/store.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { CalSnapshot, NodeSnapshot, PushChannel, PushChannels, RelayStatus } from '@shared/ipc' -import type { NodeModel } from '@shared/node-rules' -import type { NodeTelemSnapshot } from '@shared/node-telem' -import type { LogEvent } from '@shared/events' +import type { CalSnapshot } from '@shared/hid/calibration/ipc' +import type { NodeModel, NodeSnapshot, NodeTelemSnapshot } from '@shared/nodes/ipc' +import type { PushChannel, PushChannels, RelayStatus } from '@shared/app/ipc' +import type { LogEvent } from '@shared/events/ipc' // The store talks to main through window.skyhawk. Only the calls initBridge() makes need to be // real here; the rest exist so subscription setup does not throw. diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index fb7be80..4601395 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -1,23 +1,27 @@ import { create } from 'zustand' -import { isLinkUp } from '@shared/ipc' +import { + isLinkUp, + type DeviceState, + type GatewayStatus, + type SerialFrame +} from '@shared/gateway/ipc' import { HID_CONTROLS_BY_ID, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' import type { AircraftStatus, - AppConfig, - CalSnapshot, - DcsTransport, - DeviceState, - StreamState, LogRow as IpcLogRow, + StreamState, TelemetryReadout -} from '@shared/ipc' -import type { NodeModel } from '@shared/node-rules' -import type { NodeTelemSnapshot } from '@shared/node-telem' -import { DEFAULT_PSU, type PsuSetting } from '@shared/psu' +} from '@shared/dcs/ipc' +import type { AppConfig, DcsTransport } from '@shared/config/ipc' +import type { CalSnapshot } from '@shared/hid/calibration/ipc' +import { + DEFAULT_PSU, + type NodeModel, + type NodeTelemSnapshot, + type PsuSetting +} from '@shared/nodes/ipc' import { nodeViews, totalWatts } from './cockpit' -import type { SerialFrame } from '@shared/ipc' -import type { LogEvent } from '@shared/events' -import type { GatewayStatus } from '@shared/gateway' +import type { LogEvent } from '@shared/events/ipc' import { mergeEvents, restoreEvents, type EventSourceFilter, type LogView } from './log-view' export type TabId = 'overview' | 'connection' | 'cockpit' | 'log' | 'serial' | 'hid' | 'settings' diff --git a/src/renderer/src/tabs/Cockpit.tsx b/src/renderer/src/tabs/Cockpit.tsx index a8aa301..e1990f1 100644 --- a/src/renderer/src/tabs/Cockpit.tsx +++ b/src/renderer/src/tabs/Cockpit.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { isLinkUp } from '@shared/ipc' -import type { NodeModel } from '@shared/node-rules' -import { effectivePsuLimits, type PsuLimits } from '@shared/psu' +import { isLinkUp } from '@shared/gateway/ipc' +import type { NodeModel, PsuLimits } from '@shared/nodes/ipc' +import { effectivePsuLimits } from '@shared/psu' import { useStore } from '../store' import { strayFlag, strayNew } from '../cockpit-adopt' import { gatewayView, type GatewayView } from '../gateway-view' diff --git a/src/renderer/src/tabs/Connection.tsx b/src/renderer/src/tabs/Connection.tsx index 7eff9a1..ebd507e 100644 --- a/src/renderer/src/tabs/Connection.tsx +++ b/src/renderer/src/tabs/Connection.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useStore, type SourceMode, type Transport } from '../store' import { USB_IDENTITY } from '@shared/firmware' -import { isLinkUp } from '@shared/ipc' +import { isLinkUp } from '@shared/gateway/ipc' import { nodeViews } from '../cockpit' import { nodesSummary } from '../connection-summary' import { deviceView, relayNote, startBlocked, startBlockedReason } from '../device-view' diff --git a/src/renderer/src/tabs/Hid.tsx b/src/renderer/src/tabs/Hid.tsx index 05b71c4..f71a28a 100644 --- a/src/renderer/src/tabs/Hid.tsx +++ b/src/renderer/src/tabs/Hid.tsx @@ -1,5 +1,5 @@ import { HID_REPORT_LAYOUT } from '@shared/firmware' -import { isLinkUp } from '@shared/ipc' +import { isLinkUp } from '@shared/gateway/ipc' import { useStore, AXIS_LABELS, HAT_DIRS } from '../store' import { freshness } from '../freshness' import { CalibrationDialog, useCalibration } from '../CalibrationDialog' diff --git a/src/renderer/src/tabs/Log.tsx b/src/renderer/src/tabs/Log.tsx index 584e94c..b3c8f45 100644 --- a/src/renderer/src/tabs/Log.tsx +++ b/src/renderer/src/tabs/Log.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { eventText, SEVERITY_GLYPH, type EventSeverity } from '@shared/events' +import { eventText, SEVERITY_GLYPH } from '@shared/events' +import type { EventSeverity } from '@shared/events/ipc' import { fmtTime, useStore, type DirFilter } from '../store' import { eventSourceLabel, diff --git a/src/renderer/src/tabs/Overview.tsx b/src/renderer/src/tabs/Overview.tsx index 03d3bb0..4a9f602 100644 --- a/src/renderer/src/tabs/Overview.tsx +++ b/src/renderer/src/tabs/Overview.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useStore, fmtBytes, fmtUptime, type TabId } from '../store' -import { isLinkUp, type DeviceState } from '@shared/ipc' +import { isLinkUp, type DeviceState } from '@shared/gateway/ipc' import { attentionItems, healthBand, nodeViews } from '../cockpit' import { attentionSummary, cockpitTile, newDevices, newDevicesText } from '../overview' import { gatewayView } from '../gateway-view' diff --git a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx b/src/renderer/src/tabs/cockpit/AdoptDialog.tsx index 5de7c5b..4573133 100644 --- a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx +++ b/src/renderer/src/tabs/cockpit/AdoptDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { NodeMappingResult } from '@shared/ipc' +import type { NodeMappingResult } from '@shared/nodes/ipc' import type { NodeView } from '../../cockpit' import { adoptability, diff --git a/src/renderer/src/tabs/cockpit/SidePanels.tsx b/src/renderer/src/tabs/cockpit/SidePanels.tsx index 7952057..ea4592c 100644 --- a/src/renderer/src/tabs/cockpit/SidePanels.tsx +++ b/src/renderer/src/tabs/cockpit/SidePanels.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' -import type { NodeMappingResult, NodeRegistryInfo } from '@shared/ipc' -import type { NodeModelEntry } from '@shared/node-rules' +import type { NodeMappingResult, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes/ipc' import type { NodeView } from '../../cockpit' import { boardActions, diff --git a/src/renderer/src/tabs/settings/AboutSection.tsx b/src/renderer/src/tabs/settings/AboutSection.tsx index f86efb2..db96bfa 100644 --- a/src/renderer/src/tabs/settings/AboutSection.tsx +++ b/src/renderer/src/tabs/settings/AboutSection.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { isLinkUp, type NodeRegistryInfo } from '@shared/ipc' +import { isLinkUp } from '@shared/gateway/ipc' +import type { NodeRegistryInfo } from '@shared/nodes/ipc' import { useStore } from '../../store' import { Dialog } from './Dialog' import { Row, SectionHead } from './Row' diff --git a/src/renderer/src/tabs/settings/CockpitSection.tsx b/src/renderer/src/tabs/settings/CockpitSection.tsx index 0ab8b40..223fceb 100644 --- a/src/renderer/src/tabs/settings/CockpitSection.tsx +++ b/src/renderer/src/tabs/settings/CockpitSection.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { parsePsuLimit, PSU_PRESETS, type PsuLimits } from '@shared/psu' +import { parsePsuLimit, PSU_PRESETS } from '@shared/psu' +import type { PsuLimits } from '@shared/nodes/ipc' import { useStore } from '../../store' import { ResetDialog } from './ResetDialog' import { Row, SectionHead } from './Row' diff --git a/src/renderer/src/tabs/settings/ConfigurationSection.tsx b/src/renderer/src/tabs/settings/ConfigurationSection.tsx index b2b7dea..c349642 100644 --- a/src/renderer/src/tabs/settings/ConfigurationSection.tsx +++ b/src/renderer/src/tabs/settings/ConfigurationSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { ImportPreview } from '@shared/ipc' +import type { ImportPreview } from '@shared/config/ipc' import { configState, useStore } from '../../store' import { ImportDialog } from './ImportDialog' import { Row, SectionHead } from './Row' diff --git a/src/renderer/src/tabs/settings/ImportDialog.tsx b/src/renderer/src/tabs/settings/ImportDialog.tsx index 82f868f..ddd6e0b 100644 --- a/src/renderer/src/tabs/settings/ImportDialog.tsx +++ b/src/renderer/src/tabs/settings/ImportDialog.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' -import type { ConfigImportApplyResult, ImportPreview } from '@shared/ipc' -import type { SectionId } from '@shared/config-file' +import type { ConfigImportApplyResult, ImportPreview, SectionId } from '@shared/config/ipc' import { AXIS_LABELS } from '../../store' import { Dialog } from './Dialog' import { importRowKey, importSubtitle } from './settings-view' diff --git a/src/renderer/src/tabs/settings/ResetDialog.tsx b/src/renderer/src/tabs/settings/ResetDialog.tsx index 014045a..8e099ee 100644 --- a/src/renderer/src/tabs/settings/ResetDialog.tsx +++ b/src/renderer/src/tabs/settings/ResetDialog.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react' -import type { ResetPreview } from '@shared/node-mapping' -import type { NodeModel } from '@shared/node-rules' +import type { NodeModel, ResetPreview } from '@shared/nodes/ipc' import { Dialog } from './Dialog' import { resetButtonLabel } from './settings-view' diff --git a/src/renderer/src/tabs/settings/settings-view.test.ts b/src/renderer/src/tabs/settings/settings-view.test.ts index a7cce18..def777d 100644 --- a/src/renderer/src/tabs/settings/settings-view.test.ts +++ b/src/renderer/src/tabs/settings/settings-view.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { NodeRegistryInfo } from '@shared/ipc' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' +import type { NodeModel, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes/ipc' import { hiddenRow, importDoneText, diff --git a/src/renderer/src/tabs/settings/settings-view.ts b/src/renderer/src/tabs/settings/settings-view.ts index 2ebfb4c..a3dcefd 100644 --- a/src/renderer/src/tabs/settings/settings-view.ts +++ b/src/renderer/src/tabs/settings/settings-view.ts @@ -1,7 +1,6 @@ // Settings text decisions (#67), pure so they are unit-tested: what each Cockpit / About row says. import { GATEWAY_NODE_ID } from '@shared/firmware' -import type { NodeRegistryInfo } from '@shared/ipc' -import type { HiddenNode, NodeModel } from '@shared/node-rules' +import type { HiddenNode, NodeModel, NodeRegistryInfo } from '@shared/nodes/ipc' import { consoleLabel } from '../../cockpit-panels' const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` diff --git a/src/renderer/src/tabs/settings/useNodeModel.ts b/src/renderer/src/tabs/settings/useNodeModel.ts index c9ae050..3305540 100644 --- a/src/renderer/src/tabs/settings/useNodeModel.ts +++ b/src/renderer/src/tabs/settings/useNodeModel.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import type { NodeModel } from '@shared/node-rules' +import type { NodeModel } from '@shared/nodes/ipc' /** * The latest `nodes:model` while the caller is mounted: fetched once, then followed. Settings diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts index ca7495e..3ff6b02 100644 --- a/src/renderer/test/scenarios.ts +++ b/src/renderer/test/scenarios.ts @@ -2,23 +2,18 @@ // One place for them, on top of the shared node fixtures, so every component test describes the // world the same way. Each builder states its defaults; a test passes whatever differs. import { emptyCache } from '@shared/cal-cache' -import { - appSettings, - buildConfigFile, - diffImport, - parseConfigFile, - type ConfigSnapshot -} from '@shared/config-file' -import { gatewayStatus, type GatewayStatus } from '@shared/gateway' -import { - DEFAULT_CONFIG, - type DeviceState, - type DeviceStatus, - type ImportPreview -} from '@shared/ipc' -import type { NodeDefaults } from '@shared/node-mapping' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import { NODE_KIND, type NodeStatus } from '@shared/nodes' +import { appSettings, buildConfigFile, diffImport, parseConfigFile } from '@shared/config-file' +import { DEFAULT_CONFIG, type ConfigSnapshot, type ImportPreview } from '@shared/config/ipc' +import { gatewayStatus } from '@shared/gateway' +import type { DeviceState, DeviceStatus, GatewayStatus } from '@shared/gateway/ipc' +import type { + NodeDefaults, + NodeModel, + NodeModelEntry, + NodeStatus, + PduOutage +} from '@shared/nodes/ipc' +import { NODE_KIND } from '@shared/nodes' import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' export { modelNode, nodeModel, nodeStatus } diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index ac07500..24bde77 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -24,22 +24,14 @@ // `SkyhawkApi`, so an IPC change breaks the mock at typecheck, not at run time. import { act } from '@testing-library/react' import { vi, type Mock } from 'vitest' -import { - DEFAULT_CONFIG, - type AircraftStatus, - type AppConfig, - type CalSnapshot, - type DeviceStatus, - type HidSnapshot, - type NodeRegistryInfo, - type PushChannel, - type PushChannels, - type RelayState, - type SkyhawkApi -} from '@shared/ipc' -import type { LogEvent } from '@shared/events' -import type { NodeModel } from '@shared/node-rules' -import type { NodeTelemSnapshot } from '@shared/node-telem' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' +import type { AircraftStatus, RelayState } from '@shared/dcs/ipc' +import type { CalSnapshot } from '@shared/hid/calibration/ipc' +import type { DeviceStatus } from '@shared/gateway/ipc' +import type { HidSnapshot } from '@shared/hid/ipc' +import type { NodeModel, NodeRegistryInfo, NodeTelemSnapshot } from '@shared/nodes/ipc' +import type { PushChannel, PushChannels, SkyhawkApi } from '@shared/app/ipc' +import type { LogEvent } from '@shared/events/ipc' /** What main knows right now. Every field is optional: absent = main's state at launch. */ export interface Scenario { diff --git a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx index 2d61172..1c42260 100644 --- a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx +++ b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx @@ -1,7 +1,7 @@ // T11 — the Adopt dialog: "Add all", the Powered by choices, and a board power-cycled to identify it. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' +import type { NodeModel, NodeModelEntry } from '@shared/nodes/ipc' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario, settle } from './render' import { connected, model, newBoard, pdu } from './scenarios' diff --git a/src/renderer/test/t12-settings-hidden-boards.test.tsx b/src/renderer/test/t12-settings-hidden-boards.test.tsx index 420e1b9..65a1566 100644 --- a/src/renderer/test/t12-settings-hidden-boards.test.tsx +++ b/src/renderer/test/t12-settings-hidden-boards.test.tsx @@ -1,7 +1,7 @@ // T12 — Settings › Cockpit › Hidden boards. import { describe, expect, it } from 'vitest' import { screen } from '@testing-library/react' -import type { NodeModel } from '@shared/node-rules' +import type { NodeModel } from '@shared/nodes/ipc' import { Settings } from '../src/tabs/Settings' import { renderScenario, settle } from './render' import { connected, model, panel } from './scenarios' diff --git a/src/renderer/test/t13-settings-import-preview.test.tsx b/src/renderer/test/t13-settings-import-preview.test.tsx index fee80aa..1796a91 100644 --- a/src/renderer/test/t13-settings-import-preview.test.tsx +++ b/src/renderer/test/t13-settings-import-preview.test.tsx @@ -1,7 +1,7 @@ // T13 — Settings › Configuration › Import: the review of what a file would change, and Apply. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import { DEFAULT_CONFIG, type ImportPreview } from '@shared/ipc' +import { DEFAULT_CONFIG, type ImportPreview } from '@shared/config/ipc' import { appSettings } from '@shared/config-file' import { Settings } from '../src/tabs/Settings' import { renderScenario, settle } from './render' diff --git a/src/renderer/test/t16-board-panel-settings.test.tsx b/src/renderer/test/t16-board-panel-settings.test.tsx index 21bb279..80f064a 100644 --- a/src/renderer/test/t16-board-panel-settings.test.tsx +++ b/src/renderer/test/t16-board-panel-settings.test.tsx @@ -1,7 +1,7 @@ // T16 — the board panel's Settings tab: display name, console and power zone, and what Save sends. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeRegistryInfo } from '@shared/ipc' +import type { NodeRegistryInfo } from '@shared/nodes/ipc' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario, settle } from './render' import { connected, model, newBoard, panel, pdu } from './scenarios' diff --git a/src/renderer/test/t6-device-popover.test.tsx b/src/renderer/test/t6-device-popover.test.tsx index 973950c..72d854e 100644 --- a/src/renderer/test/t6-device-popover.test.tsx +++ b/src/renderer/test/t6-device-popover.test.tsx @@ -1,7 +1,7 @@ // T6 — the title-bar Device indicator and its popover: link states and the Gateway's Health. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { DeviceState } from '@shared/ipc' +import type { DeviceState } from '@shared/gateway/ipc' import { DevicePopover } from '../src/DevicePopover' import { App } from '../src/App' import { renderScenario } from './render' diff --git a/src/renderer/test/t7-board-footer-panel.test.tsx b/src/renderer/test/t7-board-footer-panel.test.tsx index fa4d64e..55b9bdd 100644 --- a/src/renderer/test/t7-board-footer-panel.test.tsx +++ b/src/renderer/test/t7-board-footer-panel.test.tsx @@ -1,7 +1,7 @@ // T7 — the board panel footer, for a panel group: Save · Reset to default | Forget · Hide. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeModelEntry } from '@shared/node-rules' +import type { NodeModelEntry } from '@shared/nodes/ipc' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario } from './render' import { connected, model, noStatusYet, offline, panel } from './scenarios' diff --git a/src/renderer/test/t8-board-footer-pdu.test.tsx b/src/renderer/test/t8-board-footer-pdu.test.tsx index b23d5bf..5f2bf2a 100644 --- a/src/renderer/test/t8-board-footer-pdu.test.tsx +++ b/src/renderer/test/t8-board-footer-pdu.test.tsx @@ -1,7 +1,7 @@ // T8 — the board panel footer, for a PDU: what Forget and Hide do to the boards it feeds. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeModel } from '@shared/node-rules' +import type { NodeModel } from '@shared/nodes/ipc' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario } from './render' import { connected, model, offline, panel, pdu } from './scenarios' diff --git a/src/shared/app/index.ts b/src/shared/app/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/app/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts new file mode 100644 index 0000000..1d280cb --- /dev/null +++ b/src/shared/app/ipc.compat.test-d.ts @@ -0,0 +1,199 @@ +// Compile-time guard on the renderer ↔ main API surface (arch-phase0-v2 §2.3, R1.3). +// +// A frozen copy of `SkyhawkApi`, `PushChannels`, `IPC` and `CTRL` as they stood before the IPC +// contract was split per topic. `npm run typecheck` fails if the live types drift from it: a +// method added, removed, renamed or re-typed, a push channel re-typed, a channel string changed. +// A later PR that changes the API on purpose updates the copy in the same diff. +// +// Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. +import type { Mutate, StoreApi } from 'zustand/vanilla' +import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator +import type { + AppConfig, + ConfigExportResult, + ConfigImportApplyResult, + ConfigImportOpen, + SectionId, + SettingsStore +} from '../config/ipc' +import type { + CachedBoard, + CalCacheEntry, + CalCommitAxis, + CalHello, + CalRawSample, + CalResult, + CalSnapshot +} from '../hid/calibration/ipc' +import type { + AircraftStatus, + CaptureState, + ExportResult, + LogRow, + RelayResult, + RelayState, + ReplayLoad, + StatsSnapshot, + TelemetryReadout +} from '../dcs/ipc' +import type { CTRL, DebugDumpResult, IPC, PushChannels, RelayStatus, SkyhawkApi } from './ipc' +import type { DeviceStatus, SerialFrame } from '../gateway/ipc' +import type { HidAvailability, HidSnapshot } from '../hid/ipc' +import type { + AdoptSpec, + MappingPatch, + NodeMappingResult, + NodeModel, + NodeRegistryInfo, + NodeSnapshot, + NodeTelemSnapshot, + ResetOptions, + ResetPreview +} from '../nodes/ipc' +import type { LogEvent } from '../events/ipc' + +/** Exact type equality (mutual assignability is not enough: it misses optional/readonly drift). */ +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false +type Expect = T + +interface OldPushChannels { + 'device:status': DeviceStatus + 'relay:state': RelayState + 'aircraft:changed': AircraftStatus + 'stats:tick': StatsSnapshot + 'log:batch': LogRow[] + 'events:batch': LogEvent[] + 'hid:report': HidSnapshot + 'telemetry:tick': TelemetryReadout[] + 'nodes:telemetry': NodeTelemSnapshot[] + 'nodes:model': NodeModel + 'serial:traffic': SerialFrame[] + 'cal:data': CalSnapshot + 'cal:raw': CalRawSample[] +} + +type OldPushChannel = keyof OldPushChannels + +interface OldIPC { + readonly deviceStatus: 'device:status' + readonly relayState: 'relay:state' + readonly aircraftChanged: 'aircraft:changed' + readonly statsTick: 'stats:tick' + readonly logBatch: 'log:batch' + readonly eventsBatch: 'events:batch' + readonly hidReport: 'hid:report' + readonly telemetryTick: 'telemetry:tick' + readonly nodesTelemetry: 'nodes:telemetry' + readonly nodesModel: 'nodes:model' + readonly serialTraffic: 'serial:traffic' + readonly calData: 'cal:data' + readonly calRaw: 'cal:raw' +} + +interface OldCTRL { + readonly configGet: 'config:get' + readonly configSet: 'config:set' + readonly configExport: 'config:export' + readonly configImportOpen: 'config:import-open' + readonly configImportApply: 'config:import-apply' + readonly relayStart: 'relay:start' + readonly relayStop: 'relay:stop' + readonly relayStatus: 'relay:status' + readonly deviceConnect: 'device:connect' + readonly deviceDisconnect: 'device:disconnect' + readonly logExport: 'log:export' + readonly eventsGet: 'events:get' + readonly eventsClear: 'events:clear' + readonly eventsExport: 'events:export' + readonly captureToggle: 'capture:toggle' + readonly replayOpen: 'replay:open' + readonly hidAvailability: 'hid:availability' + readonly nodesRefresh: 'nodes:refresh' + readonly nodesGet: 'nodes:get' + readonly nodeMappingSet: 'node-mapping:set' + readonly nodeMappingAdopt: 'node-mapping:adopt' + readonly nodeMappingForget: 'node-mapping:forget' + readonly nodeMappingResetNode: 'node-mapping:reset-node' + readonly nodeMappingReset: 'node-mapping:reset' + readonly nodeMappingResetPreview: 'node-mapping:reset-preview' + readonly nodeRegistry: 'nodes:registry' + readonly nodeMappingHide: 'node-mapping:hide' + readonly serialMonitor: 'serial:monitor' + readonly debugDumpPorts: 'debug:dump-ports' + readonly debugReveal: 'debug:reveal' + readonly calHello: 'cal:hello' + readonly calRead: 'cal:read' + readonly calSessionOpen: 'cal:session-open' + readonly calStreamSelect: 'cal:stream-select' + readonly calCommit: 'cal:commit' + readonly calReset: 'cal:reset' + readonly calSessionClose: 'cal:session-close' + readonly calCacheRead: 'cal:cache-read' + readonly calCacheStore: 'cal:cache-store' + readonly calCacheDrop: 'cal:cache-drop' +} + +interface OldSkyhawkApi { + on(channel: C, cb: (data: OldPushChannels[C]) => void): () => void + getConfig(): Promise + setConfig(patch: Partial): Promise + exportConfig(): Promise + openConfigImport(): Promise + applyConfigImport( + token: string, + sections: SectionId[], + backup: boolean + ): Promise + startRelay(): Promise + stopRelay(): Promise + getStatus(): Promise + connectDevice(): Promise + disconnectDevice(): Promise + exportLog(text: string): Promise + getEvents(): Promise + clearEvents(): Promise + exportEvents(): Promise + toggleCapture(): Promise + openReplay(): Promise + getHidAvailability(): Promise + refreshNodes(): Promise + getNodes(): Promise + setNodeMapping(nodeId: number, patch: MappingPatch): Promise + adoptNode(nodeId: number, spec: AdoptSpec): Promise + forgetNode(nodeId: number): Promise + resetNode(nodeId: number): Promise + resetNodeMapping(opts?: ResetOptions): Promise + previewNodeReset(): Promise + getNodeRegistry(): Promise + hideNode(nodeId: number, on: boolean): Promise + setSerialMonitor(on: boolean): Promise + dumpSerialPorts(): Promise + revealDebugLog(): Promise + calHello(): Promise> + calRead(): Promise> + calSessionOpen(axisIdx: number): Promise> + calStreamSelect(axisIdx: number): Promise> + calCommit(axis: CalCommitAxis): Promise> + calReset(idx: number): Promise> + calSessionClose(): Promise> + calCacheRead(): Promise> + calCacheStore(axes: CalCacheEntry[]): Promise> + calCacheDrop(idx: number): Promise> +} + +export type ApiSurfaceUnchanged = [ + Expect>, + Expect>, + Expect>, + Expect> +] + +/** + * `SettingsStore` is type-only, but a zustand vanilla store under `subscribeWithSelector` must be + * usable as one (R2.3 hands such a store out). + */ +type ZustandSettingsStore = Mutate, [['zustand/subscribeWithSelector', never]]> +export type SettingsStoreFitsZustand = Expect< + ZustandSettingsStore extends SettingsStore ? true : false +> diff --git a/src/shared/app/ipc.ts b/src/shared/app/ipc.ts new file mode 100644 index 0000000..535141c --- /dev/null +++ b/src/shared/app/ipc.ts @@ -0,0 +1,231 @@ +// Typed IPC contract between the Electron main process and the renderer. +// Channels are one-way main -> renderer pushes; control actions are renderer -> main. +// +// The app contract assembles the topic contracts (shared//ipc.ts) into the one surface the +// preload exposes: `SkyhawkApi`, every push channel and every intent channel. It owns only what +// spans topics: the `relay:status` rehydrate bundle and the debug intents. +import { + CONFIG_CTRL, + type AppConfig, + type ConfigExportResult, + type ConfigImportApplyResult, + type ConfigImportOpen, + type SectionId +} from '../config/ipc' +import { + DCS_CTRL, + DCS_PUSH, + type DcsPushChannels, + type ExportResult, + type CaptureState, + type RelayResult, + type RelayState, + type ReplayLoad +} from '../dcs/ipc' +import { EVENTS_CTRL, EVENTS_PUSH, type EventsPushChannels, type LogEvent } from '../events/ipc' +import { + GATEWAY_CTRL, + GATEWAY_PUSH, + type DeviceStatus, + type GatewayPushChannels +} from '../gateway/ipc' +import { HID_CTRL, HID_PUSH, type HidAvailability, type HidPushChannels } from '../hid/ipc' +import { + CALIBRATION_CTRL, + CALIBRATION_PUSH, + type CachedBoard, + type CalCacheEntry, + type CalCommitAxis, + type CalHello, + type CalibrationPushChannels, + type CalResult, + type CalSnapshot +} from '../hid/calibration/ipc' +import { + NODES_CTRL, + NODES_PUSH, + type AdoptSpec, + type MappingPatch, + type NodeMappingResult, + type NodeRegistryInfo, + type NodeSnapshot, + type NodesPushChannels, + type ResetOptions, + type ResetPreview +} from '../nodes/ipc' + +/** Live session state so a freshly (re)loaded renderer can rehydrate. */ +export interface RelayStatus { + running: boolean + relay: RelayState + device: DeviceStatus + /** + * Last calibration the device confirmed, if the link is still up. + * + * Carried here because `cal:data` is only pushed when the port opens. A renderer reload + * creates a fresh store while the main-process session keeps running, so without this the + * badges would stay blank until the next reconnect — indefinitely, on a stable link. + */ + cal?: CalSnapshot +} + +export interface DebugDumpResult { + path: string + count: number +} + +// main -> renderer channel payloads +// +// Two scopes (#69). **Link-scoped** pushes flow whenever the device link is up, relaying or not: +// device:status, nodes:*, serial:traffic, cal:*. On link down they stop and the renderer keeps +// the last state, dimmed via isLinkUp(). hid:report is neither: the joystick is read for as long +// as the app runs, serial port open or not. **Relay-scoped** pushes flow only while +// relaying: relay:state changes, aircraft:changed, stats:tick, telemetry:tick. log:batch carries +// both (panel commands are logged while stopped too), and so does events:batch. +// Each topic declares its own pushes (shared//ipc.ts); this is their union. +export interface PushChannels + extends + GatewayPushChannels, + DcsPushChannels, + EventsPushChannels, + HidPushChannels, + NodesPushChannels, + CalibrationPushChannels {} + +export type PushChannel = keyof PushChannels + +export const IPC = { + ...GATEWAY_PUSH, + ...DCS_PUSH, + ...EVENTS_PUSH, + ...HID_PUSH, + ...NODES_PUSH, + ...CALIBRATION_PUSH +} as const + +/** Intents this contract owns: the rehydrate bundle and the debug-log tools. */ +export const APP_CTRL = { + relayStatus: 'relay:status', + debugDumpPorts: 'debug:dump-ports', + debugReveal: 'debug:reveal' +} as const + +// ── control (renderer -> main, invoke/response) ────────────────────────────── + +export const CTRL = { + ...CONFIG_CTRL, + ...DCS_CTRL, + ...APP_CTRL, + ...GATEWAY_CTRL, + ...EVENTS_CTRL, + ...HID_CTRL, + ...NODES_CTRL, + ...CALIBRATION_CTRL +} as const + +/** The contextBridge surface exposed to the renderer as `window.skyhawk`. */ +export interface SkyhawkApi { + on(channel: C, cb: (data: PushChannels[C]) => void): () => void + getConfig(): Promise + setConfig(patch: Partial): Promise + + // ── configuration export / import (#67) ────────────────────────────────── + /** Save dialog, then write every section to one file. */ + exportConfig(): Promise + /** Open dialog, then validate and preview; the file stays in main until applied. */ + openConfigImport(): Promise + /** Apply the picked sections of the previewed file, after an optional backup of the current setup. */ + applyConfigImport( + token: string, + sections: SectionId[], + backup: boolean + ): Promise + startRelay(): Promise + stopRelay(): Promise + getStatus(): Promise + /** Open the device link now, whatever "connect automatically" says. */ + connectDevice(): Promise + /** Close the device link and keep it closed — through a replug — until connectDevice(). */ + disconnectDevice(): Promise + exportLog(text: string): Promise + + // ── event log (#64) — in memory in main, this app run only ──────────────── + /** Every event this session, oldest first, for a renderer (re)load. */ + getEvents(): Promise + /** Clear the session's events (what Clear in Log › Events does). */ + clearEvents(): Promise + /** Save dialog, then every event as TSV, oldest first, whatever the view filters. */ + exportEvents(): Promise + toggleCapture(): Promise + openReplay(): Promise + getHidAvailability(): Promise + refreshNodes(): Promise + + // ── node data layer (#61) ───────────────────────────────────────────────── + // Every mapping change persists to node-mapping.json and also pushes `nodes:model`. + /** Current model + telemetry, for a renderer (re)load. */ + getNodes(): Promise + /** Rename / move / re-zone a registry or adopted node. A value equal to its default is cleared. */ + setNodeMapping(nodeId: number, patch: MappingPatch): Promise + /** Place a new NODE_ID (not in the registry); it stops being "new". */ + adoptNode(nodeId: number, spec: AdoptSpec): Promise + /** + * Remove an offline board from your cockpit, keeping nothing; forgetting a PDU unlinks its + * boards. Refused while the board is online. + */ + forgetNode(nodeId: number): Promise + /** One registry board back to its NODE_IDS.md name, console and power zone. */ + resetNode(nodeId: number): Promise + /** + * Every registry node back to its defaults; hidden flags and last-seen times are kept, and + * adopted nodes too unless `forgetAdopted`. + */ + resetNodeMapping(opts?: ResetOptions): Promise + /** What resetNodeMapping would change, field by field — the review before a reset. */ + previewNodeReset(): Promise + /** The bundled NODE_IDS registry (PanelBridge included) and the commit it came from. */ + getNodeRegistry(): Promise + /** + * "Hidden until it reports again" on / off. Cleared automatically when the node reports; turning + * it on is refused while the board is online. + */ + hideNode(nodeId: number, on: boolean): Promise + setSerialMonitor(on: boolean): Promise + dumpSerialPorts(): Promise + revealDebugLog(): Promise + + // ── axis calibration (#46) ──────────────────────────────────────────────── + // hello/read are answered outside a session, so badges work with no dialog open. + calHello(): Promise> + calRead(): Promise> + /** Opens a session and streams RAW for one axis. 0xFF streams none. */ + calSessionOpen(axisIdx: number): Promise> + calStreamSelect(axisIdx: number): Promise> + /** Writes and persists exactly one axis. Acknowledged != stored; follow with calRead(). */ + calCommit(axis: CalCommitAxis): Promise> + /** Deletes stored calibration for one axis, or all with 0xFF. Persists immediately. */ + calReset(idx: number): Promise> + calSessionClose(): Promise> + + // ── the client's copy of what the device confirmed storing (#46, part 5) ── + /** + * What the cache holds for the board currently attached, and which of its axes the device has + * since lost. `board` is undefined when nothing is cached, or when the port reports no serial — + * without one there is no way to tell this board's calibration from another's. + */ + calCacheRead(): Promise> + /** + * Record axes the device has **confirmed** storing — call after the read-back, never after the + * ACK. Caching what was sent rather than what came back would preserve client optimism as if it + * were device state, which is the one thing this copy must never do. + */ + calCacheStore(axes: CalCacheEntry[]): Promise> + /** + * Forget an axis the user deliberately deleted, or the whole board with `0xFF`. + * + * A deletion has to reach the cache. Inferring it from a later read is impossible — an axis that + * is uncalibrated because the user erased it looks exactly like one that lost its calibration, + * and the client would offer to restore what was just deleted. + */ + calCacheDrop(idx: number): Promise> +} diff --git a/src/shared/axis-capture.test.ts b/src/shared/axis-capture.test.ts index 86d187d..3f43f02 100644 --- a/src/shared/axis-capture.test.ts +++ b/src/shared/axis-capture.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { - DEFAULT_CAPTURE_CONFIG, beginCapture, + DEFAULT_CAPTURE_CONFIG, hasTravelled, holdProgress, holdRemainingMs, @@ -11,9 +11,9 @@ import { restartSweep, retrySweep, sweepsRemaining, - tick, - type CaptureState + tick } from './axis-capture' +import type { CaptureState } from './hid/calibration/ipc' const C = DEFAULT_CAPTURE_CONFIG diff --git a/src/shared/axis-capture.ts b/src/shared/axis-capture.ts index 3ed138d..833eda1 100644 --- a/src/shared/axis-capture.ts +++ b/src/shared/axis-capture.ts @@ -20,84 +20,12 @@ // The SEQ counter cannot substitute for that. A gap is only visible on the next arriving frame, // and in the case that matters no next frame arrives. -/** - * Every threshold in one block, as #46 requires, so retuning after the hall-sensor bench is a - * one-line change rather than a hunt through render code. - */ -export interface CaptureConfig { - /** - * Movement smaller than this leaves the held value alone. - * - * A detector that restarted on *any* new sample would race the noise: sometimes completing, - * sometimes not, depending how jittery the individual axis is. So movement has to clear a - * threshold — but not one so wide that it accepts a stop the user has not actually reached. - * - * **This is the least-settled constant here.** Measured rest spread on the bench rig was - * 320–387 counts peak-to-peak (stdev 28.7, post-EWMA) at roughly one sample per second, which - * argues for something above 400. It is set below that deliberately, because 500 raw counts is - * ~885 output counts after a typical 1.77× stretch — 2.7% of a half-axis — and accepting a - * stop that early is its own kind of wrong. - * - * The trade lands on hold *duration*, not correctness: a rest swing across most of the noise - * envelope restarts the timer, so a hold occasionally takes several seconds rather than one. - * The node's 128-count hysteresis keeps most steps under the threshold, so this should be - * uncommon. `interruptions` is the instrument for tuning it — a climbing count during a hold - * means the band is too tight for that axis. - * - * Worth knowing for the hall-sensor bench: a fixed raw band is a larger *fraction* of travel - * on a low-travel axis. The bench stick spans ~37000 counts, but a short-arc axis may develop - * only half that, doubling this threshold's relative size exactly where resolution is already - * tightest. - */ - bandCounts: number - /** - * How long an endpoint must stay inside the band before it counts as held. - * - * **This is the only thing separating a stop from a hesitation.** Both look identical to the - * detector: the movement gate has already been satisfied by the sweep itself, so a pause - * anywhere along the travel is a candidate endpoint. Observed on the rig at 1000 ms — a sweep - * that paused at 33128 (dead centre) banked it as the lower stop, while the same axis reached - * 11950 on the next sweep. - * - * Raised to 1500 ms rather than solved structurally, because widest-wins already keeps a short - * sweep out of the stored numbers and the rejection rule catches a later one. If holds keep - * committing mid-sweep, the real fix is a grace window after each commit that reopens the step - * when the axis travels further the same way — a stop is a place you cannot pass, so passing - * it is proof it was not one. - */ - endpointHoldMs: number - /** Stability required at rest before a centre sample is taken. */ - centreStableMs: number - /** Give up waiting for stability and use what was gathered. */ - centreCapMs: number - /** - * How far short of the best travel a sweep may fall before it is rejected. - * - * Asymmetric by design — see `judgeSweep`. - * - * Sized off real variation, not a round number: two honest sweeps of the same bench axis gave - * 16538…48933 and 13973…50925, a 6.9% shortfall on min and 5.4% on max against the wider - * travel. A threshold near 10% would sit close enough to that to reject swings the user did - * nothing wrong on. Erring permissive is also the cheaper mistake here — with widest-wins an - * accepted-but-short sweep contributes nothing to the result, so this rule coaches the user - * rather than protecting the numbers. - */ - shortfallFraction: number - /** - * How far the axis must move within a step before any hold may commit. - * - * Without this the capture starts timing the moment it opens: the axis sits at rest, noise - * arrives, the value holds for a second, and the *rest position* is committed as the first - * stop — before the user has touched anything. The same then happens for the second stop. - * - * Sized well above the 387-count noise floor and the 300-count band, and well below any real - * travel: the bench axis spans ~37000 counts and #46 notes even a short-arc axis develops - * several thousand. It is a movement gate, not a travel measurement. - */ - minTravelCounts: number - /** Accepted sweeps needed before the axis is done. Rejected attempts do not count. */ - sweepsRequired: number -} +import type { + CaptureConfig, + CapturedSweep, + CaptureResult, + CaptureState +} from './hid/calibration/ipc' export const DEFAULT_CAPTURE_CONFIG: CaptureConfig = { bandCounts: 300, @@ -119,115 +47,6 @@ export interface CaptureSample { raw: number } -/** - * What the user is being asked to do. - * - * `stopA`/`stopB` are deliberately not "min" and "max": we do not care which direction the user - * sweeps first, and an inverted axis would make the labels lie. Whichever two values are held, - * the lower is the min. - */ -export type CaptureStep = 'stopA' | 'stopB' | 'centre' - -export type CapturePhase = - | 'idle' - | 'capturing' - /** A sweep fell short. Waiting for the user to acknowledge and redo that sweep. */ - | 'rejected' - | 'complete' - -export interface CapturedSweep { - min: number - max: number - /** Absent on a non-self-centring axis, which is never asked to release. */ - centre?: number -} - -export interface CaptureResult { - min: number - centre: number - max: number -} - -export interface CaptureState { - /** - * Whether this axis returns to a rest position when released. - * - * A per-axis setting, not a property of which axis it is, and **it defaults to true for every - * axis** — not inferred from `controlId`. Capturing a rest position is the cautious default: - * silently deriving a midpoint for an axis that does have one is how a resting offset gets - * baked in (+4669 on the bench rig before this feature existed). The user marks throttle, - * brakes or zoom as non-centring in the flow, which makes it a stated choice rather than an - * assumption the client made on their behalf. - * - * The device never reports this — there is deliberately no axis-type field in the blob. - */ - selfCentring: boolean - config: CaptureConfig - phase: CapturePhase - step: CaptureStep - /** Sweeps banked so far. Only accepted ones are here. */ - accepted: CapturedSweep[] - /** Values held during the sweep in progress. */ - current: { stopA?: number; stopB?: number; centre?: number } - /** Value the stability detector is currently timing, and since when. */ - ref: number | null - refSince: number - /** Latest sample time seen, so dwell can be evaluated without a new sample. */ - now: number - /** When the current step began — the centre cap measures from here. */ - stepSince: number - /** First value seen in this step, and the furthest the axis has moved from it. */ - stepFrom: number | null - movedBy: number - /** - * The hold has elapsed but the axis has not moved far enough this step to mean anything. - * - * Surfaced so the UI can say "move the axis" rather than showing a full progress bar that will - * never commit. - */ - awaitingMovement: boolean - /** - * Samples gathered during the current centre step, timestamped for the capped-out fallback. - * - * Timestamps are load-bearing: collection begins the instant stopB commits, so the early - * entries are the spring travelling from the mechanical stop back toward rest. Averaging that - * transit together with the rest readings would drag the stored centre toward the last stop. - */ - centreSamples: { t: number; raw: number }[] - /** - * Whether a second in-band sample has confirmed the current reference. - * - * Without this a lone spike commits. RAW is explicitly droppable under CDC back-pressure, so - * the sequence settled -> spike -> (return frame dropped) leaves the spike as the reference - * with nothing following it, and a hold evaluated purely on elapsed time would commit it — - * which widest-wins would then preserve over every good sweep. - * - * The SEQ gap cannot rescue this: a gap is only visible on the next arriving frame, and here - * nothing arrives. Corroboration is the only signal available. - */ - corroborated: boolean - /** - * Times the hold restarted during this sweep. - * - * This *is* the spike count. A spike knocks the reference out of band, the value returns, and - * the hold completes on the settled reading — so a spike can never become an endpoint, and no - * separate outlier filter is needed. What is worth showing the user is how often their hold - * was interrupted. - */ - interruptions: number - /** Why the last sweep was rejected, for the UI to explain. */ - rejection?: string - /** - * The hold has run long enough but nothing has confirmed the value yet. - * - * Surfaced so the UI can say "holding — waiting for confirmation" rather than appearing hung. - * Normally under a second: measured rest noise is 387 counts peak-to-peak, comfortably above - * the node's 128-count emission threshold, so samples do keep arriving at roughly 1/s. - */ - awaitingConfirmation: boolean - result?: CaptureResult -} - export function beginCapture( now: number, /** Defaults to true for every axis; see CaptureState.selfCentring. */ diff --git a/src/shared/cal-cache.test.ts b/src/shared/cal-cache.test.ts index ac8955d..16ce4b0 100644 --- a/src/shared/cal-cache.test.ts +++ b/src/shared/cal-cache.test.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from 'vitest' -import type { CalSnapshot } from './ipc' +import type { CachedAxis, CalCache, CalSnapshot } from './hid/calibration/ipc' import { boardFor, dropAxis, emptyCache, regressedAxes, sanitizeCache, - storeAxes, - type CachedAxis, - type CalCache + storeAxes } from './cal-cache' const SERIAL = '50031327805E871C' diff --git a/src/shared/cal-cache.ts b/src/shared/cal-cache.ts index b32b241..b20732b 100644 --- a/src/shared/cal-cache.ts +++ b/src/shared/cal-cache.ts @@ -13,37 +13,7 @@ // reverts to identity passthrough, reporting `calibratedMask = 0` rather than serving garbage. The // loss is always visible. -import type { CalSnapshot } from './ipc' - -/** - * One axis as the device confirmed storing it. - * - * `selfCentring` rides along because it is part of the calibration and the device deliberately - * does not hold it — there is no axis-type field in the blob. It decides whether centre was - * measured at rest or derived as the midpoint, so restoring the three numbers without it would - * restore values whose meaning has been lost. - */ -export interface CachedAxis { - min: number - centre: number - max: number - selfCentring: boolean -} - -export interface CachedBoard { - /** When the device last confirmed these values, ISO 8601. Shown before restoring. */ - confirmedAt: string - axes: Record -} - -/** - * Keyed by board serial, never by VID/PID — those are identical across every OpenSkyhawk gateway, - * so a VID/PID key would silently push one board's calibration onto another. - */ -export interface CalCache { - version: 1 - boards: Record -} +import type { CachedAxis, CachedBoard, CalCache, CalSnapshot } from './hid/calibration/ipc' export const CAL_CACHE_VERSION = 1 diff --git a/src/shared/capture.test.ts b/src/shared/capture.test.ts index fe39146..c3d68c8 100644 --- a/src/shared/capture.test.ts +++ b/src/shared/capture.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - CAPTURE_FORMAT, - captureDurationMs, - parseCapture, - serializeCapture, - type CaptureEvent -} from './capture' +import { CAPTURE_FORMAT, captureDurationMs, parseCapture, serializeCapture } from './capture' +import type { CaptureEvent } from './dcs/ipc' const events: CaptureEvent[] = [ { t: 0, dir: 'in', hex: '5555' }, diff --git a/src/shared/capture.ts b/src/shared/capture.ts index 8353b35..cfc7017 100644 --- a/src/shared/capture.ts +++ b/src/shared/capture.ts @@ -2,19 +2,7 @@ // the firmware repo's dcsbios_data.json: adds per-event direction + session // metadata while keeping hex-encoded raw bytes so a basic replayer can still read it. -export interface CaptureEvent { - /** Milliseconds since capture start. */ - t: number - /** "in" = export from DCS, "out" = command toward DCS. */ - dir: 'in' | 'out' - /** Hex-encoded raw bytes. */ - hex: string -} - -export interface CaptureMeta { - aircraft?: string - note?: string -} +import type { CaptureEvent, CaptureMeta } from './dcs/ipc' export interface CaptureFile extends CaptureMeta { format: 'skyhawk-capture/1' diff --git a/src/shared/config-file.test.ts b/src/shared/config-file.test.ts index 93f7404..940e507 100644 --- a/src/shared/config-file.test.ts +++ b/src/shared/config-file.test.ts @@ -2,17 +2,14 @@ import { describe, expect, it } from 'vitest' import { applyToConfig, buildConfigFile, - CONFIG_FORMAT, diffImport, mergeCalibration, - parseConfigFile, - type ConfigSnapshot, - type SectionId + parseConfigFile } from './config-file' -import { DEFAULT_CONFIG } from './ipc' -import { registryDefaults, type NodeMappingFile } from './node-mapping' -import { DEFAULT_PSU } from './psu' -import type { CalCache } from './cal-cache' +import { CONFIG_FORMAT, DEFAULT_CONFIG, type ConfigSnapshot, type SectionId } from './config/ipc' +import { registryDefaults } from './node-mapping' +import { DEFAULT_PSU, type NodeMappingFile } from './nodes/ipc' +import type { CalCache } from './hid/calibration/ipc' import { DEFAULT_APP_SETTINGS } from '../test-fixtures/config' const defaults = registryDefaults({ diff --git a/src/shared/config-file.ts b/src/shared/config-file.ts index 36a2150..8b0611f 100644 --- a/src/shared/config-file.ts +++ b/src/shared/config-file.ts @@ -9,32 +9,26 @@ // calibration calibration-cache.json sanitizeCache // Calibration only ever refills the client's copy. Writing a device is the HID tab's Review & // restore offer, never an import. -import { sanitizeConfig, type AppConfig } from './ipc' -import { sanitizeCache, type CachedAxis, type CalCache } from './cal-cache' +import { sanitizeConfig } from './settings-schema' import { - effectiveMapping, - importMapping, - sanitizeNodeMapping, - type MappedNode, - type NodeDefaults, - type NodeMappingFile -} from './node-mapping' -import { PSU_PRESETS, sanitizePsu, type PsuLimits, type PsuSetting } from './psu' + CONFIG_FORMAT, + type AppConfig, + type AppSettings, + type ConfigFile, + type ConfigSnapshot, + type DiffRow, + type SectionDiff, + type SectionId +} from './config/ipc' +import { sanitizeCache } from './cal-cache' +import type { CachedAxis, CalCache } from './hid/calibration/ipc' +import { effectiveMapping, importMapping, sanitizeNodeMapping } from './node-mapping' +import type { MappedNode, NodeDefaults, NodeMappingFile, PsuLimits, PsuSetting } from './nodes/ipc' +import { PSU_PRESETS, sanitizePsu } from './psu' -export const CONFIG_FORMAT = 'openskyhawk-client-config' /** File format this build writes. A higher version came from a newer client. */ export const CONFIG_FILE_VERSION = 1 -export type AppSettings = Omit - -export interface ConfigSnapshot { - app: AppSettings - nodeMapping: NodeMappingFile - powerSupply: PsuSetting - calibration: CalCache -} - -export type SectionId = keyof ConfigSnapshot export const SECTION_IDS: SectionId[] = ['app', 'nodeMapping', 'powerSupply', 'calibration'] export const SECTION_TITLES: Record = { @@ -44,15 +38,6 @@ export const SECTION_TITLES: Record = { calibration: 'Axis calibration' } -export interface ConfigFile { - format: typeof CONFIG_FORMAT - version: number - exportedAt: string - /** Client version that wrote it. */ - client: string - sections: Partial -} - /** Split AppConfig into its two sections. */ export function appSettings(config: AppConfig): AppSettings { const { psu: _psu, ...app } = config @@ -131,23 +116,6 @@ export function parseConfigFile(text: string): ParseResult { // ── review: what applying a section would change ───────────────────────────── -/** One `key from → to` line. `axis` set: the renderer names the axis (it owns the labels). */ -export interface DiffRow { - k: string - from: string - to: string - axis?: number - board?: string -} - -export interface SectionDiff { - id: SectionId - title: string - /** Short count for the heading: "2 changes", "3 nodes", "4 axes · board 4134-A7". */ - summary: string - rows: DiffRow[] -} - const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` const onOff = (b: boolean) => (b ? 'On' : 'Off') diff --git a/src/shared/config.test.ts b/src/shared/config.test.ts index a05411f..869b359 100644 --- a/src/shared/config.test.ts +++ b/src/shared/config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_CONFIG, sanitizeConfig } from './ipc' +import { DEFAULT_CONFIG } from './config/ipc' +import { sanitizeConfig } from './settings-schema' describe('sanitizeConfig', () => { it('falls back to defaults for empty / junk input', () => { diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/config/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/config/ipc.ts b/src/shared/config/ipc.ts new file mode 100644 index 0000000..7b09692 --- /dev/null +++ b/src/shared/config/ipc.ts @@ -0,0 +1,139 @@ +// Config topic contract (arch-phase0-v2 §2.3): the app settings, their defaults, and configuration +// export / import (#67). Imported by main, preload and the renderer. +import { COMMAND_PORT, EXPORT_PORT } from '../dcs/ipc' +import { DEFAULT_PSU, type NodeMappingFile, type PsuSetting } from '../nodes/ipc' +import type { CalCache } from '../hid/calibration/ipc' + +export type SourceMode = 'bridge' | 'monitor' | 'replay' +export type DcsTransport = 'loopback-multicast' | 'unicast-listen' | 'tcp-to-host' + +export const SOURCE_MODES: SourceMode[] = ['bridge', 'monitor', 'replay'] +export const TRANSPORTS: DcsTransport[] = ['loopback-multicast', 'unicast-listen', 'tcp-to-host'] + +export interface AppConfig { + sourceMode: SourceMode + transport: DcsTransport + host: string + commandPort: number + listenPort: number // unicast-listen bind port + /** + * Open the SimGateway on launch and on plug-in, and reopen it after a drop (#69). Off: the + * client never takes the port on its own — for anyone sharing it with socat or similar — and + * the link opens only on an explicit connect. + */ + autoConnect: boolean + replayDriveSerial: boolean // Replay mode: also write the replayed export to the SimGateway + debugMode: boolean // write diagnostics (serial enumeration, device events) to a local log file + /** Show board-reported problems on the Overview tab (#65). Settings › Cockpit (#67). */ + attentionBanner: boolean + /** Power supply limits for the Cockpit headroom (#63). Settings › Cockpit (#67). */ + psu: PsuSetting +} + +export const DEFAULT_CONFIG: AppConfig = { + sourceMode: 'monitor', + transport: 'tcp-to-host', + host: '127.0.0.1', + commandPort: COMMAND_PORT, + listenPort: EXPORT_PORT, + autoConnect: true, + replayDriveSerial: false, + debugMode: false, + attentionBanner: true, + psu: DEFAULT_PSU +} + +/** + * A read-only view of the settings: the current value and change subscriptions, by selector. + * + * Type-only, shaped like zustand/vanilla's `StoreApi` under the `subscribeWithSelector` middleware + * (`getState` + both `subscribe` overloads), so such a store can be handed out as one. + */ +export interface SettingsStore { + getState(): AppConfig + subscribe: { + (listener: (selectedState: AppConfig, previousSelectedState: AppConfig) => void): () => void + ( + selector: (state: AppConfig) => U, + listener: (selectedState: U, previousSelectedState: U) => void, + options?: { equalityFn?: (a: U, b: U) => boolean; fireImmediately?: boolean } + ): () => void + } +} + +export const CONFIG_FORMAT = 'openskyhawk-client-config' + +export type AppSettings = Omit + +export interface ConfigSnapshot { + app: AppSettings + nodeMapping: NodeMappingFile + powerSupply: PsuSetting + calibration: CalCache +} + +export type SectionId = keyof ConfigSnapshot + +export interface ConfigFile { + format: typeof CONFIG_FORMAT + version: number + exportedAt: string + /** Client version that wrote it. */ + client: string + sections: Partial +} + +/** One `key from → to` line. `axis` set: the renderer names the axis (it owns the labels). */ +export interface DiffRow { + k: string + from: string + to: string + axis?: number + board?: string +} + +export interface SectionDiff { + id: SectionId + title: string + /** Short count for the heading: "2 changes", "3 nodes", "4 axes · board 4134-A7". */ + summary: string + rows: DiffRow[] +} + +export interface ConfigExportResult { + /** Where it was written; absent when the dialog was cancelled or the write failed. */ + path?: string + error?: string +} + +export interface ImportPreview { + /** Names the file held in main for `applyConfigImport`. */ + token: string + fileName: string + exportedAt?: string + client?: string + /** One per section the file has and this client knows; `rows` empty = no change. */ + sections: SectionDiff[] + /** Sections from a newer client, left out. */ + skipped: string[] + /** Every section matches the current setup: nothing to apply. */ + identical: boolean +} + +export type ConfigImportOpen = + | { status: 'canceled' } + | { status: 'error'; error: string } + | { status: 'ready'; preview: ImportPreview } + +export type ConfigImportApplyResult = + | { ok: true; applied: SectionId[]; backupPath?: string; config: AppConfig } + | { ok: false; error: string } + +/** Intent channel names (renderer -> main, invoke/response). No pushes yet. */ +export const CONFIG_CTRL = { + configGet: 'config:get', + configSet: 'config:set', + configExport: 'config:export', + configImportOpen: 'config:import-open', + configImportApply: 'config:import-apply' +} as const diff --git a/src/shared/dcs/index.ts b/src/shared/dcs/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/dcs/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/dcs/ipc.ts b/src/shared/dcs/ipc.ts new file mode 100644 index 0000000..2e2a826 --- /dev/null +++ b/src/shared/dcs/ipc.ts @@ -0,0 +1,121 @@ +// DCS topic contract (arch-phase0-v2 §2.3): the relay to DCS-BIOS — its state, the stream it +// carries, recording and replay. Imported by main, preload and the renderer. + +/** DCS-BIOS endpoints: the export multicast group and port, and the command port. */ +export const EXPORT_MULTICAST_ADDR = '239.255.50.10' +export const EXPORT_PORT = 5010 +export const COMMAND_PORT = 7778 + +/** + * Where the DCS-BIOS stream stands while relaying. TCP connects and can drop; UDP has nothing to + * connect, so it is `connected` once the socket is bound; Replay is `connected` while playing and + * `ended` when the capture runs out. + */ +export type StreamState = 'connecting' | 'connected' | 'reconnecting' | 'error' | 'ended' + +/** The relay layer (#69): the one on/off, separate from the device link. */ +export interface RelayState { + running: boolean + /** Absent while stopped. */ + stream?: StreamState + detail?: string +} + +export interface AircraftStatus { + name: string // module name or "NONE" + inferred: boolean // true if guessed from address range rather than _ACFT_NAME + supported: boolean // false => non-A-4E warning banner +} + +export interface StatsSnapshot { + bytesInPerSec: number + bytesOutPerSec: number + framesPerSec: number + commandsPerSec: number + commandsTotal: number + lastCommand?: string + errors: number + uptimeSec: number + reconnects: number +} + +export interface LogRow { + t: number + dir: 'in' | 'out' // client diagnostics are client events in the event log (#64) + address: number + name?: string + value: number | string + raw?: string +} + +/** One sim-telemetry gauge readout (RPM / IAS / Flap / Press Alt / Fuel). */ +export interface TelemetryReadout { + id: string // A-4E-C output identifier driving this gauge + label: string + value: number // raw decoded value (or NaN when not exported) + pct: number // 0..1 fill for the ring + unit: string +} + +export interface RelayResult { + ok: boolean + error?: string +} + +export interface ExportResult { + path?: string +} + +export interface CaptureState { + recording: boolean + path?: string + events?: number +} + +export interface ReplayLoad { + loaded: boolean + path?: string + events?: number + durationMs?: number +} + +export interface CaptureEvent { + /** Milliseconds since capture start. */ + t: number + /** "in" = export from DCS, "out" = command toward DCS. */ + dir: 'in' | 'out' + /** Hex-encoded raw bytes. */ + hex: string +} + +export interface CaptureMeta { + aircraft?: string + note?: string +} + +/** This topic's main -> renderer pushes; shared/app assembles them into `PushChannels`. */ +export interface DcsPushChannels { + 'relay:state': RelayState + 'aircraft:changed': AircraftStatus + 'stats:tick': StatsSnapshot + 'log:batch': LogRow[] + 'telemetry:tick': TelemetryReadout[] +} + +/** Push channel names (main -> renderer). */ +export const DCS_PUSH = { + relayState: 'relay:state', + aircraftChanged: 'aircraft:changed', + statsTick: 'stats:tick', + logBatch: 'log:batch', + telemetryTick: 'telemetry:tick' +} as const + +/** Intent channel names (renderer -> main, invoke/response). */ +export const DCS_CTRL = { + relayStart: 'relay:start', + relayStop: 'relay:stop', + logExport: 'log:export', + captureToggle: 'capture:toggle', + replayOpen: 'replay:open' +} as const diff --git a/src/shared/dcsbios.ts b/src/shared/dcsbios.ts index 6e7da42..4aa2bc3 100644 --- a/src/shared/dcsbios.ts +++ b/src/shared/dcsbios.ts @@ -8,10 +8,6 @@ // // This mirrors the canonical DCS-BIOS Arduino ProtocolParser state machine. -export const EXPORT_MULTICAST_ADDR = '239.255.50.10' -export const EXPORT_PORT = 5010 -export const COMMAND_PORT = 7778 - /** DCS-BIOS common-metadata address of the null-terminated aircraft name. */ export const ACFT_NAME_ADDRESS = 0x0000 export const ACFT_NAME_MAX = 24 diff --git a/src/shared/events.test.ts b/src/shared/events.test.ts index 9f2d2bc..7b986f2 100644 --- a/src/shared/events.test.ts +++ b/src/shared/events.test.ts @@ -9,11 +9,12 @@ import { isProblem, NodeEventTracker, relayEvents, - type LogEventInput, type NodeLookup } from './events' -import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS, type NodeStatus } from './nodes' -import { PDU_FLAG, RAIL_FLAG, type NodeTelem } from './node-telem' +import type { LogEventInput } from './events/ipc' +import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS } from './nodes' +import type { NodeStatus, NodeTelem } from './nodes/ipc' +import { PDU_FLAG, RAIL_FLAG } from './node-telem' import { nodeStatus } from '../test-fixtures/nodes' const st = (nodeId: number, over: Partial = {}): NodeStatus => diff --git a/src/shared/events.ts b/src/shared/events.ts index 63e27d6..4b5fabb 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -3,21 +3,13 @@ // restart. Node events come from changes in the boards' own reports (`_NODE_STATUS`, // `_NODE_TELEM`): the client never applies a threshold, so every alert here is a flag a board // raised itself. -import type { DeviceStatus, RelayState, SourceMode } from './ipc' -import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS, type NodeStatus } from './nodes' -import { - displayCurrentMa, - PDU_FLAG, - RAIL_FLAG, - TELEM_PAGE, - type NodeTelem, - type PduRailPage -} from './node-telem' - -export type EventSource = 'node' | 'client' - -/** error ✕ (offline / failure), warn ! (a raised flag), new + (new on the bus), info ● (cleared / up). */ -export type EventSeverity = 'error' | 'warn' | 'new' | 'info' +import type { DeviceStatus } from './gateway/ipc' +import type { RelayState } from './dcs/ipc' +import type { SourceMode } from './config/ipc' +import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './nodes' +import type { NodeStatus, NodeTelem, PduRailPage } from './nodes/ipc' +import { displayCurrentMa, PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from './node-telem' +import { MAX_EVENTS, type EventSeverity, type LogEvent, type LogEventInput } from './events/ipc' export const SEVERITY_GLYPH: Record = { error: '✕', @@ -26,27 +18,6 @@ export const SEVERITY_GLYPH: Record = { info: '●' } -export interface LogEventInput { - t: number - source: EventSource - /** Node events about one node; absent for the roster summary and client events. */ - nodeId?: number - /** Node: its effective name. Client: `SERIAL`, `DCS`, `Gateway` or `Relay`. */ - name: string - severity: EventSeverity - /** What happened, e.g. `5V UNDER-V raised`, `offline`, `connected`. */ - event: string - /** The reading or reason that goes with it, e.g. `4.71 V`, `no heartbeat`. */ - detail?: string - /** The source line: the `_NODE_STATUS` / `_NODE_TELEM` frame, or the original client message. */ - raw: string -} - -export interface LogEvent extends LogEventInput { - /** Increasing for the whole app run; never reused, not even after Clear. */ - id: number -} - /** `event · detail`, as the Events view and the Cockpit box print it. */ export function eventText(e: Pick): string { return e.detail ? `${e.event} · ${e.detail}` : e.event @@ -397,9 +368,6 @@ export function relayEvents( // ── the session list ──────────────────────────────────────────────────────── -/** Oldest rows go first once the session holds this many. */ -export const MAX_EVENTS = 5000 - /** The session's events, oldest first, plus what has not been pushed to the renderer yet. */ export class EventLog { private rows: LogEvent[] = [] diff --git a/src/shared/events/index.ts b/src/shared/events/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/events/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/events/ipc.ts b/src/shared/events/ipc.ts new file mode 100644 index 0000000..ccb903b --- /dev/null +++ b/src/shared/events/ipc.ts @@ -0,0 +1,49 @@ +// Events topic contract (arch-phase0-v2 §2.3): the session event log (#64). Imported by main, +// preload and the renderer. + +export type EventSource = 'node' | 'client' + +/** error ✕ (offline / failure), warn ! (a raised flag), new + (new on the bus), info ● (cleared / up). */ +export type EventSeverity = 'error' | 'warn' | 'new' | 'info' + +export interface LogEventInput { + t: number + source: EventSource + /** Node events about one node; absent for the roster summary and client events. */ + nodeId?: number + /** Node: its effective name. Client: `SERIAL`, `DCS`, `Gateway` or `Relay`. */ + name: string + severity: EventSeverity + /** What happened, e.g. `5V UNDER-V raised`, `offline`, `connected`. */ + event: string + /** The reading or reason that goes with it, e.g. `4.71 V`, `no heartbeat`. */ + detail?: string + /** The source line: the `_NODE_STATUS` / `_NODE_TELEM` frame, or the original client message. */ + raw: string +} + +export interface LogEvent extends LogEventInput { + /** Increasing for the whole app run; never reused, not even after Clear. */ + id: number +} + +/** Oldest rows go first once the session holds this many. */ +export const MAX_EVENTS = 5000 + +/** This topic's main -> renderer pushes; shared/app assembles them into `PushChannels`. */ +export interface EventsPushChannels { + /** New event-log rows (#64), oldest first. */ + 'events:batch': LogEvent[] +} + +/** Push channel names (main -> renderer). */ +export const EVENTS_PUSH = { + eventsBatch: 'events:batch' +} as const + +/** Intent channel names (renderer -> main, invoke/response). */ +export const EVENTS_CTRL = { + eventsGet: 'events:get', + eventsClear: 'events:clear', + eventsExport: 'events:export' +} as const diff --git a/src/shared/gateway.test.ts b/src/shared/gateway.test.ts index be19f6b..d9ae71b 100644 --- a/src/shared/gateway.test.ts +++ b/src/shared/gateway.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { GATEWAY_NO_ANSWER_MS, GatewayWatch, gatewayEvents, gatewayStatus } from './gateway' -import { eventText, type LogEventInput } from './events' -import type { NodeStatus } from './nodes' +import { eventText } from './events' +import type { LogEventInput } from './events/ipc' +import type { NodeStatus } from './nodes/ipc' import { nodeStatus } from '../test-fixtures/nodes' const node0 = (over: Partial = {}): NodeStatus => diff --git a/src/shared/gateway.ts b/src/shared/gateway.ts index 6e7ff7b..4fc23c4 100644 --- a/src/shared/gateway.ts +++ b/src/shared/gateway.ts @@ -8,37 +8,17 @@ // popover, Overview alert, Cockpit Attention, event log — only reads it. Every alert is a flag the // Gateway raised itself; the one client-side rule is "not responding": once it has answered since // the port opened, a roster request unanswered for GATEWAY_NO_ANSWER_MS while the port is open. -import { faultLabel, STATUS_FLAGS, type LogEventInput, type StatusFlag } from './events' +import { faultLabel, STATUS_FLAGS, type StatusFlag } from './events' +import type { LogEventInput } from './events/ipc' import { NODE_FAULT_CODES, type NodeFaultName } from './firmware' -import type { NodeStatus } from './nodes' +import type { NodeStatus } from './nodes/ipc' +import type { GatewayKind, GatewayStatus } from './gateway/ipc' /** How long a roster request may go unanswered, with the port open, before the Gateway is "not responding". */ export const GATEWAY_NO_ANSWER_MS = 5000 const SECS = GATEWAY_NO_ANSWER_MS / 1000 -export type GatewayKind = - | 'ok' - | 'not-responding' - | 'bus-off' - | 'overheat' - | 'error-passive' - | 'fault' - -/** What the client shows about the Gateway. Carried on `DeviceStatus` while connected. */ -export interface GatewayStatus { - /** err: panel input can't reach DCS. warn: it reports a problem but still works. */ - severity: 'ok' | 'warn' | 'err' - /** The worst thing it reports; only that one is shown. */ - kind: GatewayKind - /** `OK · 42 °C`, `Overheat · 78 °C`, `Error-passive`, `Duplicate NODE_ID`, `Bus-off`, `Not responding`. */ - label: string - /** One line on what it means and what to do; absent when healthy. */ - note?: string - /** Serious faults only (severity err): the Overview alert. */ - alert?: { head: string; text: string } -} - type Flag = (typeof STATUS_FLAGS)[number] const flag = (key: StatusFlag): Flag => STATUS_FLAGS.find((f) => f.key === key)! diff --git a/src/shared/gateway/index.ts b/src/shared/gateway/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/gateway/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/gateway/ipc.ts b/src/shared/gateway/ipc.ts new file mode 100644 index 0000000..f2b1f02 --- /dev/null +++ b/src/shared/gateway/ipc.ts @@ -0,0 +1,96 @@ +// Gateway topic contract (arch-phase0-v2 §2.3): the device link — the Gateway's serial port and the +// Gateway's own health — and the raw serial view. Imported by main, preload and the renderer. + +/** + * State of the device link — the Gateway's serial port, and with it calibration, HID and the + * node roster. Independent of the relay (#69): the link is up whenever the board is plugged in, + * relaying or not, so nothing here says whether DCS-BIOS is flowing. That is `RelayState`. + * + * - `no-device` nothing enumerated; still looking + * - `scanning` looking for the board / opening the port + * - `connected` port open — the one state in which the link is up + * - `reconnecting` the port dropped; briefly, until the board is found again or found gone + * - `error` the port is there but would not open (e.g. held by another program); retrying + * - `disconnected` closed on purpose: Disconnect pressed, or "connect automatically" is off + */ +export type DeviceState = + | 'no-device' + | 'scanning' + | 'connected' + | 'reconnecting' + | 'error' + | 'disconnected' + +/** + * Whether the device link is up. The single definition every consumer keys on — calibration, + * the nodes card, the Cockpit tab (#63) — so none of them invents its own. + * + * While it is down, link-scoped data (node model, telemetry) is left at its last state and + * should be shown dimmed; there is no separate "stale" signal. HID is not link-scoped: the + * joystick is read alongside DCS and keeps reporting with the serial port released (#74). + */ +export function isLinkUp(state: DeviceState): boolean { + return state === 'connected' +} + +export interface DeviceStatus { + state: DeviceState + portPath?: string + vid?: number + pid?: number + detail?: string + /** + * The Gateway's own health (#94), only while `connected` and once there is something to say. + * Computed once in main (shared/gateway.ts) and only read by the UI. Pushed on `device:status` + * when it changes; the link state itself is unchanged by it. + */ + gateway?: GatewayStatus +} + +export type GatewayKind = + | 'ok' + | 'not-responding' + | 'bus-off' + | 'overheat' + | 'error-passive' + | 'fault' + +/** What the client shows about the Gateway. Carried on `DeviceStatus` while connected. */ +export interface GatewayStatus { + /** err: panel input can't reach DCS. warn: it reports a problem but still works. */ + severity: 'ok' | 'warn' | 'err' + /** The worst thing it reports; only that one is shown. */ + kind: GatewayKind + /** `OK · 42 °C`, `Overheat · 78 °C`, `Error-passive`, `Duplicate NODE_ID`, `Bus-off`, `Not responding`. */ + label: string + /** One line on what it means and what to do; absent when healthy. */ + note?: string + /** Serious faults only (severity err): the Overview alert. */ + alert?: { head: string; text: string } +} + +/** One raw serial chunk for the live serial monitor. */ +export interface SerialFrame { + t: number + dir: 'tx' | 'rx' // tx = host -> device, rx = device -> host + hex: string +} + +/** This topic's main -> renderer pushes; shared/app assembles them into `PushChannels`. */ +export interface GatewayPushChannels { + 'device:status': DeviceStatus + 'serial:traffic': SerialFrame[] +} + +/** Push channel names (main -> renderer). */ +export const GATEWAY_PUSH = { + deviceStatus: 'device:status', + serialTraffic: 'serial:traffic' +} as const + +/** Intent channel names (renderer -> main, invoke/response). */ +export const GATEWAY_CTRL = { + deviceConnect: 'device:connect', + deviceDisconnect: 'device:disconnect', + serialMonitor: 'serial:monitor' +} as const diff --git a/src/shared/hid/calibration/index.ts b/src/shared/hid/calibration/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/hid/calibration/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/hid/calibration/ipc.ts b/src/shared/hid/calibration/ipc.ts new file mode 100644 index 0000000..2ec1725 --- /dev/null +++ b/src/shared/hid/calibration/ipc.ts @@ -0,0 +1,336 @@ +// HID calibration topic contract (arch-phase0-v2 §2.3): axis calibration on the device, the +// client's cache of what it confirmed storing, and the capture shapes. Imported by main, preload +// and the renderer. + +/** + * One RAW sample, timestamped **on arrival in main**. + * + * The timestamp is not optional bookkeeping. Samples are batched to the renderer, so wall-clock + * at render time says when the batch flushed, not when the axis moved — and the capture logic's + * dwell test is "the last received value has not changed for N ms". Reading the flush time would + * make dwell depend on IPC scheduling. + */ +export interface CalRawSample { + t: number + idx: number + /** Pre-transform sensor reading. Unsigned 0–65535, and displayed in those units. */ + raw: number + /** + * The same sample through the calibration the device currently holds — not a preview of the + * endpoints being captured. + * + * Also unsigned 0–65535. One convention across this whole channel: converting this value but + * not the endpoints is the silent-failure case. + */ + cal: number +} + +/** Stored calibration as last read back from the device. Drives the badges. */ +export interface CalSnapshot { + presentMask: number + calibratedMask: number + axes: { + idx: number + controlId: number + min: number + centre: number + max: number + deadzone: number + present: boolean + calibrated: boolean + }[] + /** USB serial of the board this came from; the restore cache is keyed by it. */ + serialNumber?: string +} + +/** + * Outcome of a calibration exchange. + * + * A discriminated result rather than a thrown error, because `ipcRenderer.invoke` flattens a + * rejection into a plain Error — which would collapse the one distinction the failure design + * depends on. A **timeout** and a **nack** need opposite messages: a timeout means no frame + * came back, so the connection is suspect and the outcome is genuinely unknown; a nack means + * the device answered and refused, so the connection is fine and the values are the problem. + * + * `timeout` deliberately does not imply "nothing was written". It cannot distinguish "never + * received" from "wrote it and the reply was lost", so callers re-read and report what the + * device says. + */ +export type CalFailure = + | { kind: 'timeout'; message: string } + | { kind: 'nack'; reason: number; reasonName: string; detail: number; message: string } + | { kind: 'offline'; message: string } + | { kind: 'error'; message: string } + +export type CalResult = { ok: true; value: T } | ({ ok: false } & CalFailure) + +export interface CalHello { + proto: number + blobVersion: number + axisSlots: number + fw: { major: number; minor: number; patch: number } +} + +export interface CalCommitAxis { + idx: number + /** + * Unsigned 0–65535 calibration-channel counts — positions in the node's ADC space, exactly as + * the device stores them. + * + * **Pass through unchanged.** These are the same units the dialog displays and the capture + * logic works in, so there is no conversion to undo. The client applies no offset anywhere; + * the −32768 lives once in the firmware, at SimGateway.cpp:216, on the way into the HID report. + */ + min: number + centre: number + max: number +} + +/** + * One axis as the device confirmed storing it. + * + * `selfCentring` rides along because it is part of the calibration and the device deliberately + * does not hold it — there is no axis-type field in the blob. It decides whether centre was + * measured at rest or derived as the midpoint, so restoring the three numbers without it would + * restore values whose meaning has been lost. + */ +export interface CachedAxis { + min: number + centre: number + max: number + selfCentring: boolean +} + +export interface CachedBoard { + /** When the device last confirmed these values, ISO 8601. Shown before restoring. */ + confirmedAt: string + axes: Record +} + +/** + * Keyed by board serial, never by VID/PID — those are identical across every OpenSkyhawk gateway, + * so a VID/PID key would silently push one board's calibration onto another. + */ +export interface CalCache { + version: 1 + boards: Record +} + +/** One axis on its way into the cache: the confirmed values plus the setting the device cannot hold. */ +export interface CalCacheEntry extends CachedAxis { + idx: number +} + +/** + * Every threshold in one block, as #46 requires, so retuning after the hall-sensor bench is a + * one-line change rather than a hunt through render code. + */ +export interface CaptureConfig { + /** + * Movement smaller than this leaves the held value alone. + * + * A detector that restarted on *any* new sample would race the noise: sometimes completing, + * sometimes not, depending how jittery the individual axis is. So movement has to clear a + * threshold — but not one so wide that it accepts a stop the user has not actually reached. + * + * **This is the least-settled constant here.** Measured rest spread on the bench rig was + * 320–387 counts peak-to-peak (stdev 28.7, post-EWMA) at roughly one sample per second, which + * argues for something above 400. It is set below that deliberately, because 500 raw counts is + * ~885 output counts after a typical 1.77× stretch — 2.7% of a half-axis — and accepting a + * stop that early is its own kind of wrong. + * + * The trade lands on hold *duration*, not correctness: a rest swing across most of the noise + * envelope restarts the timer, so a hold occasionally takes several seconds rather than one. + * The node's 128-count hysteresis keeps most steps under the threshold, so this should be + * uncommon. `interruptions` is the instrument for tuning it — a climbing count during a hold + * means the band is too tight for that axis. + * + * Worth knowing for the hall-sensor bench: a fixed raw band is a larger *fraction* of travel + * on a low-travel axis. The bench stick spans ~37000 counts, but a short-arc axis may develop + * only half that, doubling this threshold's relative size exactly where resolution is already + * tightest. + */ + bandCounts: number + /** + * How long an endpoint must stay inside the band before it counts as held. + * + * **This is the only thing separating a stop from a hesitation.** Both look identical to the + * detector: the movement gate has already been satisfied by the sweep itself, so a pause + * anywhere along the travel is a candidate endpoint. Observed on the rig at 1000 ms — a sweep + * that paused at 33128 (dead centre) banked it as the lower stop, while the same axis reached + * 11950 on the next sweep. + * + * Raised to 1500 ms rather than solved structurally, because widest-wins already keeps a short + * sweep out of the stored numbers and the rejection rule catches a later one. If holds keep + * committing mid-sweep, the real fix is a grace window after each commit that reopens the step + * when the axis travels further the same way — a stop is a place you cannot pass, so passing + * it is proof it was not one. + */ + endpointHoldMs: number + /** Stability required at rest before a centre sample is taken. */ + centreStableMs: number + /** Give up waiting for stability and use what was gathered. */ + centreCapMs: number + /** + * How far short of the best travel a sweep may fall before it is rejected. + * + * Asymmetric by design — see `judgeSweep`. + * + * Sized off real variation, not a round number: two honest sweeps of the same bench axis gave + * 16538…48933 and 13973…50925, a 6.9% shortfall on min and 5.4% on max against the wider + * travel. A threshold near 10% would sit close enough to that to reject swings the user did + * nothing wrong on. Erring permissive is also the cheaper mistake here — with widest-wins an + * accepted-but-short sweep contributes nothing to the result, so this rule coaches the user + * rather than protecting the numbers. + */ + shortfallFraction: number + /** + * How far the axis must move within a step before any hold may commit. + * + * Without this the capture starts timing the moment it opens: the axis sits at rest, noise + * arrives, the value holds for a second, and the *rest position* is committed as the first + * stop — before the user has touched anything. The same then happens for the second stop. + * + * Sized well above the 387-count noise floor and the 300-count band, and well below any real + * travel: the bench axis spans ~37000 counts and #46 notes even a short-arc axis develops + * several thousand. It is a movement gate, not a travel measurement. + */ + minTravelCounts: number + /** Accepted sweeps needed before the axis is done. Rejected attempts do not count. */ + sweepsRequired: number +} + +/** + * What the user is being asked to do. + * + * `stopA`/`stopB` are deliberately not "min" and "max": we do not care which direction the user + * sweeps first, and an inverted axis would make the labels lie. Whichever two values are held, + * the lower is the min. + */ +export type CaptureStep = 'stopA' | 'stopB' | 'centre' + +export type CapturePhase = + | 'idle' + | 'capturing' + /** A sweep fell short. Waiting for the user to acknowledge and redo that sweep. */ + | 'rejected' + | 'complete' + +export interface CapturedSweep { + min: number + max: number + /** Absent on a non-self-centring axis, which is never asked to release. */ + centre?: number +} + +export interface CaptureResult { + min: number + centre: number + max: number +} + +export interface CaptureState { + /** + * Whether this axis returns to a rest position when released. + * + * A per-axis setting, not a property of which axis it is, and **it defaults to true for every + * axis** — not inferred from `controlId`. Capturing a rest position is the cautious default: + * silently deriving a midpoint for an axis that does have one is how a resting offset gets + * baked in (+4669 on the bench rig before this feature existed). The user marks throttle, + * brakes or zoom as non-centring in the flow, which makes it a stated choice rather than an + * assumption the client made on their behalf. + * + * The device never reports this — there is deliberately no axis-type field in the blob. + */ + selfCentring: boolean + config: CaptureConfig + phase: CapturePhase + step: CaptureStep + /** Sweeps banked so far. Only accepted ones are here. */ + accepted: CapturedSweep[] + /** Values held during the sweep in progress. */ + current: { stopA?: number; stopB?: number; centre?: number } + /** Value the stability detector is currently timing, and since when. */ + ref: number | null + refSince: number + /** Latest sample time seen, so dwell can be evaluated without a new sample. */ + now: number + /** When the current step began — the centre cap measures from here. */ + stepSince: number + /** First value seen in this step, and the furthest the axis has moved from it. */ + stepFrom: number | null + movedBy: number + /** + * The hold has elapsed but the axis has not moved far enough this step to mean anything. + * + * Surfaced so the UI can say "move the axis" rather than showing a full progress bar that will + * never commit. + */ + awaitingMovement: boolean + /** + * Samples gathered during the current centre step, timestamped for the capped-out fallback. + * + * Timestamps are load-bearing: collection begins the instant stopB commits, so the early + * entries are the spring travelling from the mechanical stop back toward rest. Averaging that + * transit together with the rest readings would drag the stored centre toward the last stop. + */ + centreSamples: { t: number; raw: number }[] + /** + * Whether a second in-band sample has confirmed the current reference. + * + * Without this a lone spike commits. RAW is explicitly droppable under CDC back-pressure, so + * the sequence settled -> spike -> (return frame dropped) leaves the spike as the reference + * with nothing following it, and a hold evaluated purely on elapsed time would commit it — + * which widest-wins would then preserve over every good sweep. + * + * The SEQ gap cannot rescue this: a gap is only visible on the next arriving frame, and here + * nothing arrives. Corroboration is the only signal available. + */ + corroborated: boolean + /** + * Times the hold restarted during this sweep. + * + * This *is* the spike count. A spike knocks the reference out of band, the value returns, and + * the hold completes on the settled reading — so a spike can never become an endpoint, and no + * separate outlier filter is needed. What is worth showing the user is how often their hold + * was interrupted. + */ + interruptions: number + /** Why the last sweep was rejected, for the UI to explain. */ + rejection?: string + /** + * The hold has run long enough but nothing has confirmed the value yet. + * + * Surfaced so the UI can say "holding — waiting for confirmation" rather than appearing hung. + * Normally under a second: measured rest noise is 387 counts peak-to-peak, comfortably above + * the node's 128-count emission threshold, so samples do keep arriving at roughly 1/s. + */ + awaitingConfirmation: boolean + result?: CaptureResult +} + +/** This topic's main -> renderer pushes; shared/app assembles them into `PushChannels`. */ +export interface CalibrationPushChannels { + 'cal:data': CalSnapshot + 'cal:raw': CalRawSample[] +} + +/** Push channel names (main -> renderer). */ +export const CALIBRATION_PUSH = { + calData: 'cal:data', + calRaw: 'cal:raw' +} as const + +/** Intent channel names (renderer -> main, invoke/response). */ +export const CALIBRATION_CTRL = { + calHello: 'cal:hello', + calRead: 'cal:read', + calSessionOpen: 'cal:session-open', + calStreamSelect: 'cal:stream-select', + calCommit: 'cal:commit', + calReset: 'cal:reset', + calSessionClose: 'cal:session-close', + calCacheRead: 'cal:cache-read', + calCacheStore: 'cal:cache-store', + calCacheDrop: 'cal:cache-drop' +} as const diff --git a/src/shared/hid/index.ts b/src/shared/hid/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/hid/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/hid/ipc.ts b/src/shared/hid/ipc.ts new file mode 100644 index 0000000..9d2601f --- /dev/null +++ b/src/shared/hid/ipc.ts @@ -0,0 +1,32 @@ +// HID topic contract (arch-phase0-v2 §2.3): the Gateway's joystick report as the client reads it. +// Imported by main, preload and the renderer. + +export interface HidSnapshot { + axes: number[] // 8 signed + buttons: boolean[] // 128 + hats: number[] // 4 (0 = centre) + ageMs: number // time since last report; large => idle + rateHz: number // reports/sec (on-change; 0 when idle) +} + +/** Report indices the firmware actually catalogues (the rest dim in the HID panel). */ +export interface HidAvailability { + axes: number[] + hats: number[] + buttons: number[] +} + +/** This topic's main -> renderer pushes; shared/app assembles them into `PushChannels`. */ +export interface HidPushChannels { + 'hid:report': HidSnapshot +} + +/** Push channel names (main -> renderer). */ +export const HID_PUSH = { + hidReport: 'hid:report' +} as const + +/** Intent channel names (renderer -> main, invoke/response). */ +export const HID_CTRL = { + hidAvailability: 'hid:availability' +} as const diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts deleted file mode 100644 index 87bbeca..0000000 --- a/src/shared/ipc.ts +++ /dev/null @@ -1,570 +0,0 @@ -// Typed IPC contract between the Electron main process and the renderer. -// Channels are one-way main -> renderer pushes; control actions are renderer -> main. -import type { CachedAxis, CachedBoard } from './cal-cache' -import { DEFAULT_PSU, sanitizePsu, type PsuSetting } from './psu' -import type { SectionDiff, SectionId } from './config-file' -import type { NodeTelemSnapshot } from './node-telem' -import type { AdoptSpec, MappingPatch, NodeKind, ResetOptions, ResetPreview } from './node-mapping' -import type { NodeModel } from './node-rules' -import type { LogEvent } from './events' -import type { GatewayStatus } from './gateway' - -export type SourceMode = 'bridge' | 'monitor' | 'replay' -export type DcsTransport = 'loopback-multicast' | 'unicast-listen' | 'tcp-to-host' - -/** - * State of the device link — the Gateway's serial port, and with it calibration, HID and the - * node roster. Independent of the relay (#69): the link is up whenever the board is plugged in, - * relaying or not, so nothing here says whether DCS-BIOS is flowing. That is `RelayState`. - * - * - `no-device` nothing enumerated; still looking - * - `scanning` looking for the board / opening the port - * - `connected` port open — the one state in which the link is up - * - `reconnecting` the port dropped; briefly, until the board is found again or found gone - * - `error` the port is there but would not open (e.g. held by another program); retrying - * - `disconnected` closed on purpose: Disconnect pressed, or "connect automatically" is off - */ -export type DeviceState = - | 'no-device' - | 'scanning' - | 'connected' - | 'reconnecting' - | 'error' - | 'disconnected' - -/** - * Whether the device link is up. The single definition every consumer keys on — calibration, - * the nodes card, the Cockpit tab (#63) — so none of them invents its own. - * - * While it is down, link-scoped data (node model, telemetry) is left at its last state and - * should be shown dimmed; there is no separate "stale" signal. HID is not link-scoped: the - * joystick is read alongside DCS and keeps reporting with the serial port released (#74). - */ -export function isLinkUp(state: DeviceState): boolean { - return state === 'connected' -} - -export interface DeviceStatus { - state: DeviceState - portPath?: string - vid?: number - pid?: number - detail?: string - /** - * The Gateway's own health (#94), only while `connected` and once there is something to say. - * Computed once in main (shared/gateway.ts) and only read by the UI. Pushed on `device:status` - * when it changes; the link state itself is unchanged by it. - */ - gateway?: GatewayStatus -} - -/** - * Where the DCS-BIOS stream stands while relaying. TCP connects and can drop; UDP has nothing to - * connect, so it is `connected` once the socket is bound; Replay is `connected` while playing and - * `ended` when the capture runs out. - */ -export type StreamState = 'connecting' | 'connected' | 'reconnecting' | 'error' | 'ended' - -/** The relay layer (#69): the one on/off, separate from the device link. */ -export interface RelayState { - running: boolean - /** Absent while stopped. */ - stream?: StreamState - detail?: string -} - -export interface AircraftStatus { - name: string // module name or "NONE" - inferred: boolean // true if guessed from address range rather than _ACFT_NAME - supported: boolean // false => non-A-4E warning banner -} - -export interface StatsSnapshot { - bytesInPerSec: number - bytesOutPerSec: number - framesPerSec: number - commandsPerSec: number - commandsTotal: number - lastCommand?: string - errors: number - uptimeSec: number - reconnects: number -} - -export interface LogRow { - t: number - dir: 'in' | 'out' // client diagnostics are client events in the event log (#64) - address: number - name?: string - value: number | string - raw?: string -} - -export interface HidSnapshot { - axes: number[] // 8 signed - buttons: boolean[] // 128 - hats: number[] // 4 (0 = centre) - ageMs: number // time since last report; large => idle - rateHz: number // reports/sec (on-change; 0 when idle) -} - -/** One raw serial chunk for the live serial monitor. */ -export interface SerialFrame { - t: number - dir: 'tx' | 'rx' // tx = host -> device, rx = device -> host - hex: string -} - -/** One sim-telemetry gauge readout (RPM / IAS / Flap / Press Alt / Fuel). */ -export interface TelemetryReadout { - id: string // A-4E-C output identifier driving this gauge - label: string - value: number // raw decoded value (or NaN when not exported) - pct: number // 0..1 fill for the ring - unit: string -} - -/** - * One RAW sample, timestamped **on arrival in main**. - * - * The timestamp is not optional bookkeeping. Samples are batched to the renderer, so wall-clock - * at render time says when the batch flushed, not when the axis moved — and the capture logic's - * dwell test is "the last received value has not changed for N ms". Reading the flush time would - * make dwell depend on IPC scheduling. - */ -export interface CalRawSample { - t: number - idx: number - /** Pre-transform sensor reading. Unsigned 0–65535, and displayed in those units. */ - raw: number - /** - * The same sample through the calibration the device currently holds — not a preview of the - * endpoints being captured. - * - * Also unsigned 0–65535. One convention across this whole channel: converting this value but - * not the endpoints is the silent-failure case. - */ - cal: number -} - -/** Stored calibration as last read back from the device. Drives the badges. */ -export interface CalSnapshot { - presentMask: number - calibratedMask: number - axes: { - idx: number - controlId: number - min: number - centre: number - max: number - deadzone: number - present: boolean - calibrated: boolean - }[] - /** USB serial of the board this came from; the restore cache is keyed by it. */ - serialNumber?: string -} - -// main -> renderer channel payloads -// -// Two scopes (#69). **Link-scoped** pushes flow whenever the device link is up, relaying or not: -// device:status, nodes:*, serial:traffic, cal:*. On link down they stop and the renderer keeps -// the last state, dimmed via isLinkUp(). hid:report is neither: the joystick is read for as long -// as the app runs, serial port open or not. **Relay-scoped** pushes flow only while -// relaying: relay:state changes, aircraft:changed, stats:tick, telemetry:tick. log:batch carries -// both (panel commands are logged while stopped too), and so does events:batch. -export interface PushChannels { - 'device:status': DeviceStatus - 'relay:state': RelayState - 'aircraft:changed': AircraftStatus - 'stats:tick': StatsSnapshot - 'log:batch': LogRow[] - /** New event-log rows (#64), oldest first. */ - 'events:batch': LogEvent[] - 'hid:report': HidSnapshot - 'telemetry:tick': TelemetryReadout[] - /** Latest PDU telemetry per node (#61). At most once a second, and only on change. */ - 'nodes:telemetry': NodeTelemSnapshot[] - /** Roster + telemetry + node mapping combined: what the Cockpit tab shows (#61, UI in #63). */ - 'nodes:model': NodeModel - 'serial:traffic': SerialFrame[] - 'cal:data': CalSnapshot - 'cal:raw': CalRawSample[] -} - -export type PushChannel = keyof PushChannels - -export const IPC = { - deviceStatus: 'device:status', - relayState: 'relay:state', - aircraftChanged: 'aircraft:changed', - statsTick: 'stats:tick', - logBatch: 'log:batch', - eventsBatch: 'events:batch', - hidReport: 'hid:report', - telemetryTick: 'telemetry:tick', - nodesTelemetry: 'nodes:telemetry', - nodesModel: 'nodes:model', - serialTraffic: 'serial:traffic', - calData: 'cal:data', - calRaw: 'cal:raw' -} as const - -// ── control (renderer -> main, invoke/response) ────────────────────────────── - -export interface AppConfig { - sourceMode: SourceMode - transport: DcsTransport - host: string - commandPort: number - listenPort: number // unicast-listen bind port - /** - * Open the SimGateway on launch and on plug-in, and reopen it after a drop (#69). Off: the - * client never takes the port on its own — for anyone sharing it with socat or similar — and - * the link opens only on an explicit connect. - */ - autoConnect: boolean - replayDriveSerial: boolean // Replay mode: also write the replayed export to the SimGateway - debugMode: boolean // write diagnostics (serial enumeration, device events) to a local log file - /** Show board-reported problems on the Overview tab (#65). Settings › Cockpit (#67). */ - attentionBanner: boolean - /** Power supply limits for the Cockpit headroom (#63). Settings › Cockpit (#67). */ - psu: PsuSetting -} - -export const DEFAULT_CONFIG: AppConfig = { - sourceMode: 'monitor', - transport: 'tcp-to-host', - host: '127.0.0.1', - commandPort: 7778, - listenPort: 5010, - autoConnect: true, - replayDriveSerial: false, - debugMode: false, - attentionBanner: true, - psu: DEFAULT_PSU -} - -const SOURCE_MODES: SourceMode[] = ['bridge', 'monitor', 'replay'] -const TRANSPORTS: DcsTransport[] = ['loopback-multicast', 'unicast-listen', 'tcp-to-host'] - -/** Coerce arbitrary (e.g. persisted/untrusted) data into a valid AppConfig. */ -export function sanitizeConfig(raw: unknown): AppConfig { - const r = (raw && typeof raw === 'object' ? raw : {}) as Partial - const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d) - return { - sourceMode: SOURCE_MODES.includes(r.sourceMode as SourceMode) - ? (r.sourceMode as SourceMode) - : DEFAULT_CONFIG.sourceMode, - transport: TRANSPORTS.includes(r.transport as DcsTransport) - ? (r.transport as DcsTransport) - : DEFAULT_CONFIG.transport, - host: typeof r.host === 'string' && r.host ? r.host : DEFAULT_CONFIG.host, - commandPort: num(r.commandPort, DEFAULT_CONFIG.commandPort), - listenPort: num(r.listenPort, DEFAULT_CONFIG.listenPort), - // The old `autoReconnect` is not carried over: it governed reopen-after-drop for a link that - // only existed while relaying, and connecting by default is the intended behaviour now. - autoConnect: typeof r.autoConnect === 'boolean' ? r.autoConnect : DEFAULT_CONFIG.autoConnect, - replayDriveSerial: - typeof r.replayDriveSerial === 'boolean' - ? r.replayDriveSerial - : DEFAULT_CONFIG.replayDriveSerial, - debugMode: typeof r.debugMode === 'boolean' ? r.debugMode : DEFAULT_CONFIG.debugMode, - attentionBanner: - typeof r.attentionBanner === 'boolean' ? r.attentionBanner : DEFAULT_CONFIG.attentionBanner, - psu: sanitizePsu(r.psu) - } -} - -export interface RelayResult { - ok: boolean - error?: string -} - -/** Live session state so a freshly (re)loaded renderer can rehydrate. */ -export interface RelayStatus { - running: boolean - relay: RelayState - device: DeviceStatus - /** - * Last calibration the device confirmed, if the link is still up. - * - * Carried here because `cal:data` is only pushed when the port opens. A renderer reload - * creates a fresh store while the main-process session keeps running, so without this the - * badges would stay blank until the next reconnect — indefinitely, on a stable link. - */ - cal?: CalSnapshot -} - -export interface ExportResult { - path?: string -} - -export interface CaptureState { - recording: boolean - path?: string - events?: number -} - -export interface ReplayLoad { - loaded: boolean - path?: string - events?: number - durationMs?: number -} - -/** Report indices the firmware actually catalogues (the rest dim in the HID panel). */ -export interface HidAvailability { - axes: number[] - hats: number[] - buttons: number[] -} - -export interface DebugDumpResult { - path: string - count: number -} - -/** - * Outcome of a calibration exchange. - * - * A discriminated result rather than a thrown error, because `ipcRenderer.invoke` flattens a - * rejection into a plain Error — which would collapse the one distinction the failure design - * depends on. A **timeout** and a **nack** need opposite messages: a timeout means no frame - * came back, so the connection is suspect and the outcome is genuinely unknown; a nack means - * the device answered and refused, so the connection is fine and the values are the problem. - * - * `timeout` deliberately does not imply "nothing was written". It cannot distinguish "never - * received" from "wrote it and the reply was lost", so callers re-read and report what the - * device says. - */ -export type CalFailure = - | { kind: 'timeout'; message: string } - | { kind: 'nack'; reason: number; reasonName: string; detail: number; message: string } - | { kind: 'offline'; message: string } - | { kind: 'error'; message: string } - -export type CalResult = { ok: true; value: T } | ({ ok: false } & CalFailure) - -export interface CalHello { - proto: number - blobVersion: number - axisSlots: number - fw: { major: number; minor: number; patch: number } -} - -export interface CalCommitAxis { - idx: number - /** - * Unsigned 0–65535 calibration-channel counts — positions in the node's ADC space, exactly as - * the device stores them. - * - * **Pass through unchanged.** These are the same units the dialog displays and the capture - * logic works in, so there is no conversion to undo. The client applies no offset anywhere; - * the −32768 lives once in the firmware, at SimGateway.cpp:216, on the way into the HID report. - */ - min: number - centre: number - max: number -} - -export const CTRL = { - configGet: 'config:get', - configSet: 'config:set', - configExport: 'config:export', - configImportOpen: 'config:import-open', - configImportApply: 'config:import-apply', - relayStart: 'relay:start', - relayStop: 'relay:stop', - relayStatus: 'relay:status', - deviceConnect: 'device:connect', - deviceDisconnect: 'device:disconnect', - logExport: 'log:export', - eventsGet: 'events:get', - eventsClear: 'events:clear', - eventsExport: 'events:export', - captureToggle: 'capture:toggle', - replayOpen: 'replay:open', - hidAvailability: 'hid:availability', - nodesRefresh: 'nodes:refresh', - nodesGet: 'nodes:get', - nodeMappingSet: 'node-mapping:set', - nodeMappingAdopt: 'node-mapping:adopt', - nodeMappingForget: 'node-mapping:forget', - nodeMappingResetNode: 'node-mapping:reset-node', - nodeMappingReset: 'node-mapping:reset', - nodeMappingResetPreview: 'node-mapping:reset-preview', - nodeRegistry: 'nodes:registry', - nodeMappingHide: 'node-mapping:hide', - serialMonitor: 'serial:monitor', - debugDumpPorts: 'debug:dump-ports', - debugReveal: 'debug:reveal', - calHello: 'cal:hello', - calRead: 'cal:read', - calSessionOpen: 'cal:session-open', - calStreamSelect: 'cal:stream-select', - calCommit: 'cal:commit', - calReset: 'cal:reset', - calSessionClose: 'cal:session-close', - calCacheRead: 'cal:cache-read', - calCacheStore: 'cal:cache-store', - calCacheDrop: 'cal:cache-drop' -} as const - -/** The contextBridge surface exposed to the renderer as `window.skyhawk`. */ -export interface SkyhawkApi { - on(channel: C, cb: (data: PushChannels[C]) => void): () => void - getConfig(): Promise - setConfig(patch: Partial): Promise - - // ── configuration export / import (#67) ────────────────────────────────── - /** Save dialog, then write every section to one file. */ - exportConfig(): Promise - /** Open dialog, then validate and preview; the file stays in main until applied. */ - openConfigImport(): Promise - /** Apply the picked sections of the previewed file, after an optional backup of the current setup. */ - applyConfigImport( - token: string, - sections: SectionId[], - backup: boolean - ): Promise - startRelay(): Promise - stopRelay(): Promise - getStatus(): Promise - /** Open the device link now, whatever "connect automatically" says. */ - connectDevice(): Promise - /** Close the device link and keep it closed — through a replug — until connectDevice(). */ - disconnectDevice(): Promise - exportLog(text: string): Promise - - // ── event log (#64) — in memory in main, this app run only ──────────────── - /** Every event this session, oldest first, for a renderer (re)load. */ - getEvents(): Promise - /** Clear the session's events (what Clear in Log › Events does). */ - clearEvents(): Promise - /** Save dialog, then every event as TSV, oldest first, whatever the view filters. */ - exportEvents(): Promise - toggleCapture(): Promise - openReplay(): Promise - getHidAvailability(): Promise - refreshNodes(): Promise - - // ── node data layer (#61) ───────────────────────────────────────────────── - // Every mapping change persists to node-mapping.json and also pushes `nodes:model`. - /** Current model + telemetry, for a renderer (re)load. */ - getNodes(): Promise - /** Rename / move / re-zone a registry or adopted node. A value equal to its default is cleared. */ - setNodeMapping(nodeId: number, patch: MappingPatch): Promise - /** Place a new NODE_ID (not in the registry); it stops being "new". */ - adoptNode(nodeId: number, spec: AdoptSpec): Promise - /** - * Remove an offline board from your cockpit, keeping nothing; forgetting a PDU unlinks its - * boards. Refused while the board is online. - */ - forgetNode(nodeId: number): Promise - /** One registry board back to its NODE_IDS.md name, console and power zone. */ - resetNode(nodeId: number): Promise - /** - * Every registry node back to its defaults; hidden flags and last-seen times are kept, and - * adopted nodes too unless `forgetAdopted`. - */ - resetNodeMapping(opts?: ResetOptions): Promise - /** What resetNodeMapping would change, field by field — the review before a reset. */ - previewNodeReset(): Promise - /** The bundled NODE_IDS registry (PanelBridge included) and the commit it came from. */ - getNodeRegistry(): Promise - /** - * "Hidden until it reports again" on / off. Cleared automatically when the node reports; turning - * it on is refused while the board is online. - */ - hideNode(nodeId: number, on: boolean): Promise - setSerialMonitor(on: boolean): Promise - dumpSerialPorts(): Promise - revealDebugLog(): Promise - - // ── axis calibration (#46) ──────────────────────────────────────────────── - // hello/read are answered outside a session, so badges work with no dialog open. - calHello(): Promise> - calRead(): Promise> - /** Opens a session and streams RAW for one axis. 0xFF streams none. */ - calSessionOpen(axisIdx: number): Promise> - calStreamSelect(axisIdx: number): Promise> - /** Writes and persists exactly one axis. Acknowledged != stored; follow with calRead(). */ - calCommit(axis: CalCommitAxis): Promise> - /** Deletes stored calibration for one axis, or all with 0xFF. Persists immediately. */ - calReset(idx: number): Promise> - calSessionClose(): Promise> - - // ── the client's copy of what the device confirmed storing (#46, part 5) ── - /** - * What the cache holds for the board currently attached, and which of its axes the device has - * since lost. `board` is undefined when nothing is cached, or when the port reports no serial — - * without one there is no way to tell this board's calibration from another's. - */ - calCacheRead(): Promise> - /** - * Record axes the device has **confirmed** storing — call after the read-back, never after the - * ACK. Caching what was sent rather than what came back would preserve client optimism as if it - * were device state, which is the one thing this copy must never do. - */ - calCacheStore(axes: CalCacheEntry[]): Promise> - /** - * Forget an axis the user deliberately deleted, or the whole board with `0xFF`. - * - * A deletion has to reach the cache. Inferring it from a later read is impossible — an axis that - * is uncalibrated because the user erased it looks exactly like one that lost its calibration, - * and the client would offer to restore what was just deleted. - */ - calCacheDrop(idx: number): Promise> -} - -export interface NodeRegistryInfo { - /** Path in the firmware repo, e.g. Firmware/NODE_IDS.md. */ - file: string - commit: string - /** The commit's date, YYYY-MM-DD. */ - date: string - nodes: { nodeId: number; name: string; console: string; kind: NodeKind | 'bridge' }[] -} - -export interface ConfigExportResult { - /** Where it was written; absent when the dialog was cancelled or the write failed. */ - path?: string - error?: string -} - -export interface ImportPreview { - /** Names the file held in main for `applyConfigImport`. */ - token: string - fileName: string - exportedAt?: string - client?: string - /** One per section the file has and this client knows; `rows` empty = no change. */ - sections: SectionDiff[] - /** Sections from a newer client, left out. */ - skipped: string[] - /** Every section matches the current setup: nothing to apply. */ - identical: boolean -} - -export type ConfigImportOpen = - | { status: 'canceled' } - | { status: 'error'; error: string } - | { status: 'ready'; preview: ImportPreview } - -export type ConfigImportApplyResult = - | { ok: true; applied: SectionId[]; backupPath?: string; config: AppConfig } - | { ok: false; error: string } - -export interface NodeSnapshot { - model: NodeModel - telemetry: NodeTelemSnapshot[] -} - -/** A mapping change: the new model, or why the input was refused (nothing was written). */ -export type NodeMappingResult = { ok: true; model: NodeModel } | { ok: false; error: string } - -/** One axis on its way into the cache: the confirmed values plus the setting the device cannot hold. */ -export interface CalCacheEntry extends CachedAxis { - idx: number -} diff --git a/src/shared/node-kind.ts b/src/shared/node-kind.ts index 8356d96..bc75c39 100644 --- a/src/shared/node-kind.ts +++ b/src/shared/node-kind.ts @@ -2,13 +2,7 @@ // mapping validation, Ignore's recorded kind, the Adopt gate and the Overview — so no two places // can disagree about a board. Never inferred from a NODE_ID: a PDU can take any ID (#344). import { NODE_KIND } from './nodes' -import type { NodeKind } from './node-mapping' - -/** - * A node's type once something solid says so. 'bridge' = a SimGateway/PanelBridge board; 'other' = - * a kind this client doesn't know (a newer firmware's). Neither is ever adoptable. - */ -export type NodeIdentity = 'pdu' | 'panel' | 'bridge' | 'other' +import type { NodeIdentity, NodeKind } from './nodes/ipc' /** The kind a status row reports (NodeStatus.nodeKind); undefined = not reported (0) or no row. */ export function reportedIdentity(nodeKind: number | undefined): NodeIdentity | undefined { diff --git a/src/shared/node-mapping.test.ts b/src/shared/node-mapping.test.ts index 217f79f..7932dda 100644 --- a/src/shared/node-mapping.test.ts +++ b/src/shared/node-mapping.test.ts @@ -15,11 +15,9 @@ import { resetPreview, sanitizeNodeMapping, setHidden, - setNodeMapping, - type AdoptSpec, - type MappingPatch, - type NodeMappingFile + setNodeMapping } from './node-mapping' +import type { AdoptSpec, MappingPatch, NodeMappingFile } from './nodes/ipc' // Mirrors the bundled NODE_IDS at the #216 pin. const REGISTRY = { diff --git a/src/shared/node-mapping.ts b/src/shared/node-mapping.ts index 90a78c6..3c7e0ff 100644 --- a/src/shared/node-mapping.ts +++ b/src/shared/node-mapping.ts @@ -8,9 +8,21 @@ // not know, named by the user — are stored in full, since there is nothing to default from (#93). import { MAX_NODE_ID } from './firmware' -import { isAdoptableKind, type NodeIdentity } from './node-kind' - -export type NodeKind = 'pdu' | 'panel' +import { isAdoptableKind } from './node-kind' +import type { + AdoptedRow, + AdoptSpec, + MappedNode, + MappingPatch, + NodeDefaults, + NodeIdentity, + NodeKind, + NodeMappingFile, + NodeOverride, + ResetOptions, + ResetPreview, + ResetRow +} from './nodes/ipc' /** * A NODE_ID's type from live signals (node-kind.ts resolveKind over the roster's reported kind and @@ -19,60 +31,6 @@ export type NodeKind = 'pdu' | 'panel' */ export type KindOf = (nodeId: number) => NodeIdentity | undefined -/** What node-mapping.json holds for one NODE_ID. Every field optional: absent = default. */ -export interface NodeOverride { - name?: string - console?: string - /** Power zone: the PDU NODE_ID feeding this node. null = explicitly none. */ - pduId?: number | null - /** Adopted nodes only — registry nodes take their kind from the registry. */ - kind?: NodeKind - /** Replaced / removed board: skip it until it reports again. */ - hidden?: boolean - /** - * Registry boards only: you added it to your cockpit (New on the bus › Add to cockpit). Reset to - * default keeps it; only Forget removes it. Adopted nodes are in your cockpit by being adopted. - */ - added?: boolean - /** - * When it was last on the bus, ISO 8601: written for a board in your cockpit when it comes - * online, when it drops and when the link closes. - */ - lastSeen?: string -} - -export interface NodeMappingFile { - version: 3 - nodes: Record -} - -/** Effective defaults for one registry node. */ -export interface NodeDefaults { - name: string - console: string - pduId: number | null - kind: NodeKind -} - -/** A node as the client should present it: registry defaults with overrides applied. */ -export interface MappedNode { - nodeId: number - name: string - console: string - pduId: number | null - kind: NodeKind - source: 'registry' | 'adopted' - /** Registry node with at least one stored override (what reset would undo). */ - differs: boolean - hidden: boolean - /** - * In your cockpit: adopted, or a registry board you added. A registry board that was never added - * is only a dictionary entry — when it reports it waits in New on the bus, pre-filled. - */ - known: boolean - lastSeen?: string -} - /** * File format this build writes. A file with a higher version came from a newer client. * v3 (#93): `added` and `lastSeen` per node, read exactly as written; no ignore list. Older files @@ -234,14 +192,6 @@ export function differingCount(defaults: Map, file: NodeMa return n } -export type MappingPatch = Partial> -export interface AdoptSpec { - name: string - console: string - pduId: number | null - kind: NodeKind -} - /** Thrown for input the UI should have prevented; main turns it into an error result. */ export class MappingError extends Error {} @@ -471,11 +421,6 @@ function keptOnReset(o: NodeOverride | undefined): NodeOverride { return kept } -export interface ResetOptions { - /** Also drop every adopted node: each is "new" again the next time it reports. */ - forgetAdopted?: boolean -} - /** The NODE_IDs "Also forget adopted nodes" drops — exactly the ones the review lists. */ function forgettable(defaults: Map, file: NodeMappingFile): number[] { return Object.keys(file.nodes) @@ -515,32 +460,6 @@ export function resetMapping( return { ...file, nodes } } -/** One field a reset would change, ready to show as `node · field from → to`. */ -export interface ResetRow { - nodeId: number - /** The node's registry name (what it is called after the reset). */ - node: string - field: 'name' | 'console' | 'power zone' - from: string - to: string -} - -/** An adopted node "Also forget adopted nodes" would drop. */ -export interface AdoptedRow { - nodeId: number - name: string - console: string - /** Power zone as shown: the PDU's effective name, or '—'. */ - zone: string -} - -export interface ResetPreview { - rows: ResetRow[] - /** Registry nodes the reset changes; equals `differingCount`. */ - nodeCount: number - adopted: AdoptedRow[] -} - /** What `resetMapping` would change, field by field. Counted from the file like `differingCount`. */ export function resetPreview( defaults: Map, diff --git a/src/shared/node-rules.test.ts b/src/shared/node-rules.test.ts index 2c2deec..9a33e16 100644 --- a/src/shared/node-rules.test.ts +++ b/src/shared/node-rules.test.ts @@ -7,9 +7,8 @@ import { PDU_GROUP_WINDOW_MS, type NodeModelInput } from './node-rules' -import { EMPTY_MAPPING, registryDefaults, type NodeMappingFile } from './node-mapping' -import type { NodeSeen, NodeStatus } from './nodes' -import type { NodeTelemSnapshot } from './node-telem' +import { EMPTY_MAPPING, registryDefaults } from './node-mapping' +import type { NodeMappingFile, NodeSeen, NodeStatus, NodeTelemSnapshot } from './nodes/ipc' import { nodeStatus } from '../test-fixtures/nodes' const defaults = registryDefaults({ diff --git a/src/shared/node-rules.ts b/src/shared/node-rules.ts index 814dddf..5e9ec52 100644 --- a/src/shared/node-rules.ts +++ b/src/shared/node-rules.ts @@ -1,15 +1,22 @@ // Node status rules (pure): combines the roster, telemetry and node mapping into the one picture // the UI shows — availability, new-node detection, PDU-offline grouping and PDU readings. -import { BRIDGE_NODE_ID, type NodeSeen, type NodeStatus } from './nodes' -import { isAdoptableKind, resolveKind, type NodeIdentity } from './node-kind' -import { hasFreshPduPage, type NodeTelemSnapshot } from './node-telem' -import { - differingCount, - effectiveMapping, - type NodeDefaults, - type NodeKind, - type NodeMappingFile -} from './node-mapping' +import { BRIDGE_NODE_ID } from './nodes' +import type { + HiddenNode, + NodeAvailability, + NodeDefaults, + NodeKind, + NodeMappingFile, + NodeModel, + NodeModelEntry, + NodeSeen, + NodeStatus, + NodeTelemSnapshot, + PduOutage +} from './nodes/ipc' +import { isAdoptableKind, resolveKind } from './node-kind' +import { hasFreshPduPage } from './node-telem' +import { differingCount, effectiveMapping } from './node-mapping' /** * Nodes that lose power together stop heartbeating together, so the bridge's 3 s timeout fires @@ -23,95 +30,6 @@ export const PDU_GROUP_WINDOW_MS = 2000 */ export const PDU_GROUP_POLL_CATCH_MS = 7000 -/** 'unknown' = no complete roster yet and the node has not reported — no data, not a state. */ -export type NodeAvailability = 'online' | 'offline' | 'unknown' - -export interface NodeModelEntry { - nodeId: number - /** Effective name; for a new node its NODE_IDS.md name, else `Node `. */ - name: string - /** null for a new node. */ - console: string | null - /** null for a PDU: a PDU is never fed by another one, whatever was stored. */ - pduId: number | null - /** For drawing: 'pdu' once identified as one, else 'panel'. Never stored, never a gate. */ - kind: NodeKind - /** - * What the node is, from the strongest signal (node-kind.ts resolveKind); undefined = not known - * yet. Adopt, the new-device counts and the event log key on this, never on `kind`. - */ - identified?: NodeIdentity - /** - * 'new' = on the bus but not in your cockpit: a NODE_ID the registry does not know, or a registry - * board not added yet (#93: it waits in New on the bus, pre-filled from `registry`). - */ - source: 'registry' | 'adopted' | 'new' - /** - * New registry boards: their NODE_IDS.md defaults, to pre-fill the Adopt form once the board has - * identified itself. Never used to identify it. - */ - registry?: { name: string; console: string; pduId: number | null; pduName?: string } - availability: NodeAvailability - /** While offline this session: when the client learned of it. Absent for `notSeen`. */ - offlineAt?: number - offlineVia?: 'delta' | 'roster' - /** - * A board remembered from an earlier session (#93) that the bridge has not listed since the link - * opened: offline, but not since any time this session knows — `lastSeenAt` says when it was. - */ - notSeen?: boolean - /** When it was last on the bus, from node-mapping.json (any session); absent if never recorded. */ - lastSeenAt?: number - /** Last roster entry, when the node has ever reported. */ - status?: NodeStatus - /** When `status` last arrived while the node was present (this session). */ - reportedAt?: number - /** New nodes: when this session first saw it. Not persisted. */ - firstSeenAt?: number - /** Online PDUs: 'none' = status but no fresh telemetry (never, or > TELEM_STALE_MS). */ - readings?: 'live' | 'none' - /** PDUs: telemetry has arrived at least once since the session started. */ - everHadReadings?: boolean - /** Registry node with overrides. */ - differs: boolean -} - -/** A PDU and most of the nodes it feeds went offline together — one event, not N. */ -export interface PduOutage { - pduId: number - /** The fed nodes that dropped with it. */ - nodeIds: number[] - /** Fed nodes in total (excluding hidden ones and ones not seen this session), for "N of M". */ - fedCount: number - at: number -} - -/** One hidden node, as Settings › Hidden nodes lists it: what the Cockpit called it. */ -export interface HiddenNode { - nodeId: number - /** Effective name: the user's own (renamed or adopted), else the registry's. */ - name: string - /** Effective console; '' for an adopted node placed nowhere. */ - console: string -} - -export interface NodeModel { - nodes: NodeModelEntry[] - outages: PduOutage[] - /** Registry nodes that differ from their defaults (what "Reset all" changes). */ - differingCount: number - /** Hidden until they report again — left out of `nodes`. */ - hidden: number[] - /** The same ids with the name and console the Cockpit showed (the hidden nodes are not in `nodes`). */ - hiddenNodes: HiddenNode[] - /** - * The first complete roster since the session started has arrived. Stays false when PanelBridge - * never ends a roster reply (node status not built into its firmware): registry nodes then stay - * 'unknown', since there is no roster to be missing from. - */ - rosterComplete: boolean -} - export interface NodeModelInput { roster: NodeStatus[] seen: ReadonlyMap diff --git a/src/shared/node-telem.test.ts b/src/shared/node-telem.test.ts index 336a75a..1575777 100644 --- a/src/shared/node-telem.test.ts +++ b/src/shared/node-telem.test.ts @@ -7,9 +7,9 @@ import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, - TELEM_STALE_MS, - type NodeTelem + TELEM_STALE_MS } from './node-telem' +import type { NodeTelem } from './nodes/ipc' /** `_NODE_TELEM` argument: nodeId + the 8 frame bytes, as the bridge forwards them. */ const telem = (nodeId: number, bytes: number[]) => diff --git a/src/shared/node-telem.ts b/src/shared/node-telem.ts index 5460e3b..1755d9c 100644 --- a/src/shared/node-telem.ts +++ b/src/shared/node-telem.ts @@ -14,6 +14,7 @@ import { PDU_STATUS_FLAGS, TELEM_PAGES } from './firmware' +import type { NodeTelem, NodeTelemSnapshot, TelemPageData } from './nodes/ipc' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_TELEM_PROTO = 1 @@ -41,45 +42,6 @@ export const TELEM_STALE_MS = 10_000 // NODE_TELEM_ROW_BYTES (9) as hex, 18 chars: nodeId + the 8 frame bytes in wire order. const HEX_LEN = NODE_TELEM_ROW_BYTES * 2 -/** - * PDU_RAIL_12V / PDU_RAIL_5V. Averages and the minimum cover the window since this page's - * previous PERIODIC send (3 s). A flag-change send goes out at once without restarting that - * window, so the next periodic page still covers the full 3 s. - */ -export interface PduRailPage { - kind: 'rail' - page: typeof TELEM_PAGE.PDU_RAIL_12V | typeof TELEM_PAGE.PDU_RAIL_5V - flags: number // RAIL_FLAG bits - vAvgMv: number - vMinMv: number // window minimum - /** Signed: a small negative value is residual sensor offset. Use displayCurrentMa() to show it. */ - iAvgMa: number -} - -/** PDU_STATUS. */ -export interface PduStatusPage { - kind: 'status' - page: typeof TELEM_PAGE.PDU_STATUS - flags: number // PDU_FLAG bits - /** Board NTC temperature, °C at 0.1 resolution; null = the board reports it unavailable. */ - ntcTempC: number | null - /** 12 V peak current over the same window as the rail averages, after the board's ~100 µs RC filter. */ - i12PeakMa: number - /** 5 V peak current, same window and filter. */ - i5PeakMa: number -} - -/** A page id this build does not know (a newer node type). Kept raw; never decoded. */ -export interface UnknownTelemPage { - kind: 'unknown' - page: number - /** Frame bytes 1–7 as 14 hex chars, wire order. */ - body: string -} - -export type TelemPageData = PduRailPage | PduStatusPage | UnknownTelemPage -export type NodeTelem = { nodeId: number } & TelemPageData - /** True for the page ids only a PDU sends — what marks a node as a PDU. */ export function isPduPage(page: number): boolean { return ( @@ -149,22 +111,6 @@ export function hasFreshPduPage(t: NodeTelemSnapshot): boolean { return [t.rail12V, t.rail5V, t.status].some((p) => p !== undefined && !p.stale) } -/** A page as last received, with its age. A stale page keeps its last values for "last known". */ -export type TelemPageView = T & { ageMs: number; stale: boolean } - -export interface NodeTelemSnapshot { - nodeId: number - /** Has sent at least one PDU page since the store was reset. */ - isPdu: boolean - rail12V?: TelemPageView - rail5V?: TelemPageView - status?: TelemPageView - /** Pages with ids this build does not know, by page id. */ - other: TelemPageView[] - /** At least one page is fresh (≤ TELEM_STALE_MS). */ - live: boolean -} - interface Entry { data: TelemPageData at: number diff --git a/src/shared/nodes.test.ts b/src/shared/nodes.test.ts index 39b6f76..dc32014 100644 --- a/src/shared/nodes.test.ts +++ b/src/shared/nodes.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest' import { + cleanNodeLine, + isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, + nodeFaultTag, NodeRoster, - ROSTER_REPLY_MS, - cleanNodeLine, - isLegacyNodeStatus, - parseNodeStatus, nodeRosterRequest, - nodeFaultTag, - type NodeStatus + parseNodeStatus, + ROSTER_REPLY_MS } from './nodes' +import type { NodeStatus } from './nodes/ipc' import { nodeStatus } from '../test-fixtures/nodes' const node = (over: Partial): NodeStatus => diff --git a/src/shared/nodes.ts b/src/shared/nodes.ts index 91afbac..e7f4e4b 100644 --- a/src/shared/nodes.ts +++ b/src/shared/nodes.ts @@ -15,6 +15,7 @@ import { NODE_STATUS_FIELD, NODE_STATUS_ROW_BYTES } from './firmware' +import type { NodeSeen, NodeStatus } from './nodes/ipc' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 @@ -46,30 +47,6 @@ const HEX_LEN = NODE_STATUS_ROW_BYTES * 2 // firmware no longer describes it, so it has nothing to sync from: it stays a client constant. const V2_HEX_LEN = 26 -export interface NodeStatus { - nodeId: number // 0 = the bridge's own row (v3), 1..MAX_NODE_ID CAN nodes - present: boolean - boff: boolean // CAN bus-off - epvf: boolean // CAN error-passive - uptimeSec: number // wraps ~18 h - rxCount: number // node-accepted CAN RX count - tec: number // transmit error counter (esr low byte) - rec: number // receive error counter (esr high byte) - // HEALTH_n telemetry (proto v2). Uncalibrated internal MCU sensor — die temp, not ambient. - dieTempC: number | null // whole °C; null = not yet seen (DIE_TEMP_UNAVAILABLE, 0x80 on the wire) - overheat: boolean // hFlags bit0 (opt-in firmware trip; usually false) - degraded: boolean // hFlags bit1 — a registered FaultSource reports non-NONE (rendered by #40, parsed here) - faultId: number // fault code, 0 = none; label via NODE_FAULT_CODES (node-status.generated.ts), render #40 - /** NODE_KIND as the board reports it (v3); 0 = not reported yet (or pre-#344 firmware). Kept raw: an - * unknown future value passes through and is never adoptable. */ - nodeKind: number - // Fault-dictionary enrichment — filled in main from NODE_FAULT_CODES; undefined when healthy or an - // unknown/reserved id (the decoder passes any faultId through). Renderer falls back to `Fault N`. - faultAbbr?: string - faultLabel?: string - faultDesc?: string -} - /** Short degraded-badge tag — the fault abbr, or `Fault N` for an unknown/reserved id. */ export function nodeFaultTag(n: NodeStatus): string { return n.faultAbbr ?? `Fault ${n.faultId}` @@ -136,21 +113,6 @@ export function nodeRosterRequest(): Uint8Array { */ export const ROSTER_REPLY_MS = 1000 -/** When a node was first seen and, while it is offline, when and how it went offline. */ -export interface NodeSeen { - firstSeenAt: number - /** When its latest status row arrived while it was present — what "updated N s ago" counts from. */ - reportedAt?: number - /** Set while offline: when the client learned of it. */ - offlineAt?: number - /** - * 'delta' = a live `_NODE_STATUS` pushed on the transition (arrives ~3 s after the node goes - * silent, the bridge's heartbeat timeout). 'roster' = found by a roster reply, so the real - * transition happened up to one poll earlier. - */ - offlineVia?: 'delta' | 'roster' -} - /** * Tracks every node seen since reset. Bare messages are live deltas; a request/boot * burst ends with `_NODE_STATUS_END`, after which nodes absent from the burst are diff --git a/src/shared/nodes/index.ts b/src/shared/nodes/index.ts new file mode 100644 index 0000000..5ae2b38 --- /dev/null +++ b/src/shared/nodes/index.ts @@ -0,0 +1 @@ +export * from './ipc' diff --git a/src/shared/nodes/ipc.ts b/src/shared/nodes/ipc.ts new file mode 100644 index 0000000..c8a18b3 --- /dev/null +++ b/src/shared/nodes/ipc.ts @@ -0,0 +1,361 @@ +// Nodes topic contract (arch-phase0-v2 §2.3): the CAN node roster, PDU telemetry, the node mapping +// and the PSU limits the Cockpit draws against. Imported by main, preload and the renderer. +import type { TELEM_PAGES } from '../firmware' + +export interface NodeStatus { + nodeId: number // 0 = the bridge's own row (v3), 1..MAX_NODE_ID CAN nodes + present: boolean + boff: boolean // CAN bus-off + epvf: boolean // CAN error-passive + uptimeSec: number // wraps ~18 h + rxCount: number // node-accepted CAN RX count + tec: number // transmit error counter (esr low byte) + rec: number // receive error counter (esr high byte) + // HEALTH_n telemetry (proto v2). Uncalibrated internal MCU sensor — die temp, not ambient. + dieTempC: number | null // whole °C; null = not yet seen (DIE_TEMP_UNAVAILABLE, 0x80 on the wire) + overheat: boolean // hFlags bit0 (opt-in firmware trip; usually false) + degraded: boolean // hFlags bit1 — a registered FaultSource reports non-NONE (rendered by #40, parsed here) + faultId: number // fault code, 0 = none; label via NODE_FAULT_CODES (node-status.generated.ts), render #40 + /** NODE_KIND as the board reports it (v3); 0 = not reported yet (or pre-#344 firmware). Kept raw: an + * unknown future value passes through and is never adoptable. */ + nodeKind: number + // Fault-dictionary enrichment — filled in main from NODE_FAULT_CODES; undefined when healthy or an + // unknown/reserved id (the decoder passes any faultId through). Renderer falls back to `Fault N`. + faultAbbr?: string + faultLabel?: string + faultDesc?: string +} + +/** When a node was first seen and, while it is offline, when and how it went offline. */ +export interface NodeSeen { + firstSeenAt: number + /** When its latest status row arrived while it was present — what "updated N s ago" counts from. */ + reportedAt?: number + /** Set while offline: when the client learned of it. */ + offlineAt?: number + /** + * 'delta' = a live `_NODE_STATUS` pushed on the transition (arrives ~3 s after the node goes + * silent, the bridge's heartbeat timeout). 'roster' = found by a roster reply, so the real + * transition happened up to one poll earlier. + */ + offlineVia?: 'delta' | 'roster' +} + +/** + * A node's type once something solid says so. 'bridge' = a SimGateway/PanelBridge board; 'other' = + * a kind this client doesn't know (a newer firmware's). Neither is ever adoptable. + */ +export type NodeIdentity = 'pdu' | 'panel' | 'bridge' | 'other' + +export type NodeKind = 'pdu' | 'panel' + +/** What node-mapping.json holds for one NODE_ID. Every field optional: absent = default. */ +export interface NodeOverride { + name?: string + console?: string + /** Power zone: the PDU NODE_ID feeding this node. null = explicitly none. */ + pduId?: number | null + /** Adopted nodes only — registry nodes take their kind from the registry. */ + kind?: NodeKind + /** Replaced / removed board: skip it until it reports again. */ + hidden?: boolean + /** + * Registry boards only: you added it to your cockpit (New on the bus › Add to cockpit). Reset to + * default keeps it; only Forget removes it. Adopted nodes are in your cockpit by being adopted. + */ + added?: boolean + /** + * When it was last on the bus, ISO 8601: written for a board in your cockpit when it comes + * online, when it drops and when the link closes. + */ + lastSeen?: string +} + +export interface NodeMappingFile { + version: 3 + nodes: Record +} + +/** Effective defaults for one registry node. */ +export interface NodeDefaults { + name: string + console: string + pduId: number | null + kind: NodeKind +} + +/** A node as the client should present it: registry defaults with overrides applied. */ +export interface MappedNode { + nodeId: number + name: string + console: string + pduId: number | null + kind: NodeKind + source: 'registry' | 'adopted' + /** Registry node with at least one stored override (what reset would undo). */ + differs: boolean + hidden: boolean + /** + * In your cockpit: adopted, or a registry board you added. A registry board that was never added + * is only a dictionary entry — when it reports it waits in New on the bus, pre-filled. + */ + known: boolean + lastSeen?: string +} + +export type MappingPatch = Partial> +export interface AdoptSpec { + name: string + console: string + pduId: number | null + kind: NodeKind +} + +export interface ResetOptions { + /** Also drop every adopted node: each is "new" again the next time it reports. */ + forgetAdopted?: boolean +} + +/** One field a reset would change, ready to show as `node · field from → to`. */ +export interface ResetRow { + nodeId: number + /** The node's registry name (what it is called after the reset). */ + node: string + field: 'name' | 'console' | 'power zone' + from: string + to: string +} + +/** An adopted node "Also forget adopted nodes" would drop. */ +export interface AdoptedRow { + nodeId: number + name: string + console: string + /** Power zone as shown: the PDU's effective name, or '—'. */ + zone: string +} + +export interface ResetPreview { + rows: ResetRow[] + /** Registry nodes the reset changes; equals `differingCount`. */ + nodeCount: number + adopted: AdoptedRow[] +} + +/** + * PDU_RAIL_12V / PDU_RAIL_5V. Averages and the minimum cover the window since this page's + * previous PERIODIC send (3 s). A flag-change send goes out at once without restarting that + * window, so the next periodic page still covers the full 3 s. + */ +export interface PduRailPage { + kind: 'rail' + page: typeof TELEM_PAGES.PDU_RAIL_12V | typeof TELEM_PAGES.PDU_RAIL_5V + flags: number // RAIL_FLAG bits + vAvgMv: number + vMinMv: number // window minimum + /** Signed: a small negative value is residual sensor offset. Use displayCurrentMa() to show it. */ + iAvgMa: number +} + +/** PDU_STATUS. */ +export interface PduStatusPage { + kind: 'status' + page: typeof TELEM_PAGES.PDU_STATUS + flags: number // PDU_FLAG bits + /** Board NTC temperature, °C at 0.1 resolution; null = the board reports it unavailable. */ + ntcTempC: number | null + /** 12 V peak current over the same window as the rail averages, after the board's ~100 µs RC filter. */ + i12PeakMa: number + /** 5 V peak current, same window and filter. */ + i5PeakMa: number +} + +/** A page id this build does not know (a newer node type). Kept raw; never decoded. */ +export interface UnknownTelemPage { + kind: 'unknown' + page: number + /** Frame bytes 1–7 as 14 hex chars, wire order. */ + body: string +} + +export type TelemPageData = PduRailPage | PduStatusPage | UnknownTelemPage +export type NodeTelem = { nodeId: number } & TelemPageData + +/** A page as last received, with its age. A stale page keeps its last values for "last known". */ +export type TelemPageView = T & { ageMs: number; stale: boolean } + +export interface NodeTelemSnapshot { + nodeId: number + /** Has sent at least one PDU page since the store was reset. */ + isPdu: boolean + rail12V?: TelemPageView + rail5V?: TelemPageView + status?: TelemPageView + /** Pages with ids this build does not know, by page id. */ + other: TelemPageView[] + /** At least one page is fresh (≤ TELEM_STALE_MS). */ + live: boolean +} + +/** 'unknown' = no complete roster yet and the node has not reported — no data, not a state. */ +export type NodeAvailability = 'online' | 'offline' | 'unknown' + +export interface NodeModelEntry { + nodeId: number + /** Effective name; for a new node its NODE_IDS.md name, else `Node `. */ + name: string + /** null for a new node. */ + console: string | null + /** null for a PDU: a PDU is never fed by another one, whatever was stored. */ + pduId: number | null + /** For drawing: 'pdu' once identified as one, else 'panel'. Never stored, never a gate. */ + kind: NodeKind + /** + * What the node is, from the strongest signal (node-kind.ts resolveKind); undefined = not known + * yet. Adopt, the new-device counts and the event log key on this, never on `kind`. + */ + identified?: NodeIdentity + /** + * 'new' = on the bus but not in your cockpit: a NODE_ID the registry does not know, or a registry + * board not added yet (#93: it waits in New on the bus, pre-filled from `registry`). + */ + source: 'registry' | 'adopted' | 'new' + /** + * New registry boards: their NODE_IDS.md defaults, to pre-fill the Adopt form once the board has + * identified itself. Never used to identify it. + */ + registry?: { name: string; console: string; pduId: number | null; pduName?: string } + availability: NodeAvailability + /** While offline this session: when the client learned of it. Absent for `notSeen`. */ + offlineAt?: number + offlineVia?: 'delta' | 'roster' + /** + * A board remembered from an earlier session (#93) that the bridge has not listed since the link + * opened: offline, but not since any time this session knows — `lastSeenAt` says when it was. + */ + notSeen?: boolean + /** When it was last on the bus, from node-mapping.json (any session); absent if never recorded. */ + lastSeenAt?: number + /** Last roster entry, when the node has ever reported. */ + status?: NodeStatus + /** When `status` last arrived while the node was present (this session). */ + reportedAt?: number + /** New nodes: when this session first saw it. Not persisted. */ + firstSeenAt?: number + /** Online PDUs: 'none' = status but no fresh telemetry (never, or > TELEM_STALE_MS). */ + readings?: 'live' | 'none' + /** PDUs: telemetry has arrived at least once since the session started. */ + everHadReadings?: boolean + /** Registry node with overrides. */ + differs: boolean +} + +/** A PDU and most of the nodes it feeds went offline together — one event, not N. */ +export interface PduOutage { + pduId: number + /** The fed nodes that dropped with it. */ + nodeIds: number[] + /** Fed nodes in total (excluding hidden ones and ones not seen this session), for "N of M". */ + fedCount: number + at: number +} + +/** One hidden node, as Settings › Hidden nodes lists it: what the Cockpit called it. */ +export interface HiddenNode { + nodeId: number + /** Effective name: the user's own (renamed or adopted), else the registry's. */ + name: string + /** Effective console; '' for an adopted node placed nowhere. */ + console: string +} + +export interface NodeModel { + nodes: NodeModelEntry[] + outages: PduOutage[] + /** Registry nodes that differ from their defaults (what "Reset all" changes). */ + differingCount: number + /** Hidden until they report again — left out of `nodes`. */ + hidden: number[] + /** The same ids with the name and console the Cockpit showed (the hidden nodes are not in `nodes`). */ + hiddenNodes: HiddenNode[] + /** + * The first complete roster since the session started has arrived. Stays false when PanelBridge + * never ends a roster reply (node status not built into its firmware): registry nodes then stay + * 'unknown', since there is no roster to be missing from. + */ + rosterComplete: boolean +} + +/** Rail limits as printed on a PSU label. Absent = unknown: the Cockpit shows plain totals. */ +export interface PsuLimits { + /** Combined current of all 12 V outputs, A. */ + a12?: number + /** Combined current of all 5 V outputs, A. */ + a5?: number + /** Rated output, W. */ + w?: number +} + +export type PsuPresetId = 'nx650' + +export interface PsuPreset { + /** Short label for the segmented control. */ + label: string + /** Full name, shown under the limits. */ + name: string + limits: PsuLimits +} + +export interface PsuSetting { + mode: 'preset' | 'custom' + preset: PsuPresetId + /** Kept while a preset is picked, so switching back to Custom restores what was entered. */ + custom: PsuLimits +} + +export const DEFAULT_PSU: PsuSetting = { mode: 'preset', preset: 'nx650', custom: {} } + +export interface NodeRegistryInfo { + /** Path in the firmware repo, e.g. Firmware/NODE_IDS.md. */ + file: string + commit: string + /** The commit's date, YYYY-MM-DD. */ + date: string + nodes: { nodeId: number; name: string; console: string; kind: NodeKind | 'bridge' }[] +} + +export interface NodeSnapshot { + model: NodeModel + telemetry: NodeTelemSnapshot[] +} + +/** A mapping change: the new model, or why the input was refused (nothing was written). */ +export type NodeMappingResult = { ok: true; model: NodeModel } | { ok: false; error: string } + +/** This topic's main -> renderer pushes; shared/app assembles them into `PushChannels`. */ +export interface NodesPushChannels { + /** Latest PDU telemetry per node (#61). At most once a second, and only on change. */ + 'nodes:telemetry': NodeTelemSnapshot[] + /** Roster + telemetry + node mapping combined: what the Cockpit tab shows (#61, UI in #63). */ + 'nodes:model': NodeModel +} + +/** Push channel names (main -> renderer). */ +export const NODES_PUSH = { + nodesTelemetry: 'nodes:telemetry', + nodesModel: 'nodes:model' +} as const + +/** Intent channel names (renderer -> main, invoke/response). */ +export const NODES_CTRL = { + nodesRefresh: 'nodes:refresh', + nodesGet: 'nodes:get', + nodeMappingSet: 'node-mapping:set', + nodeMappingAdopt: 'node-mapping:adopt', + nodeMappingForget: 'node-mapping:forget', + nodeMappingResetNode: 'node-mapping:reset-node', + nodeMappingReset: 'node-mapping:reset', + nodeMappingResetPreview: 'node-mapping:reset-preview', + nodeRegistry: 'nodes:registry', + nodeMappingHide: 'node-mapping:hide' +} as const diff --git a/src/shared/psu.test.ts b/src/shared/psu.test.ts index 7cd6a08..f6203ee 100644 --- a/src/shared/psu.test.ts +++ b/src/shared/psu.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_PSU, effectivePsuLimits, parsePsuLimit, PSU_PRESETS, sanitizePsu } from './psu' +import { effectivePsuLimits, parsePsuLimit, PSU_PRESETS, sanitizePsu } from './psu' +import { DEFAULT_PSU } from './nodes/ipc' describe('sanitizePsu', () => { it('falls back to the default for junk', () => { diff --git a/src/shared/psu.ts b/src/shared/psu.ts index 5bcde6b..ee01d59 100644 --- a/src/shared/psu.ts +++ b/src/shared/psu.ts @@ -1,25 +1,13 @@ // Power supply limits (#67) the Cockpit headroom is drawn against (#63). Pure; persisted in // config.json as AppConfig.psu. Display only — the client never alerts on them. -/** Rail limits as printed on a PSU label. Absent = unknown: the Cockpit shows plain totals. */ -export interface PsuLimits { - /** Combined current of all 12 V outputs, A. */ - a12?: number - /** Combined current of all 5 V outputs, A. */ - a5?: number - /** Rated output, W. */ - w?: number -} - -export type PsuPresetId = 'nx650' - -export interface PsuPreset { - /** Short label for the segmented control. */ - label: string - /** Full name, shown under the limits. */ - name: string - limits: PsuLimits -} +import { + DEFAULT_PSU, + type PsuLimits, + type PsuPreset, + type PsuPresetId, + type PsuSetting +} from './nodes/ipc' /** Supplies bundled with the client. Limits are read-only in Settings. */ export const PSU_PRESETS: Record = { @@ -28,15 +16,6 @@ export const PSU_PRESETS: Record = { nx650: { label: 'NX650', name: 'GOLDEN FIELD NX650', limits: { a12: 54, a5: 20, w: 650 } } } -export interface PsuSetting { - mode: 'preset' | 'custom' - preset: PsuPresetId - /** Kept while a preset is picked, so switching back to Custom restores what was entered. */ - custom: PsuLimits -} - -export const DEFAULT_PSU: PsuSetting = { mode: 'preset', preset: 'nx650', custom: {} } - /** Largest value accepted for any limit; anything above is a typo, not a desktop PSU. */ export const PSU_LIMIT_MAX = 10000 diff --git a/src/shared/settings-schema.ts b/src/shared/settings-schema.ts new file mode 100644 index 0000000..f94eef9 --- /dev/null +++ b/src/shared/settings-schema.ts @@ -0,0 +1,39 @@ +// The settings schema: coerces whatever config.json holds into a valid AppConfig. The shape and its +// defaults are the config contract (shared/config/ipc.ts); this is the rule that reads it back. +import { + DEFAULT_CONFIG, + SOURCE_MODES, + TRANSPORTS, + type AppConfig, + type DcsTransport, + type SourceMode +} from './config/ipc' +import { sanitizePsu } from './psu' + +/** Coerce arbitrary (e.g. persisted/untrusted) data into a valid AppConfig. */ +export function sanitizeConfig(raw: unknown): AppConfig { + const r = (raw && typeof raw === 'object' ? raw : {}) as Partial + const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d) + return { + sourceMode: SOURCE_MODES.includes(r.sourceMode as SourceMode) + ? (r.sourceMode as SourceMode) + : DEFAULT_CONFIG.sourceMode, + transport: TRANSPORTS.includes(r.transport as DcsTransport) + ? (r.transport as DcsTransport) + : DEFAULT_CONFIG.transport, + host: typeof r.host === 'string' && r.host ? r.host : DEFAULT_CONFIG.host, + commandPort: num(r.commandPort, DEFAULT_CONFIG.commandPort), + listenPort: num(r.listenPort, DEFAULT_CONFIG.listenPort), + // The old `autoReconnect` is not carried over: it governed reopen-after-drop for a link that + // only existed while relaying, and connecting by default is the intended behaviour now. + autoConnect: typeof r.autoConnect === 'boolean' ? r.autoConnect : DEFAULT_CONFIG.autoConnect, + replayDriveSerial: + typeof r.replayDriveSerial === 'boolean' + ? r.replayDriveSerial + : DEFAULT_CONFIG.replayDriveSerial, + debugMode: typeof r.debugMode === 'boolean' ? r.debugMode : DEFAULT_CONFIG.debugMode, + attentionBanner: + typeof r.attentionBanner === 'boolean' ? r.attentionBanner : DEFAULT_CONFIG.attentionBanner, + psu: sanitizePsu(r.psu) + } +} diff --git a/src/test-fixtures/config.ts b/src/test-fixtures/config.ts index 9372e7a..e477183 100644 --- a/src/test-fixtures/config.ts +++ b/src/test-fixtures/config.ts @@ -1,6 +1,6 @@ // Shared config fixtures for tests (test-only: no production code may import this folder). -import { appSettings, type AppSettings } from '@shared/config-file' -import { DEFAULT_CONFIG } from '@shared/ipc' +import { appSettings } from '@shared/config-file' +import { DEFAULT_CONFIG, type AppSettings } from '@shared/config/ipc' /** The `app` section of a config export taken from defaults: every setting but the PSU. */ export const DEFAULT_APP_SETTINGS: AppSettings = appSettings(DEFAULT_CONFIG) diff --git a/src/test-fixtures/nodes.ts b/src/test-fixtures/nodes.ts index fb53fc8..782b1d4 100644 --- a/src/test-fixtures/nodes.ts +++ b/src/test-fixtures/nodes.ts @@ -3,8 +3,7 @@ // One set of builders for the node status rows and node models that test files used to redefine // with their own defaults. A test that needs different defaults passes those fields explicitly, // so what each test feeds in stays visible at the call site. -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' +import type { NodeModel, NodeModelEntry, NodeStatus, PduOutage } from '@shared/nodes/ipc' /** A parsed `_NODE_STATUS` row: present, healthy, kind not reported (0), 30 °C, 100 s up. */ export const nodeStatus = (nodeId: number, over: Partial = {}): NodeStatus => ({ From ca57ba63e5ced07dec1cd742a72a6cfafba81e93 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 21:04:45 -0400 Subject: [PATCH 09/28] refactor(format): shared/format helpers, merge identical copies (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.3b of the structure rewrite (#101). Adds the `src/shared/format` leaf and points main, shared and the renderer at it. Every merged copy has one home now. Nothing changes on screen, in wording or in behaviour. - `src/shared/format/{text,time,units,events,psu}.ts`, with `index.ts` as the only import path. Main and the renderer import `@shared/format`. Files inside `src/shared` use `./format`, the same convention as `./firmware`. - The folder imports nothing else in `src`, type-only imports included. `L-format-leaf` stays at error and is clean. - Each module has its own test file (27 tests). The expected values come from the callers' existing tests and from today's rendered output. They pass in UTC, America/Los_Angeles and Asia/Kolkata. - The existing tests' assertions are unchanged. Only their import paths moved (11 files). ## Merged helpers | Helper (`@shared/format`) | Copies merged (base `ef57688`) | How equality was proven | |---|---|---| | `plural` | `renderer/overview.ts`, `renderer/tabs/settings/settings-view.ts`, `shared/config-file.ts` | sweep A | | `onOff` | `shared/config-file.ts` (moved) | sweep A | | `title` | `shared/gateway.ts` (moved) | sweep A (incl. `''`, which throws in both) | | `DASH` | `renderer/cockpit-panels.ts` (+ the `'—'` in `SidePanels.tsx` `pv`) | value identity | | `fileName` | `renderer/log-view.ts` (moved) | sweep A | | `errorText` | `main/node-mapping.ts` `message`, plus the inline copy in `main/config-transfer.ts` | sweep A over Error, its subclasses, strings, numbers, null, undefined, objects, Symbol, BigInt and null-prototype objects | | `pad`, `pad3` | `renderer/store.ts`, `renderer/tabs/Serial.tsx`, `renderer/tabs/settings/settings-view.ts`; `shared/events.ts` `pad(n, w = 2)`: `pad(n)` → `pad`, `pad(n, 3)` → `pad3` (its only callers) | sweep N | | `fmtTime` | `renderer/store.ts`, `renderer/tabs/Serial.tsx` | sweep T | | `hhmmss` | `renderer/connection-summary.ts` `clock`, plus the inline `fmtTime(e.t).slice(0, 8)` in `tabs/cockpit/BoardEvents.tsx` | sweep T | | `hhmm` | `renderer/tabs/Cockpit.tsx` `hhmm`, `renderer/tabs/settings/settings-view.ts` `clock` | sweep T | | `day` | `renderer/tabs/settings/settings-view.ts` (moved) | sweep T | | `fmtDateTime` | `shared/events.ts` (moved) | sweep T | | `lastSeenLabel` | `renderer/cockpit.ts` (moved) | sweep T | | `seenAgo` | `renderer/cockpit-adopt.ts` (moved) | 40k (firstSeenAt, now) pairs incl. `undefined`, NaN and ±Infinity | | `fmtUptime` | `renderer/store.ts` (moved) | sweep N | | `stamp` | `main/config-transfer.ts`, plus the inline `toISOString().replace(…)` in `main/node-mapping.ts` | 5k dates incl. an invalid date (throws `RangeError` in both) | | `volts` (`12.00 V`) | `shared/events.ts` (moved) | sweep N | | `voltsValue` (`12.00`) | `renderer/tabs/Cockpit.tsx` `volts`; the number inside `SidePanels.tsx` `pv` | sweep N | | `amps` | the inline `${(… / 1000).toFixed(2)} A` in `shared/events.ts` `railReading` and in `renderer/cockpit-adopt.ts` `rail` | sweep N through `displayCurrentMa` | | `fmtBytes` | `renderer/store.ts` (moved) | sweep N | | `fmtPct` | `renderer/cockpit.ts` (moved) | sweep N | | `hex2`, `hex4` | `renderer/store.ts` (moved) | sweep N | | `eventText` | `shared/events.ts` (moved). The parameter is now the structural `{ event; detail? }`, because the leaf can't import `LogEvent`. Every caller passes a `LogEvent` | every event × detail combination incl. `''` and `undefined` | | `PSU_LIMITS` (`psu.ts`) | `shared/config-file.ts` `LIMITS` `{key,label,unit}`, `renderer/tabs/settings/CockpitSection.tsx` `LIMITS` `{k,label,unit,custom}` | deep-equal against both base tables | **Equality proof.** This is a throwaway vitest file and is not committed. A script copied every old copy verbatim out of `git show ef57688:` and checked that each snippet appears byte-for-byte in base. The test then compared each old copy with its new home. It checked the return value, or the thrown error's class and message, using `Object.is`, so `NaN` and `-0` count. - **Sweep N** (numbers): NaN, ±Infinity, ±0, ±0.5, the rounding edges `0.005`, `9.95`, `1023.5` and `1024`, the 16-bit edges `65535`, `65536` and `0x1ffff`, negatives, `±1e21`, the safe-integer limits, every integer in −3000…3000 (plus /7 and ×1.0001), and 20k seeded random values spanning 12 orders of magnitude. - **Sweep T** (times): NaN, ±Infinity, the ±8.64e15 Date limits, 40k seeded timestamps within ±13 years of 2026-10-16, and Jan 1 / Dec 31 23:59:59.999 of each year from 1969 to 2040. - **Sweep A** (text): plurals over 8k counts × 9 words × an omitted, irregular or empty `many`; 14 strings for `title`, incl. `''`, `ß` and an emoji; 10 path shapes for `fileName`. The sweeps ran **1,724,217 checks, all equal**. The proof can fail: changing `pad3`'s fill or `DASH` turned it red, and it went green again after reverting. **Render compare.** A throwaway vitest file, not committed, rendered 23 views through the R0.2 harness. The views are: - Overview, at two stats ticks; - Cockpit (live, and link-down); - four node sheets, two PDU sheets, and the Adopt dialog with two selections; - Connection, in two scenarios; - Serial; - Log › Stream and Log › Events; - all five Settings sections, plus the Import preview. They ran on base and on head, after prettier. `diff -r` shows them identical, and the joined HTML has sha1 `e59c2ce8…` on both sides. ## Copies kept separate (they differ) - **`volts` vs `voltsValue`.** `events.ts` prints `12.00 V`. `Cockpit.tsx`'s `volts` and `SidePanels.tsx`'s `pv` print `12.00`, with no unit. - **`hhmm` vs `hhmmss`.** `hhmm` is `21:14` (`Cockpit.tsx` `hhmm`, `settings-view` `clock`). `hhmmss` is `21:14:07` (`connection-summary` `clock`, BoardEvents). - `hhmmss` stays defined as `fmtTime(ms).slice(0, 8)`. Its NaN output is `NaN:NaN:`, so rebuilding it from `pad` would change it. ## Left in place (and why) - **`SEVERITY_GLYPH`, `isProblem`** (`shared/events.ts`) and **`SEVERITY_NAME`** (`tabs/Log.tsx`). They are keyed by the events contract's `EventSeverity`. The leaf can't import it, because depcruise counts type-only imports. - Restating the severity union in the leaf would put a second owner on a contract rule. - §3.6 row 908 also schedules `SEVERITY_NAME` for R3.4. `format/events.ts` holds only `eventText`. - **`rail` text** (`cockpit-adopt.ts`). It needs `displayCurrentMa`, a model rule from `shared/node-telem`, so it stays local. It is now built from the shared `volts` and `amps`, with identical output. - **`pv`** (`SidePanels.tsx`). Its live/undefined gating is view logic, so it stays local. It is now `voltsValue` + `DASH`. - **`main/index.ts:107, :126`**: the inline `${err instanceof Error ? err.message : err}`. This is not merged into `errorText` because it differs on a `Symbol`: a template literal throws a `TypeError`, while `String()` returns `Symbol(x)`. Further copies found and left untouched (different output, or inline in a file this PR doesn't otherwise change): - `Connection.tsx:11` `usbId` is like `hex4` but has no 16-bit mask. - `Connection.tsx:259` has an inline `m[0]!.toUpperCase() + m.slice(1)`. - `Cockpit.tsx:257` has `const dash = '—'`, and there are other inline `'—'` literals. - `cockpit-panels.ts:313-317` has inline `(x / 1000).toFixed(2)`. `reportAge`'s `ago` is there too. - `Cockpit.tsx` has `${a.toFixed(2)} A`, where the input is already in A. - `config-transfer.ts:82` has `toISOString().slice(0, 10)`, which is the UTC date and not the local `day`. ## Grep proof ``` $ git grep -nE "(const|function) (plural|onOff|title|DASH|fileName|message|errorText|pad|pad3|fmtTime|hhmm|hhmmss|clock|day|fmtDateTime|lastSeenLabel|seenAgo|fmtUptime|stamp|volts|amps|fmtBytes|fmtPct|hex2|hex4|eventText|LIMITS)\b" -- src ':!src/shared/format' | grep -v '\.test\.' (no output) $ git grep -nE "err instanceof Error \? err\.message : String\(err\)|toISOString\(\)\.replace\(/\[:\.\]/g|fmtTime\([^)]*\)\.slice\(0, 8\)|padStart\(3, '0'\)" -- src ':!src/shared/format' | grep -v '\.test\.' (no output) ``` ## Checks (local) - `npm run typecheck` is clean. - `npm run lint` has 0 errors. It has 32 warnings, and each changed file has the same count as on base. - `npm run lint:deps` has 0 errors and 23 warnings, the same list as base (P4 17, P6 5, T2 1). - `npx vitest run`: 67 files, 883 tests. That is 62 / 856 plus the 5 new files and 27 new tests. - `npm run build` passes. - `prettier --check src` is clean. Refs #98 --- src/main/config-transfer.ts | 6 +- src/main/node-mapping.ts | 10 +-- src/main/session.seam.test.ts | 2 +- src/main/session.test.ts | 2 +- src/renderer/src/cockpit-adopt.test.ts | 2 +- src/renderer/src/cockpit-adopt.ts | 16 +--- src/renderer/src/cockpit-panels.test.ts | 3 +- src/renderer/src/cockpit-panels.ts | 6 +- src/renderer/src/cockpit.test.ts | 3 +- src/renderer/src/cockpit.ts | 17 +--- src/renderer/src/connection-summary.test.ts | 4 +- src/renderer/src/connection-summary.ts | 7 +- src/renderer/src/log-view.test.ts | 2 +- src/renderer/src/log-view.ts | 8 +- src/renderer/src/overview.ts | 3 +- src/renderer/src/store.ts | 22 +---- src/renderer/src/tabs/Cockpit.tsx | 16 ++-- src/renderer/src/tabs/Log.tsx | 13 +-- src/renderer/src/tabs/Overview.tsx | 3 +- src/renderer/src/tabs/Serial.tsx | 8 +- src/renderer/src/tabs/cockpit/AdoptDialog.tsx | 2 +- src/renderer/src/tabs/cockpit/BoardEvents.tsx | 5 +- src/renderer/src/tabs/cockpit/SidePanels.tsx | 3 +- .../src/tabs/settings/CockpitSection.tsx | 19 +--- .../src/tabs/settings/settings-view.ts | 16 +--- src/shared/config-file.ts | 14 +-- src/shared/events.test.ts | 2 +- src/shared/events.ts | 21 +---- src/shared/format/events.test.ts | 15 ++++ src/shared/format/events.ts | 8 ++ src/shared/format/index.ts | 11 +++ src/shared/format/psu.test.ts | 20 +++++ src/shared/format/psu.ts | 21 +++++ src/shared/format/text.test.ts | 57 ++++++++++++ src/shared/format/text.ts | 21 +++++ src/shared/format/time.test.ts | 86 +++++++++++++++++++ src/shared/format/time.ts | 68 +++++++++++++++ src/shared/format/units.test.ts | 58 +++++++++++++ src/shared/format/units.ts | 27 ++++++ src/shared/gateway.test.ts | 2 +- src/shared/gateway.ts | 4 +- 41 files changed, 451 insertions(+), 182 deletions(-) create mode 100644 src/shared/format/events.test.ts create mode 100644 src/shared/format/events.ts create mode 100644 src/shared/format/index.ts create mode 100644 src/shared/format/psu.test.ts create mode 100644 src/shared/format/psu.ts create mode 100644 src/shared/format/text.test.ts create mode 100644 src/shared/format/text.ts create mode 100644 src/shared/format/time.test.ts create mode 100644 src/shared/format/time.ts create mode 100644 src/shared/format/units.test.ts create mode 100644 src/shared/format/units.ts diff --git a/src/main/config-transfer.ts b/src/main/config-transfer.ts index e27b114..7a17ff3 100644 --- a/src/main/config-transfer.ts +++ b/src/main/config-transfer.ts @@ -26,6 +26,7 @@ import { type ParsedConfig } from '@shared/config-file' import type { CalCache } from '@shared/hid/calibration/ipc' +import { errorText, stamp } from '@shared/format' export interface ConfigTransferDeps { getConfig(): AppConfig @@ -47,8 +48,6 @@ export interface ConfigTransferDeps { now?(): Date } -const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, '-') - export class ConfigTransfer { /** The file last previewed, until it is applied or another is opened. */ private pending?: { token: string; config: ParsedConfig } @@ -142,8 +141,7 @@ export class ConfigTransfer { backupPath = join(dir, `skyhawk-config-${stamp(this.now())}.json`) writeFileSync(backupPath, this.exportText()) } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: `Backup failed, nothing was changed: ${msg}` } + return { ok: false, error: `Backup failed, nothing was changed: ${errorText(err)}` } } } diff --git a/src/main/node-mapping.ts b/src/main/node-mapping.ts index 072f83b..8e67237 100644 --- a/src/main/node-mapping.ts +++ b/src/main/node-mapping.ts @@ -8,6 +8,7 @@ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'nod import { join } from 'node:path' import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from '@shared/node-mapping' import type { NodeMappingFile } from '@shared/nodes/ipc' +import { errorText, stamp } from '@shared/format' /** A save that did not reach the disk. The message is ready to show. */ export class MappingSaveError extends Error {} @@ -23,7 +24,6 @@ function mappingPath(): string { } const empty = (): NodeMappingFile => ({ ...EMPTY_MAPPING, nodes: {} }) -const message = (err: unknown) => (err instanceof Error ? err.message : String(err)) export function loadNodeMapping(): LoadedMapping { const p = mappingPath() @@ -32,7 +32,7 @@ export function loadNodeMapping(): LoadedMapping { if (!existsSync(p)) return { file: empty() } text = readFileSync(p, 'utf8') } catch (err) { - return { file: empty(), readOnly: `node-mapping.json could not be read: ${message(err)}` } + return { file: empty(), readOnly: `node-mapping.json could not be read: ${errorText(err)}` } } let raw: unknown @@ -40,14 +40,14 @@ export function loadNodeMapping(): LoadedMapping { raw = JSON.parse(text) } catch { // Move the damaged file aside, so the next save cannot destroy whatever it still holds. - const bad = `${p}.bad-${new Date().toISOString().replace(/[:.]/g, '-')}` + const bad = `${p}.bad-${stamp(new Date())}` try { renameSync(p, bad) return { file: empty() } } catch (err) { return { file: empty(), - readOnly: `node-mapping.json is damaged and could not be moved aside: ${message(err)}` + readOnly: `node-mapping.json is damaged and could not be moved aside: ${errorText(err)}` } } } @@ -76,6 +76,6 @@ export function saveNodeMapping(file: NodeMappingFile): void { } catch { // the write already failed; a stray .tmp is harmless and overwritten next time } - throw new MappingSaveError(`Could not save node mapping: ${message(err)}`) + throw new MappingSaveError(`Could not save node mapping: ${errorText(err)}`) } } diff --git a/src/main/session.seam.test.ts b/src/main/session.seam.test.ts index 8244037..69679da 100644 --- a/src/main/session.seam.test.ts +++ b/src/main/session.seam.test.ts @@ -3,7 +3,7 @@ // and files out — through the services harness. Nothing here reaches inside main. import { describe, expect, it } from 'vitest' import { CTRL } from '@shared/app/ipc' -import { eventText } from '@shared/events' +import { eventText } from '@shared/format' import type { NodeMappingFile } from '@shared/nodes/ipc' import { statusLine } from '../test-fixtures/nodes' import { makeServices, type Services } from './test-harness/services' diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 59732d7..6bf0c9e 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CAL_TYPE, buildFrame } from '@shared/calibration' import type { LogRow } from '@shared/dcs/ipc' -import { eventText } from '@shared/events' +import { eventText } from '@shared/format' import type { LogEvent } from '@shared/events/ipc' import type { NodeModel } from '@shared/nodes/ipc' import { statusLine } from '../test-fixtures/nodes' diff --git a/src/renderer/src/cockpit-adopt.test.ts b/src/renderer/src/cockpit-adopt.test.ts index 28f7589..3e50020 100644 --- a/src/renderer/src/cockpit-adopt.test.ts +++ b/src/renderer/src/cockpit-adopt.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes/ipc' import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' import { TELEM_PAGE } from '@shared/node-telem' +import { seenAgo } from '@shared/format' import { nodeViews } from './cockpit' import { adoptEvidence, @@ -14,7 +15,6 @@ import { nextSelection, prefill, registrySpecs, - seenAgo, strayFlag, strayNew } from './cockpit-adopt' diff --git a/src/renderer/src/cockpit-adopt.ts b/src/renderer/src/cockpit-adopt.ts index 35a8d74..24910a4 100644 --- a/src/renderer/src/cockpit-adopt.ts +++ b/src/renderer/src/cockpit-adopt.ts @@ -1,13 +1,13 @@ // Cockpit Adopt dialog (#77; since #93 the onboarding for every board not in your cockpit): the // pure decisions behind it — queue order, what each new NODE_ID is seen doing, the registry // pre-fill, when the form is complete, the identify row and where the selection goes next. +import { amps, fmtUptime, volts } from '@shared/format' import type { AdoptSpec, NodeKind, NodeModelEntry } from '@shared/nodes/ipc' import { reportedIdentity } from '@shared/node-kind' import { displayCurrentMa } from '@shared/node-telem' import { nodeFaultTag } from '@shared/nodes' import type { NodeView } from './cockpit' import { consoleLabel, powerZoneOptions } from './cockpit-panels' -import { fmtUptime } from './store' /** * New NODE_IDs, oldest first seen first, then by NODE_ID. Not-yet-seen times sort last. Every new @@ -60,16 +60,6 @@ export function adoptPduChoices( return { zones, pending: name } } -/** "first seen 2 min ago" — this session's first sighting (it is not persisted). */ -export function seenAgo(firstSeenAt: number | undefined, now: number): string { - if (firstSeenAt === undefined) return 'first seen this session' - const s = Math.max(0, Math.round((now - firstSeenAt) / 1000)) - if (s < 60) return 'first seen just now' - const m = Math.floor(s / 60) - if (m < 60) return `first seen ${m} min ago` - return `first seen ${Math.floor(m / 60)} h ago` -} - export interface Adoptability { /** Set only when it can be adopted: what it identified as. */ kind?: NodeKind @@ -134,9 +124,7 @@ function detectedAs(e: NodeModelEntry): string { } const rail = (label: string, p?: { vAvgMv: number; iAvgMa: number }) => - p - ? `${label} ${(p.vAvgMv / 1000).toFixed(2)} V · ${(displayCurrentMa(p.iAvgMa) / 1000).toFixed(2)} A` - : '' + p ? `${label} ${volts(p.vAvgMv)} · ${amps(displayCurrentMa(p.iAvgMa))}` : '' /** What the dialog can say about a new NODE_ID: only what it has reported. */ export function adoptEvidence(v: NodeView): Evidence { diff --git a/src/renderer/src/cockpit-panels.test.ts b/src/renderer/src/cockpit-panels.test.ts index 723e05f..dc87bd2 100644 --- a/src/renderer/src/cockpit-panels.test.ts +++ b/src/renderer/src/cockpit-panels.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes/ipc' import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from '@shared/node-telem' -import { lastSeenLabel, nodeViews } from './cockpit' +import { lastSeenLabel } from '@shared/format' +import { nodeViews } from './cockpit' import { boardActions, CONSOLE_OPTIONS, diff --git a/src/renderer/src/cockpit-panels.ts b/src/renderer/src/cockpit-panels.ts index 324492b..08b7f4b 100644 --- a/src/renderer/src/cockpit-panels.ts +++ b/src/renderer/src/cockpit-panels.ts @@ -1,13 +1,11 @@ // Cockpit side panels (#76, one design for every board since #93): the pure decisions behind the // panel group and PDU panels — which actions a board offers, what its fault box says, the rows each panel shows and the mapping patch Save // sends. Every value is something a board reported or the client computed, labelled as such. +import { DASH, fmtUptime, lastSeenLabel } from '@shared/format' import type { MappingPatch, NodeModelEntry, PduRailPage } from '@shared/nodes/ipc' import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/node-telem' import { nodeFaultTag } from '@shared/nodes' -import { lastSeenLabel, pduAlertFlags, type NodeView } from './cockpit' -import { fmtUptime } from './store' - -const DASH = '—' +import { pduAlertFlags, type NodeView } from './cockpit' // ── board actions ─────────────────────────────────────────────────────────── diff --git a/src/renderer/src/cockpit.test.ts b/src/renderer/src/cockpit.test.ts index b1954e0..23ef3f0 100644 --- a/src/renderer/src/cockpit.test.ts +++ b/src/renderer/src/cockpit.test.ts @@ -6,6 +6,7 @@ import { nodeStatus as status } from '../../test-fixtures/nodes' import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from '@shared/node-telem' +import { fmtPct, lastSeenLabel } from '@shared/format' import { STATE_SYMBOL, affected, @@ -13,10 +14,8 @@ import { canErrorsSub, canErrorsWorst, consoleKey, - fmtPct, headroom, healthBand, - lastSeenLabel, newCount, nodeHealth, nodeViews, diff --git a/src/renderer/src/cockpit.ts b/src/renderer/src/cockpit.ts index c83e36e..a8edb17 100644 --- a/src/renderer/src/cockpit.ts +++ b/src/renderer/src/cockpit.ts @@ -1,6 +1,7 @@ // Cockpit tab (#63): the pure decisions behind the health band, the plan and Attention. Everything // here is computed from what main pushes (`nodes:model`, `nodes:telemetry`); the client applies no // thresholds — a warning is always a flag a board raised itself. +import { lastSeenLabel } from '@shared/format' import { isReportableNew } from '@shared/node-rules' import type { NodeModel, @@ -74,16 +75,6 @@ export interface NodeHealth { flag?: string } -/** "14 Oct, 21:14": when a remembered board was last on the bus (#93). */ -export function lastSeenLabel(t: number): string { - return new Date(t).toLocaleString('en-GB', { - day: 'numeric', - month: 'short', - hour: '2-digit', - minute: '2-digit' - }) -} - /** Offline wins, then bus-off, then any flag the node (or, for a PDU, its pages) raised. */ export function nodeHealth(e: NodeModelEntry, r: PduReadings = {}): NodeHealth { if (e.source === 'new') return { state: 'new', flag: 'NOT IN REGISTRY' } @@ -578,12 +569,6 @@ export function headroom(value: number | null, limit: number | undefined): Headr return { limit, pct, bar: pct === null ? 0 : Math.min(100, Math.max(0, pct)) } } -/** `4.6%` under 10 %, `53%` from there. */ -export function fmtPct(pct: number): string { - const one = Number(pct.toFixed(1)) - return one < 10 ? `${one.toFixed(1)}%` : `${Math.round(pct)}%` -} - /** What the plan's PSU box is called: the preset's model, or plain `PSU` for a custom supply. */ export function psuName(psu: PsuSetting): string { return psu.mode === 'preset' ? PSU_PRESETS[psu.preset].label : 'PSU' diff --git a/src/renderer/src/connection-summary.test.ts b/src/renderer/src/connection-summary.test.ts index 991a67c..4a071e1 100644 --- a/src/renderer/src/connection-summary.test.ts +++ b/src/renderer/src/connection-summary.test.ts @@ -6,9 +6,9 @@ import { nodeStatus as status } from '../../test-fixtures/nodes' import { TELEM_PAGE } from '@shared/node-telem' -import { STATE_SYMBOL, attentionItems, lastSeenLabel, nodeViews } from './cockpit' +import { fmtTime, lastSeenLabel } from '@shared/format' +import { STATE_SYMBOL, attentionItems, nodeViews } from './cockpit' import { nodesSummary } from './connection-summary' -import { fmtTime } from './store' const pdu = (nodeId: number, console: string, over: Partial = {}) => node(nodeId, { name: `PDU_${console}`, console, kind: 'pdu', readings: 'live', ...over }) diff --git a/src/renderer/src/connection-summary.ts b/src/renderer/src/connection-summary.ts index d776458..0040de6 100644 --- a/src/renderer/src/connection-summary.ts +++ b/src/renderer/src/connection-summary.ts @@ -1,17 +1,16 @@ // Connection › Connected nodes (#66): the pure decisions behind the summary card. Counts and the // most severe nodes come from the same helpers as the Cockpit health band and Attention, so the // two tabs always agree; the full picture lives in the Cockpit tab. +import { hhmmss, lastSeenLabel } from '@shared/format' import type { PduOutage } from '@shared/nodes/ipc' import { affected, ATTENTION_SHOWN, attentionItems, healthBand, - lastSeenLabel, type NodeState, type NodeView } from './cockpit' -import { fmtTime } from './store' export interface SummaryRow { nodeId: number @@ -48,8 +47,6 @@ export interface NodesSummary { footer: string } -const clock = (ms: number) => fmtTime(ms).slice(0, 8) - export function nodesSummary(views: NodeView[], outages: PduOutage[]): NodesSummary { const band = healthBand(views) const known = views.filter((v) => v.entry.source !== 'new') @@ -69,7 +66,7 @@ export function nodesSummary(views: NodeView[], outages: PduOutage[]): NodesSumm meta.push('not seen this session') if (e.lastSeenAt !== undefined) meta.push(`last seen ${lastSeenLabel(e.lastSeenAt)}`) } else if (e.availability === 'offline' && e.offlineAt !== undefined) { - meta.push(`offline since ${clock(e.offlineAt)}`) + meta.push(`offline since ${hhmmss(e.offlineAt)}`) } return { nodeId: i.nodeId, diff --git a/src/renderer/src/log-view.test.ts b/src/renderer/src/log-view.test.ts index b084a17..38234c9 100644 --- a/src/renderer/src/log-view.test.ts +++ b/src/renderer/src/log-view.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import type { LogEvent } from '@shared/events/ipc' +import { fileName } from '@shared/format' import { boardEventLine, eventSourceLabel, - fileName, filterEvents, mergeEvents, restoreEvents, diff --git a/src/renderer/src/log-view.ts b/src/renderer/src/log-view.ts index 3e0dd08..a1eee25 100644 --- a/src/renderer/src/log-view.ts +++ b/src/renderer/src/log-view.ts @@ -1,5 +1,6 @@ // Log tab › Events (#64): what the view shows of the session's events (pure, no React). -import { eventText, isProblem } from '@shared/events' +import { isProblem } from '@shared/events' +import { eventText } from '@shared/format' import { MAX_EVENTS, type EventSource, type LogEvent } from '@shared/events/ipc' export type LogView = 'stream' | 'events' @@ -74,8 +75,3 @@ export function rowsThatFit(height: number, rowHeight: number, max: number): num if (rowHeight <= 0) return max return Math.min(max, Math.max(1, Math.floor(height / rowHeight))) } - -/** `skyhawk-events-….tsv` from a full save path, for the footer's "Saved" note. */ -export function fileName(path: string): string { - return path.split(/[\\/]/).pop() ?? path -} diff --git a/src/renderer/src/overview.ts b/src/renderer/src/overview.ts index a4d8254..4936765 100644 --- a/src/renderer/src/overview.ts +++ b/src/renderer/src/overview.ts @@ -1,6 +1,7 @@ // Overview (#65): the pure decisions behind the Cockpit tile, the Attention banner and the // new-devices notice. All of it is read off the Cockpit's own picture (`healthBand`, // `attentionItems`); the client adds no thresholds of its own. +import { plural } from '@shared/format' import { isAdoptableKind } from '@shared/node-kind' import { isReportableNew } from '@shared/node-rules' import type { NodeModelEntry } from '@shared/nodes/ipc' @@ -17,8 +18,6 @@ export interface CockpitTile { tone: 'ok' | 'issue' | 'none' } -const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` - /** `37 / 40` nodes up, total W the client computes and the PDUs on the plan. */ export function cockpitTile(band: HealthBand, live: boolean): CockpitTile { if (!live) return { value: '—', sub: 'no live node status', tone: 'none' } diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 4601395..16d7b62 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -6,6 +6,7 @@ import { type SerialFrame } from '@shared/gateway/ipc' import { HID_CONTROLS_BY_ID, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' +import { fmtTime, hex2, hex4 } from '@shared/format' import type { AircraftStatus, LogRow as IpcLogRow, @@ -56,16 +57,6 @@ const TRANSPORT_TO_IPC: Record = { uni: 'unicast-listen' } -const pad = (n: number) => String(n).padStart(2, '0') -const pad3 = (n: number) => String(n).padStart(3, '0') -const hex2 = (n: number) => (n & 0xff).toString(16).toUpperCase().padStart(2, '0') -const hex4 = (n: number) => '0x' + (n & 0xffff).toString(16).toUpperCase().padStart(4, '0') - -export function fmtTime(ms: number): string { - const d = new Date(ms) - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad3(d.getMilliseconds())}` -} - function rawHex(addr: number, value: number | string): string { const v = typeof value === 'number' ? Math.abs(value) & 0xffff : 0 return `${hex2(addr >> 8)} ${hex2(addr)} ${hex2(v)} ${hex2(v >> 8)}` @@ -550,15 +541,4 @@ export const useStore = create((set, get) => ({ })) // ── pure helpers ───────────────────────────────────────────────────────────── -export function fmtBytes(n: number): string { - return n >= 1024 ? (n / 1024).toFixed(1) + ' KB/s' : Math.round(n) + ' B/s' -} - -export function fmtUptime(s: number): string { - const h = Math.floor(s / 3600) - const m = Math.floor((s % 3600) / 60) - const sec = s % 60 - return `${pad(h)}:${pad(m)}:${pad(sec)}` -} - export const HAT_DIRS = ['CTR', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] diff --git a/src/renderer/src/tabs/Cockpit.tsx b/src/renderer/src/tabs/Cockpit.tsx index e1990f1..870e273 100644 --- a/src/renderer/src/tabs/Cockpit.tsx +++ b/src/renderer/src/tabs/Cockpit.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { isLinkUp } from '@shared/gateway/ipc' import type { NodeModel, PsuLimits } from '@shared/nodes/ipc' import { effectivePsuLimits } from '@shared/psu' +import { fmtPct, hhmm, voltsValue } from '@shared/format' import { useStore } from '../store' import { strayFlag, strayNew } from '../cockpit-adopt' import { gatewayView, type GatewayView } from '../gateway-view' @@ -12,7 +13,6 @@ import { attentionItems, canErrorsSub, canErrorsWorst, - fmtPct, headroom, healthBand, newCount, @@ -34,12 +34,6 @@ import { NodePanel, PduPanel } from './cockpit/SidePanels' const RING_C = 289.03 // 2π × 46, the ring's circumference -const hhmm = (ms: number) => { - const d = new Date(ms) - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` -} -const volts = (mv: number) => (mv / 1000).toFixed(2) - export function Cockpit() { const model = useStore((s) => s.nodeModel) const telemetry = useStore((s) => s.nodeTelemetry) @@ -500,9 +494,13 @@ function Group(props: { const rails = ( <> 12 V{' '} - {!dash && r12 ? volts(r12.vAvgMv) : '—'} + + {!dash && r12 ? voltsValue(r12.vAvgMv) : '—'} + {g.wide ? ' · ' : ' '}5 V{' '} - {!dash && r5 ? volts(r5.vAvgMv) : '—'} + + {!dash && r5 ? voltsValue(r5.vAvgMv) : '—'} + ) const flags = off diff --git a/src/renderer/src/tabs/Log.tsx b/src/renderer/src/tabs/Log.tsx index b3c8f45..b453ad0 100644 --- a/src/renderer/src/tabs/Log.tsx +++ b/src/renderer/src/tabs/Log.tsx @@ -1,14 +1,9 @@ import { useEffect, useState } from 'react' -import { eventText, SEVERITY_GLYPH } from '@shared/events' +import { SEVERITY_GLYPH } from '@shared/events' import type { EventSeverity } from '@shared/events/ipc' -import { fmtTime, useStore, type DirFilter } from '../store' -import { - eventSourceLabel, - fileName, - filterEvents, - type EventSourceFilter, - type LogView -} from '../log-view' +import { eventText, fileName, fmtTime } from '@shared/format' +import { useStore, type DirFilter } from '../store' +import { eventSourceLabel, filterEvents, type EventSourceFilter, type LogView } from '../log-view' const SEVERITY_COLOR: Record = { error: 'var(--red)', diff --git a/src/renderer/src/tabs/Overview.tsx b/src/renderer/src/tabs/Overview.tsx index 4a9f602..e30e915 100644 --- a/src/renderer/src/tabs/Overview.tsx +++ b/src/renderer/src/tabs/Overview.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import { useStore, fmtBytes, fmtUptime, type TabId } from '../store' +import { fmtBytes, fmtUptime } from '@shared/format' +import { useStore, type TabId } from '../store' import { isLinkUp, type DeviceState } from '@shared/gateway/ipc' import { attentionItems, healthBand, nodeViews } from '../cockpit' import { attentionSummary, cockpitTile, newDevices, newDevicesText } from '../overview' diff --git a/src/renderer/src/tabs/Serial.tsx b/src/renderer/src/tabs/Serial.tsx index ebc66b5..4589126 100644 --- a/src/renderer/src/tabs/Serial.tsx +++ b/src/renderer/src/tabs/Serial.tsx @@ -1,12 +1,6 @@ +import { fmtTime } from '@shared/format' import { useStore } from '../store' -const pad = (n: number) => String(n).padStart(2, '0') -const pad3 = (n: number) => String(n).padStart(3, '0') -function fmtTime(ms: number): string { - const d = new Date(ms) - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad3(d.getMilliseconds())}` -} - const MAX_BYTES = 48 // truncate very long chunks in the display function render(hex: string): { hex: string; ascii: string; more: number } { diff --git a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx b/src/renderer/src/tabs/cockpit/AdoptDialog.tsx index 4573133..517ceff 100644 --- a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx +++ b/src/renderer/src/tabs/cockpit/AdoptDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { seenAgo } from '@shared/format' import type { NodeMappingResult } from '@shared/nodes/ipc' import type { NodeView } from '../../cockpit' import { @@ -12,7 +13,6 @@ import { nextSelection, prefill, registrySpecs, - seenAgo, type AdoptForm as FormValues } from '../../cockpit-adopt' import { CONSOLE_OPTIONS } from '../../cockpit-panels' diff --git a/src/renderer/src/tabs/cockpit/BoardEvents.tsx b/src/renderer/src/tabs/cockpit/BoardEvents.tsx index 61cba42..5a8dec4 100644 --- a/src/renderer/src/tabs/cockpit/BoardEvents.tsx +++ b/src/renderer/src/tabs/cockpit/BoardEvents.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { fmtTime, useStore } from '../../store' +import { hhmmss } from '@shared/format' +import { useStore } from '../../store' import { boardEventLine, rowsThatFit } from '../../log-view' /** Most rows the box renders before it has measured how many fit. */ @@ -43,7 +44,7 @@ export function BoardEvents() { const line = boardEventLine(e) return (
  • - + {line}
  • ) diff --git a/src/renderer/src/tabs/cockpit/SidePanels.tsx b/src/renderer/src/tabs/cockpit/SidePanels.tsx index ea4592c..1236bb0 100644 --- a/src/renderer/src/tabs/cockpit/SidePanels.tsx +++ b/src/renderer/src/tabs/cockpit/SidePanels.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' +import { DASH, voltsValue } from '@shared/format' import type { NodeMappingResult, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes/ipc' import type { NodeView } from '../../cockpit' import { @@ -308,7 +309,7 @@ function BoardFooter(props: { ) } -const pv = (live: boolean, mv?: number) => (mv !== undefined && live ? (mv / 1000).toFixed(2) : '—') +const pv = (live: boolean, mv?: number) => (mv !== undefined && live ? voltsValue(mv) : DASH) /** The clock, once a second while `on` — for "updated N s ago". */ function useNow(on: boolean): number { diff --git a/src/renderer/src/tabs/settings/CockpitSection.tsx b/src/renderer/src/tabs/settings/CockpitSection.tsx index 223fceb..54ce103 100644 --- a/src/renderer/src/tabs/settings/CockpitSection.tsx +++ b/src/renderer/src/tabs/settings/CockpitSection.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { PSU_LIMITS } from '@shared/format' import { parsePsuLimit, PSU_PRESETS } from '@shared/psu' import type { PsuLimits } from '@shared/nodes/ipc' import { useStore } from '../../store' @@ -9,22 +10,6 @@ import { useNodeModel } from './useNodeModel' type LimitKey = keyof PsuLimits -const LIMITS: { k: LimitKey; label: string; unit: string; custom: string }[] = [ - { - k: 'a12', - label: '12 V rail limit', - unit: 'A', - custom: 'Combined current of all 12 V outputs (PSU label)' - }, - { - k: 'a5', - label: '5 V rail limit', - unit: 'A', - custom: 'Combined current of all 5 V outputs (PSU label)' - }, - { k: 'w', label: 'Total power', unit: 'W', custom: 'Rated output (PSU label)' } -] - const text = (v: number | undefined) => (v === undefined ? '' : String(v)) function PowerSupply() { @@ -74,7 +59,7 @@ function PowerSupply() {
    - {LIMITS.map(({ k, label, unit, custom }) => { + {PSU_LIMITS.map(({ key: k, label, unit, hint: custom }) => { const bad = !isPreset && parsePsuLimit(typed[k]) === null return ( diff --git a/src/renderer/src/tabs/settings/settings-view.ts b/src/renderer/src/tabs/settings/settings-view.ts index a3dcefd..02adc06 100644 --- a/src/renderer/src/tabs/settings/settings-view.ts +++ b/src/renderer/src/tabs/settings/settings-view.ts @@ -1,21 +1,9 @@ // Settings text decisions (#67), pure so they are unit-tested: what each Cockpit / About row says. import { GATEWAY_NODE_ID } from '@shared/firmware' +import { day, hhmm, plural } from '@shared/format' import type { HiddenNode, NodeModel, NodeRegistryInfo } from '@shared/nodes/ipc' import { consoleLabel } from '../../cockpit-panels' -const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` - -const pad = (n: number) => String(n).padStart(2, '0') -/** Local YYYY-MM-DD. */ -const day = (t: number) => { - const d = new Date(t) - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` -} -const clock = (t: number) => { - const d = new Date(t) - return `${pad(d.getHours())}:${pad(d.getMinutes())}` -} - /** "Reset node mapping" row description. */ export function resetRowDesc(differing: number): string { return differing === 0 @@ -97,7 +85,7 @@ export function importSubtitle(p: { }): string { const parts = [p.fileName] const t = p.exportedAt ? Date.parse(p.exportedAt) : NaN - if (!Number.isNaN(t)) parts.push(`exported ${day(t)} ${clock(t)}`) + if (!Number.isNaN(t)) parts.push(`exported ${day(t)} ${hhmm(t)}`) if (p.client) parts.push(`client v${p.client}`) return parts.join(' · ') } diff --git a/src/shared/config-file.ts b/src/shared/config-file.ts index 8b0611f..676f59a 100644 --- a/src/shared/config-file.ts +++ b/src/shared/config-file.ts @@ -23,8 +23,9 @@ import { import { sanitizeCache } from './cal-cache' import type { CachedAxis, CalCache } from './hid/calibration/ipc' import { effectiveMapping, importMapping, sanitizeNodeMapping } from './node-mapping' -import type { MappedNode, NodeDefaults, NodeMappingFile, PsuLimits, PsuSetting } from './nodes/ipc' +import type { MappedNode, NodeDefaults, NodeMappingFile, PsuSetting } from './nodes/ipc' import { PSU_PRESETS, sanitizePsu } from './psu' +import { onOff, plural, PSU_LIMITS } from './format' /** File format this build writes. A higher version came from a newer client. */ export const CONFIG_FILE_VERSION = 1 @@ -116,9 +117,6 @@ export function parseConfigFile(text: string): ParseResult { // ── review: what applying a section would change ───────────────────────────── -const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` -const onOff = (b: boolean) => (b ? 'On' : 'Off') - const APP_FIELDS: { key: keyof AppSettings; label: string; show?: (v: never) => string }[] = [ { key: 'sourceMode', label: 'Source mode' }, { key: 'transport', label: 'DCS transport' }, @@ -141,19 +139,13 @@ function diffApp(cur: AppSettings, next: AppSettings): DiffRow[] { return rows } -const LIMITS: { key: keyof PsuLimits; label: string; unit: string }[] = [ - { key: 'a12', label: '12 V rail limit', unit: 'A' }, - { key: 'a5', label: '5 V rail limit', unit: 'A' }, - { key: 'w', label: 'Total power', unit: 'W' } -] - function diffPsu(cur: PsuSetting, next: PsuSetting): DiffRow[] { const name = (p: PsuSetting) => p.mode === 'preset' ? `${PSU_PRESETS[p.preset].label} preset` : 'Custom' const rows: DiffRow[] = [] if (name(cur) !== name(next)) rows.push({ k: 'PSU', from: name(cur), to: name(next) }) // Custom limits are kept while a preset is picked, so they are compared either way. - for (const l of LIMITS) { + for (const l of PSU_LIMITS) { const a = cur.custom[l.key] const b = next.custom[l.key] if (a === b) continue diff --git a/src/shared/events.test.ts b/src/shared/events.test.ts index 7b986f2..395319e 100644 --- a/src/shared/events.test.ts +++ b/src/shared/events.test.ts @@ -5,13 +5,13 @@ import { EventLog, EVENTS_TSV_HEADER, eventsToTsv, - eventText, isProblem, NodeEventTracker, relayEvents, type NodeLookup } from './events' import type { LogEventInput } from './events/ipc' +import { eventText } from './format' import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS } from './nodes' import type { NodeStatus, NodeTelem } from './nodes/ipc' import { PDU_FLAG, RAIL_FLAG } from './node-telem' diff --git a/src/shared/events.ts b/src/shared/events.ts index 4b5fabb..d4feb50 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -10,6 +10,7 @@ import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './nodes' import type { NodeStatus, NodeTelem, PduRailPage } from './nodes/ipc' import { displayCurrentMa, PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from './node-telem' import { MAX_EVENTS, type EventSeverity, type LogEvent, type LogEventInput } from './events/ipc' +import { amps, fmtDateTime, volts } from './format' export const SEVERITY_GLYPH: Record = { error: '✕', @@ -18,11 +19,6 @@ export const SEVERITY_GLYPH: Record = { info: '●' } -/** `event · detail`, as the Events view and the Cockpit box print it. */ -export function eventText(e: Pick): string { - return e.detail ? `${e.event} · ${e.detail}` : e.event -} - /** What "Problems only" keeps: something is wrong — not a new node, a cleared flag or a link up. */ export function isProblem(e: Pick): boolean { return e.severity === 'error' || e.severity === 'warn' @@ -72,12 +68,10 @@ const PDU_STATUS_FLAGS: [number, string, EventSeverity][] = [ const TEMP_FLAGS = PDU_FLAG.TEMP_CRIT | PDU_FLAG.TEMP_WARN -const volts = (mv: number): string => `${(mv / 1000).toFixed(2)} V` - /** The reading that goes with a raised rail flag: the window minimum for under-voltage. */ function railReading(bit: number, p: PduRailPage): string { if (bit === RAIL_FLAG.UNDER_V) return volts(p.vMinMv) - if (bit === RAIL_FLAG.OVER_CURRENT) return `${(displayCurrentMa(p.iAvgMa) / 1000).toFixed(2)} A` + if (bit === RAIL_FLAG.OVER_CURRENT) return amps(displayCurrentMa(p.iAvgMa)) return volts(p.vAvgMv) } @@ -405,17 +399,6 @@ export class EventLog { // ── export ────────────────────────────────────────────────────────────────── -const pad = (n: number, w = 2): string => String(n).padStart(w, '0') - -/** Local time, `2026-10-16 21:14:07.412`. */ -export function fmtDateTime(t: number): string { - const d = new Date(t) - return ( - `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + - `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` - ) -} - /** A tab or line break inside a field would shift every column after it. */ const cell = (s: string | number | undefined): string => s === undefined ? '' : String(s).replace(/[\t\r\n]+/g, ' ') diff --git a/src/shared/format/events.test.ts b/src/shared/format/events.test.ts new file mode 100644 index 0000000..0ea2060 --- /dev/null +++ b/src/shared/format/events.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { eventText } from './events' + +describe('eventText', () => { + it('`event · detail`, or the event alone', () => { + expect(eventText({ event: '5V UNDER-V raised', detail: '4.71 V' })).toBe( + '5V UNDER-V raised · 4.71 V' + ) + expect(eventText({ event: 'link error', detail: 'connect ECONNREFUSED' })).toBe( + 'link error · connect ECONNREFUSED' + ) + expect(eventText({ event: 'offline' })).toBe('offline') + expect(eventText({ event: 'offline', detail: '' })).toBe('offline') + }) +}) diff --git a/src/shared/format/events.ts b/src/shared/format/events.ts new file mode 100644 index 0000000..57ad8f4 --- /dev/null +++ b/src/shared/format/events.ts @@ -0,0 +1,8 @@ +// Event-log text. Only pure formatting lives here: the severity tables and `isProblem` are keyed +// by the events contract's EventSeverity, which this leaf cannot import, so they stay in +// shared/events.ts. + +/** `event · detail`, as the Events view and the Cockpit box print it. */ +export function eventText(e: { event: string; detail?: string }): string { + return e.detail ? `${e.event} · ${e.detail}` : e.event +} diff --git a/src/shared/format/index.ts b/src/shared/format/index.ts new file mode 100644 index 0000000..1fc0a4f --- /dev/null +++ b/src/shared/format/index.ts @@ -0,0 +1,11 @@ +// Format helpers main, shared and the renderer print with: one copy of each. This barrel is the +// only import path: import from '@shared/format' (or './format' inside src/shared), never from a +// module file. +// +// A leaf: nothing in this folder imports anything else in src (L-format-leaf), so a helper that +// needs a contract type, a model rule or a firmware table stays with that owner. +export * from './events' +export * from './psu' +export * from './text' +export * from './time' +export * from './units' diff --git a/src/shared/format/psu.test.ts b/src/shared/format/psu.test.ts new file mode 100644 index 0000000..b0877e6 --- /dev/null +++ b/src/shared/format/psu.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { PSU_LIMITS } from './psu' + +describe('PSU_LIMITS', () => { + it('names the three custom limits, in form order', () => { + expect(PSU_LIMITS.map(({ key, label, unit }) => `${key}: ${label} (${unit})`)).toEqual([ + 'a12: 12 V rail limit (A)', + 'a5: 5 V rail limit (A)', + 'w: Total power (W)' + ]) + }) + + it('carries the Settings hint for each', () => { + expect(PSU_LIMITS.map((l) => l.hint)).toEqual([ + 'Combined current of all 12 V outputs (PSU label)', + 'Combined current of all 5 V outputs (PSU label)', + 'Rated output (PSU label)' + ]) + }) +}) diff --git a/src/shared/format/psu.ts b/src/shared/format/psu.ts new file mode 100644 index 0000000..1661827 --- /dev/null +++ b/src/shared/format/psu.ts @@ -0,0 +1,21 @@ +// The PSU limit labels: the Settings › Cockpit form and the config-import review name the three +// custom limits the same way. + +/** A PSU limit field. The same keys as the nodes contract's PsuLimits, which this leaf can't import. */ +export type PsuLimitKey = 'a12' | 'a5' | 'w' + +export const PSU_LIMITS: { key: PsuLimitKey; label: string; unit: string; hint: string }[] = [ + { + key: 'a12', + label: '12 V rail limit', + unit: 'A', + hint: 'Combined current of all 12 V outputs (PSU label)' + }, + { + key: 'a5', + label: '5 V rail limit', + unit: 'A', + hint: 'Combined current of all 5 V outputs (PSU label)' + }, + { key: 'w', label: 'Total power', unit: 'W', hint: 'Rated output (PSU label)' } +] diff --git a/src/shared/format/text.test.ts b/src/shared/format/text.test.ts new file mode 100644 index 0000000..ef03e07 --- /dev/null +++ b/src/shared/format/text.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { DASH, errorText, fileName, onOff, plural, title } from './text' + +describe('plural', () => { + it('one for 1, the plural otherwise', () => { + expect(plural(1, 'node')).toBe('1 node') + expect(plural(3, 'node')).toBe('3 nodes') + expect(plural(0, 'change')).toBe('0 changes') + expect(plural(1, 'adopted node')).toBe('1 adopted node') + expect(plural(3, 'new device')).toBe('3 new devices') + expect(plural(2, 'PDU')).toBe('2 PDUs') + }) + + it('takes an irregular plural', () => { + expect(plural(1, 'child', 'children')).toBe('1 child') + expect(plural(2, 'child', 'children')).toBe('2 children') + }) +}) + +describe('onOff', () => { + it('reads a boolean setting as On / Off', () => { + expect(onOff(true)).toBe('On') + expect(onOff(false)).toBe('Off') + }) +}) + +describe('title', () => { + it('upper-cases the first letter only', () => { + expect(title('bus-off')).toBe('Bus-off') + expect(title('error-passive')).toBe('Error-passive') + expect(title('Overheat')).toBe('Overheat') + }) +}) + +describe('DASH', () => { + it('is an em dash', () => { + expect(DASH).toBe('—') + }) +}) + +describe('fileName', () => { + it('keeps the last part of a POSIX or Windows path', () => { + expect(fileName('/Users/x/skyhawk-events-1.tsv')).toBe('skyhawk-events-1.tsv') + expect(fileName('C:\\x\\skyhawk-log-2.tsv')).toBe('skyhawk-log-2.tsv') + expect(fileName('plain.json')).toBe('plain.json') + }) +}) + +describe('errorText', () => { + it("an Error's message; anything else as a string", () => { + expect(errorText(new Error('EACCES: permission denied'))).toBe('EACCES: permission denied') + expect(errorText(new TypeError('bad'))).toBe('bad') + expect(errorText('disk full')).toBe('disk full') + expect(errorText(42)).toBe('42') + expect(errorText(undefined)).toBe('undefined') + }) +}) diff --git a/src/shared/format/text.ts b/src/shared/format/text.ts new file mode 100644 index 0000000..5c1f2c2 --- /dev/null +++ b/src/shared/format/text.ts @@ -0,0 +1,21 @@ +// Text helpers: counts, switches, titles, the empty-value dash, file names and error messages. + +/** `1 node`, `3 nodes`; `many` for an irregular plural. */ +export const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` + +/** A boolean setting as the review prints it. */ +export const onOff = (b: boolean) => (b ? 'On' : 'Off') + +/** `bus-off` → `Bus-off`: the first letter upper-cased. */ +export const title = (s: string): string => s[0].toUpperCase() + s.slice(1) + +/** What a value with nothing to show reads as. */ +export const DASH = '—' + +/** `skyhawk-events-….tsv` from a full save path, for the footer's "Saved" note. */ +export function fileName(path: string): string { + return path.split(/[\\/]/).pop() ?? path +} + +/** A caught error's message; anything else thrown, as a string. */ +export const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err)) diff --git a/src/shared/format/time.test.ts b/src/shared/format/time.test.ts new file mode 100644 index 0000000..4a272a3 --- /dev/null +++ b/src/shared/format/time.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + day, + fmtDateTime, + fmtTime, + fmtUptime, + hhmm, + hhmmss, + lastSeenLabel, + pad, + pad3, + seenAgo, + stamp +} from './time' + +/** 16 Oct 2026, 21:14:07.412 local time. */ +const T = new Date(2026, 9, 16, 21, 14, 7, 412).getTime() +/** 2 Mar 2026, 09:05:03.007 local time: every field needs its padding. */ +const EARLY = new Date(2026, 2, 2, 9, 5, 3, 7).getTime() + +describe('pad / pad3', () => { + it('pads to two and three digits', () => { + expect(pad(7)).toBe('07') + expect(pad(42)).toBe('42') + expect(pad(123)).toBe('123') + expect(pad3(7)).toBe('007') + expect(pad3(412)).toBe('412') + }) +}) + +describe('clock times', () => { + it('fmtTime: HH:MM:SS.mmm', () => { + expect(fmtTime(T)).toBe('21:14:07.412') + expect(fmtTime(EARLY)).toBe('09:05:03.007') + }) + + it('hhmmss: fmtTime without the milliseconds', () => { + expect(hhmmss(T)).toBe('21:14:07') + expect(hhmmss(EARLY)).toBe('09:05:03') + }) + + it('hhmm: HH:MM', () => { + expect(hhmm(T)).toBe('21:14') + expect(hhmm(EARLY)).toBe('09:05') + }) +}) + +describe('dates', () => { + it('day: YYYY-MM-DD', () => { + expect(day(T)).toBe('2026-10-16') + expect(day(EARLY)).toBe('2026-03-02') + }) + + it('fmtDateTime: day and fmtTime', () => { + expect(fmtDateTime(T)).toBe('2026-10-16 21:14:07.412') + expect(fmtDateTime(EARLY)).toBe('2026-03-02 09:05:03.007') + }) + + it('lastSeenLabel: "16 Oct, 21:14"', () => { + expect(lastSeenLabel(T)).toBe('16 Oct, 21:14') + expect(lastSeenLabel(EARLY)).toBe('2 Mar, 09:05') + }) + + it('stamp: the UTC ISO time, safe for a file name', () => { + expect(stamp(new Date(Date.UTC(2026, 9, 16, 10, 0, 0)))).toBe('2026-10-16T10-00-00-000Z') + expect(stamp(new Date(Date.UTC(2026, 9, 16, 21, 14, 7, 412)))).toBe('2026-10-16T21-14-07-412Z') + }) +}) + +describe('seenAgo', () => { + it('just now, minutes, then hours', () => { + expect(seenAgo(0, 30_000)).toBe('first seen just now') + expect(seenAgo(0, 2 * 60_000 + 5_000)).toBe('first seen 2 min ago') + expect(seenAgo(0, 3 * 3_600_000)).toBe('first seen 3 h ago') + expect(seenAgo(undefined, 0)).toBe('first seen this session') + }) +}) + +describe('fmtUptime', () => { + it('seconds as HH:MM:SS', () => { + expect(fmtUptime(130)).toBe('00:02:10') + expect(fmtUptime(3725)).toBe('01:02:05') + expect(fmtUptime(59)).toBe('00:00:59') + expect(fmtUptime(360_000)).toBe('100:00:00') + }) +}) diff --git a/src/shared/format/time.ts b/src/shared/format/time.ts new file mode 100644 index 0000000..4151f44 --- /dev/null +++ b/src/shared/format/time.ts @@ -0,0 +1,68 @@ +// Time helpers: clock times, dates, ages and durations. Local time unless the name says otherwise. + +/** Two digits: `7` → `07`. */ +export const pad = (n: number) => String(n).padStart(2, '0') + +/** Three digits: `7` → `007`. */ +export const pad3 = (n: number) => String(n).padStart(3, '0') + +/** `21:14:07.412`. */ +export function fmtTime(ms: number): string { + const d = new Date(ms) + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad3(d.getMilliseconds())}` +} + +/** `21:14:07`: fmtTime without the milliseconds. */ +export const hhmmss = (ms: number) => fmtTime(ms).slice(0, 8) + +/** `21:14`. */ +export const hhmm = (ms: number) => { + const d = new Date(ms) + return `${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +/** `2026-10-16`. */ +export const day = (t: number) => { + const d = new Date(t) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** `2026-10-16 21:14:07.412`. */ +export function fmtDateTime(t: number): string { + const d = new Date(t) + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad3(d.getMilliseconds())}` + ) +} + +/** "14 Oct, 21:14": when a remembered board was last on the bus (#93). */ +export function lastSeenLabel(t: number): string { + return new Date(t).toLocaleString('en-GB', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }) +} + +/** "first seen 2 min ago" — this session's first sighting (it is not persisted). */ +export function seenAgo(firstSeenAt: number | undefined, now: number): string { + if (firstSeenAt === undefined) return 'first seen this session' + const s = Math.max(0, Math.round((now - firstSeenAt) / 1000)) + if (s < 60) return 'first seen just now' + const m = Math.floor(s / 60) + if (m < 60) return `first seen ${m} min ago` + return `first seen ${Math.floor(m / 60)} h ago` +} + +/** Seconds as `HH:MM:SS`; hours keep counting past 99. */ +export function fmtUptime(s: number): string { + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = s % 60 + return `${pad(h)}:${pad(m)}:${pad(sec)}` +} + +/** UTC ISO time made safe for a file name: `2026-10-16T10-00-00-000Z`. */ +export const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, '-') diff --git a/src/shared/format/units.test.ts b/src/shared/format/units.test.ts new file mode 100644 index 0000000..b1840ab --- /dev/null +++ b/src/shared/format/units.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { amps, fmtBytes, fmtPct, hex2, hex4, volts, voltsValue } from './units' + +describe('volts / amps', () => { + it('volts: millivolts as "12.00 V"', () => { + expect(volts(4710)).toBe('4.71 V') + expect(volts(4900)).toBe('4.90 V') + expect(volts(12050)).toBe('12.05 V') + }) + + it('voltsValue: the same number, no unit', () => { + expect(voltsValue(12000)).toBe('12.00') + expect(voltsValue(5020)).toBe('5.02') + }) + + it('amps: milliamps as "1.50 A"', () => { + expect(amps(1500)).toBe('1.50 A') + expect(amps(20)).toBe('0.02 A') + expect(amps(0)).toBe('0.00 A') + }) +}) + +describe('fmtBytes', () => { + it('B/s below 1 KB, KB/s with one decimal from there', () => { + expect(fmtBytes(1000)).toBe('1000 B/s') + expect(fmtBytes(512.6)).toBe('513 B/s') + expect(fmtBytes(1023.9)).toBe('1024 B/s') + expect(fmtBytes(1024)).toBe('1.0 KB/s') + expect(fmtBytes(2048.4)).toBe('2.0 KB/s') + }) +}) + +describe('fmtPct', () => { + it('shows one decimal under 10 % and whole numbers from there', () => { + expect(fmtPct(0)).toBe('0.0%') + expect(fmtPct(4.59)).toBe('4.6%') + expect(fmtPct(9.94)).toBe('9.9%') + expect(fmtPct(9.96)).toBe('10%') + expect(fmtPct(53.2)).toBe('53%') + expect(fmtPct(112)).toBe('112%') + }) +}) + +describe('hex2 / hex4', () => { + it('hex2: the low byte, two upper-case digits', () => { + expect(hex2(0x12)).toBe('12') + expect(hex2(0xab)).toBe('AB') + expect(hex2(0x1234)).toBe('34') + expect(hex2(5)).toBe('05') + }) + + it('hex4: the low 16 bits as 0x + four upper-case digits', () => { + expect(hex4(0x1234)).toBe('0x1234') + expect(hex4(0xabcd)).toBe('0xABCD') + expect(hex4(0xff)).toBe('0x00FF') + expect(hex4(0x12345)).toBe('0x2345') + }) +}) diff --git a/src/shared/format/units.ts b/src/shared/format/units.ts new file mode 100644 index 0000000..8bb7f1f --- /dev/null +++ b/src/shared/format/units.ts @@ -0,0 +1,27 @@ +// Unit helpers: volts and amps from milli-units, byte rates, percentages and hex. + +/** Millivolts as `12.00 V`. */ +export const volts = (mv: number): string => `${(mv / 1000).toFixed(2)} V` + +/** Millivolts as `12.00`, no unit: where the label or column already says V. */ +export const voltsValue = (mv: number) => (mv / 1000).toFixed(2) + +/** Milliamps as `1.25 A`. */ +export const amps = (ma: number): string => `${(ma / 1000).toFixed(2)} A` + +/** A byte rate: `512 B/s` below 1 KB, `2.0 KB/s` from there. */ +export function fmtBytes(n: number): string { + return n >= 1024 ? (n / 1024).toFixed(1) + ' KB/s' : Math.round(n) + ' B/s' +} + +/** `4.6%` under 10 %, `53%` from there. */ +export function fmtPct(pct: number): string { + const one = Number(pct.toFixed(1)) + return one < 10 ? `${one.toFixed(1)}%` : `${Math.round(pct)}%` +} + +/** The low byte as two upper-case hex digits: `0x1a2b` → `2B`. */ +export const hex2 = (n: number) => (n & 0xff).toString(16).toUpperCase().padStart(2, '0') + +/** The low 16 bits as `0x` + four upper-case hex digits: `0x1A2B`. */ +export const hex4 = (n: number) => '0x' + (n & 0xffff).toString(16).toUpperCase().padStart(4, '0') diff --git a/src/shared/gateway.test.ts b/src/shared/gateway.test.ts index d9ae71b..b0a4f08 100644 --- a/src/shared/gateway.test.ts +++ b/src/shared/gateway.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { GATEWAY_NO_ANSWER_MS, GatewayWatch, gatewayEvents, gatewayStatus } from './gateway' -import { eventText } from './events' +import { eventText } from './format' import type { LogEventInput } from './events/ipc' import type { NodeStatus } from './nodes/ipc' import { nodeStatus } from '../test-fixtures/nodes' diff --git a/src/shared/gateway.ts b/src/shared/gateway.ts index 4fc23c4..f44b6da 100644 --- a/src/shared/gateway.ts +++ b/src/shared/gateway.ts @@ -11,6 +11,7 @@ import { faultLabel, STATUS_FLAGS, type StatusFlag } from './events' import type { LogEventInput } from './events/ipc' import { NODE_FAULT_CODES, type NodeFaultName } from './firmware' +import { title } from './format' import type { NodeStatus } from './nodes/ipc' import type { GatewayKind, GatewayStatus } from './gateway/ipc' @@ -22,9 +23,6 @@ const SECS = GATEWAY_NO_ANSWER_MS / 1000 type Flag = (typeof STATUS_FLAGS)[number] const flag = (key: StatusFlag): Flag => STATUS_FLAGS.find((f) => f.key === key)! -/** `bus-off` → `Bus-off`: the node rows' flag names, as a title. */ -const title = (s: string): string => s[0].toUpperCase() + s.slice(1) - /** * Client wording for the faults a Gateway reports about itself, keyed by NODE_FAULT_CODES name. * Others show their label alone: the dictionary's descriptions read like firmware notes. From 111e907a3f3c6028be74637f890d912ce7eb5ad5 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 21:25:46 -0400 Subject: [PATCH 10/28] refactor(main): topic folders for main's own files, injected paths and log (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.4a of the structure rewrite (#101, phase #98). This PR only moves files. Nothing changes for the user: the IPC channels, the files written under ``, and the startup and shutdown order are all the same. ## What changes - Main's own files move into `src/main//` folders. Each topic gets an `index.ts` that exports only what other topics use. - `src/main/index.ts` is now a one-line `import './app'`. The old entry moved whole to `app/index.ts`. - `biosconfig.ts` and its test are deleted (UD-E). Nothing imported them. - **P6:** only `main/app` imports electron now. Paths and the debug log are handed in as ports. - `.dependency-cruiser.severity.json`: `crossProcess` (P1/P2) and `electron` (P6) are now `error`. DeviceLink and Session stay whole until R2.2 and R2.3. The `src/shared` models are untouched (R1.4b). No exported symbol was renamed. ## Old path → new path | Old | New | |---|---| | `index.ts` | `app/index.ts` (the entry `index.ts` is now `import './app'`) | | `session.ts` (+ `.test`, `.seam.test`) | `app/session.ts` | | `debug.ts` | `app/debug-log.ts` | | `user-data.ts` (+ test) | `app/user-data.ts` | | `serial.ts` (+ test) | `gateway/serial-bridge.ts` | | `device-link.ts` | `gateway/device-link.ts` | | `net.ts` | `dcs/transports.ts` | | `decode.ts` (+ test) | `dcs/sim-telemetry-decoder.ts` | | `replay.ts` (+ test) | `dcs/replay-source.ts` | | `stats.ts` | `dcs/stream-stats.ts` | | `reference/dcsbios-metadata.ts` | `dcs/dcsbios-metadata.ts` | | `node-hub.ts` (+ test, seam test) | `nodes/node-hub.ts` | | `phase0-probes.test.ts` | `nodes/phase0-probes.test.ts` (it probes NodeHub) | | `node-mapping.ts` (+ test, seam test) | `nodes/mapping-store.ts` | | `hid.ts` (+ test) | `hid/hid-reader.ts` | | `callink.ts` (+ test) | `hid/calibration/cal-link.ts` | | `cal-cache.ts` | `hid/calibration/cache-store.ts` | | `settings.ts` | `config/settings-service.ts` | | `config-transfer.ts` (+ test, seam test) | `config/config-transfer.ts` | | `biosconfig.ts` (+ test) | deleted | All of these are `git mv`. Over the whole diff, git detects every one as a rename except `app/index.ts`. The entry file stays at `src/main/index.ts`, so `app/index.ts` shows as a copy of it, and `src/main/index.ts` as rewritten. The first commit (c836b36) is renames only. ## Topic `index.ts` exports | Topic | Exports | |---|---| | `gateway` | `DeviceLink`, `DeviceLinkDeps`, `listSerialPorts` | | `dcs` | `createTransport`, `Transport`, `Decoder`, `Recorder`, `ReplaySource`, `ReplayInfo`, `Stats` | | `nodes` | `NodeHub`, `withFaultInfo` | | `hid` | `HidReader` | | `hid/calibration` | `CalLink`, `CalNackError`, `CalTimeoutError`, `loadCalCache`, `saveCalCache` | | `config` | `loadConfig`, `saveConfig`, `ConfigTransfer` | | `app` | the entry itself (its side effects, unchanged) | ## Ports added (P6) Each port is declared by the module that uses it. `app` provides the implementation. | Port | Shape | Declared in | Provided by | |---|---|---|---| | `MappingPaths` | `{ nodeMappingFile(): string }` | `nodes/mapping-store.ts` | `app/user-data.ts` `storePaths()`, via Session → DeviceLink → NodeHub | | `CachePaths` | `{ calibrationCacheFile(): string }` | `hid/calibration/cache-store.ts` | `storePaths()`, via Session → DeviceLink, and `app/index.ts` for ConfigTransfer | | `SettingsPaths` | `{ configFile(): string }` | `config/settings-service.ts` | `storePaths()` in `app/index.ts` | | `Log` | `(tag: string, data?: unknown) => void` | `gateway/serial-bridge.ts`, `hid/calibration/cal-link.ts`, `nodes/node-hub.ts` (one each: one shared declaration would create an import cycle or a cross-topic import) | `app/debug-log.ts` `debugLog`, via Session → DeviceLink | | `DeviceLinkDeps` | `{ paths: { nodeMappingFile(); calibrationCacheFile() }, log: Log }` | `gateway/device-link.ts` | `app/session.ts` | | `NodeHubDeps` | `{ paths?: MappingPaths, log?: Log }` | `nodes/node-hub.ts` | DeviceLink (passes its own deps) | - **`storePaths(userData: () => string)`** returns getters. `app/index.ts` calls it after the userData override, as `storePaths(() => app.getPath('userData'))`. Each path is still worked out at every use, the way `join(app.getPath('userData'), name)` was. So the timing is the same as before. - **`app/debug-log.ts` is unchanged.** It is in `app`, so it may keep using electron for `debug.log`. - **Optional parameters.** The new constructor arguments on `SerialBridge`, `CalLink` and `NodeHub` are optional and trailing: `log` defaults to a no-op, and NodeHub's `paths` defaults to one that throws on first use. Existing unit tests build these classes with their old arguments. - DeviceLink's deps are required, and production always passes everything. - `Session(emit, paths?)` defaults to the same `storePaths(() => app.getPath('userData'))`, for tests that build a Session on its own. Session is in `app`, so it may import electron. ## lint:deps, per rule | Rule | Before (ca57ba6) | After | |---|---|---| | P4-renderer-imports-contracts-only | 17 warn | 17 warn | | P6-electron-only-in-app | 5 warn | **0** (now `error`) | | P1 / P2 | 0 | 0 (now `error`) | | T2-renderer-no-deep-topic-import | 1 warn | 1 warn | | topic-main-gateway | n/a (no folders) | 3 warn | | T2-main-no-deep-topic-import | n/a | **0** | | **Total** | 23 warn, 0 error | 21 warn, 0 error | The only new main warnings are the expected ones, all due to DeviceLink spanning topics until R2.2: - `gateway/device-link.ts → nodes/index.ts` (NodeHub, withFaultInfo) - `gateway/device-link.ts → hid/index.ts` (HidReader) - `gateway/device-link.ts → hid/calibration/index.ts` (CalLink, cache) The old `→ app` edges (`debugLog` from gateway, nodes and hid/calibration) are gone: Log is injected now. `hid/hid-reader.ts` used to import `SIMGATEWAY_VID/PID` from `serial`. Those two constants are just `USB_IDENTITY.vid/pid`, so it now reads `USB_IDENTITY` from `@shared/firmware` directly. The values are identical, and hid has no import from gateway at all. `serial-bridge` still exports `SIMGATEWAY_VID/PID` as before. ## Proof the paths are unchanged 1. **B13** (`app/session.seam.test.ts`, through `makeServices()`) passes unchanged. The harness now builds its paths with `storePaths(() => world.userData)` and passes them exactly as `app/index.ts` does. B13 writes `node-mapping.json` and `config.json` through those paths and reads them back by name. 2. **B17** (`nodes/mapping-store.seam.test.ts`) and `mapping-store.test.ts` hand the store `storePaths(() => userData.dir)`. They then check the file at `join(userData.dir, 'node-mapping.json')`, and all their assertions are unchanged. 3. **New `storePaths` unit tests** (`app/user-data.test.ts`, +2) check: - the three names: `config.json`, `calibration-cache.json`, `node-mapping.json`; - that `` is worked out at each use. 4. **Built-bundle boot probe.** `out/main/index.js` was built from ca57ba6 and from this branch, and each was loaded in node with electron, serialport and node-hid stubbed. Each run went through `whenReady`, `config:set`, `debug:dump-ports`, `node-mapping:set` and `will-quit`. The two outputs are **byte-identical**, with both `SKYHAWK_USER_DATA` set and the dev default (`/.userdata`). They match on: - the `setPath('userData', …)` call; - the order of the `app.on` registrations; - the preload and renderer paths; - all 40 IPC channels; - which files appear in ``, and what they contain. ## Tests - 881 tests in 66 files. The old count was 883 in 67 files; the difference is the 4 `biosconfig` tests removed and the 2 `storePaths` tests added. - The tests changed only import paths and `vi.mock` paths, with these exceptions: - **`mapping-store.test.ts` and `mapping-store.seam.test.ts`.** The store no longer reads electron, so their `vi.mock('electron', …getPath…)` line became a `storePaths(() => userData.dir)` handed to the store, through a two-line local `loadNodeMapping`/`saveNodeMapping` wrapper. Every call and assertion line is unchanged. - **The `makeServices()` harness** passes `paths` to `Session`, `loadConfig` and `saveConfig`, the way `app/index.ts` does. - **Two comments** in tests that named old paths were updated. - The other `vi.mock('electron')` lines are left as they were; in modules that no longer import electron they now do nothing. ## Checks - `npm run typecheck`: passes. - `npm run lint`: 0 errors, 32 warnings, the same P5 renderer warnings as on base. - `npm run lint:deps`: 0 errors. - `npx vitest run`: 881 passed. - `npm run build`: passes. Refs #98 --- .dependency-cruiser.severity.json | 4 +- scripts/sync-a4ec.ts | 2 +- src/main/{debug.ts => app/debug-log.ts} | 0 src/main/app/index.ts | 297 ++++++++++++++++++ src/main/{ => app}/session.seam.test.ts | 4 +- src/main/{ => app}/session.test.ts | 20 +- src/main/{ => app}/session.ts | 32 +- src/main/{ => app}/user-data.test.ts | 20 +- src/main/{ => app}/user-data.ts | 15 + src/main/biosconfig.test.ts | 36 --- src/main/biosconfig.ts | 54 ---- .../{ => config}/config-transfer.seam.test.ts | 0 src/main/{ => config}/config-transfer.test.ts | 0 src/main/{ => config}/config-transfer.ts | 0 src/main/config/index.ts | 4 + .../settings-service.ts} | 16 +- .../{reference => dcs}/dcsbios-metadata.ts | 0 src/main/dcs/index.ts | 5 + .../replay-source.test.ts} | 2 +- src/main/{replay.ts => dcs/replay-source.ts} | 0 .../sim-telemetry-decoder.test.ts} | 2 +- .../sim-telemetry-decoder.ts} | 2 +- src/main/{stats.ts => dcs/stream-stats.ts} | 0 src/main/{net.ts => dcs/transports.ts} | 0 src/main/{ => gateway}/device-link.ts | 69 ++-- src/main/gateway/index.ts | 3 + .../serial-bridge.test.ts} | 4 +- .../{serial.ts => gateway/serial-bridge.ts} | 19 +- .../calibration/cache-store.ts} | 17 +- .../calibration/cal-link.test.ts} | 2 +- .../calibration/cal-link.ts} | 13 +- src/main/hid/calibration/index.ts | 4 + .../{hid.test.ts => hid/hid-reader.test.ts} | 2 +- src/main/{hid.ts => hid/hid-reader.ts} | 4 +- src/main/hid/index.ts | 2 + src/main/index.ts | 297 +----------------- src/main/nodes/index.ts | 3 + .../mapping-store.seam.test.ts} | 9 +- .../mapping-store.test.ts} | 9 +- .../mapping-store.ts} | 16 +- src/main/{ => nodes}/node-hub.seam.test.ts | 4 +- src/main/{ => nodes}/node-hub.test.ts | 2 +- src/main/{ => nodes}/node-hub.ts | 40 ++- src/main/{ => nodes}/phase0-probes.test.ts | 4 +- src/main/test-harness/services.ts | 36 ++- src/renderer/src/phase0-probes.test.ts | 2 +- src/shared/firmware/reference.test.ts | 4 +- src/shared/node-mapping.ts | 2 +- 48 files changed, 568 insertions(+), 514 deletions(-) rename src/main/{debug.ts => app/debug-log.ts} (100%) create mode 100644 src/main/app/index.ts rename src/main/{ => app}/session.seam.test.ts (97%) rename src/main/{ => app}/session.test.ts (98%) rename src/main/{ => app}/session.ts (94%) rename src/main/{ => app}/user-data.test.ts (62%) rename src/main/{ => app}/user-data.ts (57%) delete mode 100644 src/main/biosconfig.test.ts delete mode 100644 src/main/biosconfig.ts rename src/main/{ => config}/config-transfer.seam.test.ts (100%) rename src/main/{ => config}/config-transfer.test.ts (100%) rename src/main/{ => config}/config-transfer.ts (100%) create mode 100644 src/main/config/index.ts rename src/main/{settings.ts => config/settings-service.ts} (53%) rename src/main/{reference => dcs}/dcsbios-metadata.ts (100%) create mode 100644 src/main/dcs/index.ts rename src/main/{replay.test.ts => dcs/replay-source.test.ts} (96%) rename src/main/{replay.ts => dcs/replay-source.ts} (100%) rename src/main/{decode.test.ts => dcs/sim-telemetry-decoder.test.ts} (95%) rename src/main/{decode.ts => dcs/sim-telemetry-decoder.ts} (98%) rename src/main/{stats.ts => dcs/stream-stats.ts} (100%) rename src/main/{net.ts => dcs/transports.ts} (100%) rename src/main/{ => gateway}/device-link.ts (94%) create mode 100644 src/main/gateway/index.ts rename src/main/{serial.test.ts => gateway/serial-bridge.test.ts} (97%) rename src/main/{serial.ts => gateway/serial-bridge.ts} (94%) rename src/main/{cal-cache.ts => hid/calibration/cache-store.ts} (60%) rename src/main/{callink.test.ts => hid/calibration/cal-link.test.ts} (99%) rename src/main/{callink.ts => hid/calibration/cal-link.ts} (96%) create mode 100644 src/main/hid/calibration/index.ts rename src/main/{hid.test.ts => hid/hid-reader.test.ts} (97%) rename src/main/{hid.ts => hid/hid-reader.ts} (98%) create mode 100644 src/main/hid/index.ts create mode 100644 src/main/nodes/index.ts rename src/main/{node-mapping.seam.test.ts => nodes/mapping-store.seam.test.ts} (88%) rename src/main/{node-mapping.test.ts => nodes/mapping-store.test.ts} (89%) rename src/main/{node-mapping.ts => nodes/mapping-store.ts} (84%) rename src/main/{ => nodes}/node-hub.seam.test.ts (98%) rename src/main/{ => nodes}/node-hub.test.ts (99%) rename src/main/{ => nodes}/node-hub.ts (92%) rename src/main/{ => nodes}/phase0-probes.test.ts (98%) diff --git a/.dependency-cruiser.severity.json b/.dependency-cruiser.severity.json index f4f562e..dd347ea 100644 --- a/.dependency-cruiser.severity.json +++ b/.dependency-cruiser.severity.json @@ -1,7 +1,7 @@ { "process": "error", - "crossProcess": "warn", - "electron": "warn", + "crossProcess": "error", + "electron": "error", "rendererShared": "warn", "contracts": "error", "cycles": "error", diff --git a/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index 824f30c..3094171 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -14,7 +14,7 @@ * Firmware/NODE_IDS.md -> node-names.generated.ts * * The DCS-BIOS common-metadata address (_ACFT_NAME @ 0x0000) is NOT vendored in - * the firmware repo, so it lives in the hand-curated src/main/reference/dcsbios-metadata.ts. + * the firmware repo, so it lives in the hand-curated src/main/dcs/dcsbios-metadata.ts. * * Source resolution: * 1. --local | OPENSKYHAWK_REPO_LOCAL= read files from a local checkout (no fetch) diff --git a/src/main/debug.ts b/src/main/app/debug-log.ts similarity index 100% rename from src/main/debug.ts rename to src/main/app/debug-log.ts diff --git a/src/main/app/index.ts b/src/main/app/index.ts new file mode 100644 index 0000000..f246ebe --- /dev/null +++ b/src/main/app/index.ts @@ -0,0 +1,297 @@ +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { CTRL, type PushChannel, type PushChannels } from '@shared/app/ipc' +import type { AppConfig, ConfigExportResult, ConfigImportOpen } from '@shared/config/ipc' +import type { CalCacheEntry, CalCommitAxis } from '@shared/hid/calibration/ipc' +import type { CaptureState, ReplayLoad } from '@shared/dcs/ipc' +import type { HidAvailability } from '@shared/hid/ipc' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes/ipc' +import { eventsToTsv } from '@shared/events' +import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' +import { ConfigTransfer, loadConfig, saveConfig } from '../config' +import { listSerialPorts } from '../gateway' +import { loadCalCache, saveCalCache } from '../hid/calibration' +import { Session } from './session' +import { debugLog, debugLogPath, setDebugEnabled } from './debug-log' +import { storePaths, userDataOverride } from './user-data' + +// Before anything touches userData: dev worktrees get their own folder (#81). +const userDataDir = userDataOverride({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + env: process.env, + cwd: process.cwd() +}) +if (userDataDir) { + mkdirSync(userDataDir, { recursive: true }) // setPath throws on a missing folder + app.setPath('userData', userDataDir) +} + +/** The files, handed to the topics that own them (P6: only app asks electron). */ +const paths = storePaths(() => app.getPath('userData')) + +/** Report indices the firmware catalogues, derived from HIDControls.h. */ +function hidAvailability(): HidAvailability { + const axes: number[] = [] + const hats: number[] = [] + const buttons: number[] = [] + for (const c of HID_CONTROLS) { + if ( + c.id >= HID_ID.AXIS_MIN && + c.id <= HID_ID.AXIS_MAX && + c.id - HID_ID.AXIS_MIN < HID_REPORT_LAYOUT.axes.count + ) { + axes.push(c.id - HID_ID.AXIS_MIN) + } else if ( + c.id >= HID_ID.HAT_MIN && + c.id <= HID_ID.HAT_MAX && + c.id - HID_ID.HAT_MIN < HID_REPORT_LAYOUT.hats.count + ) { + hats.push(c.id - HID_ID.HAT_MIN) + } else if ( + c.id >= HID_ID.BUTTON_MIN && + c.id <= HID_ID.BUTTON_MAX && + c.id - HID_ID.BUTTON_MIN < HID_REPORT_LAYOUT.buttons.count + ) { + buttons.push(c.id - HID_ID.BUTTON_MIN) + } + } + return { axes, hats, buttons } +} + +let mainWindow: BrowserWindow | null = null + +function emit(channel: C, payload: PushChannels[C]): void { + mainWindow?.webContents.send(channel, payload) +} + +const session = new Session(emit, paths) + +/** Apply and persist a settings change: the one path for config:set and a config import. */ +function applyConfig(patch: Partial): AppConfig { + const config = session.setConfig(patch) + setDebugEnabled(config.debugMode) + saveConfig(config, paths) + return config +} + +const transfer = new ConfigTransfer({ + getConfig: () => session.getConfig(), + setConfig: (config) => applyConfig(config), + nodes: session.link.nodes, + loadCalCache: () => loadCalCache(paths), + saveCalCache: (cache) => saveCalCache(cache, paths), + backupDir: () => join(app.getPath('userData'), 'backups'), + clientVersion: () => app.getVersion() +}) + +const CONFIG_FILTER = [{ name: 'OpenSkyhawk configuration', extensions: ['json'] }] +/** Generous: a full export is a few KB. Anything bigger is not one. */ +const CONFIG_MAX_BYTES = 5 * 1024 * 1024 + +const CAPTURE_FILTER = [{ name: 'DCS-BIOS capture', extensions: ['json'] }] + +function registerIpc(): void { + ipcMain.handle(CTRL.configGet, () => session.getConfig()) + ipcMain.handle(CTRL.configSet, (_e, patch: Partial) => applyConfig(patch)) + ipcMain.handle(CTRL.configExport, async (): Promise => { + const res = await dialog.showSaveDialog({ + title: 'Export configuration', + defaultPath: transfer.defaultFileName(), + filters: CONFIG_FILTER + }) + if (res.canceled || !res.filePath) return {} + try { + writeFileSync(res.filePath, transfer.exportText()) + return { path: res.filePath } + } catch (err) { + return { error: `Could not write the file: ${err instanceof Error ? err.message : err}` } + } + }) + ipcMain.handle(CTRL.configImportOpen, async (): Promise => { + const res = await dialog.showOpenDialog({ + title: 'Import configuration', + properties: ['openFile'], + filters: CONFIG_FILTER + }) + const path = res.filePaths[0] + if (res.canceled || !path) return { status: 'canceled' } + try { + if (statSync(path).size > CONFIG_MAX_BYTES) { + return { status: 'error', error: 'This file is too large to be a configuration file.' } + } + return transfer.preview(readFileSync(path, 'utf8'), path) + } catch (err) { + return { + status: 'error', + error: `Could not read the file: ${err instanceof Error ? err.message : err}` + } + } + }) + ipcMain.handle(CTRL.configImportApply, (_e, token: string, sections: unknown, backup: unknown) => + transfer.apply(token, sections, backup) + ) + ipcMain.handle(CTRL.relayStart, () => session.start()) + ipcMain.handle(CTRL.relayStop, () => session.stop()) + ipcMain.handle(CTRL.relayStatus, () => session.status()) + ipcMain.handle(CTRL.deviceConnect, () => session.link.connect()) + ipcMain.handle(CTRL.deviceDisconnect, () => session.link.disconnect()) + ipcMain.handle(CTRL.hidAvailability, () => hidAvailability()) + ipcMain.handle(CTRL.nodesRefresh, () => session.link.requestNodes()) + ipcMain.handle(CTRL.nodesGet, () => session.link.nodes.snapshot()) + ipcMain.handle(CTRL.nodeMappingSet, (_e, id: number, patch: MappingPatch) => + session.link.nodes.setMapping(id, patch) + ) + ipcMain.handle(CTRL.nodeMappingAdopt, (_e, id: number, spec: AdoptSpec) => + session.link.nodes.adopt(id, spec) + ) + ipcMain.handle(CTRL.nodeMappingForget, (_e, id: number) => session.link.nodes.forget(id)) + ipcMain.handle(CTRL.nodeMappingResetNode, (_e, id: number) => session.link.nodes.resetBoard(id)) + ipcMain.handle(CTRL.nodeMappingReset, (_e, opts?: ResetOptions) => + session.link.nodes.resetAll(opts) + ) + ipcMain.handle(CTRL.nodeMappingResetPreview, () => session.link.nodes.previewReset()) + ipcMain.handle(CTRL.nodeRegistry, () => session.link.nodes.registry()) + ipcMain.handle(CTRL.nodeMappingHide, (_e, id: number, on: boolean) => + session.link.nodes.hide(id, on) + ) + ipcMain.handle(CTRL.serialMonitor, (_e, on: boolean) => session.link.setSerialMonitor(on)) + + // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: + // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the + // timeout-vs-nack distinction the UI's failure messaging depends on. + ipcMain.handle(CTRL.calHello, () => session.link.calHello()) + ipcMain.handle(CTRL.calRead, () => session.link.calRead()) + ipcMain.handle(CTRL.calSessionOpen, (_e, axisIdx: number) => session.link.calSessionOpen(axisIdx)) + ipcMain.handle(CTRL.calStreamSelect, (_e, axisIdx: number) => + session.link.calStreamSelect(axisIdx) + ) + ipcMain.handle(CTRL.calCommit, (_e, axis: CalCommitAxis) => session.link.calCommit(axis)) + ipcMain.handle(CTRL.calReset, (_e, idx: number) => session.link.calReset(idx)) + ipcMain.handle(CTRL.calCacheRead, () => session.link.calCacheRead()) + ipcMain.handle(CTRL.calCacheStore, (_e, axes: CalCacheEntry[]) => + session.link.calCacheStore(axes) + ) + ipcMain.handle(CTRL.calCacheDrop, (_e, idx: number) => session.link.calCacheDrop(idx)) + ipcMain.handle(CTRL.calSessionClose, () => session.link.calSessionClose()) + + ipcMain.handle(CTRL.logExport, async (_e, text: string) => { + const res = await dialog.showSaveDialog({ + title: 'Export log', + defaultPath: `skyhawk-log-${Date.now()}.tsv`, + filters: [{ name: 'Log', extensions: ['tsv', 'txt'] }] + }) + if (res.canceled || !res.filePath) return {} + writeFileSync(res.filePath, text) + return { path: res.filePath } + }) + + ipcMain.handle(CTRL.eventsGet, () => session.events.snapshot()) + ipcMain.handle(CTRL.eventsClear, () => session.events.clear()) + ipcMain.handle(CTRL.eventsExport, async () => { + const rows = session.events.snapshot() + const res = await dialog.showSaveDialog({ + title: 'Export events', + defaultPath: `skyhawk-events-${Date.now()}.tsv`, + filters: [{ name: 'Events', extensions: ['tsv', 'txt'] }] + }) + if (res.canceled || !res.filePath) return {} + writeFileSync(res.filePath, eventsToTsv(rows)) + return { path: res.filePath } + }) + + ipcMain.handle(CTRL.debugDumpPorts, async () => { + const ports = await listSerialPorts() + debugLog('serial.list', ports, true) // force-write regardless of the toggle + return { path: debugLogPath(), count: ports.length } + }) + ipcMain.handle(CTRL.debugReveal, () => { + shell.showItemInFolder(debugLogPath()) + }) + + ipcMain.handle(CTRL.captureToggle, async (): Promise => { + if (session.isRecording()) { + const { path, events } = session.stopRecording() + return { recording: false, path, events } + } + const res = await dialog.showSaveDialog({ + title: 'Record DCS-BIOS capture', + defaultPath: `skyhawk-capture-${Date.now()}.json`, + filters: CAPTURE_FILTER + }) + if (res.canceled || !res.filePath) return { recording: false } + session.startRecording(res.filePath) + return { recording: true, path: res.filePath } + }) + + ipcMain.handle(CTRL.replayOpen, async (): Promise => { + const res = await dialog.showOpenDialog({ + title: 'Open DCS-BIOS capture', + properties: ['openFile'], + filters: CAPTURE_FILTER + }) + const path = res.filePaths[0] + if (res.canceled || !path) return { loaded: false } + try { + const info = session.loadReplay(path) + return { loaded: true, path, events: info.events, durationMs: info.durationMs } + } catch { + return { loaded: false } + } + }) +} + +function createWindow(): void { + const win = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 940, + minHeight: 600, + show: false, + backgroundColor: '#0a0e1a', + title: 'OpenSkyhawk Client', + autoHideMenuBar: true, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + mainWindow = win + + win.on('ready-to-show', () => win.show()) + win.on('closed', () => { + if (mainWindow === win) mainWindow = null + }) + + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url) + return { action: 'deny' } + }) + + // electron-vite sets ELECTRON_RENDERER_URL in dev; load the built file otherwise. + if (process.env.ELECTRON_RENDERER_URL) { + void win.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + void win.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +app.whenReady().then(() => { + const cfg = session.setConfig(loadConfig(paths)) // seed from persisted settings + setDebugEnabled(cfg.debugMode) + registerIpc() + createWindow() + session.open() // the device link is up from launch, relaying or not (#69) + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +app.on('window-all-closed', () => { + session.stop() + if (process.platform !== 'darwin') app.quit() +}) + +// Releases the port: the link otherwise holds it for as long as the app runs. +app.on('will-quit', () => session.shutdown()) diff --git a/src/main/session.seam.test.ts b/src/main/app/session.seam.test.ts similarity index 97% rename from src/main/session.seam.test.ts rename to src/main/app/session.seam.test.ts index 69679da..fa81464 100644 --- a/src/main/session.seam.test.ts +++ b/src/main/app/session.seam.test.ts @@ -5,8 +5,8 @@ import { describe, expect, it } from 'vitest' import { CTRL } from '@shared/app/ipc' import { eventText } from '@shared/format' import type { NodeMappingFile } from '@shared/nodes/ipc' -import { statusLine } from '../test-fixtures/nodes' -import { makeServices, type Services } from './test-harness/services' +import { statusLine } from '../../test-fixtures/nodes' +import { makeServices, type Services } from '../test-harness/services' const iso = (ms: number) => new Date(ms).toISOString() const lastSeen = (app: Services, id: number) => diff --git a/src/main/session.test.ts b/src/main/app/session.test.ts similarity index 98% rename from src/main/session.test.ts rename to src/main/app/session.test.ts index 6bf0c9e..8ddf3e6 100644 --- a/src/main/session.test.ts +++ b/src/main/app/session.test.ts @@ -4,7 +4,7 @@ import type { LogRow } from '@shared/dcs/ipc' import { eventText } from '@shared/format' import type { LogEvent } from '@shared/events/ipc' import type { NodeModel } from '@shared/nodes/ipc' -import { statusLine } from '../test-fixtures/nodes' +import { statusLine } from '../../test-fixtures/nodes' // Session pulls in electron (debug log path), the serial port, the DCS transport and node-hid. // Only the seams matter here, so each is replaced with something a test can drive directly. @@ -75,7 +75,7 @@ class FakeTransport { } } -vi.mock('./serial', () => ({ +vi.mock('../gateway/serial-bridge', () => ({ SerialBridge: FakeSerial, PortNotFoundError: class extends Error {}, SIMGATEWAY_VID: 0x2e8a, @@ -83,9 +83,9 @@ vi.mock('./serial', () => ({ findSimGatewayPort: () => Promise.resolve('/dev/fake'), listSerialPorts: () => Promise.resolve([{ path: '/dev/fake', serialNumber: 'TESTSERIAL' }]) })) -vi.mock('./net', () => ({ createTransport: () => new FakeTransport() })) +vi.mock('../dcs/transports', () => ({ createTransport: () => new FakeTransport() })) // Keep the node mapping off the real disk. -vi.mock('./node-mapping', () => ({ +vi.mock('../nodes/mapping-store', () => ({ // Center_Armament and Right_Navigation are in this cockpit (#93: added). loadNodeMapping: () => ({ file: { version: 3, nodes: { 1: { added: true }, 2: { added: true } } } @@ -93,7 +93,7 @@ vi.mock('./node-mapping', () => ({ saveNodeMapping: () => {}, MappingSaveError: class extends Error {} })) -vi.mock('./hid', () => ({ +vi.mock('../hid/hid-reader', () => ({ HidReader: class { static last: InstanceType | undefined buttons: boolean[] = [] @@ -127,7 +127,7 @@ vi.mock('./hid', () => ({ })) const { Session, deviceToDcs, exportToDevice, relayConfigChanged } = await import('./session') -const { PortNotFoundError } = await import('./serial') +const { PortNotFoundError } = await import('../gateway/serial-bridge') const { nodeRosterRequest } = await import('@shared/nodes') const { DEFAULT_CONFIG } = await import('@shared/config/ipc') @@ -268,7 +268,7 @@ describe('HID reporting latency', () => { session.open() session.start() - const { HidReader } = (await import('./hid')) as unknown as { + const { HidReader } = (await import('../hid/hid-reader')) as unknown as { HidReader: { last?: { fire(b: boolean[]): void } } } const hid = HidReader.last! @@ -299,7 +299,7 @@ describe('HID recovery after an unplug', () => { // interfaces are the same physical device and a disconnect does not reliably surface as an // error event on every platform. Serial recovery is proven; being told is not. const { session, serial } = bridgeSession() - const { HidReader } = (await import('./hid')) as unknown as { + const { HidReader } = (await import('../hid/hid-reader')) as unknown as { HidReader: { last?: { reopened: number } } } const hid = HidReader.last! @@ -326,8 +326,8 @@ function launched(config: Parameters['setConfig']>[ } const HID = async () => - ((await import('./hid')) as unknown as { HidReader: { last?: { stopped: boolean } } }).HidReader - .last! + ((await import('../hid/hid-reader')) as unknown as { HidReader: { last?: { stopped: boolean } } }) + .HidReader.last! describe('device link is independent of the relay (#69)', () => { beforeEach(() => { diff --git a/src/main/session.ts b/src/main/app/session.ts similarity index 94% rename from src/main/session.ts rename to src/main/app/session.ts index fcba9ae..ec578ef 100644 --- a/src/main/session.ts +++ b/src/main/app/session.ts @@ -4,7 +4,7 @@ // capture); recording can tap any live mode. // // The device link is not started or stopped here. It is up whenever the board is plugged in -// (device-link.ts); a source mode only chooses where the stream comes from and whether it +// (gateway/device-link.ts); a source mode only chooses where the stream comes from and whether it // reaches the device. import { DcsBiosProtocol } from '@shared/dcsbios' import { @@ -24,12 +24,19 @@ import { import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' import { isLinkUp, type DeviceState, type DeviceStatus } from '@shared/gateway/ipc' import type { PushChannel, PushChannels, RelayStatus } from '@shared/app/ipc' -import { createTransport, type Transport } from './net' -import { debugLog } from './debug' -import { DeviceLink } from './device-link' -import { Recorder, ReplaySource, type ReplayInfo } from './replay' -import { Decoder } from './decode' -import { Stats } from './stats' +import { app } from 'electron' +import { + createTransport, + Decoder, + Recorder, + ReplaySource, + Stats, + type ReplayInfo, + type Transport +} from '../dcs' +import { DeviceLink } from '../gateway' +import { debugLog } from './debug-log' +import { storePaths, type StorePaths } from './user-data' type Emit = (channel: C, payload: PushChannels[C]) => void @@ -104,9 +111,16 @@ export class Session { private logTimer?: ReturnType private timers: ReturnType[] = [] - constructor(private readonly emit: Emit) { + /** + * `paths`: the files, from app/index.ts. The default resolves the same paths the + * same way, for callers that build a Session on its own. + */ + constructor( + private readonly emit: Emit, + paths: StorePaths = storePaths(() => app.getPath('userData')) + ) { this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) - this.link = new DeviceLink(emit) + this.link = new DeviceLink(emit, { paths, log: debugLog }) this.link.onBytes((bytes) => this.fromDevice(bytes)) this.link.onLine((l) => { if (l.kind !== 'command') return diff --git a/src/main/user-data.test.ts b/src/main/app/user-data.test.ts similarity index 62% rename from src/main/user-data.test.ts rename to src/main/app/user-data.test.ts index 32a5a20..d243530 100644 --- a/src/main/user-data.test.ts +++ b/src/main/app/user-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { join, resolve } from 'node:path' -import { userDataOverride, USER_DATA_ENV, type UserDataInputs } from './user-data' +import { storePaths, userDataOverride, USER_DATA_ENV, type UserDataInputs } from './user-data' const repo = resolve('/work/SkyHawkClient') const base: UserDataInputs = { isPackaged: false, appPath: repo, env: {}, cwd: repo } @@ -38,3 +38,21 @@ describe('userDataOverride', () => { expect(userDataOverride({ ...base, isPackaged: true, env: { [USER_DATA_ENV]: '' } })).toBeNull() }) }) + +// The file names the stores used when each asked electron for itself (R1.4a, P6). +describe('storePaths', () => { + it('names the same three files under as before', () => { + const dir = resolve('/u') + const paths = storePaths(() => dir) + expect(paths.configFile()).toBe(join(dir, 'config.json')) + expect(paths.calibrationCacheFile()).toBe(join(dir, 'calibration-cache.json')) + expect(paths.nodeMappingFile()).toBe(join(dir, 'node-mapping.json')) + }) + + it('resolves at each use, as app.getPath did', () => { + let dir = resolve('/first') + const paths = storePaths(() => dir) + dir = resolve('/second') + expect(paths.nodeMappingFile()).toBe(join(dir, 'node-mapping.json')) + }) +}) diff --git a/src/main/user-data.ts b/src/main/app/user-data.ts similarity index 57% rename from src/main/user-data.ts rename to src/main/app/user-data.ts index 3cda82c..e443208 100644 --- a/src/main/user-data.ts +++ b/src/main/app/user-data.ts @@ -20,3 +20,18 @@ export function userDataOverride({ isPackaged, appPath, env, cwd }: UserDataInpu if (!isPackaged) return join(appPath, '.userdata') return null } + +/** + * The files main keeps under , handed to the topics that own them (they never ask + * electron, P6). Each path is resolved at use, exactly as `join(app.getPath('userData'), name)` + * was, so the override above still applies. Satisfies MappingPaths, CachePaths and SettingsPaths. + */ +export function storePaths(userData: () => string) { + return { + configFile: (): string => join(userData(), 'config.json'), + calibrationCacheFile: (): string => join(userData(), 'calibration-cache.json'), + nodeMappingFile: (): string => join(userData(), 'node-mapping.json') + } +} + +export type StorePaths = ReturnType diff --git a/src/main/biosconfig.test.ts b/src/main/biosconfig.test.ts deleted file mode 100644 index 3ffbaad..0000000 --- a/src/main/biosconfig.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseBiosConfig, isLoopbackExport } from './biosconfig' - -const LUA = ` -BIOSConfig = {} -tcp_config = {{ address = "*", port = 7778 }} -udp_config = {{ receive_address = "*", receive_port = 7778, send_address = "239.255.50.10", send_port = 5010 }} -` - -describe('parseBiosConfig', () => { - it('reads tcp + udp config', () => { - const cfg = parseBiosConfig(LUA) - expect(cfg.tcp).toEqual({ address: '*', port: 7778 }) - expect(cfg.udp).toEqual({ - receiveAddress: '*', - receivePort: 7778, - sendAddress: '239.255.50.10', - sendPort: 5010 - }) - }) - - it('flags the default multicast export as loopback-only', () => { - expect(isLoopbackExport(parseBiosConfig(LUA))).toBe(true) - }) - - it('treats a unicast send_address as remote-reachable', () => { - const cfg = parseBiosConfig( - 'udp_config = {{ send_address = "192.168.1.50", send_port = 5010 }}' - ) - expect(isLoopbackExport(cfg)).toBe(false) - }) - - it('returns empty when no blocks present', () => { - expect(parseBiosConfig('-- nothing here')).toEqual({}) - }) -}) diff --git a/src/main/biosconfig.ts b/src/main/biosconfig.ts deleted file mode 100644 index ee9fd20..0000000 --- a/src/main/biosconfig.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Convenience discovery: read the user's DCS-BIOS BIOSConfig.lua to learn the -// configured export/command addresses (and warn when a remote setup needs an -// edit). Not required for the TCP path — purely an auto-fill helper. Pure text -// parsing so it is unit-testable without a filesystem. - -export interface BiosConfig { - tcp?: { address?: string; port?: number } - udp?: { - receiveAddress?: string - receivePort?: number - sendAddress?: string - sendPort?: number - } -} - -function matchStr(text: string, block: string, key: string): string | undefined { - const re = new RegExp(`${block}[\\s\\S]*?${key}\\s*=\\s*"([^"]*)"`) - return re.exec(text)?.[1] -} - -function matchNum(text: string, block: string, key: string): number | undefined { - const re = new RegExp(`${block}[\\s\\S]*?${key}\\s*=\\s*(\\d+)`) - const m = re.exec(text) - return m ? Number(m[1]) : undefined -} - -export function parseBiosConfig(text: string): BiosConfig { - const cfg: BiosConfig = {} - - if (/tcp_config/.test(text)) { - cfg.tcp = { - address: matchStr(text, 'tcp_config', 'address'), - port: matchNum(text, 'tcp_config', 'port') - } - } - if (/udp_config/.test(text)) { - cfg.udp = { - receiveAddress: matchStr(text, 'udp_config', 'receive_address'), - receivePort: matchNum(text, 'udp_config', 'receive_port'), - sendAddress: matchStr(text, 'udp_config', 'send_address'), - sendPort: matchNum(text, 'udp_config', 'send_port') - } - } - return cfg -} - -/** - * True when the export multicast is loopback-only (default), so a *remote* DCS - * needs either the TCP transport or a unicast `send_address` edit to reach this host. - */ -export function isLoopbackExport(cfg: BiosConfig): boolean { - const addr = cfg.udp?.sendAddress - return !!addr && (addr.startsWith('239.') || addr === '127.0.0.1') -} diff --git a/src/main/config-transfer.seam.test.ts b/src/main/config/config-transfer.seam.test.ts similarity index 100% rename from src/main/config-transfer.seam.test.ts rename to src/main/config/config-transfer.seam.test.ts diff --git a/src/main/config-transfer.test.ts b/src/main/config/config-transfer.test.ts similarity index 100% rename from src/main/config-transfer.test.ts rename to src/main/config/config-transfer.test.ts diff --git a/src/main/config-transfer.ts b/src/main/config/config-transfer.ts similarity index 100% rename from src/main/config-transfer.ts rename to src/main/config/config-transfer.ts diff --git a/src/main/config/index.ts b/src/main/config/index.ts new file mode 100644 index 0000000..bbd1d98 --- /dev/null +++ b/src/main/config/index.ts @@ -0,0 +1,4 @@ +// config: settings and the configuration file transfer. Exports only what other topics use +// (arch-phase0-v2 §2.1). +export { loadConfig, saveConfig } from './settings-service' +export { ConfigTransfer } from './config-transfer' diff --git a/src/main/settings.ts b/src/main/config/settings-service.ts similarity index 53% rename from src/main/settings.ts rename to src/main/config/settings-service.ts index ae53b90..f61e660 100644 --- a/src/main/settings.ts +++ b/src/main/config/settings-service.ts @@ -1,17 +1,17 @@ // Persist AppConfig to /config.json so settings survive restarts. -import { app } from 'electron' +// Where that file lives is handed in by app (SettingsPaths): this module never asks electron. import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' import { sanitizeConfig } from '@shared/settings-schema' -function configPath(): string { - return join(app.getPath('userData'), 'config.json') +/** Port: where config.json lives. app provides it; resolved at each use, as before. */ +export interface SettingsPaths { + configFile(): string } -export function loadConfig(): AppConfig { +export function loadConfig(paths: SettingsPaths): AppConfig { try { - const p = configPath() + const p = paths.configFile() if (!existsSync(p)) return { ...DEFAULT_CONFIG } return sanitizeConfig(JSON.parse(readFileSync(p, 'utf8'))) } catch { @@ -19,9 +19,9 @@ export function loadConfig(): AppConfig { } } -export function saveConfig(config: AppConfig): void { +export function saveConfig(config: AppConfig, paths: SettingsPaths): void { try { - writeFileSync(configPath(), JSON.stringify(config, null, 2)) + writeFileSync(paths.configFile(), JSON.stringify(config, null, 2)) } catch { // best-effort; a read-only userData dir shouldn't crash the app } diff --git a/src/main/reference/dcsbios-metadata.ts b/src/main/dcs/dcsbios-metadata.ts similarity index 100% rename from src/main/reference/dcsbios-metadata.ts rename to src/main/dcs/dcsbios-metadata.ts diff --git a/src/main/dcs/index.ts b/src/main/dcs/index.ts new file mode 100644 index 0000000..e49b7d9 --- /dev/null +++ b/src/main/dcs/index.ts @@ -0,0 +1,5 @@ +// dcs: the DCS-BIOS side of the relay. Exports only what other topics use (arch-phase0-v2 §2.1). +export { createTransport, type Transport } from './transports' +export { Decoder } from './sim-telemetry-decoder' +export { Recorder, ReplaySource, type ReplayInfo } from './replay-source' +export { Stats } from './stream-stats' diff --git a/src/main/replay.test.ts b/src/main/dcs/replay-source.test.ts similarity index 96% rename from src/main/replay.test.ts rename to src/main/dcs/replay-source.test.ts index 73ed008..e600c68 100644 --- a/src/main/replay.test.ts +++ b/src/main/dcs/replay-source.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ReplaySource } from './replay' +import { ReplaySource } from './replay-source' import type { CaptureFile } from '@shared/capture' const file: CaptureFile = { diff --git a/src/main/replay.ts b/src/main/dcs/replay-source.ts similarity index 100% rename from src/main/replay.ts rename to src/main/dcs/replay-source.ts diff --git a/src/main/decode.test.ts b/src/main/dcs/sim-telemetry-decoder.test.ts similarity index 95% rename from src/main/decode.test.ts rename to src/main/dcs/sim-telemetry-decoder.test.ts index 11f5064..53a2ad0 100644 --- a/src/main/decode.test.ts +++ b/src/main/dcs/sim-telemetry-decoder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Decoder, extractValue } from './decode' +import { Decoder, extractValue } from './sim-telemetry-decoder' describe('extractValue', () => { it('masks and shifts', () => { diff --git a/src/main/decode.ts b/src/main/dcs/sim-telemetry-decoder.ts similarity index 98% rename from src/main/decode.ts rename to src/main/dcs/sim-telemetry-decoder.ts index 3c8450a..214ff00 100644 --- a/src/main/decode.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -1,7 +1,7 @@ // Turns raw DCS-BIOS writes into named log rows, aircraft state, and the five // sim-telemetry readouts — using the generated A-4E-C reference. import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' -import { A4EC_ADDRESS_RANGE } from './reference/dcsbios-metadata' +import { A4EC_ADDRESS_RANGE } from './dcsbios-metadata' import { StringRegion, ACFT_NAME_ADDRESS, ACFT_NAME_MAX } from '@shared/dcsbios' import type { AircraftStatus, LogRow, TelemetryReadout } from '@shared/dcs/ipc' diff --git a/src/main/stats.ts b/src/main/dcs/stream-stats.ts similarity index 100% rename from src/main/stats.ts rename to src/main/dcs/stream-stats.ts diff --git a/src/main/net.ts b/src/main/dcs/transports.ts similarity index 100% rename from src/main/net.ts rename to src/main/dcs/transports.ts diff --git a/src/main/device-link.ts b/src/main/gateway/device-link.ts similarity index 94% rename from src/main/device-link.ts rename to src/main/gateway/device-link.ts index 4a9fbf9..de56047 100644 --- a/src/main/device-link.ts +++ b/src/main/gateway/device-link.ts @@ -2,7 +2,7 @@ // (CalLink), HID, the node roster and the node data layer. Up whenever the board is plugged in, in // every source mode, relaying or not; down only on unplug or an explicit disconnect. // -// The relay (session.ts) attaches to it rather than owning it: export goes out through write(), +// The relay (app/session.ts) attaches to it rather than owning it: export goes out through write(), // and device traffic comes back through onBytes() / onLine(). That split is the point. Bundled // together, there was no node status, PDU telemetry, HID or calibration unless DCS was being // relayed, and stopping the relay tore the port down with it. @@ -34,22 +34,39 @@ import type { import type { PushChannel, PushChannels } from '@shared/app/ipc' import { CAL_AXIS_NONE, nackReasonName } from '@shared/calibration' import { boardFor, dropAxis, regressedAxes, storeAxes } from '@shared/cal-cache' -import { CalLink, CalNackError, CalTimeoutError } from './callink' +import { + CalLink, + CalNackError, + CalTimeoutError, + loadCalCache, + saveCalCache +} from '../hid/calibration' import { findSimGatewayPort, listSerialPorts, PortNotFoundError, SerialBridge, SIMGATEWAY_PID, - SIMGATEWAY_VID -} from './serial' -import { debugLog } from './debug' -import { HidReader } from './hid' -import { loadCalCache, saveCalCache } from './cal-cache' -import { NodeHub, withFaultInfo } from './node-hub' + SIMGATEWAY_VID, + type Log +} from './serial-bridge' +import { HidReader } from '../hid' +import { NodeHub, withFaultInfo } from '../nodes' type Emit = (channel: C, payload: PushChannels[C]) => void +/** + * What DeviceLink needs from app (P6: it never asks electron): where its two files live under + * , and the debug log. Handed on to NodeHub, CalLink and SerialBridge. + */ +export interface DeviceLinkDeps { + paths: { + nodeMappingFile(): string + calibrationCacheFile(): string + } + log: Log +} + /** Node status, node model and the HID snapshot. Same cadence the relay used for them. */ const LINK_FLUSH_MS = 200 /** @@ -118,8 +135,11 @@ export class DeviceLink { private readonly errorCbs = new Set<(tag: string, message: string) => void>() private readonly eventCbs = new Set<(events: LogEventInput[]) => void>() - constructor(private readonly emit: Emit) { - this.nodes = new NodeHub(emit, this.roster, (events) => this.report(events)) + constructor( + private readonly emit: Emit, + private readonly deps: DeviceLinkDeps + ) { + this.nodes = new NodeHub(emit, this.roster, (events) => this.report(events), deps) } private report(events: LogEventInput[]): void { @@ -185,7 +205,7 @@ export class DeviceLink { private startHid(): void { const h = new HidReader() this.hid = h - h.onError((err) => debugLog('hid.error', { message: err.message })) + h.onError((err) => this.deps.log('hid.error', { message: err.message })) h.onReport(() => this.pushHid()) h.start() } @@ -222,14 +242,15 @@ export class DeviceLink { this.teardown() // Always retries: "not found" keeps looking whatever the setting, because someone asked for // this link. What auto-connect governs is reopening after a drop, handled in onClose. - const s = new SerialBridge(true) + const s = new SerialBridge(true, this.deps.log) this.serial = s // The calibration channel shares this port. It runs whenever the port is open, not only // during a session: HELLO and GET_CAL are answered outside one, so there is no point at // which it is safe to stop watching for frames. this.cal = new CalLink( (data) => s.write(data), - (sample) => this.calRawBuf.push({ t: Date.now(), ...sample }) + (sample) => this.calRawBuf.push({ t: Date.now(), ...sample }), + this.deps.log ) s.onData((chunk) => this.onSerialData(chunk)) s.onMonitor((dir, chunk) => this.onSerialTraffic(dir, chunk)) @@ -388,7 +409,7 @@ export class DeviceLink { let kind: DeviceLine['kind'] = 'command' // Node-status messages are tapped to the roster, not logged as panel commands. if (identifier === NODE_MSG || identifier === NODE_END_MSG) { - debugLog('node', `${identifier} ${arg}`.trim()) + this.deps.log('node', `${identifier} ${arg}`.trim()) this.roster.applyMessage(identifier, arg, t) this.nodes.statusLine(identifier, line.trim(), t) this.gatewayLine(identifier, arg, line.trim(), t) @@ -500,11 +521,11 @@ export class DeviceLink { this.calSerialNumber = await this.gatewaySerialNumber() const hello = await this.calHello() if (!hello.ok) { - debugLog('cal.absent', `no calibration channel: ${hello.message}`) + this.deps.log('cal.absent', `no calibration channel: ${hello.message}`) return } const { proto, blobVersion, fw } = hello.value - debugLog('cal.hello', { + this.deps.log('cal.hello', { proto, blobVersion, fw: `${fw.major}.${fw.minor}.${fw.patch}`, @@ -512,12 +533,12 @@ export class DeviceLink { }) const read = await this.calRead() if (!read.ok) { - debugLog('cal.absent', `read failed: ${read.message}`) + this.deps.log('cal.absent', `read failed: ${read.message}`) return } // Masks, not endpoints: this is the line a bug report needs — which axes the sketch // declares, and which of them the device considers calibrated. - debugLog('cal.data', { + this.deps.log('cal.data', { present: read.value.presentMask.toString(2).padStart(8, '0'), calibrated: read.value.calibratedMask.toString(2).padStart(8, '0') }) @@ -592,7 +613,7 @@ export class DeviceLink { * renderer never sees a board identity it could accidentally key on. */ calCacheRead(): CalResult<{ board?: CachedBoard; regressed: number[] }> { - const cache = loadCalCache() + const cache = loadCalCache(this.deps.paths) return { ok: true, value: { @@ -610,7 +631,10 @@ export class DeviceLink { for (const a of axes) { entries[a.idx] = { min: a.min, centre: a.centre, max: a.max, selfCentring: a.selfCentring } } - saveCalCache(storeAxes(loadCalCache(), serial, entries, new Date().toISOString())) + saveCalCache( + storeAxes(loadCalCache(this.deps.paths), serial, entries, new Date().toISOString()), + this.deps.paths + ) return { ok: true, value: null } } @@ -618,7 +642,10 @@ export class DeviceLink { calCacheDrop(idx: number): CalResult { const serial = this.calSerialNumber if (!serial) return { ok: true, value: null } - saveCalCache(dropAxis(loadCalCache(), serial, idx === CAL_AXIS_NONE ? undefined : idx)) + saveCalCache( + dropAxis(loadCalCache(this.deps.paths), serial, idx === CAL_AXIS_NONE ? undefined : idx), + this.deps.paths + ) return { ok: true, value: null } } diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts new file mode 100644 index 0000000..27c03f6 --- /dev/null +++ b/src/main/gateway/index.ts @@ -0,0 +1,3 @@ +// gateway: the SimGateway USB link. Exports only what other topics use (arch-phase0-v2 §2.1). +export { DeviceLink, type DeviceLinkDeps } from './device-link' +export { listSerialPorts } from './serial-bridge' diff --git a/src/main/serial.test.ts b/src/main/gateway/serial-bridge.test.ts similarity index 97% rename from src/main/serial.test.ts rename to src/main/gateway/serial-bridge.test.ts index a7214a0..ffc1e88 100644 --- a/src/main/serial.test.ts +++ b/src/main/gateway/serial-bridge.test.ts @@ -66,9 +66,9 @@ class FakePort { } vi.mock('serialport', () => ({ SerialPort: FakePort })) -vi.mock('./debug', () => ({ debugLog: () => {} })) +vi.mock('../app/debug-log', () => ({ debugLog: () => {} })) -const { SerialBridge } = await import('./serial') +const { SerialBridge } = await import('./serial-bridge') const flush = () => new Promise((r) => setTimeout(r, 0)) diff --git a/src/main/serial.ts b/src/main/gateway/serial-bridge.ts similarity index 94% rename from src/main/serial.ts rename to src/main/gateway/serial-bridge.ts index e59ff24..f7cd8e7 100644 --- a/src/main/serial.ts +++ b/src/main/gateway/serial-bridge.ts @@ -3,7 +3,9 @@ // interface is a separate device the OS claims; it is handled in M5, not here. import { SerialPort } from 'serialport' import { USB_IDENTITY } from '@shared/firmware' -import { debugLog } from './debug' + +/** Port: the debug log, handed in by app (debug.log lives in app). */ +export type Log = (tag: string, data?: unknown) => void export const SIMGATEWAY_VID = USB_IDENTITY.vid export const SIMGATEWAY_PID = USB_IDENTITY.pid @@ -79,7 +81,10 @@ export class SerialBridge implements SerialLink { private openCb: (portPath: string) => void = () => {} private closeCb: () => void = () => {} - constructor(autoReconnect: boolean) { + constructor( + autoReconnect: boolean, + private readonly log: Log = () => {} + ) { this.autoReconnect = autoReconnect } @@ -169,7 +174,7 @@ export class SerialBridge implements SerialLink { try { const path = await findSimGatewayPort() if (!path) { - if (!this.notFoundLogged) debugLog('serial.notFound', { vid: VID_HEX, pid: PID_HEX }) + if (!this.notFoundLogged) this.log('serial.notFound', { vid: VID_HEX, pid: PID_HEX }) this.notFoundLogged = true this.errorCb(new PortNotFoundError()) this.retry() @@ -177,7 +182,7 @@ export class SerialBridge implements SerialLink { } this.path = path this.notFoundLogged = false - debugLog('serial.open', { path }) + this.log('serial.open', { path }) // Awaited, not merely called: `close()` only *begins* the release, and on Windows the OS // holds the COM handle until it completes — so opening straight after is the process // blocking its own reopen. Measured against real hardware: 24 of 30 reopens failed with @@ -190,11 +195,11 @@ export class SerialBridge implements SerialLink { this.dataCb(d) }) port.on('error', (e: Error) => { - debugLog('serial.error', e.message) + this.log('serial.error', e.message) this.errorCb(e) }) port.on('close', () => { - debugLog('serial.close', { path }) + this.log('serial.close', { path }) this.closeCb() this.retry() }) @@ -202,7 +207,7 @@ export class SerialBridge implements SerialLink { await new Promise((resolve) => { port.open((err) => { if (err) { - debugLog('serial.openError', err.message) + this.log('serial.openError', err.message) this.errorCb(err) this.retry() } else { diff --git a/src/main/cal-cache.ts b/src/main/hid/calibration/cache-store.ts similarity index 60% rename from src/main/cal-cache.ts rename to src/main/hid/calibration/cache-store.ts index 1f34af2..e847f73 100644 --- a/src/main/cal-cache.ts +++ b/src/main/hid/calibration/cache-store.ts @@ -1,22 +1,21 @@ // Persist the calibration cache to /calibration-cache.json (#46, part 5). // -// Deliberately the same shape as settings.ts: tolerant read, best-effort write. A read-only +// Deliberately the same shape as settings-service.ts: tolerant read, best-effort write. A read-only // userData directory must not crash the app, and it must not break calibration either — the cache // is a recovery aid, so failing to keep one leaves the user exactly where they were, whereas // throwing would break the write that had already succeeded on the device. -import { app } from 'electron' import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' import { emptyCache, sanitizeCache } from '@shared/cal-cache' import type { CalCache } from '@shared/hid/calibration/ipc' -function cachePath(): string { - return join(app.getPath('userData'), 'calibration-cache.json') +/** Port: where calibration-cache.json lives. app hands it in (this module never asks electron). */ +export interface CachePaths { + calibrationCacheFile(): string } -export function loadCalCache(): CalCache { +export function loadCalCache(paths: CachePaths): CalCache { try { - const p = cachePath() + const p = paths.calibrationCacheFile() if (!existsSync(p)) return emptyCache() return sanitizeCache(JSON.parse(readFileSync(p, 'utf8'))) } catch { @@ -24,9 +23,9 @@ export function loadCalCache(): CalCache { } } -export function saveCalCache(cache: CalCache): void { +export function saveCalCache(cache: CalCache, paths: CachePaths): void { try { - writeFileSync(cachePath(), JSON.stringify(cache, null, 2)) + writeFileSync(paths.calibrationCacheFile(), JSON.stringify(cache, null, 2)) } catch { // best-effort; see the note at the top of this file } diff --git a/src/main/callink.test.ts b/src/main/hid/calibration/cal-link.test.ts similarity index 99% rename from src/main/callink.test.ts rename to src/main/hid/calibration/cal-link.test.ts index 78b06cf..94a6d48 100644 --- a/src/main/callink.test.ts +++ b/src/main/hid/calibration/cal-link.test.ts @@ -4,7 +4,7 @@ import { CAL_AXIS_NONE, CAL_NACK, CAL_TYPE, buildFrame } from '@shared/calibrati // debug.ts imports electron for its log path; nothing here needs the real thing. vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -const { CalLink, CalNackError, CalTimeoutError } = await import('./callink') +const { CalLink, CalNackError, CalTimeoutError } = await import('./cal-link') type CalLinkT = InstanceType const ascii = (s: string) => Buffer.from(s, 'ascii') diff --git a/src/main/callink.ts b/src/main/hid/calibration/cal-link.ts similarity index 96% rename from src/main/callink.ts rename to src/main/hid/calibration/cal-link.ts index c37b630..a980371 100644 --- a/src/main/callink.ts +++ b/src/main/hid/calibration/cal-link.ts @@ -27,7 +27,9 @@ import { type RawSample } from '@shared/calibration' import { CAL_PROTO } from '@shared/firmware' -import { debugLog } from './debug' + +/** Port: the debug log, handed in by app (debug.log lives in app). */ +export type Log = (tag: string, data?: unknown) => void /** Past the device's worst-case sector erase (~28 ms measured) by a wide margin. */ const REPLY_TIMEOUT_MS = 2000 @@ -91,7 +93,8 @@ export class CalLink { constructor( private readonly write: (data: Buffer) => void, - private readonly onRaw: (sample: RawSample) => void + private readonly onRaw: (sample: RawSample) => void, + private readonly log: Log = () => {} ) {} /** @@ -132,7 +135,7 @@ export class CalLink { } const p = this.pending.get(f.seq) if (!p) { - debugLog('cal.unmatched', { type: f.type, seq: f.seq }) + this.log('cal.unmatched', { type: f.type, seq: f.seq }) return } clearTimeout(p.timer) @@ -299,7 +302,7 @@ export class CalLink { this.expiry = undefined this.session = false this.streamAxis = CAL_AXIS_NONE - debugLog('cal.session', 'close unacknowledged; assuming device-side expiry, gate reopened') + this.log('cal.session', 'close unacknowledged; assuming device-side expiry, gate reopened') }, this.deviceTimeoutMs) } @@ -308,7 +311,7 @@ export class CalLink { this.keepalive = setInterval(() => { // Any accepted frame refreshes the device's timer, so this only matters across stretches // where the user is reading rather than acting. A failure here is not worth surfacing. - this.request(CAL_TYPE.KEEPALIVE).catch((e: Error) => debugLog('cal.keepalive', e.message)) + this.request(CAL_TYPE.KEEPALIVE).catch((e: Error) => this.log('cal.keepalive', e.message)) }, KEEPALIVE_MS) } diff --git a/src/main/hid/calibration/index.ts b/src/main/hid/calibration/index.ts new file mode 100644 index 0000000..31c8712 --- /dev/null +++ b/src/main/hid/calibration/index.ts @@ -0,0 +1,4 @@ +// hid/calibration: the axis calibration channel and its cache. Exports only what other topics use +// (arch-phase0-v2 §2.1). +export { CalLink, CalNackError, CalTimeoutError } from './cal-link' +export { loadCalCache, saveCalCache } from './cache-store' diff --git a/src/main/hid.test.ts b/src/main/hid/hid-reader.test.ts similarity index 97% rename from src/main/hid.test.ts rename to src/main/hid/hid-reader.test.ts index afe65fd..07f9d8a 100644 --- a/src/main/hid.test.ts +++ b/src/main/hid/hid-reader.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { HidReader } from './hid' +import { HidReader } from './hid-reader' /** The private surface this test drives; the class deliberately exposes none of it. */ type Innards = { diff --git a/src/main/hid.ts b/src/main/hid/hid-reader.ts similarity index 98% rename from src/main/hid.ts rename to src/main/hid/hid-reader.ts index 536dd14..63b440b 100644 --- a/src/main/hid.ts +++ b/src/main/hid/hid-reader.ts @@ -14,7 +14,7 @@ import { HID_REPORT_SIZE } from '@shared/hid' import type { HidSnapshot } from '@shared/hid/ipc' -import { SIMGATEWAY_PID, SIMGATEWAY_VID } from './serial' +import { USB_IDENTITY } from '@shared/firmware' /** Matches the serial link's reconnect cadence — the same cable, coming back at the same time. */ const RECONNECT_MS = 2000 @@ -140,7 +140,7 @@ export class HidReader { } try { - const dev = await mod.HIDAsync.open(SIMGATEWAY_VID, SIMGATEWAY_PID) + const dev = await mod.HIDAsync.open(USB_IDENTITY.vid, USB_IDENTITY.pid) if (this.stopped) { await dev.close() return diff --git a/src/main/hid/index.ts b/src/main/hid/index.ts new file mode 100644 index 0000000..b1819ab --- /dev/null +++ b/src/main/hid/index.ts @@ -0,0 +1,2 @@ +// hid: the SimGateway's HID joystick. Exports only what other topics use (arch-phase0-v2 §2.1). +export { HidReader } from './hid-reader' diff --git a/src/main/index.ts b/src/main/index.ts index f189dc2..1ee6475 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,295 +1,2 @@ -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { CTRL, type PushChannel, type PushChannels } from '@shared/app/ipc' -import type { AppConfig, ConfigExportResult, ConfigImportOpen } from '@shared/config/ipc' -import type { CalCacheEntry, CalCommitAxis } from '@shared/hid/calibration/ipc' -import type { CaptureState, ReplayLoad } from '@shared/dcs/ipc' -import type { HidAvailability } from '@shared/hid/ipc' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes/ipc' -import { eventsToTsv } from '@shared/events' -import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' -import { Session } from './session' -import { loadConfig, saveConfig } from './settings' -import { loadCalCache, saveCalCache } from './cal-cache' -import { ConfigTransfer } from './config-transfer' -import { debugLog, debugLogPath, setDebugEnabled } from './debug' -import { listSerialPorts } from './serial' -import { userDataOverride } from './user-data' - -// Before anything touches userData: dev worktrees get their own folder (#81). -const userDataDir = userDataOverride({ - isPackaged: app.isPackaged, - appPath: app.getAppPath(), - env: process.env, - cwd: process.cwd() -}) -if (userDataDir) { - mkdirSync(userDataDir, { recursive: true }) // setPath throws on a missing folder - app.setPath('userData', userDataDir) -} - -/** Report indices the firmware catalogues, derived from HIDControls.h. */ -function hidAvailability(): HidAvailability { - const axes: number[] = [] - const hats: number[] = [] - const buttons: number[] = [] - for (const c of HID_CONTROLS) { - if ( - c.id >= HID_ID.AXIS_MIN && - c.id <= HID_ID.AXIS_MAX && - c.id - HID_ID.AXIS_MIN < HID_REPORT_LAYOUT.axes.count - ) { - axes.push(c.id - HID_ID.AXIS_MIN) - } else if ( - c.id >= HID_ID.HAT_MIN && - c.id <= HID_ID.HAT_MAX && - c.id - HID_ID.HAT_MIN < HID_REPORT_LAYOUT.hats.count - ) { - hats.push(c.id - HID_ID.HAT_MIN) - } else if ( - c.id >= HID_ID.BUTTON_MIN && - c.id <= HID_ID.BUTTON_MAX && - c.id - HID_ID.BUTTON_MIN < HID_REPORT_LAYOUT.buttons.count - ) { - buttons.push(c.id - HID_ID.BUTTON_MIN) - } - } - return { axes, hats, buttons } -} - -let mainWindow: BrowserWindow | null = null - -function emit(channel: C, payload: PushChannels[C]): void { - mainWindow?.webContents.send(channel, payload) -} - -const session = new Session(emit) - -/** Apply and persist a settings change: the one path for config:set and a config import. */ -function applyConfig(patch: Partial): AppConfig { - const config = session.setConfig(patch) - setDebugEnabled(config.debugMode) - saveConfig(config) - return config -} - -const transfer = new ConfigTransfer({ - getConfig: () => session.getConfig(), - setConfig: (config) => applyConfig(config), - nodes: session.link.nodes, - loadCalCache, - saveCalCache, - backupDir: () => join(app.getPath('userData'), 'backups'), - clientVersion: () => app.getVersion() -}) - -const CONFIG_FILTER = [{ name: 'OpenSkyhawk configuration', extensions: ['json'] }] -/** Generous: a full export is a few KB. Anything bigger is not one. */ -const CONFIG_MAX_BYTES = 5 * 1024 * 1024 - -const CAPTURE_FILTER = [{ name: 'DCS-BIOS capture', extensions: ['json'] }] - -function registerIpc(): void { - ipcMain.handle(CTRL.configGet, () => session.getConfig()) - ipcMain.handle(CTRL.configSet, (_e, patch: Partial) => applyConfig(patch)) - ipcMain.handle(CTRL.configExport, async (): Promise => { - const res = await dialog.showSaveDialog({ - title: 'Export configuration', - defaultPath: transfer.defaultFileName(), - filters: CONFIG_FILTER - }) - if (res.canceled || !res.filePath) return {} - try { - writeFileSync(res.filePath, transfer.exportText()) - return { path: res.filePath } - } catch (err) { - return { error: `Could not write the file: ${err instanceof Error ? err.message : err}` } - } - }) - ipcMain.handle(CTRL.configImportOpen, async (): Promise => { - const res = await dialog.showOpenDialog({ - title: 'Import configuration', - properties: ['openFile'], - filters: CONFIG_FILTER - }) - const path = res.filePaths[0] - if (res.canceled || !path) return { status: 'canceled' } - try { - if (statSync(path).size > CONFIG_MAX_BYTES) { - return { status: 'error', error: 'This file is too large to be a configuration file.' } - } - return transfer.preview(readFileSync(path, 'utf8'), path) - } catch (err) { - return { - status: 'error', - error: `Could not read the file: ${err instanceof Error ? err.message : err}` - } - } - }) - ipcMain.handle(CTRL.configImportApply, (_e, token: string, sections: unknown, backup: unknown) => - transfer.apply(token, sections, backup) - ) - ipcMain.handle(CTRL.relayStart, () => session.start()) - ipcMain.handle(CTRL.relayStop, () => session.stop()) - ipcMain.handle(CTRL.relayStatus, () => session.status()) - ipcMain.handle(CTRL.deviceConnect, () => session.link.connect()) - ipcMain.handle(CTRL.deviceDisconnect, () => session.link.disconnect()) - ipcMain.handle(CTRL.hidAvailability, () => hidAvailability()) - ipcMain.handle(CTRL.nodesRefresh, () => session.link.requestNodes()) - ipcMain.handle(CTRL.nodesGet, () => session.link.nodes.snapshot()) - ipcMain.handle(CTRL.nodeMappingSet, (_e, id: number, patch: MappingPatch) => - session.link.nodes.setMapping(id, patch) - ) - ipcMain.handle(CTRL.nodeMappingAdopt, (_e, id: number, spec: AdoptSpec) => - session.link.nodes.adopt(id, spec) - ) - ipcMain.handle(CTRL.nodeMappingForget, (_e, id: number) => session.link.nodes.forget(id)) - ipcMain.handle(CTRL.nodeMappingResetNode, (_e, id: number) => session.link.nodes.resetBoard(id)) - ipcMain.handle(CTRL.nodeMappingReset, (_e, opts?: ResetOptions) => - session.link.nodes.resetAll(opts) - ) - ipcMain.handle(CTRL.nodeMappingResetPreview, () => session.link.nodes.previewReset()) - ipcMain.handle(CTRL.nodeRegistry, () => session.link.nodes.registry()) - ipcMain.handle(CTRL.nodeMappingHide, (_e, id: number, on: boolean) => - session.link.nodes.hide(id, on) - ) - ipcMain.handle(CTRL.serialMonitor, (_e, on: boolean) => session.link.setSerialMonitor(on)) - - // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: - // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the - // timeout-vs-nack distinction the UI's failure messaging depends on. - ipcMain.handle(CTRL.calHello, () => session.link.calHello()) - ipcMain.handle(CTRL.calRead, () => session.link.calRead()) - ipcMain.handle(CTRL.calSessionOpen, (_e, axisIdx: number) => session.link.calSessionOpen(axisIdx)) - ipcMain.handle(CTRL.calStreamSelect, (_e, axisIdx: number) => - session.link.calStreamSelect(axisIdx) - ) - ipcMain.handle(CTRL.calCommit, (_e, axis: CalCommitAxis) => session.link.calCommit(axis)) - ipcMain.handle(CTRL.calReset, (_e, idx: number) => session.link.calReset(idx)) - ipcMain.handle(CTRL.calCacheRead, () => session.link.calCacheRead()) - ipcMain.handle(CTRL.calCacheStore, (_e, axes: CalCacheEntry[]) => - session.link.calCacheStore(axes) - ) - ipcMain.handle(CTRL.calCacheDrop, (_e, idx: number) => session.link.calCacheDrop(idx)) - ipcMain.handle(CTRL.calSessionClose, () => session.link.calSessionClose()) - - ipcMain.handle(CTRL.logExport, async (_e, text: string) => { - const res = await dialog.showSaveDialog({ - title: 'Export log', - defaultPath: `skyhawk-log-${Date.now()}.tsv`, - filters: [{ name: 'Log', extensions: ['tsv', 'txt'] }] - }) - if (res.canceled || !res.filePath) return {} - writeFileSync(res.filePath, text) - return { path: res.filePath } - }) - - ipcMain.handle(CTRL.eventsGet, () => session.events.snapshot()) - ipcMain.handle(CTRL.eventsClear, () => session.events.clear()) - ipcMain.handle(CTRL.eventsExport, async () => { - const rows = session.events.snapshot() - const res = await dialog.showSaveDialog({ - title: 'Export events', - defaultPath: `skyhawk-events-${Date.now()}.tsv`, - filters: [{ name: 'Events', extensions: ['tsv', 'txt'] }] - }) - if (res.canceled || !res.filePath) return {} - writeFileSync(res.filePath, eventsToTsv(rows)) - return { path: res.filePath } - }) - - ipcMain.handle(CTRL.debugDumpPorts, async () => { - const ports = await listSerialPorts() - debugLog('serial.list', ports, true) // force-write regardless of the toggle - return { path: debugLogPath(), count: ports.length } - }) - ipcMain.handle(CTRL.debugReveal, () => { - shell.showItemInFolder(debugLogPath()) - }) - - ipcMain.handle(CTRL.captureToggle, async (): Promise => { - if (session.isRecording()) { - const { path, events } = session.stopRecording() - return { recording: false, path, events } - } - const res = await dialog.showSaveDialog({ - title: 'Record DCS-BIOS capture', - defaultPath: `skyhawk-capture-${Date.now()}.json`, - filters: CAPTURE_FILTER - }) - if (res.canceled || !res.filePath) return { recording: false } - session.startRecording(res.filePath) - return { recording: true, path: res.filePath } - }) - - ipcMain.handle(CTRL.replayOpen, async (): Promise => { - const res = await dialog.showOpenDialog({ - title: 'Open DCS-BIOS capture', - properties: ['openFile'], - filters: CAPTURE_FILTER - }) - const path = res.filePaths[0] - if (res.canceled || !path) return { loaded: false } - try { - const info = session.loadReplay(path) - return { loaded: true, path, events: info.events, durationMs: info.durationMs } - } catch { - return { loaded: false } - } - }) -} - -function createWindow(): void { - const win = new BrowserWindow({ - width: 1280, - height: 820, - minWidth: 940, - minHeight: 600, - show: false, - backgroundColor: '#0a0e1a', - title: 'OpenSkyhawk Client', - autoHideMenuBar: true, - webPreferences: { - preload: join(__dirname, '../preload/index.js'), - sandbox: false - } - }) - mainWindow = win - - win.on('ready-to-show', () => win.show()) - win.on('closed', () => { - if (mainWindow === win) mainWindow = null - }) - - win.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url) - return { action: 'deny' } - }) - - // electron-vite sets ELECTRON_RENDERER_URL in dev; load the built file otherwise. - if (process.env.ELECTRON_RENDERER_URL) { - void win.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - void win.loadFile(join(__dirname, '../renderer/index.html')) - } -} - -app.whenReady().then(() => { - const cfg = session.setConfig(loadConfig()) // seed from persisted settings - setDebugEnabled(cfg.debugMode) - registerIpc() - createWindow() - session.open() // the device link is up from launch, relaying or not (#69) - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() - }) -}) - -app.on('window-all-closed', () => { - session.stop() - if (process.platform !== 'darwin') app.quit() -}) - -// Releases the port: the link otherwise holds it for as long as the app runs. -app.on('will-quit', () => session.shutdown()) +// Main-process entry (electron-vite builds out/main/index.js from here). The app lives in app/. +import './app' diff --git a/src/main/nodes/index.ts b/src/main/nodes/index.ts new file mode 100644 index 0000000..b0dbf5b --- /dev/null +++ b/src/main/nodes/index.ts @@ -0,0 +1,3 @@ +// nodes: the CAN node picture and the node mapping. Exports only what other topics use +// (arch-phase0-v2 §2.1). +export { NodeHub, withFaultInfo } from './node-hub' diff --git a/src/main/node-mapping.seam.test.ts b/src/main/nodes/mapping-store.seam.test.ts similarity index 88% rename from src/main/node-mapping.seam.test.ts rename to src/main/nodes/mapping-store.seam.test.ts index 2b0d1d8..595ddcc 100644 --- a/src/main/node-mapping.seam.test.ts +++ b/src/main/nodes/mapping-store.seam.test.ts @@ -4,11 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { NodeMappingFile } from '@shared/nodes/ipc' +import { storePaths } from '../app/user-data' const userData = vi.hoisted(() => ({ dir: '' })) -vi.mock('electron', () => ({ app: { getPath: () => userData.dir } })) +// is handed in the way app hands it in (the store no longer asks electron, P6). +const paths = storePaths(() => userData.dir) -const { loadNodeMapping, saveNodeMapping } = await import('./node-mapping') +const store = await import('./mapping-store') +const loadNodeMapping = () => store.loadNodeMapping(paths) +const saveNodeMapping = (f: NodeMappingFile) => store.saveNodeMapping(f, paths) const path = () => join(userData.dir, 'node-mapping.json') const write = (raw: unknown) => writeFileSync(path(), JSON.stringify(raw)) diff --git a/src/main/node-mapping.test.ts b/src/main/nodes/mapping-store.test.ts similarity index 89% rename from src/main/node-mapping.test.ts rename to src/main/nodes/mapping-store.test.ts index 4ff0e3b..477b4f1 100644 --- a/src/main/node-mapping.test.ts +++ b/src/main/nodes/mapping-store.test.ts @@ -4,11 +4,16 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { EMPTY_MAPPING, importMapping } from '@shared/node-mapping' import type { NodeMappingFile } from '@shared/nodes/ipc' +import { storePaths } from '../app/user-data' const userData = vi.hoisted(() => ({ dir: '' })) -vi.mock('electron', () => ({ app: { getPath: () => userData.dir } })) +// is handed in the way app hands it in (the store no longer asks electron, P6). +const paths = storePaths(() => userData.dir) -const { loadNodeMapping, saveNodeMapping, MappingSaveError } = await import('./node-mapping') +const store = await import('./mapping-store') +const { MappingSaveError } = store +const loadNodeMapping = () => store.loadNodeMapping(paths) +const saveNodeMapping = (f: NodeMappingFile) => store.saveNodeMapping(f, paths) const file = (): string => join(userData.dir, 'node-mapping.json') const sample: NodeMappingFile = { diff --git a/src/main/node-mapping.ts b/src/main/nodes/mapping-store.ts similarity index 84% rename from src/main/node-mapping.ts rename to src/main/nodes/mapping-store.ts index 8e67237..1c4c25b 100644 --- a/src/main/node-mapping.ts +++ b/src/main/nodes/mapping-store.ts @@ -1,11 +1,10 @@ // Persist the node mapping to /node-mapping.json (shape and rules: shared/node-mapping.ts). +// Where that file lives is handed in by app (MappingPaths): this module never asks electron. // // Unlike config.json, a lost write here is user work gone (names, adopted boards, hidden boards), so // failures are reported rather than swallowed, writes are atomic, and a file this build cannot // safely overwrite — unreadable, or from a newer client — is loaded read-only. -import { app } from 'electron' import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from '@shared/node-mapping' import type { NodeMappingFile } from '@shared/nodes/ipc' import { errorText, stamp } from '@shared/format' @@ -19,14 +18,15 @@ export interface LoadedMapping { readOnly?: string } -function mappingPath(): string { - return join(app.getPath('userData'), 'node-mapping.json') +/** Port: where node-mapping.json lives. app provides it; resolved at each use, as before. */ +export interface MappingPaths { + nodeMappingFile(): string } const empty = (): NodeMappingFile => ({ ...EMPTY_MAPPING, nodes: {} }) -export function loadNodeMapping(): LoadedMapping { - const p = mappingPath() +export function loadNodeMapping(paths: MappingPaths): LoadedMapping { + const p = paths.nodeMappingFile() let text: string try { if (!existsSync(p)) return { file: empty() } @@ -64,8 +64,8 @@ export function loadNodeMapping(): LoadedMapping { * Write via a temp file and rename, so a crash mid-write leaves the old file intact rather than * half a JSON document. Throws MappingSaveError if the file did not reach the disk. */ -export function saveNodeMapping(file: NodeMappingFile): void { - const p = mappingPath() +export function saveNodeMapping(file: NodeMappingFile, paths: MappingPaths): void { + const p = paths.nodeMappingFile() const tmp = `${p}.tmp` try { writeFileSync(tmp, JSON.stringify(file, null, 2)) diff --git a/src/main/node-hub.seam.test.ts b/src/main/nodes/node-hub.seam.test.ts similarity index 98% rename from src/main/node-hub.seam.test.ts rename to src/main/nodes/node-hub.seam.test.ts index c823a01..aed36b2 100644 --- a/src/main/node-hub.seam.test.ts +++ b/src/main/nodes/node-hub.seam.test.ts @@ -5,11 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LogEventInput } from '@shared/events/ipc' import type { NodeMappingFile } from '@shared/nodes/ipc' import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' -import { statusRow } from '../test-fixtures/nodes' +import { statusRow } from '../../test-fixtures/nodes' const disk: { file: NodeMappingFile; saves: number } = { file: { version: 3, nodes: {} }, saves: 0 } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path -vi.mock('./node-mapping', () => ({ +vi.mock('./mapping-store', () => ({ MappingSaveError: class MappingSaveError extends Error {}, loadNodeMapping: () => ({ file: disk.file }), saveNodeMapping: (f: NodeMappingFile) => { diff --git a/src/main/node-hub.test.ts b/src/main/nodes/node-hub.test.ts similarity index 99% rename from src/main/node-hub.test.ts rename to src/main/nodes/node-hub.test.ts index 13c4bf0..934ba32 100644 --- a/src/main/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -12,7 +12,7 @@ const disk: { saves: 0 } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path -vi.mock('./node-mapping', () => { +vi.mock('./mapping-store', () => { class MappingSaveError extends Error {} return { MappingSaveError, diff --git a/src/main/node-hub.ts b/src/main/nodes/node-hub.ts similarity index 92% rename from src/main/node-hub.ts rename to src/main/nodes/node-hub.ts index e0fdb84..7e2e6e4 100644 --- a/src/main/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -1,5 +1,5 @@ // Node data layer in main: taps `_NODE_TELEM`, owns the node mapping, and pushes the combined -// node picture. Kept out of session.ts so the session only feeds it lines and ticks. +// node picture. Kept out of app/session.ts so the session only feeds it lines and ticks. import type { AdoptSpec, MappedNode, @@ -47,12 +47,28 @@ import { loadNodeMapping, MappingSaveError, saveNodeMapping, - type LoadedMapping -} from './node-mapping' -import { debugLog } from './debug' + type LoadedMapping, + type MappingPaths +} from './mapping-store' type Emit = (channel: C, payload: PushChannels[C]) => void +/** Port: the debug log, handed in by app (debug.log lives in app). */ +export type Log = (tag: string, data?: unknown) => void + +/** What NodeHub needs from app, through DeviceLink: where node-mapping.json lives, and the log. */ +export interface NodeHubDeps { + paths?: MappingPaths + log?: Log +} + +/** Until a path is handed in: fails loudly on first use rather than guess one. */ +const NO_PATHS: MappingPaths = { + nodeMappingFile: () => { + throw new Error('NodeHub: no node-mapping.json path was handed in') + } +} + /** Shortest gap between `nodes:telemetry` pushes. ~3 PDUs send ~3 frames/s between them. */ const NODE_TELEM_PUSH_MS = 1000 @@ -101,16 +117,22 @@ export class NodeHub { }) private mappedFor?: NodeMappingFile private mappedCache = new Map() + private readonly paths: MappingPaths + private readonly log: Log constructor( private readonly emit: Emit, private readonly roster: NodeRoster, - private readonly onEvents: (events: LogEventInput[]) => void = () => {} - ) {} + private readonly onEvents: (events: LogEventInput[]) => void = () => {}, + deps: NodeHubDeps = {} + ) { + this.paths = deps.paths ?? NO_PATHS + this.log = deps.log ?? (() => {}) + } /** Read lazily: the userData path is only safe to touch once the app is up. */ private store(): LoadedMapping { - return (this.loaded ??= loadNodeMapping()) + return (this.loaded ??= loadNodeMapping(this.paths)) } private mapping(): NodeMappingFile { @@ -336,7 +358,7 @@ export class NodeHub { this.save(next) } catch (err) { if (!(err instanceof MappingSaveError)) throw err - debugLog('nodes.mapping', err.message) + this.log('nodes.mapping', err.message) this.commit(next) } } @@ -346,7 +368,7 @@ export class NodeHub { if (next === this.mapping()) return const { readOnly } = this.store() if (readOnly) throw new MappingSaveError(`Not saved: ${readOnly}`) - saveNodeMapping(next) + saveNodeMapping(next, this.paths) this.commit(next) } diff --git a/src/main/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts similarity index 98% rename from src/main/phase0-probes.test.ts rename to src/main/nodes/phase0-probes.test.ts index 87d1185..71ea5b2 100644 --- a/src/main/phase0-probes.test.ts +++ b/src/main/nodes/phase0-probes.test.ts @@ -18,11 +18,11 @@ import { diffImport, parseConfigFile } from '@shared/config-file' import { CONFIG_FORMAT } from '@shared/config/ipc' import { buildNodeModel } from '@shared/node-rules' import { GatewayWatch, GATEWAY_NO_ANSWER_MS } from '@shared/gateway' -import { DEFAULT_APP_SETTINGS } from '../test-fixtures/config' +import { DEFAULT_APP_SETTINGS } from '../../test-fixtures/config' const disk: { file: NodeMappingFile } = { file: { version: 3, nodes: {} } } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('./node-mapping', () => { +vi.mock('./mapping-store', () => { class MappingSaveError extends Error {} return { MappingSaveError, diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index dd5d1ff..99b4967 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -1,6 +1,6 @@ // Services harness for SESSION-seam tests (test-only: production code never imports this). // -// `makeServices()` launches the main process the way index.ts does — settings loaded from +// `makeServices()` launches the main process the way app/index.ts does — settings loaded from // config.json, then the device link opened — with fakes standing in for the hardware and the // outside world: the SimGateway serial port, the DCS transport, HID, the clock and . // @@ -113,7 +113,9 @@ class FakeHid { } // Registered before main is imported (below), so main is built against the fakes. Paths are -// relative to this file. node-mapping, settings and cal-cache are real: they write . +// relative to this file. The mapping, settings and calibration-cache stores are real: they write +// , through the same store paths app/index.ts hands them. electron is still faked for +// app's own use of it (debug.log, the version). vi.doMock('electron', () => ({ app: { getPath: () => world.userData, @@ -121,7 +123,7 @@ vi.doMock('electron', () => ({ isPackaged: false } })) -vi.doMock('../serial', () => ({ +vi.doMock('../gateway/serial-bridge', () => ({ SerialBridge: FakeSerial, PortNotFoundError: class PortNotFoundError extends Error {}, SIMGATEWAY_VID: 0x2e8a, @@ -130,13 +132,14 @@ vi.doMock('../serial', () => ({ listSerialPorts: () => Promise.resolve([{ path: world.portPath, serialNumber: world.serialNumber }]) })) -vi.doMock('../net', () => ({ createTransport: () => new FakeTransport() })) -vi.doMock('../hid', () => ({ HidReader: FakeHid })) +vi.doMock('../dcs/transports', () => ({ createTransport: () => new FakeTransport() })) +vi.doMock('../hid/hid-reader', () => ({ HidReader: FakeHid })) -const { Session } = await import('../session') -const { loadConfig, saveConfig } = await import('../settings') -const { setDebugEnabled } = await import('../debug') -const { PortNotFoundError } = await import('../serial') +const { Session } = await import('../app/session') +const { storePaths } = await import('../app/user-data') +const { loadConfig, saveConfig } = await import('../config/settings-service') +const { setDebugEnabled } = await import('../app/debug-log') +const { PortNotFoundError } = await import('../gateway/serial-bridge') // ── the harness ────────────────────────────────────────────────────────────── @@ -200,20 +203,23 @@ function launch(opts: ServicesOptions) { FakeSerial.last = undefined FakeTransport.last = undefined + // app/index.ts: the files, handed to the stores. + const paths = storePaths(() => world.userData) + const pushes: Push[] = [] const session = new Session((channel, payload) => { pushes.push({ channel, payload } as Push) - }) + }, paths) - // index.ts: applyConfig(), the one path for config:set and a config import. + // app/index.ts: applyConfig(), the one path for config:set and a config import. const applyConfig = (patch: Partial): AppConfig => { const config = session.setConfig(patch) setDebugEnabled(config.debugMode) - saveConfig(config) + saveConfig(config, paths) return config } - // index.ts registerIpc(): the renderer's requests main answers, by channel. + // app/index.ts registerIpc(): the renderer's requests main answers, by channel. const intents = { [CTRL.configGet]: () => session.getConfig(), [CTRL.configSet]: (patch: Partial) => applyConfig(patch), @@ -239,8 +245,8 @@ function launch(opts: ServicesOptions) { } type Intents = typeof intents - // index.ts app.whenReady(): seed from persisted settings, then bring the device link up. - const cfg = session.setConfig(loadConfig()) + // app/index.ts app.whenReady(): seed from persisted settings, then bring the device link up. + const cfg = session.setConfig(loadConfig(paths)) setDebugEnabled(cfg.debugMode) session.open() diff --git a/src/renderer/src/phase0-probes.test.ts b/src/renderer/src/phase0-probes.test.ts index 8a39869..06d49db 100644 --- a/src/renderer/src/phase0-probes.test.ts +++ b/src/renderer/src/phase0-probes.test.ts @@ -2,7 +2,7 @@ // each test asserts TODAY's behaviour for a conflict or fix-track row that the renderer helpers // decide, so the structure rewrite can show it changed nothing. A fix ticket that changes one of // these behaviours on purpose updates its probe. The main/shared half is -// src/main/phase0-probes.test.ts. +// src/main/nodes/phase0-probes.test.ts. import { describe, expect, it } from 'vitest' import type { NodeMappingFile } from '@shared/nodes/ipc' import { NODE_MSG, NodeRoster } from '@shared/nodes' diff --git a/src/shared/firmware/reference.test.ts b/src/shared/firmware/reference.test.ts index 6bf5e06..5b3d73c 100644 --- a/src/shared/firmware/reference.test.ts +++ b/src/shared/firmware/reference.test.ts @@ -47,7 +47,7 @@ import { PDU_STATUS_FLAG_INFO, type NodeFaultName } from '.' -import { ACFT_NAME } from '../../main/reference/dcsbios-metadata' +import { ACFT_NAME } from '../../main/dcs/dcsbios-metadata' import { SUPPORTED_NODE_PROTO } from '@shared/nodes' import { SUPPORTED_TELEM_PROTO } from '@shared/node-telem' import * as cal from '@shared/calibration' @@ -259,7 +259,7 @@ describe('axis-cal.generated', () => { it('pins the largest frame and the device session timeout', () => { expect(CAL_PROTO.maxFrame).toBe(92) expect(CAL_PROTO.maxFrame).toBe(CAL_PROTO.envelopeBytes + CAL_PROTO.maxPayload) - // KEEPALIVE_MS (main/callink.ts) is client policy chosen to land well inside this. + // KEEPALIVE_MS (main/hid/calibration/cal-link.ts) is client policy chosen to land well inside this. expect(CAL_PROTO.sessionTimeoutMs).toBe(30000) }) diff --git a/src/shared/node-mapping.ts b/src/shared/node-mapping.ts index 3c7e0ff..8d25c96 100644 --- a/src/shared/node-mapping.ts +++ b/src/shared/node-mapping.ts @@ -1,6 +1,6 @@ // Node mapping: display name, console and power zone (PDU) per NODE_ID, when each board was last // seen, and "hidden until it reports again" flags. Pure, no Node/Electron deps — main persists it -// as /node-mapping.json (src/main/node-mapping.ts). +// as /node-mapping.json (src/main/nodes/mapping-store.ts). // // The registry (the bundled NODE_IDS) is a dictionary: a registry board joins your cockpit when you // add it in New on the bus (pre-filled with its registry defaults), and the file stores that it was From 64310ecd163ab51243a1b7fe628297e8295ba480 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 21:50:09 -0400 Subject: [PATCH 11/28] refactor(main): main-only models move from shared to main (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1.4b of the structure rewrite (#101, phase #98). This PR moves code and splits files. Nothing changes for the user, and no exported symbol was renamed. ## What changes - Every model in `src/shared` that isn't a contract, `firmware` or `format` moves into its `src/main//` file (arch-phase0-v2 §1.2, §3.6). - The rules the renderer still calls move into `src/shared//legacy-rules.ts`. These files are pure and marked `@deprecated`, and main imports them too, so each rule has one copy. - Imports: - contracts come from the bare `@shared/`, since `shared/gateway.ts`, `events.ts`, `hid.ts` and `nodes.ts` no longer shadow the folders; - other main topics are reached through their `index.ts`, with five exceptions listed below; - inside a topic, imports are relative. - depcruise: - P4 accepts `legacy-rules.ts`; - a new rule `P4-legacy-rules` (severity key `legacyRules: "warn"`) counts the renderer's calls into those files; - `rendererShared` (P4) is now `error`. - `src/shared` now holds only `/{index,ipc}.ts`, `/legacy-rules.ts`, `firmware/`, `format/` and their tests. DeviceLink and Session are not split; that is R2.2 and R2.3. ## Commits 1. `68ac0fb` is renames only. Each `git mv` has 100% similarity: a file that moved whole went to its new home, and a file that split went to the home of its largest part. The build is broken at this commit. 2. `24d4d3c` splits the files and fixes the imports. 3. `79a3cfe` changes the depcruise rule and severity. ## Old → new | `src/shared/…` (old) | New home | |---|---| | `dcsbios.ts` (+ test) | `main/dcs/bios-protocol.ts`, plus `main/gateway/link-framing.ts` (split) | | `main/dcs/dcsbios-metadata.ts` | merged into `main/dcs/bios-protocol.ts`, then deleted | | `capture.ts` (+ test) | `main/dcs/capture-file.ts` | | `calibration.ts` (+ test) | `main/hid/calibration/codec.ts` (CalDemux included) | | `cal-cache.ts` (+ test) | `main/hid/calibration/cache-rules.ts` (+ `mergeCalibration`, from config-file) | | `config-file.ts` (+ test) | `main/config/config-file.ts` | | `settings-schema.ts`, `config.test.ts` | `main/config/settings-schema.ts` (+ `.test`) | | `gateway.ts` (+ test) | `main/gateway/gateway-health.ts`. `isLinkUp` stays in the gateway contract | | `events.ts` | split, see below. The test moves to `main/events/events.test.ts` | | `nodes.ts` (+ test) | `main/nodes/node-status-wire.ts` (minus `nodeFaultTag` → legacy) | | `node-telem.ts` (+ test) | `main/nodes/node-telem.ts` (minus `displayCurrentMa`, `RAIL_FLAG`, `PDU_FLAG` → legacy) | | `node-kind.ts` (+ test) | `main/nodes/node-kind.ts` (minus `reportedIdentity`, `isAdoptableKind` → legacy) | | `node-mapping.ts` (+ test) | `main/nodes/mapping-rules.ts`, `registry.ts`, `membership.ts` (split) | | `node-rules.ts` (+ test) | `main/nodes/node-model.ts`, `power-zone.ts` (split; `isReportableNew` → legacy) | | `psu.ts` (+ test) | `main/nodes/psu-limits.ts` (minus the renderer's four → legacy) | | `hid.ts` (+ test) | `main/hid/hid-report.ts` | | `axis-capture.ts` (+ test) | `shared/hid/calibration/legacy-rules.ts` (+ `.test`), whole | Each test file moved whole and its cases were not split. A test that covers symbols that now live in several files imports each one from its new home. ## The split files Code is cut and pasted verbatim. A script compared every non-comment, non-import line of the 17 old files with the new files, as multisets, with the `export` keyword ignored. There is no difference in either direction. What changed is new header comments, the `@deprecated` tags, and `export` added where a private helper is now used across files. | From | Symbols | To | |---|---|---| | `events.ts` | `NodeLookup`, `NodeEventTracker` | `main/nodes/node-events.ts` | | | `RAIL_FLAGS`, `PDU_STATUS_FLAGS`, `TEMP_FLAGS`, `railReading` (private → exported) | `main/nodes/faults.ts` | | | `StatusFlag`, `STATUS_FLAGS`, `faultLabel` | `main/events/status-flags.ts` (OQ4) | | | `clientErrorEvent`, `EventLog`, `cell`, `EVENTS_TSV_HEADER`, `eventsToTsv` | `main/events/event-log.ts` | | | `deviceEvent` | `main/gateway/link-events.ts` | | | `RelayContext`, `MODE_LABEL`, `relayEvents` | `main/dcs/relay-events.ts` | | | `SEVERITY_GLYPH`, `isProblem` | `shared/events/legacy-rules.ts` | | `dcsbios.ts` | `encodeExportFrame`, `parseCommand`, `LineAssembler` | `main/gateway/link-framing.ts` (OQ2) | | | the rest (`formatCommand` included, for R3.4) + `ACFT_NAME`, `A4EC_ADDRESS_RANGE` | `main/dcs/bios-protocol.ts` | | `node-mapping.ts` | `isPduName`, `registryDefaults` | `main/nodes/registry.ts` | | | `adopted`, `inCockpit`, `forgettable` (private → exported) | `main/nodes/membership.ts` | | | the rest (`checkPowerZone` included; `isNodeId` now exported for registry) | `main/nodes/mapping-rules.ts` | | `node-rules.ts` | `PDU_GROUP_WINDOW_MS`, `PDU_GROUP_POLL_CATCH_MS`, `droppedTogether`, `pduOutages` | `main/nodes/power-zone.ts` | | | `NodeModelInput`, `buildNodeModel`, `cameOnline` | `main/nodes/node-model.ts` | | | `isReportableNew` | `shared/nodes/legacy-rules.ts` | | `config-file.ts` | `mergeCalibration` | `main/hid/calibration/cache-rules.ts` | ## legacy-rules contents Each file opens with: *"Renderer still calls these; they move to main in R3.x and this file disappears."* Each export is `@deprecated`. | File | Exports | Renderer callers | |---|---|---| | `shared/nodes/legacy-rules.ts` | `RAIL_FLAG`, `PDU_FLAG`, `displayCurrentMa`, `nodeFaultTag`, `reportedIdentity`, `isAdoptableKind`, `isReportableNew`, `PSU_PRESETS`, `PSU_LIMIT_MAX`, `isLimit`, `effectivePsuLimits`, `parsePsuLimit` | `cockpit.ts`, `cockpit-panels.ts`, `cockpit-adopt.ts`, `overview.ts`, `tabs/Cockpit.tsx`, `tabs/settings/CockpitSection.tsx` | | `shared/events/legacy-rules.ts` | `SEVERITY_GLYPH`, `isProblem` | `log-view.ts`, `tabs/Log.tsx` | | `shared/hid/calibration/legacy-rules.ts` | the whole of `axis-capture` | `CalibrationDialog.tsx`, `calibration-controller.ts` | - `DEFAULT_PSU` was already in the nodes contract and stays there. - `PSU_LIMIT_MAX` and `isLimit` aren't called by the renderer. They are here because `parsePsuLimit` needs them, and shared can't import main. - `reportedIdentity` reads `NODE_KINDS` from `../firmware`, aliased as `NODE_KIND` so its body is unchanged. - The renderer's production code now imports from `src/shared` only contracts, `firmware`, `format` and these three files. ## lint:deps, per rule | Rule | Before (111e907) | After | |---|---|---| | P4-renderer-imports-contracts-only | 17 warn | **0** (now `error`) | | P4-legacy-rules (new) | n/a | 10 warn | | topic-main-gateway | 3 warn | 3 warn (unchanged: DeviceLink until R2.2) | | T2-main-no-deep-topic-import | 0 | 5 warn | | T2-renderer-no-deep-topic-import | 1 warn | 1 warn | | every other rule | 0 | 0 | | **Total** | 21 warn, 0 error | 19 warn, 0 error | No topic-direction edge is new. The five T2-main deep imports are deliberate: - **`nodes/node-status-wire.ts → gateway/link-framing.ts`** (`encodeExportFrame`). Going through `gateway/index.ts` would close an import cycle (C2, error): gateway index → `device-link` → nodes index → `node-hub` → `node-status-wire` → gateway index. The cycle goes away when R2.2 splits DeviceLink. - **`config/config-file.ts → nodes/mapping-rules.ts`, `nodes/psu-limits.ts`, `hid/calibration/cache-rules.ts`**, and **`config/settings-schema.ts → nodes/psu-limits.ts`**. The renderer's test helpers (`renderer/test/scenarios.ts`, the t13 test and `test-fixtures/config.ts`) import `config-file`, so the renderer `tsconfig.json`, which has no Node types, typechecks its closure. The nodes and hid/calibration indexes also export NodeHub and CalLink, which pull in `node:fs` and `Buffer`, so going through the indexes fails `npm run typecheck`. Each import carries a comment that says why. ## Proof nothing changed 1. **Tests:** 881 passed in 66 files, the same count as base. Test files changed only import paths, plus four comments that named old paths: three notes in `reference.test.ts` and one JSDoc in `scenarios.ts`. A diff filter over every test, fixture and harness file shows no other line changed. 2. **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`). `out/main/index.js` from base and from head, each with electron, serialport and node-hid stubbed, through `whenReady`, `config:set`, `debug:dump-ports`, `node-mapping:set` and `will-quit`. The outputs are **byte-identical**, both with `SKYHAWK_USER_DATA` set and with the dev default (`/.userdata`). That covers: - the `setPath` call; - the `app.on` order; - the preload and renderer paths; - all 40 channels; - the files in `` and what they contain. The preload bundle is byte-identical too. 3. **Render compare.** A one-off jsdom test (not committed) dumps the innerHTML and text of each view below, base vs head. The dumps are byte-identical (sha1 `4ea8c9e3…`): - Cockpit; - Cockpit with the PDU board panel open, the degraded board panel open, and the Adopt dialog open; - Overview; - Connection; - HID; - the Calibration dialog, link up and link down; - Settings › Cockpit, PSU preset and custom; - Log, in the Stream view, the Events view and Problems only. The scenario was built to exercise each moved rule: a degraded node (`nodeFaultTag`), PDU pages with 12 V UNDER-V and TEMP WARN raised, a negative 5 V current (`displayCurrentMa`, `RAIL_FLAG`, `PDU_FLAG`), two reportable new boards (`isReportableNew`, `isAdoptableKind`, `reportedIdentity`), the NX650 preset and custom limits (`PSU_PRESETS`, `effectivePsuLimits`), and events of all four severities (`SEVERITY_GLYPH`, `isProblem`). ## Left as is, on purpose - `checkPowerZone` stays in `mapping-rules.ts`. §3.6 puts it in `power-zone.ts`, but it calls `effectiveMapping` and `MappingError`, and mapping-rules calls it back, so the move would create an import cycle (C2, error). Its X4 co-location is R3.2 work anyway. - `MODE_LABEL` is in `dcs/relay-events.ts`, next to its only user, as §1.2 lists it. §3.6 names `relay-rules.ts`, a file that doesn't exist until R3.3. - `isObject` in config-file is not merged into mapping-rules' `isPlainObject` (§3.6 MERGE). That is a dedupe, not a move. - `nodes/faults.ts` holds only the rail and PDU flag text from `events.ts`. `FAULT_TEXT` and `withFaultInfo` live in `node-hub.ts`, which is main's own file, and `nodeFaultTag` is in legacy-rules. - The generated firmware files (`node-status.generated.ts`, `axis-cal.generated.ts`) and the matching `scripts/sync-a4ec.ts` doc strings still say `shared/nodes.ts`, `shared/node-telem.ts` and `shared/calibration.ts`. Changing them means regenerating under the freshness gate, so they wait for the next sync. ## Checks - `npm run typecheck`: passes. - `npm run lint`: 0 errors, 32 warnings, the same P5 renderer warnings as on base. - `npm run lint:deps`: 0 errors, 19 warnings. - `npx vitest run`: 881 passed in 66 files. - `npm run build`: passes. Refs #98 --- .dependency-cruiser.cjs | 12 +- .dependency-cruiser.severity.json | 3 +- scripts/sync-a4ec.ts | 2 +- src/main/app/index.ts | 14 +- src/main/app/session.seam.test.ts | 4 +- src/main/app/session.test.ts | 12 +- src/main/app/session.ts | 22 +- .../config}/config-file.test.ts | 19 +- src/{shared => main/config}/config-file.ts | 33 +- src/main/config/config-transfer.seam.test.ts | 8 +- src/main/config/config-transfer.test.ts | 8 +- src/main/config/config-transfer.ts | 12 +- .../config/settings-schema.test.ts} | 2 +- .../config}/settings-schema.ts | 6 +- src/main/config/settings-service.ts | 4 +- .../dcs/bios-protocol.test.ts} | 10 +- .../dcsbios.ts => main/dcs/bios-protocol.ts} | 59 +-- .../dcs/capture-file.test.ts} | 4 +- .../capture.ts => main/dcs/capture-file.ts} | 2 +- src/main/dcs/dcsbios-metadata.ts | 20 - src/main/dcs/index.ts | 2 + src/main/dcs/relay-events.ts | 70 +++ src/main/dcs/replay-source.test.ts | 2 +- src/main/dcs/replay-source.ts | 11 +- src/main/dcs/sim-telemetry-decoder.ts | 5 +- src/main/dcs/stream-stats.ts | 2 +- src/main/dcs/transports.ts | 4 +- src/main/events/event-log.ts | 77 ++++ src/{shared => main/events}/events.test.ts | 28 +- src/main/events/index.ts | 4 + src/main/events/status-flags.ts | 19 + src/main/gateway/device-link.ts | 49 +- .../gateway/gateway-health.test.ts} | 10 +- .../gateway/gateway-health.ts} | 14 +- src/main/gateway/index.ts | 1 + src/main/gateway/link-events.ts | 33 ++ src/main/gateway/link-framing.ts | 47 ++ .../hid/calibration/cache-rules.test.ts} | 4 +- .../hid/calibration/cache-rules.ts} | 17 +- src/main/hid/calibration/cache-store.ts | 4 +- src/main/hid/calibration/cal-link.test.ts | 2 +- src/main/hid/calibration/cal-link.ts | 4 +- .../hid/calibration/codec.test.ts} | 2 +- .../hid/calibration/codec.ts} | 2 +- src/main/hid/calibration/index.ts | 2 + src/main/hid/hid-reader.ts | 4 +- .../hid/hid-report.test.ts} | 2 +- src/{shared/hid.ts => main/hid/hid-report.ts} | 2 +- src/main/nodes/faults.ts | 33 ++ src/main/nodes/index.ts | 11 + .../nodes/mapping-rules.test.ts} | 6 +- .../nodes/mapping-rules.ts} | 58 +-- src/main/nodes/mapping-store.seam.test.ts | 2 +- src/main/nodes/mapping-store.test.ts | 4 +- src/main/nodes/mapping-store.ts | 6 +- src/main/nodes/membership.ts | 25 ++ src/main/nodes/node-events.ts | 191 ++++++++ src/main/nodes/node-hub.seam.test.ts | 6 +- src/main/nodes/node-hub.test.ts | 4 +- src/main/nodes/node-hub.ts | 21 +- src/{shared => main/nodes}/node-kind.test.ts | 3 +- src/{shared => main/nodes}/node-kind.ts | 26 +- .../nodes/node-model.test.ts} | 18 +- .../nodes/node-model.ts} | 67 +-- .../nodes/node-status-wire.test.ts} | 8 +- .../nodes/node-status-wire.ts} | 13 +- src/{shared => main/nodes}/node-telem.test.ts | 6 +- src/{shared => main/nodes}/node-telem.ts | 17 +- src/main/nodes/phase0-probes.test.ts | 16 +- src/main/nodes/power-zone.ts | 48 ++ .../nodes/psu-limits.test.ts} | 5 +- src/main/nodes/psu-limits.ts | 25 ++ src/main/nodes/registry.ts | 31 ++ src/main/test-harness/services.ts | 6 +- src/preload/index.ts | 19 +- src/renderer/src/CalibrationDialog.tsx | 4 +- src/renderer/src/DevicePopover.tsx | 2 +- .../src/calibration-controller.test.ts | 4 +- src/renderer/src/calibration-controller.ts | 4 +- src/renderer/src/cockpit-adopt.test.ts | 4 +- src/renderer/src/cockpit-adopt.ts | 6 +- src/renderer/src/cockpit-panels.test.ts | 5 +- src/renderer/src/cockpit-panels.ts | 5 +- src/renderer/src/cockpit.test.ts | 5 +- src/renderer/src/cockpit.ts | 14 +- src/renderer/src/connection-summary.test.ts | 4 +- src/renderer/src/connection-summary.ts | 2 +- src/renderer/src/device-view.test.ts | 2 +- src/renderer/src/device-view.ts | 4 +- src/renderer/src/env.d.ts | 2 +- src/renderer/src/freshness.ts | 2 +- src/renderer/src/gateway-view.test.ts | 4 +- src/renderer/src/gateway-view.ts | 4 +- src/renderer/src/log-view.test.ts | 2 +- src/renderer/src/log-view.ts | 4 +- src/renderer/src/overview.test.ts | 4 +- src/renderer/src/overview.ts | 5 +- src/renderer/src/phase0-probes.test.ts | 9 +- src/renderer/src/store.test.ts | 8 +- src/renderer/src/store.ts | 22 +- src/renderer/src/tabs/Cockpit.tsx | 6 +- src/renderer/src/tabs/Connection.tsx | 2 +- src/renderer/src/tabs/Hid.tsx | 2 +- src/renderer/src/tabs/Log.tsx | 4 +- src/renderer/src/tabs/Overview.tsx | 2 +- src/renderer/src/tabs/cockpit/AdoptDialog.tsx | 2 +- src/renderer/src/tabs/cockpit/SidePanels.tsx | 2 +- .../src/tabs/settings/AboutSection.tsx | 4 +- .../src/tabs/settings/CockpitSection.tsx | 4 +- .../tabs/settings/ConfigurationSection.tsx | 2 +- .../src/tabs/settings/ImportDialog.tsx | 2 +- .../src/tabs/settings/ResetDialog.tsx | 2 +- .../src/tabs/settings/settings-view.test.ts | 2 +- .../src/tabs/settings/settings-view.ts | 2 +- .../src/tabs/settings/useNodeModel.ts | 2 +- src/renderer/test/scenarios.ts | 27 +- src/renderer/test/skyhawk-mock.ts | 16 +- .../test/t11-adopt-dialog-add-all.test.tsx | 2 +- .../test/t12-settings-hidden-boards.test.tsx | 2 +- .../test/t13-settings-import-preview.test.tsx | 4 +- .../test/t16-board-panel-settings.test.tsx | 2 +- src/renderer/test/t6-device-popover.test.tsx | 2 +- .../test/t7-board-footer-panel.test.tsx | 2 +- .../test/t8-board-footer-pdu.test.tsx | 2 +- src/shared/events.ts | 418 ------------------ src/shared/events/legacy-rules.ts | 21 + src/shared/firmware/reference.test.ts | 16 +- src/shared/format/events.ts | 4 +- .../calibration/legacy-rules.test.ts} | 4 +- .../calibration/legacy-rules.ts} | 55 ++- src/shared/nodes/legacy-rules.ts | 133 ++++++ src/shared/psu.ts | 60 --- src/test-fixtures/config.ts | 4 +- src/test-fixtures/nodes.ts | 2 +- 134 files changed, 1212 insertions(+), 1089 deletions(-) rename src/{shared => main/config}/config-file.test.ts (95%) rename src/{shared => main/config}/config-file.ts (92%) rename src/{shared/config.test.ts => main/config/settings-schema.test.ts} (97%) rename src/{shared => main/config}/settings-schema.ts (87%) rename src/{shared/dcsbios.test.ts => main/dcs/bios-protocol.test.ts} (94%) rename src/{shared/dcsbios.ts => main/dcs/bios-protocol.ts} (74%) rename src/{shared/capture.test.ts => main/dcs/capture-file.test.ts} (92%) rename src/{shared/capture.ts => main/dcs/capture-file.ts} (95%) delete mode 100644 src/main/dcs/dcsbios-metadata.ts create mode 100644 src/main/dcs/relay-events.ts create mode 100644 src/main/events/event-log.ts rename src/{shared => main/events}/events.test.ts (95%) create mode 100644 src/main/events/index.ts create mode 100644 src/main/events/status-flags.ts rename src/{shared/gateway.test.ts => main/gateway/gateway-health.test.ts} (97%) rename src/{shared/gateway.ts => main/gateway/gateway-health.ts} (96%) create mode 100644 src/main/gateway/link-events.ts create mode 100644 src/main/gateway/link-framing.ts rename src/{shared/cal-cache.test.ts => main/hid/calibration/cache-rules.test.ts} (98%) rename src/{shared/cal-cache.ts => main/hid/calibration/cache-rules.ts} (89%) rename src/{shared/calibration.test.ts => main/hid/calibration/codec.test.ts} (99%) rename src/{shared/calibration.ts => main/hid/calibration/codec.ts} (99%) rename src/{shared/hid.test.ts => main/hid/hid-report.test.ts} (97%) rename src/{shared/hid.ts => main/hid/hid-report.ts} (97%) create mode 100644 src/main/nodes/faults.ts rename src/{shared/node-mapping.test.ts => main/nodes/mapping-rules.test.ts} (99%) rename src/{shared/node-mapping.ts => main/nodes/mapping-rules.ts} (90%) create mode 100644 src/main/nodes/membership.ts create mode 100644 src/main/nodes/node-events.ts rename src/{shared => main/nodes}/node-kind.test.ts (92%) rename src/{shared => main/nodes}/node-kind.ts (54%) rename src/{shared/node-rules.test.ts => main/nodes/node-model.test.ts} (97%) rename src/{shared/node-rules.ts => main/nodes/node-model.ts} (66%) rename src/{shared/nodes.test.ts => main/nodes/node-status-wire.test.ts} (97%) rename src/{shared/nodes.ts => main/nodes/node-status-wire.ts} (96%) rename src/{shared => main/nodes}/node-telem.test.ts (98%) rename src/{shared => main/nodes}/node-telem.ts (93%) create mode 100644 src/main/nodes/power-zone.ts rename src/{shared/psu.test.ts => main/nodes/psu-limits.test.ts} (88%) create mode 100644 src/main/nodes/psu-limits.ts create mode 100644 src/main/nodes/registry.ts delete mode 100644 src/shared/events.ts create mode 100644 src/shared/events/legacy-rules.ts rename src/shared/{axis-capture.test.ts => hid/calibration/legacy-rules.test.ts} (99%) rename src/shared/{axis-capture.ts => hid/calibration/legacy-rules.ts} (90%) create mode 100644 src/shared/nodes/legacy-rules.ts delete mode 100644 src/shared/psu.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index cb63a11..3763a77 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -20,6 +20,9 @@ const FIRMWARE = '^src/shared/firmware/' const FORMAT = '^src/shared/format/' const KIT = `^${RENDERER}/app/kit/` const CONTRACT = '^src/shared/[a-z-]+(/calibration)?/(index|ipc)\\.ts$' +// Pure rules the renderer still calls until R3.x moves them to main (arch-phase0-v2 §3.2). Main +// imports them from here too, so there is one copy. +const LEGACY = '^src/shared/[a-z-]+(/calibration)?/legacy-rules\\.ts$' // Topic directions, the same inside main and inside the renderer. `app` may import everything, // so it has no rule. Every topic may also import the leaves, the kit and the shared contracts. @@ -96,7 +99,14 @@ module.exports = { severity: SEV.rendererShared, comment: 'the renderer imports only shared contracts and the firmware/format leaves', from: { path: '^src/renderer/' }, - to: { path: '^src/shared/', pathNot: [CONTRACT, FIRMWARE, FORMAT] } + to: { path: '^src/shared/', pathNot: [CONTRACT, FIRMWARE, FORMAT, LEGACY] } + }, + { + name: 'P4-legacy-rules', + severity: SEV.legacyRules, + comment: 'a renderer call into shared//legacy-rules.ts; R3.x moves the rule to main', + from: { path: '^src/renderer/' }, + to: { path: LEGACY } }, { name: 'P6-electron-only-in-app', diff --git a/.dependency-cruiser.severity.json b/.dependency-cruiser.severity.json index dd347ea..52f696f 100644 --- a/.dependency-cruiser.severity.json +++ b/.dependency-cruiser.severity.json @@ -2,7 +2,8 @@ "process": "error", "crossProcess": "error", "electron": "error", - "rendererShared": "warn", + "rendererShared": "error", + "legacyRules": "warn", "contracts": "error", "cycles": "error", "firmwareLeaf": "error", diff --git a/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index 3094171..916113c 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -14,7 +14,7 @@ * Firmware/NODE_IDS.md -> node-names.generated.ts * * The DCS-BIOS common-metadata address (_ACFT_NAME @ 0x0000) is NOT vendored in - * the firmware repo, so it lives in the hand-curated src/main/dcs/dcsbios-metadata.ts. + * the firmware repo, so it lives hand-curated in src/main/dcs/bios-protocol.ts. * * Source resolution: * 1. --local | OPENSKYHAWK_REPO_LOCAL= read files from a local checkout (no fetch) diff --git a/src/main/app/index.ts b/src/main/app/index.ts index f246ebe..0a05273 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -1,15 +1,15 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { CTRL, type PushChannel, type PushChannels } from '@shared/app/ipc' -import type { AppConfig, ConfigExportResult, ConfigImportOpen } from '@shared/config/ipc' -import type { CalCacheEntry, CalCommitAxis } from '@shared/hid/calibration/ipc' -import type { CaptureState, ReplayLoad } from '@shared/dcs/ipc' -import type { HidAvailability } from '@shared/hid/ipc' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes/ipc' -import { eventsToTsv } from '@shared/events' +import { CTRL, type PushChannel, type PushChannels } from '@shared/app' +import type { AppConfig, ConfigExportResult, ConfigImportOpen } from '@shared/config' +import type { CalCacheEntry, CalCommitAxis } from '@shared/hid/calibration' +import type { CaptureState, ReplayLoad } from '@shared/dcs' +import type { HidAvailability } from '@shared/hid' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' import { ConfigTransfer, loadConfig, saveConfig } from '../config' +import { eventsToTsv } from '../events' import { listSerialPorts } from '../gateway' import { loadCalCache, saveCalCache } from '../hid/calibration' import { Session } from './session' diff --git a/src/main/app/session.seam.test.ts b/src/main/app/session.seam.test.ts index fa81464..b3393a1 100644 --- a/src/main/app/session.seam.test.ts +++ b/src/main/app/session.seam.test.ts @@ -2,9 +2,9 @@ // as the outside world sees it — fake serial port and DCS socket in, renderer pushes, IPC replies // and files out — through the services harness. Nothing here reaches inside main. import { describe, expect, it } from 'vitest' -import { CTRL } from '@shared/app/ipc' +import { CTRL } from '@shared/app' import { eventText } from '@shared/format' -import type { NodeMappingFile } from '@shared/nodes/ipc' +import type { NodeMappingFile } from '@shared/nodes' import { statusLine } from '../../test-fixtures/nodes' import { makeServices, type Services } from '../test-harness/services' diff --git a/src/main/app/session.test.ts b/src/main/app/session.test.ts index 8ddf3e6..4ac415e 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/session.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { CAL_TYPE, buildFrame } from '@shared/calibration' -import type { LogRow } from '@shared/dcs/ipc' +import { CAL_TYPE, buildFrame } from '../hid/calibration/codec' +import type { LogRow } from '@shared/dcs' import { eventText } from '@shared/format' -import type { LogEvent } from '@shared/events/ipc' -import type { NodeModel } from '@shared/nodes/ipc' +import type { LogEvent } from '@shared/events' +import type { NodeModel } from '@shared/nodes' import { statusLine } from '../../test-fixtures/nodes' // Session pulls in electron (debug log path), the serial port, the DCS transport and node-hid. @@ -128,8 +128,8 @@ vi.mock('../hid/hid-reader', () => ({ const { Session, deviceToDcs, exportToDevice, relayConfigChanged } = await import('./session') const { PortNotFoundError } = await import('../gateway/serial-bridge') -const { nodeRosterRequest } = await import('@shared/nodes') -const { DEFAULT_CONFIG } = await import('@shared/config/ipc') +const { nodeRosterRequest } = await import('../nodes/node-status-wire') +const { DEFAULT_CONFIG } = await import('@shared/config') const ascii = (s: string) => Buffer.from(s, 'ascii') diff --git a/src/main/app/session.ts b/src/main/app/session.ts index ec578ef..ffdddd7 100644 --- a/src/main/app/session.ts +++ b/src/main/app/session.ts @@ -6,35 +6,31 @@ // The device link is not started or stopped here. It is up whenever the board is plugged in // (gateway/device-link.ts); a source mode only chooses where the stream comes from and whether it // reaches the device. -import { DcsBiosProtocol } from '@shared/dcsbios' import { EXPORT_MULTICAST_ADDR, EXPORT_PORT, type LogRow, type RelayResult, type RelayState -} from '@shared/dcs/ipc' -import { - clientErrorEvent, - deviceEvent, - EventLog, - relayEvents, - type RelayContext -} from '@shared/events' -import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' -import { isLinkUp, type DeviceState, type DeviceStatus } from '@shared/gateway/ipc' -import type { PushChannel, PushChannels, RelayStatus } from '@shared/app/ipc' +} from '@shared/dcs' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' +import { isLinkUp, type DeviceState, type DeviceStatus } from '@shared/gateway' +import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' import { app } from 'electron' import { createTransport, + DcsBiosProtocol, Decoder, Recorder, + relayEvents, ReplaySource, Stats, + type RelayContext, type ReplayInfo, type Transport } from '../dcs' -import { DeviceLink } from '../gateway' +import { clientErrorEvent, EventLog } from '../events' +import { deviceEvent, DeviceLink } from '../gateway' import { debugLog } from './debug-log' import { storePaths, type StorePaths } from './user-data' diff --git a/src/shared/config-file.test.ts b/src/main/config/config-file.test.ts similarity index 95% rename from src/shared/config-file.test.ts rename to src/main/config/config-file.test.ts index 940e507..f56f1cc 100644 --- a/src/shared/config-file.test.ts +++ b/src/main/config/config-file.test.ts @@ -1,16 +1,11 @@ import { describe, expect, it } from 'vitest' -import { - applyToConfig, - buildConfigFile, - diffImport, - mergeCalibration, - parseConfigFile -} from './config-file' -import { CONFIG_FORMAT, DEFAULT_CONFIG, type ConfigSnapshot, type SectionId } from './config/ipc' -import { registryDefaults } from './node-mapping' -import { DEFAULT_PSU, type NodeMappingFile } from './nodes/ipc' -import type { CalCache } from './hid/calibration/ipc' -import { DEFAULT_APP_SETTINGS } from '../test-fixtures/config' +import { applyToConfig, buildConfigFile, diffImport, parseConfigFile } from './config-file' +import { mergeCalibration } from '../hid/calibration/cache-rules' +import { CONFIG_FORMAT, DEFAULT_CONFIG, type ConfigSnapshot, type SectionId } from '@shared/config' +import { registryDefaults } from '../nodes/registry' +import { DEFAULT_PSU, type NodeMappingFile } from '@shared/nodes' +import type { CalCache } from '@shared/hid/calibration' +import { DEFAULT_APP_SETTINGS } from '../../test-fixtures/config' const defaults = registryDefaults({ 0: { name: 'PanelBridge', console: '—' }, diff --git a/src/shared/config-file.ts b/src/main/config/config-file.ts similarity index 92% rename from src/shared/config-file.ts rename to src/main/config/config-file.ts index 676f59a..538a605 100644 --- a/src/shared/config-file.ts +++ b/src/main/config/config-file.ts @@ -19,13 +19,17 @@ import { type DiffRow, type SectionDiff, type SectionId -} from './config/ipc' -import { sanitizeCache } from './cal-cache' -import type { CachedAxis, CalCache } from './hid/calibration/ipc' -import { effectiveMapping, importMapping, sanitizeNodeMapping } from './node-mapping' -import type { MappedNode, NodeDefaults, NodeMappingFile, PsuSetting } from './nodes/ipc' -import { PSU_PRESETS, sanitizePsu } from './psu' -import { onOff, plural, PSU_LIMITS } from './format' +} from '@shared/config' +import type { CachedAxis, CalCache } from '@shared/hid/calibration' +import type { MappedNode, NodeDefaults, NodeMappingFile, PsuSetting } from '@shared/nodes' +import { PSU_PRESETS } from '@shared/nodes/legacy-rules' +import { onOff, plural, PSU_LIMITS } from '@shared/format' +// Deep imports of pure models, not the topic indexes: those also export services (NodeHub, +// CalLink) that need Node types, and the renderer's test helpers import this file (and typecheck +// it without them). +import { sanitizeCache } from '../hid/calibration/cache-rules' +import { effectiveMapping, importMapping, sanitizeNodeMapping } from '../nodes/mapping-rules' +import { sanitizePsu } from '../nodes/psu-limits' /** File format this build writes. A higher version came from a newer client. */ export const CONFIG_FILE_VERSION = 1 @@ -277,21 +281,6 @@ export function diffImport( // ── apply ──────────────────────────────────────────────────────────────────── -/** - * Calibration merges into the client's copy per board serial: the axes the file has replace the - * same axes, every other cached axis and board is kept. The board's date is the newer of the two. - */ -export function mergeCalibration(cache: CalCache, imported: CalCache): CalCache { - const boards = { ...cache.boards } - for (const [serial, board] of Object.entries(imported.boards)) { - const cur = boards[serial] - const confirmedAt = - cur && cur.confirmedAt > board.confirmedAt ? cur.confirmedAt : board.confirmedAt - boards[serial] = { confirmedAt, axes: { ...cur?.axes, ...board.axes } } - } - return { ...cache, boards } -} - /** The AppConfig after applying the picked sections of an import: PSU and the rest separately. */ export function applyToConfig( config: AppConfig, diff --git a/src/main/config/config-transfer.seam.test.ts b/src/main/config/config-transfer.seam.test.ts index 0295e91..3103b93 100644 --- a/src/main/config/config-transfer.seam.test.ts +++ b/src/main/config/config-transfer.seam.test.ts @@ -4,10 +4,10 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { CONFIG_FORMAT, DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' -import { registryDefaults } from '@shared/node-mapping' -import type { NodeMappingFile } from '@shared/nodes/ipc' -import type { CalCache } from '@shared/hid/calibration/ipc' +import { CONFIG_FORMAT, DEFAULT_CONFIG, type AppConfig } from '@shared/config' +import { registryDefaults } from '../nodes/registry' +import type { NodeMappingFile } from '@shared/nodes' +import type { CalCache } from '@shared/hid/calibration' import { ConfigTransfer } from './config-transfer' const axis = (min: number, centre: number, max: number) => ({ diff --git a/src/main/config/config-transfer.test.ts b/src/main/config/config-transfer.test.ts index 1c930b0..fde7ca1 100644 --- a/src/main/config/config-transfer.test.ts +++ b/src/main/config/config-transfer.test.ts @@ -2,10 +2,10 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { DEFAULT_CONFIG, type AppConfig, type ImportPreview } from '@shared/config/ipc' -import { registryDefaults } from '@shared/node-mapping' -import type { NodeMappingFile } from '@shared/nodes/ipc' -import type { CalCache } from '@shared/hid/calibration/ipc' +import { DEFAULT_CONFIG, type AppConfig, type ImportPreview } from '@shared/config' +import { registryDefaults } from '../nodes/registry' +import type { NodeMappingFile } from '@shared/nodes' +import type { CalCache } from '@shared/hid/calibration' import { ConfigTransfer } from './config-transfer' const defaults = registryDefaults({ diff --git a/src/main/config/config-transfer.ts b/src/main/config/config-transfer.ts index 7a17ff3..15cfc9d 100644 --- a/src/main/config/config-transfer.ts +++ b/src/main/config/config-transfer.ts @@ -1,6 +1,6 @@ // Configuration export / import (#67) in main: gathers the four stores into one file, previews a // file against them, and applies the sections the user picked. The rules are in -// shared/config-file.ts; this file only reads and writes. Dialogs stay in index.ts. +// config-file.ts; this file only reads and writes. Dialogs stay in index.ts. // // Calibration is written to the client's copy only (calibration-cache.json). The device is never // touched here: if it has lost calibration, the HID tab's Review & restore offer picks up the copy. @@ -13,20 +13,20 @@ import type { ConfigImportOpen, ConfigSnapshot, SectionId -} from '@shared/config/ipc' -import type { NodeDefaults, NodeMappingFile, NodeMappingResult } from '@shared/nodes/ipc' +} from '@shared/config' +import type { NodeDefaults, NodeMappingFile, NodeMappingResult } from '@shared/nodes' import { applyToConfig, appSettings, buildConfigFile, diffImport, - mergeCalibration, parseConfigFile, SECTION_IDS, type ParsedConfig -} from '@shared/config-file' -import type { CalCache } from '@shared/hid/calibration/ipc' +} from './config-file' +import type { CalCache } from '@shared/hid/calibration' import { errorText, stamp } from '@shared/format' +import { mergeCalibration } from '../hid/calibration' export interface ConfigTransferDeps { getConfig(): AppConfig diff --git a/src/shared/config.test.ts b/src/main/config/settings-schema.test.ts similarity index 97% rename from src/shared/config.test.ts rename to src/main/config/settings-schema.test.ts index 869b359..5ce4e5a 100644 --- a/src/shared/config.test.ts +++ b/src/main/config/settings-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_CONFIG } from './config/ipc' +import { DEFAULT_CONFIG } from '@shared/config' import { sanitizeConfig } from './settings-schema' describe('sanitizeConfig', () => { diff --git a/src/shared/settings-schema.ts b/src/main/config/settings-schema.ts similarity index 87% rename from src/shared/settings-schema.ts rename to src/main/config/settings-schema.ts index f94eef9..6af15e8 100644 --- a/src/shared/settings-schema.ts +++ b/src/main/config/settings-schema.ts @@ -7,8 +7,10 @@ import { type AppConfig, type DcsTransport, type SourceMode -} from './config/ipc' -import { sanitizePsu } from './psu' +} from '@shared/config' +// Deep import of a pure model, not '../nodes': that index also exports NodeHub, which needs Node +// types, and the renderer's test helpers reach this file through config-file. +import { sanitizePsu } from '../nodes/psu-limits' /** Coerce arbitrary (e.g. persisted/untrusted) data into a valid AppConfig. */ export function sanitizeConfig(raw: unknown): AppConfig { diff --git a/src/main/config/settings-service.ts b/src/main/config/settings-service.ts index f61e660..3421cc8 100644 --- a/src/main/config/settings-service.ts +++ b/src/main/config/settings-service.ts @@ -1,8 +1,8 @@ // Persist AppConfig to /config.json so settings survive restarts. // Where that file lives is handed in by app (SettingsPaths): this module never asks electron. import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' -import { sanitizeConfig } from '@shared/settings-schema' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' +import { sanitizeConfig } from './settings-schema' /** Port: where config.json lives. app provides it; resolved at each use, as before. */ export interface SettingsPaths { diff --git a/src/shared/dcsbios.test.ts b/src/main/dcs/bios-protocol.test.ts similarity index 94% rename from src/shared/dcsbios.test.ts rename to src/main/dcs/bios-protocol.test.ts index 4c42f78..d8a2a69 100644 --- a/src/shared/dcsbios.test.ts +++ b/src/main/dcs/bios-protocol.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - DcsBiosProtocol, - LineAssembler, - StringRegion, - formatCommand, - parseCommand, - ACFT_NAME_ADDRESS -} from './dcsbios' +import { DcsBiosProtocol, StringRegion, formatCommand, ACFT_NAME_ADDRESS } from './bios-protocol' +import { LineAssembler, parseCommand } from '../gateway/link-framing' /** Build a sync + one write block: addr, then count bytes of data words. */ function frame(address: number, words: number[]): number[] { diff --git a/src/shared/dcsbios.ts b/src/main/dcs/bios-protocol.ts similarity index 74% rename from src/shared/dcsbios.ts rename to src/main/dcs/bios-protocol.ts index 4aa2bc3..6cc302f 100644 --- a/src/shared/dcsbios.ts +++ b/src/main/dcs/bios-protocol.ts @@ -130,46 +130,23 @@ export function formatCommand(identifier: string, arg: string | number): string return `${identifier} ${arg}\n` } +// Hand-curated — NOT generated by `npm run sync`. +// +// The DCS-BIOS "common data" / metadata module (the cross-aircraft block that +// carries _ACFT_NAME) is not vendored in the OpenSkyhawk firmware repo, so there +// is no upstream file to sync. These constants come from the DCS-BIOS common +// metadata definition (see PRD.md §12) and change only with DCS-BIOS itself. + +/** Live aircraft module name, exported by DCS-BIOS at a fixed cross-module address. */ +export const ACFT_NAME = { + /** DCS-BIOS export address of the _ACFT_NAME string. */ + address: 0x0000, + /** Null-terminated, max 24 bytes. Empty / "NONE" when no module is loaded. */ + maxLength: 24 +} as const + /** - * Encode a single 16-bit DCS-BIOS export write as a self-contained frame: - * sync (4×0x55) + address(LE) + count=2(LE) + value(LE). Used to inject the - * node-roster request into the export stream sent to the SimGateway. + * Address window the A-4E-C module exports into. Used to *infer* "A-4E-C" when + * _ACFT_NAME is unavailable, and to gate named decoding (raw-only outside it). */ -export function encodeExportFrame(address: number, value: number): Uint8Array { - return new Uint8Array([ - 0x55, - 0x55, - 0x55, - 0x55, - address & 0xff, - (address >> 8) & 0xff, - 0x02, - 0x00, - value & 0xff, - (value >> 8) & 0xff - ]) -} - -/** Split "IDENTIFIER ARG" into its parts (first space separates). */ -export function parseCommand(line: string): { identifier: string; arg: string } { - const i = line.indexOf(' ') - return i === -1 - ? { identifier: line, arg: '' } - : { identifier: line.slice(0, i), arg: line.slice(i + 1) } -} - -/** Accumulates ASCII bytes and yields complete newline-terminated lines (CR stripped). */ -export class LineAssembler { - private buf = '' - - push(bytes: Uint8Array): string[] { - for (let i = 0; i < bytes.length; i++) this.buf += String.fromCharCode(bytes[i]!) - const lines: string[] = [] - let idx: number - while ((idx = this.buf.indexOf('\n')) >= 0) { - lines.push(this.buf.slice(0, idx).replace(/\r$/, '')) - this.buf = this.buf.slice(idx + 1) - } - return lines - } -} +export const A4EC_ADDRESS_RANGE = { start: 0x8400, end: 0x8554 } as const diff --git a/src/shared/capture.test.ts b/src/main/dcs/capture-file.test.ts similarity index 92% rename from src/shared/capture.test.ts rename to src/main/dcs/capture-file.test.ts index c3d68c8..f316fc2 100644 --- a/src/shared/capture.test.ts +++ b/src/main/dcs/capture-file.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { CAPTURE_FORMAT, captureDurationMs, parseCapture, serializeCapture } from './capture' -import type { CaptureEvent } from './dcs/ipc' +import { CAPTURE_FORMAT, captureDurationMs, parseCapture, serializeCapture } from './capture-file' +import type { CaptureEvent } from '@shared/dcs' const events: CaptureEvent[] = [ { t: 0, dir: 'in', hex: '5555' }, diff --git a/src/shared/capture.ts b/src/main/dcs/capture-file.ts similarity index 95% rename from src/shared/capture.ts rename to src/main/dcs/capture-file.ts index cfc7017..7ae152b 100644 --- a/src/shared/capture.ts +++ b/src/main/dcs/capture-file.ts @@ -2,7 +2,7 @@ // the firmware repo's dcsbios_data.json: adds per-event direction + session // metadata while keeping hex-encoded raw bytes so a basic replayer can still read it. -import type { CaptureEvent, CaptureMeta } from './dcs/ipc' +import type { CaptureEvent, CaptureMeta } from '@shared/dcs' export interface CaptureFile extends CaptureMeta { format: 'skyhawk-capture/1' diff --git a/src/main/dcs/dcsbios-metadata.ts b/src/main/dcs/dcsbios-metadata.ts deleted file mode 100644 index 1a0b22f..0000000 --- a/src/main/dcs/dcsbios-metadata.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Hand-curated — NOT generated by `npm run sync`. -// -// The DCS-BIOS "common data" / metadata module (the cross-aircraft block that -// carries _ACFT_NAME) is not vendored in the OpenSkyhawk firmware repo, so there -// is no upstream file to sync. These constants come from the DCS-BIOS common -// metadata definition (see PRD.md §12) and change only with DCS-BIOS itself. - -/** Live aircraft module name, exported by DCS-BIOS at a fixed cross-module address. */ -export const ACFT_NAME = { - /** DCS-BIOS export address of the _ACFT_NAME string. */ - address: 0x0000, - /** Null-terminated, max 24 bytes. Empty / "NONE" when no module is loaded. */ - maxLength: 24 -} as const - -/** - * Address window the A-4E-C module exports into. Used to *infer* "A-4E-C" when - * _ACFT_NAME is unavailable, and to gate named decoding (raw-only outside it). - */ -export const A4EC_ADDRESS_RANGE = { start: 0x8400, end: 0x8554 } as const diff --git a/src/main/dcs/index.ts b/src/main/dcs/index.ts index e49b7d9..b1d4114 100644 --- a/src/main/dcs/index.ts +++ b/src/main/dcs/index.ts @@ -1,4 +1,6 @@ // dcs: the DCS-BIOS side of the relay. Exports only what other topics use (arch-phase0-v2 §2.1). +export { DcsBiosProtocol } from './bios-protocol' +export { relayEvents, type RelayContext } from './relay-events' export { createTransport, type Transport } from './transports' export { Decoder } from './sim-telemetry-decoder' export { Recorder, ReplaySource, type ReplayInfo } from './replay-source' diff --git a/src/main/dcs/relay-events.ts b/src/main/dcs/relay-events.ts new file mode 100644 index 0000000..d7471f6 --- /dev/null +++ b/src/main/dcs/relay-events.ts @@ -0,0 +1,70 @@ +// 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 { SourceMode } from '@shared/config' +import type { RelayState } from '@shared/dcs' +import type { LogEventInput } from '@shared/events' + +export interface RelayContext { + mode: SourceMode + /** Where the DCS-BIOS stream comes from, e.g. `127.0.0.1:7778`. */ + endpoint: string + /** TCP connects; UDP only binds, so "connected" there is "listening". */ + tcp: boolean +} + +const MODE_LABEL: Record = { + bridge: 'Bridge mode', + monitor: 'Monitor mode', + replay: 'Replay' +} + +/** + * Relay started / stopped, and the DCS-BIOS stream coming up or dropping while it runs. A stream + * error is not repeated here: the transport's own error already makes a `DCS` row, and the + * reconnect attempts that follow it are not rows either. + */ +export function relayEvents( + prev: RelayState, + next: RelayState, + ctx: RelayContext, + now: number +): LogEventInput[] { + const relay = { t: now, source: 'client' as const, name: 'Relay' } + const dcs = { t: now, source: 'client' as const, name: 'DCS' } + const out: LogEventInput[] = [] + if (!prev.running && next.running) { + out.push({ + ...relay, + severity: 'info', + event: 'started', + detail: MODE_LABEL[ctx.mode], + raw: `relay: start (${ctx.mode})` + }) + } else if (prev.running && !next.running) { + out.push({ ...relay, severity: 'info', event: 'stopped', raw: 'relay: stop' }) + } + if (!next.running || prev.stream === next.stream) return out + if (ctx.mode === 'replay') { + if (next.stream === 'ended') { + out.push({ ...relay, severity: 'info', event: 'replay ended', raw: 'relay: replay ended' }) + } + } else if (next.stream === 'connected') { + out.push({ + ...dcs, + severity: 'info', + event: ctx.tcp ? 'link up' : 'listening', + detail: ctx.endpoint, + raw: `DCS: connected ${ctx.endpoint}` + }) + } else if (next.stream === 'reconnecting' && prev.stream === 'connected') { + // Only a link that was up dropping: retries after an error are that error's row, once. + out.push({ + ...dcs, + severity: 'warn', + event: 'reconnecting', + detail: ctx.endpoint, + raw: `DCS: reconnecting ${ctx.endpoint}` + }) + } + return out +} diff --git a/src/main/dcs/replay-source.test.ts b/src/main/dcs/replay-source.test.ts index e600c68..be7fbf2 100644 --- a/src/main/dcs/replay-source.test.ts +++ b/src/main/dcs/replay-source.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ReplaySource } from './replay-source' -import type { CaptureFile } from '@shared/capture' +import type { CaptureFile } from './capture-file' const file: CaptureFile = { format: 'skyhawk-capture/1', diff --git a/src/main/dcs/replay-source.ts b/src/main/dcs/replay-source.ts index 3cd4d4e..36957a6 100644 --- a/src/main/dcs/replay-source.ts +++ b/src/main/dcs/replay-source.ts @@ -1,14 +1,9 @@ // Capture a live DCS-BIOS session to disk, and replay one as a synthetic export -// source (drive the parser/UI with no DCS running). Reuses the @shared/capture +// source (drive the parser/UI with no DCS running). Reuses the capture-file.ts // format so captures are portable. import { readFileSync, writeFileSync } from 'node:fs' -import { - captureDurationMs, - parseCapture, - serializeCapture, - type CaptureFile -} from '@shared/capture' -import type { CaptureEvent, CaptureMeta } from '@shared/dcs/ipc' +import { captureDurationMs, parseCapture, serializeCapture, type CaptureFile } from './capture-file' +import type { CaptureEvent, CaptureMeta } from '@shared/dcs' export class Recorder { private events: CaptureEvent[] = [] diff --git a/src/main/dcs/sim-telemetry-decoder.ts b/src/main/dcs/sim-telemetry-decoder.ts index 214ff00..bad4ebc 100644 --- a/src/main/dcs/sim-telemetry-decoder.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -1,9 +1,8 @@ // Turns raw DCS-BIOS writes into named log rows, aircraft state, and the five // sim-telemetry readouts — using the generated A-4E-C reference. import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' -import { A4EC_ADDRESS_RANGE } from './dcsbios-metadata' -import { StringRegion, ACFT_NAME_ADDRESS, ACFT_NAME_MAX } from '@shared/dcsbios' -import type { AircraftStatus, LogRow, TelemetryReadout } from '@shared/dcs/ipc' +import { A4EC_ADDRESS_RANGE, StringRegion, ACFT_NAME_ADDRESS, ACFT_NAME_MAX } from './bios-protocol' +import type { AircraftStatus, LogRow, TelemetryReadout } from '@shared/dcs' interface TelemetrySpec { id: string diff --git a/src/main/dcs/stream-stats.ts b/src/main/dcs/stream-stats.ts index 323c559..4814d9c 100644 --- a/src/main/dcs/stream-stats.ts +++ b/src/main/dcs/stream-stats.ts @@ -1,7 +1,7 @@ // Rolling counters for the Stream Health / Command Activity panels. // snapshot() reports per-second rates as deltas since the previous snapshot, // so it is expected to be called on a steady ~1 Hz tick. -import type { StatsSnapshot } from '@shared/dcs/ipc' +import type { StatsSnapshot } from '@shared/dcs' export class Stats { private bytesIn = 0 diff --git a/src/main/dcs/transports.ts b/src/main/dcs/transports.ts index 96c9aa6..31de1cd 100644 --- a/src/main/dcs/transports.ts +++ b/src/main/dcs/transports.ts @@ -3,8 +3,8 @@ // without caring which transport produced the bytes. import dgram from 'node:dgram' import net from 'node:net' -import { EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcs/ipc' -import type { AppConfig } from '@shared/config/ipc' +import { EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcs' +import type { AppConfig } from '@shared/config' export interface Transport { start(): void diff --git a/src/main/events/event-log.ts b/src/main/events/event-log.ts new file mode 100644 index 0000000..1972d39 --- /dev/null +++ b/src/main/events/event-log.ts @@ -0,0 +1,77 @@ +// Event log (#64): what the client observed, node and client, as a session list (pure, no +// Node/Electron deps). In memory only — never written to the debug log — and cleared on Clear or +// restart. +import { MAX_EVENTS, type LogEvent, type LogEventInput } from '@shared/events' +import { fmtDateTime } from '@shared/format' + +// ── client events ─────────────────────────────────────────────────────────── + +/** A client diagnostic — the `SERIAL` / `DCS` errors the Stream used to show as `! SYS`. */ +export function clientErrorEvent(tag: string, message: string, now: number): LogEventInput { + return { + t: now, + source: 'client', + name: tag, + severity: 'error', + event: tag === 'SERIAL' ? 'serial port error' : tag === 'DCS' ? 'link error' : 'error', + detail: message, + raw: `${tag} Error: ${message}` + } +} + +// ── the session list ──────────────────────────────────────────────────────── + +/** The session's events, oldest first, plus what has not been pushed to the renderer yet. */ +export class EventLog { + private rows: LogEvent[] = [] + private pending: LogEvent[] = [] + private nextId = 1 + + constructor(private readonly max = MAX_EVENTS) {} + + add(inputs: LogEventInput[]): void { + for (const input of inputs) { + const e = { ...input, id: this.nextId++ } + this.rows.push(e) + this.pending.push(e) + } + if (this.rows.length > this.max) this.rows.splice(0, this.rows.length - this.max) + if (this.pending.length > this.max) this.pending.splice(0, this.pending.length - this.max) + } + + /** Rows added since the last call, oldest first. */ + take(): LogEvent[] { + const p = this.pending + this.pending = [] + return p + } + + snapshot(): LogEvent[] { + return [...this.rows] + } + + clear(): void { + this.rows = [] + this.pending = [] + } +} + +// ── export ────────────────────────────────────────────────────────────────── + +/** A tab or line break inside a field would shift every column after it. */ +const cell = (s: string | number | undefined): string => + s === undefined ? '' : String(s).replace(/[\t\r\n]+/g, ' ') + +export const EVENTS_TSV_HEADER = 'date_time\tsource\tnode_id\tname\tseverity\tevent\tdetail\traw' + +/** Every row, oldest first, whatever the view filters — like the stream export. */ +export function eventsToTsv(rows: LogEvent[]): string { + const body = [...rows] + .sort((a, b) => a.id - b.id) + .map((e) => + [fmtDateTime(e.t), e.source, e.nodeId, e.name, e.severity, e.event, e.detail, e.raw] + .map(cell) + .join('\t') + ) + return [EVENTS_TSV_HEADER, ...body].join('\n') + '\n' +} diff --git a/src/shared/events.test.ts b/src/main/events/events.test.ts similarity index 95% rename from src/shared/events.test.ts rename to src/main/events/events.test.ts index 395319e..5783f4a 100644 --- a/src/shared/events.test.ts +++ b/src/main/events/events.test.ts @@ -1,21 +1,15 @@ import { describe, expect, it } from 'vitest' -import { - clientErrorEvent, - deviceEvent, - EventLog, - EVENTS_TSV_HEADER, - eventsToTsv, - isProblem, - NodeEventTracker, - relayEvents, - type NodeLookup -} from './events' -import type { LogEventInput } from './events/ipc' -import { eventText } from './format' -import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS } from './nodes' -import type { NodeStatus, NodeTelem } from './nodes/ipc' -import { PDU_FLAG, RAIL_FLAG } from './node-telem' -import { nodeStatus } from '../test-fixtures/nodes' +import { clientErrorEvent, EventLog, EVENTS_TSV_HEADER, eventsToTsv } from './event-log' +import { isProblem } from '@shared/events/legacy-rules' +import { relayEvents } from '../dcs/relay-events' +import { deviceEvent } from '../gateway/link-events' +import { NodeEventTracker, type NodeLookup } from '../nodes/node-events' +import type { LogEventInput } from '@shared/events' +import { eventText } from '@shared/format' +import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS } from '../nodes/node-status-wire' +import type { NodeStatus, NodeTelem } from '@shared/nodes' +import { PDU_FLAG, RAIL_FLAG } from '@shared/nodes/legacy-rules' +import { nodeStatus } from '../../test-fixtures/nodes' const st = (nodeId: number, over: Partial = {}): NodeStatus => nodeStatus(nodeId, { uptimeSec: 0, rxCount: 0, dieTempC: null, ...over }) diff --git a/src/main/events/index.ts b/src/main/events/index.ts new file mode 100644 index 0000000..f3b2724 --- /dev/null +++ b/src/main/events/index.ts @@ -0,0 +1,4 @@ +// events: the event log and the status-flag vocabulary. Exports only what other topics use +// (arch-phase0-v2 §2.1). +export { clientErrorEvent, EventLog, eventsToTsv } from './event-log' +export { faultLabel, STATUS_FLAGS, type StatusFlag } from './status-flags' diff --git a/src/main/events/status-flags.ts b/src/main/events/status-flags.ts new file mode 100644 index 0000000..51cbcab --- /dev/null +++ b/src/main/events/status-flags.ts @@ -0,0 +1,19 @@ +// The node status-flag vocabulary (pure, no Node/Electron deps): the heartbeat / health flags and +// fault labels that node rows and the Gateway's own row share (arch-phase0-v2 OQ4). +import type { EventSeverity } from '@shared/events' +import type { NodeStatus } from '@shared/nodes' + +export type StatusFlag = 'boff' | 'epvf' | 'degraded' | 'overheat' + +/** A node's own heartbeat / health flags, as every row and the Gateway (#94) name them. */ +export const STATUS_FLAGS: { key: StatusFlag; label: string; severity: EventSeverity }[] = [ + { key: 'boff', label: 'bus-off', severity: 'error' }, + { key: 'epvf', label: 'error-passive', severity: 'warn' }, + { key: 'degraded', label: 'degraded', severity: 'warn' }, + { key: 'overheat', label: 'overheat', severity: 'warn' } +] + +/** The fault-dictionary label, else `Fault N`. */ +export function faultLabel(n: Pick): string { + return n.faultLabel ?? `Fault ${n.faultId}` +} diff --git a/src/main/gateway/device-link.ts b/src/main/gateway/device-link.ts index de56047..e78d92b 100644 --- a/src/main/gateway/device-link.ts +++ b/src/main/gateway/device-link.ts @@ -6,21 +6,8 @@ // and device traffic comes back through onBytes() / onLine(). That split is the point. Bundled // together, there was no node status, PDU telemetry, HID or calibration unless DCS was being // relayed, and stopping the relay tore the port down with it. -import { LineAssembler, parseCommand } from '@shared/dcsbios' -import { - BRIDGE_NODE_ID, - NODE_END_MSG, - NODE_MSG, - NodeRoster, - cleanNodeLine, - isLegacyNodeStatus, - nodeRosterRequest, - parseNodeStatus -} from '@shared/nodes' -import { GatewayWatch } from '@shared/gateway' -import { NODE_TELEM_MSG } from '@shared/node-telem' -import type { LogEventInput } from '@shared/events/ipc' -import { isLinkUp, type DeviceStatus, type SerialFrame } from '@shared/gateway/ipc' +import type { LogEventInput } from '@shared/events' +import { isLinkUp, type DeviceStatus, type SerialFrame } from '@shared/gateway' import type { CachedAxis, CachedBoard, @@ -30,16 +17,20 @@ import type { CalRawSample, CalResult, CalSnapshot -} from '@shared/hid/calibration/ipc' -import type { PushChannel, PushChannels } from '@shared/app/ipc' -import { CAL_AXIS_NONE, nackReasonName } from '@shared/calibration' -import { boardFor, dropAxis, regressedAxes, storeAxes } from '@shared/cal-cache' +} from '@shared/hid/calibration' +import type { PushChannel, PushChannels } from '@shared/app' import { + boardFor, + CAL_AXIS_NONE, CalLink, CalNackError, CalTimeoutError, + dropAxis, loadCalCache, - saveCalCache + nackReasonName, + regressedAxes, + saveCalCache, + storeAxes } from '../hid/calibration' import { findSimGatewayPort, @@ -50,8 +41,22 @@ import { SIMGATEWAY_VID, type Log } from './serial-bridge' +import { GatewayWatch } from './gateway-health' +import { LineAssembler, parseCommand } from './link-framing' import { HidReader } from '../hid' -import { NodeHub, withFaultInfo } from '../nodes' +import { + BRIDGE_NODE_ID, + NODE_END_MSG, + NODE_MSG, + NODE_TELEM_MSG, + NodeHub, + NodeRoster, + cleanNodeLine, + isLegacyNodeStatus, + nodeRosterRequest, + parseNodeStatus, + withFaultInfo +} from '../nodes' type Emit = (channel: C, payload: PushChannels[C]) => void @@ -425,7 +430,7 @@ export class DeviceLink { /** * Any node-status line answers the roster request; the Gateway's own row (node 0) is its health. - * That row stays out of the cockpit model (node-rules); this is where it is shown instead (#94). + * That row stays out of the cockpit model (node-model); this is where it is shown instead (#94). */ private gatewayLine(identifier: string, arg: string, raw: string, t: number): void { this.report(this.gateway.answered(raw, t)) diff --git a/src/shared/gateway.test.ts b/src/main/gateway/gateway-health.test.ts similarity index 97% rename from src/shared/gateway.test.ts rename to src/main/gateway/gateway-health.test.ts index b0a4f08..a78cef6 100644 --- a/src/shared/gateway.test.ts +++ b/src/main/gateway/gateway-health.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { GATEWAY_NO_ANSWER_MS, GatewayWatch, gatewayEvents, gatewayStatus } from './gateway' -import { eventText } from './format' -import type { LogEventInput } from './events/ipc' -import type { NodeStatus } from './nodes/ipc' -import { nodeStatus } from '../test-fixtures/nodes' +import { GATEWAY_NO_ANSWER_MS, GatewayWatch, gatewayEvents, gatewayStatus } from './gateway-health' +import { eventText } from '@shared/format' +import type { LogEventInput } from '@shared/events' +import type { NodeStatus } from '@shared/nodes' +import { nodeStatus } from '../../test-fixtures/nodes' const node0 = (over: Partial = {}): NodeStatus => nodeStatus(0, { rxCount: 5, dieTempC: 42, nodeKind: 3, ...over }) diff --git a/src/shared/gateway.ts b/src/main/gateway/gateway-health.ts similarity index 96% rename from src/shared/gateway.ts rename to src/main/gateway/gateway-health.ts index f44b6da..44eb1c7 100644 --- a/src/shared/gateway.ts +++ b/src/main/gateway/gateway-health.ts @@ -1,19 +1,19 @@ // The Gateway's own health (#94), pure (no Node/Electron deps). The Gateway is the SimGateway + // PanelBridge board: the client's own link, never a cockpit node. Since firmware v3 // (OpenSkyhawk#344) it reports itself as the node-0 row of `_NODE_STATUS`; the cockpit model leaves -// that row out (node-rules), and this module carries it instead. +// that row out (nodes/node-model), and this module carries it instead. // // The one owner of the Gateway's health: `gatewayStatus` decides severity, label, note and alert, // main computes it (GatewayWatch) and sends it on `device:status`, and every surface — pill, // popover, Overview alert, Cockpit Attention, event log — only reads it. Every alert is a flag the // Gateway raised itself; the one client-side rule is "not responding": once it has answered since // the port opened, a roster request unanswered for GATEWAY_NO_ANSWER_MS while the port is open. -import { faultLabel, STATUS_FLAGS, type StatusFlag } from './events' -import type { LogEventInput } from './events/ipc' -import { NODE_FAULT_CODES, type NodeFaultName } from './firmware' -import { title } from './format' -import type { NodeStatus } from './nodes/ipc' -import type { GatewayKind, GatewayStatus } from './gateway/ipc' +import type { LogEventInput } from '@shared/events' +import { NODE_FAULT_CODES, type NodeFaultName } from '@shared/firmware' +import { title } from '@shared/format' +import type { NodeStatus } from '@shared/nodes' +import type { GatewayKind, GatewayStatus } from '@shared/gateway' +import { faultLabel, STATUS_FLAGS, type StatusFlag } from '../events' /** How long a roster request may go unanswered, with the port open, before the Gateway is "not responding". */ export const GATEWAY_NO_ANSWER_MS = 5000 diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts index 27c03f6..41af677 100644 --- a/src/main/gateway/index.ts +++ b/src/main/gateway/index.ts @@ -1,3 +1,4 @@ // gateway: the SimGateway USB link. Exports only what other topics use (arch-phase0-v2 §2.1). export { DeviceLink, type DeviceLinkDeps } from './device-link' +export { deviceEvent } from './link-events' export { listSerialPorts } from './serial-bridge' diff --git a/src/main/gateway/link-events.ts b/src/main/gateway/link-events.ts new file mode 100644 index 0000000..5121385 --- /dev/null +++ b/src/main/gateway/link-events.ts @@ -0,0 +1,33 @@ +// Device-link events (pure, no Node/Electron deps): the event-log row a Gateway link change makes. +import type { LogEventInput } from '@shared/events' +import type { DeviceStatus } from '@shared/gateway' + +/** + * A device-link change worth a row, or null. `last` is the last state other than `scanning`, + * which never makes a row — it comes and goes on every retry — so a retry loop of + * scanning → no-device → scanning adds nothing after its first "no device". + */ +export function deviceEvent( + last: DeviceStatus['state'] | undefined, + next: DeviceStatus, + now: number +): LogEventInput | null { + if (next.state === 'scanning' || next.state === last) return null + const base = { t: now, source: 'client' as const, name: 'Gateway' } + const raw = ['device:', next.state, next.portPath, next.detail].filter(Boolean).join(' ') + switch (next.state) { + case 'connected': + return { ...base, severity: 'info', event: 'connected', detail: next.portPath, raw } + case 'reconnecting': + return { ...base, severity: 'warn', event: 'reconnecting', detail: 'link dropped', raw } + case 'error': + // The link raises the same failure as a SERIAL error, which already makes the row. + return null + case 'no-device': + // Launching (or pressing Connect) with nothing plugged in is a resting state, not news. + if (last === undefined || last === 'disconnected') return null + return { ...base, severity: 'warn', event: 'no device', detail: 'unplugged', raw } + case 'disconnected': + return { ...base, severity: 'info', event: 'disconnected', raw } + } +} diff --git a/src/main/gateway/link-framing.ts b/src/main/gateway/link-framing.ts new file mode 100644 index 0000000..a21b17a --- /dev/null +++ b/src/main/gateway/link-framing.ts @@ -0,0 +1,47 @@ +// DCS-BIOS line framing on the SimGateway link (pure, no Node/Electron deps): the command lines +// the device sends, and the export frame the client injects. The gateway and nodes topics both +// need these, so they live here rather than in dcs (arch-phase0-v2 OQ2). + +/** + * Encode a single 16-bit DCS-BIOS export write as a self-contained frame: + * sync (4×0x55) + address(LE) + count=2(LE) + value(LE). Used to inject the + * node-roster request into the export stream sent to the SimGateway. + */ +export function encodeExportFrame(address: number, value: number): Uint8Array { + return new Uint8Array([ + 0x55, + 0x55, + 0x55, + 0x55, + address & 0xff, + (address >> 8) & 0xff, + 0x02, + 0x00, + value & 0xff, + (value >> 8) & 0xff + ]) +} + +/** Split "IDENTIFIER ARG" into its parts (first space separates). */ +export function parseCommand(line: string): { identifier: string; arg: string } { + const i = line.indexOf(' ') + return i === -1 + ? { identifier: line, arg: '' } + : { identifier: line.slice(0, i), arg: line.slice(i + 1) } +} + +/** Accumulates ASCII bytes and yields complete newline-terminated lines (CR stripped). */ +export class LineAssembler { + private buf = '' + + push(bytes: Uint8Array): string[] { + for (let i = 0; i < bytes.length; i++) this.buf += String.fromCharCode(bytes[i]!) + const lines: string[] = [] + let idx: number + while ((idx = this.buf.indexOf('\n')) >= 0) { + lines.push(this.buf.slice(0, idx).replace(/\r$/, '')) + this.buf = this.buf.slice(idx + 1) + } + return lines + } +} diff --git a/src/shared/cal-cache.test.ts b/src/main/hid/calibration/cache-rules.test.ts similarity index 98% rename from src/shared/cal-cache.test.ts rename to src/main/hid/calibration/cache-rules.test.ts index 16ce4b0..676785b 100644 --- a/src/shared/cal-cache.test.ts +++ b/src/main/hid/calibration/cache-rules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { CachedAxis, CalCache, CalSnapshot } from './hid/calibration/ipc' +import type { CachedAxis, CalCache, CalSnapshot } from '@shared/hid/calibration' import { boardFor, dropAxis, @@ -7,7 +7,7 @@ import { regressedAxes, sanitizeCache, storeAxes -} from './cal-cache' +} from './cache-rules' const SERIAL = '50031327805E871C' const axis = (over: Partial = {}): CachedAxis => ({ diff --git a/src/shared/cal-cache.ts b/src/main/hid/calibration/cache-rules.ts similarity index 89% rename from src/shared/cal-cache.ts rename to src/main/hid/calibration/cache-rules.ts index b20732b..90387dc 100644 --- a/src/shared/cal-cache.ts +++ b/src/main/hid/calibration/cache-rules.ts @@ -13,7 +13,7 @@ // reverts to identity passthrough, reporting `calibratedMask = 0` rather than serving garbage. The // loss is always visible. -import type { CachedAxis, CachedBoard, CalCache, CalSnapshot } from './hid/calibration/ipc' +import type { CachedAxis, CachedBoard, CalCache, CalSnapshot } from '@shared/hid/calibration' export const CAL_CACHE_VERSION = 1 @@ -134,3 +134,18 @@ export function regressedAxes(cache: CalCache, snapshot?: CalSnapshot): number[] }) .sort((a, b) => a - b) } + +/** + * Calibration merges into the client's copy per board serial: the axes the file has replace the + * same axes, every other cached axis and board is kept. The board's date is the newer of the two. + */ +export function mergeCalibration(cache: CalCache, imported: CalCache): CalCache { + const boards = { ...cache.boards } + for (const [serial, board] of Object.entries(imported.boards)) { + const cur = boards[serial] + const confirmedAt = + cur && cur.confirmedAt > board.confirmedAt ? cur.confirmedAt : board.confirmedAt + boards[serial] = { confirmedAt, axes: { ...cur?.axes, ...board.axes } } + } + return { ...cache, boards } +} diff --git a/src/main/hid/calibration/cache-store.ts b/src/main/hid/calibration/cache-store.ts index e847f73..1fd53e1 100644 --- a/src/main/hid/calibration/cache-store.ts +++ b/src/main/hid/calibration/cache-store.ts @@ -5,8 +5,8 @@ // is a recovery aid, so failing to keep one leaves the user exactly where they were, whereas // throwing would break the write that had already succeeded on the device. import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { emptyCache, sanitizeCache } from '@shared/cal-cache' -import type { CalCache } from '@shared/hid/calibration/ipc' +import { emptyCache, sanitizeCache } from './cache-rules' +import type { CalCache } from '@shared/hid/calibration' /** Port: where calibration-cache.json lives. app hands it in (this module never asks electron). */ export interface CachePaths { diff --git a/src/main/hid/calibration/cal-link.test.ts b/src/main/hid/calibration/cal-link.test.ts index 94a6d48..221c963 100644 --- a/src/main/hid/calibration/cal-link.test.ts +++ b/src/main/hid/calibration/cal-link.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CAL_AXIS_NONE, CAL_NACK, CAL_TYPE, buildFrame } from '@shared/calibration' +import { CAL_AXIS_NONE, CAL_NACK, CAL_TYPE, buildFrame } from './codec' // debug.ts imports electron for its log path; nothing here needs the real thing. vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) diff --git a/src/main/hid/calibration/cal-link.ts b/src/main/hid/calibration/cal-link.ts index a980371..476b07e 100644 --- a/src/main/hid/calibration/cal-link.ts +++ b/src/main/hid/calibration/cal-link.ts @@ -1,7 +1,7 @@ // Calibration control channel over the SimGateway's CDC port (main process). // // Owns the request/response half of the protocol in FirmwarePlan/03-uart-usb-hid-protocol.md -// § Calibration Protocol (USB CDC). The codec itself is in @shared/calibration; this adds SEQ +// § Calibration Protocol (USB CDC). The codec itself is in codec.ts; this adds SEQ // matching, timeouts, session upkeep, and the export-gate signal Session consumes. // // It does NOT own the serial port. Session does, and hands bytes in via ingest(); this returns @@ -25,7 +25,7 @@ import { type CalFrame, type HelloAck, type RawSample -} from '@shared/calibration' +} from './codec' import { CAL_PROTO } from '@shared/firmware' /** Port: the debug log, handed in by app (debug.log lives in app). */ diff --git a/src/shared/calibration.test.ts b/src/main/hid/calibration/codec.test.ts similarity index 99% rename from src/shared/calibration.test.ts rename to src/main/hid/calibration/codec.test.ts index b279d33..5a3ac65 100644 --- a/src/shared/calibration.test.ts +++ b/src/main/hid/calibration/codec.test.ts @@ -18,7 +18,7 @@ import { encodeHello, encodeReset, encodeSessionOpen -} from './calibration' +} from './codec' const hex = (s: string) => Uint8Array.from(s.match(/../g)!.map((b) => parseInt(b, 16))) const cat = (...parts: Uint8Array[]) => { diff --git a/src/shared/calibration.ts b/src/main/hid/calibration/codec.ts similarity index 99% rename from src/shared/calibration.ts rename to src/main/hid/calibration/codec.ts index a0ae8e0..bc0d00b 100644 --- a/src/shared/calibration.ts +++ b/src/main/hid/calibration/codec.ts @@ -29,7 +29,7 @@ import { CAL_STREAM_SELECT_FIELD, CAL_TYPES, type CalNackName -} from './firmware' +} from '@shared/firmware' /** * Constants from the generated firmware reference (shared/firmware, AxisCal.h), under the names diff --git a/src/main/hid/calibration/index.ts b/src/main/hid/calibration/index.ts index 31c8712..ae23301 100644 --- a/src/main/hid/calibration/index.ts +++ b/src/main/hid/calibration/index.ts @@ -2,3 +2,5 @@ // (arch-phase0-v2 §2.1). export { CalLink, CalNackError, CalTimeoutError } from './cal-link' export { loadCalCache, saveCalCache } from './cache-store' +export { boardFor, dropAxis, mergeCalibration, regressedAxes, storeAxes } from './cache-rules' +export { CAL_AXIS_NONE, nackReasonName } from './codec' diff --git a/src/main/hid/hid-reader.ts b/src/main/hid/hid-reader.ts index 63b440b..1111864 100644 --- a/src/main/hid/hid-reader.ts +++ b/src/main/hid/hid-reader.ts @@ -12,8 +12,8 @@ import { HID_BUTTON_COUNT, HID_HAT_COUNT, HID_REPORT_SIZE -} from '@shared/hid' -import type { HidSnapshot } from '@shared/hid/ipc' +} from './hid-report' +import type { HidSnapshot } from '@shared/hid' import { USB_IDENTITY } from '@shared/firmware' /** Matches the serial link's reconnect cadence — the same cable, coming back at the same time. */ diff --git a/src/shared/hid.test.ts b/src/main/hid/hid-report.test.ts similarity index 97% rename from src/shared/hid.test.ts rename to src/main/hid/hid-report.test.ts index fead74c..773efec 100644 --- a/src/shared/hid.test.ts +++ b/src/main/hid/hid-report.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { decodeAxis, isButtonPressed, decodeHat, decodeReport, HID_REPORT_SIZE } from './hid' +import { decodeAxis, isButtonPressed, decodeHat, decodeReport, HID_REPORT_SIZE } from './hid-report' describe('HID decode', () => { it('decodeAxis sign-extends a 16-bit field', () => { diff --git a/src/shared/hid.ts b/src/main/hid/hid-report.ts similarity index 97% rename from src/shared/hid.ts rename to src/main/hid/hid-report.ts index 51b1712..8a180e5 100644 --- a/src/shared/hid.ts +++ b/src/main/hid/hid-report.ts @@ -5,7 +5,7 @@ // int16_t axes[8] // X Y Z Rx Ry Rz Slider Dial (little-endian) // => 34 bytes, no report id. The numbers come from HID_REPORT_LAYOUT (shared/firmware), which the // sync asserts against that struct. -import { HID_REPORT_LAYOUT } from './firmware' +import { HID_REPORT_LAYOUT } from '@shared/firmware' export const HID_REPORT_SIZE = HID_REPORT_LAYOUT.size export const HID_AXIS_COUNT = HID_REPORT_LAYOUT.axes.count diff --git a/src/main/nodes/faults.ts b/src/main/nodes/faults.ts new file mode 100644 index 0000000..5575229 --- /dev/null +++ b/src/main/nodes/faults.ts @@ -0,0 +1,33 @@ +// Node fault and flag text (pure, no Node/Electron deps): what a raised PDU rail or status flag is +// called in an event row, and the reading that goes with it. +import type { EventSeverity } from '@shared/events' +import { amps, volts } from '@shared/format' +import type { PduRailPage } from '@shared/nodes' +import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/nodes/legacy-rules' + +/** Rail flag text, as the Cockpit prints it (`5V UNDER-V`). */ +export const RAIL_FLAGS: [number, string][] = [ + [RAIL_FLAG.UNDER_V, 'UNDER-V'], + [RAIL_FLAG.OVER_V, 'OVER-V'], + [RAIL_FLAG.OVER_CURRENT, 'OVER-I'], + [RAIL_FLAG.FUSE_OPEN, 'FUSE OPEN'], + [RAIL_FLAG.SENSOR_FAULT, 'SENSOR FAULT'] +] + +/** UNCAL_* are calibration state, not alerts (as on the Cockpit), so they are info. */ +export const PDU_STATUS_FLAGS: [number, string, EventSeverity][] = [ + [PDU_FLAG.TEMP_CRIT, 'TEMP CRIT', 'warn'], + [PDU_FLAG.TEMP_WARN, 'TEMP WARN', 'warn'], + [PDU_FLAG.NTC_FAULT, 'NTC FAULT', 'warn'], + [PDU_FLAG.UNCAL_VDDA, 'UNCAL VDDA', 'info'], + [PDU_FLAG.UNCAL_ZERO, 'UNCAL ZERO', 'info'] +] + +export const TEMP_FLAGS = PDU_FLAG.TEMP_CRIT | PDU_FLAG.TEMP_WARN + +/** The reading that goes with a raised rail flag: the window minimum for under-voltage. */ +export function railReading(bit: number, p: PduRailPage): string { + if (bit === RAIL_FLAG.UNDER_V) return volts(p.vMinMv) + if (bit === RAIL_FLAG.OVER_CURRENT) return amps(displayCurrentMa(p.iAvgMa)) + return volts(p.vAvgMv) +} diff --git a/src/main/nodes/index.ts b/src/main/nodes/index.ts index b0dbf5b..a7e2a9f 100644 --- a/src/main/nodes/index.ts +++ b/src/main/nodes/index.ts @@ -1,3 +1,14 @@ // nodes: the CAN node picture and the node mapping. Exports only what other topics use // (arch-phase0-v2 §2.1). export { NodeHub, withFaultInfo } from './node-hub' +export { + BRIDGE_NODE_ID, + cleanNodeLine, + isLegacyNodeStatus, + NODE_END_MSG, + NODE_MSG, + NodeRoster, + nodeRosterRequest, + parseNodeStatus +} from './node-status-wire' +export { NODE_TELEM_MSG } from './node-telem' diff --git a/src/shared/node-mapping.test.ts b/src/main/nodes/mapping-rules.test.ts similarity index 99% rename from src/shared/node-mapping.test.ts rename to src/main/nodes/mapping-rules.test.ts index 7932dda..ee899a1 100644 --- a/src/shared/node-mapping.test.ts +++ b/src/main/nodes/mapping-rules.test.ts @@ -9,15 +9,15 @@ import { importMapping, MappingError, markSeen, - registryDefaults, resetMapping, resetNode, resetPreview, sanitizeNodeMapping, setHidden, setNodeMapping -} from './node-mapping' -import type { AdoptSpec, MappingPatch, NodeMappingFile } from './nodes/ipc' +} from './mapping-rules' +import { registryDefaults } from './registry' +import type { AdoptSpec, MappingPatch, NodeMappingFile } from '@shared/nodes' // Mirrors the bundled NODE_IDS at the #216 pin. const REGISTRY = { diff --git a/src/shared/node-mapping.ts b/src/main/nodes/mapping-rules.ts similarity index 90% rename from src/shared/node-mapping.ts rename to src/main/nodes/mapping-rules.ts index 8d25c96..010b64f 100644 --- a/src/shared/node-mapping.ts +++ b/src/main/nodes/mapping-rules.ts @@ -7,8 +7,7 @@ // added, only what differs, and when it was last seen. Adopted nodes — NODE_IDs the registry does // not know, named by the user — are stored in full, since there is nothing to default from (#93). -import { MAX_NODE_ID } from './firmware' -import { isAdoptableKind } from './node-kind' +import { MAX_NODE_ID } from '@shared/firmware' import type { AdoptedRow, AdoptSpec, @@ -22,7 +21,9 @@ import type { ResetOptions, ResetPreview, ResetRow -} from './nodes/ipc' +} from '@shared/nodes' +import { isAdoptableKind } from '@shared/nodes/legacy-rules' +import { adopted, forgettable, inCockpit } from './membership' /** * A NODE_ID's type from live signals (node-kind.ts resolveKind over the roster's reported kind and @@ -42,7 +43,7 @@ export const EMPTY_MAPPING: NodeMappingFile = { version: 3, nodes: {} } const MAX_TEXT = 64 -const isNodeId = (v: unknown): v is number => +export const isNodeId = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= MAX_NODE_ID const text = (v: unknown): string | undefined => { @@ -51,33 +52,6 @@ const text = (v: unknown): string | undefined => { return t || undefined } -/** PDUs are named `PDU_` in NODE_IDS.md, which has no type column. */ -export const isPduName = (name: string): boolean => /^PDU_/.test(name) - -/** - * Registry defaults, keyed by NODE_ID. NODE_ID 0 (PanelBridge) never appears on the bus and is - * left out. A panel's default power zone is the PDU on its console when there is exactly one; - * with none or several there is no safe guess, so it is left unmapped. - */ -export function registryDefaults( - registry: Record -): Map { - const ids = Object.keys(registry) - .map(Number) - .filter(isNodeId) - .sort((a, b) => a - b) - const pdusOn = (console: string) => - ids.filter((id) => isPduName(registry[id]!.name) && registry[id]!.console === console) - const out = new Map() - for (const id of ids) { - const { name, console } = registry[id]! - const kind: NodeKind = isPduName(name) ? 'pdu' : 'panel' - const pdus = kind === 'panel' ? pdusOn(console) : [] - out.set(id, { name, console, kind, pduId: pdus.length === 1 ? pdus[0]! : null }) - } - return out -} - function sanitizeOverride(raw: unknown, legacy: boolean): NodeOverride | undefined { if (!raw || typeof raw !== 'object') return undefined const r = raw as Record @@ -121,20 +95,6 @@ export function sanitizeNodeMapping(raw: unknown): NodeMappingFile { return { version: 3, nodes } } -/** An adopted node is a non-registry entry complete enough to place: name, console and kind. */ -function adopted( - o: NodeOverride | undefined -): o is NodeOverride & { name: string; kind: NodeKind } { - return !!o?.name && !!o.kind -} - -/** - * In your cockpit: a registry board you added, or a board you adopted — and an adopted board stays - * in it even once its NODE_ID is in NODE_IDS.md (a later registry); the registry is only where - * names come from, never what decides membership (#93 re-review F). The one rule every check uses. - */ -const inCockpit = (o: NodeOverride | undefined): boolean => o?.added === true || adopted(o) - const hasOverride = (o: NodeOverride): boolean => o.name !== undefined || o.console !== undefined || o.pduId !== undefined @@ -421,14 +381,6 @@ function keptOnReset(o: NodeOverride | undefined): NodeOverride { return kept } -/** The NODE_IDs "Also forget adopted nodes" drops — exactly the ones the review lists. */ -function forgettable(defaults: Map, file: NodeMappingFile): number[] { - return Object.keys(file.nodes) - .map(Number) - .filter((id) => !defaults.has(id) && adopted(file.nodes[id])) - .sort((a, b) => a - b) -} - /** * Every registry node back to its defaults. Adopted nodes, being added, hidden flags and last-seen * times are not registry overrides, so they stay — unless `forgetAdopted`, which also drops the adopted nodes diff --git a/src/main/nodes/mapping-store.seam.test.ts b/src/main/nodes/mapping-store.seam.test.ts index 595ddcc..7f77863 100644 --- a/src/main/nodes/mapping-store.seam.test.ts +++ b/src/main/nodes/mapping-store.seam.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { NodeMappingFile } from '@shared/nodes/ipc' +import type { NodeMappingFile } from '@shared/nodes' import { storePaths } from '../app/user-data' const userData = vi.hoisted(() => ({ dir: '' })) diff --git a/src/main/nodes/mapping-store.test.ts b/src/main/nodes/mapping-store.test.ts index 477b4f1..a349eef 100644 --- a/src/main/nodes/mapping-store.test.ts +++ b/src/main/nodes/mapping-store.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { EMPTY_MAPPING, importMapping } from '@shared/node-mapping' -import type { NodeMappingFile } from '@shared/nodes/ipc' +import { EMPTY_MAPPING, importMapping } from './mapping-rules' +import type { NodeMappingFile } from '@shared/nodes' import { storePaths } from '../app/user-data' const userData = vi.hoisted(() => ({ dir: '' })) diff --git a/src/main/nodes/mapping-store.ts b/src/main/nodes/mapping-store.ts index 1c4c25b..4aa1b63 100644 --- a/src/main/nodes/mapping-store.ts +++ b/src/main/nodes/mapping-store.ts @@ -1,12 +1,12 @@ -// Persist the node mapping to /node-mapping.json (shape and rules: shared/node-mapping.ts). +// Persist the node mapping to /node-mapping.json (shape and rules: mapping-rules.ts). // Where that file lives is handed in by app (MappingPaths): this module never asks electron. // // Unlike config.json, a lost write here is user work gone (names, adopted boards, hidden boards), so // failures are reported rather than swallowed, writes are atomic, and a file this build cannot // safely overwrite — unreadable, or from a newer client — is loaded read-only. import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from '@shared/node-mapping' -import type { NodeMappingFile } from '@shared/nodes/ipc' +import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from './mapping-rules' +import type { NodeMappingFile } from '@shared/nodes' import { errorText, stamp } from '@shared/format' /** A save that did not reach the disk. The message is ready to show. */ diff --git a/src/main/nodes/membership.ts b/src/main/nodes/membership.ts new file mode 100644 index 0000000..d95006f --- /dev/null +++ b/src/main/nodes/membership.ts @@ -0,0 +1,25 @@ +// Cockpit membership (pure, no Node/Electron deps): which node-mapping entries are in your cockpit +// — a registry board you added, or a board you adopted. +import type { NodeDefaults, NodeKind, NodeMappingFile, NodeOverride } from '@shared/nodes' + +/** An adopted node is a non-registry entry complete enough to place: name, console and kind. */ +export function adopted( + o: NodeOverride | undefined +): o is NodeOverride & { name: string; kind: NodeKind } { + return !!o?.name && !!o.kind +} + +/** + * In your cockpit: a registry board you added, or a board you adopted — and an adopted board stays + * in it even once its NODE_ID is in NODE_IDS.md (a later registry); the registry is only where + * names come from, never what decides membership (#93 re-review F). The one rule every check uses. + */ +export const inCockpit = (o: NodeOverride | undefined): boolean => o?.added === true || adopted(o) + +/** The NODE_IDs "Also forget adopted nodes" drops — exactly the ones the review lists. */ +export function forgettable(defaults: Map, file: NodeMappingFile): number[] { + return Object.keys(file.nodes) + .map(Number) + .filter((id) => !defaults.has(id) && adopted(file.nodes[id])) + .sort((a, b) => a - b) +} diff --git a/src/main/nodes/node-events.ts b/src/main/nodes/node-events.ts new file mode 100644 index 0000000..20121d8 --- /dev/null +++ b/src/main/nodes/node-events.ts @@ -0,0 +1,191 @@ +// Node events (#64): the change events the event log gets from the boards' own reports +// (`_NODE_STATUS`, `_NODE_TELEM`). Pure, no Node/Electron deps. The client never applies a +// threshold, so every alert here is a flag a board raised itself. +import type { EventSeverity, LogEventInput } from '@shared/events' +import type { NodeStatus, NodeTelem } from '@shared/nodes' +import { faultLabel, STATUS_FLAGS } from '../events' +import { PDU_STATUS_FLAGS, RAIL_FLAGS, railReading, TEMP_FLAGS } from './faults' +import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './node-status-wire' +import { TELEM_PAGE } from './node-telem' + +/** What the node mapping says about an id, as the tracker needs it. */ +export interface NodeLookup { + /** Effective name; `Node ` when the id has none. */ + name(nodeId: number): string + /** Neither in the registry nor adopted, and identified as something adoptable: news. */ + isNew(nodeId: number): boolean + /** Not in your cockpit (not added, not adopted), whatever it has identified as. */ + unmapped(nodeId: number): boolean + /** In NODE_IDS.md — a new one is waiting to be added, pre-filled (#93). Absent = never. */ + inRegistry?(nodeId: number): boolean +} + +/** + * Turns the node reports the client receives into change events. + * + * Keeps its own last-seen state per node, so a roster poll that repeats what is already known + * produces nothing. After every port open (`reset`) the first roster is a baseline: nodes found + * online there are one "N nodes online" row, not N; problems already present (offline, a flag + * raised) still get their own rows, since nothing else would show them. + */ +export class NodeEventTracker { + private prev = new Map() + private pages = new Map() + private baseline = true + private baselineAt?: number + /** New ids reported this app run — kept through a port reopen, so a replug does not repeat them. */ + private newSeen = new Set() + + constructor(private readonly lookup: NodeLookup) {} + + /** A port open: a different board, or the same one after an outage. Its state starts over. */ + reset(): void { + this.prev.clear() + this.pages.clear() + this.baseline = true + this.baselineAt = undefined + } + + /** + * After one `_NODE_STATUS` / `_NODE_STATUS_END` line was applied to the roster: the roster as it + * now stands (fault labels filled in), and the line that changed it. + */ + status(roster: NodeStatus[], identifier: string, raw: string, now: number): LogEventInput[] { + const out: LogEventInput[] = [] + const isEnd = identifier === NODE_END_MSG + this.baselineAt ??= now + // A bridge that never ends its roster reply: the baseline closes once the reply window passes. + if (this.baseline && !isEnd && now - this.baselineAt > ROSTER_REPLY_MS) { + out.push(this.endBaseline('', now)) + } + for (const n of roster) { + // The bridge's own row (node 0, v3) is this client's link: no events, not in the counts. + if (n.nodeId === BRIDGE_NODE_ID) continue + const p = this.prev.get(n.nodeId) + this.prev.set(n.nodeId, n) + out.push(...this.diff(p, n, raw, isEnd, now)) + } + if (isEnd && this.baseline) out.push(this.endBaseline(raw, now)) + return out + } + + /** One decoded `_NODE_TELEM` frame: PDU rail / status flags raised and cleared. */ + telemetry(t: NodeTelem, raw: string, now: number): LogEventInput[] { + if (t.kind === 'unknown') return [] + const key = `${t.nodeId}:${t.page}` + const was = this.pages.get(key) ?? 0 + this.pages.set(key, t.flags) + if (was === t.flags) return [] + const out: LogEventInput[] = [] + const ev = (bit: number, text: string, severity: EventSeverity, reading?: string): void => { + const on = (t.flags & bit) !== 0 + if (on === ((was & bit) !== 0)) return + out.push({ + t: now, + source: 'node', + nodeId: t.nodeId, + name: this.lookup.name(t.nodeId), + severity: on ? severity : 'info', + event: `${text} ${on ? 'raised' : 'cleared'}`, + detail: on ? reading : undefined, + raw + }) + } + if (t.kind === 'rail') { + const rail = t.page === TELEM_PAGE.PDU_RAIL_12V ? '12V' : '5V' + for (const [bit, text] of RAIL_FLAGS) { + ev(bit, `${rail} ${text}`, 'warn', railReading(bit, t)) + } + } else { + const temp = t.ntcTempC === null ? undefined : `${t.ntcTempC.toFixed(1)} °C` + for (const [bit, text, severity] of PDU_STATUS_FLAGS) { + ev(bit, text, severity, bit & TEMP_FLAGS ? temp : undefined) + } + } + return out + } + + private endBaseline(raw: string, now: number): LogEventInput { + this.baseline = false + let online = 0 + for (const n of this.prev.values()) if (n.present) online++ + return { + t: now, + source: 'node', + name: 'roster', + severity: 'info', + event: `${online} node${online === 1 ? '' : 's'} online`, + raw + } + } + + private diff( + p: NodeStatus | undefined, + n: NodeStatus, + raw: string, + isEnd: boolean, + now: number + ): LogEventInput[] { + const out: LogEventInput[] = [] + const id = n.nodeId + const ev = (severity: EventSeverity, event: string, detail?: string): void => { + out.push({ + t: now, + source: 'node', + nodeId: id, + name: this.lookup.name(id), + severity, + event, + detail, + raw + }) + } + const emittedNew = n.present && this.lookup.isNew(id) && !this.newSeen.has(id) + if (emittedNew) { + this.newSeen.add(id) + ev( + 'new', + 'new on the bus', + this.lookup.inRegistry?.(id) ? 'in NODE_IDS.md' : 'not in registry' + ) + } + const wasPresent = p?.present ?? false + if (!n.present && (p === undefined || wasPresent)) { + // A live delta is the bridge's heartbeat timeout; a roster end finds a node missing from it. + ev('error', 'offline', isEnd ? 'missing from roster' : 'no heartbeat') + } else if (n.present && !wasPresent) { + // A first sighting the baseline counts in its summary row, or that just made its "new" row, + // needs no "online" too; nor does an unmapped node not yet announced — it gets one "new on + // the bus" row once it identifies (#344). A new node seen in an earlier port session does: + // it has no other row. + const first = p === undefined + const unannounced = this.lookup.unmapped(id) && !this.newSeen.has(id) + if (!first || !(this.baseline || emittedNew || unannounced)) ev('info', 'online') + } + return this.flagsFrom(wasPresent ? p : undefined, n, ev, out) + } + + /** Flags only mean something while the node reports; one coming back reports them afresh. */ + private flagsFrom( + was: NodeStatus | undefined, + n: NodeStatus, + ev: (severity: EventSeverity, event: string, detail?: string) => void, + out: LogEventInput[] + ): LogEventInput[] { + if (!n.present) return out + for (const f of STATUS_FLAGS) { + const before = was?.[f.key] ?? false + const on = n[f.key] + if (f.key === 'degraded' && on && before && was!.faultId !== n.faultId) { + ev('warn', 'degraded', faultLabel(n)) + } else if (on && !before) { + const detail = + f.key === 'degraded' ? faultLabel(n) : f.key === 'epvf' ? `TEC ${n.tec}` : undefined + ev(f.severity, f.label, detail) + } else if (!on && before) { + ev('info', `${f.label} cleared`) + } + } + return out + } +} diff --git a/src/main/nodes/node-hub.seam.test.ts b/src/main/nodes/node-hub.seam.test.ts index aed36b2..ca307af 100644 --- a/src/main/nodes/node-hub.seam.test.ts +++ b/src/main/nodes/node-hub.seam.test.ts @@ -2,9 +2,9 @@ // telemetry, flush, snapshot and the mutators — with the mapping store kept in memory. They pin // what the app does today, so the refactor can show it still does. import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LogEventInput } from '@shared/events/ipc' -import type { NodeMappingFile } from '@shared/nodes/ipc' -import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' +import type { LogEventInput } from '@shared/events' +import type { NodeMappingFile } from '@shared/nodes' +import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' import { statusRow } from '../../test-fixtures/nodes' const disk: { file: NodeMappingFile; saves: number } = { file: { version: 3, nodes: {} }, saves: 0 } diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 934ba32..2431670 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { NodeMappingFile } from '@shared/nodes/ipc' -import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' +import type { NodeMappingFile } from '@shared/nodes' +import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' const disk: { file: NodeMappingFile diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index 7e2e6e4..26be032 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -14,13 +14,9 @@ import type { NodeStatus, ResetOptions, ResetPreview -} from '@shared/nodes/ipc' -import type { PushChannel, PushChannels } from '@shared/app/ipc' -import { BRIDGE_NODE_ID, type NodeRoster } from '@shared/nodes' -import { isAdoptableKind, resolveKind } from '@shared/node-kind' -import { NodeTelemStore, parseNodeTelem } from '@shared/node-telem' -import { NodeEventTracker } from '@shared/events' -import type { LogEventInput } from '@shared/events/ipc' +} from '@shared/nodes' +import type { PushChannel, PushChannels } from '@shared/app' +import type { LogEventInput } from '@shared/events' import { adoptNode, clearReportedHidden, @@ -29,20 +25,25 @@ import { importMapping, MappingError, markSeen, - registryDefaults, resetMapping, resetNode, resetPreview, setHidden, setNodeMapping -} from '@shared/node-mapping' -import { buildNodeModel } from '@shared/node-rules' +} from './mapping-rules' import { NODE_FAULT_CODES, NODE_NAMES, NODE_REGISTRY_SOURCE, type NodeFaultName } from '@shared/firmware' +import { isAdoptableKind } from '@shared/nodes/legacy-rules' +import { buildNodeModel } from './node-model' +import { NodeEventTracker } from './node-events' +import { resolveKind } from './node-kind' +import { BRIDGE_NODE_ID, type NodeRoster } from './node-status-wire' +import { NodeTelemStore, parseNodeTelem } from './node-telem' +import { registryDefaults } from './registry' import { loadNodeMapping, MappingSaveError, diff --git a/src/shared/node-kind.test.ts b/src/main/nodes/node-kind.test.ts similarity index 92% rename from src/shared/node-kind.test.ts rename to src/main/nodes/node-kind.test.ts index 065255b..8215537 100644 --- a/src/shared/node-kind.test.ts +++ b/src/main/nodes/node-kind.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isAdoptableKind, reportedIdentity, resolveKind } from './node-kind' +import { resolveKind } from './node-kind' +import { isAdoptableKind, reportedIdentity } from '@shared/nodes/legacy-rules' describe('reportedIdentity', () => { it('maps the v3 nodeKind byte; 0 (or no row) is not a report', () => { diff --git a/src/shared/node-kind.ts b/src/main/nodes/node-kind.ts similarity index 54% rename from src/shared/node-kind.ts rename to src/main/nodes/node-kind.ts index bc75c39..fe46f43 100644 --- a/src/shared/node-kind.ts +++ b/src/main/nodes/node-kind.ts @@ -1,25 +1,8 @@ // What a node is (pure, no Node/Electron deps). One resolver for the whole client — the model, main's // mapping validation, Ignore's recorded kind, the Adopt gate and the Overview — so no two places // can disagree about a board. Never inferred from a NODE_ID: a PDU can take any ID (#344). -import { NODE_KIND } from './nodes' -import type { NodeIdentity, NodeKind } from './nodes/ipc' - -/** The kind a status row reports (NodeStatus.nodeKind); undefined = not reported (0) or no row. */ -export function reportedIdentity(nodeKind: number | undefined): NodeIdentity | undefined { - switch (nodeKind) { - case undefined: - case NODE_KIND.UNKNOWN: - return undefined - case NODE_KIND.PANEL_GROUP: - return 'panel' - case NODE_KIND.PDU: - return 'pdu' - case NODE_KIND.BRIDGE: - return 'bridge' - default: - return 'other' - } -} +import type { NodeIdentity, NodeKind } from '@shared/nodes' +import { reportedIdentity } from '@shared/nodes/legacy-rules' export interface KindSignals { /** The board's own announcement (NodeStatus.nodeKind, v3). */ @@ -37,8 +20,3 @@ export interface KindSignals { export function resolveKind(s: KindSignals): NodeIdentity | undefined { return reportedIdentity(s.reported) ?? (s.pduTelemetry ? 'pdu' : undefined) ?? s.stored } - -/** Only these can be adopted — a whitelist, so unknown, bridge and any future kind never are. */ -export function isAdoptableKind(k: NodeIdentity | undefined): k is NodeKind { - return k === 'pdu' || k === 'panel' -} diff --git a/src/shared/node-rules.test.ts b/src/main/nodes/node-model.test.ts similarity index 97% rename from src/shared/node-rules.test.ts rename to src/main/nodes/node-model.test.ts index 9a33e16..cf13f06 100644 --- a/src/shared/node-rules.test.ts +++ b/src/main/nodes/node-model.test.ts @@ -1,15 +1,11 @@ import { describe, expect, it } from 'vitest' -import { - buildNodeModel, - cameOnline, - isReportableNew, - PDU_GROUP_POLL_CATCH_MS, - PDU_GROUP_WINDOW_MS, - type NodeModelInput -} from './node-rules' -import { EMPTY_MAPPING, registryDefaults } from './node-mapping' -import type { NodeMappingFile, NodeSeen, NodeStatus, NodeTelemSnapshot } from './nodes/ipc' -import { nodeStatus } from '../test-fixtures/nodes' +import { buildNodeModel, cameOnline, type NodeModelInput } from './node-model' +import { isReportableNew } from '@shared/nodes/legacy-rules' +import { PDU_GROUP_POLL_CATCH_MS, PDU_GROUP_WINDOW_MS } from './power-zone' +import { EMPTY_MAPPING } from './mapping-rules' +import { registryDefaults } from './registry' +import type { NodeMappingFile, NodeSeen, NodeStatus, NodeTelemSnapshot } from '@shared/nodes' +import { nodeStatus } from '../../test-fixtures/nodes' const defaults = registryDefaults({ 0: { name: 'PanelBridge', console: '—' }, diff --git a/src/shared/node-rules.ts b/src/main/nodes/node-model.ts similarity index 66% rename from src/shared/node-rules.ts rename to src/main/nodes/node-model.ts index 5e9ec52..e16984f 100644 --- a/src/shared/node-rules.ts +++ b/src/main/nodes/node-model.ts @@ -1,6 +1,5 @@ // Node status rules (pure): combines the roster, telemetry and node mapping into the one picture // the UI shows — availability, new-node detection, PDU-offline grouping and PDU readings. -import { BRIDGE_NODE_ID } from './nodes' import type { HiddenNode, NodeAvailability, @@ -11,24 +10,13 @@ import type { NodeModelEntry, NodeSeen, NodeStatus, - NodeTelemSnapshot, - PduOutage -} from './nodes/ipc' -import { isAdoptableKind, resolveKind } from './node-kind' + NodeTelemSnapshot +} from '@shared/nodes' +import { differingCount, effectiveMapping } from './mapping-rules' +import { resolveKind } from './node-kind' +import { BRIDGE_NODE_ID } from './node-status-wire' import { hasFreshPduPage } from './node-telem' -import { differingCount, effectiveMapping } from './node-mapping' - -/** - * Nodes that lose power together stop heartbeating together, so the bridge's 3 s timeout fires - * for all of them within one 500 ms heartbeat period and each arrives as a live delta. 2 s - * covers that with margin. - */ -export const PDU_GROUP_WINDOW_MS = 2000 -/** - * A drop whose delta was lost is found by the next roster poll instead (every 5 s), so it can - * arrive up to a poll plus the window after the PDU's. - */ -export const PDU_GROUP_POLL_CATCH_MS = 7000 +import { pduOutages } from './power-zone' export interface NodeModelInput { roster: NodeStatus[] @@ -139,39 +127,6 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { } } -/** Did `n` drop together with `pdu`? Both must be offline with a known time. */ -export function droppedTogether(n: NodeModelEntry, pdu: NodeModelEntry): boolean { - if (n.offlineAt === undefined || pdu.offlineAt === undefined) return false - const d = n.offlineAt - pdu.offlineAt - if (Math.abs(d) <= PDU_GROUP_WINDOW_MS) return true - // One side was found late by a roster poll: it was still listed online when the other dropped. - if (n.offlineVia === 'roster' && d > 0 && d <= PDU_GROUP_POLL_CATCH_MS) return true - if (pdu.offlineVia === 'roster' && d < 0 && -d <= PDU_GROUP_POLL_CATCH_MS) return true - return false -} - -/** An offline PDU plus more than half of the nodes it feeds dropping with it → one outage. */ -export function pduOutages(nodes: NodeModelEntry[]): PduOutage[] { - const out: PduOutage[] = [] - for (const pdu of nodes) { - if (pdu.kind !== 'pdu' || pdu.availability !== 'offline' || pdu.offlineAt === undefined) { - continue - } - // A board not seen this session could not have dropped with it: it counts for neither side. - const fed = nodes.filter((n) => n.pduId === pdu.nodeId && n.nodeId !== pdu.nodeId && !n.notSeen) - const hit = fed.filter((n) => n.availability === 'offline' && droppedTogether(n, pdu)) - if (fed.length > 0 && hit.length * 2 > fed.length) { - out.push({ - pduId: pdu.nodeId, - nodeIds: hit.map((n) => n.nodeId), - fedCount: fed.length, - at: pdu.offlineAt - }) - } - } - return out -} - /** * NODE_IDs present now that were not present at the previous roster change: the nodes that just * came (back) online. A node that stays present across updates is not in the list, which is what @@ -180,13 +135,3 @@ export function pduOutages(nodes: NodeModelEntry[]): PduOutage[] { export function cameOnline(previous: ReadonlySet, roster: NodeStatus[]): number[] { return roster.filter((n) => n.present && !previous.has(n.nodeId)).map((n) => n.nodeId) } - -/** - * A new device worth telling the user about: not in the registry or adopted, on the bus now, and - * identified as something that can be adopted. Until then it is not news — not on the Cockpit - * count, the Overview notice or the event log. One predicate for every tab (node 0 is never in the - * model). - */ -export function isReportableNew(e: NodeModelEntry): boolean { - return e.source === 'new' && e.availability === 'online' && isAdoptableKind(e.identified) -} diff --git a/src/shared/nodes.test.ts b/src/main/nodes/node-status-wire.test.ts similarity index 97% rename from src/shared/nodes.test.ts rename to src/main/nodes/node-status-wire.test.ts index dc32014..98a834e 100644 --- a/src/shared/nodes.test.ts +++ b/src/main/nodes/node-status-wire.test.ts @@ -4,14 +4,14 @@ import { isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, - nodeFaultTag, NodeRoster, nodeRosterRequest, parseNodeStatus, ROSTER_REPLY_MS -} from './nodes' -import type { NodeStatus } from './nodes/ipc' -import { nodeStatus } from '../test-fixtures/nodes' +} from './node-status-wire' +import { nodeFaultTag } from '@shared/nodes/legacy-rules' +import type { NodeStatus } from '@shared/nodes' +import { nodeStatus } from '../../test-fixtures/nodes' const node = (over: Partial): NodeStatus => nodeStatus(1, { uptimeSec: 0, rxCount: 0, ...over }) diff --git a/src/shared/nodes.ts b/src/main/nodes/node-status-wire.ts similarity index 96% rename from src/shared/nodes.ts rename to src/main/nodes/node-status-wire.ts index e7f4e4b..c557083 100644 --- a/src/shared/nodes.ts +++ b/src/main/nodes/node-status-wire.ts @@ -1,7 +1,6 @@ // PanelBridge node-status decode + roster (pure, no Node/Electron deps). // Wire contract: NodeStatus.h NODE_STATUS_* (generated into shared/firmware; reference.test // asserts the decoder's protoVersion matches). -import { encodeExportFrame } from './dcsbios' import { DIE_TEMP_UNAVAILABLE, GATEWAY_NODE_ID, @@ -14,8 +13,11 @@ import { NODE_STATUS, NODE_STATUS_FIELD, NODE_STATUS_ROW_BYTES -} from './firmware' -import type { NodeSeen, NodeStatus } from './nodes/ipc' +} from '@shared/firmware' +import type { NodeSeen, NodeStatus } from '@shared/nodes' +// Deep import, not '../gateway': the gateway index reaches back into nodes through DeviceLink +// until R2.2 splits it, so going through the index would close an import cycle. +import { encodeExportFrame } from '../gateway/link-framing' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 @@ -47,11 +49,6 @@ const HEX_LEN = NODE_STATUS_ROW_BYTES * 2 // firmware no longer describes it, so it has nothing to sync from: it stays a client constant. const V2_HEX_LEN = 26 -/** Short degraded-badge tag — the fault abbr, or `Fault N` for an unknown/reserved id. */ -export function nodeFaultTag(n: NodeStatus): string { - return n.faultAbbr ?? `Fault ${n.faultId}` -} - /** Decode the 28-hex v3 `_NODE_STATUS` argument; null if malformed / nodeId out of range. */ export function parseNodeStatus(hex: string): NodeStatus | null { if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]+$/.test(hex)) return null diff --git a/src/shared/node-telem.test.ts b/src/main/nodes/node-telem.test.ts similarity index 98% rename from src/shared/node-telem.test.ts rename to src/main/nodes/node-telem.test.ts index 1575777..31d239e 100644 --- a/src/shared/node-telem.test.ts +++ b/src/main/nodes/node-telem.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it } from 'vitest' import { - displayCurrentMa, hasFreshPduPage, NodeTelemStore, parseNodeTelem, - PDU_FLAG, - RAIL_FLAG, TELEM_PAGE, TELEM_STALE_MS } from './node-telem' -import type { NodeTelem } from './nodes/ipc' +import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/nodes/legacy-rules' +import type { NodeTelem } from '@shared/nodes' /** `_NODE_TELEM` argument: nodeId + the 8 frame bytes, as the bridge forwards them. */ const telem = (nodeId: number, bytes: number[]) => diff --git a/src/shared/node-telem.ts b/src/main/nodes/node-telem.ts similarity index 93% rename from src/shared/node-telem.ts rename to src/main/nodes/node-telem.ts index 1755d9c..85cd8a4 100644 --- a/src/shared/node-telem.ts +++ b/src/main/nodes/node-telem.ts @@ -9,12 +9,10 @@ import { NODE_TELEM_ROW_BYTES, NTC_TEMP_UNAVAILABLE, PDU_RAIL_FIELD, - PDU_RAIL_FLAGS, PDU_STATUS_FIELD, - PDU_STATUS_FLAGS, TELEM_PAGES -} from './firmware' -import type { NodeTelem, NodeTelemSnapshot, TelemPageData } from './nodes/ipc' +} from '@shared/firmware' +import type { NodeTelem, NodeTelemSnapshot, TelemPageData } from '@shared/nodes' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_TELEM_PROTO = 1 @@ -25,12 +23,6 @@ export const NODE_TELEM_MSG = NODE_TELEM.msgName /** TelemPage ids (TELEM_PAGES). Global and append-only; 0x00 is invalid. */ export const TELEM_PAGE = TELEM_PAGES -/** PDU_RAIL_* byte 1 bits (PDU_RAIL_FLAGS). All zero = healthy. */ -export const RAIL_FLAG = PDU_RAIL_FLAGS - -/** PDU_STATUS byte 1 bits (PDU_STATUS_FLAGS). All zero = healthy, calibrated. */ -export const PDU_FLAG = PDU_STATUS_FLAGS - /** * A page older than this is stale. Each PDU page refreshes every 3 s (one TELEM_n frame per * TELEM_PERIOD_MS in shared/firmware, rotating through three pages), so 10 s is three missed @@ -51,11 +43,6 @@ export function isPduPage(page: number): boolean { ) } -/** iAvgMa for display: residual negative offset reads as 0. */ -export function displayCurrentMa(ma: number): number { - return Math.max(0, ma) -} - /** Decode the 18-hex `_NODE_TELEM` argument; null if malformed, nodeId out of range or page 0. */ export function parseNodeTelem(hex: string): NodeTelem | null { if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]+$/.test(hex)) return null diff --git a/src/main/nodes/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts index 71ea5b2..f123e68 100644 --- a/src/main/nodes/phase0-probes.test.ts +++ b/src/main/nodes/phase0-probes.test.ts @@ -4,20 +4,20 @@ // Main and shared only; the probes that read renderer helpers are in // src/renderer/src/phase0-probes.test.ts (main never imports renderer code). import { describe, expect, it, vi } from 'vitest' -import { DEFAULT_PSU, type NodeMappingFile } from '@shared/nodes/ipc' -import { NODE_END_MSG, NODE_MSG, NodeRoster } from '@shared/nodes' +import { DEFAULT_PSU, type NodeMappingFile } from '@shared/nodes' +import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' import { - registryDefaults, importMapping, setNodeMapping, adoptNode, effectiveMapping, EMPTY_MAPPING -} from '@shared/node-mapping' -import { diffImport, parseConfigFile } from '@shared/config-file' -import { CONFIG_FORMAT } from '@shared/config/ipc' -import { buildNodeModel } from '@shared/node-rules' -import { GatewayWatch, GATEWAY_NO_ANSWER_MS } from '@shared/gateway' +} from './mapping-rules' +import { registryDefaults } from './registry' +import { diffImport, parseConfigFile } from '../config/config-file' +import { CONFIG_FORMAT } from '@shared/config' +import { buildNodeModel } from './node-model' +import { GatewayWatch, GATEWAY_NO_ANSWER_MS } from '../gateway/gateway-health' import { DEFAULT_APP_SETTINGS } from '../../test-fixtures/config' const disk: { file: NodeMappingFile } = { file: { version: 3, nodes: {} } } diff --git a/src/main/nodes/power-zone.ts b/src/main/nodes/power-zone.ts new file mode 100644 index 0000000..59d6317 --- /dev/null +++ b/src/main/nodes/power-zone.ts @@ -0,0 +1,48 @@ +// Power zones (pure, no Node/Electron deps): which boards dropped together with the PDU that feeds +// them, so a PDU outage reads as one event rather than one per board. +import type { NodeModelEntry, PduOutage } from '@shared/nodes' + +/** + * Nodes that lose power together stop heartbeating together, so the bridge's 3 s timeout fires + * for all of them within one 500 ms heartbeat period and each arrives as a live delta. 2 s + * covers that with margin. + */ +export const PDU_GROUP_WINDOW_MS = 2000 +/** + * A drop whose delta was lost is found by the next roster poll instead (every 5 s), so it can + * arrive up to a poll plus the window after the PDU's. + */ +export const PDU_GROUP_POLL_CATCH_MS = 7000 + +/** Did `n` drop together with `pdu`? Both must be offline with a known time. */ +export function droppedTogether(n: NodeModelEntry, pdu: NodeModelEntry): boolean { + if (n.offlineAt === undefined || pdu.offlineAt === undefined) return false + const d = n.offlineAt - pdu.offlineAt + if (Math.abs(d) <= PDU_GROUP_WINDOW_MS) return true + // One side was found late by a roster poll: it was still listed online when the other dropped. + if (n.offlineVia === 'roster' && d > 0 && d <= PDU_GROUP_POLL_CATCH_MS) return true + if (pdu.offlineVia === 'roster' && d < 0 && -d <= PDU_GROUP_POLL_CATCH_MS) return true + return false +} + +/** An offline PDU plus more than half of the nodes it feeds dropping with it → one outage. */ +export function pduOutages(nodes: NodeModelEntry[]): PduOutage[] { + const out: PduOutage[] = [] + for (const pdu of nodes) { + if (pdu.kind !== 'pdu' || pdu.availability !== 'offline' || pdu.offlineAt === undefined) { + continue + } + // A board not seen this session could not have dropped with it: it counts for neither side. + const fed = nodes.filter((n) => n.pduId === pdu.nodeId && n.nodeId !== pdu.nodeId && !n.notSeen) + const hit = fed.filter((n) => n.availability === 'offline' && droppedTogether(n, pdu)) + if (fed.length > 0 && hit.length * 2 > fed.length) { + out.push({ + pduId: pdu.nodeId, + nodeIds: hit.map((n) => n.nodeId), + fedCount: fed.length, + at: pdu.offlineAt + }) + } + } + return out +} diff --git a/src/shared/psu.test.ts b/src/main/nodes/psu-limits.test.ts similarity index 88% rename from src/shared/psu.test.ts rename to src/main/nodes/psu-limits.test.ts index f6203ee..03ed3f0 100644 --- a/src/shared/psu.test.ts +++ b/src/main/nodes/psu-limits.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { effectivePsuLimits, parsePsuLimit, PSU_PRESETS, sanitizePsu } from './psu' -import { DEFAULT_PSU } from './nodes/ipc' +import { sanitizePsu } from './psu-limits' +import { effectivePsuLimits, parsePsuLimit, PSU_PRESETS } from '@shared/nodes/legacy-rules' +import { DEFAULT_PSU } from '@shared/nodes' describe('sanitizePsu', () => { it('falls back to the default for junk', () => { diff --git a/src/main/nodes/psu-limits.ts b/src/main/nodes/psu-limits.ts new file mode 100644 index 0000000..81edb21 --- /dev/null +++ b/src/main/nodes/psu-limits.ts @@ -0,0 +1,25 @@ +// Power supply limits (#67) the Cockpit headroom is drawn against (#63). Pure; persisted in +// config.json as AppConfig.psu. Display only — the client never alerts on them. + +import { DEFAULT_PSU, type PsuLimits, type PsuPresetId, type PsuSetting } from '@shared/nodes' +import { isLimit, PSU_PRESETS } from '@shared/nodes/legacy-rules' + +function sanitizeLimits(raw: unknown): PsuLimits { + const r = (raw && typeof raw === 'object' ? raw : {}) as Record + const out: PsuLimits = {} + for (const k of ['a12', 'a5', 'w'] as const) if (isLimit(r[k])) out[k] = r[k] + return out +} + +const isPreset = (v: unknown): v is PsuPresetId => + typeof v === 'string' && Object.prototype.hasOwnProperty.call(PSU_PRESETS, v) + +/** Tolerant read: anything malformed falls back to its default, field by field. */ +export function sanitizePsu(raw: unknown): PsuSetting { + const p = (raw && typeof raw === 'object' ? raw : {}) as Record + return { + mode: p.mode === 'custom' || p.mode === 'preset' ? p.mode : DEFAULT_PSU.mode, + preset: isPreset(p.preset) ? p.preset : DEFAULT_PSU.preset, + custom: sanitizeLimits(p.custom) + } +} diff --git a/src/main/nodes/registry.ts b/src/main/nodes/registry.ts new file mode 100644 index 0000000..69f05b8 --- /dev/null +++ b/src/main/nodes/registry.ts @@ -0,0 +1,31 @@ +// The node registry (pure, no Node/Electron deps): the bundled NODE_IDS read as defaults per +// NODE_ID — the dictionary names and power zones resolve against. +import type { NodeDefaults, NodeKind } from '@shared/nodes' +import { isNodeId } from './mapping-rules' + +/** PDUs are named `PDU_` in NODE_IDS.md, which has no type column. */ +export const isPduName = (name: string): boolean => /^PDU_/.test(name) + +/** + * Registry defaults, keyed by NODE_ID. NODE_ID 0 (PanelBridge) never appears on the bus and is + * left out. A panel's default power zone is the PDU on its console when there is exactly one; + * with none or several there is no safe guess, so it is left unmapped. + */ +export function registryDefaults( + registry: Record +): Map { + const ids = Object.keys(registry) + .map(Number) + .filter(isNodeId) + .sort((a, b) => a - b) + const pdusOn = (console: string) => + ids.filter((id) => isPduName(registry[id]!.name) && registry[id]!.console === console) + const out = new Map() + for (const id of ids) { + const { name, console } = registry[id]! + const kind: NodeKind = isPduName(name) ? 'pdu' : 'panel' + const pdus = kind === 'panel' ? pdusOn(console) : [] + out.set(id, { name, console, kind, pduId: pdus.length === 1 ? pdus[0]! : null }) + } + return out +} diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 99b4967..569b5b8 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -16,9 +16,9 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSy import { tmpdir } from 'node:os' import { join } from 'node:path' import { onTestFinished, vi } from 'vitest' -import { CTRL, type PushChannel, type PushChannels } from '@shared/app/ipc' -import type { AppConfig } from '@shared/config/ipc' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes/ipc' +import { CTRL, type PushChannel, type PushChannels } from '@shared/app' +import type { AppConfig } from '@shared/config' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' // ── the outside world, faked ───────────────────────────────────────────────── diff --git a/src/preload/index.ts b/src/preload/index.ts index 25b1d0b..f7a95f6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { LogEvent } from '@shared/events/ipc' +import type { LogEvent } from '@shared/events' import { CTRL, type DebugDumpResult, @@ -7,13 +7,13 @@ import { type PushChannels, type RelayStatus, type SkyhawkApi -} from '@shared/app/ipc' +} from '@shared/app' import type { AppConfig, ConfigExportResult, ConfigImportApplyResult, ConfigImportOpen -} from '@shared/config/ipc' +} from '@shared/config' import type { CachedBoard, CalCacheEntry, @@ -21,15 +21,10 @@ import type { CalHello, CalResult, CalSnapshot -} from '@shared/hid/calibration/ipc' -import type { CaptureState, ExportResult, RelayResult, ReplayLoad } from '@shared/dcs/ipc' -import type { HidAvailability } from '@shared/hid/ipc' -import type { - NodeMappingResult, - NodeRegistryInfo, - NodeSnapshot, - ResetPreview -} from '@shared/nodes/ipc' +} from '@shared/hid/calibration' +import type { CaptureState, ExportResult, RelayResult, ReplayLoad } from '@shared/dcs' +import type { HidAvailability } from '@shared/hid' +import type { NodeMappingResult, NodeRegistryInfo, NodeSnapshot, ResetPreview } from '@shared/nodes' // Typed bridge: main -> renderer push subscriptions + renderer -> main control. const api: SkyhawkApi = { diff --git a/src/renderer/src/CalibrationDialog.tsx b/src/renderer/src/CalibrationDialog.tsx index 2bfa2e9..c751042 100644 --- a/src/renderer/src/CalibrationDialog.tsx +++ b/src/renderer/src/CalibrationDialog.tsx @@ -5,9 +5,9 @@ import { holdProgress, holdRemainingMs, sweepsRemaining -} from '@shared/axis-capture' +} from '@shared/hid/calibration/legacy-rules' import { CAL_PROTO, HID_REPORT_LAYOUT } from '@shared/firmware' -import type { CalSnapshot } from '@shared/hid/calibration/ipc' +import type { CalSnapshot } from '@shared/hid/calibration' import { CalibrationController, type CalibrationState } from './calibration-controller' import { AXIS_LABELS, useStore } from './store' diff --git a/src/renderer/src/DevicePopover.tsx b/src/renderer/src/DevicePopover.tsx index 3320302..129cb08 100644 --- a/src/renderer/src/DevicePopover.tsx +++ b/src/renderer/src/DevicePopover.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { isLinkUp } from '@shared/gateway/ipc' +import { isLinkUp } from '@shared/gateway' import { useStore } from './store' import { deviceView } from './device-view' import { gatewayView } from './gateway-view' diff --git a/src/renderer/src/calibration-controller.test.ts b/src/renderer/src/calibration-controller.test.ts index a39af10..d0889fa 100644 --- a/src/renderer/src/calibration-controller.test.ts +++ b/src/renderer/src/calibration-controller.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DEFAULT_CAPTURE_CONFIG, midpoint } from '@shared/axis-capture' +import { DEFAULT_CAPTURE_CONFIG, midpoint } from '@shared/hid/calibration/legacy-rules' import type { CachedBoard, CalCacheEntry, CalRawSample, CalResult, CalSnapshot -} from '@shared/hid/calibration/ipc' +} from '@shared/hid/calibration' import { CalibrationController, type CalibrationApi } from './calibration-controller' const C = DEFAULT_CAPTURE_CONFIG diff --git a/src/renderer/src/calibration-controller.ts b/src/renderer/src/calibration-controller.ts index 14de027..da7aa84 100644 --- a/src/renderer/src/calibration-controller.ts +++ b/src/renderer/src/calibration-controller.ts @@ -10,7 +10,7 @@ import { restartSweep, retrySweep, tick -} from '@shared/axis-capture' +} from '@shared/hid/calibration/legacy-rules' import type { CachedBoard, CalCacheEntry, @@ -21,7 +21,7 @@ import type { CalSnapshot, CaptureResult, CaptureState -} from '@shared/hid/calibration/ipc' +} from '@shared/hid/calibration' /** Only the calls this controller makes — narrower than SkyhawkApi, and trivial to fake. */ export interface CalibrationApi { diff --git a/src/renderer/src/cockpit-adopt.test.ts b/src/renderer/src/cockpit-adopt.test.ts index 3e50020..d864dd9 100644 --- a/src/renderer/src/cockpit-adopt.test.ts +++ b/src/renderer/src/cockpit-adopt.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes/ipc' +import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes' import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' -import { TELEM_PAGE } from '@shared/node-telem' +import { TELEM_PAGE } from '../../main/nodes/node-telem' import { seenAgo } from '@shared/format' import { nodeViews } from './cockpit' import { diff --git a/src/renderer/src/cockpit-adopt.ts b/src/renderer/src/cockpit-adopt.ts index 24910a4..0615899 100644 --- a/src/renderer/src/cockpit-adopt.ts +++ b/src/renderer/src/cockpit-adopt.ts @@ -2,10 +2,8 @@ // pure decisions behind it — queue order, what each new NODE_ID is seen doing, the registry // pre-fill, when the form is complete, the identify row and where the selection goes next. import { amps, fmtUptime, volts } from '@shared/format' -import type { AdoptSpec, NodeKind, NodeModelEntry } from '@shared/nodes/ipc' -import { reportedIdentity } from '@shared/node-kind' -import { displayCurrentMa } from '@shared/node-telem' -import { nodeFaultTag } from '@shared/nodes' +import type { AdoptSpec, NodeKind, NodeModelEntry } from '@shared/nodes' +import { displayCurrentMa, nodeFaultTag, reportedIdentity } from '@shared/nodes/legacy-rules' import type { NodeView } from './cockpit' import { consoleLabel, powerZoneOptions } from './cockpit-panels' diff --git a/src/renderer/src/cockpit-panels.test.ts b/src/renderer/src/cockpit-panels.test.ts index dc87bd2..2dea2fd 100644 --- a/src/renderer/src/cockpit-panels.test.ts +++ b/src/renderer/src/cockpit-panels.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes/ipc' +import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes' import { modelNode, nodeModel as model, nodeStatus } from '../../test-fixtures/nodes' -import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from '@shared/node-telem' +import { PDU_FLAG, RAIL_FLAG } from '@shared/nodes/legacy-rules' +import { TELEM_PAGE } from '../../main/nodes/node-telem' import { lastSeenLabel } from '@shared/format' import { nodeViews } from './cockpit' import { diff --git a/src/renderer/src/cockpit-panels.ts b/src/renderer/src/cockpit-panels.ts index 08b7f4b..6169ba7 100644 --- a/src/renderer/src/cockpit-panels.ts +++ b/src/renderer/src/cockpit-panels.ts @@ -2,9 +2,8 @@ // panel group and PDU panels — which actions a board offers, what its fault box says, the rows each panel shows and the mapping patch Save // sends. Every value is something a board reported or the client computed, labelled as such. import { DASH, fmtUptime, lastSeenLabel } from '@shared/format' -import type { MappingPatch, NodeModelEntry, PduRailPage } from '@shared/nodes/ipc' -import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/node-telem' -import { nodeFaultTag } from '@shared/nodes' +import type { MappingPatch, NodeModelEntry, PduRailPage } from '@shared/nodes' +import { displayCurrentMa, nodeFaultTag, PDU_FLAG, RAIL_FLAG } from '@shared/nodes/legacy-rules' import { pduAlertFlags, type NodeView } from './cockpit' // ── board actions ─────────────────────────────────────────────────────────── diff --git a/src/renderer/src/cockpit.test.ts b/src/renderer/src/cockpit.test.ts index 23ef3f0..d03a408 100644 --- a/src/renderer/src/cockpit.test.ts +++ b/src/renderer/src/cockpit.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes/ipc' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes' import { modelNode as node, nodeModel as model, nodeStatus as status } from '../../test-fixtures/nodes' -import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from '@shared/node-telem' +import { PDU_FLAG, RAIL_FLAG } from '@shared/nodes/legacy-rules' +import { TELEM_PAGE } from '../../main/nodes/node-telem' import { fmtPct, lastSeenLabel } from '@shared/format' import { STATE_SYMBOL, diff --git a/src/renderer/src/cockpit.ts b/src/renderer/src/cockpit.ts index a8edb17..9dfa413 100644 --- a/src/renderer/src/cockpit.ts +++ b/src/renderer/src/cockpit.ts @@ -2,7 +2,6 @@ // here is computed from what main pushes (`nodes:model`, `nodes:telemetry`); the client applies no // thresholds — a warning is always a flag a board raised itself. import { lastSeenLabel } from '@shared/format' -import { isReportableNew } from '@shared/node-rules' import type { NodeModel, NodeModelEntry, @@ -11,10 +10,15 @@ import type { PduRailPage, PduStatusPage, PsuSetting -} from '@shared/nodes/ipc' -import { displayCurrentMa, PDU_FLAG, RAIL_FLAG } from '@shared/node-telem' -import { nodeFaultTag } from '@shared/nodes' -import { PSU_PRESETS } from '@shared/psu' +} from '@shared/nodes' +import { + displayCurrentMa, + isReportableNew, + nodeFaultTag, + PDU_FLAG, + PSU_PRESETS, + RAIL_FLAG +} from '@shared/nodes/legacy-rules' /** How a node reads on the plan. 'unknown' = no complete roster yet and it has not reported. */ export type NodeState = 'on' | 'warn' | 'off' | 'new' | 'unknown' diff --git a/src/renderer/src/connection-summary.test.ts b/src/renderer/src/connection-summary.test.ts index 4a071e1..a0da9e4 100644 --- a/src/renderer/src/connection-summary.test.ts +++ b/src/renderer/src/connection-summary.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes/ipc' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes' import { modelNode as node, nodeModel as model, nodeStatus as status } from '../../test-fixtures/nodes' -import { TELEM_PAGE } from '@shared/node-telem' +import { TELEM_PAGE } from '../../main/nodes/node-telem' import { fmtTime, lastSeenLabel } from '@shared/format' import { STATE_SYMBOL, attentionItems, nodeViews } from './cockpit' import { nodesSummary } from './connection-summary' diff --git a/src/renderer/src/connection-summary.ts b/src/renderer/src/connection-summary.ts index 0040de6..74b4cb2 100644 --- a/src/renderer/src/connection-summary.ts +++ b/src/renderer/src/connection-summary.ts @@ -2,7 +2,7 @@ // most severe nodes come from the same helpers as the Cockpit health band and Attention, so the // two tabs always agree; the full picture lives in the Cockpit tab. import { hhmmss, lastSeenLabel } from '@shared/format' -import type { PduOutage } from '@shared/nodes/ipc' +import type { PduOutage } from '@shared/nodes' import { affected, ATTENTION_SHOWN, diff --git a/src/renderer/src/device-view.test.ts b/src/renderer/src/device-view.test.ts index f9d2afc..fff81f8 100644 --- a/src/renderer/src/device-view.test.ts +++ b/src/renderer/src/device-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DeviceState } from '@shared/gateway/ipc' +import type { DeviceState } from '@shared/gateway' import { deviceView, relayNote, diff --git a/src/renderer/src/device-view.ts b/src/renderer/src/device-view.ts index 43539b8..7d6135e 100644 --- a/src/renderer/src/device-view.ts +++ b/src/renderer/src/device-view.ts @@ -1,5 +1,5 @@ -import type { DeviceState } from '@shared/gateway/ipc' -import type { StreamState } from '@shared/dcs/ipc' +import type { DeviceState } from '@shared/gateway' +import type { StreamState } from '@shared/dcs' import type { SourceMode } from './store' /** diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e728e3d..82894e6 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -1,5 +1,5 @@ /// -import type { SkyhawkApi } from '@shared/app/ipc' +import type { SkyhawkApi } from '@shared/app' declare global { interface Window { diff --git a/src/renderer/src/freshness.ts b/src/renderer/src/freshness.ts index 9e31194..029b86d 100644 --- a/src/renderer/src/freshness.ts +++ b/src/renderer/src/freshness.ts @@ -1,4 +1,4 @@ -import type { DeviceState } from '@shared/gateway/ipc' +import type { DeviceState } from '@shared/gateway' /** * How recently the device last said anything, as a colour. diff --git a/src/renderer/src/gateway-view.test.ts b/src/renderer/src/gateway-view.test.ts index 3549843..b517fc3 100644 --- a/src/renderer/src/gateway-view.test.ts +++ b/src/renderer/src/gateway-view.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { gatewayStatus } from '@shared/gateway' -import type { NodeStatus } from '@shared/nodes/ipc' +import { gatewayStatus } from '../../main/gateway/gateway-health' +import type { NodeStatus } from '@shared/nodes' import { gatewayView } from './gateway-view' import { nodeStatus } from '../../test-fixtures/nodes' diff --git a/src/renderer/src/gateway-view.ts b/src/renderer/src/gateway-view.ts index 84749b3..b86a1c4 100644 --- a/src/renderer/src/gateway-view.ts +++ b/src/renderer/src/gateway-view.ts @@ -1,7 +1,7 @@ // Where the Gateway's status lands in the UI (#94): the title-bar pill, the Device popover, the // Overview alert and the Cockpit's Attention item. Presentation only: severity, label, note and -// alert all come from shared/gateway.ts (gatewayStatus), computed once in main. -import type { GatewayStatus } from '@shared/gateway/ipc' +// alert all come from gatewayStatus (main/gateway/gateway-health.ts), computed once in main. +import type { GatewayStatus } from '@shared/gateway' export interface GatewayView { /** Pill modifier while it reports a problem (replaces `devind--on`); with the "!" symbol. */ diff --git a/src/renderer/src/log-view.test.ts b/src/renderer/src/log-view.test.ts index 38234c9..4d6a200 100644 --- a/src/renderer/src/log-view.test.ts +++ b/src/renderer/src/log-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { LogEvent } from '@shared/events/ipc' +import type { LogEvent } from '@shared/events' import { fileName } from '@shared/format' import { boardEventLine, diff --git a/src/renderer/src/log-view.ts b/src/renderer/src/log-view.ts index a1eee25..45f1059 100644 --- a/src/renderer/src/log-view.ts +++ b/src/renderer/src/log-view.ts @@ -1,7 +1,7 @@ // Log tab › Events (#64): what the view shows of the session's events (pure, no React). -import { isProblem } from '@shared/events' +import { isProblem } from '@shared/events/legacy-rules' import { eventText } from '@shared/format' -import { MAX_EVENTS, type EventSource, type LogEvent } from '@shared/events/ipc' +import { MAX_EVENTS, type EventSource, type LogEvent } from '@shared/events' export type LogView = 'stream' | 'events' export type EventSourceFilter = 'all' | EventSource diff --git a/src/renderer/src/overview.test.ts b/src/renderer/src/overview.test.ts index 075add8..252321e 100644 --- a/src/renderer/src/overview.test.ts +++ b/src/renderer/src/overview.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes/ipc' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes' import { modelNode as node, nodeModel as model, nodeStatus as status } from '../../test-fixtures/nodes' -import { TELEM_PAGE } from '@shared/node-telem' +import { TELEM_PAGE } from '../../main/nodes/node-telem' import { attentionItems, healthBand, nodeViews } from './cockpit' import { attentionSummary, cockpitTile, newDevices, newDevicesText, reportedKind } from './overview' diff --git a/src/renderer/src/overview.ts b/src/renderer/src/overview.ts index 4936765..90dedd9 100644 --- a/src/renderer/src/overview.ts +++ b/src/renderer/src/overview.ts @@ -2,9 +2,8 @@ // new-devices notice. All of it is read off the Cockpit's own picture (`healthBand`, // `attentionItems`); the client adds no thresholds of its own. import { plural } from '@shared/format' -import { isAdoptableKind } from '@shared/node-kind' -import { isReportableNew } from '@shared/node-rules' -import type { NodeModelEntry } from '@shared/nodes/ipc' +import { isAdoptableKind, isReportableNew } from '@shared/nodes/legacy-rules' +import type { NodeModelEntry } from '@shared/nodes' import type { AttentionItem, HealthBand, NodeView } from './cockpit' // ── Cockpit tile ──────────────────────────────────────────────────────────── diff --git a/src/renderer/src/phase0-probes.test.ts b/src/renderer/src/phase0-probes.test.ts index 06d49db..7b05fe4 100644 --- a/src/renderer/src/phase0-probes.test.ts +++ b/src/renderer/src/phase0-probes.test.ts @@ -4,10 +4,11 @@ // these behaviours on purpose updates its probe. The main/shared half is // src/main/nodes/phase0-probes.test.ts. import { describe, expect, it } from 'vitest' -import type { NodeMappingFile } from '@shared/nodes/ipc' -import { NODE_MSG, NodeRoster } from '@shared/nodes' -import { registryDefaults, setHidden, EMPTY_MAPPING } from '@shared/node-mapping' -import { buildNodeModel } from '@shared/node-rules' +import type { NodeMappingFile } from '@shared/nodes' +import { NODE_MSG, NodeRoster } from '../../main/nodes/node-status-wire' +import { setHidden, EMPTY_MAPPING } from '../../main/nodes/mapping-rules' +import { registryDefaults } from '../../main/nodes/registry' +import { buildNodeModel } from '../../main/nodes/node-model' import { nodeViews, healthBand, attentionItems, nodeHealth } from './cockpit' import { strayNew, registrySpecs, adoptPduChoices } from './cockpit-adopt' import { nodeFaultBoxes } from './cockpit-panels' diff --git a/src/renderer/src/store.test.ts b/src/renderer/src/store.test.ts index d2b9dac..17f2a7c 100644 --- a/src/renderer/src/store.test.ts +++ b/src/renderer/src/store.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { CalSnapshot } from '@shared/hid/calibration/ipc' -import type { NodeModel, NodeSnapshot, NodeTelemSnapshot } from '@shared/nodes/ipc' -import type { PushChannel, PushChannels, RelayStatus } from '@shared/app/ipc' -import type { LogEvent } from '@shared/events/ipc' +import type { CalSnapshot } from '@shared/hid/calibration' +import type { NodeModel, NodeSnapshot, NodeTelemSnapshot } from '@shared/nodes' +import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' +import type { LogEvent } from '@shared/events' // The store talks to main through window.skyhawk. Only the calls initBridge() makes need to be // real here; the rest exist so subscription setup does not throw. diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 16d7b62..b9846b4 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -1,10 +1,5 @@ import { create } from 'zustand' -import { - isLinkUp, - type DeviceState, - type GatewayStatus, - type SerialFrame -} from '@shared/gateway/ipc' +import { isLinkUp, type DeviceState, type GatewayStatus, type SerialFrame } from '@shared/gateway' import { HID_CONTROLS_BY_ID, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' import { fmtTime, hex2, hex4 } from '@shared/format' import type { @@ -12,17 +7,12 @@ import type { LogRow as IpcLogRow, StreamState, TelemetryReadout -} from '@shared/dcs/ipc' -import type { AppConfig, DcsTransport } from '@shared/config/ipc' -import type { CalSnapshot } from '@shared/hid/calibration/ipc' -import { - DEFAULT_PSU, - type NodeModel, - type NodeTelemSnapshot, - type PsuSetting -} from '@shared/nodes/ipc' +} from '@shared/dcs' +import type { AppConfig, DcsTransport } from '@shared/config' +import type { CalSnapshot } from '@shared/hid/calibration' +import { DEFAULT_PSU, type NodeModel, type NodeTelemSnapshot, type PsuSetting } from '@shared/nodes' import { nodeViews, totalWatts } from './cockpit' -import type { LogEvent } from '@shared/events/ipc' +import type { LogEvent } from '@shared/events' import { mergeEvents, restoreEvents, type EventSourceFilter, type LogView } from './log-view' export type TabId = 'overview' | 'connection' | 'cockpit' | 'log' | 'serial' | 'hid' | 'settings' diff --git a/src/renderer/src/tabs/Cockpit.tsx b/src/renderer/src/tabs/Cockpit.tsx index 870e273..dcf6cf7 100644 --- a/src/renderer/src/tabs/Cockpit.tsx +++ b/src/renderer/src/tabs/Cockpit.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { isLinkUp } from '@shared/gateway/ipc' -import type { NodeModel, PsuLimits } from '@shared/nodes/ipc' -import { effectivePsuLimits } from '@shared/psu' +import { isLinkUp } from '@shared/gateway' +import type { NodeModel, PsuLimits } from '@shared/nodes' +import { effectivePsuLimits } from '@shared/nodes/legacy-rules' import { fmtPct, hhmm, voltsValue } from '@shared/format' import { useStore } from '../store' import { strayFlag, strayNew } from '../cockpit-adopt' diff --git a/src/renderer/src/tabs/Connection.tsx b/src/renderer/src/tabs/Connection.tsx index ebd507e..4fe4e07 100644 --- a/src/renderer/src/tabs/Connection.tsx +++ b/src/renderer/src/tabs/Connection.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useStore, type SourceMode, type Transport } from '../store' import { USB_IDENTITY } from '@shared/firmware' -import { isLinkUp } from '@shared/gateway/ipc' +import { isLinkUp } from '@shared/gateway' import { nodeViews } from '../cockpit' import { nodesSummary } from '../connection-summary' import { deviceView, relayNote, startBlocked, startBlockedReason } from '../device-view' diff --git a/src/renderer/src/tabs/Hid.tsx b/src/renderer/src/tabs/Hid.tsx index f71a28a..a8a7712 100644 --- a/src/renderer/src/tabs/Hid.tsx +++ b/src/renderer/src/tabs/Hid.tsx @@ -1,5 +1,5 @@ import { HID_REPORT_LAYOUT } from '@shared/firmware' -import { isLinkUp } from '@shared/gateway/ipc' +import { isLinkUp } from '@shared/gateway' import { useStore, AXIS_LABELS, HAT_DIRS } from '../store' import { freshness } from '../freshness' import { CalibrationDialog, useCalibration } from '../CalibrationDialog' diff --git a/src/renderer/src/tabs/Log.tsx b/src/renderer/src/tabs/Log.tsx index b453ad0..b5afcba 100644 --- a/src/renderer/src/tabs/Log.tsx +++ b/src/renderer/src/tabs/Log.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { SEVERITY_GLYPH } from '@shared/events' -import type { EventSeverity } from '@shared/events/ipc' +import { SEVERITY_GLYPH } from '@shared/events/legacy-rules' +import type { EventSeverity } from '@shared/events' import { eventText, fileName, fmtTime } from '@shared/format' import { useStore, type DirFilter } from '../store' import { eventSourceLabel, filterEvents, type EventSourceFilter, type LogView } from '../log-view' diff --git a/src/renderer/src/tabs/Overview.tsx b/src/renderer/src/tabs/Overview.tsx index e30e915..8988085 100644 --- a/src/renderer/src/tabs/Overview.tsx +++ b/src/renderer/src/tabs/Overview.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { fmtBytes, fmtUptime } from '@shared/format' import { useStore, type TabId } from '../store' -import { isLinkUp, type DeviceState } from '@shared/gateway/ipc' +import { isLinkUp, type DeviceState } from '@shared/gateway' import { attentionItems, healthBand, nodeViews } from '../cockpit' import { attentionSummary, cockpitTile, newDevices, newDevicesText } from '../overview' import { gatewayView } from '../gateway-view' diff --git a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx b/src/renderer/src/tabs/cockpit/AdoptDialog.tsx index 517ceff..8ac45cd 100644 --- a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx +++ b/src/renderer/src/tabs/cockpit/AdoptDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { seenAgo } from '@shared/format' -import type { NodeMappingResult } from '@shared/nodes/ipc' +import type { NodeMappingResult } from '@shared/nodes' import type { NodeView } from '../../cockpit' import { adoptability, diff --git a/src/renderer/src/tabs/cockpit/SidePanels.tsx b/src/renderer/src/tabs/cockpit/SidePanels.tsx index 1236bb0..c4cacf0 100644 --- a/src/renderer/src/tabs/cockpit/SidePanels.tsx +++ b/src/renderer/src/tabs/cockpit/SidePanels.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { DASH, voltsValue } from '@shared/format' -import type { NodeMappingResult, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes/ipc' +import type { NodeMappingResult, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes' import type { NodeView } from '../../cockpit' import { boardActions, diff --git a/src/renderer/src/tabs/settings/AboutSection.tsx b/src/renderer/src/tabs/settings/AboutSection.tsx index db96bfa..d5d5ab0 100644 --- a/src/renderer/src/tabs/settings/AboutSection.tsx +++ b/src/renderer/src/tabs/settings/AboutSection.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { isLinkUp } from '@shared/gateway/ipc' -import type { NodeRegistryInfo } from '@shared/nodes/ipc' +import { isLinkUp } from '@shared/gateway' +import type { NodeRegistryInfo } from '@shared/nodes' import { useStore } from '../../store' import { Dialog } from './Dialog' import { Row, SectionHead } from './Row' diff --git a/src/renderer/src/tabs/settings/CockpitSection.tsx b/src/renderer/src/tabs/settings/CockpitSection.tsx index 54ce103..1f789c0 100644 --- a/src/renderer/src/tabs/settings/CockpitSection.tsx +++ b/src/renderer/src/tabs/settings/CockpitSection.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { PSU_LIMITS } from '@shared/format' -import { parsePsuLimit, PSU_PRESETS } from '@shared/psu' -import type { PsuLimits } from '@shared/nodes/ipc' +import { parsePsuLimit, PSU_PRESETS } from '@shared/nodes/legacy-rules' +import type { PsuLimits } from '@shared/nodes' import { useStore } from '../../store' import { ResetDialog } from './ResetDialog' import { Row, SectionHead } from './Row' diff --git a/src/renderer/src/tabs/settings/ConfigurationSection.tsx b/src/renderer/src/tabs/settings/ConfigurationSection.tsx index c349642..74c4de9 100644 --- a/src/renderer/src/tabs/settings/ConfigurationSection.tsx +++ b/src/renderer/src/tabs/settings/ConfigurationSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { ImportPreview } from '@shared/config/ipc' +import type { ImportPreview } from '@shared/config' import { configState, useStore } from '../../store' import { ImportDialog } from './ImportDialog' import { Row, SectionHead } from './Row' diff --git a/src/renderer/src/tabs/settings/ImportDialog.tsx b/src/renderer/src/tabs/settings/ImportDialog.tsx index ddd6e0b..f9a89d0 100644 --- a/src/renderer/src/tabs/settings/ImportDialog.tsx +++ b/src/renderer/src/tabs/settings/ImportDialog.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { ConfigImportApplyResult, ImportPreview, SectionId } from '@shared/config/ipc' +import type { ConfigImportApplyResult, ImportPreview, SectionId } from '@shared/config' import { AXIS_LABELS } from '../../store' import { Dialog } from './Dialog' import { importRowKey, importSubtitle } from './settings-view' diff --git a/src/renderer/src/tabs/settings/ResetDialog.tsx b/src/renderer/src/tabs/settings/ResetDialog.tsx index 8e099ee..9c3e649 100644 --- a/src/renderer/src/tabs/settings/ResetDialog.tsx +++ b/src/renderer/src/tabs/settings/ResetDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import type { NodeModel, ResetPreview } from '@shared/nodes/ipc' +import type { NodeModel, ResetPreview } from '@shared/nodes' import { Dialog } from './Dialog' import { resetButtonLabel } from './settings-view' diff --git a/src/renderer/src/tabs/settings/settings-view.test.ts b/src/renderer/src/tabs/settings/settings-view.test.ts index def777d..e0eddac 100644 --- a/src/renderer/src/tabs/settings/settings-view.test.ts +++ b/src/renderer/src/tabs/settings/settings-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes/ipc' +import type { NodeModel, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes' import { hiddenRow, importDoneText, diff --git a/src/renderer/src/tabs/settings/settings-view.ts b/src/renderer/src/tabs/settings/settings-view.ts index 02adc06..ac1d269 100644 --- a/src/renderer/src/tabs/settings/settings-view.ts +++ b/src/renderer/src/tabs/settings/settings-view.ts @@ -1,7 +1,7 @@ // Settings text decisions (#67), pure so they are unit-tested: what each Cockpit / About row says. import { GATEWAY_NODE_ID } from '@shared/firmware' import { day, hhmm, plural } from '@shared/format' -import type { HiddenNode, NodeModel, NodeRegistryInfo } from '@shared/nodes/ipc' +import type { HiddenNode, NodeModel, NodeRegistryInfo } from '@shared/nodes' import { consoleLabel } from '../../cockpit-panels' /** "Reset node mapping" row description. */ diff --git a/src/renderer/src/tabs/settings/useNodeModel.ts b/src/renderer/src/tabs/settings/useNodeModel.ts index 3305540..60f14a2 100644 --- a/src/renderer/src/tabs/settings/useNodeModel.ts +++ b/src/renderer/src/tabs/settings/useNodeModel.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import type { NodeModel } from '@shared/nodes/ipc' +import type { NodeModel } from '@shared/nodes' /** * The latest `nodes:model` while the caller is mounted: fetched once, then followed. Settings diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts index 3ff6b02..34088e7 100644 --- a/src/renderer/test/scenarios.ts +++ b/src/renderer/test/scenarios.ts @@ -1,19 +1,18 @@ // Scenario builders for component tests: main-side state at today's push level (skyhawk-mock.ts). // One place for them, on top of the shared node fixtures, so every component test describes the // world the same way. Each builder states its defaults; a test passes whatever differs. -import { emptyCache } from '@shared/cal-cache' -import { appSettings, buildConfigFile, diffImport, parseConfigFile } from '@shared/config-file' -import { DEFAULT_CONFIG, type ConfigSnapshot, type ImportPreview } from '@shared/config/ipc' -import { gatewayStatus } from '@shared/gateway' -import type { DeviceState, DeviceStatus, GatewayStatus } from '@shared/gateway/ipc' -import type { - NodeDefaults, - NodeModel, - NodeModelEntry, - NodeStatus, - PduOutage -} from '@shared/nodes/ipc' -import { NODE_KIND } from '@shared/nodes' +import { emptyCache } from '../../main/hid/calibration/cache-rules' +import { + appSettings, + buildConfigFile, + diffImport, + parseConfigFile +} from '../../main/config/config-file' +import { DEFAULT_CONFIG, type ConfigSnapshot, type ImportPreview } from '@shared/config' +import { gatewayStatus } from '../../main/gateway/gateway-health' +import type { DeviceState, DeviceStatus, GatewayStatus } from '@shared/gateway' +import type { NodeDefaults, NodeModel, NodeModelEntry, NodeStatus, PduOutage } from '@shared/nodes' +import { NODE_KIND } from '../../main/nodes/node-status-wire' import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' export { modelNode, nodeModel, nodeStatus } @@ -35,7 +34,7 @@ export const connected = (gateway?: GatewayStatus): DeviceStatus => // ── the Gateway's own health (`device:status.gateway`), from main's own builder ───────────── /** - * What main computes from the Gateway's node-0 status row (shared/gateway.ts gatewayStatus): its + * What main computes from the Gateway's node-0 status row (gateway-health.ts gatewayStatus): its * health frame has arrived (kind 03), 42 °C, plus `row` (e.g. `{ boff: true }`). */ export function gateway(row: Partial = {}): GatewayStatus { diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 24bde77..122302d 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -24,14 +24,14 @@ // `SkyhawkApi`, so an IPC change breaks the mock at typecheck, not at run time. import { act } from '@testing-library/react' import { vi, type Mock } from 'vitest' -import { DEFAULT_CONFIG, type AppConfig } from '@shared/config/ipc' -import type { AircraftStatus, RelayState } from '@shared/dcs/ipc' -import type { CalSnapshot } from '@shared/hid/calibration/ipc' -import type { DeviceStatus } from '@shared/gateway/ipc' -import type { HidSnapshot } from '@shared/hid/ipc' -import type { NodeModel, NodeRegistryInfo, NodeTelemSnapshot } from '@shared/nodes/ipc' -import type { PushChannel, PushChannels, SkyhawkApi } from '@shared/app/ipc' -import type { LogEvent } from '@shared/events/ipc' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' +import type { AircraftStatus, RelayState } from '@shared/dcs' +import type { CalSnapshot } from '@shared/hid/calibration' +import type { DeviceStatus } from '@shared/gateway' +import type { HidSnapshot } from '@shared/hid' +import type { NodeModel, NodeRegistryInfo, NodeTelemSnapshot } from '@shared/nodes' +import type { PushChannel, PushChannels, SkyhawkApi } from '@shared/app' +import type { LogEvent } from '@shared/events' /** What main knows right now. Every field is optional: absent = main's state at launch. */ export interface Scenario { diff --git a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx index 1c42260..658f88e 100644 --- a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx +++ b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx @@ -1,7 +1,7 @@ // T11 — the Adopt dialog: "Add all", the Powered by choices, and a board power-cycled to identify it. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeModel, NodeModelEntry } from '@shared/nodes/ipc' +import type { NodeModel, NodeModelEntry } from '@shared/nodes' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario, settle } from './render' import { connected, model, newBoard, pdu } from './scenarios' diff --git a/src/renderer/test/t12-settings-hidden-boards.test.tsx b/src/renderer/test/t12-settings-hidden-boards.test.tsx index 65a1566..06790e8 100644 --- a/src/renderer/test/t12-settings-hidden-boards.test.tsx +++ b/src/renderer/test/t12-settings-hidden-boards.test.tsx @@ -1,7 +1,7 @@ // T12 — Settings › Cockpit › Hidden boards. import { describe, expect, it } from 'vitest' import { screen } from '@testing-library/react' -import type { NodeModel } from '@shared/nodes/ipc' +import type { NodeModel } from '@shared/nodes' import { Settings } from '../src/tabs/Settings' import { renderScenario, settle } from './render' import { connected, model, panel } from './scenarios' diff --git a/src/renderer/test/t13-settings-import-preview.test.tsx b/src/renderer/test/t13-settings-import-preview.test.tsx index 1796a91..a8d3b77 100644 --- a/src/renderer/test/t13-settings-import-preview.test.tsx +++ b/src/renderer/test/t13-settings-import-preview.test.tsx @@ -1,8 +1,8 @@ // T13 — Settings › Configuration › Import: the review of what a file would change, and Apply. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import { DEFAULT_CONFIG, type ImportPreview } from '@shared/config/ipc' -import { appSettings } from '@shared/config-file' +import { DEFAULT_CONFIG, type ImportPreview } from '@shared/config' +import { appSettings } from '../../main/config/config-file' import { Settings } from '../src/tabs/Settings' import { renderScenario, settle } from './render' import { configFileText, importPreview, setup } from './scenarios' diff --git a/src/renderer/test/t16-board-panel-settings.test.tsx b/src/renderer/test/t16-board-panel-settings.test.tsx index 80f064a..ae96af8 100644 --- a/src/renderer/test/t16-board-panel-settings.test.tsx +++ b/src/renderer/test/t16-board-panel-settings.test.tsx @@ -1,7 +1,7 @@ // T16 — the board panel's Settings tab: display name, console and power zone, and what Save sends. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeRegistryInfo } from '@shared/nodes/ipc' +import type { NodeRegistryInfo } from '@shared/nodes' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario, settle } from './render' import { connected, model, newBoard, panel, pdu } from './scenarios' diff --git a/src/renderer/test/t6-device-popover.test.tsx b/src/renderer/test/t6-device-popover.test.tsx index 72d854e..0c3da6d 100644 --- a/src/renderer/test/t6-device-popover.test.tsx +++ b/src/renderer/test/t6-device-popover.test.tsx @@ -1,7 +1,7 @@ // T6 — the title-bar Device indicator and its popover: link states and the Gateway's Health. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { DeviceState } from '@shared/gateway/ipc' +import type { DeviceState } from '@shared/gateway' import { DevicePopover } from '../src/DevicePopover' import { App } from '../src/App' import { renderScenario } from './render' diff --git a/src/renderer/test/t7-board-footer-panel.test.tsx b/src/renderer/test/t7-board-footer-panel.test.tsx index 55b9bdd..ae2a149 100644 --- a/src/renderer/test/t7-board-footer-panel.test.tsx +++ b/src/renderer/test/t7-board-footer-panel.test.tsx @@ -1,7 +1,7 @@ // T7 — the board panel footer, for a panel group: Save · Reset to default | Forget · Hide. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeModelEntry } from '@shared/nodes/ipc' +import type { NodeModelEntry } from '@shared/nodes' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario } from './render' import { connected, model, noStatusYet, offline, panel } from './scenarios' diff --git a/src/renderer/test/t8-board-footer-pdu.test.tsx b/src/renderer/test/t8-board-footer-pdu.test.tsx index 5f2bf2a..b0f3473 100644 --- a/src/renderer/test/t8-board-footer-pdu.test.tsx +++ b/src/renderer/test/t8-board-footer-pdu.test.tsx @@ -1,7 +1,7 @@ // T8 — the board panel footer, for a PDU: what Forget and Hide do to the boards it feeds. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' -import type { NodeModel } from '@shared/nodes/ipc' +import type { NodeModel } from '@shared/nodes' import { Cockpit } from '../src/tabs/Cockpit' import { renderScenario } from './render' import { connected, model, offline, panel, pdu } from './scenarios' diff --git a/src/shared/events.ts b/src/shared/events.ts deleted file mode 100644 index d4feb50..0000000 --- a/src/shared/events.ts +++ /dev/null @@ -1,418 +0,0 @@ -// Event log (#64): what the client observed, node and client, as a session list (pure, no -// Node/Electron deps). In memory only — never written to the debug log — and cleared on Clear or -// restart. Node events come from changes in the boards' own reports (`_NODE_STATUS`, -// `_NODE_TELEM`): the client never applies a threshold, so every alert here is a flag a board -// raised itself. -import type { DeviceStatus } from './gateway/ipc' -import type { RelayState } from './dcs/ipc' -import type { SourceMode } from './config/ipc' -import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './nodes' -import type { NodeStatus, NodeTelem, PduRailPage } from './nodes/ipc' -import { displayCurrentMa, PDU_FLAG, RAIL_FLAG, TELEM_PAGE } from './node-telem' -import { MAX_EVENTS, type EventSeverity, type LogEvent, type LogEventInput } from './events/ipc' -import { amps, fmtDateTime, volts } from './format' - -export const SEVERITY_GLYPH: Record = { - error: '✕', - warn: '!', - new: '+', - info: '●' -} - -/** What "Problems only" keeps: something is wrong — not a new node, a cleared flag or a link up. */ -export function isProblem(e: Pick): boolean { - return e.severity === 'error' || e.severity === 'warn' -} - -// ── node events ───────────────────────────────────────────────────────────── - -/** What the node mapping says about an id, as the tracker needs it. */ -export interface NodeLookup { - /** Effective name; `Node ` when the id has none. */ - name(nodeId: number): string - /** Neither in the registry nor adopted, and identified as something adoptable: news. */ - isNew(nodeId: number): boolean - /** Not in your cockpit (not added, not adopted), whatever it has identified as. */ - unmapped(nodeId: number): boolean - /** In NODE_IDS.md — a new one is waiting to be added, pre-filled (#93). Absent = never. */ - inRegistry?(nodeId: number): boolean -} - -export type StatusFlag = 'boff' | 'epvf' | 'degraded' | 'overheat' - -/** A node's own heartbeat / health flags, as every row and the Gateway (#94) name them. */ -export const STATUS_FLAGS: { key: StatusFlag; label: string; severity: EventSeverity }[] = [ - { key: 'boff', label: 'bus-off', severity: 'error' }, - { key: 'epvf', label: 'error-passive', severity: 'warn' }, - { key: 'degraded', label: 'degraded', severity: 'warn' }, - { key: 'overheat', label: 'overheat', severity: 'warn' } -] - -/** Rail flag text, as the Cockpit prints it (`5V UNDER-V`). */ -const RAIL_FLAGS: [number, string][] = [ - [RAIL_FLAG.UNDER_V, 'UNDER-V'], - [RAIL_FLAG.OVER_V, 'OVER-V'], - [RAIL_FLAG.OVER_CURRENT, 'OVER-I'], - [RAIL_FLAG.FUSE_OPEN, 'FUSE OPEN'], - [RAIL_FLAG.SENSOR_FAULT, 'SENSOR FAULT'] -] - -/** UNCAL_* are calibration state, not alerts (as on the Cockpit), so they are info. */ -const PDU_STATUS_FLAGS: [number, string, EventSeverity][] = [ - [PDU_FLAG.TEMP_CRIT, 'TEMP CRIT', 'warn'], - [PDU_FLAG.TEMP_WARN, 'TEMP WARN', 'warn'], - [PDU_FLAG.NTC_FAULT, 'NTC FAULT', 'warn'], - [PDU_FLAG.UNCAL_VDDA, 'UNCAL VDDA', 'info'], - [PDU_FLAG.UNCAL_ZERO, 'UNCAL ZERO', 'info'] -] - -const TEMP_FLAGS = PDU_FLAG.TEMP_CRIT | PDU_FLAG.TEMP_WARN - -/** The reading that goes with a raised rail flag: the window minimum for under-voltage. */ -function railReading(bit: number, p: PduRailPage): string { - if (bit === RAIL_FLAG.UNDER_V) return volts(p.vMinMv) - if (bit === RAIL_FLAG.OVER_CURRENT) return amps(displayCurrentMa(p.iAvgMa)) - return volts(p.vAvgMv) -} - -/** The fault-dictionary label, else `Fault N`. */ -export function faultLabel(n: Pick): string { - return n.faultLabel ?? `Fault ${n.faultId}` -} - -/** - * Turns the node reports the client receives into change events. - * - * Keeps its own last-seen state per node, so a roster poll that repeats what is already known - * produces nothing. After every port open (`reset`) the first roster is a baseline: nodes found - * online there are one "N nodes online" row, not N; problems already present (offline, a flag - * raised) still get their own rows, since nothing else would show them. - */ -export class NodeEventTracker { - private prev = new Map() - private pages = new Map() - private baseline = true - private baselineAt?: number - /** New ids reported this app run — kept through a port reopen, so a replug does not repeat them. */ - private newSeen = new Set() - - constructor(private readonly lookup: NodeLookup) {} - - /** A port open: a different board, or the same one after an outage. Its state starts over. */ - reset(): void { - this.prev.clear() - this.pages.clear() - this.baseline = true - this.baselineAt = undefined - } - - /** - * After one `_NODE_STATUS` / `_NODE_STATUS_END` line was applied to the roster: the roster as it - * now stands (fault labels filled in), and the line that changed it. - */ - status(roster: NodeStatus[], identifier: string, raw: string, now: number): LogEventInput[] { - const out: LogEventInput[] = [] - const isEnd = identifier === NODE_END_MSG - this.baselineAt ??= now - // A bridge that never ends its roster reply: the baseline closes once the reply window passes. - if (this.baseline && !isEnd && now - this.baselineAt > ROSTER_REPLY_MS) { - out.push(this.endBaseline('', now)) - } - for (const n of roster) { - // The bridge's own row (node 0, v3) is this client's link: no events, not in the counts. - if (n.nodeId === BRIDGE_NODE_ID) continue - const p = this.prev.get(n.nodeId) - this.prev.set(n.nodeId, n) - out.push(...this.diff(p, n, raw, isEnd, now)) - } - if (isEnd && this.baseline) out.push(this.endBaseline(raw, now)) - return out - } - - /** One decoded `_NODE_TELEM` frame: PDU rail / status flags raised and cleared. */ - telemetry(t: NodeTelem, raw: string, now: number): LogEventInput[] { - if (t.kind === 'unknown') return [] - const key = `${t.nodeId}:${t.page}` - const was = this.pages.get(key) ?? 0 - this.pages.set(key, t.flags) - if (was === t.flags) return [] - const out: LogEventInput[] = [] - const ev = (bit: number, text: string, severity: EventSeverity, reading?: string): void => { - const on = (t.flags & bit) !== 0 - if (on === ((was & bit) !== 0)) return - out.push({ - t: now, - source: 'node', - nodeId: t.nodeId, - name: this.lookup.name(t.nodeId), - severity: on ? severity : 'info', - event: `${text} ${on ? 'raised' : 'cleared'}`, - detail: on ? reading : undefined, - raw - }) - } - if (t.kind === 'rail') { - const rail = t.page === TELEM_PAGE.PDU_RAIL_12V ? '12V' : '5V' - for (const [bit, text] of RAIL_FLAGS) { - ev(bit, `${rail} ${text}`, 'warn', railReading(bit, t)) - } - } else { - const temp = t.ntcTempC === null ? undefined : `${t.ntcTempC.toFixed(1)} °C` - for (const [bit, text, severity] of PDU_STATUS_FLAGS) { - ev(bit, text, severity, bit & TEMP_FLAGS ? temp : undefined) - } - } - return out - } - - private endBaseline(raw: string, now: number): LogEventInput { - this.baseline = false - let online = 0 - for (const n of this.prev.values()) if (n.present) online++ - return { - t: now, - source: 'node', - name: 'roster', - severity: 'info', - event: `${online} node${online === 1 ? '' : 's'} online`, - raw - } - } - - private diff( - p: NodeStatus | undefined, - n: NodeStatus, - raw: string, - isEnd: boolean, - now: number - ): LogEventInput[] { - const out: LogEventInput[] = [] - const id = n.nodeId - const ev = (severity: EventSeverity, event: string, detail?: string): void => { - out.push({ - t: now, - source: 'node', - nodeId: id, - name: this.lookup.name(id), - severity, - event, - detail, - raw - }) - } - const emittedNew = n.present && this.lookup.isNew(id) && !this.newSeen.has(id) - if (emittedNew) { - this.newSeen.add(id) - ev( - 'new', - 'new on the bus', - this.lookup.inRegistry?.(id) ? 'in NODE_IDS.md' : 'not in registry' - ) - } - const wasPresent = p?.present ?? false - if (!n.present && (p === undefined || wasPresent)) { - // A live delta is the bridge's heartbeat timeout; a roster end finds a node missing from it. - ev('error', 'offline', isEnd ? 'missing from roster' : 'no heartbeat') - } else if (n.present && !wasPresent) { - // A first sighting the baseline counts in its summary row, or that just made its "new" row, - // needs no "online" too; nor does an unmapped node not yet announced — it gets one "new on - // the bus" row once it identifies (#344). A new node seen in an earlier port session does: - // it has no other row. - const first = p === undefined - const unannounced = this.lookup.unmapped(id) && !this.newSeen.has(id) - if (!first || !(this.baseline || emittedNew || unannounced)) ev('info', 'online') - } - return this.flagsFrom(wasPresent ? p : undefined, n, ev, out) - } - - /** Flags only mean something while the node reports; one coming back reports them afresh. */ - private flagsFrom( - was: NodeStatus | undefined, - n: NodeStatus, - ev: (severity: EventSeverity, event: string, detail?: string) => void, - out: LogEventInput[] - ): LogEventInput[] { - if (!n.present) return out - for (const f of STATUS_FLAGS) { - const before = was?.[f.key] ?? false - const on = n[f.key] - if (f.key === 'degraded' && on && before && was!.faultId !== n.faultId) { - ev('warn', 'degraded', faultLabel(n)) - } else if (on && !before) { - const detail = - f.key === 'degraded' ? faultLabel(n) : f.key === 'epvf' ? `TEC ${n.tec}` : undefined - ev(f.severity, f.label, detail) - } else if (!on && before) { - ev('info', `${f.label} cleared`) - } - } - return out - } -} - -// ── client events ─────────────────────────────────────────────────────────── - -/** A client diagnostic — the `SERIAL` / `DCS` errors the Stream used to show as `! SYS`. */ -export function clientErrorEvent(tag: string, message: string, now: number): LogEventInput { - return { - t: now, - source: 'client', - name: tag, - severity: 'error', - event: tag === 'SERIAL' ? 'serial port error' : tag === 'DCS' ? 'link error' : 'error', - detail: message, - raw: `${tag} Error: ${message}` - } -} - -/** - * A device-link change worth a row, or null. `last` is the last state other than `scanning`, - * which never makes a row — it comes and goes on every retry — so a retry loop of - * scanning → no-device → scanning adds nothing after its first "no device". - */ -export function deviceEvent( - last: DeviceStatus['state'] | undefined, - next: DeviceStatus, - now: number -): LogEventInput | null { - if (next.state === 'scanning' || next.state === last) return null - const base = { t: now, source: 'client' as const, name: 'Gateway' } - const raw = ['device:', next.state, next.portPath, next.detail].filter(Boolean).join(' ') - switch (next.state) { - case 'connected': - return { ...base, severity: 'info', event: 'connected', detail: next.portPath, raw } - case 'reconnecting': - return { ...base, severity: 'warn', event: 'reconnecting', detail: 'link dropped', raw } - case 'error': - // The link raises the same failure as a SERIAL error, which already makes the row. - return null - case 'no-device': - // Launching (or pressing Connect) with nothing plugged in is a resting state, not news. - if (last === undefined || last === 'disconnected') return null - return { ...base, severity: 'warn', event: 'no device', detail: 'unplugged', raw } - case 'disconnected': - return { ...base, severity: 'info', event: 'disconnected', raw } - } -} - -export interface RelayContext { - mode: SourceMode - /** Where the DCS-BIOS stream comes from, e.g. `127.0.0.1:7778`. */ - endpoint: string - /** TCP connects; UDP only binds, so "connected" there is "listening". */ - tcp: boolean -} - -const MODE_LABEL: Record = { - bridge: 'Bridge mode', - monitor: 'Monitor mode', - replay: 'Replay' -} - -/** - * Relay started / stopped, and the DCS-BIOS stream coming up or dropping while it runs. A stream - * error is not repeated here: the transport's own error already makes a `DCS` row, and the - * reconnect attempts that follow it are not rows either. - */ -export function relayEvents( - prev: RelayState, - next: RelayState, - ctx: RelayContext, - now: number -): LogEventInput[] { - const relay = { t: now, source: 'client' as const, name: 'Relay' } - const dcs = { t: now, source: 'client' as const, name: 'DCS' } - const out: LogEventInput[] = [] - if (!prev.running && next.running) { - out.push({ - ...relay, - severity: 'info', - event: 'started', - detail: MODE_LABEL[ctx.mode], - raw: `relay: start (${ctx.mode})` - }) - } else if (prev.running && !next.running) { - out.push({ ...relay, severity: 'info', event: 'stopped', raw: 'relay: stop' }) - } - if (!next.running || prev.stream === next.stream) return out - if (ctx.mode === 'replay') { - if (next.stream === 'ended') { - out.push({ ...relay, severity: 'info', event: 'replay ended', raw: 'relay: replay ended' }) - } - } else if (next.stream === 'connected') { - out.push({ - ...dcs, - severity: 'info', - event: ctx.tcp ? 'link up' : 'listening', - detail: ctx.endpoint, - raw: `DCS: connected ${ctx.endpoint}` - }) - } else if (next.stream === 'reconnecting' && prev.stream === 'connected') { - // Only a link that was up dropping: retries after an error are that error's row, once. - out.push({ - ...dcs, - severity: 'warn', - event: 'reconnecting', - detail: ctx.endpoint, - raw: `DCS: reconnecting ${ctx.endpoint}` - }) - } - return out -} - -// ── the session list ──────────────────────────────────────────────────────── - -/** The session's events, oldest first, plus what has not been pushed to the renderer yet. */ -export class EventLog { - private rows: LogEvent[] = [] - private pending: LogEvent[] = [] - private nextId = 1 - - constructor(private readonly max = MAX_EVENTS) {} - - add(inputs: LogEventInput[]): void { - for (const input of inputs) { - const e = { ...input, id: this.nextId++ } - this.rows.push(e) - this.pending.push(e) - } - if (this.rows.length > this.max) this.rows.splice(0, this.rows.length - this.max) - if (this.pending.length > this.max) this.pending.splice(0, this.pending.length - this.max) - } - - /** Rows added since the last call, oldest first. */ - take(): LogEvent[] { - const p = this.pending - this.pending = [] - return p - } - - snapshot(): LogEvent[] { - return [...this.rows] - } - - clear(): void { - this.rows = [] - this.pending = [] - } -} - -// ── export ────────────────────────────────────────────────────────────────── - -/** A tab or line break inside a field would shift every column after it. */ -const cell = (s: string | number | undefined): string => - s === undefined ? '' : String(s).replace(/[\t\r\n]+/g, ' ') - -export const EVENTS_TSV_HEADER = 'date_time\tsource\tnode_id\tname\tseverity\tevent\tdetail\traw' - -/** Every row, oldest first, whatever the view filters — like the stream export. */ -export function eventsToTsv(rows: LogEvent[]): string { - const body = [...rows] - .sort((a, b) => a.id - b.id) - .map((e) => - [fmtDateTime(e.t), e.source, e.nodeId, e.name, e.severity, e.event, e.detail, e.raw] - .map(cell) - .join('\t') - ) - return [EVENTS_TSV_HEADER, ...body].join('\n') + '\n' -} diff --git a/src/shared/events/legacy-rules.ts b/src/shared/events/legacy-rules.ts new file mode 100644 index 0000000..0ecafed --- /dev/null +++ b/src/shared/events/legacy-rules.ts @@ -0,0 +1,21 @@ +// Renderer still calls these; they move to main in R3.x and this file disappears. +// +// Pure, no Node/Electron deps. Main imports them from here too, so there is one copy. +import type { EventSeverity, LogEvent } from './ipc' + +/** @deprecated Renderer still calls this; it moves to main in R3.x. */ +export const SEVERITY_GLYPH: Record = { + error: '✕', + warn: '!', + new: '+', + info: '●' +} + +/** + * What "Problems only" keeps: something is wrong — not a new node, a cleared flag or a link up. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function isProblem(e: Pick): boolean { + return e.severity === 'error' || e.severity === 'warn' +} diff --git a/src/shared/firmware/reference.test.ts b/src/shared/firmware/reference.test.ts index 5b3d73c..941a10e 100644 --- a/src/shared/firmware/reference.test.ts +++ b/src/shared/firmware/reference.test.ts @@ -47,10 +47,10 @@ import { PDU_STATUS_FLAG_INFO, type NodeFaultName } from '.' -import { ACFT_NAME } from '../../main/dcs/dcsbios-metadata' -import { SUPPORTED_NODE_PROTO } from '@shared/nodes' -import { SUPPORTED_TELEM_PROTO } from '@shared/node-telem' -import * as cal from '@shared/calibration' +import { ACFT_NAME } from '../../main/dcs/bios-protocol' +import { SUPPORTED_NODE_PROTO } from '../../main/nodes/node-status-wire' +import { SUPPORTED_TELEM_PROTO } from '../../main/nodes/node-telem' +import * as cal from '../../main/hid/calibration/codec' describe('a4ec-controls.generated', () => { it('has outputs, each with a numeric address', () => { @@ -200,8 +200,8 @@ describe('node-status.generated', () => { expect(new Set(tags).size).toBe(tags.length) }) - // shared/nodes.ts decodes the row by these names (#346). Pinned at the values the decoder was - // proven against, so a firmware relayout fails here before it mis-decodes a node. + // nodes/node-status-wire.ts decodes the row by these names (#346). Pinned at the values the + // decoder was proven against, so a firmware relayout fails here before it mis-decodes a node. it('pins the _NODE_STATUS row layout the decoder reads', () => { expect(GATEWAY_NODE_ID).toBe(0) expect(NODE_STATUS_ROW_BYTES).toBe(14) @@ -230,7 +230,7 @@ describe('node-status.generated', () => { }) }) -// shared/node-telem.ts imports its page ids and flag bits from here; only the decoder's protocol +// nodes/node-telem.ts imports its page ids and flag bits from here; only the decoder's protocol // version is its own, so a firmware bump fails here rather than mis-decoding. describe('node-telem contract', () => { it('matches the shared decoder (fails loudly on a firmware bump)', () => { @@ -248,7 +248,7 @@ describe('node-telem contract', () => { }) }) -// shared/calibration.ts imports these from here. The codec's framing and payload decoders are +// hid/calibration/codec.ts imports these from here. The codec's framing and payload decoders are // written for one protocol version, so a firmware bump to another has to fail here, not surface as // a mis-framed packet on the wire. describe('axis-cal.generated', () => { diff --git a/src/shared/format/events.ts b/src/shared/format/events.ts index 57ad8f4..e4e22ed 100644 --- a/src/shared/format/events.ts +++ b/src/shared/format/events.ts @@ -1,6 +1,6 @@ // Event-log text. Only pure formatting lives here: the severity tables and `isProblem` are keyed -// by the events contract's EventSeverity, which this leaf cannot import, so they stay in -// shared/events.ts. +// by the events contract's EventSeverity, which this leaf cannot import, so they live in +// shared/events/legacy-rules.ts. /** `event · detail`, as the Events view and the Cockpit box print it. */ export function eventText(e: { event: string; detail?: string }): string { diff --git a/src/shared/axis-capture.test.ts b/src/shared/hid/calibration/legacy-rules.test.ts similarity index 99% rename from src/shared/axis-capture.test.ts rename to src/shared/hid/calibration/legacy-rules.test.ts index 3f43f02..a0869a8 100644 --- a/src/shared/axis-capture.test.ts +++ b/src/shared/hid/calibration/legacy-rules.test.ts @@ -12,8 +12,8 @@ import { retrySweep, sweepsRemaining, tick -} from './axis-capture' -import type { CaptureState } from './hid/calibration/ipc' +} from './legacy-rules' +import type { CaptureState } from './ipc' const C = DEFAULT_CAPTURE_CONFIG diff --git a/src/shared/axis-capture.ts b/src/shared/hid/calibration/legacy-rules.ts similarity index 90% rename from src/shared/axis-capture.ts rename to src/shared/hid/calibration/legacy-rules.ts index 833eda1..c755a04 100644 --- a/src/shared/axis-capture.ts +++ b/src/shared/hid/calibration/legacy-rules.ts @@ -1,3 +1,7 @@ +// Renderer still calls these; they move to main in R3.x and this file disappears. +// +// Main imports them from here too, so there is one copy. (Was shared/axis-capture.ts.) +// // Axis travel capture (pure, no Node/Electron deps, fully testable). // // The client owns the whole calibration procedure — the device stores three numbers and models @@ -20,13 +24,9 @@ // The SEQ counter cannot substitute for that. A gap is only visible on the next arriving frame, // and in the case that matters no next frame arrives. -import type { - CaptureConfig, - CapturedSweep, - CaptureResult, - CaptureState -} from './hid/calibration/ipc' +import type { CaptureConfig, CapturedSweep, CaptureResult, CaptureState } from './ipc' +/** @deprecated Renderer still calls this; it moves to main in R3.1. */ export const DEFAULT_CAPTURE_CONFIG: CaptureConfig = { bandCounts: 300, endpointHoldMs: 1500, @@ -40,6 +40,8 @@ export const DEFAULT_CAPTURE_CONFIG: CaptureConfig = { /** * Unsigned 0–65535, as the wire and device storage use — and as the dialog displays. The client * applies no offset anywhere, so there is nothing to convert on the way in or out. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. */ export interface CaptureSample { /** Stamped on arrival in main, not at render time — batching would otherwise skew dwell. */ @@ -47,6 +49,7 @@ export interface CaptureSample { raw: number } +/** @deprecated Renderer still calls this; it moves to main in R3.1. */ export function beginCapture( now: number, /** Defaults to true for every axis; see CaptureState.selfCentring. */ @@ -79,7 +82,11 @@ function holdTarget(s: CaptureState): number { return s.step === 'centre' ? s.config.centreStableMs : s.config.endpointHoldMs } -/** Progress of the current hold, 0..1 — for a progress indicator, not a decision. */ +/** + * Progress of the current hold, 0..1 — for a progress indicator, not a decision. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. + */ export function holdProgress(s: CaptureState): number { if (s.phase !== 'capturing' || s.ref === null) return 0 return Math.min(1, (s.now - s.refSince) / holdTarget(s)) @@ -91,6 +98,8 @@ export function holdProgress(s: CaptureState): number { * Floors at zero rather than going negative: once the time is up the hold is waiting on * corroboration or on the movement gate, and a countdown running below zero would suggest the * clock is what is holding things up. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function holdRemainingMs(s: CaptureState): number { if (s.phase !== 'capturing' || s.ref === null) return holdTarget(s) @@ -104,6 +113,8 @@ export function holdRemainingMs(s: CaptureState): number { * it because `awaitingMovement` cannot answer that question: that flag is only raised once a * full hold has elapsed with too little travel, so for the first second of every step it reads * false whether or not the user has touched the axis. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function hasTravelled(s: CaptureState): boolean { return s.movedBy >= s.config.minTravelCounts @@ -150,6 +161,8 @@ function judgeSweep(s: CaptureState, sweep: CapturedSweep): string | null { * Centre is the mean of the release samples. A settled axis emits almost nothing, so no single * hold can beat the ±160-count uncertainty floor; averaging independent releases is the only * thing that improves on it. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function reconcile(s: CaptureState): CaptureResult { const min = Math.min(...s.accepted.map((a) => a.min)) @@ -170,7 +183,11 @@ export function reconcile(s: CaptureState): CaptureResult { return { min, centre, max } } -/** The default centre: half way between the endpoints. */ +/** + * The default centre: half way between the endpoints. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. + */ export function midpoint(min: number, max: number): number { return Math.round((min + max) / 2) } @@ -254,6 +271,8 @@ function commitStep(s: CaptureState, value: number): CaptureState { * * Essential rather than a convenience: a held axis emits nothing, so if dwell were only * evaluated on arrival, a perfectly steady hold would never complete. The UI ticks this. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function tick(s: CaptureState, now: number): CaptureState { if (s.phase !== 'capturing') return { ...s, now } @@ -309,7 +328,11 @@ function restCluster(s: CaptureState): number { return Math.round(recent.reduce((a, b) => a + b.raw, 0) / recent.length) } -/** Feed one RAW sample. */ +/** + * Feed one RAW sample. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. + */ export function push(s: CaptureState, sample: CaptureSample): CaptureState { if (s.phase !== 'capturing') return { ...s, now: sample.t } @@ -350,7 +373,11 @@ export function push(s: CaptureState, sample: CaptureSample): CaptureState { return tick(next, sample.t) } -/** Acknowledge a rejected sweep and redo it. Accepted sweeps are kept. */ +/** + * Acknowledge a rejected sweep and redo it. Accepted sweeps are kept. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. + */ export function retrySweep(s: CaptureState, now: number): CaptureState { if (s.phase !== 'rejected') return s return restartSweep(s, now) @@ -363,6 +390,8 @@ export function retrySweep(s: CaptureState, now: number): CaptureState { * For a sweep that was interrupted rather than rejected — the serial link closing mid-capture. * Its samples stop at the close and the gap is not visible in the data, so the half-finished * sweep cannot be trusted to continue: a hold that spans the outage would be timed across it. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function restartSweep(s: CaptureState, now: number): CaptureState { return { @@ -385,7 +414,11 @@ export function restartSweep(s: CaptureState, now: number): CaptureState { } } -/** Sweeps still needed. Rejected attempts do not count toward it. */ +/** + * Sweeps still needed. Rejected attempts do not count toward it. + * + * @deprecated Renderer still calls this; it moves to main in R3.1. + */ export function sweepsRemaining(s: CaptureState): number { return Math.max(0, s.config.sweepsRequired - s.accepted.length) } diff --git a/src/shared/nodes/legacy-rules.ts b/src/shared/nodes/legacy-rules.ts new file mode 100644 index 0000000..5a7a5f7 --- /dev/null +++ b/src/shared/nodes/legacy-rules.ts @@ -0,0 +1,133 @@ +// Renderer still calls these; they move to main in R3.x and this file disappears. +// +// Pure, no Node/Electron deps. Main imports them from here too, so there is one copy. +import { NODE_KINDS as NODE_KIND, PDU_RAIL_FLAGS, PDU_STATUS_FLAGS } from '../firmware' +import type { + NodeIdentity, + NodeKind, + NodeModelEntry, + NodeStatus, + PsuLimits, + PsuPreset, + PsuPresetId, + PsuSetting +} from './ipc' + +/** + * PDU_RAIL_* byte 1 bits (PDU_RAIL_FLAGS). All zero = healthy. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export const RAIL_FLAG = PDU_RAIL_FLAGS + +/** + * PDU_STATUS byte 1 bits (PDU_STATUS_FLAGS). All zero = healthy, calibrated. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export const PDU_FLAG = PDU_STATUS_FLAGS + +/** + * iAvgMa for display: residual negative offset reads as 0. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function displayCurrentMa(ma: number): number { + return Math.max(0, ma) +} + +/** + * Short degraded-badge tag — the fault abbr, or `Fault N` for an unknown/reserved id. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function nodeFaultTag(n: NodeStatus): string { + return n.faultAbbr ?? `Fault ${n.faultId}` +} + +/** + * The kind a status row reports (NodeStatus.nodeKind); undefined = not reported (0) or no row. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function reportedIdentity(nodeKind: number | undefined): NodeIdentity | undefined { + switch (nodeKind) { + case undefined: + case NODE_KIND.UNKNOWN: + return undefined + case NODE_KIND.PANEL_GROUP: + return 'panel' + case NODE_KIND.PDU: + return 'pdu' + case NODE_KIND.BRIDGE: + return 'bridge' + default: + return 'other' + } +} + +/** + * Only these can be adopted — a whitelist, so unknown, bridge and any future kind never are. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function isAdoptableKind(k: NodeIdentity | undefined): k is NodeKind { + return k === 'pdu' || k === 'panel' +} + +/** + * A new device worth telling the user about: not in the registry or adopted, on the bus now, and + * identified as something that can be adopted. Until then it is not news — not on the Cockpit + * count, the Overview notice or the event log. One predicate for every tab (node 0 is never in the + * model). + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function isReportableNew(e: NodeModelEntry): boolean { + return e.source === 'new' && e.availability === 'online' && isAdoptableKind(e.identified) +} + +/** + * Supplies bundled with the client. Limits are read-only in Settings. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export const PSU_PRESETS: Record = { + // GOLDEN FIELD NX650 — the project's supply. From its spec: +12 V 54 A, +5 V 20 A, 650 W. + // (+3.3 V and +5 V share 100 W combined; the Cockpit shows no 3.3 V rail, so that is left out.) + nx650: { label: 'NX650', name: 'GOLDEN FIELD NX650', limits: { a12: 54, a5: 20, w: 650 } } +} + +/** + * Largest value accepted for any limit; anything above is a typo, not a desktop PSU. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export const PSU_LIMIT_MAX = 10000 + +/** @deprecated Renderer still calls this; it moves to main in R3.x. */ +export const isLimit = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= PSU_LIMIT_MAX + +/** + * The limits the Cockpit should use: the preset's, or what was entered. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function effectivePsuLimits(psu: PsuSetting): PsuLimits { + return psu.mode === 'preset' ? PSU_PRESETS[psu.preset].limits : psu.custom +} + +/** + * Parse one limit as typed. Empty means "unknown" (undefined); anything that is not a positive + * number up to PSU_LIMIT_MAX is refused (null), so the field can show it and nothing is saved. + * + * @deprecated Renderer still calls this; it moves to main in R3.x. + */ +export function parsePsuLimit(text: string): number | undefined | null { + const t = text.trim() + if (!t) return undefined + if (!/^\d+(\.\d+)?$/.test(t)) return null + const n = Number(t) + return isLimit(n) ? n : null +} diff --git a/src/shared/psu.ts b/src/shared/psu.ts deleted file mode 100644 index ee01d59..0000000 --- a/src/shared/psu.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Power supply limits (#67) the Cockpit headroom is drawn against (#63). Pure; persisted in -// config.json as AppConfig.psu. Display only — the client never alerts on them. - -import { - DEFAULT_PSU, - type PsuLimits, - type PsuPreset, - type PsuPresetId, - type PsuSetting -} from './nodes/ipc' - -/** Supplies bundled with the client. Limits are read-only in Settings. */ -export const PSU_PRESETS: Record = { - // GOLDEN FIELD NX650 — the project's supply. From its spec: +12 V 54 A, +5 V 20 A, 650 W. - // (+3.3 V and +5 V share 100 W combined; the Cockpit shows no 3.3 V rail, so that is left out.) - nx650: { label: 'NX650', name: 'GOLDEN FIELD NX650', limits: { a12: 54, a5: 20, w: 650 } } -} - -/** Largest value accepted for any limit; anything above is a typo, not a desktop PSU. */ -export const PSU_LIMIT_MAX = 10000 - -const isLimit = (v: unknown): v is number => - typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= PSU_LIMIT_MAX - -function sanitizeLimits(raw: unknown): PsuLimits { - const r = (raw && typeof raw === 'object' ? raw : {}) as Record - const out: PsuLimits = {} - for (const k of ['a12', 'a5', 'w'] as const) if (isLimit(r[k])) out[k] = r[k] - return out -} - -const isPreset = (v: unknown): v is PsuPresetId => - typeof v === 'string' && Object.prototype.hasOwnProperty.call(PSU_PRESETS, v) - -/** Tolerant read: anything malformed falls back to its default, field by field. */ -export function sanitizePsu(raw: unknown): PsuSetting { - const p = (raw && typeof raw === 'object' ? raw : {}) as Record - return { - mode: p.mode === 'custom' || p.mode === 'preset' ? p.mode : DEFAULT_PSU.mode, - preset: isPreset(p.preset) ? p.preset : DEFAULT_PSU.preset, - custom: sanitizeLimits(p.custom) - } -} - -/** The limits the Cockpit should use: the preset's, or what was entered. */ -export function effectivePsuLimits(psu: PsuSetting): PsuLimits { - return psu.mode === 'preset' ? PSU_PRESETS[psu.preset].limits : psu.custom -} - -/** - * Parse one limit as typed. Empty means "unknown" (undefined); anything that is not a positive - * number up to PSU_LIMIT_MAX is refused (null), so the field can show it and nothing is saved. - */ -export function parsePsuLimit(text: string): number | undefined | null { - const t = text.trim() - if (!t) return undefined - if (!/^\d+(\.\d+)?$/.test(t)) return null - const n = Number(t) - return isLimit(n) ? n : null -} diff --git a/src/test-fixtures/config.ts b/src/test-fixtures/config.ts index e477183..178273f 100644 --- a/src/test-fixtures/config.ts +++ b/src/test-fixtures/config.ts @@ -1,6 +1,6 @@ // Shared config fixtures for tests (test-only: no production code may import this folder). -import { appSettings } from '@shared/config-file' -import { DEFAULT_CONFIG, type AppSettings } from '@shared/config/ipc' +import { appSettings } from '../main/config/config-file' +import { DEFAULT_CONFIG, type AppSettings } from '@shared/config' /** The `app` section of a config export taken from defaults: every setting but the PSU. */ export const DEFAULT_APP_SETTINGS: AppSettings = appSettings(DEFAULT_CONFIG) diff --git a/src/test-fixtures/nodes.ts b/src/test-fixtures/nodes.ts index 782b1d4..b767271 100644 --- a/src/test-fixtures/nodes.ts +++ b/src/test-fixtures/nodes.ts @@ -3,7 +3,7 @@ // One set of builders for the node status rows and node models that test files used to redefine // with their own defaults. A test that needs different defaults passes those fields explicitly, // so what each test feeds in stays visible at the call site. -import type { NodeModel, NodeModelEntry, NodeStatus, PduOutage } from '@shared/nodes/ipc' +import type { NodeModel, NodeModelEntry, NodeStatus, PduOutage } from '@shared/nodes' /** A parsed `_NODE_STATUS` row: present, healthy, kind not reported (0), 30 °C, 100 s up. */ export const nodeStatus = (nodeId: number, over: Partial = {}): NodeStatus => ({ From afadc633fa77e67595676363104a7288324701c2 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 22:19:31 -0400 Subject: [PATCH 12/28] refactor(nodes): the mapping store service and the JSON store (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.1 of the structure rewrite. Main's three JSON files under `` now share one set of read, parse and write mechanics. Each file keeps its own **policy object**, so it behaves exactly as it does today. The node mapping's state and persistence move out of `NodeHub` into a `MappingStore` service. There is no UI change and no functional change. Each file keeps today's read, write, error and recovery behaviour (X13). Unifying them (fsync, `.bak`) is fix track, not this PR. ## Commits 1. `test(config): pin config.json load and save behaviour (B97)`. This tier-2 FILE-seam test was written first, against today's code. It was proved able to fail by four mutations, each turning at least one case red: - rethrowing write errors; - writing compact JSON; - returning the shared `DEFAULT_CONFIG` object; - skipping `sanitizeConfig`. 2. `refactor(nodes): the MappingStore service and the JSON store`. ## The JSON store: `config/json-store.ts` `jsonFile(path, policy)` returns `{ read(), write(value) }`. - **`read()`** resolves the path; a throw there propagates. It then reports what it found: - `missing`; - `read` (with the parsed data); - `unreadable` (with the error); - for a file that doesn't parse: `damaged` (left in place), or `movedAside` / `stuck` (moved aside, or couldn't be). - **`write()`** always writes `JSON.stringify(value, null, 2)`. The store knows nothing about what a file means. The owner decides that through its port: defaults, an empty cache, an empty or read-only mapping. | Policy field | What it reproduces (base 64310ec) | config.json | calibration-cache.json | node-mapping.json | |---|---|---|---|---| | `atomic` | Write to `.tmp`, rename it over the file, and `rmSync(tmp, {force})` on failure (`nodes/mapping-store.ts:67-80`). When false, `writeFileSync` writes in place (`settings-service.ts:23-27`, `cache-store.ts:27-32`). | false | false | **true** | | `reportWriteErrors` | When true, a failed write is handed back as `{ error }` and `saveNodeMapping` throws `MappingSaveError("Could not save node mapping: …")`. The path is resolved *outside* that guard, so a throwing path throws raw, as at `mapping-store.ts:68`. When false, everything is swallowed, the path included, because it was inside `try` (`settings-service.ts:23`, `cache-store.ts:28`). | false | false | **true** | | `moveAsideDamaged` | When true, an unparseable file is renamed to `.bad-`. If the rename fails, the result is `stuck`, which loads read-only (`mapping-store.ts:38-53`). When false, the file is left where it is and the defaults load. | false | false | **true** | **Read-only.** Read-only behaviour (newer version, unreadable file, stuck file) isn't a store mechanic. It is the mapping format's meaning, so it lives with the owner in `nodes/mapping-file.ts`, with today's exact messages (`mapping-store.ts:34-59`). The plan's `readOnlyIfNewer` field would have made json-store know the mapping's version, so it isn't a field. The plan's `throwOnError` became `reportWriteErrors`; the reason is under *Didn't match the brief* below. ## What moved from NodeHub into MappingStore `nodes/mapping-store.ts` is the `MappingStore` service. These moved into it from `node-hub.ts`: - `loaded`; - `store()` (lazy load); - `mapping()`; - `save()` (no-op for the same file, refused while read-only, written before it is committed); - `commit()`; - `persist()` (logs `nodes.mapping` and commits unsaved); - the `NO_PATHS` fail-loud default, now `NO_FILE` with the same message. NodeHub builds one MappingStore and delegates to it: - `mapping()` → `store.mapping()`; - `persist` → `store.persist`; - `update` → `store.save`. `commit` marks the model dirty through an `onCommit` callback. The mapping rules stay in `mapping-rules.ts`. Today's `loadNodeMapping` / `saveNodeMapping` / `MappingSaveError` / `LoadedMapping` are now the file format in **`nodes/mapping-file.ts`**, over the port. They are a separate module from the service because NodeHub's tests mock the file layer below the store (`vi.mock('./mapping-file')`). That way the real `save`/`persist`/read-only logic still runs under B11, B12, B18 and B19. ## Ports - **`MappingFileIO`** is declared in `nodes/mapping-file.ts`, re-exported by `mapping-store.ts` and `nodes/index.ts`. It has `read()` (missing / read / unreadable / movedAside / stuck) and `write(file) → { error } | undefined`. Because `JsonRead` is typed by the policy, only a JsonFile under a move-aside policy can satisfy it; TypeScript checks this at the wiring. - **`CacheFileIO`** is declared in `hid/calibration/cache-store.ts`. It has `read()` (read / missing / unreadable / damaged) and a best-effort `write(cache)`. `loadCalCache` / `saveCalCache` take it in place of `CachePaths`. - **Wiring.** `app/user-data.ts` `storeFiles(paths)` builds both ports from `storePaths` and `JSON_POLICY`: - `Session` passes them to `DeviceLink`, whose deps are now `{ mappingFile, cacheFile, log }`, and `DeviceLink` passes them on to `NodeHub`; - `app/index.ts` uses `cacheFile` for ConfigTransfer's calibration section. - **Settings.** `config/settings-service.ts` is in `config`, so it builds its own `jsonFile` under `JSON_POLICY.config`. It stays a pair of functions, with the same `loadConfig(paths)` / `saveConfig(config, paths)` signatures. - **Imports.** No new import from `nodes` or `hid/calibration` into `config`. ## Proofs - **Contract rows.** B11, B12, B16, B17, B18, B19, B97 and B104 pass with their assertions unchanged. The `makeServices()` seam tests (`session.seam.test.ts`) pass, and the harness is unchanged. - **Test changes.** Only `vi.mock` paths changed, `./mapping-store` → `./mapping-file`, in `node-hub.test.ts`, `node-hub.seam.test.ts`, `phase0-probes.test.ts` and `session.test.ts`. In `mapping-store.test.ts` and `mapping-store.seam.test.ts`, the import path changed and the two-line local wrapper now hands the store `storeFiles(storePaths(...)).mappingFile` in place of `paths`. Every call and assert line is unchanged. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`). `out/main/index.js` was built at base and at head. The outputs are **byte-identical** with `SKYHAWK_USER_DATA` set and with the dev default (`/.userdata`). The `config.json` and `node-mapping.json` written to `` have identical sha1s. - **One-off per-file proof** (not committed). The same script was run at base and at head, and the outputs are **byte-identical** (71 cases, 223 lines). For config.json, calibration-cache.json and node-mapping.json, it records the return value or the thrown error class and message, plus the full `` listing with modes and contents, for: - missing file; - round trip; - damaged JSON; - damaged JSON in a read-only folder (move-aside fails); - unreadable file (EACCES, and a folder); - newer version; - older version; - write error (read-only folder, and read-only file); - a stale `.tmp`, on a successful and on a failed save; - a path port that throws on load and on save. It also drives NodeHub through the real `Session → DeviceLink` wiring (`importMapping`, `setMapping`, `mappingFile`) with the mapping missing, good, damaged, damaged-and-stuck, unreadable, newer, and failing to write. It also calls `DeviceLink.calCacheRead`. ## Didn't match the brief - **`throwOnError` became `reportWriteErrors`.** For node-mapping.json, today's save resolves the path *outside* its `try` (`mapping-store.ts:68`). A path that throws there propagates raw, not as `MappingSaveError`, and NodeHub's `persist` rethrows it rather than logging and committing. A store that simply threw would leave the owner unable to tell a write failure from a path failure without importing a `config` error class. So the store hands the write failure back as `{ error }`, `saveNodeMapping` wraps only that, and the proof's "path port throws on save" case is byte-identical. - **A new file, `nodes/mapping-file.ts`.** It holds the file-format half (the old `loadNodeMapping`/`saveNodeMapping`), so the tests that mock the file layer keep running the real MappingStore logic. The brief puts both halves in `mapping-store.ts`, but that would force those tests to mock MappingStore itself. - **No `readOnlyIfNewer` policy field.** Read-only stays in the mapping format, as described under *Read-only* above. ## Checks | Check | Result | |---|---| | `npx vitest run` | 67 files, 889 tests: 881 plus the 8 B97 tests | | `npm run typecheck` | clean | | `npm run lint` | 0 errors, 32 warnings. None of them is in a changed file. | | `npm run lint:deps` | 0 errors, 19 warnings, the same count as base | | `npm run build` | passes | Refs #99 --- src/main/app/index.ts | 8 +- src/main/app/session.test.ts | 2 +- src/main/app/session.ts | 4 +- src/main/app/user-data.ts | 13 +++ src/main/config/index.ts | 1 + src/main/config/json-store.ts | 123 ++++++++++++++++++++ src/main/config/settings-service.test.ts | 102 +++++++++++++++++ src/main/config/settings-service.ts | 18 +-- src/main/gateway/device-link.ts | 27 ++--- src/main/hid/calibration/cache-store.ts | 29 ++--- src/main/hid/calibration/index.ts | 2 +- src/main/nodes/index.ts | 1 + src/main/nodes/mapping-file.ts | 74 ++++++++++++ src/main/nodes/mapping-rules.ts | 2 +- src/main/nodes/mapping-store.seam.test.ts | 13 ++- src/main/nodes/mapping-store.test.ts | 13 ++- src/main/nodes/mapping-store.ts | 131 ++++++++++++---------- src/main/nodes/node-hub.seam.test.ts | 2 +- src/main/nodes/node-hub.test.ts | 2 +- src/main/nodes/node-hub.ts | 80 ++++--------- src/main/nodes/phase0-probes.test.ts | 2 +- 21 files changed, 467 insertions(+), 182 deletions(-) create mode 100644 src/main/config/json-store.ts create mode 100644 src/main/config/settings-service.test.ts create mode 100644 src/main/nodes/mapping-file.ts diff --git a/src/main/app/index.ts b/src/main/app/index.ts index 0a05273..f087afc 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -14,7 +14,7 @@ import { listSerialPorts } from '../gateway' import { loadCalCache, saveCalCache } from '../hid/calibration' import { Session } from './session' import { debugLog, debugLogPath, setDebugEnabled } from './debug-log' -import { storePaths, userDataOverride } from './user-data' +import { storeFiles, storePaths, userDataOverride } from './user-data' // Before anything touches userData: dev worktrees get their own folder (#81). const userDataDir = userDataOverride({ @@ -30,6 +30,8 @@ if (userDataDir) { /** The files, handed to the topics that own them (P6: only app asks electron). */ const paths = storePaths(() => app.getPath('userData')) +/** The same files as ports, for ConfigTransfer's calibration section. */ +const { cacheFile } = storeFiles(paths) /** Report indices the firmware catalogues, derived from HIDControls.h. */ function hidAvailability(): HidAvailability { @@ -80,8 +82,8 @@ const transfer = new ConfigTransfer({ getConfig: () => session.getConfig(), setConfig: (config) => applyConfig(config), nodes: session.link.nodes, - loadCalCache: () => loadCalCache(paths), - saveCalCache: (cache) => saveCalCache(cache, paths), + loadCalCache: () => loadCalCache(cacheFile), + saveCalCache: (cache) => saveCalCache(cache, cacheFile), backupDir: () => join(app.getPath('userData'), 'backups'), clientVersion: () => app.getVersion() }) diff --git a/src/main/app/session.test.ts b/src/main/app/session.test.ts index 4ac415e..ee9afd0 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/session.test.ts @@ -85,7 +85,7 @@ vi.mock('../gateway/serial-bridge', () => ({ })) vi.mock('../dcs/transports', () => ({ createTransport: () => new FakeTransport() })) // Keep the node mapping off the real disk. -vi.mock('../nodes/mapping-store', () => ({ +vi.mock('../nodes/mapping-file', () => ({ // Center_Armament and Right_Navigation are in this cockpit (#93: added). loadNodeMapping: () => ({ file: { version: 3, nodes: { 1: { added: true }, 2: { added: true } } } diff --git a/src/main/app/session.ts b/src/main/app/session.ts index ffdddd7..6c7ebd6 100644 --- a/src/main/app/session.ts +++ b/src/main/app/session.ts @@ -32,7 +32,7 @@ import { import { clientErrorEvent, EventLog } from '../events' import { deviceEvent, DeviceLink } from '../gateway' import { debugLog } from './debug-log' -import { storePaths, type StorePaths } from './user-data' +import { storeFiles, storePaths, type StorePaths } from './user-data' type Emit = (channel: C, payload: PushChannels[C]) => void @@ -116,7 +116,7 @@ export class Session { paths: StorePaths = storePaths(() => app.getPath('userData')) ) { this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) - this.link = new DeviceLink(emit, { paths, log: debugLog }) + this.link = new DeviceLink(emit, { ...storeFiles(paths), log: debugLog }) this.link.onBytes((bytes) => this.fromDevice(bytes)) this.link.onLine((l) => { if (l.kind !== 'command') return diff --git a/src/main/app/user-data.ts b/src/main/app/user-data.ts index e443208..c1ec416 100644 --- a/src/main/app/user-data.ts +++ b/src/main/app/user-data.ts @@ -2,6 +2,7 @@ // /.userdata so parallel worktrees (and the installed app) don't share // settings. SKYHAWK_USER_DATA overrides both. Pure, so it's testable without Electron. import { join, resolve } from 'node:path' +import { JSON_POLICY, jsonFile } from '../config' export const USER_DATA_ENV = 'SKYHAWK_USER_DATA' @@ -35,3 +36,15 @@ export function storePaths(userData: () => string) { } export type StorePaths = ReturnType + +/** + * The two files other topics reach through a port, each under the JSON store with its + * own policy: satisfies nodes' MappingFileIO and hid/calibration's CacheFileIO. config.json is + * config's own, so settings-service builds its file itself. + */ +export function storeFiles(paths: StorePaths) { + return { + mappingFile: jsonFile(() => paths.nodeMappingFile(), JSON_POLICY.nodeMapping), + cacheFile: jsonFile(() => paths.calibrationCacheFile(), JSON_POLICY.calibrationCache) + } +} diff --git a/src/main/config/index.ts b/src/main/config/index.ts index bbd1d98..f4bf89f 100644 --- a/src/main/config/index.ts +++ b/src/main/config/index.ts @@ -2,3 +2,4 @@ // (arch-phase0-v2 §2.1). export { loadConfig, saveConfig } from './settings-service' export { ConfigTransfer } from './config-transfer' +export { JSON_POLICY, jsonFile, type JsonFile, type JsonPolicy, type JsonRead } from './json-store' diff --git a/src/main/config/json-store.ts b/src/main/config/json-store.ts new file mode 100644 index 0000000..07d0b2b --- /dev/null +++ b/src/main/config/json-store.ts @@ -0,0 +1,123 @@ +// The JSON store: the one set of read / parse / write mechanics behind main's three files under +// (config.json, calibration-cache.json, node-mapping.json). What differs between them +// is a per-file policy object, so each keeps exactly the behaviour it had when it carried its own +// copy of this code (arch-phase0-v2 §1.2, X13). +// +// The store knows nothing about what a file means. It reports what it found; the owner decides +// what that means — defaults, an empty mapping, a read-only mapping — through its own port +// (nodes' MappingFileIO, hid/calibration's CacheFileIO), which a JsonFile satisfies. +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { stamp } from '@shared/format' + +export interface JsonPolicy { + /** + * Write `.tmp` and rename it over the file, removing the temp file if that fails, so a + * crash mid-write leaves the old file intact. false: write the file in place. + */ + readonly atomic: boolean + /** + * A failed write is handed back to the owner as `{ error }` (node mapping turns it into + * MappingSaveError). The path is resolved outside that guard, so a path that cannot be resolved + * throws, as before. false: any failure, the path included, is swallowed. + */ + readonly reportWriteErrors: boolean + /** + * A file that does not parse is renamed to `.bad-` before anything can overwrite + * it. false: it is left where it is. + */ + readonly moveAsideDamaged: boolean +} + +/** Each file's policy: today's behaviour, per file. Changing one is a behaviour change (fix track). */ +export const JSON_POLICY = { + /** config.json: written in place, best effort — a read-only userData must not crash the app. */ + config: { atomic: false, reportWriteErrors: false, moveAsideDamaged: false }, + /** calibration-cache.json: deliberately the same as config.json (a recovery aid, never fatal). */ + calibrationCache: { atomic: false, reportWriteErrors: false, moveAsideDamaged: false }, + /** node-mapping.json: a lost write is user work gone, so atomic, reported, and kept if damaged. */ + nodeMapping: { atomic: true, reportWriteErrors: true, moveAsideDamaged: true } +} as const satisfies Record + +/** What a read found. A damaged file is reported by what happened to it, per the policy. */ +export type JsonRead = + | { kind: 'missing' } + | { kind: 'read'; data: unknown } + | { kind: 'unreadable'; error: unknown } + | (MoveAside extends true + ? { kind: 'movedAside'; to: string } | { kind: 'stuck'; error: unknown } + : { kind: 'damaged' }) + +export interface JsonFile

    { + /** Resolves the path (a throw there propagates), then reads and parses the file. */ + read(): JsonRead + /** + * Writes `JSON.stringify(value, null, 2)`, as the policy says. Returns `{ error }` when the + * write failed and the policy reports it; otherwise undefined. + */ + write(value: unknown): { error: unknown } | undefined +} + +/** One file under the store's mechanics. `path` is resolved at each use. */ +export function jsonFile

    (path: () => string, policy: P): JsonFile

    { + return { + read: () => readJson(path(), policy) as JsonRead, + write: (value) => { + if (policy.reportWriteErrors) { + const p = path() + try { + writeJson(p, value, policy.atomic) + } catch (error) { + return { error } + } + return undefined + } + try { + writeJson(path(), value, policy.atomic) + } catch { + // best effort, by policy + } + return undefined + } + } +} + +function readJson(p: string, policy: JsonPolicy): JsonRead { + let text: string + try { + if (!existsSync(p)) return { kind: 'missing' } + text = readFileSync(p, 'utf8') + } catch (error) { + return { kind: 'unreadable', error } + } + try { + return { kind: 'read', data: JSON.parse(text) } + } catch { + if (!policy.moveAsideDamaged) return { kind: 'damaged' } + const to = `${p}.bad-${stamp(new Date())}` + try { + renameSync(p, to) + return { kind: 'movedAside', to } + } catch (error) { + return { kind: 'stuck', error } + } + } +} + +function writeJson(p: string, value: unknown, atomic: boolean): void { + if (!atomic) { + writeFileSync(p, JSON.stringify(value, null, 2)) + return + } + const tmp = `${p}.tmp` + try { + writeFileSync(tmp, JSON.stringify(value, null, 2)) + renameSync(tmp, p) + } catch (err) { + try { + rmSync(tmp, { force: true }) + } catch { + // the write already failed; a stray .tmp is harmless and overwritten next time + } + throw err + } +} diff --git a/src/main/config/settings-service.test.ts b/src/main/config/settings-service.test.ts new file mode 100644 index 0000000..e501ad1 --- /dev/null +++ b/src/main/config/settings-service.test.ts @@ -0,0 +1,102 @@ +// FILE-seam contract test (Phase 0, row B97): config.json as main reads and writes it in . +// It pins today's behaviour: a tolerant read (anything wrong loads the defaults) and a best-effort +// write (a failure is swallowed, so a read-only userData folder never crashes the app). +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' +import { storePaths } from '../app/user-data' +import { loadConfig, saveConfig, type SettingsPaths } from './settings-service' + +let dir = '' +// is handed in the way app hands it in. +const paths = storePaths(() => dir) +const file = (): string => join(dir, 'config.json') + +const custom: AppConfig = { + ...DEFAULT_CONFIG, + sourceMode: 'bridge', + host: '10.0.0.7', + listenPort: 7777, + debugMode: true, + attentionBanner: false +} + +describe('B97 config.json load / save round trip', () => { + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skyhawk-settings-')) + }) + afterEach(() => { + chmodSync(dir, 0o700) + rmSync(dir, { recursive: true, force: true }) + }) + + it('a missing file loads the defaults, as a fresh copy, and writes nothing', () => { + const cfg = loadConfig(paths) + expect(cfg).toEqual(DEFAULT_CONFIG) + expect(cfg).not.toBe(DEFAULT_CONFIG) + expect(readdirSync(dir)).toEqual([]) + }) + + it('a damaged file loads the defaults and is left where it is', () => { + writeFileSync(file(), '{"debugMode":tr') + expect(loadConfig(paths)).toEqual(DEFAULT_CONFIG) + expect(readdirSync(dir)).toEqual(['config.json']) + expect(readFileSync(file(), 'utf8')).toBe('{"debugMode":tr') + }) + + it('an unreadable file (here a folder) loads the defaults', () => { + mkdirSync(file()) + expect(loadConfig(paths)).toEqual(DEFAULT_CONFIG) + }) + + it('a file that is valid JSON but not a config is coerced field by field', () => { + writeFileSync(file(), JSON.stringify({ debugMode: true, host: 42, sourceMode: 'nope' })) + expect(loadConfig(paths)).toEqual({ ...DEFAULT_CONFIG, debugMode: true }) + writeFileSync(file(), 'null') + expect(loadConfig(paths)).toEqual(DEFAULT_CONFIG) + }) + + it('save writes what load reads back, pretty-printed, in place (no temp file)', () => { + saveConfig(custom, paths) + expect(readdirSync(dir)).toEqual(['config.json']) + expect(readFileSync(file(), 'utf8')).toBe(JSON.stringify(custom, null, 2)) + expect(loadConfig(paths)).toEqual(custom) + }) + + it('a failed write is swallowed: no throw, nothing written', () => { + const nowhere: SettingsPaths = { configFile: () => join(dir, 'missing', 'config.json') } + expect(() => saveConfig(custom, nowhere)).not.toThrow() + expect(readdirSync(dir)).toEqual([]) + }) + + it.skipIf(process.getuid?.() === 0)( + 'a read-only file is not rewritten, and the failure is swallowed', + () => { + saveConfig(custom, paths) + chmodSync(file(), 0o400) + expect(() => saveConfig(DEFAULT_CONFIG, paths)).not.toThrow() + expect(loadConfig(paths)).toEqual(custom) + chmodSync(file(), 0o600) + } + ) + + it('a path that cannot be resolved loads the defaults and saves nothing, without throwing', () => { + const broken: SettingsPaths = { + configFile: () => { + throw new Error('no userData') + } + } + expect(loadConfig(broken)).toEqual(DEFAULT_CONFIG) + expect(() => saveConfig(custom, broken)).not.toThrow() + }) +}) diff --git a/src/main/config/settings-service.ts b/src/main/config/settings-service.ts index 3421cc8..1016c52 100644 --- a/src/main/config/settings-service.ts +++ b/src/main/config/settings-service.ts @@ -1,28 +1,28 @@ // Persist AppConfig to /config.json so settings survive restarts. // Where that file lives is handed in by app (SettingsPaths): this module never asks electron. -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +// The file mechanics are the JSON store's, under config.json's policy: a tolerant read (anything +// wrong loads the defaults) and a best-effort write. import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' import { sanitizeConfig } from './settings-schema' +import { JSON_POLICY, jsonFile } from './json-store' /** Port: where config.json lives. app provides it; resolved at each use, as before. */ export interface SettingsPaths { configFile(): string } +const configFile = (paths: SettingsPaths) => jsonFile(() => paths.configFile(), JSON_POLICY.config) + export function loadConfig(paths: SettingsPaths): AppConfig { try { - const p = paths.configFile() - if (!existsSync(p)) return { ...DEFAULT_CONFIG } - return sanitizeConfig(JSON.parse(readFileSync(p, 'utf8'))) + const r = configFile(paths).read() + return r.kind === 'read' ? sanitizeConfig(r.data) : { ...DEFAULT_CONFIG } } catch { return { ...DEFAULT_CONFIG } } } +/** Best effort (the policy swallows a failure): a read-only userData dir shouldn't crash the app. */ export function saveConfig(config: AppConfig, paths: SettingsPaths): void { - try { - writeFileSync(paths.configFile(), JSON.stringify(config, null, 2)) - } catch { - // best-effort; a read-only userData dir shouldn't crash the app - } + configFile(paths).write(config) } diff --git a/src/main/gateway/device-link.ts b/src/main/gateway/device-link.ts index e78d92b..5a1ddca 100644 --- a/src/main/gateway/device-link.ts +++ b/src/main/gateway/device-link.ts @@ -30,7 +30,8 @@ import { nackReasonName, regressedAxes, saveCalCache, - storeAxes + storeAxes, + type CacheFileIO } from '../hid/calibration' import { findSimGatewayPort, @@ -55,20 +56,20 @@ import { isLegacyNodeStatus, nodeRosterRequest, parseNodeStatus, - withFaultInfo + withFaultInfo, + type MappingFileIO } from '../nodes' type Emit = (channel: C, payload: PushChannels[C]) => void /** - * What DeviceLink needs from app (P6: it never asks electron): where its two files live under - * , and the debug log. Handed on to NodeHub, CalLink and SerialBridge. + * What DeviceLink needs from app (P6: it never asks electron): its two files under , + * as ports (node-mapping.json for NodeHub, calibration-cache.json for the cache), and the debug + * log. Handed on to NodeHub, CalLink and SerialBridge. */ export interface DeviceLinkDeps { - paths: { - nodeMappingFile(): string - calibrationCacheFile(): string - } + mappingFile: MappingFileIO + cacheFile: CacheFileIO log: Log } @@ -618,7 +619,7 @@ export class DeviceLink { * renderer never sees a board identity it could accidentally key on. */ calCacheRead(): CalResult<{ board?: CachedBoard; regressed: number[] }> { - const cache = loadCalCache(this.deps.paths) + const cache = loadCalCache(this.deps.cacheFile) return { ok: true, value: { @@ -637,8 +638,8 @@ export class DeviceLink { entries[a.idx] = { min: a.min, centre: a.centre, max: a.max, selfCentring: a.selfCentring } } saveCalCache( - storeAxes(loadCalCache(this.deps.paths), serial, entries, new Date().toISOString()), - this.deps.paths + storeAxes(loadCalCache(this.deps.cacheFile), serial, entries, new Date().toISOString()), + this.deps.cacheFile ) return { ok: true, value: null } } @@ -648,8 +649,8 @@ export class DeviceLink { const serial = this.calSerialNumber if (!serial) return { ok: true, value: null } saveCalCache( - dropAxis(loadCalCache(this.deps.paths), serial, idx === CAL_AXIS_NONE ? undefined : idx), - this.deps.paths + dropAxis(loadCalCache(this.deps.cacheFile), serial, idx === CAL_AXIS_NONE ? undefined : idx), + this.deps.cacheFile ) return { ok: true, value: null } } diff --git a/src/main/hid/calibration/cache-store.ts b/src/main/hid/calibration/cache-store.ts index 1fd53e1..43b51fe 100644 --- a/src/main/hid/calibration/cache-store.ts +++ b/src/main/hid/calibration/cache-store.ts @@ -4,29 +4,30 @@ // userData directory must not crash the app, and it must not break calibration either — the cache // is a recovery aid, so failing to keep one leaves the user exactly where they were, whereas // throwing would break the write that had already succeeded on the device. -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +// +// The file itself is reached through CacheFileIO, which app wires to the JSON store under +// calibration-cache.json's policy (written in place, failures swallowed, a damaged file left as is). import { emptyCache, sanitizeCache } from './cache-rules' import type { CalCache } from '@shared/hid/calibration' -/** Port: where calibration-cache.json lives. app hands it in (this module never asks electron). */ -export interface CachePaths { - calibrationCacheFile(): string +/** + * Port: calibration-cache.json. `read` resolves the path and reports what it found (a throw means + * the path could not be resolved); `write` is best effort and never throws. + */ +export interface CacheFileIO { + read(): { kind: 'read'; data: unknown } | { kind: 'missing' | 'unreadable' | 'damaged' } + write(cache: CalCache): void } -export function loadCalCache(paths: CachePaths): CalCache { +export function loadCalCache(io: CacheFileIO): CalCache { try { - const p = paths.calibrationCacheFile() - if (!existsSync(p)) return emptyCache() - return sanitizeCache(JSON.parse(readFileSync(p, 'utf8'))) + const r = io.read() + return r.kind === 'read' ? sanitizeCache(r.data) : emptyCache() } catch { return emptyCache() } } -export function saveCalCache(cache: CalCache, paths: CachePaths): void { - try { - writeFileSync(paths.calibrationCacheFile(), JSON.stringify(cache, null, 2)) - } catch { - // best-effort; see the note at the top of this file - } +export function saveCalCache(cache: CalCache, io: CacheFileIO): void { + io.write(cache) // best effort, by the file's policy; see the note at the top of this file } diff --git a/src/main/hid/calibration/index.ts b/src/main/hid/calibration/index.ts index ae23301..fd3dc91 100644 --- a/src/main/hid/calibration/index.ts +++ b/src/main/hid/calibration/index.ts @@ -1,6 +1,6 @@ // hid/calibration: the axis calibration channel and its cache. Exports only what other topics use // (arch-phase0-v2 §2.1). export { CalLink, CalNackError, CalTimeoutError } from './cal-link' -export { loadCalCache, saveCalCache } from './cache-store' +export { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' export { boardFor, dropAxis, mergeCalibration, regressedAxes, storeAxes } from './cache-rules' export { CAL_AXIS_NONE, nackReasonName } from './codec' diff --git a/src/main/nodes/index.ts b/src/main/nodes/index.ts index a7e2a9f..5557d6e 100644 --- a/src/main/nodes/index.ts +++ b/src/main/nodes/index.ts @@ -1,6 +1,7 @@ // nodes: the CAN node picture and the node mapping. Exports only what other topics use // (arch-phase0-v2 §2.1). export { NodeHub, withFaultInfo } from './node-hub' +export type { MappingFileIO } from './mapping-store' export { BRIDGE_NODE_ID, cleanNodeLine, diff --git a/src/main/nodes/mapping-file.ts b/src/main/nodes/mapping-file.ts new file mode 100644 index 0000000..add0613 --- /dev/null +++ b/src/main/nodes/mapping-file.ts @@ -0,0 +1,74 @@ +// The node-mapping.json format on disk (shape and rules: mapping-rules.ts): what a read means and +// how a failed write is reported. The file itself is reached through MappingFileIO, which app wires +// to the JSON store under node-mapping.json's policy (atomic write, failures reported, a damaged +// file moved aside). +// +// Unlike config.json, a lost write here is user work gone (names, adopted boards, hidden boards), so +// failures are reported rather than swallowed, writes are atomic, and a file this build cannot +// safely overwrite — unreadable, or from a newer client — is loaded read-only. +import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from './mapping-rules' +import type { NodeMappingFile } from '@shared/nodes' +import { errorText } from '@shared/format' + +/** A save that did not reach the disk. The message is ready to show. */ +export class MappingSaveError extends Error {} + +export interface LoadedMapping { + file: NodeMappingFile + /** Set when saving must not overwrite what is on disk; the reason is shown on a refused save. */ + readOnly?: string +} + +/** + * Port: node-mapping.json. `read` resolves the path (a throw there propagates) and reports what it + * found, a damaged file already moved aside or stuck in place. `write` returns `{ error }` when the + * file did not reach the disk (a path that cannot be resolved throws instead). + */ +export interface MappingFileIO { + read(): + | { kind: 'missing' } + | { kind: 'read'; data: unknown } + | { kind: 'unreadable'; error: unknown } + | { kind: 'movedAside'; to: string } + | { kind: 'stuck'; error: unknown } + write(file: NodeMappingFile): { error: unknown } | undefined +} + +const empty = (): NodeMappingFile => ({ ...EMPTY_MAPPING, nodes: {} }) + +export function loadNodeMapping(io: MappingFileIO): LoadedMapping { + const r = io.read() + switch (r.kind) { + case 'missing': + return { file: empty() } + case 'unreadable': + return { + file: empty(), + readOnly: `node-mapping.json could not be read: ${errorText(r.error)}` + } + // Moved aside, so the next save cannot destroy whatever the damaged file still holds. + case 'movedAside': + return { file: empty() } + case 'stuck': + return { + file: empty(), + readOnly: `node-mapping.json is damaged and could not be moved aside: ${errorText(r.error)}` + } + } + + const file = sanitizeNodeMapping(r.data) + const version = (r.data as { version?: unknown } | null)?.version + if (typeof version === 'number' && version > NODE_MAPPING_VERSION) { + return { file, readOnly: 'node-mapping.json was written by a newer client' } + } + return { file } +} + +/** + * Write the file (atomically, by its policy: a crash mid-write leaves the old file intact rather + * than half a JSON document). Throws MappingSaveError if the file did not reach the disk. + */ +export function saveNodeMapping(file: NodeMappingFile, io: MappingFileIO): void { + const failed = io.write(file) + if (failed) throw new MappingSaveError(`Could not save node mapping: ${errorText(failed.error)}`) +} diff --git a/src/main/nodes/mapping-rules.ts b/src/main/nodes/mapping-rules.ts index 010b64f..31c55fc 100644 --- a/src/main/nodes/mapping-rules.ts +++ b/src/main/nodes/mapping-rules.ts @@ -1,6 +1,6 @@ // Node mapping: display name, console and power zone (PDU) per NODE_ID, when each board was last // seen, and "hidden until it reports again" flags. Pure, no Node/Electron deps — main persists it -// as /node-mapping.json (src/main/nodes/mapping-store.ts). +// as /node-mapping.json (src/main/nodes/mapping-store.ts, file format mapping-file.ts). // // The registry (the bundled NODE_IDS) is a dictionary: a registry board joins your cockpit when you // add it in New on the bus (pre-filled with its registry defaults), and the file stores that it was diff --git a/src/main/nodes/mapping-store.seam.test.ts b/src/main/nodes/mapping-store.seam.test.ts index 7f77863..8291283 100644 --- a/src/main/nodes/mapping-store.seam.test.ts +++ b/src/main/nodes/mapping-store.seam.test.ts @@ -5,15 +5,16 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { NodeMappingFile } from '@shared/nodes' -import { storePaths } from '../app/user-data' +import { storeFiles, storePaths } from '../app/user-data' const userData = vi.hoisted(() => ({ dir: '' })) -// is handed in the way app hands it in (the store no longer asks electron, P6). -const paths = storePaths(() => userData.dir) +// is handed in the way app hands it in (the store no longer asks electron, P6), as the +// node-mapping.json port app wires (the JSON store under the file's policy). +const io = storeFiles(storePaths(() => userData.dir)).mappingFile -const store = await import('./mapping-store') -const loadNodeMapping = () => store.loadNodeMapping(paths) -const saveNodeMapping = (f: NodeMappingFile) => store.saveNodeMapping(f, paths) +const store = await import('./mapping-file') +const loadNodeMapping = () => store.loadNodeMapping(io) +const saveNodeMapping = (f: NodeMappingFile) => store.saveNodeMapping(f, io) const path = () => join(userData.dir, 'node-mapping.json') const write = (raw: unknown) => writeFileSync(path(), JSON.stringify(raw)) diff --git a/src/main/nodes/mapping-store.test.ts b/src/main/nodes/mapping-store.test.ts index a349eef..7560a23 100644 --- a/src/main/nodes/mapping-store.test.ts +++ b/src/main/nodes/mapping-store.test.ts @@ -4,16 +4,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { EMPTY_MAPPING, importMapping } from './mapping-rules' import type { NodeMappingFile } from '@shared/nodes' -import { storePaths } from '../app/user-data' +import { storeFiles, storePaths } from '../app/user-data' const userData = vi.hoisted(() => ({ dir: '' })) -// is handed in the way app hands it in (the store no longer asks electron, P6). -const paths = storePaths(() => userData.dir) +// is handed in the way app hands it in (the store no longer asks electron, P6), as the +// node-mapping.json port app wires (the JSON store under the file's policy). +const io = storeFiles(storePaths(() => userData.dir)).mappingFile -const store = await import('./mapping-store') +const store = await import('./mapping-file') const { MappingSaveError } = store -const loadNodeMapping = () => store.loadNodeMapping(paths) -const saveNodeMapping = (f: NodeMappingFile) => store.saveNodeMapping(f, paths) +const loadNodeMapping = () => store.loadNodeMapping(io) +const saveNodeMapping = (f: NodeMappingFile) => store.saveNodeMapping(f, io) const file = (): string => join(userData.dir, 'node-mapping.json') const sample: NodeMappingFile = { diff --git a/src/main/nodes/mapping-store.ts b/src/main/nodes/mapping-store.ts index 4aa1b63..1f4d9d9 100644 --- a/src/main/nodes/mapping-store.ts +++ b/src/main/nodes/mapping-store.ts @@ -1,81 +1,88 @@ -// Persist the node mapping to /node-mapping.json (shape and rules: mapping-rules.ts). -// Where that file lives is handed in by app (MappingPaths): this module never asks electron. -// -// Unlike config.json, a lost write here is user work gone (names, adopted boards, hidden boards), so -// failures are reported rather than swallowed, writes are atomic, and a file this build cannot -// safely overwrite — unreadable, or from a newer client — is loaded read-only. -import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { EMPTY_MAPPING, NODE_MAPPING_VERSION, sanitizeNodeMapping } from './mapping-rules' +// MappingStore: the node mapping main holds, and how it reaches node-mapping.json. Loaded lazily +// through the MappingFileIO port (the file format: mapping-file.ts; the rules: mapping-rules.ts), +// saved before it is adopted in memory, and refused while the file is read-only. import type { NodeMappingFile } from '@shared/nodes' -import { errorText, stamp } from '@shared/format' +import { + loadNodeMapping, + MappingSaveError, + saveNodeMapping, + type LoadedMapping, + type MappingFileIO +} from './mapping-file' -/** A save that did not reach the disk. The message is ready to show. */ -export class MappingSaveError extends Error {} +export { MappingSaveError, type LoadedMapping, type MappingFileIO } from './mapping-file' -export interface LoadedMapping { - file: NodeMappingFile - /** Set when saving must not overwrite what is on disk; the reason is shown on a refused save. */ - readOnly?: string +/** Port: the debug log, handed in by app (debug.log lives in app). */ +type Log = (tag: string, data?: unknown) => void + +/** Until a file is handed in: fails loudly on first use rather than guess one. */ +const NO_FILE: MappingFileIO = { + read: () => { + throw new Error('NodeHub: no node-mapping.json path was handed in') + }, + write: () => { + throw new Error('NodeHub: no node-mapping.json path was handed in') + } } -/** Port: where node-mapping.json lives. app provides it; resolved at each use, as before. */ -export interface MappingPaths { - nodeMappingFile(): string +export interface MappingStoreDeps { + file?: MappingFileIO + log?: Log + /** The mapping in memory changed (saved, or kept unsaved by `persist`). */ + onCommit?: () => void } -const empty = (): NodeMappingFile => ({ ...EMPTY_MAPPING, nodes: {} }) +export class MappingStore { + private loaded?: LoadedMapping + private readonly io: MappingFileIO + private readonly log: Log + private readonly onCommit: () => void -export function loadNodeMapping(paths: MappingPaths): LoadedMapping { - const p = paths.nodeMappingFile() - let text: string - try { - if (!existsSync(p)) return { file: empty() } - text = readFileSync(p, 'utf8') - } catch (err) { - return { file: empty(), readOnly: `node-mapping.json could not be read: ${errorText(err)}` } + constructor(deps: MappingStoreDeps = {}) { + this.io = deps.file ?? NO_FILE + this.log = deps.log ?? (() => {}) + this.onCommit = deps.onCommit ?? (() => {}) } - let raw: unknown - try { - raw = JSON.parse(text) - } catch { - // Move the damaged file aside, so the next save cannot destroy whatever it still holds. - const bad = `${p}.bad-${stamp(new Date())}` + /** Read lazily: the userData path is only safe to touch once the app is up. */ + private store(): LoadedMapping { + return (this.loaded ??= loadNodeMapping(this.io)) + } + + /** The mapping as main holds it now. */ + mapping(): NodeMappingFile { + return this.store().file + } + + /** + * Save a change main made on its own (not a user edit). If the save fails the change still + * applies in memory — a reporting board must show, so un-hiding it is right — and may be lost + * after a restart, which is the lesser problem. + */ + persist(next: NodeMappingFile): void { try { - renameSync(p, bad) - return { file: empty() } + this.save(next) } catch (err) { - return { - file: empty(), - readOnly: `node-mapping.json is damaged and could not be moved aside: ${errorText(err)}` - } + if (!(err instanceof MappingSaveError)) throw err + this.log('nodes.mapping', err.message) + this.commit(next) } } - const file = sanitizeNodeMapping(raw) - const version = (raw as { version?: unknown } | null)?.version - if (typeof version === 'number' && version > NODE_MAPPING_VERSION) { - return { file, readOnly: 'node-mapping.json was written by a newer client' } + /** + * Persist, then adopt in memory — never the other way round, or a failed write looks saved. + * Throws MappingSaveError when read-only or when the write failed. The same file is a no-op. + */ + save(next: NodeMappingFile): void { + if (next === this.mapping()) return + const { readOnly } = this.store() + if (readOnly) throw new MappingSaveError(`Not saved: ${readOnly}`) + saveNodeMapping(next, this.io) + this.commit(next) } - return { file } -} -/** - * Write via a temp file and rename, so a crash mid-write leaves the old file intact rather than - * half a JSON document. Throws MappingSaveError if the file did not reach the disk. - */ -export function saveNodeMapping(file: NodeMappingFile, paths: MappingPaths): void { - const p = paths.nodeMappingFile() - const tmp = `${p}.tmp` - try { - writeFileSync(tmp, JSON.stringify(file, null, 2)) - renameSync(tmp, p) - } catch (err) { - try { - rmSync(tmp, { force: true }) - } catch { - // the write already failed; a stray .tmp is harmless and overwritten next time - } - throw new MappingSaveError(`Could not save node mapping: ${errorText(err)}`) + private commit(next: NodeMappingFile): void { + this.store().file = next + this.onCommit() } } diff --git a/src/main/nodes/node-hub.seam.test.ts b/src/main/nodes/node-hub.seam.test.ts index ca307af..0373eb5 100644 --- a/src/main/nodes/node-hub.seam.test.ts +++ b/src/main/nodes/node-hub.seam.test.ts @@ -9,7 +9,7 @@ import { statusRow } from '../../test-fixtures/nodes' const disk: { file: NodeMappingFile; saves: number } = { file: { version: 3, nodes: {} }, saves: 0 } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path -vi.mock('./mapping-store', () => ({ +vi.mock('./mapping-file', () => ({ MappingSaveError: class MappingSaveError extends Error {}, loadNodeMapping: () => ({ file: disk.file }), saveNodeMapping: (f: NodeMappingFile) => { diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 2431670..b8b4aad 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -12,7 +12,7 @@ const disk: { saves: 0 } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path -vi.mock('./mapping-store', () => { +vi.mock('./mapping-file', () => { class MappingSaveError extends Error {} return { MappingSaveError, diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index 26be032..227c45a 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -1,5 +1,6 @@ -// Node data layer in main: taps `_NODE_TELEM`, owns the node mapping, and pushes the combined -// node picture. Kept out of app/session.ts so the session only feeds it lines and ticks. +// Node data layer in main: taps `_NODE_TELEM`, holds the node mapping (through MappingStore), and +// pushes the combined node picture. Kept out of app/session.ts so the session only feeds it lines +// and ticks. import type { AdoptSpec, MappedNode, @@ -44,32 +45,19 @@ import { resolveKind } from './node-kind' import { BRIDGE_NODE_ID, type NodeRoster } from './node-status-wire' import { NodeTelemStore, parseNodeTelem } from './node-telem' import { registryDefaults } from './registry' -import { - loadNodeMapping, - MappingSaveError, - saveNodeMapping, - type LoadedMapping, - type MappingPaths -} from './mapping-store' +import { MappingSaveError, MappingStore, type MappingFileIO } from './mapping-store' type Emit = (channel: C, payload: PushChannels[C]) => void /** Port: the debug log, handed in by app (debug.log lives in app). */ export type Log = (tag: string, data?: unknown) => void -/** What NodeHub needs from app, through DeviceLink: where node-mapping.json lives, and the log. */ +/** What NodeHub needs from app, through DeviceLink: the node-mapping.json port, and the log. */ export interface NodeHubDeps { - paths?: MappingPaths + mappingFile?: MappingFileIO log?: Log } -/** Until a path is handed in: fails loudly on first use rather than guess one. */ -const NO_PATHS: MappingPaths = { - nodeMappingFile: () => { - throw new Error('NodeHub: no node-mapping.json path was handed in') - } -} - /** Shortest gap between `nodes:telemetry` pushes. ~3 PDUs send ~3 frames/s between them. */ const NODE_TELEM_PUSH_MS = 1000 @@ -100,7 +88,6 @@ export function withFaultInfo(n: NodeStatus): NodeStatus { export class NodeHub { private readonly telem = new NodeTelemStore() private readonly defaults = registryDefaults(NODE_NAMES) - private loaded?: LoadedMapping private telemPushedAt = 0 private modelDirty = true /** Present NODE_IDs at the last roster change, to tell "came back" from "still listed". */ @@ -118,8 +105,8 @@ export class NodeHub { }) private mappedFor?: NodeMappingFile private mappedCache = new Map() - private readonly paths: MappingPaths - private readonly log: Log + /** The node mapping and its file. */ + private readonly store: MappingStore constructor( private readonly emit: Emit, @@ -127,17 +114,17 @@ export class NodeHub { private readonly onEvents: (events: LogEventInput[]) => void = () => {}, deps: NodeHubDeps = {} ) { - this.paths = deps.paths ?? NO_PATHS - this.log = deps.log ?? (() => {}) - } - - /** Read lazily: the userData path is only safe to touch once the app is up. */ - private store(): LoadedMapping { - return (this.loaded ??= loadNodeMapping(this.paths)) + this.store = new MappingStore({ + file: deps.mappingFile, + log: deps.log, + onCommit: () => { + this.modelDirty = true + } + }) } private mapping(): NodeMappingFile { - return this.store().file + return this.store.mapping() } /** New session: telemetry and first-seen times start over; the mapping persists. */ @@ -195,7 +182,7 @@ export class NodeHub { */ linkClosed(now = Date.now()): void { if (!this.wasPresent.size) return - this.persist( + this.store.persist( markSeen(this.mapping(), this.defaults, this.wasPresent, new Date(now).toISOString()) ) this.wasPresent.clear() @@ -344,47 +331,18 @@ export class NodeHub { this.wasPresent = present if (!came.length && !went.length) return const at = new Date(now).toISOString() - this.persist( + this.store.persist( markSeen(clearReportedHidden(this.mapping(), came), this.defaults, [...came, ...went], at) ) } - /** - * Save a change main made on its own (not a user edit). If the save fails the change still - * applies in memory — a reporting board must show, so un-hiding it is right — and may be lost - * after a restart, which is the lesser problem. - */ - private persist(next: NodeMappingFile): void { - try { - this.save(next) - } catch (err) { - if (!(err instanceof MappingSaveError)) throw err - this.log('nodes.mapping', err.message) - this.commit(next) - } - } - - /** Persist, then adopt in memory — never the other way round, or a failed write looks saved. */ - private save(next: NodeMappingFile): void { - if (next === this.mapping()) return - const { readOnly } = this.store() - if (readOnly) throw new MappingSaveError(`Not saved: ${readOnly}`) - saveNodeMapping(next, this.paths) - this.commit(next) - } - - private commit(next: NodeMappingFile): void { - this.store().file = next - this.modelDirty = true - } - /** Apply one mapping change, persist it, and push the new model straight away. */ private update(fn: (f: NodeMappingFile) => NodeMappingFile): NodeMappingResult { const before = this.mapping() let next: NodeMappingFile try { next = fn(before) - this.save(next) + this.store.save(next) } catch (err) { if (err instanceof MappingError || err instanceof MappingSaveError) { return { ok: false, error: err.message } diff --git a/src/main/nodes/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts index f123e68..c2cdbe3 100644 --- a/src/main/nodes/phase0-probes.test.ts +++ b/src/main/nodes/phase0-probes.test.ts @@ -22,7 +22,7 @@ import { DEFAULT_APP_SETTINGS } from '../../test-fixtures/config' const disk: { file: NodeMappingFile } = { file: { version: 3, nodes: {} } } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('./mapping-store', () => { +vi.mock('./mapping-file', () => { class MappingSaveError extends Error {} return { MappingSaveError, From 3b9d5da8a02a5e450e1c0a3253cf2fcb0cc869c1 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 22:41:01 -0400 Subject: [PATCH 13/28] refactor(gateway): move HID, calibration and event reporting out of DeviceLink (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.2a of the structure rewrite (#101, phase #99): the first half of the DeviceLink split. HID, calibration and event reporting move into their own services. The connection, the line taps, the serial monitor and the node poll stay in DeviceLink for R2.2b. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port, the files written and the order of all of them are the same. The trace proof below checks this. ## What moved where | From DeviceLink | To | Notes | |---|---|---| | `startHid`, `.hid` (the reader), `pushHid`, `.hidPushedAt`, `.hidPushTimer`, `HID_PUSH_MS`, `HID_RATE_MS` + the rate timer, `hid.reopen()` on port open, the HID part of `flushLink`, the HID part of `shutdown` | `hid/hid-service.ts` **HidService** | Its I/O is the HID reader (`hid/hid-reader.ts`) | | `.cal` (a CalLink per `connect()`), `readCalibrationState`, `gatewaySerialNumber`, `calRun`, `calHello` / `calRead` / `calSessionOpen` / `calStreamSelect` / `calCommit` / `calReset` / `calSessionClose`, `calCacheRead` / `calCacheStore` / `calCacheDrop` (through `CacheFileIO`), `.calRawBuf` + `flushCalRaw` + its timer, `.lastCal` + `calSnapshot`, `.calSerialNumber`, `exportGated`, and the calibration parts of `teardown` and `onClose` | `hid/calibration/hid-calibration-service.ts` **HidCalibrationService** | Same `CalResult`s. It is also the link's calibration frame filter (see below) | | `report()`, `.eventCbs`, `onNodeEvents` | `events/event-journal.ts` **EventJournal** | `report(events)` + `onEvents(cb)`, the same fan-out as before. Session still hosts the EventLog (R2.3) | Callers change their receiver only: `app/index.ts` IPC handlers call `session.calibration.cal*`, and Session uses `calibration.exportGated` and `calibration.calSnapshot()`. No method was renamed. **The frame filter.** CalDemux stays where it was, inside CalLink in the codec. DeviceLink still runs it first on every incoming chunk, at the same call site. It now calls it through a `CalFrameFilter` port (`ingest(chunk)`, `exportGated`), which the calibration service implements with the same code as before: `this.cal ? this.cal.ingest(chunk) : chunk`. That is how DeviceLink drops its `hid/calibration` import. ## Open/close reset order, and how it is kept `onOpen`/`onClose` and the other moments become a `LinkRider` subscription. Session adds the riders in a fixed order: `ride(calibration)`, then `ride(hid)`. At each moment, DeviceLink calls them in that order, at the exact line where their code used to be: | Moment | DeviceLink order (unchanged) | |---|---| | `start()` | link flush timer (200) → serial flush timer (50) → **riders' timers**: cal:raw (50), HID rate (1000) → roster poll timer (5000) → **riders' `start`**: HID reader → connect | | `connect()` | teardown → new SerialBridge → **`attach`**: CalLink → port callbacks → `scanning` → port start | | `teardown()` | **`release`**: CalLink close + cal state cleared → port stop → gateway reset | | port opens | roster/nodes/gateway resets, assembler → `connected` → roster request → **`opened`**: calibration read-on-open, then HID reopen | | port closes | `nodes.linkClosed()` → **`closed`**: CalLink close, held bytes passed back down the stream, snapshot cleared → disconnect or `reconnecting` | | 200 ms tick | **`flush`**: HID snapshot → node flush, gateway tick, health push (link up only) | | `shutdown()` | `nodes.linkClosed()` → clear all link timers (riders' included, same order) → teardown → **`stop`**: HID pending push, reader → `started = false` | The riders' timers go on the link's own timer list through `every(ms, fn)`. That keeps both the creation order and the clearing order the same. This matters because same-period timers fire in the order they were created. The CalLink writes through the injected `write`, which is `DeviceLink.write`, where before it wrote through the SerialBridge handle directly. These are the same port: a CalLink only writes synchronously inside a call, while it is the current one, or from its keepalive, which `close()` stops before the port changes. ## Proof - **Side-effect trace (not committed).** A one-off harness drives `Session` with fakes for the serial port, the DCS transport, the HID reader, the debug log and `node:fs`, under fake timers. It records, in order: every emit (channel + payload), the serial port's lifecycle and every byte written, DCS sends, the HID reader's lifecycle and `sampleRate` calls, every timer created or cleared (with its period), the debug-log calls, the fs writes and renames under ``, the values returned to callers, and the final `` files. The outputs at base (afadc63) and head are **byte-identical**: 9 scenarios, 1,362 recorded events (595 emits, 127 serial, 211 timer, 130 HID, 59 returned values, 38 debug-log, 13 fs, 19 DCS) and the same sha1. The scenarios: - HID at different rates (a burst inside 16 ms, spaced reports, a 1 ms flood, idle across the rate window and the roster poll, a trailing push pending at quit); - calibration frames split across chunks and interleaved with DCS-BIOS lines while the relay runs, with export gated and ungated; - every `cal*` command, including NACK, timeout, a bad protocol version, the keepalive, the assumed expiry after a failed close, and an unmatched reply; - the offline paths (no port, `scanning`, after disconnect); - cache read/store/drop, before and after the serial is known; - node status and telemetry lines, the gateway row, a v2 row, a mapping write, the roster poll and silence; - the serial monitor, including overflow; - port close mid-exchange with held bytes, requests while reconnecting, reopen, close with auto-connect off, error and Retry; - shutdown with an exchange, a held candidate and a HID push all in flight. As a sanity check, swapping the two `ride()` calls, or moving the HID reader start ahead of the roster timer, changes the trace (102 and 36 diff lines). - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`): base and head outputs are byte-identical, both with `SKYHAWK_USER_DATA` set and with the dev default. `config.json` and `node-mapping.json` have the same sha1s. - **Tests.** 67 files, 889 tests, all passing. One test edit: `session.test.ts` reached `calHello` through `session.link`, so those 2 lines now call `session.calibration.calHello()`. Their assertions are unchanged. The `makeServices()` seam tests and the component tests are untouched. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before; `src/main` has none. - `npm run lint:deps`: 0 errors, **17 warnings** (was 19). `topic-main-gateway` goes from 3 to 1. The one left is `device-link → nodes`, which is for R2.2b. - `npm run build`: OK. Refs #99 --- src/main/app/index.ts | 22 +- src/main/app/session.test.ts | 4 +- src/main/app/session.ts | 37 +- src/main/events/event-journal.ts | 20 + src/main/events/index.ts | 1 + src/main/gateway/device-link.ts | 445 ++++-------------- src/main/gateway/index.ts | 10 +- .../calibration/hid-calibration-service.ts | 319 +++++++++++++ src/main/hid/calibration/index.ts | 5 +- src/main/hid/hid-service.ts | 103 ++++ src/main/hid/index.ts | 2 +- 11 files changed, 589 insertions(+), 379 deletions(-) create mode 100644 src/main/events/event-journal.ts create mode 100644 src/main/hid/calibration/hid-calibration-service.ts create mode 100644 src/main/hid/hid-service.ts diff --git a/src/main/app/index.ts b/src/main/app/index.ts index f087afc..329e913 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -163,20 +163,22 @@ function registerIpc(): void { // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the // timeout-vs-nack distinction the UI's failure messaging depends on. - ipcMain.handle(CTRL.calHello, () => session.link.calHello()) - ipcMain.handle(CTRL.calRead, () => session.link.calRead()) - ipcMain.handle(CTRL.calSessionOpen, (_e, axisIdx: number) => session.link.calSessionOpen(axisIdx)) + ipcMain.handle(CTRL.calHello, () => session.calibration.calHello()) + ipcMain.handle(CTRL.calRead, () => session.calibration.calRead()) + ipcMain.handle(CTRL.calSessionOpen, (_e, axisIdx: number) => + session.calibration.calSessionOpen(axisIdx) + ) ipcMain.handle(CTRL.calStreamSelect, (_e, axisIdx: number) => - session.link.calStreamSelect(axisIdx) + session.calibration.calStreamSelect(axisIdx) ) - ipcMain.handle(CTRL.calCommit, (_e, axis: CalCommitAxis) => session.link.calCommit(axis)) - ipcMain.handle(CTRL.calReset, (_e, idx: number) => session.link.calReset(idx)) - ipcMain.handle(CTRL.calCacheRead, () => session.link.calCacheRead()) + ipcMain.handle(CTRL.calCommit, (_e, axis: CalCommitAxis) => session.calibration.calCommit(axis)) + ipcMain.handle(CTRL.calReset, (_e, idx: number) => session.calibration.calReset(idx)) + ipcMain.handle(CTRL.calCacheRead, () => session.calibration.calCacheRead()) ipcMain.handle(CTRL.calCacheStore, (_e, axes: CalCacheEntry[]) => - session.link.calCacheStore(axes) + session.calibration.calCacheStore(axes) ) - ipcMain.handle(CTRL.calCacheDrop, (_e, idx: number) => session.link.calCacheDrop(idx)) - ipcMain.handle(CTRL.calSessionClose, () => session.link.calSessionClose()) + ipcMain.handle(CTRL.calCacheDrop, (_e, idx: number) => session.calibration.calCacheDrop(idx)) + ipcMain.handle(CTRL.calSessionClose, () => session.calibration.calSessionClose()) ipcMain.handle(CTRL.logExport, async (_e, text: string) => { const res = await dialog.showSaveDialog({ diff --git a/src/main/app/session.test.ts b/src/main/app/session.test.ts index ee9afd0..7ff2b8e 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/session.test.ts @@ -364,7 +364,7 @@ describe('device link is independent of the relay (#69)', () => { expect((await HID()).stopped).toBe(false) expect(session.status().device.state).toBe('connected') // CalLink is still attached: an exchange is attempted rather than refused as offline. - const hello = session.link.calHello() + const hello = session.calibration.calHello() session.shutdown() // fails the in-flight exchange const r = await hello expect(r.ok ? 'ok' : r.kind).not.toBe('offline') @@ -515,7 +515,7 @@ describe('device link is independent of the relay (#69)', () => { session.link.disconnect() expect(serial.stopped).toBe(true) expect((await HID()).stopped, 'HID is shared with DCS and stays up').toBe(false) - const cal = await session.link.calHello() + const cal = await session.calibration.calHello() expect(cal.ok ? 'ok' : cal.kind, 'calibration rides on serial').toBe('offline') expect(session.status().device.state).toBe('disconnected') session.link.connect() diff --git a/src/main/app/session.ts b/src/main/app/session.ts index 6c7ebd6..56c7dde 100644 --- a/src/main/app/session.ts +++ b/src/main/app/session.ts @@ -29,8 +29,10 @@ import { type ReplayInfo, type Transport } from '../dcs' -import { clientErrorEvent, EventLog } from '../events' +import { clientErrorEvent, EventJournal, EventLog } from '../events' import { deviceEvent, DeviceLink } from '../gateway' +import { HidService } from '../hid' +import { HidCalibrationService } from '../hid/calibration' import { debugLog } from './debug-log' import { storeFiles, storePaths, type StorePaths } from './user-data' @@ -85,8 +87,14 @@ export function relayContext(cfg: AppConfig): RelayContext { } export class Session { - /** The device link: serial, calibration, HID and nodes. Up independently of the relay. */ + /** The device link: serial and nodes. Up independently of the relay. */ readonly link: DeviceLink + /** Axis calibration (#46), riding on the device link's port. */ + readonly calibration: HidCalibrationService + /** The HID joystick, up for as long as the app is. */ + readonly hid: HidService + /** Where the link's event-log rows are reported. */ + readonly journal = new EventJournal() private config: AppConfig = { ...DEFAULT_CONFIG } private transport?: Transport private replay?: ReplaySource @@ -116,7 +124,24 @@ export class Session { paths: StorePaths = storePaths(() => app.getPath('userData')) ) { this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) - this.link = new DeviceLink(emit, { ...storeFiles(paths), log: debugLog }) + const { mappingFile, cacheFile } = storeFiles(paths) + this.calibration = new HidCalibrationService(emit, { + write: (data) => this.link.write(data), + cacheFile, + log: debugLog + }) + this.hid = new HidService(emit, debugLog) + this.link = new DeviceLink(emit, { + mappingFile, + log: debugLog, + report: (events) => this.journal.report(events), + frames: this.calibration + }) + // The order is load-bearing: at each of the link's moments its riders run in the order added, + // and where both handle one (start's timers, a port opening) calibration's part came first + // inside DeviceLink before the split (R2.2a). See LinkRider. + this.link.ride(this.calibration) + this.link.ride(this.hid) this.link.onBytes((bytes) => this.fromDevice(bytes)) this.link.onLine((l) => { if (l.kind !== 'command') return @@ -128,7 +153,7 @@ export class Session { this.stats.error() this.logError(tag, message) }) - this.link.onNodeEvents((events) => this.events.add(events)) + this.journal.onEvents((events) => this.events.add(events)) this.link.onStatus((s) => { if (s.state === 'reconnecting') this.stats.reconnect() // The port's own retry loop stays in `error`; any other state (an explicit Connect or Retry @@ -290,7 +315,7 @@ export class Session { this.recorder?.record('in', chunk) // Forward byte-for-byte to the SimGateway — unless a calibration exchange is in flight. // Parsing for our own UI continues; only the forward pauses. - if (exportToDevice(this.config) && !this.link.exportGated) this.link.write(chunk) + if (exportToDevice(this.config) && !this.calibration.exportGated) this.link.write(chunk) } /** Device traffic, calibration frames already removed by the link: relay to DCS:7778. */ @@ -334,7 +359,7 @@ export class Session { /** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */ status(): RelayStatus { const device: DeviceStatus = this.link.deviceStatus() - return { running: this.running, relay: this.relay, device, cal: this.link.calSnapshot() } + return { running: this.running, relay: this.relay, device, cal: this.calibration.calSnapshot() } } /** Push a client diagnostic into the event log + the debug log file. */ diff --git a/src/main/events/event-journal.ts b/src/main/events/event-journal.ts new file mode 100644 index 0000000..436be01 --- /dev/null +++ b/src/main/events/event-journal.ts @@ -0,0 +1,20 @@ +// Where event-log rows are reported (moved out of DeviceLink, R2.2a): node changes and the +// Gateway's health, handed on to whoever records them. A thin owner for now — Session still hosts +// the EventLog (R2.3 moves it here). +import type { LogEventInput } from '@shared/events' + +type Unsubscribe = () => void + +export class EventJournal { + private readonly eventCbs = new Set<(events: LogEventInput[]) => void>() + + /** Rows for the event log (#64): online / offline, flags raised and cleared, new nodes. */ + report(events: LogEventInput[]): void { + if (events.length) for (const cb of this.eventCbs) cb(events) + } + + onEvents(cb: (events: LogEventInput[]) => void): Unsubscribe { + this.eventCbs.add(cb) + return () => this.eventCbs.delete(cb) + } +} diff --git a/src/main/events/index.ts b/src/main/events/index.ts index f3b2724..999af1a 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,4 +1,5 @@ // events: the event log and the status-flag vocabulary. Exports only what other topics use // (arch-phase0-v2 §2.1). +export { EventJournal } from './event-journal' export { clientErrorEvent, EventLog, eventsToTsv } from './event-log' export { faultLabel, STATUS_FLAGS, type StatusFlag } from './status-flags' diff --git a/src/main/gateway/device-link.ts b/src/main/gateway/device-link.ts index 5a1ddca..fbd1a6d 100644 --- a/src/main/gateway/device-link.ts +++ b/src/main/gateway/device-link.ts @@ -1,6 +1,10 @@ -// The device link (#69): the SimGateway serial port and everything that rides on it — calibration -// (CalLink), HID, the node roster and the node data layer. Up whenever the board is plugged in, in -// every source mode, relaying or not; down only on unplug or an explicit disconnect. +// The device link (#69): the SimGateway serial port, the node roster and the node data layer. Up +// whenever the board is plugged in, in every source mode, relaying or not; down only on unplug or an +// explicit disconnect. +// +// What else rides on the port — calibration (hid/calibration HidCalibrationService) and HID +// (hid HidService) — is told about the link's moments through `ride()` (R2.2a), and event-log rows +// go to the EventJournal through `report`. // // The relay (app/session.ts) attaches to it rather than owning it: export goes out through write(), // and device traffic comes back through onBytes() / onLine(). That split is the point. Bundled @@ -8,34 +12,8 @@ // relayed, and stopping the relay tore the port down with it. import type { LogEventInput } from '@shared/events' import { isLinkUp, type DeviceStatus, type SerialFrame } from '@shared/gateway' -import type { - CachedAxis, - CachedBoard, - CalCacheEntry, - CalCommitAxis, - CalHello, - CalRawSample, - CalResult, - CalSnapshot -} from '@shared/hid/calibration' import type { PushChannel, PushChannels } from '@shared/app' import { - boardFor, - CAL_AXIS_NONE, - CalLink, - CalNackError, - CalTimeoutError, - dropAxis, - loadCalCache, - nackReasonName, - regressedAxes, - saveCalCache, - storeAxes, - type CacheFileIO -} from '../hid/calibration' -import { - findSimGatewayPort, - listSerialPorts, PortNotFoundError, SerialBridge, SIMGATEWAY_PID, @@ -44,7 +22,6 @@ import { } from './serial-bridge' import { GatewayWatch } from './gateway-health' import { LineAssembler, parseCommand } from './link-framing' -import { HidReader } from '../hid' import { BRIDGE_NODE_ID, NODE_END_MSG, @@ -63,27 +40,68 @@ import { type Emit = (channel: C, payload: PushChannels[C]) => void /** - * What DeviceLink needs from app (P6: it never asks electron): its two files under , - * as ports (node-mapping.json for NodeHub, calibration-cache.json for the cache), and the debug - * log. Handed on to NodeHub, CalLink and SerialBridge. + * The calibration frame filter on the incoming byte path (hid/calibration's HidCalibrationService + * supplies it). DeviceLink runs it on every chunk before anything else sees the bytes. + */ +export interface CalFrameFilter { + /** Feed inbound bytes; what is not a calibration frame comes back, for DCS and the line taps. */ + ingest(chunk: Buffer): Buffer + /** A calibration exchange is in flight, and the export stream must not be forwarded. */ + readonly exportGated: boolean +} + +/** Adds one interval to the link's own timer list: created in start(), cleared at shutdown. */ +export type LinkTimer = (ms: number, fn: () => void) => void + +/** + * What rides on the link — calibration and HID (R2.2a) — and the link's moments it is told about. + * + * At each moment DeviceLink calls its riders in the order they were added with `ride()`, at exactly + * the point where that rider's code sat inside DeviceLink before the split, so the order of side + * effects is unchanged. Session adds calibration first, then HID: where both handle a moment + * (start's timers, a port opening), calibration's part came first. + */ +export interface LinkRider { + /** + * start(): the rider's timers, on the link's list between its serial flush and its roster poll. + * Same-period timers fire in creation order, so this position is the push order; and they are + * cleared with the link's own at shutdown. + */ + timers?(every: LinkTimer): void + /** start(): after the link's timers, before the first connect. */ + start?(): void + /** connect(): a new port, before any byte from it can arrive. */ + attach?(): void + /** teardown(): the port is being released. First, before the port stops. */ + release?(): void + /** The port opened: after the link's own resets, its status push and the roster request. */ + opened?(path: string): void + /** + * The port closed under us: after the node layer noted it, before reconnecting or disconnecting. + * `pass` sends bytes on down the stream, below the frame filter. + */ + closed?(pass: (held: Buffer) => void): void + /** The link's 200 ms push tick: first, and while the link is down too. */ + flush?(): void + /** shutdown(): after the port is released. */ + stop?(): void +} + +/** + * What DeviceLink needs from app (P6: it never asks electron): node-mapping.json as a port (for + * NodeHub), the debug log (NodeHub, SerialBridge), where event-log rows go (EventJournal), and the + * calibration frame filter. */ export interface DeviceLinkDeps { mappingFile: MappingFileIO - cacheFile: CacheFileIO log: Log + /** Event-log rows: node changes and the Gateway's health (EventJournal.report). */ + report: (events: LogEventInput[]) => void + frames: CalFrameFilter } /** Node status, node model and the HID snapshot. Same cadence the relay used for them. */ const LINK_FLUSH_MS = 200 -/** - * Shortest gap between HID pushes to the renderer. - * - * A press used to wait for the 200 ms telemetry tick, which is visible lag on a button and - * enough to lose a quick tap entirely. Reports now drive the push, capped here at roughly one - * frame so a fast axis sweep — the device can emit a few hundred a second — cannot flood IPC. - */ -const HID_PUSH_MS = 16 -const HID_RATE_MS = 1000 const NODES_REFRESH_MS = 5000 const SERIAL_FLUSH_MS = 50 const MAX_SERIAL_BUF = 4000 @@ -114,42 +132,31 @@ export class DeviceLink { /** The Gateway's own health (#94): its node-0 row, and whether it still answers. */ private readonly gateway = new GatewayWatch() private serial?: SerialBridge - private hid?: HidReader - private hidPushedAt = 0 - private hidPushTimer?: ReturnType private assembler = new LineAssembler() private serialMonitor = false private serialBuf: SerialFrame[] = [] - private cal?: CalLink - private calRawBuf: CalRawSample[] = [] - private calSerialNumber?: string - /** - * Last snapshot the device confirmed, held so a renderer reload can rehydrate. - * - * Cleared whenever the link goes down, which is what stops it describing a board that is no - * longer attached — or, worse, a different one after a reconnect. - */ - private lastCal?: CalSnapshot private status: DeviceStatus = { state: 'disconnected' } private autoConnect = true private started = false private timers: ReturnType[] = [] + /** Calibration, then HID: see LinkRider. */ + private readonly riders: LinkRider[] = [] private readonly bytesCbs = new Set<(bytes: Buffer) => void>() private readonly lineCbs = new Set<(line: DeviceLine) => void>() private readonly statusCbs = new Set<(status: DeviceStatus) => void>() private readonly errorCbs = new Set<(tag: string, message: string) => void>() - private readonly eventCbs = new Set<(events: LogEventInput[]) => void>() constructor( private readonly emit: Emit, private readonly deps: DeviceLinkDeps ) { - this.nodes = new NodeHub(emit, this.roster, (events) => this.report(events), deps) + this.nodes = new NodeHub(emit, this.roster, (events) => this.deps.report(events), deps) } - private report(events: LogEventInput[]): void { - if (events.length) for (const cb of this.eventCbs) cb(events) + /** Add a rider. Riders are called in the order added; see LinkRider. */ + ride(rider: LinkRider): void { + this.riders.push(rider) } // ── events ───────────────────────────────────────────────────────────────── @@ -177,11 +184,6 @@ export class DeviceLink { return subscribe(this.errorCbs, cb) } - /** Node changes for the event log (#64): online / offline, flags raised and cleared, new nodes. */ - onNodeEvents(cb: (events: LogEventInput[]) => void): Unsubscribe { - return subscribe(this.eventCbs, cb) - } - // ── lifecycle ────────────────────────────────────────────────────────────── /** App launch: start the link's timers and, unless the user opted out, the link itself. */ @@ -189,43 +191,27 @@ export class DeviceLink { if (this.started) return this.started = true this.autoConnect = autoConnect - this.timers = [ - setInterval(() => this.flushLink(), LINK_FLUSH_MS), - setInterval(() => this.flushSerial(), SERIAL_FLUSH_MS), - setInterval(() => this.flushCalRaw(), SERIAL_FLUSH_MS), - setInterval(() => this.hid?.sampleRate(), HID_RATE_MS), - // Poll the node roster so silent deaths get reconciled — relaying or not. - setInterval(() => this.requestNodes(), NODES_REFRESH_MS) - ] - this.startHid() + this.timers = [] + const every: LinkTimer = (ms, fn) => { + this.timers.push(setInterval(fn, ms)) + } + every(LINK_FLUSH_MS, () => this.flushLink()) + every(SERIAL_FLUSH_MS, () => this.flushSerial()) + for (const r of this.riders) r.timers?.(every) // calibration: cal:raw; HID: the rate window + // Poll the node roster so silent deaths get reconciled — relaying or not. + every(NODES_REFRESH_MS, () => this.requestNodes()) + for (const r of this.riders) r.start?.() // HID: the reader if (autoConnect) this.connect() else this.setStatus({ state: 'disconnected' }) } - /** - * HID is read, never claimed: DCS reads the same interface as a joystick, so it runs for as long - * as the app does — independent of the serial port, and still up while the port is released. - * It reconnects on its own, so its errors are worth recording rather than swallowing — they are - * how we learn whether an unplug surfaces here at all, or only as the serial port dropping. - */ - private startHid(): void { - const h = new HidReader() - this.hid = h - h.onError((err) => this.deps.log('hid.error', { message: err.message })) - h.onReport(() => this.pushHid()) - h.start() - } - /** App quit. */ shutdown(): void { this.nodes.linkClosed() // remember when the boards on the bus were last seen (#93) for (const t of this.timers) clearInterval(t) this.timers = [] this.teardown() - if (this.hidPushTimer) clearTimeout(this.hidPushTimer) - this.hidPushTimer = undefined - this.hid?.stop() - this.hid = undefined + for (const r of this.riders) r.stop?.() // HID: the pending push, the reader this.started = false } @@ -250,14 +236,7 @@ export class DeviceLink { // this link. What auto-connect governs is reopening after a drop, handled in onClose. const s = new SerialBridge(true, this.deps.log) this.serial = s - // The calibration channel shares this port. It runs whenever the port is open, not only - // during a session: HELLO and GET_CAL are answered outside one, so there is no point at - // which it is safe to stop watching for frames. - this.cal = new CalLink( - (data) => s.write(data), - (sample) => this.calRawBuf.push({ t: Date.now(), ...sample }), - this.deps.log - ) + for (const r of this.riders) r.attach?.() // calibration: a CalLink for this port s.onData((chunk) => this.onSerialData(chunk)) s.onMonitor((dir, chunk) => this.onSerialTraffic(dir, chunk)) s.onOpen((path) => this.onOpen(path)) @@ -288,13 +267,7 @@ export class DeviceLink { /** Release the serial port and what rides on it. */ private teardown(): void { - // Bytes the de-mux was holding are DCS traffic, but with the link going away deliberately - // there is no stream left for them to belong to. - this.cal?.close() - this.cal = undefined - this.calRawBuf = [] - this.lastCal = undefined - this.calSerialNumber = undefined + for (const r of this.riders) r.release?.() // calibration: its CalLink and what it held this.serial?.stop() this.serial = undefined this.gateway.reset() @@ -318,22 +291,14 @@ export class DeviceLink { }) this.gateway.takeChanged() // just sent this.requestNodes() // seed the roster as soon as the device is up - void this.readCalibrationState() // badges, with no dialog open - // Same physical device, so the port reopening is proof it re-enumerated and any HID handle - // we hold is stale. This is the reliable trigger: a disconnect does not surface as a - // node-hid error event on every platform, and serial recovery is proven. - this.hid?.reopen() + // Calibration reads what the board holds (badges, with no dialog open), then HID reopens. + for (const r of this.riders) r.opened?.(path) } private onClose(): void { this.nodes.linkClosed() // the boards on the bus were last seen now (#93) - // Fail anything in flight and release held bytes: a reconnect must not resume - // mid-candidate, and an awaiting caller would otherwise hang on a reply that cannot come. - // The released bytes are a candidate that never completed — not a frame, therefore DCS - // traffic — so they go on down the relay rather than being dropped here. - const held = this.cal?.close() - if (held?.length) this.fromDevice(held) - this.lastCal = undefined // the board it described may not be the one that comes back + // Calibration fails what is in flight and hands back held bytes, which go on down the relay. + for (const r of this.riders) r.closed?.((held) => this.fromDevice(held)) if (!this.autoConnect) { // Opted out of the client taking the port on its own: a drop leaves it down. this.disconnect() @@ -349,17 +314,6 @@ export class DeviceLink { this.serial?.write(data) } - /** - * A calibration exchange is in flight, and the export stream must not be forwarded. - * - * The protocol's one rule is that the client stops relaying the export stream before ANY - * exchange, a 10 ms HELLO as much as a whole session: it frees the link and removes any chance - * of the gateway reading export binary as a calibration frame. - */ - get exportGated(): boolean { - return !!this.cal?.exportGated - } - /** Ask PanelBridge for the full node roster (inject the request export to the serial). */ requestNodes(): void { if (!this.serial || !isLinkUp(this.status.state)) return @@ -372,10 +326,6 @@ export class DeviceLink { return this.status } - calSnapshot(): CalSnapshot | undefined { - return this.lastCal - } - // ── input ────────────────────────────────────────────────────────────────── private onSerialData(chunk: Buffer): void { @@ -384,7 +334,7 @@ export class DeviceLink { // arrives at up to ~250 frames/s — that binary has no business reaching DCS. Second, the // gateway injects frames at arbitrary byte boundaries, so one can land mid-line; removing its // bytes is exactly what rejoins the interrupted line for the assembler below. - this.fromDevice(this.cal ? this.cal.ingest(chunk) : chunk) + this.fromDevice(this.deps.frames.ingest(chunk)) } /** @@ -434,10 +384,11 @@ export class DeviceLink { * That row stays out of the cockpit model (node-model); this is where it is shown instead (#94). */ private gatewayLine(identifier: string, arg: string, raw: string, t: number): void { - this.report(this.gateway.answered(raw, t)) + this.deps.report(this.gateway.answered(raw, t)) if (identifier !== NODE_MSG) return const n = parseNodeStatus(arg.trim()) - if (n?.nodeId === BRIDGE_NODE_ID) this.report(this.gateway.report(withFaultInfo(n), raw, t)) + if (n?.nodeId === BRIDGE_NODE_ID) + this.deps.report(this.gateway.report(withFaultInfo(n), raw, t)) } // ── renderer pushes ──────────────────────────────────────────────────────── @@ -469,12 +420,10 @@ export class DeviceLink { * computed during an unplug would only repeat what the link state already says. */ private flushLink(): void { - // Still emitted while nothing is arriving: a silent device is the normal resting state, and - // ageMs and the rate have to keep moving for the UI to say so. - if (this.hid) this.emit('hid:report', this.hid.snapshot()) + for (const r of this.riders) r.flush?.() // HID: its snapshot, link up or not if (!isLinkUp(this.status.state)) return this.nodes.flush(this.roster.takeDirty()) - this.report(this.gateway.tick(Date.now(), this.exportGated)) + this.deps.report(this.gateway.tick(Date.now(), this.deps.frames.exportGated)) if (this.gateway.takeChanged()) { // Health rides on `device:status`, but it is not a change of link state: no onStatus. this.status = { ...this.status, gateway: this.gateway.status() } @@ -482,220 +431,6 @@ export class DeviceLink { } } - /** - * Push HID state, at most once per `HID_PUSH_MS`. - * - * Leading edge so a press shows immediately, trailing edge so the state that follows it is - * never the one left unsent — which is what stops a press-and-release inside one window from - * disappearing. Both halves are needed: leading alone loses the release, trailing alone adds - * the delay back. - */ - private pushHid(): void { - if (!this.hid) return - const now = Date.now() - const since = now - this.hidPushedAt - if (since >= HID_PUSH_MS) { - this.hidPushedAt = now - this.emit('hid:report', this.hid.snapshot()) - return - } - if (this.hidPushTimer) return - this.hidPushTimer = setTimeout(() => { - this.hidPushTimer = undefined - if (!this.hid) return - this.hidPushedAt = Date.now() - this.emit('hid:report', this.hid.snapshot()) - }, HID_PUSH_MS - since) - } - - // ── axis calibration (#46) ───────────────────────────────────────────────── - - /** - * Read-only: ask the gateway what it is and what calibration it holds, once per port open. - * - * Writes nothing. It exists so the HID tab can show per-axis calibrated/uncalibrated badges - * and an uncalibrated count **without anyone opening the dialog** — which is precisely why - * the firmware answers HELLO and GET_CAL outside a session. Everything that changes stored - * calibration is scoped to an open dialog. - * - * Also captures the board's USB serial, the key part of the restore cache in #46. - * - * Failure is not an error state: a gateway on older firmware simply never answers, and the - * relay is unaffected either way. - */ - private async readCalibrationState(): Promise { - this.calSerialNumber = await this.gatewaySerialNumber() - const hello = await this.calHello() - if (!hello.ok) { - this.deps.log('cal.absent', `no calibration channel: ${hello.message}`) - return - } - const { proto, blobVersion, fw } = hello.value - this.deps.log('cal.hello', { - proto, - blobVersion, - fw: `${fw.major}.${fw.minor}.${fw.patch}`, - serial: this.calSerialNumber ?? null - }) - const read = await this.calRead() - if (!read.ok) { - this.deps.log('cal.absent', `read failed: ${read.message}`) - return - } - // Masks, not endpoints: this is the line a bug report needs — which axes the sketch - // declares, and which of them the device considers calibrated. - this.deps.log('cal.data', { - present: read.value.presentMask.toString(2).padStart(8, '0'), - calibrated: read.value.calibratedMask.toString(2).padStart(8, '0') - }) - } - - /** - * USB serial of the attached gateway, for keying the restore cache in #46. - * - * From SerialPort.list(), not node-hid: node-hid is an optional dependency that may be absent - * entirely, and HidReader opens by VID/PID without exposing a serial. Confirmed on hardware — - * the board reports 50031327805E871C. Absent or empty means no restore may be offered, since - * guessing which board a cached calibration belongs to is the failure the key exists to stop. - */ - private async gatewaySerialNumber(): Promise { - try { - const path = await findSimGatewayPort() - if (!path) return undefined - const ports = await listSerialPorts() - const hit = ports.find((p) => p.path === path) - return hit?.serialNumber || undefined - } catch { - return undefined - } - } - - /** Convert a CalLink rejection into the discriminated result the renderer can branch on. */ - private async calRun(fn: (link: CalLink) => Promise): Promise> { - if (!this.cal) { - return { ok: false, kind: 'offline', message: 'Gateway is not connected' } - } - try { - return { ok: true, value: await fn(this.cal) } - } catch (err) { - if (err instanceof CalTimeoutError) { - return { ok: false, kind: 'timeout', message: err.message } - } - if (err instanceof CalNackError) { - const name = nackReasonName(err.reason) - return { - ok: false, - kind: 'nack', - reason: err.reason, - reasonName: name, - detail: err.detail, - message: err.message - } - } - return { ok: false, kind: 'error', message: (err as Error).message } - } - } - - calHello(): Promise> { - return this.calRun((l) => l.hello()) - } - - /** Read stored calibration and push it to the renderer, so badges follow the device. */ - async calRead(): Promise> { - const r = await this.calRun((l) => l.getCal()) - if (r.ok) { - const snap: CalSnapshot = { ...r.value, serialNumber: this.calSerialNumber } - this.lastCal = snap - this.emit('cal:data', snap) - return { ok: true, value: snap } - } - return r - } - - /** - * What the cache holds for the attached board, and which of its axes the device has lost. - * - * Regression is computed here rather than in the renderer because the serial lives here — the - * renderer never sees a board identity it could accidentally key on. - */ - calCacheRead(): CalResult<{ board?: CachedBoard; regressed: number[] }> { - const cache = loadCalCache(this.deps.cacheFile) - return { - ok: true, - value: { - board: boardFor(cache, this.calSerialNumber), - regressed: regressedAxes(cache, this.lastCal) - } - } - } - - /** Record axes the device confirmed storing. A board with no serial is not cached at all. */ - calCacheStore(axes: CalCacheEntry[]): CalResult { - const serial = this.calSerialNumber - if (!serial || axes.length === 0) return { ok: true, value: null } - const entries: Record = {} - for (const a of axes) { - entries[a.idx] = { min: a.min, centre: a.centre, max: a.max, selfCentring: a.selfCentring } - } - saveCalCache( - storeAxes(loadCalCache(this.deps.cacheFile), serial, entries, new Date().toISOString()), - this.deps.cacheFile - ) - return { ok: true, value: null } - } - - /** Forget a deliberately deleted axis, or the whole board on the reset-all index. */ - calCacheDrop(idx: number): CalResult { - const serial = this.calSerialNumber - if (!serial) return { ok: true, value: null } - saveCalCache( - dropAxis(loadCalCache(this.deps.cacheFile), serial, idx === CAL_AXIS_NONE ? undefined : idx), - this.deps.cacheFile - ) - return { ok: true, value: null } - } - - calSessionOpen(axisIdx: number): Promise> { - return this.calRun((l) => l.openSession(axisIdx)) - } - - calStreamSelect(axisIdx: number): Promise> { - return this.calRun(async (l) => { - await l.streamSelect(axisIdx) - return null - }) - } - - /** - * Write one axis. Resolving means the device acknowledged receipt, not that it stored the - * values — the caller re-reads with calRead() to learn what is actually on the device. - */ - calCommit(axis: CalCommitAxis): Promise> { - return this.calRun(async (l) => { - await l.commit(axis) - return null - }) - } - - calReset(idx: number): Promise> { - return this.calRun(async (l) => { - await l.reset(idx) - return null - }) - } - - calSessionClose(): Promise> { - return this.calRun(async (l) => { - await l.closeSession() - return null - }) - } - - private flushCalRaw(): void { - if (this.calRawBuf.length === 0) return - this.emit('cal:raw', this.calRawBuf.splice(0, this.calRawBuf.length)) - } - private setStatus(status: DeviceStatus): void { const prev = this.status // "Not found" repeats every retry; only a change is news to the renderer. diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts index 41af677..14ff086 100644 --- a/src/main/gateway/index.ts +++ b/src/main/gateway/index.ts @@ -1,4 +1,10 @@ // gateway: the SimGateway USB link. Exports only what other topics use (arch-phase0-v2 §2.1). -export { DeviceLink, type DeviceLinkDeps } from './device-link' +export { + DeviceLink, + type CalFrameFilter, + type DeviceLinkDeps, + type LinkRider, + type LinkTimer +} from './device-link' export { deviceEvent } from './link-events' -export { listSerialPorts } from './serial-bridge' +export { findSimGatewayPort, listSerialPorts } from './serial-bridge' diff --git a/src/main/hid/calibration/hid-calibration-service.ts b/src/main/hid/calibration/hid-calibration-service.ts new file mode 100644 index 0000000..bd95975 --- /dev/null +++ b/src/main/hid/calibration/hid-calibration-service.ts @@ -0,0 +1,319 @@ +// Axis calibration (#46) over the SimGateway's serial port, as the app runs it (moved out of +// DeviceLink, R2.2a): a CalLink per port, the read-on-open that drives the HID tab's badges, the +// cal* commands as discriminated CalResults, the restore cache, `cal:raw` batching, the +// last-confirmed snapshot and the export gate. +// +// It rides on the device link (gateway LinkRider) for the port's moments, and is the link's +// calibration frame filter (CalFrameFilter): DeviceLink runs it on every incoming chunk, and the +// codec's CalDemux inside CalLink takes the frames out. +import type { + CachedAxis, + CachedBoard, + CalCacheEntry, + CalCommitAxis, + CalHello, + CalRawSample, + CalResult, + CalSnapshot +} from '@shared/hid/calibration' +import type { PushChannel, PushChannels } from '@shared/app' +import { + findSimGatewayPort, + listSerialPorts, + type CalFrameFilter, + type LinkRider, + type LinkTimer +} from '../../gateway' +import { CalLink, CalNackError, CalTimeoutError, type Log } from './cal-link' +import { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' +import { boardFor, dropAxis, regressedAxes, storeAxes } from './cache-rules' +import { CAL_AXIS_NONE, nackReasonName } from './codec' + +type Emit = (channel: C, payload: PushChannels[C]) => void + +/** `cal:raw` batches: the serial monitor's cadence, as before the split. */ +const CAL_RAW_FLUSH_MS = 50 + +/** + * What the service needs from app: the write to the device (DeviceLink.write), the + * calibration-cache.json port, and the debug log. + */ +export interface HidCalibrationDeps { + write: (data: Buffer) => void + cacheFile: CacheFileIO + log: Log +} + +export class HidCalibrationService implements LinkRider, CalFrameFilter { + private cal?: CalLink + private calRawBuf: CalRawSample[] = [] + private calSerialNumber?: string + /** + * Last snapshot the device confirmed, held so a renderer reload can rehydrate. + * + * Cleared whenever the link goes down, which is what stops it describing a board that is no + * longer attached — or, worse, a different one after a reconnect. + */ + private lastCal?: CalSnapshot + + constructor( + private readonly emit: Emit, + private readonly deps: HidCalibrationDeps + ) {} + + // ── the link's moments (LinkRider) ───────────────────────────────────────── + + /** `cal:raw` batching, on the link's timer list. */ + timers(every: LinkTimer): void { + every(CAL_RAW_FLUSH_MS, () => this.flushCalRaw()) + } + + /** + * The calibration channel shares this port. It runs whenever the port is open, not only + * during a session: HELLO and GET_CAL are answered outside one, so there is no point at + * which it is safe to stop watching for frames. + */ + attach(): void { + this.cal = new CalLink( + (data) => this.deps.write(data), + (sample) => this.calRawBuf.push({ t: Date.now(), ...sample }), + this.deps.log + ) + } + + /** The port is released. */ + release(): void { + // Bytes the de-mux was holding are DCS traffic, but with the link going away deliberately + // there is no stream left for them to belong to. + this.cal?.close() + this.cal = undefined + this.calRawBuf = [] + this.lastCal = undefined + this.calSerialNumber = undefined + } + + /** Badges, with no dialog open. */ + opened(): void { + void this.readCalibrationState() + } + + /** The port closed under the link. */ + closed(pass: (held: Buffer) => void): void { + // Fail anything in flight and release held bytes: a reconnect must not resume + // mid-candidate, and an awaiting caller would otherwise hang on a reply that cannot come. + // The released bytes are a candidate that never completed — not a frame, therefore DCS + // traffic — so they go on down the relay rather than being dropped here. + const held = this.cal?.close() + if (held?.length) pass(held) + this.lastCal = undefined // the board it described may not be the one that comes back + } + + // ── the frame filter (CalFrameFilter) ────────────────────────────────────── + + ingest(chunk: Buffer): Buffer { + return this.cal ? this.cal.ingest(chunk) : chunk + } + + /** + * A calibration exchange is in flight, and the export stream must not be forwarded. + * + * The protocol's one rule is that the client stops relaying the export stream before ANY + * exchange, a 10 ms HELLO as much as a whole session: it frees the link and removes any chance + * of the gateway reading export binary as a calibration frame. + */ + get exportGated(): boolean { + return !!this.cal?.exportGated + } + + calSnapshot(): CalSnapshot | undefined { + return this.lastCal + } + + // ── axis calibration (#46) ───────────────────────────────────────────────── + + /** + * Read-only: ask the gateway what it is and what calibration it holds, once per port open. + * + * Writes nothing. It exists so the HID tab can show per-axis calibrated/uncalibrated badges + * and an uncalibrated count **without anyone opening the dialog** — which is precisely why + * the firmware answers HELLO and GET_CAL outside a session. Everything that changes stored + * calibration is scoped to an open dialog. + * + * Also captures the board's USB serial, the key part of the restore cache in #46. + * + * Failure is not an error state: a gateway on older firmware simply never answers, and the + * relay is unaffected either way. + */ + private async readCalibrationState(): Promise { + this.calSerialNumber = await this.gatewaySerialNumber() + const hello = await this.calHello() + if (!hello.ok) { + this.deps.log('cal.absent', `no calibration channel: ${hello.message}`) + return + } + const { proto, blobVersion, fw } = hello.value + this.deps.log('cal.hello', { + proto, + blobVersion, + fw: `${fw.major}.${fw.minor}.${fw.patch}`, + serial: this.calSerialNumber ?? null + }) + const read = await this.calRead() + if (!read.ok) { + this.deps.log('cal.absent', `read failed: ${read.message}`) + return + } + // Masks, not endpoints: this is the line a bug report needs — which axes the sketch + // declares, and which of them the device considers calibrated. + this.deps.log('cal.data', { + present: read.value.presentMask.toString(2).padStart(8, '0'), + calibrated: read.value.calibratedMask.toString(2).padStart(8, '0') + }) + } + + /** + * USB serial of the attached gateway, for keying the restore cache in #46. + * + * From SerialPort.list(), not node-hid: node-hid is an optional dependency that may be absent + * entirely, and HidReader opens by VID/PID without exposing a serial. Confirmed on hardware — + * the board reports 50031327805E871C. Absent or empty means no restore may be offered, since + * guessing which board a cached calibration belongs to is the failure the key exists to stop. + */ + private async gatewaySerialNumber(): Promise { + try { + const path = await findSimGatewayPort() + if (!path) return undefined + const ports = await listSerialPorts() + const hit = ports.find((p) => p.path === path) + return hit?.serialNumber || undefined + } catch { + return undefined + } + } + + /** Convert a CalLink rejection into the discriminated result the renderer can branch on. */ + private async calRun(fn: (link: CalLink) => Promise): Promise> { + if (!this.cal) { + return { ok: false, kind: 'offline', message: 'Gateway is not connected' } + } + try { + return { ok: true, value: await fn(this.cal) } + } catch (err) { + if (err instanceof CalTimeoutError) { + return { ok: false, kind: 'timeout', message: err.message } + } + if (err instanceof CalNackError) { + const name = nackReasonName(err.reason) + return { + ok: false, + kind: 'nack', + reason: err.reason, + reasonName: name, + detail: err.detail, + message: err.message + } + } + return { ok: false, kind: 'error', message: (err as Error).message } + } + } + + calHello(): Promise> { + return this.calRun((l) => l.hello()) + } + + /** Read stored calibration and push it to the renderer, so badges follow the device. */ + async calRead(): Promise> { + const r = await this.calRun((l) => l.getCal()) + if (r.ok) { + const snap: CalSnapshot = { ...r.value, serialNumber: this.calSerialNumber } + this.lastCal = snap + this.emit('cal:data', snap) + return { ok: true, value: snap } + } + return r + } + + /** + * What the cache holds for the attached board, and which of its axes the device has lost. + * + * Regression is computed here rather than in the renderer because the serial lives here — the + * renderer never sees a board identity it could accidentally key on. + */ + calCacheRead(): CalResult<{ board?: CachedBoard; regressed: number[] }> { + const cache = loadCalCache(this.deps.cacheFile) + return { + ok: true, + value: { + board: boardFor(cache, this.calSerialNumber), + regressed: regressedAxes(cache, this.lastCal) + } + } + } + + /** Record axes the device confirmed storing. A board with no serial is not cached at all. */ + calCacheStore(axes: CalCacheEntry[]): CalResult { + const serial = this.calSerialNumber + if (!serial || axes.length === 0) return { ok: true, value: null } + const entries: Record = {} + for (const a of axes) { + entries[a.idx] = { min: a.min, centre: a.centre, max: a.max, selfCentring: a.selfCentring } + } + saveCalCache( + storeAxes(loadCalCache(this.deps.cacheFile), serial, entries, new Date().toISOString()), + this.deps.cacheFile + ) + return { ok: true, value: null } + } + + /** Forget a deliberately deleted axis, or the whole board on the reset-all index. */ + calCacheDrop(idx: number): CalResult { + const serial = this.calSerialNumber + if (!serial) return { ok: true, value: null } + saveCalCache( + dropAxis(loadCalCache(this.deps.cacheFile), serial, idx === CAL_AXIS_NONE ? undefined : idx), + this.deps.cacheFile + ) + return { ok: true, value: null } + } + + calSessionOpen(axisIdx: number): Promise> { + return this.calRun((l) => l.openSession(axisIdx)) + } + + calStreamSelect(axisIdx: number): Promise> { + return this.calRun(async (l) => { + await l.streamSelect(axisIdx) + return null + }) + } + + /** + * Write one axis. Resolving means the device acknowledged receipt, not that it stored the + * values — the caller re-reads with calRead() to learn what is actually on the device. + */ + calCommit(axis: CalCommitAxis): Promise> { + return this.calRun(async (l) => { + await l.commit(axis) + return null + }) + } + + calReset(idx: number): Promise> { + return this.calRun(async (l) => { + await l.reset(idx) + return null + }) + } + + calSessionClose(): Promise> { + return this.calRun(async (l) => { + await l.closeSession() + return null + }) + } + + private flushCalRaw(): void { + if (this.calRawBuf.length === 0) return + this.emit('cal:raw', this.calRawBuf.splice(0, this.calRawBuf.length)) + } +} diff --git a/src/main/hid/calibration/index.ts b/src/main/hid/calibration/index.ts index fd3dc91..83f1b9b 100644 --- a/src/main/hid/calibration/index.ts +++ b/src/main/hid/calibration/index.ts @@ -1,6 +1,5 @@ // hid/calibration: the axis calibration channel and its cache. Exports only what other topics use // (arch-phase0-v2 §2.1). -export { CalLink, CalNackError, CalTimeoutError } from './cal-link' +export { HidCalibrationService } from './hid-calibration-service' export { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' -export { boardFor, dropAxis, mergeCalibration, regressedAxes, storeAxes } from './cache-rules' -export { CAL_AXIS_NONE, nackReasonName } from './codec' +export { mergeCalibration } from './cache-rules' diff --git a/src/main/hid/hid-service.ts b/src/main/hid/hid-service.ts new file mode 100644 index 0000000..fc74a70 --- /dev/null +++ b/src/main/hid/hid-service.ts @@ -0,0 +1,103 @@ +// The SimGateway's HID joystick, as the app runs it (moved out of DeviceLink, R2.2a): the reader's +// lifecycle, the throttled push to the renderer, the rate window, and the reopen when the serial +// port comes back. It rides on the device link (gateway LinkRider) for those moments; its I/O is +// the HID reader. +import type { PushChannel, PushChannels } from '@shared/app' +import type { LinkRider, LinkTimer } from '../gateway' +import { HidReader } from './hid-reader' + +type Emit = (channel: C, payload: PushChannels[C]) => void + +/** Port: the debug log, handed in by app (debug.log lives in app). */ +export type Log = (tag: string, data?: unknown) => void + +/** + * Shortest gap between HID pushes to the renderer. + * + * A press used to wait for the 200 ms telemetry tick, which is visible lag on a button and + * enough to lose a quick tap entirely. Reports now drive the push, capped here at roughly one + * frame so a fast axis sweep — the device can emit a few hundred a second — cannot flood IPC. + */ +const HID_PUSH_MS = 16 +const HID_RATE_MS = 1000 + +export class HidService implements LinkRider { + private hid?: HidReader + private hidPushedAt = 0 + private hidPushTimer?: ReturnType + + constructor( + private readonly emit: Emit, + private readonly log: Log + ) {} + + /** The rate window, on the link's timer list. */ + timers(every: LinkTimer): void { + every(HID_RATE_MS, () => this.hid?.sampleRate()) + } + + /** + * HID is read, never claimed: DCS reads the same interface as a joystick, so it runs for as long + * as the app does — independent of the serial port, and still up while the port is released. + * It reconnects on its own, so its errors are worth recording rather than swallowing — they are + * how we learn whether an unplug surfaces here at all, or only as the serial port dropping. + */ + start(): void { + const h = new HidReader() + this.hid = h + h.onError((err) => this.log('hid.error', { message: err.message })) + h.onReport(() => this.pushHid()) + h.start() + } + + /** + * Same physical device, so the port reopening is proof it re-enumerated and any HID handle + * we hold is stale. This is the reliable trigger: a disconnect does not surface as a + * node-hid error event on every platform, and serial recovery is proven. + */ + opened(): void { + this.hid?.reopen() + } + + /** + * The link's push tick. Still emitted while nothing is arriving: a silent device is the normal + * resting state, and ageMs and the rate have to keep moving for the UI to say so. + */ + flush(): void { + if (this.hid) this.emit('hid:report', this.hid.snapshot()) + } + + /** App quit. */ + stop(): void { + if (this.hidPushTimer) clearTimeout(this.hidPushTimer) + this.hidPushTimer = undefined + this.hid?.stop() + this.hid = undefined + } + + /** + * Push HID state, at most once per `HID_PUSH_MS`. + * + * Leading edge so a press shows immediately, trailing edge so the state that follows it is + * never the one left unsent — which is what stops a press-and-release inside one window from + * disappearing. Both halves are needed: leading alone loses the release, trailing alone adds + * the delay back. + */ + private pushHid(): void { + if (!this.hid) return + const now = Date.now() + const since = now - this.hidPushedAt + if (since >= HID_PUSH_MS) { + this.hidPushedAt = now + this.emit('hid:report', this.hid.snapshot()) + return + } + if (this.hidPushTimer) return + this.hidPushTimer = setTimeout(() => { + this.hidPushTimer = undefined + if (!this.hid) return + this.hidPushedAt = Date.now() + this.emit('hid:report', this.hid.snapshot()) + }, HID_PUSH_MS - since) + } +} diff --git a/src/main/hid/index.ts b/src/main/hid/index.ts index b1819ab..8932bec 100644 --- a/src/main/hid/index.ts +++ b/src/main/hid/index.ts @@ -1,2 +1,2 @@ // hid: the SimGateway's HID joystick. Exports only what other topics use (arch-phase0-v2 §2.1). -export { HidReader } from './hid-reader' +export { HidService } from './hid-service' From f6679792527eee722703afeeb43611a490768446 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 23:00:37 -0400 Subject: [PATCH 14/28] refactor(gateway): finish the DeviceLink split (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.2b of the structure rewrite (#101, phase #99): the second half of the DeviceLink split. What was left in DeviceLink after R2.2a goes to its owners from the decision table: the USB port to **GatewayConnection**, the line taps to **DeviceLineTap**, the Serial tab's raw view to **SerialTrafficMonitor**, and the roster poll, node lines and Gateway health to **NodeHub** (UD-C). `gateway/device-link.ts` is deleted. No test needed an alias for it. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port, the files written and the order of all of them are the same. The trace proof below checks this. ## What moved where | From DeviceLink | To | Notes | |---|---|---| | `.serial`, `.status`, `.autoConnect`, `.started`, `.timers`, `.riders` + `ride()`, `start`, `shutdown`, `setAutoConnect`, `connect`, `disconnect`, `teardown`, `onOpen`, `onClose`, `write`, `deviceStatus`, `onSerialData` (the `CalFrameFilter` call site), `fromDevice` (its byte half), `flushLink` (the 200 ms timer and its `isLinkUp` guard), `setStatus`, `raise`, `onBytes` / `onStatus` / `onError`, `LINK_FLUSH_MS`, `CalFrameFilter` / `LinkRider` / `LinkTimer` | `gateway/gateway-connection.ts` **GatewayConnection** | New: `onTraffic` (the serial monitor's tap), `attached` (was `!!this.serial` inside `requestNodes`), `exportGated` (was `deps.frames.exportGated` inside `flushLink`), `pushGatewayHealth` (the health half of `flushLink`) | | `gatewaySerialNumber` (moved into HidCalibrationService by R2.2a) | **GatewayConnection** `usbSerialNumber()` | Same body. Calibration gets it injected as `serialNumber` and still awaits it first thing in its read-on-open | | `.assembler`, the line loop of `fromDevice`, `onLine`, `DeviceLine` | `gateway/device-line-tap.ts` **DeviceLineTap** | Read-only. Node lines go to NodeHub through a gateway-declared `NodeLineSink` port, so gateway never imports nodes | | `.serialMonitor`, `.serialBuf`, `setSerialMonitor`, `onSerialTraffic`, `flushSerial`, `SERIAL_FLUSH_MS`, `MAX_SERIAL_BUF` | `gateway/serial-traffic-monitor.ts` **SerialTrafficMonitor** | Its 50 ms timer goes on the connection's timer list as a rider, in the same slot | | `.roster`, `.gateway` (GatewayWatch), `.legacyNodesWarned`, `requestNodes`, `NODES_REFRESH_MS` + the 5 s poll, `gatewayLine`, the v2-legacy warning, the node branches of `fromDevice`, the node and Gateway-health part of `flushLink`, the node resets in `onOpen`, `gateway.reset()` in `teardown`, `nodes.linkClosed()` in `shutdown` / `disconnect` / `onClose` | `nodes/node-hub.ts` **NodeHub** | It writes the roster request through the connection's `write`, through the `NodeHubLink` port. Its link moments are `NodeHub.rider`. NodeHub's own `flush` must never be called as a rider's `flush`, so the rider is a separate object and not the hub itself. The v2 warning goes out on `NodeHub.onError`, which Session wires to the same handler as the connection's errors. That keeps the `NODES` row's once-per-run dedupe in `logError` | | `isBoardPresent`, `isBusy` (`link-state.ts`) | not created | Neither exists in main today: they are renderer logic (R3.3). `isLinkUp` stays in `shared/gateway` for the renderer | Callers change their receiver only. `app/index.ts` and `test-harness/services.ts` call `session.connection.connect()` / `.disconnect()`, `session.nodes.*` (with `requestNodes`) and `session.serialMonitor.setSerialMonitor()`. `session.test.ts` changes 9 `session.link.connect()` / `.disconnect()` lines to `session.connection.*`. Their arguments and assertions are unchanged. No method was renamed. The `nodes` index now exports only what other topics use: `NodeHub`, `NodeRoster` and `MappingFileIO`. Its node-wire re-exports were DeviceLink's. ## Wiring and the order it keeps Session constructs, in this order: calibration, HID, GatewayConnection, NodeHub, SerialTrafficMonitor, DeviceLineTap. None of these constructors has a side effect; NodeHub's MappingStore still loads lazily. Two of the dependencies are circular, and both are lazy arrows, the same way calibration's `write` already was: the connection's `health` calls `nodes.gatewayHealth()`, and NodeHub's `link` is the connection. Riders are added in this order: `serialMonitor`, `calibration`, `hid`, `nodes.rider`, `lines`. At each moment, the connection calls them in that order, at the line where DeviceLink's own code used to sit: | Moment | Order (unchanged from DeviceLink) | |---|---| | `start()` | link tick (200, the connection's own) → **`timers`**: serial flush (50), cal:raw (50), HID rate (1000), roster poll (5000) → **`start`**: HID reader → connect | | `connect()` | teardown → new SerialBridge → **`attach`**: CalLink → port callbacks → `scanning` → port start | | `teardown()` | **`release`**: calibration → port stop → **`released`**: the Gateway's health reset | | port opens | **`opening`**: roster, node data, Gateway health, the v2 flag, then the line assembler → `connected` status (carrying `health()`) → **`connected`**: health `takeChanged`, roster request → **`opened`**: calibration read-on-open, HID reopen | | port closes | **`closing`**: nodes' last seen → **`closed`**: calibration, held bytes back down the stream → disconnect or `reconnecting` | | 200 ms tick | **`flush`**: HID snapshot → (link up only) **`tick`**: node flush, Gateway tick, health push | | `disconnect()` | **`closing`** → teardown → `disconnected` | | `shutdown()` | **`closing`** → all link timers cleared (same order) → teardown → **`stop`**: HID → `started = false` | Subscriptions, in order: - `connection.onTraffic` → serial monitor; - `connection.onBytes` → the relay to DCS first, then `lines.push`. That is the order DeviceLink handed bytes on in: relay first, then the line loop; - `lines.onLine` → command rows; - `connection.onError` and `nodes.onError` → the same handler; - `journal.onEvents`; - `connection.onStatus`. The rider table and this list are also written as a comment in `Session`'s constructor. ## Proof - **Side-effect trace (not committed).** This is the R2.2a harness, extended. It drives `Session` with fakes for the serial port, the DCS transport, the HID reader, the debug log and `node:fs`, under fake timers. It records, in order: every emit (channel + payload), the serial port's lifecycle and every byte written, DCS sends, the HID reader's lifecycle, every timer created or cleared (with its period), the debug-log calls, fs writes, returned values and the final `` files. - **Changes to the harness:** - the fake port now feeds the monitor tap as `SerialBridge` does (`tx` on write, `rx` before data), so the serial monitor sees real traffic; - the intents resolve through accessors that work at both base and head. - **New scenarios:** - **roster-poll-disconnect**: the poll while scanning, steady polls, Disconnect (the polls stop writing), connect/not-found/open, a drop with a reply cut mid-line, a poll while reconnecting, a reopen (the cut line does not prefix the next), and an error with Retry mid-poll; - **gateway-health**: the Gateway's own row going ok, bus-off, error-passive and cleared; unanswered polls going "not responding"; answering again; a calibration exchange pausing the clock; a drop while not responding, then a reopen; - **monitor-roster-bursts**: the monitor switched on mid-burst, the roster request tapped as tx, off before a flush, on twice, telemetry + commands + RAW, a flood across flushes, a disconnect while on; - **relay-node-lines**: node rows, a v2 row, telemetry and commands while the Bridge relay runs. This one makes the relay-before-line-tap order observable. - **Result:** base (3b9d5da) and head outputs are **byte-identical**, with the same sha1 (`a5ed9ba0…`): 13 scenarios and 2,304 recorded events (1,332 emits, 197 serial, 281 timer, 267 HID, 71 returned values, 93 debug-log, 37 fs, 26 DCS). Base is deterministic over two runs. - **It can fail.** One deliberate reorder at a time: - swapping the serial monitor and calibration riders: 4 diff lines; - subscribing the line tap before the relay: 6; - riding NodeHub before HID: 52. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with `SKYHAWK_USER_DATA` set and with the dev default: - base and head probe outputs are byte-identical; - `config.json` and `node-mapping.json` have the same sha1s; - `debug.log` is identical apart from its timestamp. - **Tests.** 67 files, 889 tests, all passing. The `makeServices()` seam tests and the component tests are untouched. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before. - `npm run lint:deps`: 0 errors, **15 warnings (was 17)**: - `topic-main-gateway` goes from 1 to **0**, because gateway no longer imports nodes; - `T2-main` goes from 5 to **4**: `nodes/node-status-wire` now imports `encodeExportFrame` through `gateway/index.ts`, with no cycle. The 4 left are the known `config → nodes` / `config → hid/calibration` deep imports, for R2.3. - `npm run build`: OK. Refs #99 --- src/main/app/index.ts | 32 +- src/main/app/session.test.ts | 18 +- src/main/app/session.ts | 84 +++-- src/main/gateway/device-line-tap.ts | 59 ++++ .../{device-link.ts => gateway-connection.ts} | 288 +++++++----------- src/main/gateway/index.ts | 12 +- src/main/gateway/serial-traffic-monitor.ts | 43 +++ .../calibration/hid-calibration-service.ts | 42 +-- src/main/hid/hid-service.ts | 4 +- src/main/nodes/index.ts | 14 +- src/main/nodes/node-hub.ts | 157 +++++++++- src/main/nodes/node-status-wire.ts | 4 +- src/main/test-harness/services.ts | 27 +- 13 files changed, 479 insertions(+), 305 deletions(-) create mode 100644 src/main/gateway/device-line-tap.ts rename src/main/gateway/{device-link.ts => gateway-connection.ts} (54%) create mode 100644 src/main/gateway/serial-traffic-monitor.ts diff --git a/src/main/app/index.ts b/src/main/app/index.ts index 329e913..40b993c 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -81,7 +81,7 @@ function applyConfig(patch: Partial): AppConfig { const transfer = new ConfigTransfer({ getConfig: () => session.getConfig(), setConfig: (config) => applyConfig(config), - nodes: session.link.nodes, + nodes: session.nodes, loadCalCache: () => loadCalCache(cacheFile), saveCalCache: (cache) => saveCalCache(cache, cacheFile), backupDir: () => join(app.getPath('userData'), 'backups'), @@ -137,28 +137,26 @@ function registerIpc(): void { ipcMain.handle(CTRL.relayStart, () => session.start()) ipcMain.handle(CTRL.relayStop, () => session.stop()) ipcMain.handle(CTRL.relayStatus, () => session.status()) - ipcMain.handle(CTRL.deviceConnect, () => session.link.connect()) - ipcMain.handle(CTRL.deviceDisconnect, () => session.link.disconnect()) + ipcMain.handle(CTRL.deviceConnect, () => session.connection.connect()) + ipcMain.handle(CTRL.deviceDisconnect, () => session.connection.disconnect()) ipcMain.handle(CTRL.hidAvailability, () => hidAvailability()) - ipcMain.handle(CTRL.nodesRefresh, () => session.link.requestNodes()) - ipcMain.handle(CTRL.nodesGet, () => session.link.nodes.snapshot()) + ipcMain.handle(CTRL.nodesRefresh, () => session.nodes.requestNodes()) + ipcMain.handle(CTRL.nodesGet, () => session.nodes.snapshot()) ipcMain.handle(CTRL.nodeMappingSet, (_e, id: number, patch: MappingPatch) => - session.link.nodes.setMapping(id, patch) + session.nodes.setMapping(id, patch) ) ipcMain.handle(CTRL.nodeMappingAdopt, (_e, id: number, spec: AdoptSpec) => - session.link.nodes.adopt(id, spec) + session.nodes.adopt(id, spec) ) - ipcMain.handle(CTRL.nodeMappingForget, (_e, id: number) => session.link.nodes.forget(id)) - ipcMain.handle(CTRL.nodeMappingResetNode, (_e, id: number) => session.link.nodes.resetBoard(id)) - ipcMain.handle(CTRL.nodeMappingReset, (_e, opts?: ResetOptions) => - session.link.nodes.resetAll(opts) + ipcMain.handle(CTRL.nodeMappingForget, (_e, id: number) => session.nodes.forget(id)) + ipcMain.handle(CTRL.nodeMappingResetNode, (_e, id: number) => session.nodes.resetBoard(id)) + ipcMain.handle(CTRL.nodeMappingReset, (_e, opts?: ResetOptions) => session.nodes.resetAll(opts)) + ipcMain.handle(CTRL.nodeMappingResetPreview, () => session.nodes.previewReset()) + ipcMain.handle(CTRL.nodeRegistry, () => session.nodes.registry()) + ipcMain.handle(CTRL.nodeMappingHide, (_e, id: number, on: boolean) => session.nodes.hide(id, on)) + ipcMain.handle(CTRL.serialMonitor, (_e, on: boolean) => + session.serialMonitor.setSerialMonitor(on) ) - ipcMain.handle(CTRL.nodeMappingResetPreview, () => session.link.nodes.previewReset()) - ipcMain.handle(CTRL.nodeRegistry, () => session.link.nodes.registry()) - ipcMain.handle(CTRL.nodeMappingHide, (_e, id: number, on: boolean) => - session.link.nodes.hide(id, on) - ) - ipcMain.handle(CTRL.serialMonitor, (_e, on: boolean) => session.link.setSerialMonitor(on)) // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the diff --git a/src/main/app/session.test.ts b/src/main/app/session.test.ts index 7ff2b8e..9a8677b 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/session.test.ts @@ -350,7 +350,7 @@ describe('device link is independent of the relay (#69)', () => { const { session, serial } = launched({ autoConnect: false }) expect(serial).toBeUndefined() expect(session.status().device.state).toBe('disconnected') - session.link.connect() // the explicit Connect still works + session.connection.connect() // the explicit Connect still works expect(FakeSerial.last).toBeDefined() session.shutdown() }) @@ -457,7 +457,7 @@ describe('device link is independent of the relay (#69)', () => { portPath: '/dev/fake', detail: 'Access denied' }) - session.link.connect() // Retry + session.connection.connect() // Retry expect(serial.stopped, 'the failed port is released').toBe(true) expect(FakeSerial.last).not.toBe(serial) expect(session.status().device.state).toBe('scanning') @@ -466,10 +466,10 @@ describe('device link is independent of the relay (#69)', () => { it('ignores connect while the link is already open or looking', () => { const { session, serial } = launched() - session.link.connect() + session.connection.connect() expect(FakeSerial.last).toBe(serial) serial.openCb('/dev/fake') - session.link.connect() + session.connection.connect() expect(FakeSerial.last).toBe(serial) expect(serial.stopped).toBe(false) session.shutdown() @@ -512,13 +512,13 @@ describe('device link is independent of the relay (#69)', () => { it('disconnect releases the serial port until connect, and leaves HID running', async () => { const { session, serial } = launched() serial.openCb('/dev/fake') - session.link.disconnect() + session.connection.disconnect() expect(serial.stopped).toBe(true) expect((await HID()).stopped, 'HID is shared with DCS and stays up').toBe(false) const cal = await session.calibration.calHello() expect(cal.ok ? 'ok' : cal.kind, 'calibration rides on serial').toBe('offline') expect(session.status().device.state).toBe('disconnected') - session.link.connect() + session.connection.connect() expect(FakeSerial.last).not.toBe(serial) expect(session.status().device.state).toBe('scanning') session.shutdown() @@ -537,7 +537,7 @@ describe('device link is independent of the relay (#69)', () => { it('does not reconnect a released link on Start', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.link.disconnect() + session.connection.disconnect() expect(session.start().ok).toBe(false) expect(FakeSerial.last, 'Start leaves the link alone').toBe(serial) expect(session.status().device.state).toBe('disconnected') @@ -886,8 +886,8 @@ describe('event log (#64)', () => { const { session, serial } = launched() serial.errorCb(new Error('Cannot lock port')) serial.errorCb(new Error('Cannot lock port')) // the port's own retry loop - session.link.disconnect() - session.link.connect() + session.connection.disconnect() + session.connection.connect() FakeSerial.last!.errorCb(new Error('Cannot lock port')) expect(rows(session)).toEqual([ 'client:SERIAL: serial port error · Cannot lock port', diff --git a/src/main/app/session.ts b/src/main/app/session.ts index 56c7dde..14acea9 100644 --- a/src/main/app/session.ts +++ b/src/main/app/session.ts @@ -4,8 +4,8 @@ // capture); recording can tap any live mode. // // The device link is not started or stopped here. It is up whenever the board is plugged in -// (gateway/device-link.ts); a source mode only chooses where the stream comes from and whether it -// reaches the device. +// (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and +// whether it reaches the device. import { EXPORT_MULTICAST_ADDR, EXPORT_PORT, @@ -30,9 +30,10 @@ import { type Transport } from '../dcs' import { clientErrorEvent, EventJournal, EventLog } from '../events' -import { deviceEvent, DeviceLink } from '../gateway' +import { deviceEvent, DeviceLineTap, GatewayConnection, SerialTrafficMonitor } from '../gateway' import { HidService } from '../hid' import { HidCalibrationService } from '../hid/calibration' +import { NodeHub, NodeRoster } from '../nodes' import { debugLog } from './debug-log' import { storeFiles, storePaths, type StorePaths } from './user-data' @@ -87,9 +88,15 @@ export function relayContext(cfg: AppConfig): RelayContext { } export class Session { - /** The device link: serial and nodes. Up independently of the relay. */ - readonly link: DeviceLink - /** Axis calibration (#46), riding on the device link's port. */ + /** The Gateway's USB serial port. Up independently of the relay. */ + readonly connection: GatewayConnection + /** The port's text lines, read: node lines to NodeHub, panel commands to the log. */ + readonly lines: DeviceLineTap + /** The Serial tab's raw view of the port. */ + readonly serialMonitor: SerialTrafficMonitor + /** The node roster, node data and mapping, and the Gateway's health (UD-C). */ + readonly nodes: NodeHub + /** Axis calibration (#46), riding on the Gateway port. */ readonly calibration: HidCalibrationService /** The HID joystick, up for as long as the app is. */ readonly hid: HidService @@ -126,35 +133,60 @@ export class Session { this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) const { mappingFile, cacheFile } = storeFiles(paths) this.calibration = new HidCalibrationService(emit, { - write: (data) => this.link.write(data), + write: (data) => this.connection.write(data), + serialNumber: () => this.connection.usbSerialNumber(), cacheFile, log: debugLog }) this.hid = new HidService(emit, debugLog) - this.link = new DeviceLink(emit, { + this.connection = new GatewayConnection(emit, { + log: debugLog, + frames: this.calibration, + health: () => this.nodes.gatewayHealth() + }) + this.nodes = new NodeHub(emit, new NodeRoster(), (events) => this.journal.report(events), { mappingFile, log: debugLog, - report: (events) => this.journal.report(events), - frames: this.calibration + link: this.connection }) - // The order is load-bearing: at each of the link's moments its riders run in the order added, - // and where both handle one (start's timers, a port opening) calibration's part came first - // inside DeviceLink before the split (R2.2a). See LinkRider. - this.link.ride(this.calibration) - this.link.ride(this.hid) - this.link.onBytes((bytes) => this.fromDevice(bytes)) - this.link.onLine((l) => { + this.serialMonitor = new SerialTrafficMonitor(emit) + this.lines = new DeviceLineTap(this.nodes) + // The wiring order is load-bearing (R2.2b): it is the order DeviceLink ran these in before the + // split. At each of the connection's moments its riders run in the order added: + // start's timers link tick (200, its own) → serial flush (50) → cal:raw (50) → + // HID rate (1000) → roster poll (5000); then the HID reader + // port opens `opening`: node resets, then the line assembler → connected status → + // `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 + // shutdown `closing`: nodes → timers cleared → teardown → `stop`: HID + this.connection.ride(this.serialMonitor) + this.connection.ride(this.calibration) + this.connection.ride(this.hid) + this.connection.ride(this.nodes.rider) + this.connection.ride(this.lines) + // Port traffic: the serial monitor taps it raw, above the frame filter. + this.connection.onTraffic((dir, chunk) => this.serialMonitor.onSerialTraffic(dir, chunk)) + // Bytes below the frame filter: the relay to DCS first, then the line tap — the order + // DeviceLink handed them on in. + this.connection.onBytes((bytes) => this.fromDevice(bytes)) + this.connection.onBytes((bytes) => this.lines.push(bytes)) + this.lines.onLine((l) => { if (l.kind !== 'command') return // Logged relaying or not: watching panel commands with no DCS is how a panel gets tested. this.stats.command(l.line) this.logBuf.push({ t: l.t, dir: 'out', address: 0, name: l.identifier, value: l.arg }) }) - this.link.onError((tag, message) => { + // Link faults and the v2-Gateway warning: one path, as when DeviceLink raised both. + const onError = (tag: string, message: string): void => { this.stats.error() this.logError(tag, message) - }) + } + this.connection.onError(onError) + this.nodes.onError(onError) this.journal.onEvents((events) => this.events.add(events)) - this.link.onStatus((s) => { + this.connection.onStatus((s) => { if (s.state === 'reconnecting') this.stats.reconnect() // The port's own retry loop stays in `error`; any other state (an explicit Connect or Retry // goes through `scanning`) ends that loop, so the next failure is news again. @@ -169,13 +201,13 @@ export class Session { open(): void { if (this.logTimer) return this.logTimer = setInterval(() => this.flushLog(), LOG_FLUSH_MS) - this.link.start(this.config.autoConnect) + this.connection.start(this.config.autoConnect) } /** App quit. */ shutdown(): void { this.stop() - this.link.shutdown() + this.connection.shutdown() if (this.logTimer) clearInterval(this.logTimer) this.logTimer = undefined } @@ -188,7 +220,7 @@ export class Session { const prev = this.config this.config = { ...prev, ...patch } if (prev.autoConnect !== this.config.autoConnect) { - this.link.setAutoConnect(this.config.autoConnect) + this.connection.setAutoConnect(this.config.autoConnect) } if (this.running && relayConfigChanged(prev, this.config)) { // A rebuild of a relay already running, not a new Start: it is not gated on the device, @@ -207,7 +239,7 @@ export class Session { * resumes when the link returns. */ start(): RelayResult { - if (exportToDevice(this.config) && !isLinkUp(this.link.deviceStatus().state)) { + if (exportToDevice(this.config) && !isLinkUp(this.connection.deviceStatus().state)) { return { ok: false, error: 'Gateway is not connected' } } return this.run() @@ -315,7 +347,7 @@ export class Session { this.recorder?.record('in', chunk) // Forward byte-for-byte to the SimGateway — unless a calibration exchange is in flight. // Parsing for our own UI continues; only the forward pauses. - if (exportToDevice(this.config) && !this.calibration.exportGated) this.link.write(chunk) + if (exportToDevice(this.config) && !this.calibration.exportGated) this.connection.write(chunk) } /** Device traffic, calibration frames already removed by the link: relay to DCS:7778. */ @@ -358,7 +390,7 @@ export class Session { /** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */ status(): RelayStatus { - const device: DeviceStatus = this.link.deviceStatus() + const device: DeviceStatus = this.connection.deviceStatus() return { running: this.running, relay: this.relay, device, cal: this.calibration.calSnapshot() } } diff --git a/src/main/gateway/device-line-tap.ts b/src/main/gateway/device-line-tap.ts new file mode 100644 index 0000000..55ec38d --- /dev/null +++ b/src/main/gateway/device-line-tap.ts @@ -0,0 +1,59 @@ +// The device line tap: the Gateway's byte stream, below the calibration frame filter, assembled +// into text lines and read — never changed. Node lines go to the node layer (NodeHub, through the +// NodeLineSink port), and every line is handed on to its subscribers (Session logs the panel +// commands). The bytes the relay sends to DCS are GatewayConnection's onBytes(), untouched by this. +import { LineAssembler, parseCommand } from './link-framing' +import type { LinkRider } from './gateway-connection' + +/** One complete text line from the device, below the calibration de-mux. */ +export interface DeviceLine { + /** `node` lines are tapped to the roster / node layer; `command` lines are panel commands. */ + kind: 'node' | 'command' + identifier: string + arg: string + line: string + t: number +} + +/** Where node lines go (nodes NodeHub implements it): gateway never imports nodes. */ +export interface NodeLineSink { + /** The raw line with any UART noise in front of a node-status row cut off. */ + cleanLine(raw: string): string + /** One parsed line. True when it was a node line, tapped by the node layer. */ + nodeLine(identifier: string, arg: string, line: string, t: number): boolean +} + +type Unsubscribe = () => void + +export class DeviceLineTap implements LinkRider { + private assembler = new LineAssembler() + private readonly lineCbs = new Set<(line: DeviceLine) => void>() + + constructor(private readonly nodes: NodeLineSink) {} + + /** Every complete text line, after the node layer has seen it. */ + onLine(cb: (line: DeviceLine) => void): Unsubscribe { + this.lineCbs.add(cb) + return () => this.lineCbs.delete(cb) + } + + /** The port opened (LinkRider): a line cut by the unplug must not prefix the next one. */ + opening(): void { + this.assembler = new LineAssembler() + } + + /** Device bytes, calibration frames already removed (GatewayConnection.onBytes). */ + push(rest: Buffer): void { + const t = Date.now() + for (const raw of this.assembler.push(rest)) { + if (!raw.trim()) continue + // UART noise from a bridge reset can precede the first node-status row; cut it off. + const line = this.nodes.cleanLine(raw) + const { identifier, arg } = parseCommand(line) + let kind: DeviceLine['kind'] = 'command' + // Node-status and node-telemetry messages are tapped, not logged as panel commands. + if (this.nodes.nodeLine(identifier, arg, line.trim(), t)) kind = 'node' + for (const cb of this.lineCbs) cb({ kind, identifier, arg, line: line.trim(), t }) + } + } +} diff --git a/src/main/gateway/device-link.ts b/src/main/gateway/gateway-connection.ts similarity index 54% rename from src/main/gateway/device-link.ts rename to src/main/gateway/gateway-connection.ts index fbd1a6d..1a4848c 100644 --- a/src/main/gateway/device-link.ts +++ b/src/main/gateway/gateway-connection.ts @@ -1,47 +1,32 @@ -// The device link (#69): the SimGateway serial port, the node roster and the node data layer. Up -// whenever the board is plugged in, in every source mode, relaying or not; down only on unplug or an -// explicit disconnect. +// The Gateway connection (#69): the SimGateway's USB serial port, and only that. Up whenever the +// board is plugged in, in every source mode, relaying or not; down only on unplug or an explicit +// disconnect. Finds, opens and retries the port, holds the link state and pushes it as +// `device:status`, writes, knows the board's USB serial, and runs the calibration frame filter on +// every byte that comes in. // -// What else rides on the port — calibration (hid/calibration HidCalibrationService) and HID -// (hid HidService) — is told about the link's moments through `ride()` (R2.2a), and event-log rows -// go to the EventJournal through `report`. -// -// The relay (app/session.ts) attaches to it rather than owning it: export goes out through write(), -// and device traffic comes back through onBytes() / onLine(). That split is the point. Bundled -// together, there was no node status, PDU telemetry, HID or calibration unless DCS was being -// relayed, and stopping the relay tore the port down with it. -import type { LogEventInput } from '@shared/events' -import { isLinkUp, type DeviceStatus, type SerialFrame } from '@shared/gateway' +// What else rides on the port is told about the link's moments through `ride()` (LinkRider): the +// serial monitor, calibration, HID, the node layer and the line tap (R2.2a, R2.2b). Device traffic +// goes out through onBytes(), below the frame filter: the relay (app/session.ts) and the line tap +// (device-line-tap.ts) subscribe to it. Export goes in through write(). That split is the point. +// Bundled together, there was no node status, PDU telemetry, HID or calibration unless DCS was +// being relayed, and stopping the relay tore the port down with it. +import { isLinkUp, type DeviceStatus, type GatewayStatus } from '@shared/gateway' import type { PushChannel, PushChannels } from '@shared/app' import { + findSimGatewayPort, + listSerialPorts, PortNotFoundError, SerialBridge, SIMGATEWAY_PID, SIMGATEWAY_VID, type Log } from './serial-bridge' -import { GatewayWatch } from './gateway-health' -import { LineAssembler, parseCommand } from './link-framing' -import { - BRIDGE_NODE_ID, - NODE_END_MSG, - NODE_MSG, - NODE_TELEM_MSG, - NodeHub, - NodeRoster, - cleanNodeLine, - isLegacyNodeStatus, - nodeRosterRequest, - parseNodeStatus, - withFaultInfo, - type MappingFileIO -} from '../nodes' type Emit = (channel: C, payload: PushChannels[C]) => void /** * The calibration frame filter on the incoming byte path (hid/calibration's HidCalibrationService - * supplies it). DeviceLink runs it on every chunk before anything else sees the bytes. + * supplies it). GatewayConnection runs it on every chunk before anything else sees the bytes. */ export interface CalFrameFilter { /** Feed inbound bytes; what is not a calibration frame comes back, for DCS and the line taps. */ @@ -54,67 +39,63 @@ export interface CalFrameFilter { export type LinkTimer = (ms: number, fn: () => void) => void /** - * What rides on the link — calibration and HID (R2.2a) — and the link's moments it is told about. + * What rides on the link, and the link's moments it is told about. * - * At each moment DeviceLink calls its riders in the order they were added with `ride()`, at exactly - * the point where that rider's code sat inside DeviceLink before the split, so the order of side - * effects is unchanged. Session adds calibration first, then HID: where both handle a moment - * (start's timers, a port opening), calibration's part came first. + * At each moment GatewayConnection calls its riders in the order they were added with `ride()`, + * at exactly the point where that rider's code sat inside DeviceLink before the split, so the + * order of side effects is unchanged. Session adds the serial monitor, calibration, HID, the node + * layer (NodeHub's rider) and the line tap, in that order: see app/session.ts for which rider takes + * part in which moment. */ export interface LinkRider { /** - * start(): the rider's timers, on the link's list between its serial flush and its roster poll. - * Same-period timers fire in creation order, so this position is the push order; and they are - * cleared with the link's own at shutdown. + * start(): the rider's timers, on the link's list after its 200 ms tick. Same-period timers fire + * in creation order, so this position is the push order; and they are cleared with the link's + * own at shutdown. */ timers?(every: LinkTimer): void - /** start(): after the link's timers, before the first connect. */ + /** start(): after the timers, before the first connect. */ start?(): void + /** shutdown(), disconnect(), and the port closing under the link: before anything else. */ + closing?(): void /** connect(): a new port, before any byte from it can arrive. */ attach?(): void /** teardown(): the port is being released. First, before the port stops. */ release?(): void - /** The port opened: after the link's own resets, its status push and the roster request. */ + /** teardown(): the port is released. Last. */ + released?(): void + /** The port opened: first, before the connected status. What the link carries starts over. */ + opening?(): void + /** The port opened: right after the connected status, before `opened`. */ + connected?(): void + /** The port opened: after the connected status and the `connected` riders. */ opened?(path: string): void /** - * The port closed under us: after the node layer noted it, before reconnecting or disconnecting. + * The port closed under us: after `closing`, before reconnecting or disconnecting. * `pass` sends bytes on down the stream, below the frame filter. */ closed?(pass: (held: Buffer) => void): void /** The link's 200 ms push tick: first, and while the link is down too. */ flush?(): void + /** The link's 200 ms push tick, after `flush`: only while the link is up. */ + tick?(): void /** shutdown(): after the port is released. */ stop?(): void } /** - * What DeviceLink needs from app (P6: it never asks electron): node-mapping.json as a port (for - * NodeHub), the debug log (NodeHub, SerialBridge), where event-log rows go (EventJournal), and the - * calibration frame filter. + * What GatewayConnection needs from app (P6: it never asks electron): the debug log + * (SerialBridge), the calibration frame filter, and the Gateway's health as the node layer follows + * it (NodeHub drives GatewayWatch, UD-C), which the connected status carries. */ -export interface DeviceLinkDeps { - mappingFile: MappingFileIO +export interface GatewayConnectionDeps { log: Log - /** Event-log rows: node changes and the Gateway's health (EventJournal.report). */ - report: (events: LogEventInput[]) => void frames: CalFrameFilter + health: () => GatewayStatus | undefined } -/** Node status, node model and the HID snapshot. Same cadence the relay used for them. */ +/** The link's push tick: node status, node model and the HID snapshot. */ const LINK_FLUSH_MS = 200 -const NODES_REFRESH_MS = 5000 -const SERIAL_FLUSH_MS = 50 -const MAX_SERIAL_BUF = 4000 - -/** One complete text line from the device, below the calibration de-mux. */ -export interface DeviceLine { - /** `node` lines are tapped to the roster / node layer; `command` lines are panel commands. */ - kind: 'node' | 'command' - identifier: string - arg: string - line: string - t: number -} type Unsubscribe = () => void @@ -123,36 +104,24 @@ function subscribe(set: Set, cb: T): Unsubscribe { return () => set.delete(cb) } -export class DeviceLink { - readonly roster = new NodeRoster() - /** A v2 (pre-#344) PanelBridge was reported this link: say it once, not on every row. */ - private legacyNodesWarned = false - /** Node telemetry, mapping and the combined node model (#61). */ - readonly nodes: NodeHub - /** The Gateway's own health (#94): its node-0 row, and whether it still answers. */ - private readonly gateway = new GatewayWatch() +export class GatewayConnection { private serial?: SerialBridge - private assembler = new LineAssembler() - private serialMonitor = false - private serialBuf: SerialFrame[] = [] private status: DeviceStatus = { state: 'disconnected' } private autoConnect = true private started = false private timers: ReturnType[] = [] - /** Calibration, then HID: see LinkRider. */ + /** In the order added: see LinkRider. */ private readonly riders: LinkRider[] = [] private readonly bytesCbs = new Set<(bytes: Buffer) => void>() - private readonly lineCbs = new Set<(line: DeviceLine) => void>() + private readonly trafficCbs = new Set<(dir: 'tx' | 'rx', chunk: Buffer) => void>() private readonly statusCbs = new Set<(status: DeviceStatus) => void>() private readonly errorCbs = new Set<(tag: string, message: string) => void>() constructor( private readonly emit: Emit, - private readonly deps: DeviceLinkDeps - ) { - this.nodes = new NodeHub(emit, this.roster, (events) => this.deps.report(events), deps) - } + private readonly deps: GatewayConnectionDeps + ) {} /** Add a rider. Riders are called in the order added; see LinkRider. */ ride(rider: LinkRider): void { @@ -163,15 +132,16 @@ export class DeviceLink { /** * Device traffic with calibration frames removed — the DCS-BIOS command stream, byte for byte. - * Fired whether or not anything relays it; forwarding is the relay's decision. + * Fired whether or not anything relays it; forwarding is the relay's decision. Subscribers are + * called in the order they subscribed. */ onBytes(cb: (bytes: Buffer) => void): Unsubscribe { return subscribe(this.bytesCbs, cb) } - /** Every complete text line, after the link's own node taps have seen it. */ - onLine(cb: (line: DeviceLine) => void): Unsubscribe { - return subscribe(this.lineCbs, cb) + /** Raw port traffic both ways, above the frame filter (the serial monitor's tap). */ + onTraffic(cb: (dir: 'tx' | 'rx', chunk: Buffer) => void): Unsubscribe { + return subscribe(this.trafficCbs, cb) } /** Each change of link state, also pushed to the renderer as `device:status`. */ @@ -196,10 +166,8 @@ export class DeviceLink { this.timers.push(setInterval(fn, ms)) } every(LINK_FLUSH_MS, () => this.flushLink()) - every(SERIAL_FLUSH_MS, () => this.flushSerial()) - for (const r of this.riders) r.timers?.(every) // calibration: cal:raw; HID: the rate window - // Poll the node roster so silent deaths get reconciled — relaying or not. - every(NODES_REFRESH_MS, () => this.requestNodes()) + // The serial monitor's flush, calibration's cal:raw, HID's rate window, the node roster poll. + for (const r of this.riders) r.timers?.(every) for (const r of this.riders) r.start?.() // HID: the reader if (autoConnect) this.connect() else this.setStatus({ state: 'disconnected' }) @@ -207,7 +175,7 @@ export class DeviceLink { /** App quit. */ shutdown(): void { - this.nodes.linkClosed() // remember when the boards on the bus were last seen (#93) + for (const r of this.riders) r.closing?.() // nodes: when the boards were last seen (#93) for (const t of this.timers) clearInterval(t) this.timers = [] this.teardown() @@ -238,7 +206,9 @@ export class DeviceLink { this.serial = s for (const r of this.riders) r.attach?.() // calibration: a CalLink for this port s.onData((chunk) => this.onSerialData(chunk)) - s.onMonitor((dir, chunk) => this.onSerialTraffic(dir, chunk)) + s.onMonitor((dir, chunk) => { + for (const cb of this.trafficCbs) cb(dir, chunk) + }) s.onOpen((path) => this.onOpen(path)) s.onClose(() => this.onClose()) s.onError((err) => { @@ -260,7 +230,7 @@ export class DeviceLink { * Calibration rides on the serial port, so it is unavailable until the link is back. */ disconnect(): void { - this.nodes.linkClosed() + for (const r of this.riders) r.closing?.() // nodes: when the boards were last seen (#93) this.teardown() this.setStatus({ state: 'disconnected' }) } @@ -270,33 +240,29 @@ export class DeviceLink { for (const r of this.riders) r.release?.() // calibration: its CalLink and what it held this.serial?.stop() this.serial = undefined - this.gateway.reset() + for (const r of this.riders) r.released?.() // nodes: the Gateway's health starts over } private onOpen(path: string): void { // A port open may be a different board, or the same one after an outage: node state starts // over, together. What survives is persisted (node-mapping.json) — names, adopted nodes, hidden // flags, last-seen times. Not on close: while unplugged the renderer shows the last state, dimmed. - this.roster.reset() - this.nodes.reset() - this.gateway.reset() - this.legacyNodesWarned = false - this.assembler = new LineAssembler() // a line cut by the unplug must not prefix the next one + // Nodes: roster, node data, the Gateway's health, the v2 warning; the line tap: its assembler. + for (const r of this.riders) r.opening?.() this.setStatus({ state: 'connected', portPath: path, vid: SIMGATEWAY_VID, pid: SIMGATEWAY_PID, - gateway: this.gateway.status() + gateway: this.deps.health() }) - this.gateway.takeChanged() // just sent - this.requestNodes() // seed the roster as soon as the device is up + for (const r of this.riders) r.connected?.() // nodes: health just sent; seed the roster // Calibration reads what the board holds (badges, with no dialog open), then HID reopens. for (const r of this.riders) r.opened?.(path) } private onClose(): void { - this.nodes.linkClosed() // the boards on the bus were last seen now (#93) + for (const r of this.riders) r.closing?.() // nodes: the boards were last seen now (#93) // Calibration fails what is in flight and hands back held bytes, which go on down the relay. for (const r of this.riders) r.closed?.((held) => this.fromDevice(held)) if (!this.autoConnect) { @@ -314,18 +280,40 @@ export class DeviceLink { this.serial?.write(data) } - /** Ask PanelBridge for the full node roster (inject the request export to the serial). */ - requestNodes(): void { - if (!this.serial || !isLinkUp(this.status.state)) return - this.roster.beginBurst() // isolate the reply burst so prior deltas don't block pruning - this.gateway.asked(Date.now()) - this.serial.write(Buffer.from(nodeRosterRequest())) - } - deviceStatus(): DeviceStatus { return this.status } + /** A port is held — looking for the board, opening or open — from connect() until released. */ + get attached(): boolean { + return !!this.serial + } + + /** A calibration exchange holds the link (the frame filter says so). */ + get exportGated(): boolean { + return this.deps.frames.exportGated + } + + /** + * USB serial of the attached gateway, for keying the calibration restore cache in #46. + * + * From SerialPort.list(), not node-hid: node-hid is an optional dependency that may be absent + * entirely, and HidReader opens by VID/PID without exposing a serial. Confirmed on hardware — + * the board reports 50031327805E871C. Absent or empty means no restore may be offered, since + * guessing which board a cached calibration belongs to is the failure the key exists to stop. + */ + async usbSerialNumber(): Promise { + try { + const path = await findSimGatewayPort() + if (!path) return undefined + const ports = await listSerialPorts() + const hit = ports.find((p) => p.path === path) + return hit?.serialNumber || undefined + } catch { + return undefined + } + } + // ── input ────────────────────────────────────────────────────────────────── private onSerialData(chunk: Buffer): void { @@ -333,12 +321,12 @@ export class DeviceLink { // that bites: the relay sends this on to DCS's command socket, and during a session RAW // arrives at up to ~250 frames/s — that binary has no business reaching DCS. Second, the // gateway injects frames at arbitrary byte boundaries, so one can land mid-line; removing its - // bytes is exactly what rejoins the interrupted line for the assembler below. + // bytes is exactly what rejoins the interrupted line for the line tap's assembler. this.fromDevice(this.deps.frames.ingest(chunk)) } /** - * Everything downstream of the de-mux: hand the bytes to the relay, assemble and tap lines. + * Everything downstream of the de-mux: hand the bytes to the relay, then to the line tap. * * Split out because it has a second caller. On port close the de-mux flushes any incomplete * candidate it was holding, and those bytes were never a frame — they belong to the DCS @@ -348,72 +336,13 @@ export class DeviceLink { private fromDevice(rest: Buffer): void { if (rest.length === 0) return for (const cb of this.bytesCbs) cb(rest) - const t = Date.now() - for (const raw of this.assembler.push(rest)) { - if (!raw.trim()) continue - // UART noise from a bridge reset can precede the first node-status row; cut it off. - const line = cleanNodeLine(raw) - const { identifier, arg } = parseCommand(line) - if (identifier === NODE_MSG && !this.legacyNodesWarned && isLegacyNodeStatus(arg.trim())) { - // Every v2 row would be dropped as malformed, leaving the Cockpit silently empty. - this.legacyNodesWarned = true - this.raise( - 'NODES', - 'The Gateway sends node status v2; this client needs v3 — update the Gateway firmware (OpenSkyhawk#344)' - ) - } - let kind: DeviceLine['kind'] = 'command' - // Node-status messages are tapped to the roster, not logged as panel commands. - if (identifier === NODE_MSG || identifier === NODE_END_MSG) { - this.deps.log('node', `${identifier} ${arg}`.trim()) - this.roster.applyMessage(identifier, arg, t) - this.nodes.statusLine(identifier, line.trim(), t) - this.gatewayLine(identifier, arg, line.trim(), t) - kind = 'node' - } else if (identifier === NODE_TELEM_MSG) { - // Node telemetry (~1/s per PDU) likewise: tapped, never logged as a panel command. - this.nodes.telemetry(arg, t, line.trim()) - kind = 'node' - } - for (const cb of this.lineCbs) cb({ kind, identifier, arg, line: line.trim(), t }) - } - } - - /** - * Any node-status line answers the roster request; the Gateway's own row (node 0) is its health. - * That row stays out of the cockpit model (node-model); this is where it is shown instead (#94). - */ - private gatewayLine(identifier: string, arg: string, raw: string, t: number): void { - this.deps.report(this.gateway.answered(raw, t)) - if (identifier !== NODE_MSG) return - const n = parseNodeStatus(arg.trim()) - if (n?.nodeId === BRIDGE_NODE_ID) - this.deps.report(this.gateway.report(withFaultInfo(n), raw, t)) } // ── renderer pushes ──────────────────────────────────────────────────────── - /** Toggle the raw serial monitor (gated to avoid streaming the export at full rate). */ - setSerialMonitor(on: boolean): void { - this.serialMonitor = on - if (!on) this.serialBuf = [] - } - - private onSerialTraffic(dir: 'tx' | 'rx', chunk: Buffer): void { - if (!this.serialMonitor) return - this.serialBuf.push({ t: Date.now(), dir, hex: chunk.toString('hex') }) - if (this.serialBuf.length > MAX_SERIAL_BUF) { - this.serialBuf.splice(0, this.serialBuf.length - MAX_SERIAL_BUF) - } - } - - private flushSerial(): void { - if (this.serialBuf.length === 0) return - this.emit('serial:traffic', this.serialBuf.splice(0, MAX_SERIAL_BUF)) - } - /** - * Node and HID pushes, relaying or not. + * The link's push tick: HID relaying or not, and — while the link is up — the node pushes and + * the Gateway's health (NodeHub). * * Node pushes stop while the link is down, deliberately with no reset and no empty model: the * renderer keeps the last state and dims it from `device:status`. A stale-telemetry transition @@ -422,13 +351,16 @@ export class DeviceLink { private flushLink(): void { for (const r of this.riders) r.flush?.() // HID: its snapshot, link up or not if (!isLinkUp(this.status.state)) return - this.nodes.flush(this.roster.takeDirty()) - this.deps.report(this.gateway.tick(Date.now(), this.deps.frames.exportGated)) - if (this.gateway.takeChanged()) { - // Health rides on `device:status`, but it is not a change of link state: no onStatus. - this.status = { ...this.status, gateway: this.gateway.status() } - this.emit('device:status', this.status) - } + for (const r of this.riders) r.tick?.() // nodes: node pushes, the Gateway's health + } + + /** + * The Gateway's health changed (NodeHub). Health rides on `device:status`, but it is not a + * change of link state: no onStatus. + */ + pushGatewayHealth(gateway: GatewayStatus | undefined): void { + this.status = { ...this.status, gateway } + this.emit('device:status', this.status) } private setStatus(status: DeviceStatus): void { diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts index 14ff086..0c978cc 100644 --- a/src/main/gateway/index.ts +++ b/src/main/gateway/index.ts @@ -1,10 +1,14 @@ // gateway: the SimGateway USB link. Exports only what other topics use (arch-phase0-v2 §2.1). export { - DeviceLink, + GatewayConnection, type CalFrameFilter, - type DeviceLinkDeps, + type GatewayConnectionDeps, type LinkRider, type LinkTimer -} from './device-link' +} from './gateway-connection' +export { DeviceLineTap, type DeviceLine, type NodeLineSink } from './device-line-tap' +export { SerialTrafficMonitor } from './serial-traffic-monitor' +export { GatewayWatch } from './gateway-health' +export { encodeExportFrame } from './link-framing' export { deviceEvent } from './link-events' -export { findSimGatewayPort, listSerialPorts } from './serial-bridge' +export { listSerialPorts } from './serial-bridge' diff --git a/src/main/gateway/serial-traffic-monitor.ts b/src/main/gateway/serial-traffic-monitor.ts new file mode 100644 index 0000000..d8bb3b8 --- /dev/null +++ b/src/main/gateway/serial-traffic-monitor.ts @@ -0,0 +1,43 @@ +// The serial monitor: the Serial tab's raw view of the Gateway port, both ways. Off unless the tab +// asks (it would otherwise stream the export at full rate); taps GatewayConnection.onTraffic, and +// flushes `serial:traffic` on its own timer on the link's list (LinkRider). +import type { SerialFrame } from '@shared/gateway' +import type { PushChannel, PushChannels } from '@shared/app' +import type { LinkRider, LinkTimer } from './gateway-connection' + +type Emit = (channel: C, payload: PushChannels[C]) => void + +const SERIAL_FLUSH_MS = 50 +const MAX_SERIAL_BUF = 4000 + +export class SerialTrafficMonitor implements LinkRider { + private serialMonitor = false + private serialBuf: SerialFrame[] = [] + + constructor(private readonly emit: Emit) {} + + /** The `serial:traffic` flush, on the link's timer list. */ + timers(every: LinkTimer): void { + every(SERIAL_FLUSH_MS, () => this.flushSerial()) + } + + /** Toggle the raw serial monitor (gated to avoid streaming the export at full rate). */ + setSerialMonitor(on: boolean): void { + this.serialMonitor = on + if (!on) this.serialBuf = [] + } + + /** One chunk of port traffic (GatewayConnection.onTraffic). */ + onSerialTraffic(dir: 'tx' | 'rx', chunk: Buffer): void { + if (!this.serialMonitor) return + this.serialBuf.push({ t: Date.now(), dir, hex: chunk.toString('hex') }) + if (this.serialBuf.length > MAX_SERIAL_BUF) { + this.serialBuf.splice(0, this.serialBuf.length - MAX_SERIAL_BUF) + } + } + + private flushSerial(): void { + if (this.serialBuf.length === 0) return + this.emit('serial:traffic', this.serialBuf.splice(0, MAX_SERIAL_BUF)) + } +} diff --git a/src/main/hid/calibration/hid-calibration-service.ts b/src/main/hid/calibration/hid-calibration-service.ts index bd95975..1b09f60 100644 --- a/src/main/hid/calibration/hid-calibration-service.ts +++ b/src/main/hid/calibration/hid-calibration-service.ts @@ -3,9 +3,9 @@ // cal* commands as discriminated CalResults, the restore cache, `cal:raw` batching, the // last-confirmed snapshot and the export gate. // -// It rides on the device link (gateway LinkRider) for the port's moments, and is the link's -// calibration frame filter (CalFrameFilter): DeviceLink runs it on every incoming chunk, and the -// codec's CalDemux inside CalLink takes the frames out. +// It rides on the Gateway connection (gateway LinkRider) for the port's moments, and is the link's +// calibration frame filter (CalFrameFilter): GatewayConnection runs it on every incoming chunk, and +// the codec's CalDemux inside CalLink takes the frames out. import type { CachedAxis, CachedBoard, @@ -17,13 +17,7 @@ import type { CalSnapshot } from '@shared/hid/calibration' import type { PushChannel, PushChannels } from '@shared/app' -import { - findSimGatewayPort, - listSerialPorts, - type CalFrameFilter, - type LinkRider, - type LinkTimer -} from '../../gateway' +import type { CalFrameFilter, LinkRider, LinkTimer } from '../../gateway' import { CalLink, CalNackError, CalTimeoutError, type Log } from './cal-link' import { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' import { boardFor, dropAxis, regressedAxes, storeAxes } from './cache-rules' @@ -35,11 +29,13 @@ type Emit = (channel: C, payload: PushChannels[C]) => voi const CAL_RAW_FLUSH_MS = 50 /** - * What the service needs from app: the write to the device (DeviceLink.write), the - * calibration-cache.json port, and the debug log. + * What the service needs from app: the write to the device and the board's USB serial + * (GatewayConnection.write / usbSerialNumber), the calibration-cache.json port, and the debug log. */ export interface HidCalibrationDeps { write: (data: Buffer) => void + /** USB serial of the attached gateway, the key of the restore cache (#46). */ + serialNumber: () => Promise cacheFile: CacheFileIO log: Log } @@ -145,7 +141,7 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { * relay is unaffected either way. */ private async readCalibrationState(): Promise { - this.calSerialNumber = await this.gatewaySerialNumber() + this.calSerialNumber = await this.deps.serialNumber() const hello = await this.calHello() if (!hello.ok) { this.deps.log('cal.absent', `no calibration channel: ${hello.message}`) @@ -171,26 +167,6 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { }) } - /** - * USB serial of the attached gateway, for keying the restore cache in #46. - * - * From SerialPort.list(), not node-hid: node-hid is an optional dependency that may be absent - * entirely, and HidReader opens by VID/PID without exposing a serial. Confirmed on hardware — - * the board reports 50031327805E871C. Absent or empty means no restore may be offered, since - * guessing which board a cached calibration belongs to is the failure the key exists to stop. - */ - private async gatewaySerialNumber(): Promise { - try { - const path = await findSimGatewayPort() - if (!path) return undefined - const ports = await listSerialPorts() - const hit = ports.find((p) => p.path === path) - return hit?.serialNumber || undefined - } catch { - return undefined - } - } - /** Convert a CalLink rejection into the discriminated result the renderer can branch on. */ private async calRun(fn: (link: CalLink) => Promise): Promise> { if (!this.cal) { diff --git a/src/main/hid/hid-service.ts b/src/main/hid/hid-service.ts index fc74a70..e14661e 100644 --- a/src/main/hid/hid-service.ts +++ b/src/main/hid/hid-service.ts @@ -1,7 +1,7 @@ // The SimGateway's HID joystick, as the app runs it (moved out of DeviceLink, R2.2a): the reader's // lifecycle, the throttled push to the renderer, the rate window, and the reopen when the serial -// port comes back. It rides on the device link (gateway LinkRider) for those moments; its I/O is -// the HID reader. +// port comes back. It rides on the Gateway connection (gateway LinkRider) for those moments; its +// I/O is the HID reader. import type { PushChannel, PushChannels } from '@shared/app' import type { LinkRider, LinkTimer } from '../gateway' import { HidReader } from './hid-reader' diff --git a/src/main/nodes/index.ts b/src/main/nodes/index.ts index 5557d6e..314d6e7 100644 --- a/src/main/nodes/index.ts +++ b/src/main/nodes/index.ts @@ -1,15 +1,5 @@ // nodes: the CAN node picture and the node mapping. Exports only what other topics use // (arch-phase0-v2 §2.1). -export { NodeHub, withFaultInfo } from './node-hub' +export { NodeHub } from './node-hub' export type { MappingFileIO } from './mapping-store' -export { - BRIDGE_NODE_ID, - cleanNodeLine, - isLegacyNodeStatus, - NODE_END_MSG, - NODE_MSG, - NodeRoster, - nodeRosterRequest, - parseNodeStatus -} from './node-status-wire' -export { NODE_TELEM_MSG } from './node-telem' +export { NodeRoster } from './node-status-wire' diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index 227c45a..b87445d 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -1,6 +1,8 @@ -// Node data layer in main: taps `_NODE_TELEM`, holds the node mapping (through MappingStore), and -// pushes the combined node picture. Kept out of app/session.ts so the session only feeds it lines -// and ticks. +// Node data layer in main: the node roster and its 5 s poll, the node lines the Gateway's line +// tap hands it (`_NODE_STATUS`, `_NODE_TELEM`), the Gateway's own health (it drives GatewayWatch, +// UD-C), the node mapping (through MappingStore), and the combined node picture it pushes. It +// rides on the Gateway connection (its `rider`, a gateway LinkRider) and writes the roster request +// through the connection's write (R2.2b). import type { AdoptSpec, MappedNode, @@ -18,6 +20,8 @@ import type { } from '@shared/nodes' import type { PushChannel, PushChannels } from '@shared/app' import type { LogEventInput } from '@shared/events' +import { isLinkUp, type DeviceStatus, type GatewayStatus } from '@shared/gateway' +import { GatewayWatch, type LinkRider, type NodeLineSink } from '../gateway' import { adoptNode, clearReportedHidden, @@ -42,8 +46,17 @@ import { isAdoptableKind } from '@shared/nodes/legacy-rules' import { buildNodeModel } from './node-model' import { NodeEventTracker } from './node-events' import { resolveKind } from './node-kind' -import { BRIDGE_NODE_ID, type NodeRoster } from './node-status-wire' -import { NodeTelemStore, parseNodeTelem } from './node-telem' +import { + BRIDGE_NODE_ID, + cleanNodeLine, + isLegacyNodeStatus, + NODE_END_MSG, + NODE_MSG, + nodeRosterRequest, + parseNodeStatus, + type NodeRoster +} from './node-status-wire' +import { NODE_TELEM_MSG, NodeTelemStore, parseNodeTelem } from './node-telem' import { registryDefaults } from './registry' import { MappingSaveError, MappingStore, type MappingFileIO } from './mapping-store' @@ -52,14 +65,41 @@ type Emit = (channel: C, payload: PushChannels[C]) => voi /** Port: the debug log, handed in by app (debug.log lives in app). */ export type Log = (tag: string, data?: unknown) => void -/** What NodeHub needs from app, through DeviceLink: the node-mapping.json port, and the log. */ +/** + * What NodeHub needs of the Gateway link (gateway GatewayConnection): whether a port is held and + * the link is up, the write the roster request goes out through, whether a calibration exchange + * holds the link, and where the Gateway's health is pushed. + */ +export interface NodeHubLink { + readonly attached: boolean + deviceStatus(): DeviceStatus + write(data: Buffer): void + readonly exportGated: boolean + pushGatewayHealth(gateway: GatewayStatus | undefined): void +} + +/** No link (a hub on its own, as the unit tests build one): nothing is requested. */ +const NO_LINK: NodeHubLink = { + attached: false, + deviceStatus: () => ({ state: 'disconnected' }), + write: () => {}, + exportGated: false, + pushGatewayHealth: () => {} +} + +/** What NodeHub needs from app: the node-mapping.json port, the log, and the Gateway link. */ export interface NodeHubDeps { mappingFile?: MappingFileIO log?: Log + link?: NodeHubLink } /** Shortest gap between `nodes:telemetry` pushes. ~3 PDUs send ~3 frames/s between them. */ const NODE_TELEM_PUSH_MS = 1000 +/** The roster poll. */ +const NODES_REFRESH_MS = 5000 + +type Unsubscribe = () => void /** Fault-dictionary enrichment: the abbr, label and description for the node's faultId. */ /** @@ -85,8 +125,15 @@ export function withFaultInfo(n: NodeStatus): NodeStatus { } } -export class NodeHub { +export class NodeHub implements NodeLineSink { private readonly telem = new NodeTelemStore() + /** The Gateway's own health (#94): its node-0 row, and whether it still answers. */ + private readonly gateway = new GatewayWatch() + /** A v2 (pre-#344) PanelBridge was reported this link: say it once, not on every row. */ + private legacyNodesWarned = false + private readonly link: NodeHubLink + private readonly log: Log + private readonly errorCbs = new Set<(tag: string, message: string) => void>() private readonly defaults = registryDefaults(NODE_NAMES) private telemPushedAt = 0 private modelDirty = true @@ -114,6 +161,8 @@ export class NodeHub { private readonly onEvents: (events: LogEventInput[]) => void = () => {}, deps: NodeHubDeps = {} ) { + this.link = deps.link ?? NO_LINK + this.log = deps.log ?? (() => {}) this.store = new MappingStore({ file: deps.mappingFile, log: deps.log, @@ -127,6 +176,100 @@ export class NodeHub { return this.store.mapping() } + /** Faults worth showing the user: a v2 Gateway (the same path as the link's own). */ + onError(cb: (tag: string, message: string) => void): Unsubscribe { + this.errorCbs.add(cb) + return () => this.errorCbs.delete(cb) + } + + // ── the link's moments (gateway LinkRider) ──────────────────────────────── + + /** NodeHub's part of the Gateway connection's moments. Session rides it after HID. */ + readonly rider: LinkRider = { + // Poll the node roster so silent deaths get reconciled — relaying or not. + timers: (every) => every(NODES_REFRESH_MS, () => this.requestNodes()), + // Shutdown, Disconnect, unplug: the boards on the bus were last seen now (#93). + closing: () => this.linkClosed(), + released: () => this.gateway.reset(), + // A port open: the roster, the node data and the Gateway's health start over, together. + opening: () => { + this.roster.reset() + this.reset() + this.gateway.reset() + this.legacyNodesWarned = false + }, + connected: () => { + this.gateway.takeChanged() // just sent, on the connected status + this.requestNodes() // seed the roster as soon as the device is up + }, + // The link's push tick, while it is up: node pushes, then the Gateway's health. + tick: () => { + this.flush(this.roster.takeDirty()) + this.onEvents(this.gateway.tick(Date.now(), this.link.exportGated)) + if (this.gateway.takeChanged()) this.link.pushGatewayHealth(this.gateway.status()) + } + } + + /** The Gateway's health as it stands, for the connected status. */ + gatewayHealth(): GatewayStatus | undefined { + return this.gateway.status() + } + + /** Ask PanelBridge for the full node roster (inject the request export to the serial). */ + requestNodes(): void { + if (!this.link.attached || !isLinkUp(this.link.deviceStatus().state)) return + this.roster.beginBurst() // isolate the reply burst so prior deltas don't block pruning + this.gateway.asked(Date.now()) + this.link.write(Buffer.from(nodeRosterRequest())) + } + + // ── node lines (gateway NodeLineSink) ───────────────────────────────────── + + cleanLine(raw: string): string { + return cleanNodeLine(raw) + } + + /** One line from the Gateway's line tap. True for a node line: tapped here, not a command. */ + nodeLine(identifier: string, arg: string, line: string, t: number): boolean { + if (identifier === NODE_MSG && !this.legacyNodesWarned && isLegacyNodeStatus(arg.trim())) { + // Every v2 row would be dropped as malformed, leaving the Cockpit silently empty. + this.legacyNodesWarned = true + this.raise( + 'NODES', + 'The Gateway sends node status v2; this client needs v3 — update the Gateway firmware (OpenSkyhawk#344)' + ) + } + // Node-status messages are tapped to the roster, not logged as panel commands. + if (identifier === NODE_MSG || identifier === NODE_END_MSG) { + this.log('node', `${identifier} ${arg}`.trim()) + this.roster.applyMessage(identifier, arg, t) + this.statusLine(identifier, line, t) + this.gatewayLine(identifier, arg, line, t) + return true + } + if (identifier === NODE_TELEM_MSG) { + // Node telemetry (~1/s per PDU) likewise: tapped, never logged as a panel command. + this.telemetry(arg, t, line) + return true + } + return false + } + + /** + * Any node-status line answers the roster request; the Gateway's own row (node 0) is its health. + * That row stays out of the cockpit model (node-model); this is where it is shown instead (#94). + */ + private gatewayLine(identifier: string, arg: string, raw: string, t: number): void { + this.onEvents(this.gateway.answered(raw, t)) + if (identifier !== NODE_MSG) return + const n = parseNodeStatus(arg.trim()) + if (n?.nodeId === BRIDGE_NODE_ID) this.onEvents(this.gateway.report(withFaultInfo(n), raw, t)) + } + + private raise(tag: string, message: string): void { + for (const cb of this.errorCbs) cb(tag, message) + } + /** New session: telemetry and first-seen times start over; the mapping persists. */ reset(): void { this.telem.reset() diff --git a/src/main/nodes/node-status-wire.ts b/src/main/nodes/node-status-wire.ts index c557083..a778af2 100644 --- a/src/main/nodes/node-status-wire.ts +++ b/src/main/nodes/node-status-wire.ts @@ -15,9 +15,7 @@ import { NODE_STATUS_ROW_BYTES } from '@shared/firmware' import type { NodeSeen, NodeStatus } from '@shared/nodes' -// Deep import, not '../gateway': the gateway index reaches back into nodes through DeviceLink -// until R2.2 splits it, so going through the index would close an import cycle. -import { encodeExportFrame } from '../gateway/link-framing' +import { encodeExportFrame } from '../gateway' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 569b5b8..b982eb1 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -226,20 +226,19 @@ function launch(opts: ServicesOptions) { [CTRL.relayStart]: () => session.start(), [CTRL.relayStop]: () => session.stop(), [CTRL.relayStatus]: () => session.status(), - [CTRL.deviceConnect]: () => session.link.connect(), - [CTRL.deviceDisconnect]: () => session.link.disconnect(), - [CTRL.nodesRefresh]: () => session.link.requestNodes(), - [CTRL.nodesGet]: () => session.link.nodes.snapshot(), - [CTRL.nodeMappingSet]: (id: number, patch: MappingPatch) => - session.link.nodes.setMapping(id, patch), - [CTRL.nodeMappingAdopt]: (id: number, spec: AdoptSpec) => session.link.nodes.adopt(id, spec), - [CTRL.nodeMappingForget]: (id: number) => session.link.nodes.forget(id), - [CTRL.nodeMappingResetNode]: (id: number) => session.link.nodes.resetBoard(id), - [CTRL.nodeMappingReset]: (opts?: ResetOptions) => session.link.nodes.resetAll(opts), - [CTRL.nodeMappingResetPreview]: () => session.link.nodes.previewReset(), - [CTRL.nodeRegistry]: () => session.link.nodes.registry(), - [CTRL.nodeMappingHide]: (id: number, on: boolean) => session.link.nodes.hide(id, on), - [CTRL.serialMonitor]: (on: boolean) => session.link.setSerialMonitor(on), + [CTRL.deviceConnect]: () => session.connection.connect(), + [CTRL.deviceDisconnect]: () => session.connection.disconnect(), + [CTRL.nodesRefresh]: () => session.nodes.requestNodes(), + [CTRL.nodesGet]: () => session.nodes.snapshot(), + [CTRL.nodeMappingSet]: (id: number, patch: MappingPatch) => session.nodes.setMapping(id, patch), + [CTRL.nodeMappingAdopt]: (id: number, spec: AdoptSpec) => session.nodes.adopt(id, spec), + [CTRL.nodeMappingForget]: (id: number) => session.nodes.forget(id), + [CTRL.nodeMappingResetNode]: (id: number) => session.nodes.resetBoard(id), + [CTRL.nodeMappingReset]: (opts?: ResetOptions) => session.nodes.resetAll(opts), + [CTRL.nodeMappingResetPreview]: () => session.nodes.previewReset(), + [CTRL.nodeRegistry]: () => session.nodes.registry(), + [CTRL.nodeMappingHide]: (id: number, on: boolean) => session.nodes.hide(id, on), + [CTRL.serialMonitor]: (on: boolean) => session.serialMonitor.setSerialMonitor(on), [CTRL.eventsGet]: () => session.events.snapshot(), [CTRL.eventsClear]: () => session.events.clear() } From 62289475842df4cb6623c9b464c897076a297020 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 23:23:23 -0400 Subject: [PATCH 15/28] refactor(dcs): move the DCS owners out of Session (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.3a of the structure rewrite (#101, phase #99). This is the DCS half of R2.3. Session's relay, stream and capture code moves, verbatim, to the owners in the decision table (DCS side, out of Session). Settings, the event log, AppServices and the per-topic IPC are left for R2.3b. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port and to DCS, the files written, the timers and the order of all of them are the same. The trace proof below checks this. ## Tests written first Each one is its own commit, before the refactor. Each was shown to fail, then committed. - **B107: DCS link transports** (`dcs/transports.seam.test.ts`, 10 tests). - **TCP to host** runs on real sockets on 127.0.0.1. It covers connect, export in and commands out on one socket, a command queued while connecting, the 2 s reconnect after DCS drops the socket, and a refused connect (an error, then a close, retried every 2 s until Stop). It also covers a DCS that comes up after Start, and the close after Stop being reported asynchronously. - **Unicast listen** runs on real sockets on 127.0.0.1. It covers bind, then connected, export in, and commands to host:commandPort. It also covers Stop reporting the close synchronously, sharing the port with another reuseAddr listener, and a port held exclusively (`EADDRINUSE`, never connected). - **Loopback multicast** runs on a stand-in socket: whether a CI runner can join a group is not ours to pin. It covers the export port bound with reuseAddr (not listenPort), the group join, then connected. A failed join is an error and still reports connected. Socket errors pass through, and commands go to host:commandPort. - **It can fail.** One mutation at a time: - TCP reconnect delay 2000 → 1000: 1 test fails; - TCP retrying after Stop: 1; - multicast binding listenPort: 1; - no `connected` after the join: 2; - UDP Stop not reporting the close: 3; - socket without reuseAddr: 2. - **B108: the stats tick** (`dcs/stream-stats.seam.test.ts`, 7 tests). This runs through `makeServices()`, so it survives the move. - It covers: - one `stats:tick` a second, only while relaying; - bytes in and decoded frames per second; - command rate, total and last command, and bytes out to DCS in Bridge; - commands counted in Monitor with nothing sent; - errors and reconnects from both DCS and the Gateway link; - uptime; - a fresh start on every Start, and on a settings change that rebuilds the relay. - **It can fail.** One mutation at a time: - `reset` keeping errors: 2 tests fail; - total not counted: 2; - `STATS_MS` 1001: 6; - link `reconnecting` not counted: 1; - bytes out not counted: 1. ## What moved where | From Session | To | Notes | |---|---|---| | `start`, `run`, `stop`, `setRelay`, `.running`, `.relay`, `.timers`, `.replay`, `.parser`, `.decoder`, `onExport`, `fromDevice`, `onWrite`, `TELEMETRY_MS`, the telemetry and stats ticks | `dcs/dcs-relay.ts` **DcsRelay** | Bodies verbatim. `this.config` is a getter over the `RelayOptions` port; `this.events` is a `RelayEventSink` port (see below) | | the `running && relayConfigChanged → stop(); run()` block of `setConfig` | **DcsRelay** `reconfigure(prev, next)` | `run` stays private. Session's `setConfig` calls it at the same point | | `loadReplay` | **DcsRelay** `loadReplay` | See *Not as briefed* | | `.transport`, its creation and subscriptions in `run`, the stale guards, its stop in `stop`, `send` in `fromDevice` | `dcs/dcs-connection.ts` **DcsConnection** `open` / `start` / `stop` / `send` | `open` builds and subscribes; `start` binds or dials. In between, the relay sets `running` and `connecting`, as before | | `exportToDevice`, `deviceToDcs`, `RELAY_KEYS`, `relayConfigChanged`, `ForwardConfig` | `dcs/relay-rules.ts` | Adds `RelayOptions` = `Pick`, which is exactly what the relay reads. `relayConfigChanged` is generic over it, so `AppConfig` still passes | | `relayContext` | `dcs/relay-events.ts` | Next to `relayEvents` (X9) | | `.logBuf`, `.logTimer`, `flushLog`, `LOG_FLUSH_MS`, `MAX_BATCH`, the `lines.onLine` command-row body | `dcs/stream-log.ts` **StreamLog** `open` / `close` / `onLine` / `rows` / `flushLog` | `flushLog` still emits `events:batch` (drained from the EventLog it is handed) before `log:batch`, until R2.3b splits it. `open()` returns false when already open, so `Session.open` keeps its early return | | `.recorder`, `.recordPath`, `startRecording`, `stopRecording`, `isRecording`; `Recorder` (from `replay-source.ts`) | `dcs/session-recorder.ts` **SessionRecorder** | `record(dir, chunk)` is `recorder?.record` | | `flushTelemetry` | `dcs/sim-telemetry-decoder.ts` `flushTelemetry(decoder, emit)` | Next to the decoder. The relay's 200 ms tick reads `this.decoder` at call time, as before | | `STATS_MS` | `dcs/stream-stats.ts` | | | `config`, `getConfig`, `setConfig`, `events`, `lastErr`, `logError`, `lastDevice`, `status()`, `open`, `shutdown`, the wiring | stay in **Session** | `status()` reads `{ running, relay }` from `relay.status()` | `createTransport` and the transports now take `TransportOptions`, the four settings they read. This is a type-only change. **Ports.** DcsRelay writes to the board through `RelayDevice` (`deviceStatus`, `write`, `exportGated`), which Session fills with the GatewayConnection itself. `connection.exportGated` is the same `calibration.exportGated` getter that Session read, one hop further, so `dcs` does not import `hid`. Session fills `RelayEventSink` with `events.add`, `logError` and `lastErr.delete`. **Callers change their receiver only.** - `app/index.ts` and `test-harness/services.ts` use `session.relay.start()`, `.stop()` and `.loadReplay()`, and `session.recorder.*`. - `session.test.ts` changes 24 `session.start()` / `.stop()` lines to `session.relay.*`, and imports the three rules from `../dcs/relay-rules`. - Arguments and assertions are unchanged. The `makeServices()` seam tests and the component tests are untouched. The `dcs` index now exports only what `app` uses: `DcsConnection`, `DcsRelay`, `SessionRecorder`, `Stats`, `StreamLog`. ## Wiring and the order it keeps Session constructs, in this order: calibration, HID, GatewayConnection, NodeHub, SerialTrafficMonitor, DeviceLineTap, then **SessionRecorder, StreamLog, DcsConnection and DcsRelay**. None of the new constructors has a side effect. The parser and the first decoder are built inside DcsRelay, as they were inside Session. The subscriptions are unchanged, in the same order: - `onTraffic`; - `onBytes`: `relay.fromDevice`, then `lines.push`; - `lines.onLine`: `streamLog.onLine`; - `onError` ×2; - `journal.onEvents`; - `onStatus`. The DCS side, now written in the constructor comment too: | Moment | Order (unchanged from Session) | |---|---| | `open()` | stream-log flush (33) first, then `connection.start` | | relay Start | Bridge / driven Replay refused unless the link is up → a running relay stopped → decoder, parser, stats reset → DCS error forgotten → Replay: `connected`, playback; otherwise the transport built and subscribed → `connecting` → transport start; then the telemetry (200) and stats (1000) ticks | | relay Stop | ticks cleared → replay stopped → transport forgotten, then stopped → stream log flushed → `stopped` | | export chunk | stats in → parser (named rows: stats frames, then the log) → recorder → device write (Bridge / driven Replay, not while calibration gates it) | | `setConfig` | auto-connect → the relay rebuilt if a setting it is built from changed | | `shutdown()` | relay Stop → `connection.shutdown` → stream-log flush cleared | ## Proof - **Side-effect trace (not committed).** This is the R2.2b harness, extended. It drives Session with every edge faked under fake timers. It records, in order: emits, the serial lifecycle and writes, DCS transport lifecycle and sends, the HID lifecycle, timer create/clear, debug-log calls, fs writes, returned values and the final `` files. - **Changes to the harness:** - relay and recorder intents resolve through accessors that work at both base and head; - the fake transport can be told to throw on construction. - **New scenarios:** - **relay-bridge**: start, both directions, DCS drop and back, the same error twice, stop, stop again, restart; - **relay-monitor**: no board, then a board, still read-only; - **start-refused**: scanning, no-device, reconnecting, a drop while running, a relay setting changed during the drop (rebuilt, not refused), a released link, driven Replay with no board, undriven Replay with no capture; - **replay-undriven**: no capture, load, play to the end, restart, reload mid-play, a bad capture; - **replay-driven**: refused, then plays into the board, with a calibration exchange gating the forward; - **record-during-relay**: record across start, export and commands; across a stop; restarted; - **dcs-reconnect**: refused before ever up, up and dropping, the error being news again, stale transport callbacks after a rebuild and after Stop; - **config-while-running**: non-relay settings, transport, listen and command port, to Bridge with no board, to Replay with nothing loaded (the relay ends), stopped, a transport that throws on Start and on a rebuild, into a loaded Replay; - **commands-stopped-running**: commands while stopped and running in Bridge, a burst over one flush, Monitor. - **Result:** base (f667979) and head outputs are **byte-identical**, with the same sha1 (`1e67f88f…`): 22 scenarios (13 from R2.2b + 9 new) and 4,639 recorded lines (1,699 emits, 583 timer, 389 DCS, 314 HID, 282 serial, 147 returned values, 115 debug-log, 40 fs). Base is deterministic over two runs. - **It can fail.** One deliberate reorder at a time: - Stop flushing the log after `stopped` instead of before: 82 diff lines; - the stats tick created before the telemetry tick: 116; - the transport stopped before it is forgotten: 76; - the log flush not draining the event log: 84. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with `SKYHAWK_USER_DATA` set and with the dev default: - base and head probe outputs are byte-identical; - `config.json` and `node-mapping.json` have the same sha1s; - `debug.log` is identical apart from its timestamp. - **Tests.** 69 files, 906 tests: the 889 from before plus B107 (10) and B108 (7). The existing assertions are unchanged. ## Not as briefed - `loadReplay` is on **DcsRelay**, not on ReplaySource. It swaps the capture in the slot that `run()` plays and `stop()` stops, so it lives with that slot. ReplaySource keeps `load` / `info` / `play` / `stop` unchanged. - `flushLog` still drains the event log (`events:batch`), because Session keeps the event log in R2.3a. StreamLog is handed the EventLog's `take`. R2.3b splits it. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before. - `npm run lint:deps`: 0 errors, 15 warnings. The list is identical to base (diffed). There are no new warnings: `dcs` imports only `gateway` and `events` (types: `DeviceLine`, `EventLog`) and the shared contracts. - `npm run build`: OK. Refs #99 --- src/main/app/index.ts | 14 +- src/main/app/session.test.ts | 51 ++-- src/main/app/session.ts | 321 +++++------------------- src/main/dcs/dcs-connection.ts | 46 ++++ src/main/dcs/dcs-relay.ts | 220 +++++++++++++++++ src/main/dcs/index.ts | 9 +- src/main/dcs/relay-events.ts | 17 +- src/main/dcs/relay-rules.ts | 38 +++ src/main/dcs/replay-source.ts | 32 +-- src/main/dcs/session-recorder.ts | 56 +++++ src/main/dcs/sim-telemetry-decoder.ts | 13 + src/main/dcs/stream-log.ts | 59 +++++ src/main/dcs/stream-stats.seam.test.ts | 165 +++++++++++++ src/main/dcs/stream-stats.ts | 3 + src/main/dcs/transports.seam.test.ts | 326 +++++++++++++++++++++++++ src/main/dcs/transports.ts | 13 +- src/main/gateway/device-line-tap.ts | 2 +- src/main/gateway/gateway-connection.ts | 2 +- src/main/test-harness/services.ts | 6 +- 19 files changed, 1050 insertions(+), 343 deletions(-) create mode 100644 src/main/dcs/dcs-connection.ts create mode 100644 src/main/dcs/dcs-relay.ts create mode 100644 src/main/dcs/relay-rules.ts create mode 100644 src/main/dcs/session-recorder.ts create mode 100644 src/main/dcs/stream-log.ts create mode 100644 src/main/dcs/stream-stats.seam.test.ts create mode 100644 src/main/dcs/transports.seam.test.ts diff --git a/src/main/app/index.ts b/src/main/app/index.ts index 40b993c..60250d9 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -134,8 +134,8 @@ function registerIpc(): void { ipcMain.handle(CTRL.configImportApply, (_e, token: string, sections: unknown, backup: unknown) => transfer.apply(token, sections, backup) ) - ipcMain.handle(CTRL.relayStart, () => session.start()) - ipcMain.handle(CTRL.relayStop, () => session.stop()) + ipcMain.handle(CTRL.relayStart, () => session.relay.start()) + ipcMain.handle(CTRL.relayStop, () => session.relay.stop()) ipcMain.handle(CTRL.relayStatus, () => session.status()) ipcMain.handle(CTRL.deviceConnect, () => session.connection.connect()) ipcMain.handle(CTRL.deviceDisconnect, () => session.connection.disconnect()) @@ -213,8 +213,8 @@ function registerIpc(): void { }) ipcMain.handle(CTRL.captureToggle, async (): Promise => { - if (session.isRecording()) { - const { path, events } = session.stopRecording() + if (session.recorder.isRecording()) { + const { path, events } = session.recorder.stopRecording() return { recording: false, path, events } } const res = await dialog.showSaveDialog({ @@ -223,7 +223,7 @@ function registerIpc(): void { filters: CAPTURE_FILTER }) if (res.canceled || !res.filePath) return { recording: false } - session.startRecording(res.filePath) + session.recorder.startRecording(res.filePath) return { recording: true, path: res.filePath } }) @@ -236,7 +236,7 @@ function registerIpc(): void { const path = res.filePaths[0] if (res.canceled || !path) return { loaded: false } try { - const info = session.loadReplay(path) + const info = session.relay.loadReplay(path) return { loaded: true, path, events: info.events, durationMs: info.durationMs } } catch { return { loaded: false } @@ -291,7 +291,7 @@ app.whenReady().then(() => { }) app.on('window-all-closed', () => { - session.stop() + session.relay.stop() if (process.platform !== 'darwin') app.quit() }) diff --git a/src/main/app/session.test.ts b/src/main/app/session.test.ts index 9a8677b..6d50fa0 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/session.test.ts @@ -126,7 +126,8 @@ vi.mock('../hid/hid-reader', () => ({ } })) -const { Session, deviceToDcs, exportToDevice, relayConfigChanged } = await import('./session') +const { Session } = await import('./session') +const { deviceToDcs, exportToDevice, relayConfigChanged } = await import('../dcs/relay-rules') const { PortNotFoundError } = await import('../gateway/serial-bridge') const { nodeRosterRequest } = await import('../nodes/node-status-wire') const { DEFAULT_CONFIG } = await import('@shared/config') @@ -138,7 +139,7 @@ function bridgeSession() { session.setConfig({ sourceMode: 'bridge' }) session.open() FakeSerial.last!.openCb('/dev/fake') // a Bridge relay starts only with the board connected - session.start() + session.relay.start() const serial = FakeSerial.last! const transport = FakeTransport.last! transport.sent.length = 0 // drop the roster request seeded on open @@ -216,7 +217,7 @@ describe('node telemetry tap', () => { session.open() const serial = FakeSerial.last! serial.openCb('/dev/fake') // node pushes flow only while the link is up - session.start() + session.relay.start() const transport = FakeTransport.last! transport.sent.length = 0 @@ -266,7 +267,7 @@ describe('HID reporting latency', () => { }) session.setConfig({ sourceMode: 'bridge' }) session.open() - session.start() + session.relay.start() const { HidReader } = (await import('../hid/hid-reader')) as unknown as { HidReader: { last?: { fire(b: boolean[]): void } } @@ -358,8 +359,8 @@ describe('device link is independent of the relay (#69)', () => { it('leaves serial, HID and calibration up when the relay stops', async () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.start() - session.stop() + session.relay.start() + session.relay.stop() expect(serial.stopped).toBe(false) expect((await HID()).stopped).toBe(false) expect(session.status().device.state).toBe('connected') @@ -401,8 +402,8 @@ describe('device link is independent of the relay (#69)', () => { try { const { session, serial, of } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.start() - session.stop() + session.relay.start() + session.relay.stop() serial.dataCb(ascii('_NODE_TELEM 030100E02E182EBC02\n')) vi.advanceTimersByTime(250) expect(of('nodes:telemetry')).toHaveLength(1) @@ -526,11 +527,11 @@ describe('device link is independent of the relay (#69)', () => { it('refuses Start until the board is connected when the mode delivers to it', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) - expect(session.start()).toEqual({ ok: false, error: 'Gateway is not connected' }) + 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() serial.openCb('/dev/fake') - expect(session.start().ok).toBe(true) + expect(session.relay.start().ok).toBe(true) session.shutdown() }) @@ -538,7 +539,7 @@ describe('device link is independent of the relay (#69)', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') session.connection.disconnect() - expect(session.start().ok).toBe(false) + expect(session.relay.start().ok).toBe(false) expect(FakeSerial.last, 'Start leaves the link alone').toBe(serial) expect(session.status().device.state).toBe('disconnected') session.shutdown() @@ -546,22 +547,22 @@ describe('device link is independent of the relay (#69)', () => { it('lets Monitor and an undriven Replay start with no board', () => { const monitor = launched({ sourceMode: 'monitor' }) - expect(monitor.session.start().ok).toBe(true) + expect(monitor.session.relay.start().ok).toBe(true) monitor.session.shutdown() const driven = launched({ sourceMode: 'replay', replayDriveSerial: true }) - expect(driven.session.start()).toEqual({ ok: false, error: 'Gateway is not connected' }) + expect(driven.session.relay.start()).toEqual({ ok: false, error: 'Gateway is not connected' }) 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.start()).toEqual({ ok: false, error: 'No capture loaded' }) + expect(plain.session.relay.start()).toEqual({ ok: false, error: 'No capture loaded' }) plain.session.shutdown() }) it('does not end a running relay when a setting changes during a drop', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.start() + session.relay.start() serial.closeCb() // the board drops; the relay waits session.setConfig({ host: '10.0.0.2' }) // rebuilds the relay expect(session.status().running, 'only Stop ends a relay').toBe(true) @@ -571,7 +572,7 @@ describe('device link is independent of the relay (#69)', () => { it('keeps the relay running through a device drop', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.start() + session.relay.start() serial.closeCb() expect(session.status().running, 'only Stop relay ends the relay').toBe(true) session.shutdown() @@ -587,7 +588,7 @@ describe('relay forwarding by source mode (#69)', () => { it('Monitor is read-only even though the port is open', () => { const { session, serial } = launched({ sourceMode: 'monitor' }) serial.openCb('/dev/fake') - session.start() + session.relay.start() const transport = FakeTransport.last! serial.written.length = 0 transport.exportCb(Buffer.from([0x55, 0x55, 0x55, 0x55])) @@ -600,7 +601,7 @@ describe('relay forwarding by source mode (#69)', () => { it('Bridge forwards both ways while relaying', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.start() + session.relay.start() const transport = FakeTransport.last! serial.written.length = 0 transport.exportCb(Buffer.from([0x55, 0x55])) @@ -649,7 +650,7 @@ describe('relay forwarding by source mode (#69)', () => { expect(relayConfigChanged(base, { ...base, debugMode: true })).toBe(false) const { session } = launched() - session.start() + session.relay.start() const first = FakeTransport.last session.setConfig({ autoConnect: false }) expect(FakeTransport.last, 'toggling auto-connect must not restart the relay').toBe(first) @@ -660,13 +661,13 @@ describe('relay forwarding by source mode (#69)', () => { describe('relay state (#69)', () => { it('reports the stream separately from the device', () => { const { session, of } = launched({ sourceMode: 'monitor' }) - session.start() + session.relay.start() expect(of('relay:state').at(-1)).toEqual({ running: true, stream: 'connecting' }) FakeTransport.last!.connectedCb(true) expect(of('relay:state').at(-1)).toEqual({ running: true, stream: 'connected' }) FakeTransport.last!.connectedCb(false) expect(of('relay:state').at(-1)).toEqual({ running: true, stream: 'reconnecting' }) - session.stop() + session.relay.stop() expect(of('relay:state').at(-1)).toEqual({ running: false }) expect(session.status().relay).toEqual({ running: false }) expect(session.status().device.state, 'the device knows nothing of the relay').toBe('scanning') @@ -849,7 +850,7 @@ describe('event log (#64)', () => { it('a retry loop is one row per error, even with two loops interleaved', () => { const { session, serial } = launched({ sourceMode: 'monitor' }) - session.start() + session.relay.start() const t = FakeTransport.last! for (let i = 0; i < 3; i++) { serial.errorCb(new Error('Cannot lock port')) @@ -866,7 +867,7 @@ describe('event log (#64)', () => { it('a DCS link that came up and dropped reports its next error again (review of #86)', () => { const { session } = launched({ sourceMode: 'monitor' }) - session.start() + session.relay.start() const t = FakeTransport.last! t.errorCb(new Error('connect ECONNREFUSED')) // started before DCS t.connectedCb(true) // DCS starts: link up @@ -900,9 +901,9 @@ describe('event log (#64)', () => { it('records relay start and stop', () => { const { session, serial } = launched({ sourceMode: 'monitor' }) serial.openCb('/dev/fake') - session.start() + session.relay.start() FakeTransport.last!.connectedCb(true) - session.stop() + session.relay.stop() expect(rows(session).slice(1)).toEqual([ 'client:Relay: started · Monitor mode', 'client:DCS: link up · 127.0.0.1:7778', diff --git a/src/main/app/session.ts b/src/main/app/session.ts index 14acea9..8e68629 100644 --- a/src/main/app/session.ts +++ b/src/main/app/session.ts @@ -1,34 +1,16 @@ -// The relay (#69): the DCS-BIOS stream, and the one on/off. Orchestrates a live source — -// transport -> protocol parser -> decoder -> stats — batching pushes to the renderer. -// Source-agnostic across Bridge (DCS <-> device), Monitor (LAN, read-only) and Replay (recorded -// capture); recording can tap any live mode. +// The main process's services, built and wired: the Gateway link (gateway/), the nodes (nodes/), +// HID and calibration (hid/), and the DCS side — the relay, its network end, the stream log, the +// stats and the recorder (dcs/). Session owns the settings, the event log and the rehydrate +// bundle; everything else lives with its owner, and Session only connects them. // -// The device link is not started or stopped here. It is up whenever the board is plugged in -// (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and +// The device link is not started or stopped by the relay. It is up whenever the board is plugged +// in (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and // whether it reaches the device. -import { - EXPORT_MULTICAST_ADDR, - EXPORT_PORT, - type LogRow, - type RelayResult, - type RelayState -} from '@shared/dcs' import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' -import { isLinkUp, type DeviceState, type DeviceStatus } from '@shared/gateway' +import type { DeviceState, DeviceStatus } from '@shared/gateway' import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' import { app } from 'electron' -import { - createTransport, - DcsBiosProtocol, - Decoder, - Recorder, - relayEvents, - ReplaySource, - Stats, - type RelayContext, - type ReplayInfo, - type Transport -} from '../dcs' +import { DcsConnection, DcsRelay, SessionRecorder, Stats, StreamLog } from '../dcs' import { clientErrorEvent, EventJournal, EventLog } from '../events' import { deviceEvent, DeviceLineTap, GatewayConnection, SerialTrafficMonitor } from '../gateway' import { HidService } from '../hid' @@ -39,54 +21,6 @@ import { storeFiles, storePaths, type StorePaths } from './user-data' type Emit = (channel: C, payload: PushChannels[C]) => void -const LOG_FLUSH_MS = 33 -const TELEMETRY_MS = 200 -const STATS_MS = 1000 -const MAX_BATCH = 250 - -type ForwardConfig = Pick - -/** - * Whether the export stream is written to the SimGateway — and so whether Start needs the board. - * - * 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 - * it had no port to write to. - */ -export function exportToDevice(cfg: ForwardConfig): boolean { - return cfg.sourceMode === 'bridge' || (cfg.sourceMode === 'replay' && cfg.replayDriveSerial) -} - -/** Whether device traffic goes on to DCS: only a running Bridge relay has a DCS to send it to. */ -export function deviceToDcs(cfg: ForwardConfig, running: boolean): boolean { - return running && cfg.sourceMode === 'bridge' -} - -/** Settings a running relay is built from. Changing anything else must not restart it. */ -const RELAY_KEYS = [ - 'sourceMode', - 'transport', - 'host', - 'commandPort', - 'listenPort', - 'replayDriveSerial' -] as const satisfies readonly (keyof AppConfig)[] - -export function relayConfigChanged(prev: AppConfig, next: AppConfig): boolean { - return RELAY_KEYS.some((k) => prev[k] !== next[k]) -} - -/** Where the stream comes from, for the relay's event-log rows. */ -export function relayContext(cfg: AppConfig): 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 } -} - export class Session { /** The Gateway's USB serial port. Up independently of the relay. */ readonly connection: GatewayConnection @@ -102,35 +36,28 @@ export class Session { readonly hid: HidService /** Where the link's event-log rows are reported. */ readonly journal = new EventJournal() + /** The relay: Start/Stop, the source mode, the stream and its ticks. */ + readonly relay: DcsRelay + /** Recording a live session to a capture file. */ + readonly recorder: SessionRecorder + /** The relay's network end: the link to DCS-BIOS. */ + private readonly dcs: DcsConnection + /** The Log tab's stream rows and their flush. */ + private readonly streamLog: StreamLog private config: AppConfig = { ...DEFAULT_CONFIG } - private transport?: Transport - private replay?: ReplaySource - private recorder?: Recorder - private recordPath?: string - private readonly parser: DcsBiosProtocol - private decoder = new Decoder() private readonly stats = new Stats() - private logBuf: LogRow[] = [] /** The event log (#64): node and client events, this app run only. */ readonly events = new EventLog() /** Last device state the event log reported (scanning never is). */ private lastDevice?: DeviceState - private running = false - private relay: RelayState = { running: false } /** Last message per error tag: a retry loop repeating one failure is one row, not one per try. */ private lastErr = new Map() - private logTimer?: ReturnType - private timers: ReturnType[] = [] /** * `paths`: the files, from app/index.ts. The default resolves the same paths the * same way, for callers that build a Session on its own. */ - constructor( - private readonly emit: Emit, - paths: StorePaths = storePaths(() => app.getPath('userData')) - ) { - this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) + constructor(emit: Emit, paths: StorePaths = storePaths(() => app.getPath('userData'))) { const { mappingFile, cacheFile } = storeFiles(paths) this.calibration = new HidCalibrationService(emit, { write: (data) => this.connection.write(data), @@ -151,6 +78,22 @@ export class Session { }) this.serialMonitor = new SerialTrafficMonitor(emit) this.lines = new DeviceLineTap(this.nodes) + this.recorder = new SessionRecorder() + this.streamLog = new StreamLog(emit, this.stats, this.events) + this.dcs = new DcsConnection() + this.relay = new DcsRelay(emit, { + options: () => this.config, + device: this.connection, + dcs: this.dcs, + stats: this.stats, + log: this.streamLog, + recorder: this.recorder, + events: { + add: (events) => this.events.add(events), + error: (tag, message) => this.logError(tag, message), + forget: (tag) => this.lastErr.delete(tag) + } + }) // The wiring order is load-bearing (R2.2b): it is the order DeviceLink ran these in before the // split. At each of the connection's moments its riders run in the order added: // start's timers link tick (200, its own) → serial flush (50) → cal:raw (50) → @@ -161,6 +104,19 @@ export class Session { // teardown `release`: calibration → port stop → `released`: the Gateway's health // 200 ms tick `flush`: HID → (link up) `tick`: node pushes, Gateway health // shutdown `closing`: nodes → timers cleared → teardown → `stop`: HID + // The DCS side keeps Session's order 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 + // 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 + // (1000) ticks + // relay Stop ticks cleared → replay stopped → transport stopped (forgotten first, so + // its close is stale) → the stream log flushed → `stopped` + // export chunk stats in → parser (named rows: stats frames, then the log) → recorder → + // device write (Bridge / driven Replay, not while calibration gates it) + // setConfig auto-connect → the relay rebuilt if a setting it is built from changed + // shutdown relay Stop → the connection's shutdown → the stream log's flush cleared this.connection.ride(this.serialMonitor) this.connection.ride(this.calibration) this.connection.ride(this.hid) @@ -170,14 +126,10 @@ export class Session { this.connection.onTraffic((dir, chunk) => this.serialMonitor.onSerialTraffic(dir, chunk)) // Bytes below the frame filter: the relay to DCS first, then the line tap — the order // DeviceLink handed them on in. - this.connection.onBytes((bytes) => this.fromDevice(bytes)) + this.connection.onBytes((bytes) => this.relay.fromDevice(bytes)) this.connection.onBytes((bytes) => this.lines.push(bytes)) - this.lines.onLine((l) => { - if (l.kind !== 'command') return - // Logged relaying or not: watching panel commands with no DCS is how a panel gets tested. - this.stats.command(l.line) - this.logBuf.push({ t: l.t, dir: 'out', address: 0, name: l.identifier, value: l.arg }) - }) + // Panel commands to the stream log, relaying or not. + this.lines.onLine((l) => this.streamLog.onLine(l)) // Link faults and the v2-Gateway warning: one path, as when DeviceLink raised both. const onError = (tag: string, message: string): void => { this.stats.error() @@ -199,17 +151,15 @@ export class Session { /** App launch: bring up the device link and the log, independent of any relay. */ open(): void { - if (this.logTimer) return - this.logTimer = setInterval(() => this.flushLog(), LOG_FLUSH_MS) + if (!this.streamLog.open()) return this.connection.start(this.config.autoConnect) } /** App quit. */ shutdown(): void { - this.stop() + this.relay.stop() this.connection.shutdown() - if (this.logTimer) clearInterval(this.logTimer) - this.logTimer = undefined + this.streamLog.close() } getConfig(): AppConfig { @@ -222,176 +172,15 @@ export class Session { if (prev.autoConnect !== this.config.autoConnect) { this.connection.setAutoConnect(this.config.autoConnect) } - if (this.running && relayConfigChanged(prev, this.config)) { - // 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. - this.stop() - this.run() - } + this.relay.reconfigure(prev, this.config) return this.config } - /** - * 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. - */ - start(): RelayResult { - if (exportToDevice(this.config) && !isLinkUp(this.connection.deviceStatus().state)) { - return { ok: false, error: 'Gateway is not connected' } - } - return this.run() - } - - private run(): RelayResult { - if (this.running) this.stop() - this.decoder = new Decoder() - this.parser.reset() - this.stats.reset() - this.lastErr.delete('DCS') // a new relay reports its stream afresh - const mode = this.config.sourceMode - - try { - if (mode === 'replay') { - const replay = this.replay - if (!replay) return { ok: false, error: 'No capture loaded' } - this.running = true - this.setRelay({ running: true, stream: 'connected' }) - replay.play( - (chunk) => this.onExport(chunk), // optionally drives the real cockpit from the capture - () => this.setRelay({ running: true, stream: 'ended' }) - ) - } else { - const t = createTransport(this.config) - this.transport = t - t.onExport((chunk) => this.onExport(chunk)) - t.onError((err) => { - if (this.transport !== t) return - this.stats.error() - this.logError('DCS', err.message) - this.setRelay({ running: true, stream: 'error', detail: err.message }) - }) - t.onConnected((connected) => { - if (this.transport !== t) return // a transport being stopped reports its own close - if (connected) { - this.setRelay({ running: true, stream: 'connected' }) - } else { - this.stats.reconnect() - this.setRelay({ running: true, stream: 'reconnecting' }) - } - }) - this.running = true - this.setRelay({ running: true, stream: 'connecting' }) - t.start() - } - - this.timers = [ - setInterval(() => this.flushTelemetry(), TELEMETRY_MS), - setInterval(() => this.emit('stats:tick', this.stats.snapshot()), STATS_MS) - ] - return { ok: true } - } catch (err) { - this.stop() - return { ok: false, error: (err as Error).message } - } - } - - /** Stop relaying. The device link — HID, calibration, nodes — stays up. */ - stop(): RelayResult { - this.running = false - for (const t of this.timers) clearInterval(t) - this.timers = [] - this.replay?.stop() - const t = this.transport - this.transport = undefined // first, so its closing callbacks are recognised as stale - t?.stop() - this.flushLog() - this.setRelay({ running: false }) - return { ok: true } - } - - // ── record / replay control ──────────────────────────────────────────────── - - startRecording(path: string): void { - this.recorder = new Recorder() - this.recordPath = path - } - - stopRecording(): { path?: string; events: number } { - const events = this.recorder?.count ?? 0 - if (this.recorder && this.recordPath) this.recorder.save(this.recordPath) - const path = this.recordPath - this.recorder = undefined - this.recordPath = undefined - return { path, events } - } - - isRecording(): boolean { - return !!this.recorder - } - - loadReplay(path: string): ReplayInfo { - this.replay?.stop() - this.replay = ReplaySource.load(path) - return this.replay.info() - } - - // ── pipeline ─────────────────────────────────────────────────────────────── - - /** One chunk of the DCS-BIOS export, from DCS or a capture. */ - private onExport(chunk: Buffer): void { - this.stats.addIn(chunk.length) - this.parser.processBuffer(chunk) - this.recorder?.record('in', chunk) - // Forward byte-for-byte to the SimGateway — unless a calibration exchange is in flight. - // Parsing for our own UI continues; only the forward pauses. - if (exportToDevice(this.config) && !this.calibration.exportGated) this.connection.write(chunk) - } - - /** Device traffic, calibration frames already removed by the link: relay to DCS:7778. */ - private fromDevice(bytes: Buffer): void { - if (!deviceToDcs(this.config, this.running)) return - this.transport?.send(bytes) - this.stats.addOut(bytes.length) - this.recorder?.record('out', bytes) - } - - private onWrite(address: number, value: number): void { - const rows = this.decoder.handle(address, value, Date.now()) - if (rows.length) { - this.stats.addFrames(rows.length) - for (const r of rows) this.logBuf.push(r) - } - } - - private flushLog(): void { - const events = this.events.take() - if (events.length) this.emit('events:batch', events) - if (this.logBuf.length === 0) return - const batch = this.logBuf.splice(0, MAX_BATCH) - this.emit('log:batch', batch) - } - - private flushTelemetry(): void { - this.emit('telemetry:tick', this.decoder.telemetrySnapshot()) - const ac = this.decoder.aircraft() - if (ac) this.emit('aircraft:changed', ac) - } - - private setRelay(relay: RelayState): void { - this.events.add(relayEvents(this.relay, relay, relayContext(this.config), Date.now())) - if (relay.stream === 'connected') this.lastErr.delete('DCS') // a later drop's error is news - - this.relay = relay - this.emit('relay:state', relay) - } - /** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */ status(): RelayStatus { const device: DeviceStatus = this.connection.deviceStatus() - return { running: this.running, relay: this.relay, device, cal: this.calibration.calSnapshot() } + const { running, relay } = this.relay.status() + return { running, relay, device, cal: this.calibration.calSnapshot() } } /** Push a client diagnostic into the event log + the debug log file. */ diff --git a/src/main/dcs/dcs-connection.ts b/src/main/dcs/dcs-connection.ts new file mode 100644 index 0000000..5a4d3fe --- /dev/null +++ b/src/main/dcs/dcs-connection.ts @@ -0,0 +1,46 @@ +// The network end of the relay: the link to DCS-BIOS over loopback multicast, unicast listen or +// TCP to host (transports.ts), which reconnects on its own. One transport at a time; a transport +// being replaced or stopped is stale, and what it reports after that is ignored. +import { createTransport, type Transport, type TransportOptions } from './transports' + +/** What the DCS link reports, as the relay hears it. */ +export interface DcsLinkEvents { + export(chunk: Buffer): void + error(err: Error): void + connected(connected: boolean): void +} + +export class DcsConnection { + private transport?: Transport + + /** Build the transport for these settings and subscribe to it. Nothing is opened until start(). */ + open(cfg: TransportOptions, on: DcsLinkEvents): void { + const t = createTransport(cfg) + this.transport = t + t.onExport((chunk) => on.export(chunk)) + t.onError((err) => { + if (this.transport !== t) return + on.error(err) + }) + t.onConnected((connected) => { + if (this.transport !== t) return // a transport being stopped reports its own close + on.connected(connected) + }) + } + + /** Bind or dial the transport open() built. */ + start(): void { + this.transport?.start() + } + + stop(): void { + const t = this.transport + this.transport = undefined // first, so its closing callbacks are recognised as stale + t?.stop() + } + + /** Device traffic to DCS (its import port, or the TCP socket). */ + send(data: Buffer): void { + this.transport?.send(data) + } +} diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts new file mode 100644 index 0000000..d0321c3 --- /dev/null +++ b/src/main/dcs/dcs-relay.ts @@ -0,0 +1,220 @@ +// The relay (#69): the DCS-BIOS stream, and the one on/off. Orchestrates a live source — +// transport -> protocol parser -> decoder -> stats — and the ticks that push it to the renderer. +// Source-agnostic across Bridge (DCS <-> device), Monitor (LAN, read-only) and Replay (recorded +// capture); recording can tap any live mode. +// +// The device link is not started or stopped here. It is up whenever the board is plugged in +// (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and +// whether it reaches the device. +import type { RelayResult, RelayState } from '@shared/dcs' +import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' +import type { LogEventInput } from '@shared/events' +import { isLinkUp, type DeviceStatus } from '@shared/gateway' +import { DcsBiosProtocol } from './bios-protocol' +import type { DcsConnection } from './dcs-connection' +import { relayContext, relayEvents } from './relay-events' +import { deviceToDcs, exportToDevice, relayConfigChanged, type RelayOptions } from './relay-rules' +import { ReplaySource, type ReplayInfo } from './replay-source' +import type { SessionRecorder } from './session-recorder' +import { Decoder, flushTelemetry } from './sim-telemetry-decoder' +import { STATS_MS, type Stats } from './stream-stats' +import type { StreamLog } from './stream-log' + +type Emit = (channel: C, payload: PushChannels[C]) => void + +const TELEMETRY_MS = 200 + +/** The Gateway link, as the relay uses it (GatewayConnection). */ +export interface RelayDevice { + deviceStatus(): DeviceStatus + write(data: Buffer): void + /** A calibration exchange is in flight: the export forward to the board pauses. */ + readonly exportGated: boolean +} + +/** The event log, as the relay reports to it. */ +export interface RelayEventSink { + add(events: LogEventInput[]): void + /** A client error row, deduped per tag. */ + error(tag: string, message: string): void + /** Forget the tag's last error, so the next one is news. */ + forget(tag: string): void +} + +export interface DcsRelayDeps { + /** The settings the relay is built from, read when it needs them. */ + options: () => RelayOptions + device: RelayDevice + dcs: DcsConnection + stats: Stats + log: StreamLog + recorder: SessionRecorder + events: RelayEventSink +} + +export class DcsRelay { + private replay?: ReplaySource + private readonly parser: DcsBiosProtocol + private decoder = new Decoder() + private running = false + private relay: RelayState = { running: false } + private timers: ReturnType[] = [] + private readonly device: RelayDevice + private readonly dcs: DcsConnection + private readonly stats: Stats + private readonly log: StreamLog + private readonly recorder: SessionRecorder + private readonly events: RelayEventSink + + constructor( + private readonly emit: Emit, + private readonly deps: DcsRelayDeps + ) { + this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) + this.device = deps.device + this.dcs = deps.dcs + this.stats = deps.stats + this.log = deps.log + this.recorder = deps.recorder + this.events = deps.events + } + + private get config(): RelayOptions { + return this.deps.options() + } + + /** + * 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. + */ + 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. + this.stop() + this.run() + } + } + + private run(): RelayResult { + if (this.running) this.stop() + this.decoder = new Decoder() + this.parser.reset() + this.stats.reset() + this.events.forget('DCS') // a new relay reports its stream afresh + const mode = this.config.sourceMode + + try { + if (mode === 'replay') { + const replay = this.replay + if (!replay) return { ok: false, error: 'No capture loaded' } + this.running = true + this.setRelay({ running: true, stream: 'connected' }) + replay.play( + (chunk) => this.onExport(chunk), // optionally drives the real cockpit from the capture + () => this.setRelay({ running: true, stream: 'ended' }) + ) + } else { + this.dcs.open(this.config, { + export: (chunk) => this.onExport(chunk), + error: (err) => { + this.stats.error() + this.events.error('DCS', err.message) + this.setRelay({ running: true, stream: 'error', detail: err.message }) + }, + connected: (connected) => { + if (connected) { + this.setRelay({ running: true, stream: 'connected' }) + } else { + this.stats.reconnect() + this.setRelay({ running: true, stream: 'reconnecting' }) + } + } + }) + this.running = true + this.setRelay({ running: true, stream: 'connecting' }) + this.dcs.start() + } + + this.timers = [ + setInterval(() => flushTelemetry(this.decoder, this.emit), TELEMETRY_MS), + setInterval(() => this.emit('stats:tick', this.stats.snapshot()), STATS_MS) + ] + return { ok: true } + } catch (err) { + this.stop() + return { ok: false, error: (err as Error).message } + } + } + + /** Stop relaying. The device link — HID, calibration, nodes — stays up. */ + stop(): RelayResult { + this.running = false + for (const t of this.timers) clearInterval(t) + this.timers = [] + this.replay?.stop() + this.dcs.stop() + this.log.flushLog() + this.setRelay({ running: false }) + return { ok: true } + } + + /** Load a capture for Replay, stopping the one loaded before. */ + loadReplay(path: string): ReplayInfo { + this.replay?.stop() + this.replay = ReplaySource.load(path) + return this.replay.info() + } + + /** Whether it is relaying, and the relay's state, for the renderer's rehydrate bundle. */ + status(): Pick { + return { running: this.running, relay: this.relay } + } + + // ── pipeline ─────────────────────────────────────────────────────────────── + + /** One chunk of the DCS-BIOS export, from DCS or a capture. */ + private onExport(chunk: Buffer): void { + this.stats.addIn(chunk.length) + this.parser.processBuffer(chunk) + this.recorder.record('in', chunk) + // Forward byte-for-byte to the SimGateway — unless a calibration exchange is in flight. + // Parsing for our own UI continues; only the forward pauses. + if (exportToDevice(this.config) && !this.device.exportGated) this.device.write(chunk) + } + + /** Device traffic, calibration frames already removed by the link: relay to DCS:7778. */ + fromDevice(bytes: Buffer): void { + if (!deviceToDcs(this.config, this.running)) return + this.dcs.send(bytes) + this.stats.addOut(bytes.length) + this.recorder.record('out', bytes) + } + + private onWrite(address: number, value: number): void { + const rows = this.decoder.handle(address, value, Date.now()) + if (rows.length) { + this.stats.addFrames(rows.length) + this.log.rows(rows) + } + } + + private setRelay(relay: RelayState): void { + this.events.add(relayEvents(this.relay, relay, relayContext(this.config), Date.now())) + if (relay.stream === 'connected') this.events.forget('DCS') // a later drop's error is news + + this.relay = relay + this.emit('relay:state', relay) + } +} diff --git a/src/main/dcs/index.ts b/src/main/dcs/index.ts index b1d4114..cfcd308 100644 --- a/src/main/dcs/index.ts +++ b/src/main/dcs/index.ts @@ -1,7 +1,6 @@ // dcs: the DCS-BIOS side of the relay. Exports only what other topics use (arch-phase0-v2 §2.1). -export { DcsBiosProtocol } from './bios-protocol' -export { relayEvents, type RelayContext } from './relay-events' -export { createTransport, type Transport } from './transports' -export { Decoder } from './sim-telemetry-decoder' -export { Recorder, ReplaySource, type ReplayInfo } from './replay-source' +export { DcsConnection } from './dcs-connection' +export { DcsRelay } from './dcs-relay' +export { SessionRecorder } from './session-recorder' export { Stats } from './stream-stats' +export { StreamLog } from './stream-log' diff --git a/src/main/dcs/relay-events.ts b/src/main/dcs/relay-events.ts index d7471f6..fb592d0 100644 --- a/src/main/dcs/relay-events.ts +++ b/src/main/dcs/relay-events.ts @@ -1,7 +1,7 @@ // 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 { SourceMode } from '@shared/config' -import type { RelayState } from '@shared/dcs' +import type { AppConfig, SourceMode } from '@shared/config' +import { EXPORT_MULTICAST_ADDR, EXPORT_PORT, type RelayState } from '@shared/dcs' import type { LogEventInput } from '@shared/events' export interface RelayContext { @@ -12,6 +12,19 @@ export interface RelayContext { tcp: boolean } +/** Where the stream comes from, for the relay's event-log rows. */ +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 } +} + const MODE_LABEL: Record = { bridge: 'Bridge mode', monitor: 'Monitor mode', diff --git a/src/main/dcs/relay-rules.ts b/src/main/dcs/relay-rules.ts new file mode 100644 index 0000000..cc56e44 --- /dev/null +++ b/src/main/dcs/relay-rules.ts @@ -0,0 +1,38 @@ +// Relay rules (pure, no Node/Electron deps): which way the stream goes in each source mode, and +// which settings a running relay is built from. +import type { AppConfig } from '@shared/config' + +/** Settings a running relay is built from. Changing anything else must not restart it. */ +export const RELAY_KEYS = [ + 'sourceMode', + 'transport', + 'host', + 'commandPort', + 'listenPort', + 'replayDriveSerial' +] as const satisfies readonly (keyof AppConfig)[] + +/** The settings the relay reads: exactly the ones it is built from (RELAY_KEYS). */ +export type RelayOptions = Pick + +type ForwardConfig = Pick + +/** + * Whether the export stream is written to the SimGateway — and so whether Start needs the board. + * + * 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 + * it had no port to write to. + */ +export function exportToDevice(cfg: ForwardConfig): boolean { + return cfg.sourceMode === 'bridge' || (cfg.sourceMode === 'replay' && cfg.replayDriveSerial) +} + +/** Whether device traffic goes on to DCS: only a running Bridge relay has a DCS to send it to. */ +export function deviceToDcs(cfg: ForwardConfig, running: boolean): boolean { + return running && cfg.sourceMode === 'bridge' +} + +export function relayConfigChanged(prev: T, next: T): boolean { + return RELAY_KEYS.some((k) => prev[k] !== next[k]) +} diff --git a/src/main/dcs/replay-source.ts b/src/main/dcs/replay-source.ts index 36957a6..b26ea08 100644 --- a/src/main/dcs/replay-source.ts +++ b/src/main/dcs/replay-source.ts @@ -1,31 +1,7 @@ -// Capture a live DCS-BIOS session to disk, and replay one as a synthetic export -// source (drive the parser/UI with no DCS running). Reuses the capture-file.ts -// format so captures are portable. -import { readFileSync, writeFileSync } from 'node:fs' -import { captureDurationMs, parseCapture, serializeCapture, type CaptureFile } from './capture-file' -import type { CaptureEvent, CaptureMeta } from '@shared/dcs' - -export class Recorder { - private events: CaptureEvent[] = [] - private startedAt = Date.now() - - reset(): void { - this.events = [] - this.startedAt = Date.now() - } - - record(dir: 'in' | 'out', chunk: Buffer): void { - this.events.push({ t: Date.now() - this.startedAt, dir, hex: chunk.toString('hex') }) - } - - get count(): number { - return this.events.length - } - - save(path: string, meta: CaptureMeta = {}): void { - writeFileSync(path, serializeCapture(this.events, meta)) - } -} +// Replay a captured DCS-BIOS session as a synthetic export source (drive the parser/UI with no +// DCS running). Reads the capture-file.ts format, which session-recorder.ts writes. +import { readFileSync } from 'node:fs' +import { captureDurationMs, parseCapture, type CaptureFile } from './capture-file' export interface ReplayInfo { events: number diff --git a/src/main/dcs/session-recorder.ts b/src/main/dcs/session-recorder.ts new file mode 100644 index 0000000..0b9e37b --- /dev/null +++ b/src/main/dcs/session-recorder.ts @@ -0,0 +1,56 @@ +// Record a live DCS-BIOS session to disk: the export coming in and the device traffic going out, +// in the capture-file.ts format replay-source.ts plays back, so captures are portable. +import { writeFileSync } from 'node:fs' +import { serializeCapture } from './capture-file' +import type { CaptureEvent, CaptureMeta } from '@shared/dcs' + +export class Recorder { + private events: CaptureEvent[] = [] + private startedAt = Date.now() + + reset(): void { + this.events = [] + this.startedAt = Date.now() + } + + record(dir: 'in' | 'out', chunk: Buffer): void { + this.events.push({ t: Date.now() - this.startedAt, dir, hex: chunk.toString('hex') }) + } + + get count(): number { + return this.events.length + } + + save(path: string, meta: CaptureMeta = {}): void { + writeFileSync(path, serializeCapture(this.events, meta)) + } +} + +/** The one capture being recorded, if any: the relay taps every live mode into it. */ +export class SessionRecorder { + private recorder?: Recorder + private recordPath?: string + + startRecording(path: string): void { + this.recorder = new Recorder() + this.recordPath = path + } + + stopRecording(): { path?: string; events: number } { + const events = this.recorder?.count ?? 0 + if (this.recorder && this.recordPath) this.recorder.save(this.recordPath) + const path = this.recordPath + this.recorder = undefined + this.recordPath = undefined + return { path, events } + } + + isRecording(): boolean { + return !!this.recorder + } + + /** One chunk of the stream, while recording: export `in`, device traffic `out`. */ + record(dir: 'in' | 'out', chunk: Buffer): void { + this.recorder?.record(dir, chunk) + } +} diff --git a/src/main/dcs/sim-telemetry-decoder.ts b/src/main/dcs/sim-telemetry-decoder.ts index bad4ebc..3906419 100644 --- a/src/main/dcs/sim-telemetry-decoder.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -154,3 +154,16 @@ export class Decoder { }) } } + +/** The two pushes the telemetry tick makes. */ +interface TelemetryEmit { + (channel: 'telemetry:tick', payload: TelemetryReadout[]): void + (channel: 'aircraft:changed', payload: AircraftStatus): void +} + +/** The telemetry tick: the five readouts, then the aircraft if it changed since the last tick. */ +export function flushTelemetry(decoder: Decoder, emit: TelemetryEmit): void { + emit('telemetry:tick', decoder.telemetrySnapshot()) + const ac = decoder.aircraft() + if (ac) emit('aircraft:changed', ac) +} diff --git a/src/main/dcs/stream-log.ts b/src/main/dcs/stream-log.ts new file mode 100644 index 0000000..15e0f89 --- /dev/null +++ b/src/main/dcs/stream-log.ts @@ -0,0 +1,59 @@ +// The Log tab's stream rows: panel commands from the device and the rows the decoder names from +// the export, batched to the renderer on a 33 ms flush. The same flush drains the event log +// first (its `events:batch`), as it did in Session. +import type { LogRow } from '@shared/dcs' +import type { PushChannel, PushChannels } from '@shared/app' +import type { DeviceLine } from '../gateway' +import type { EventLog } from '../events' +import type { Stats } from './stream-stats' + +type Emit = (channel: C, payload: PushChannels[C]) => void + +export const LOG_FLUSH_MS = 33 +export const MAX_BATCH = 250 + +export class StreamLog { + private logBuf: LogRow[] = [] + private logTimer?: ReturnType + + constructor( + private readonly emit: Emit, + private readonly stats: Stats, + /** The event log whose new rows ride this flush. */ + private readonly events: Pick + ) {} + + /** Start the flush. False when it is already running. */ + open(): boolean { + if (this.logTimer) return false + this.logTimer = setInterval(() => this.flushLog(), LOG_FLUSH_MS) + return true + } + + /** Stop the flush. */ + close(): void { + if (this.logTimer) clearInterval(this.logTimer) + this.logTimer = undefined + } + + /** A line from the device: panel commands are logged and counted. */ + onLine(l: DeviceLine): void { + if (l.kind !== 'command') return + // Logged relaying or not: watching panel commands with no DCS is how a panel gets tested. + this.stats.command(l.line) + this.logBuf.push({ t: l.t, dir: 'out', address: 0, name: l.identifier, value: l.arg }) + } + + /** Rows the decoder named from one export write. */ + rows(rows: LogRow[]): void { + for (const r of rows) this.logBuf.push(r) + } + + flushLog(): void { + const events = this.events.take() + if (events.length) this.emit('events:batch', events) + if (this.logBuf.length === 0) return + const batch = this.logBuf.splice(0, MAX_BATCH) + this.emit('log:batch', batch) + } +} diff --git a/src/main/dcs/stream-stats.seam.test.ts b/src/main/dcs/stream-stats.seam.test.ts new file mode 100644 index 0000000..f9284bc --- /dev/null +++ b/src/main/dcs/stream-stats.seam.test.ts @@ -0,0 +1,165 @@ +// SESSION-seam contract test (Phase 0, row B108): the stream stats the Stream Health and Command +// Activity panels show, as the renderer sees them — the 1 s `stats:tick` push while a relay runs. +// Driven through the services harness, so it pins what the app does today and survives the +// relay's move out of Session. +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' +import { makeServices, type Services } from '../test-harness/services' + +/** An A-4E-C address the decoder names, and how many log rows one write to it decodes to. */ +const [ADDRESS, OUTPUTS] = [...A4EC_OUTPUTS_BY_ADDRESS].find(([, o]) => o.length >= 2)! +const ROWS_PER_WRITE = OUTPUTS.length + +/** One DCS-BIOS export frame: sync, then `words` 16-bit writes from ADDRESS on. */ +function exportFrame(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])]) +} + +/** One second on: the next stats tick. */ +function tick(app: Services) { + const before = app.pushesOf('stats:tick').length + app.clock.advance(1000) + const ticks = app.pushesOf('stats:tick') + expect(ticks.length, 'one stats tick per second while relaying').toBe(before + 1) + return ticks.at(-1)! +} + +const idle = { + bytesInPerSec: 0, + bytesOutPerSec: 0, + framesPerSec: 0, + commandsPerSec: 0, + commandsTotal: 0, + lastCommand: undefined, + errors: 0, + reconnects: 0 +} + +describe('B108 the stats tick', () => { + it('ticks once a second only while relaying, with the uptime of the relay', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'monitor' } } } }) + app.clock.advance(5000) + expect(app.pushesOf('stats:tick'), 'no tick while stopped').toEqual([]) + + app.invoke(CTRL.relayStart) + app.clock.advance(999) + expect(app.pushesOf('stats:tick')).toEqual([]) + expect(tick(app)).toEqual({ ...idle, uptimeSec: 1 }) + expect(tick(app)).toEqual({ ...idle, uptimeSec: 2 }) + + app.invoke(CTRL.relayStop) + const n = app.pushesOf('stats:tick').length + app.clock.advance(5000) + expect(app.pushesOf('stats:tick'), 'Stop ends the tick').toHaveLength(n) + }) + + it('counts export bytes in and decoded frames, per second', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'monitor' } } } }) + app.invoke(CTRL.relayStart) + const frame = exportFrame([0x1234]) + app.transport.export(frame) + app.transport.export(frame) + expect(tick(app)).toEqual({ + ...idle, + bytesInPerSec: frame.length * 2, + framesPerSec: ROWS_PER_WRITE * 2, // one write, one row per output at its address + uptimeSec: 1 + }) + expect(tick(app), 'rates are per second: a quiet second reads 0').toEqual({ + ...idle, + uptimeSec: 2 + }) + }) + + it('counts panel commands (rate, total, last) and the bytes sent to DCS in Bridge', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) + app.serial.open() + app.invoke(CTRL.relayStart) + app.serial.receive('MASTER_ARM 1\nGEAR_LEVER 0\n') + expect(tick(app)).toEqual({ + ...idle, + bytesOutPerSec: 'MASTER_ARM 1\nGEAR_LEVER 0\n'.length, + commandsPerSec: 2, + commandsTotal: 2, + lastCommand: 'GEAR_LEVER 0', + uptimeSec: 1 + }) + app.serial.receive('HOOK 1\n') + expect(tick(app)).toEqual({ + ...idle, + bytesOutPerSec: 'HOOK 1\n'.length, + commandsPerSec: 1, + commandsTotal: 3, + lastCommand: 'HOOK 1', + uptimeSec: 2 + }) + expect(tick(app), 'the total and the last command stay; the rates fall to 0').toEqual({ + ...idle, + commandsTotal: 3, + lastCommand: 'HOOK 1', + uptimeSec: 3 + }) + }) + + it('counts commands in Monitor too, with nothing sent to DCS', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'monitor' } } } }) + app.serial.open() + app.invoke(CTRL.relayStart) + app.serial.receive('MASTER_ARM 1\n') + expect(tick(app)).toEqual({ + ...idle, + commandsPerSec: 1, + commandsTotal: 1, + lastCommand: 'MASTER_ARM 1', + uptimeSec: 1 + }) + expect(app.transport.sent()).toEqual([]) + }) + + it('counts errors from DCS and from the link, and reconnects of both', () => { + const app = makeServices({ + transport: { connectOnStart: true }, + paths: { files: { 'config.json': { sourceMode: 'monitor' } } } + }) + app.serial.open() + app.invoke(CTRL.relayStart) + app.transport.fail('ECONNREFUSED') + app.transport.fail('ECONNREFUSED') // a repeat is one event-log row, but two errors here + app.transport.connected(false) // the DCS link drops + app.transport.connected(true) + app.serial.close() // the Gateway is unplugged: the link reconnects + expect(tick(app)).toEqual({ ...idle, errors: 2, reconnects: 2, uptimeSec: 1 }) + app.serial.open() + app.serial.fail('Access denied') // a link error + expect(tick(app)).toEqual({ ...idle, errors: 3, reconnects: 2, uptimeSec: 2 }) + }) + + it('starts over on every Start: counters, totals and uptime, but not commands seen stopped', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) + app.serial.open() + app.serial.receive('MASTER_ARM 1\n') // stopped: logged, not counted for the next relay + app.invoke(CTRL.relayStart) + expect(tick(app)).toEqual({ ...idle, uptimeSec: 1 }) + app.serial.receive('GEAR_LEVER 1\n') + app.transport.fail('boom') + app.clock.advance(1500) + app.invoke(CTRL.relayStop) + app.clock.advance(3000) + app.invoke(CTRL.relayStart) + expect(tick(app)).toEqual({ ...idle, uptimeSec: 1 }) + }) + + it('a settings change that rebuilds the relay starts the stats over too', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'monitor' } } } }) + app.invoke(CTRL.relayStart) + app.transport.export(exportFrame([1])) + app.clock.advance(2500) + app.transport.fail('boom') + app.invoke(CTRL.configSet, { host: '10.0.0.2' }) // a relay setting: the relay is rebuilt + expect(tick(app)).toEqual({ ...idle, uptimeSec: 1 }) + app.invoke(CTRL.configSet, { debugMode: true }) // not a relay setting: no rebuild + expect(tick(app)).toEqual({ ...idle, uptimeSec: 2 }) + }) +}) diff --git a/src/main/dcs/stream-stats.ts b/src/main/dcs/stream-stats.ts index 4814d9c..14893fc 100644 --- a/src/main/dcs/stream-stats.ts +++ b/src/main/dcs/stream-stats.ts @@ -3,6 +3,9 @@ // so it is expected to be called on a steady ~1 Hz tick. import type { StatsSnapshot } from '@shared/dcs' +/** The stats tick: one `stats:tick` a second while a relay runs. */ +export const STATS_MS = 1000 + export class Stats { private bytesIn = 0 private bytesOut = 0 diff --git a/src/main/dcs/transports.seam.test.ts b/src/main/dcs/transports.seam.test.ts new file mode 100644 index 0000000..7296951 --- /dev/null +++ b/src/main/dcs/transports.seam.test.ts @@ -0,0 +1,326 @@ +// SESSION-seam contract test (Phase 0, row B107): the DCS link transports, against real sockets +// on 127.0.0.1 where the OS allows it deterministically (TCP to host, unicast listen), and against +// a stand-in socket for the loopback multicast join (whether a CI runner can join a multicast +// group is not ours to pin). What a transport reports — export chunks, errors, connected — is +// recorded in order, the way the relay hears it. +import dgram from 'node:dgram' +import net from 'node:net' +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' +import { EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcs' +import { createTransport, type Transport } from './transports' + +/** Everything a transport reported, in order. */ +function record(t: Transport): string[] { + const log: string[] = [] + t.onExport((chunk) => log.push(`export ${chunk.toString('hex')}`)) + t.onError((err) => log.push(`error ${(err as NodeJS.ErrnoException).code ?? err.message}`)) + t.onConnected((up) => log.push(`connected ${up}`)) + return log +} + +/** Let real I/O run (setImmediate is never faked here) until `cond` holds. */ +async function until(cond: () => boolean, what: string): Promise { + const end = performance.now() + 3000 + while (!cond()) { + if (performance.now() > end) throw new Error(`timed out waiting for ${what}`) + await new Promise((r) => setImmediate(r)) + } +} + +/** Let real I/O run for a while, for "nothing more happens" checks. */ +async function settle(ms = 50): Promise { + const end = performance.now() + ms + while (performance.now() < end) await new Promise((r) => setImmediate(r)) +} + +const cleanup: (() => void)[] = [] +afterEach(() => { + while (cleanup.length) cleanup.pop()!() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const config = (patch: Partial): AppConfig => ({ ...DEFAULT_CONFIG, ...patch }) + +// ── TCP to host ────────────────────────────────────────────────────────────── + +/** A DCS-BIOS stand-in listening on 127.0.0.1: the sockets it accepted and what it read. */ +async function dcsServer() { + const accepted: net.Socket[] = [] + const received: Buffer[] = [] + const server = net.createServer((sock) => { + accepted.push(sock) + sock.on('data', (d) => received.push(d)) + sock.on('error', () => {}) + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const port = (server.address() as net.AddressInfo).port + const close = () => { + for (const s of accepted) s.destroy() + server.close() + } + cleanup.push(close) + return { port, accepted, received, close } +} + +/** A port nothing listens on. */ +async function closedPort(): Promise { + const s = net.createServer() + await new Promise((r) => s.listen(0, '127.0.0.1', r)) + const port = (s.address() as net.AddressInfo).port + await new Promise((r) => s.close(r)) + return port +} + +describe('B107 TCP to host', () => { + it('connects to host:commandPort, carries export in and commands out on one socket', async () => { + const dcs = await dcsServer() + const t = createTransport(config({ transport: 'tcp-to-host', commandPort: dcs.port })) + const log = record(t) + t.start() + await until(() => log.includes('connected true') && dcs.accepted.length === 1, 'connect') + + dcs.accepted[0]!.write(Buffer.from([0x55, 0x55, 0x55, 0x55, 1, 2])) + await until(() => log.length === 2, 'export') + t.send(Buffer.from('MASTER_ARM 1\n')) + await until(() => dcs.received.length > 0, 'the command') + expect(Buffer.concat(dcs.received).toString()).toBe('MASTER_ARM 1\n') + + t.stop() + // Stop destroys the socket; its close is still reported, after Stop returns. + expect(log).toEqual(['connected true', 'export 555555550102']) + await until(() => log.length === 3, 'the close') + expect(log).toEqual(['connected true', 'export 555555550102', 'connected false']) + t.send(Buffer.from('X 1\n')) // after Stop: dropped, no throw + await settle() + expect(dcs.accepted).toHaveLength(1) + }) + + it('queues a command sent while connecting and delivers it once connected', async () => { + const dcs = await dcsServer() + const t = createTransport(config({ transport: 'tcp-to-host', commandPort: dcs.port })) + record(t) + t.start() + t.send(Buffer.from('EARLY 1\n')) + await until(() => dcs.received.length > 0, 'the queued command') + expect(Buffer.concat(dcs.received).toString()).toBe('EARLY 1\n') + t.stop() + }) + + it('reconnects 2 s after DCS drops it, for as long as the relay runs', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const dcs = await dcsServer() + const t = createTransport(config({ transport: 'tcp-to-host', commandPort: dcs.port })) + const log = record(t) + t.start() + await until(() => log.includes('connected true') && dcs.accepted.length === 1, 'connect') + + dcs.accepted[0]!.destroy() // DCS closes the socket (mission end, DCS quit) + await until(() => log.length === 2, 'the drop') + expect(log).toEqual(['connected true', 'connected false']) + vi.advanceTimersByTime(1999) + await settle() + expect(dcs.accepted, 'no retry before 2 s').toHaveLength(1) + vi.advanceTimersByTime(1) + await until(() => log.length === 3 && dcs.accepted.length === 2, 'the reconnect') + expect(log).toEqual(['connected true', 'connected false', 'connected true']) + t.stop() + }) + + it('a refused connect is an error then a close, retried every 2 s until Stop', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const port = await closedPort() + const t = createTransport(config({ transport: 'tcp-to-host', commandPort: port })) + const log = record(t) + t.start() + await until(() => log.length === 2, 'the refusal') + expect(log, 'never connected, so a close without an up').toEqual([ + 'error ECONNREFUSED', + 'connected false' + ]) + vi.advanceTimersByTime(2000) + await until(() => log.length === 4, 'the retry') + expect(log.slice(2)).toEqual(['error ECONNREFUSED', 'connected false']) + + t.stop() // the next retry is pending: Stop cancels it + vi.advanceTimersByTime(10_000) + await settle() + expect(log).toHaveLength(4) + }) + + it('a relay started against a DCS that comes up later connects on a retry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const port = await closedPort() + const t = createTransport(config({ transport: 'tcp-to-host', commandPort: port })) + const log = record(t) + t.start() + await until(() => log.length === 2, 'the refusal') + const server = net.createServer((s) => s.on('error', () => {})) + await new Promise((r) => server.listen(port, '127.0.0.1', r)) + cleanup.push(() => server.close()) + vi.advanceTimersByTime(2000) + await until(() => log.length === 3, 'the connect') + expect(log).toEqual(['error ECONNREFUSED', 'connected false', 'connected true']) + t.stop() + await until(() => log.length === 4, 'the close') + vi.advanceTimersByTime(10_000) + await settle() + expect(log.at(-1), 'no retry after Stop').toBe('connected false') + expect(log).toHaveLength(4) + }) +}) + +// ── unicast listen ─────────────────────────────────────────────────────────── + +/** A free UDP port on 127.0.0.1, and a socket bound to it (closed after the test) if `hold`. */ +async function udpPort(hold = false) { + const s = dgram.createSocket('udp4') + const received: Buffer[] = [] + s.on('message', (m) => received.push(m)) + await new Promise((r) => s.bind(0, '127.0.0.1', r)) + const port = s.address().port + if (hold) cleanup.push(() => s.close()) + else await new Promise((r) => s.close(r)) + return { port, received } +} + +function sendTo(port: number, data: Buffer): Promise { + const s = dgram.createSocket('udp4') + return new Promise((r) => s.send(data, port, '127.0.0.1', () => (s.close(), r()))) +} + +describe('B107 unicast listen', () => { + it('listens on listenPort, reports listening as connected, and sends to host:commandPort', async () => { + const { port: listenPort } = await udpPort() + const dcs = await udpPort(true) // DCS-BIOS's import port + const t = createTransport( + config({ transport: 'unicast-listen', listenPort, commandPort: dcs.port }) + ) + const log = record(t) + t.start() + expect(log, 'bind is asynchronous').toEqual([]) + await until(() => log.length === 1, 'the bind') + expect(log).toEqual(['connected true']) + + await sendTo(listenPort, Buffer.from([0x55, 0x55, 0x55, 0x55, 9])) + await until(() => log.length === 2, 'export') + expect(log[1]).toBe('export 5555555509') + t.send(Buffer.from('GEAR 1\n')) + await until(() => dcs.received.length === 1, 'the command') + expect(dcs.received[0]!.toString()).toBe('GEAR 1\n') + + t.stop() + expect(log.at(-1), 'Stop reports the close itself, synchronously').toBe('connected false') + expect(log).toHaveLength(3) + t.send(Buffer.from('X 1\n')) // after Stop: dropped, no throw + await settle() + expect(dcs.received).toHaveLength(1) + }) + + it('shares the listen port with another reuseAddr listener (DCS-BIOS tools coexist)', async () => { + const { port: listenPort } = await udpPort() + const other = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + await new Promise((r) => other.bind(listenPort, r)) + cleanup.push(() => other.close()) + const t = createTransport(config({ transport: 'unicast-listen', listenPort })) + const log = record(t) + t.start() + await until(() => log.length === 1, 'the bind') + expect(log).toEqual(['connected true']) + t.stop() + }) + + it('a port held exclusively is an error, never connected', async () => { + const held = dgram.createSocket({ type: 'udp4', reuseAddr: false }) + await new Promise((r) => held.bind(0, r)) + cleanup.push(() => held.close()) + const t = createTransport( + config({ transport: 'unicast-listen', listenPort: held.address().port }) + ) + const log = record(t) + t.start() + await until(() => log.length === 1, 'the bind error') + expect(log).toEqual(['error EADDRINUSE']) + await settle() + expect(log).toEqual(['error EADDRINUSE']) + t.stop() // the relay's Stop after the error: no throw, and the close is reported + expect(log).toEqual(['error EADDRINUSE', 'connected false']) + }) +}) + +// ── loopback multicast ─────────────────────────────────────────────────────── + +/** Stands in for the dgram socket: records the calls the transport makes on it. */ +class FakeSocket extends EventEmitter { + calls: string[] = [] + membershipError?: Error + bind(port: number, cb: () => void) { + this.calls.push(`bind ${port}`) + queueMicrotask(cb) + } + addMembership(addr: string) { + this.calls.push(`addMembership ${addr}`) + if (this.membershipError) throw this.membershipError + } + send(data: Buffer, port: number, host: string) { + this.calls.push(`send ${data.toString('hex')} ${host}:${port}`) + } + close() { + this.calls.push('close') + } +} + +function fakeDgram(): { sock: FakeSocket; opts: unknown[] } { + const sock = new FakeSocket() + const opts: unknown[] = [] + vi.spyOn(dgram, 'createSocket').mockImplementation(((o: unknown) => { + opts.push(o) + return sock + }) as unknown as typeof dgram.createSocket) + return { sock, opts } +} + +describe('B107 loopback multicast', () => { + it(`binds the export port with reuseAddr, joins ${EXPORT_MULTICAST_ADDR}, then reports connected`, async () => { + const { sock, opts } = fakeDgram() + const t = createTransport( + config({ + transport: 'loopback-multicast', + listenPort: 1, + commandPort: 7778, + host: '10.0.0.9' + }) + ) + const log = record(t) + t.start() + expect(opts).toEqual([{ type: 'udp4', reuseAddr: true }]) + expect(sock.calls, 'the export port, not listenPort').toEqual([`bind ${EXPORT_PORT}`]) + expect(log).toEqual([]) + await Promise.resolve() + expect(sock.calls).toEqual([`bind ${EXPORT_PORT}`, `addMembership ${EXPORT_MULTICAST_ADDR}`]) + expect(log).toEqual(['connected true']) + + sock.emit('message', Buffer.from([0x55, 1])) + sock.emit('error', new Error('socket hiccup')) + t.send(Buffer.from([0x41, 0x0a])) + expect(sock.calls.at(-1), 'commands go to host:commandPort').toBe('send 410a 10.0.0.9:7778') + t.stop() + expect(sock.calls.at(-1)).toBe('close') + expect(log).toEqual(['connected true', 'export 5501', 'error socket hiccup', 'connected false']) + t.send(Buffer.from([1])) // after Stop: dropped + expect(sock.calls.at(-1)).toBe('close') + }) + + it('a failed group join is reported as an error, and it still reports connected', async () => { + const { sock } = fakeDgram() + sock.membershipError = new Error('addMembership EADDRNOTAVAIL') + const t = createTransport(config({ transport: 'loopback-multicast' })) + const log = record(t) + t.start() + await Promise.resolve() + expect(log).toEqual(['error addMembership EADDRNOTAVAIL', 'connected true']) + t.stop() + }) +}) diff --git a/src/main/dcs/transports.ts b/src/main/dcs/transports.ts index 31de1cd..a8f23be 100644 --- a/src/main/dcs/transports.ts +++ b/src/main/dcs/transports.ts @@ -6,6 +6,9 @@ import net from 'node:net' import { EXPORT_MULTICAST_ADDR, EXPORT_PORT } from '@shared/dcs' import type { AppConfig } from '@shared/config' +/** The settings a transport is built from. */ +export type TransportOptions = Pick + export interface Transport { start(): void stop(): void @@ -39,10 +42,10 @@ abstract class BaseTransport implements Transport { /** Export over UDP — either loopback multicast join or a plain unicast listen. */ class UdpTransport extends BaseTransport { private sock?: dgram.Socket - private readonly cfg: AppConfig + private readonly cfg: TransportOptions private readonly multicast: boolean - constructor(cfg: AppConfig, multicast: boolean) { + constructor(cfg: TransportOptions, multicast: boolean) { super() this.cfg = cfg this.multicast = multicast @@ -82,9 +85,9 @@ class TcpTransport extends BaseTransport { private sock?: net.Socket private reconnectTimer?: ReturnType private stopped = false - private readonly cfg: AppConfig + private readonly cfg: TransportOptions - constructor(cfg: AppConfig) { + constructor(cfg: TransportOptions) { super() this.cfg = cfg } @@ -122,7 +125,7 @@ class TcpTransport extends BaseTransport { } } -export function createTransport(cfg: AppConfig): Transport { +export function createTransport(cfg: TransportOptions): Transport { switch (cfg.transport) { case 'loopback-multicast': return new UdpTransport(cfg, true) diff --git a/src/main/gateway/device-line-tap.ts b/src/main/gateway/device-line-tap.ts index 55ec38d..7753676 100644 --- a/src/main/gateway/device-line-tap.ts +++ b/src/main/gateway/device-line-tap.ts @@ -1,6 +1,6 @@ // The device line tap: the Gateway's byte stream, below the calibration frame filter, assembled // into text lines and read — never changed. Node lines go to the node layer (NodeHub, through the -// NodeLineSink port), and every line is handed on to its subscribers (Session logs the panel +// NodeLineSink port), and every line is handed on to its subscribers (dcs StreamLog logs the panel // commands). The bytes the relay sends to DCS are GatewayConnection's onBytes(), untouched by this. import { LineAssembler, parseCommand } from './link-framing' import type { LinkRider } from './gateway-connection' diff --git a/src/main/gateway/gateway-connection.ts b/src/main/gateway/gateway-connection.ts index 1a4848c..1d6429a 100644 --- a/src/main/gateway/gateway-connection.ts +++ b/src/main/gateway/gateway-connection.ts @@ -6,7 +6,7 @@ // // What else rides on the port is told about the link's moments through `ride()` (LinkRider): the // serial monitor, calibration, HID, the node layer and the line tap (R2.2a, R2.2b). Device traffic -// goes out through onBytes(), below the frame filter: the relay (app/session.ts) and the line tap +// goes out through onBytes(), below the frame filter: the relay (dcs/dcs-relay.ts) and the line tap // (device-line-tap.ts) subscribe to it. Export goes in through write(). That split is the point. // Bundled together, there was no node status, PDU telemetry, HID or calibration unless DCS was // being relayed, and stopping the relay tore the port down with it. diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index b982eb1..4da3cf8 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -223,8 +223,8 @@ function launch(opts: ServicesOptions) { const intents = { [CTRL.configGet]: () => session.getConfig(), [CTRL.configSet]: (patch: Partial) => applyConfig(patch), - [CTRL.relayStart]: () => session.start(), - [CTRL.relayStop]: () => session.stop(), + [CTRL.relayStart]: () => session.relay.start(), + [CTRL.relayStop]: () => session.relay.stop(), [CTRL.relayStatus]: () => session.status(), [CTRL.deviceConnect]: () => session.connection.connect(), [CTRL.deviceDisconnect]: () => session.connection.disconnect(), @@ -375,7 +375,7 @@ function launch(opts: ServicesOptions) { /** The window closes (`window-all-closed`): the relay stops, the app keeps running. */ closeWindow(): void { - session.stop() + session.relay.stop() }, /** The app quits (`will-quit`). */ From f3cc40ab14a650e332fb3fb423882f93318266ac Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Fri, 16 Oct 2026 23:40:33 -0400 Subject: [PATCH 16/28] refactor(config): settings store and the event journal (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.3b of the structure rewrite (#101, phase #99). This is the settings and event-log half of R2.3. Session's config and event log move to their owners. Session keeps only its wiring, `status()`, and `open()` / `shutdown()`. AppServices and the per-topic IPC are left for R2.3c. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port and to DCS, the files written, the timers and the order of all of them are the same. The trace proof below checks this. ## What moved where | From | To | Notes | |---|---|---| | `Session.config`, `getConfig`, `setConfig` | `config/settings-service.ts` **SettingsService** `get` / `update` | The live `AppConfig` is now a zustand/vanilla store under `subscribeWithSelector` (see below). `update` is `setConfig`: it merges, the store's subscribers react, and nothing is saved | | `app/index.ts` `applyConfig` | **SettingsService** `apply` | `update` → debug-log switch → `saveConfig`, in that order. `config:set` and ConfigTransfer's `setConfig` both call it | | `app/index.ts` whenReady seed (`setConfig(loadConfig(paths))` + `setDebugEnabled`) | **SettingsService** `load` | It seeds and switches the debug log. It does not write `config.json`, as before | | `Session.events` (EventLog), `logError` + `lastErr`, the `lastErr.delete` calls | `events/event-journal.ts` **EventJournal** `add` / `error` / `forget` | The per-tag dedupe is verbatim. `NODES` is still never forgotten, so the v2 warning stays once per run (B29) | | `Session.lastDevice` and the onStatus row logic | **EventJournal** `linkState(state, row)` | `deviceEvent` stays in `gateway/link-events.ts`. Session hands the journal a closure that makes the row from the last state the journal holds. The make-row → add → remember-unless-scanning order is unchanged | | the `events:batch` half of `StreamLog.flushLog` | **EventJournal** `flush` | StreamLog now calls `events.flush()` where it used to `take()` and emit, so it still runs first, ahead of `log:batch` | | `events:get` / `clear` / `export` (`session.events.*`, `eventsToTsv`) | **EventJournal** `snapshot` / `clear` / `exportTsv` | See *Not as briefed* | | the `journal.onEvents → events.add` hop | gone | `report` now adds to the log it hosts. It had one subscriber | **Ports.** - DcsRelay's `options: () => RelayOptions` is replaced by `settings: RelaySettings` (`{ getState(): RelayOptions }`), which the SettingsStore fits. - StreamLog's `Pick` is replaced by `StreamLogEvents` (`{ flush(): void }`). `dcs` no longer imports `events` for StreamLog. - DcsRelay's `RelayEventSink` (`add` / `error` / `forget`) is the journal itself. - NodeHub still reports through its `onEvents` callback, now pointed at `journal.report`. - The journal is handed `emit` and `debugLog`. `events` imports only `@shared/events` and `@shared/gateway` (types). `events/index.ts` now exports only `EventJournal` and the status-flag vocabulary. `config/index.ts` exports `SettingsService` in place of `loadConfig` / `saveConfig`, which have no callers left outside `config`. ## The settings store, and who gets it - `SettingsService` builds one store per instance with `createSettingsStore(initial = DEFAULT_CONFIG)`. It is not a module singleton, and a test can build its own. - The service holds the writable store. It hands out `store: SettingsStore`, the read-only view from `@shared/config`, which the compile guard already checks a real zustand store fits. - **The relay** gets `settings.store` and reads `getState()` wherever it used to call `options()`. - **The connection** gets its slice. `open()` passes `settings.get().autoConnect` to `start`, and a change reaches `setAutoConnect` through the subscriber below. GatewayConnection's own `autoConnect` field and `start` / `setAutoConnect` are unchanged. - **The debug log** (app's `setDebugEnabled`) is injected into SettingsService as `debugMode(on)`, because `config` cannot import `app`. **How the reaction order is kept.** zustand runs subscribers synchronously inside `setState`, after the state is replaced. That is the same moment `setConfig` ran its side effects, after `this.config` was reassigned. Session registers **one** subscriber, and it is `setConfig`'s body verbatim: auto-connect if it changed, then `relay.reconfigure(prev, next)`. The order is in that one listener, not in registration order. `apply` then runs the debug-log switch and the save, as `applyConfig` did. `update` calls `setState({ ...prev, ...patch }, true)`, replacing rather than merging. zustand's own merge treats a non-object `partial` as the whole new state. A `config:set` with no patch would therefore have set the settings to `undefined`, where `setConfig` kept a copy. The trace covers this: dropping the `replace` gives a 1,019-line diff. ## Wiring Session's constructor comment has a new block for R2.3b: the store's one subscriber, what `config:set` / import / launch seed do after it, and the flush order. Construction order: SettingsService and EventJournal are built first, then the rest as before. Neither constructor has a side effect. Session is now 173 lines (was 193); `app/index.ts` is 289 (was 299). **Callers change their receiver only.** - `app/index.ts` and `test-harness/services.ts` use `session.settings.get` / `apply` / `load` and `session.journal.*`. The harness's own copy of `applyConfig` is gone. - `session.test.ts` changes eight `session.setConfig(` to `session.settings.update(`, three `session.events.snapshot()` to `session.journal.snapshot()`, and one `Parameters<…>` type. - Arguments and assertions are unchanged. The `makeServices()` seam tests and the component tests are untouched. ## Proof - **Side-effect trace (not committed).** This is the R2.3a harness, extended. It records the same edges, plus every `setDebugEnabled` call. - **Changes to the harness:** settings, events and export go through accessors that work at both base and head. Seeding can go through the launch path (`load`). - **New scenarios:** - **config-set-autoconnect**: off and on with the link looking, after a Disconnect, while open (left open, then the drop is not reopened), back on after the drop, and on while up (nothing); - **config-set-autoconnect-off-launch**: off from launch, then on, then off while looking; - **config-set-relay**: - a relay setting while running (rebuilt and saved); - debug on together with a relay setting (the switch lands after the rebuild and before the save); - non-relay settings (`attentionBanner`, `psu`, `debugMode`), an empty patch, an undefined patch, and a whole config as an import sends it; - a mode switch while running, then while stopped; - auto-connect and a relay setting in one change, with the link dropped and the relay running; - **config-load-seed**: a `config.json` with bad fields is sanitized, the debug log is switched, nothing is written back, and auto-connect off holds until a `config:set`; - **config-load-damaged**: defaults, and the file is left as it is until a `config:set`; - **error-dedupe**: - the same SERIAL error twice, then a DCS error twice, then a new SERIAL message; - Retry forgetting SERIAL; - DCS up forgetting DCS; - the v2 warning once, and not again after a reopen; - **events-get-clear-export**: empty, then rows with tabs and newlines in a field, a clear with a row pending (never pushed), ids continuing after a clear, and a double clear; - **device-rows-reconnect**: no board at launch (no row), plug/unplug/replug, Disconnect twice, Connect with nothing there, then a fault and a Retry. - **Result:** base (6228947) and head outputs are **byte-identical**, sha1 `38a4e2b5…`. That is 30 scenarios (22 from R2.3a + 8 new) and 6,028 recorded lines: 1,933 emits (105 of them `events:batch`), 747 timer, 416 DCS, 384 serial, 367 HID, 213 returned values, 156 debug-log, 69 fs writes and 27 debug-log switches. Base is deterministic over two runs. - **It can fail.** One deliberate reorder at a time: - the relay rebuilt before auto-connect, inside the subscriber: 6 diff lines; - `config.json` saved before the debug log is switched: 50; - the debug log switched before the change (and so before the rebuild): 75; - `events:batch` pushed after `log:batch`: 40; - no error dedupe: 30; - `lastDevice` remembered before the row is made: 153; - zustand's merge instead of a replace: 1,019. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with `SKYHAWK_USER_DATA` set and with the dev default: - base and head probe outputs are byte-identical; - `config.json` after `config:set` and `node-mapping.json` have the same sha1s; - `debug.log` is identical apart from its timestamp. - The probe copies the bundle out of the repo, so it now runs with `NODE_PATH` set to the repo's `node_modules`, at both base and head. Main now `require`s `zustand/vanilla` and `zustand/middleware`, which are externalized like `serialport`. `zustand` is already a runtime dependency, so the packaged app ships it. - **Tests.** 69 files, 906 tests, as before. The existing assertions are unchanged. ## Not as briefed - **The settings reaction lives in Session's wiring, not inside SettingsService.** It calls `connection.setAutoConnect` and `relay.reconfigure`, and `config` may not import `gateway` or `dcs`. So SettingsService owns the store and the `update` / `apply` / `load` paths, and Session (AppServices in R2.3c) registers the one ordered subscriber. - **GatewayConnection gets a slice, not the store.** Its `autoConnect` is pushed to it at `start` and through `setAutoConnect`, and its `was` check compares against its own field. Reading the store instead would change `start`'s signature and that check, so the connection keeps both. It is handed the value from the store. - **`events:export` builds the TSV before the save dialog, not after.** It used to take the snapshot before the dialog and format it after. `exportTsv()` does both at the snapshot's moment. Formatting is pure, so the file is the same. The only difference is that a cancelled dialog has still formatted the text. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before. - `npm run lint:deps`: 0 errors, 15 warnings. The list is identical to base (diffed). There are no new warnings: `events` imports no topic, and `config` imports only `zustand` beyond its own files and the contracts. - `npm run build`: OK. Refs #99 --- src/main/app/index.ts | 32 ++++------- src/main/app/session.test.ts | 24 ++++---- src/main/app/session.ts | 86 ++++++++++++----------------- src/main/config/index.ts | 2 +- src/main/config/settings-service.ts | 67 ++++++++++++++++++++-- src/main/dcs/dcs-relay.ts | 11 +++- src/main/dcs/stream-log.ts | 16 ++++-- src/main/events/event-journal.ts | 84 ++++++++++++++++++++++++---- src/main/events/index.ts | 1 - src/main/test-harness/services.ts | 21 ++----- 10 files changed, 217 insertions(+), 127 deletions(-) diff --git a/src/main/app/index.ts b/src/main/app/index.ts index 60250d9..0aef3bd 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -8,12 +8,11 @@ import type { CaptureState, ReplayLoad } from '@shared/dcs' import type { HidAvailability } from '@shared/hid' import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' -import { ConfigTransfer, loadConfig, saveConfig } from '../config' -import { eventsToTsv } from '../events' +import { ConfigTransfer } from '../config' import { listSerialPorts } from '../gateway' import { loadCalCache, saveCalCache } from '../hid/calibration' import { Session } from './session' -import { debugLog, debugLogPath, setDebugEnabled } from './debug-log' +import { debugLog, debugLogPath } from './debug-log' import { storeFiles, storePaths, userDataOverride } from './user-data' // Before anything touches userData: dev worktrees get their own folder (#81). @@ -70,17 +69,9 @@ function emit(channel: C, payload: PushChannels[C]): void const session = new Session(emit, paths) -/** Apply and persist a settings change: the one path for config:set and a config import. */ -function applyConfig(patch: Partial): AppConfig { - const config = session.setConfig(patch) - setDebugEnabled(config.debugMode) - saveConfig(config, paths) - return config -} - const transfer = new ConfigTransfer({ - getConfig: () => session.getConfig(), - setConfig: (config) => applyConfig(config), + getConfig: () => session.settings.get(), + setConfig: (config) => session.settings.apply(config), nodes: session.nodes, loadCalCache: () => loadCalCache(cacheFile), saveCalCache: (cache) => saveCalCache(cache, cacheFile), @@ -95,8 +86,8 @@ const CONFIG_MAX_BYTES = 5 * 1024 * 1024 const CAPTURE_FILTER = [{ name: 'DCS-BIOS capture', extensions: ['json'] }] function registerIpc(): void { - ipcMain.handle(CTRL.configGet, () => session.getConfig()) - ipcMain.handle(CTRL.configSet, (_e, patch: Partial) => applyConfig(patch)) + ipcMain.handle(CTRL.configGet, () => session.settings.get()) + ipcMain.handle(CTRL.configSet, (_e, patch: Partial) => session.settings.apply(patch)) ipcMain.handle(CTRL.configExport, async (): Promise => { const res = await dialog.showSaveDialog({ title: 'Export configuration', @@ -189,17 +180,17 @@ function registerIpc(): void { return { path: res.filePath } }) - ipcMain.handle(CTRL.eventsGet, () => session.events.snapshot()) - ipcMain.handle(CTRL.eventsClear, () => session.events.clear()) + ipcMain.handle(CTRL.eventsGet, () => session.journal.snapshot()) + ipcMain.handle(CTRL.eventsClear, () => session.journal.clear()) ipcMain.handle(CTRL.eventsExport, async () => { - const rows = session.events.snapshot() + const text = session.journal.exportTsv() // the rows as they are when asked const res = await dialog.showSaveDialog({ title: 'Export events', defaultPath: `skyhawk-events-${Date.now()}.tsv`, filters: [{ name: 'Events', extensions: ['tsv', 'txt'] }] }) if (res.canceled || !res.filePath) return {} - writeFileSync(res.filePath, eventsToTsv(rows)) + writeFileSync(res.filePath, text) return { path: res.filePath } }) @@ -280,8 +271,7 @@ function createWindow(): void { } app.whenReady().then(() => { - const cfg = session.setConfig(loadConfig(paths)) // seed from persisted settings - setDebugEnabled(cfg.debugMode) + session.settings.load() // seed from persisted settings registerIpc() createWindow() session.open() // the device link is up from launch, relaying or not (#69) diff --git a/src/main/app/session.test.ts b/src/main/app/session.test.ts index 6d50fa0..570113e 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/session.test.ts @@ -136,7 +136,7 @@ const ascii = (s: string) => Buffer.from(s, 'ascii') function bridgeSession() { const session = new Session(() => {}) - session.setConfig({ sourceMode: 'bridge' }) + session.settings.update({ sourceMode: 'bridge' }) session.open() FakeSerial.last!.openCb('/dev/fake') // a Bridge relay starts only with the board connected session.relay.start() @@ -213,7 +213,7 @@ describe('node telemetry tap', () => { try { const pushes: [string, unknown][] = [] const session = new Session((c, p) => pushes.push([c, p])) - session.setConfig({ sourceMode: 'bridge' }) + session.settings.update({ sourceMode: 'bridge' }) session.open() const serial = FakeSerial.last! serial.openCb('/dev/fake') // node pushes flow only while the link is up @@ -265,7 +265,7 @@ describe('HID reporting latency', () => { const session = new Session((ch, payload) => { if (ch === 'hid:report') hidEvents.push([...(payload as { buttons: boolean[] }).buttons]) }) - session.setConfig({ sourceMode: 'bridge' }) + session.settings.update({ sourceMode: 'bridge' }) session.open() session.relay.start() @@ -316,10 +316,10 @@ describe('HID recovery after an unplug', () => { type Push = [string, unknown] /** A session with the link open (app launched), the relay not started, and a recorder of pushes. */ -function launched(config: Parameters['setConfig']>[0] = {}) { +function launched(config: Parameters['settings']['update']>[0] = {}) { const pushes: Push[] = [] const session = new Session((c, p) => pushes.push([c, p])) - session.setConfig(config) + session.settings.update(config) session.open() const serial = FakeSerial.last! const of = (channel: string) => pushes.filter(([c]) => c === channel).map(([, p]) => p) @@ -493,7 +493,7 @@ describe('device link is independent of the relay (#69)', () => { const off = launched() off.serial.openCb('/dev/fake') - off.session.setConfig({ autoConnect: false }) // an open link is left alone + off.session.settings.update({ autoConnect: false }) // an open link is left alone expect(off.session.status().device.state).toBe('connected') off.serial.closeCb() expect(off.session.status().device.state).toBe('disconnected') @@ -504,7 +504,7 @@ describe('device link is independent of the relay (#69)', () => { it('connects again when auto-connect is turned back on', () => { const { session } = launched({ autoConnect: false }) expect(FakeSerial.last).toBeUndefined() - session.setConfig({ autoConnect: true }) + session.settings.update({ autoConnect: true }) expect(FakeSerial.last).toBeDefined() expect(session.status().device.state).toBe('scanning') session.shutdown() @@ -564,7 +564,7 @@ describe('device link is independent of the relay (#69)', () => { serial.openCb('/dev/fake') session.relay.start() serial.closeCb() // the board drops; the relay waits - session.setConfig({ host: '10.0.0.2' }) // rebuilds the relay + session.settings.update({ host: '10.0.0.2' }) // rebuilds the relay expect(session.status().running, 'only Stop ends a relay').toBe(true) session.shutdown() }) @@ -652,7 +652,7 @@ describe('relay forwarding by source mode (#69)', () => { const { session } = launched() session.relay.start() const first = FakeTransport.last - session.setConfig({ autoConnect: false }) + session.settings.update({ autoConnect: false }) expect(FakeTransport.last, 'toggling auto-connect must not restart the relay').toBe(first) session.shutdown() }) @@ -682,7 +682,7 @@ describe('Gateway health (#94)', () => { }) const rows = (session: InstanceType) => - session.events.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) + session.journal.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) /** The Gateway's own row (node 0, kind 03, 42 °C), with these heartbeat flags. */ const gatewayLine = (flags = 0): string => @@ -786,7 +786,7 @@ describe('event log (#64)', () => { }) const rows = (session: InstanceType) => - session.events.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) + session.journal.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) it('moves link errors out of the stream log into client events', () => { vi.useFakeTimers() @@ -818,7 +818,7 @@ describe('event log (#64)', () => { 'node:Right_Navigation: offline · no heartbeat', 'client:Gateway: reconnecting · link dropped' ]) - const offline = session.events.snapshot()[2] + const offline = session.journal.snapshot()[2] expect(offline.nodeId).toBe(2) expect(offline.raw).toBe(statusLine(2, false).trim()) session.shutdown() diff --git a/src/main/app/session.ts b/src/main/app/session.ts index 8e68629..c8a7790 100644 --- a/src/main/app/session.ts +++ b/src/main/app/session.ts @@ -1,22 +1,22 @@ // The main process's services, built and wired: the Gateway link (gateway/), the nodes (nodes/), // HID and calibration (hid/), and the DCS side — the relay, its network end, the stream log, the -// stats and the recorder (dcs/). Session owns the settings, the event log and the rehydrate -// bundle; everything else lives with its owner, and Session only connects them. +// stats and the recorder (dcs/), the settings (config/) and the event log (events/). Each lives +// with its owner; Session only builds and connects them, and answers the rehydrate bundle. // // The device link is not started or stopped by the relay. It is up whenever the board is plugged // in (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and // whether it reaches the device. -import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' -import type { DeviceState, DeviceStatus } from '@shared/gateway' +import type { DeviceStatus } from '@shared/gateway' import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' import { app } from 'electron' import { DcsConnection, DcsRelay, SessionRecorder, Stats, StreamLog } from '../dcs' -import { clientErrorEvent, EventJournal, EventLog } from '../events' +import { SettingsService } from '../config' +import { EventJournal } from '../events' import { deviceEvent, DeviceLineTap, GatewayConnection, SerialTrafficMonitor } from '../gateway' import { HidService } from '../hid' import { HidCalibrationService } from '../hid/calibration' import { NodeHub, NodeRoster } from '../nodes' -import { debugLog } from './debug-log' +import { debugLog, setDebugEnabled } from './debug-log' import { storeFiles, storePaths, type StorePaths } from './user-data' type Emit = (channel: C, payload: PushChannels[C]) => void @@ -34,8 +34,10 @@ export class Session { readonly calibration: HidCalibrationService /** The HID joystick, up for as long as the app is. */ readonly hid: HidService - /** Where the link's event-log rows are reported. */ - readonly journal = new EventJournal() + /** The settings (config.json): the live copy, its store, and config:set. */ + readonly settings: SettingsService + /** The event log (#64): where every owner reports its rows; events:get / clear / export. */ + readonly journal: EventJournal /** The relay: Start/Stop, the source mode, the stream and its ticks. */ readonly relay: DcsRelay /** Recording a live session to a capture file. */ @@ -44,14 +46,7 @@ export class Session { private readonly dcs: DcsConnection /** The Log tab's stream rows and their flush. */ private readonly streamLog: StreamLog - private config: AppConfig = { ...DEFAULT_CONFIG } private readonly stats = new Stats() - /** The event log (#64): node and client events, this app run only. */ - readonly events = new EventLog() - /** Last device state the event log reported (scanning never is). */ - private lastDevice?: DeviceState - /** Last message per error tag: a retry loop repeating one failure is one row, not one per try. */ - private lastErr = new Map() /** * `paths`: the files, from app/index.ts. The default resolves the same paths the @@ -59,6 +54,8 @@ export class Session { */ constructor(emit: Emit, paths: StorePaths = storePaths(() => app.getPath('userData'))) { const { mappingFile, cacheFile } = storeFiles(paths) + this.settings = new SettingsService({ paths, debugMode: (on) => setDebugEnabled(on) }) + this.journal = new EventJournal(emit, debugLog) this.calibration = new HidCalibrationService(emit, { write: (data) => this.connection.write(data), serialNumber: () => this.connection.usbSerialNumber(), @@ -79,20 +76,16 @@ export class Session { this.serialMonitor = new SerialTrafficMonitor(emit) this.lines = new DeviceLineTap(this.nodes) this.recorder = new SessionRecorder() - this.streamLog = new StreamLog(emit, this.stats, this.events) + this.streamLog = new StreamLog(emit, this.stats, this.journal) this.dcs = new DcsConnection() this.relay = new DcsRelay(emit, { - options: () => this.config, + settings: this.settings.store, device: this.connection, dcs: this.dcs, stats: this.stats, log: this.streamLog, recorder: this.recorder, - events: { - add: (events) => this.events.add(events), - error: (tag, message) => this.logError(tag, message), - forget: (tag) => this.lastErr.delete(tag) - } + events: this.journal }) // The wiring order is load-bearing (R2.2b): it is the order DeviceLink ran these in before the // split. At each of the connection's moments its riders run in the order added: @@ -115,8 +108,15 @@ export class Session { // its close is stale) → the stream log flushed → `stopped` // export chunk stats in → parser (named rows: stats frames, then the log) → recorder → // device write (Bridge / driven Replay, not while calibration gates it) - // setConfig auto-connect → the relay rebuilt if a setting it is built from changed // shutdown relay Stop → the connection's shutdown → the stream log's flush cleared + // Settings and the event log (R2.3b). Neither constructor has a side effect. The settings + // store is one instance: the relay reads it (`settings.store`); the connection is handed its + // auto-connect slice at open and, on a change, by the store's subscriber. + // settings change the store's one subscriber, below: auto-connect → the relay rebuilt if a + // setting it is built from changed. config:set and a config import then + // switch the debug log and write config.json; the launch seed only + // switches the debug log + // stream-log flush the event log's new rows (`events:batch`), then the stream's (`log:batch`) this.connection.ride(this.serialMonitor) this.connection.ride(this.calibration) this.connection.ride(this.hid) @@ -133,26 +133,30 @@ export class Session { // Link faults and the v2-Gateway warning: one path, as when DeviceLink raised both. const onError = (tag: string, message: string): void => { this.stats.error() - this.logError(tag, message) + this.journal.error(tag, message) } this.connection.onError(onError) this.nodes.onError(onError) - this.journal.onEvents((events) => this.events.add(events)) this.connection.onStatus((s) => { if (s.state === 'reconnecting') this.stats.reconnect() // The port's own retry loop stays in `error`; any other state (an explicit Connect or Retry // goes through `scanning`) ends that loop, so the next failure is news again. - if (s.state !== 'error') this.lastErr.delete('SERIAL') - const e = deviceEvent(this.lastDevice, s, Date.now()) - if (e) this.events.add([e]) - if (s.state !== 'scanning') this.lastDevice = s.state + if (s.state !== 'error') this.journal.forget('SERIAL') + this.journal.linkState(s.state, (last) => deviceEvent(last, s, Date.now())) + }) + // A settings change, in one listener so its order is explicit: auto-connect, then the relay. + this.settings.store.subscribe((config, prev) => { + if (prev.autoConnect !== config.autoConnect) { + this.connection.setAutoConnect(config.autoConnect) + } + this.relay.reconfigure(prev, config) }) } /** App launch: bring up the device link and the log, independent of any relay. */ open(): void { if (!this.streamLog.open()) return - this.connection.start(this.config.autoConnect) + this.connection.start(this.settings.get().autoConnect) } /** App quit. */ @@ -162,32 +166,10 @@ export class Session { this.streamLog.close() } - getConfig(): AppConfig { - return this.config - } - - setConfig(patch: Partial): AppConfig { - const prev = this.config - this.config = { ...prev, ...patch } - if (prev.autoConnect !== this.config.autoConnect) { - this.connection.setAutoConnect(this.config.autoConnect) - } - this.relay.reconfigure(prev, this.config) - return this.config - } - /** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */ status(): RelayStatus { const device: DeviceStatus = this.connection.deviceStatus() const { running, relay } = this.relay.status() return { running, relay, device, cal: this.calibration.calSnapshot() } } - - /** Push a client diagnostic into the event log + the debug log file. */ - private logError(tag: string, msg: string): void { - debugLog(`error.${tag}`, msg) // always reaches debug.log when debug mode is on - if (this.lastErr.get(tag) === msg) return // dedupe the event log only - this.lastErr.set(tag, msg) - this.events.add([clientErrorEvent(tag, msg, Date.now())]) - } } diff --git a/src/main/config/index.ts b/src/main/config/index.ts index f4bf89f..1d4d72d 100644 --- a/src/main/config/index.ts +++ b/src/main/config/index.ts @@ -1,5 +1,5 @@ // config: settings and the configuration file transfer. Exports only what other topics use // (arch-phase0-v2 §2.1). -export { loadConfig, saveConfig } from './settings-service' +export { SettingsService } from './settings-service' export { ConfigTransfer } from './config-transfer' export { JSON_POLICY, jsonFile, type JsonFile, type JsonPolicy, type JsonRead } from './json-store' diff --git a/src/main/config/settings-service.ts b/src/main/config/settings-service.ts index 1016c52..7d5d2c1 100644 --- a/src/main/config/settings-service.ts +++ b/src/main/config/settings-service.ts @@ -1,8 +1,15 @@ -// Persist AppConfig to /config.json so settings survive restarts. -// Where that file lives is handed in by app (SettingsPaths): this module never asks electron. -// The file mechanics are the JSON store's, under config.json's policy: a tolerant read (anything -// wrong loads the defaults) and a best-effort write. -import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' +// The app settings (AppConfig): the live copy, and config.json in so they survive +// restarts. Where that file lives is handed in by app (SettingsPaths): this module never asks +// electron. The file mechanics are the JSON store's, under config.json's policy: a tolerant read +// (anything wrong loads the defaults) and a best-effort write. +// +// The live copy is one zustand/vanilla store under `subscribeWithSelector`. SettingsService owns +// it and hands out the read-only view (`store`); the services that read settings are given that +// same instance, and read it with `getState()` and react with `subscribe(selector, listener)`. +// Subscribers run synchronously, inside the change, in the order they subscribed. +import { createStore } from 'zustand/vanilla' +import { subscribeWithSelector } from 'zustand/middleware' +import { DEFAULT_CONFIG, type AppConfig, type SettingsStore } from '@shared/config' import { sanitizeConfig } from './settings-schema' import { JSON_POLICY, jsonFile } from './json-store' @@ -26,3 +33,53 @@ export function loadConfig(paths: SettingsPaths): AppConfig { export function saveConfig(config: AppConfig, paths: SettingsPaths): void { configFile(paths).write(config) } + +/** A settings store of its own, starting at `initial` (a copy). Not a singleton: each owner builds one. */ +export function createSettingsStore(initial: AppConfig = DEFAULT_CONFIG) { + return createStore()(subscribeWithSelector(() => ({ ...initial }))) +} + +export interface SettingsServiceDeps { + paths: SettingsPaths + /** The debug log's switch (app's debug-log), set from each applied or loaded config. */ + debugMode(on: boolean): void +} + +export class SettingsService { + private readonly live = createSettingsStore() + + /** The live settings, read-only: the one instance every settings reader is handed. */ + readonly store: SettingsStore = this.live + + constructor(private readonly deps: SettingsServiceDeps) {} + + get(): AppConfig { + return this.live.getState() + } + + /** + * Merge a change into the live settings. The store's subscribers react inside the call, with + * the new and the previous settings; nothing is saved. + */ + update(patch: Partial): AppConfig { + const prev = this.live.getState() + // Replaced, not merged by zustand: the new settings are exactly `{ ...prev, ...patch }`. + this.live.setState({ ...prev, ...patch }, true) + return this.live.getState() + } + + /** Apply and persist a settings change: the one path for config:set and a config import. */ + apply(patch: Partial): AppConfig { + const config = this.update(patch) + this.deps.debugMode(config.debugMode) + saveConfig(config, this.deps.paths) + return config + } + + /** App launch: seed from config.json. Not written back. */ + load(): AppConfig { + const cfg = this.update(loadConfig(this.deps.paths)) // seed from persisted settings + this.deps.debugMode(cfg.debugMode) + return cfg + } +} diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts index d0321c3..45ee790 100644 --- a/src/main/dcs/dcs-relay.ts +++ b/src/main/dcs/dcs-relay.ts @@ -41,9 +41,14 @@ export interface RelayEventSink { forget(tag: string): void } +/** The settings, as the relay reads them: the SettingsStore, read when it needs them. */ +export interface RelaySettings { + getState(): RelayOptions +} + export interface DcsRelayDeps { - /** The settings the relay is built from, read when it needs them. */ - options: () => RelayOptions + /** The settings the relay is built from. */ + settings: RelaySettings device: RelayDevice dcs: DcsConnection stats: Stats @@ -80,7 +85,7 @@ export class DcsRelay { } private get config(): RelayOptions { - return this.deps.options() + return this.deps.settings.getState() } /** diff --git a/src/main/dcs/stream-log.ts b/src/main/dcs/stream-log.ts index 15e0f89..fa2b755 100644 --- a/src/main/dcs/stream-log.ts +++ b/src/main/dcs/stream-log.ts @@ -1,14 +1,19 @@ // The Log tab's stream rows: panel commands from the device and the rows the decoder names from -// the export, batched to the renderer on a 33 ms flush. The same flush drains the event log -// first (its `events:batch`), as it did in Session. +// the export, batched to the renderer on a 33 ms flush. The same flush pushes the event log's new +// rows first (its `events:batch`), as it did in Session. import type { LogRow } from '@shared/dcs' import type { PushChannel, PushChannels } from '@shared/app' import type { DeviceLine } from '../gateway' -import type { EventLog } from '../events' import type { Stats } from './stream-stats' type Emit = (channel: C, payload: PushChannels[C]) => void +/** The event log, as this flush drives it (EventJournal). */ +export interface StreamLogEvents { + /** Push the event log's new rows (`events:batch`). */ + flush(): void +} + export const LOG_FLUSH_MS = 33 export const MAX_BATCH = 250 @@ -20,7 +25,7 @@ export class StreamLog { private readonly emit: Emit, private readonly stats: Stats, /** The event log whose new rows ride this flush. */ - private readonly events: Pick + private readonly events: StreamLogEvents ) {} /** Start the flush. False when it is already running. */ @@ -50,8 +55,7 @@ export class StreamLog { } flushLog(): void { - const events = this.events.take() - if (events.length) this.emit('events:batch', events) + this.events.flush() if (this.logBuf.length === 0) return const batch = this.logBuf.splice(0, MAX_BATCH) this.emit('log:batch', batch) diff --git a/src/main/events/event-journal.ts b/src/main/events/event-journal.ts index 436be01..eb245b6 100644 --- a/src/main/events/event-journal.ts +++ b/src/main/events/event-journal.ts @@ -1,20 +1,84 @@ -// Where event-log rows are reported (moved out of DeviceLink, R2.2a): node changes and the -// Gateway's health, handed on to whoever records them. A thin owner for now — Session still hosts -// the EventLog (R2.3 moves it here). -import type { LogEventInput } from '@shared/events' +// The event log's owner (#64): hosts the session's EventLog (moved out of Session, R2.3b) and +// takes the rows the other owners report — node changes and the Gateway's health (NodeHub), the +// relay's rows (DcsRelay), client errors, and the Gateway link's state rows. The rows themselves +// are made where they happen (nodes, gateway/link-events.ts, dcs/relay-events.ts); this only +// records them, dedupes the repeats, and pushes what is new on the stream log's flush. +import type { EventsPushChannels, LogEvent, LogEventInput } from '@shared/events' +import type { DeviceState } from '@shared/gateway' +import { clientErrorEvent, EventLog, eventsToTsv } from './event-log' -type Unsubscribe = () => void +type Emit = (channel: C, payload: EventsPushChannels[C]) => void + +/** Where client errors also go (app's debug log). */ +export type JournalLog = (tag: string, data?: unknown) => void export class EventJournal { - private readonly eventCbs = new Set<(events: LogEventInput[]) => void>() + /** The event log (#64): node and client events, this app run only. */ + private readonly events = new EventLog() + /** Last device state the event log reported (scanning never is). */ + private lastDevice?: DeviceState + /** Last message per error tag: a retry loop repeating one failure is one row, not one per try. */ + private lastErr = new Map() + + constructor( + private readonly emit: Emit, + private readonly log: JournalLog + ) {} /** Rows for the event log (#64): online / offline, flags raised and cleared, new nodes. */ report(events: LogEventInput[]): void { - if (events.length) for (const cb of this.eventCbs) cb(events) + if (events.length) this.add(events) + } + + /** Rows, as they are. */ + add(events: LogEventInput[]): void { + this.events.add(events) + } + + /** Push a client diagnostic into the event log + the debug log file. */ + error(tag: string, msg: string): void { + this.log(`error.${tag}`, msg) // always reaches debug.log when debug mode is on + if (this.lastErr.get(tag) === msg) return // dedupe the event log only + this.lastErr.set(tag, msg) + this.events.add([clientErrorEvent(tag, msg, Date.now())]) + } + + /** Forget the tag's last error, so the next one is news. */ + forget(tag: string): void { + this.lastErr.delete(tag) + } + + /** + * A Gateway link state: `row` makes its row, if any, from the last state reported. One row per + * state; scanning is never remembered, so a retry loop adds nothing after its first row. + */ + linkState( + state: DeviceState, + row: (last: DeviceState | undefined) => LogEventInput | null + ): void { + const e = row(this.lastDevice) + if (e) this.events.add([e]) + if (state !== 'scanning') this.lastDevice = state + } + + /** The rows added since the last flush, pushed as `events:batch`. */ + flush(): void { + const events = this.events.take() + if (events.length) this.emit('events:batch', events) + } + + /** events:get. */ + snapshot(): LogEvent[] { + return this.events.snapshot() + } + + /** events:clear. */ + clear(): void { + this.events.clear() } - onEvents(cb: (events: LogEventInput[]) => void): Unsubscribe { - this.eventCbs.add(cb) - return () => this.eventCbs.delete(cb) + /** events:export: every row as it is now, as TSV. */ + exportTsv(): string { + return eventsToTsv(this.events.snapshot()) } } diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 999af1a..8f23c41 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,5 +1,4 @@ // events: the event log and the status-flag vocabulary. Exports only what other topics use // (arch-phase0-v2 §2.1). export { EventJournal } from './event-journal' -export { clientErrorEvent, EventLog, eventsToTsv } from './event-log' export { faultLabel, STATUS_FLAGS, type StatusFlag } from './status-flags' diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 4da3cf8..7d7b3c8 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -137,8 +137,6 @@ vi.doMock('../hid/hid-reader', () => ({ HidReader: FakeHid })) const { Session } = await import('../app/session') const { storePaths } = await import('../app/user-data') -const { loadConfig, saveConfig } = await import('../config/settings-service') -const { setDebugEnabled } = await import('../app/debug-log') const { PortNotFoundError } = await import('../gateway/serial-bridge') // ── the harness ────────────────────────────────────────────────────────────── @@ -211,18 +209,10 @@ function launch(opts: ServicesOptions) { pushes.push({ channel, payload } as Push) }, paths) - // app/index.ts: applyConfig(), the one path for config:set and a config import. - const applyConfig = (patch: Partial): AppConfig => { - const config = session.setConfig(patch) - setDebugEnabled(config.debugMode) - saveConfig(config, paths) - return config - } - // app/index.ts registerIpc(): the renderer's requests main answers, by channel. const intents = { - [CTRL.configGet]: () => session.getConfig(), - [CTRL.configSet]: (patch: Partial) => applyConfig(patch), + [CTRL.configGet]: () => session.settings.get(), + [CTRL.configSet]: (patch: Partial) => session.settings.apply(patch), [CTRL.relayStart]: () => session.relay.start(), [CTRL.relayStop]: () => session.relay.stop(), [CTRL.relayStatus]: () => session.status(), @@ -239,14 +229,13 @@ function launch(opts: ServicesOptions) { [CTRL.nodeRegistry]: () => session.nodes.registry(), [CTRL.nodeMappingHide]: (id: number, on: boolean) => session.nodes.hide(id, on), [CTRL.serialMonitor]: (on: boolean) => session.serialMonitor.setSerialMonitor(on), - [CTRL.eventsGet]: () => session.events.snapshot(), - [CTRL.eventsClear]: () => session.events.clear() + [CTRL.eventsGet]: () => session.journal.snapshot(), + [CTRL.eventsClear]: () => session.journal.clear() } type Intents = typeof intents // app/index.ts app.whenReady(): seed from persisted settings, then bring the device link up. - const cfg = session.setConfig(loadConfig(paths)) - setDebugEnabled(cfg.debugMode) + session.settings.load() session.open() let quit = false From 49ec8730c1058d12ed62887ed35894b175c99c8e Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 00:06:30 -0400 Subject: [PATCH 17/28] refactor(app): session becomes app services (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.3c of the structure rewrite (#101, phase #99), the last part of R2.3. `Session` becomes **`AppServices`** (UD-F), a composition object that only builds the services and wires them. `app/index.ts` is split by job, and each main topic gets its own `ipc.ts`. The renderer tests get their own tsconfig, so the last four T2-main deep imports can go through the topic indexes. The main topic rules and T2-main are now **error**. Nothing changes for the user. The IPC channels and their registration order, the `app.on` order, the pushes, the bytes on the serial port and to DCS, the files written and the timers are all the same. The proof is below. ## Old → new | Old | New | Notes | |---|---|---| | `app/session.ts` `Session` | `app/app-services.ts` **`AppServices`** | `git mv`, then the class is renamed. The wiring, the settings subscriber, `open()`, `shutdown()` and `status()` are verbatim. It imports `Emit` from `@shared/app`. | | `app/session.test.ts`, `app/session.seam.test.ts` | `app/app-services.test.ts`, `app/app-services.seam.test.ts` | `git mv`. Only the import, `new AppServices(`, `typeof AppServices` and one `describe` title change. Assertions are unchanged. | | `app/index.ts`: the userData override, the store paths, the construction of Session and ConfigTransfer, `whenReady`, `window-all-closed`, `will-quit` | `app/bootstrap.ts` `start()` | Moved verbatim, in the same order. ConfigTransfer is still built here, as before (see *Not as briefed*). | | `app/index.ts`: `mainWindow`, `emit`, `createWindow` | `app/window.ts` | | | 9 local `type Emit` copies: AppServices, GatewayConnection, SerialTrafficMonitor, NodeHub, HidService, HidCalibrationService, DcsRelay, StreamLog, and EventJournal's events-only variant | **one `Emit` in `@shared/app/ipc.ts`**, next to `PushChannels` | A type only. `Emit`. Every service imports it from the contract. EventJournal takes `Emit`, which is derived from it rather than redefined. The full `emit` is still assignable to it. | | `app/index.ts`: `CONFIG_FILTER`, `CONFIG_MAX_BYTES`, `CAPTURE_FILTER` and the six `dialog.show*Dialog` calls | `app/dialogs.ts` `dialogs: Dialogs` | `Dialogs = ConfigDialogs & DcsDialogs & EventsDialogs`. Each topic declares the dialogs it needs as a port. | | `app/index.ts` `registerIpc()` | `app/ipc.ts` `registerIpc(ipcMain, { services, transfer, dialogs })` | Calls each topic's registrar. Owns `relay:status`, `debug:dump-ports` and `debug:reveal`. | | `config:get/set/export/import-open/import-apply` | `config/ipc.ts` `registerConfigIpc` | | | `relay:start/stop`, `log:export`, `capture:toggle`, `replay:open` | `dcs/ipc.ts` `registerDcsIpc` | | | `device:connect/disconnect`, `serial:monitor` | `gateway/ipc.ts` `registerGatewayIpc` | | | `hid:availability`, and `hidAvailability()` | `hid/ipc.ts` `registerHidIpc`, and `hid/hid-report.ts` | The function is moved verbatim to the HID model (§1.2). | | `nodes:*`, `node-mapping:*` (10) | `nodes/ipc.ts` `registerNodesIpc` | | | `cal:*` (10) | `hid/calibration/ipc.ts` `registerCalibrationIpc` | | | `events:get/clear/export` | `events/ipc.ts` `registerEventsIpc` | | | `app/index.ts` (289 lines) | `app/index.ts`: `export { start } from './bootstrap'` | | | `main/index.ts`: `import './app'` | `import { start } from './app'; start()` | | | `test-harness/services.ts` | the same file | `makeServices()` now builds `AppServices`, as planned. No test that uses the harness changes. | **The handlers move verbatim. Only the receiver changes**, from `session.x` to the `x` passed in `deps`. Channel names come from the topic contracts (`CONFIG_CTRL`, `DCS_CTRL`, …). The one change inside a handler is the dialog call. `await dialog.showSaveDialog({…})` followed by `if (res.canceled || !res.filePath)` becomes `await dialogs.saveX()` followed by `if (!filePath)`. The cancel check itself now lives in `dialogs.ts`, unchanged. Arguments are still evaluated where they were: `transfer.defaultFileName()` and `Date.now()` run before the dialog opens, and `journal.exportTsv()` still runs before the events dialog. **What the topic registrars take.** A topic can't import electron (P6) or app (the topic rules), so `@shared/app` gains three types: `CtrlChannel` (any CTRL value), `IpcListener` (ipcMain's listener shape) and `IpcRegistrar` (`{ handle(channel, listener) }`). The real `ipcMain` fits `IpcRegistrar` structurally. `app/*` line counts: app-services 175, bootstrap 61, ipc 116, dialogs 81, window 45, debug-log 38, user-data 50, index 2. ## IPC registration order Today's `ipcMain.handle` order interleaves topics: config, dcs, **app**, gateway, hid, nodes, **gateway**, calibration, **dcs**, events, **app**, **dcs**. Registering one topic after another would reorder it. So: - The topics' registrars and app's own three handlers register into a **collector**, a `Map` behind an `IpcRegistrar`. - `app/ipc.ts` keeps **`IPC_ORDER`**, the 40 channels in today's order. It then calls `ipcMain.handle` once per channel, in that order. - **A channel missing from `IPC_ORDER` fails the typecheck** and names the channel. `EveryChannel` checks this. I tried it by dropping `debug:reveal`: `TS2741 … '{ unordered: "debug:reveal"; }'`. - `satisfies readonly CtrlChannel[]` rejects a misspelled channel. - At startup, a second handler for the same channel throws, as `ipcMain` itself does. So does a listed channel that has no handler. Neither can happen in a correct build. **Proof.** The boot probe now also prints the registration order (`order …`), the handler count and any duplicate `handle`. Base and head print the same 40-channel order: ``` order config:get,config:set,config:export,config:import-open,config:import-apply,relay:start,relay:stop,relay:status,device:connect,device:disconnect,hid:availability,nodes:refresh,nodes:get,node-mapping:set,node-mapping:adopt,node-mapping:forget,node-mapping:reset-node,node-mapping:reset,node-mapping:reset-preview,nodes:registry,node-mapping:hide,serial:monitor,cal:hello,cal:read,cal:session-open,cal:stream-select,cal:commit,cal:reset,cal:cache-read,cal:cache-store,cal:cache-drop,cal:session-close,log:export,events:get,events:clear,events:export,debug:dump-ports,debug:reveal,capture:toggle,replay:open count 40 ``` The probe can fail. Swapping `relay:start` and `relay:stop` in `IPC_ORDER` gives a one-line diff on `order`. ## The tsconfig change **The premise was off.** `tsconfig.json` set no `types`, so TypeScript loaded every `@types/*` package, `@types/node` included (66 files). The renderer's app code could already use `Buffer` or `node:fs` and typecheck. I checked this at base with a probe file in `src/renderer/src`. So moving the deep imports to `../nodes` typechecked even without the split. This PR makes the boundary real: - **`tsconfig.json`** adds `"types": []`. It leaves out `src/renderer/test`, `src/renderer/**/*.test.ts(x)`, `src/shared/**/*.test.ts` and `src/test-fixtures`. - With these settings, renderer app code that uses `Buffer` fails with TS2591. - The shared tests are still checked by `tsconfig.node.json`, as before. - `ipc.compat.test-d.ts` stays in `tsconfig.json`. - **`tsconfig.test.json`** (new) extends `tsconfig.json`. It sets `types: ["node"]`, so the files it checks get DOM and Node types. It covers `src/renderer`, `src/shared` and `src/test-fixtures`, tests included. - **`npm run typecheck`** runs all three projects. vitest is unaffected. ## Deep imports removed | From | Was | Now | |---|---|---| | `config/config-file.ts` | `../nodes/mapping-rules` (`effectiveMapping`, `importMapping`, `sanitizeNodeMapping`) | `../nodes` | | `config/config-file.ts` | `../nodes/psu-limits` (`sanitizePsu`) | `../nodes` | | `config/config-file.ts` | `../hid/calibration/cache-rules` (`sanitizeCache`) | `../hid/calibration` | | `config/settings-schema.ts` | `../nodes/psu-limits` (`sanitizePsu`) | `../nodes` | `nodes/index.ts` now exports those four rules, and `hid/calibration/index.ts` exports `sanitizeCache`. There are no cycles: C2 stays at 0, at error. `.dependency-cruiser.severity.json` sets `topicsMain` and `deepMain` to **error**. ## Lint counts | | Before (f3cc40a) | After | |---|---|---| | `npm run lint` | 0 errors, 32 warnings (all P5, renderer) | 0 errors, 32 warnings (the same) | | `npm run lint:deps` | 0 errors, 15 warnings: P4-legacy-rules 10, T2-main 4, T2-renderer 1 | **0 errors, 11 warnings**: P4-legacy-rules 10, T2-renderer 1 | | `npm run typecheck` | 2 projects, clean | 3 projects, clean | | `npx vitest run` | 69 files, 906 tests | 69 files, 906 tests, assertions unchanged | | `npm run build` | ok | ok | ## Trace and boot proof - **Side-effect trace (R2.3b harness, not committed).** It drives the services with every edge faked: 30 scenarios. - The head copy changes one line, the import: `const { AppServices: Session } = await import('./app/app-services')`. - Base (f3cc40a) and head output are **byte-identical**: sha1 `38a4e2b5…`, the same as R2.3b's head. - The output is 6,028 recorded lines, 1,933 of them emits. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`, plus the registration-order dump). It ran with `SKYHAWK_USER_DATA` set and with the dev default. - Base and head output are **byte-identical**: `setPath`, the `app.on` order (`window-all-closed`, `will-quit`, then `activate` inside `whenReady`), the window's preload and renderer paths, the 40 channels in registration order, `config:get`/`config:set`, `debug:dump-ports`, `node-mapping:set` and the `will-quit` save. - `config.json` and `node-mapping.json` are byte-identical. `debug.log` differs only in its wall-clock timestamp. ## Not as briefed The review moved `Emit` to the contract: one type, no topic copies (commit `788ffc6`). - **`IpcRegistrar`, `IpcListener` and `CtrlChannel` in `@shared/app`.** The brief asked for an "ipcMain-like" parameter. This puts it in one place rather than seven: a topic can't import electron or app. `IpcListener` uses `any[]` for the arguments with an eslint-disable, because electron's own listener type does. - **The `Dialogs` port** resolves to a path or `undefined` rather than electron's result object. `CONFIG_MAX_BYTES` lives in `dialogs.ts`, as planned. The import's size check in `config/ipc.ts` reads it as `dialogs.configMaxBytes`, because config can't import app. - **ConfigTransfer is still built in `start()`**, as it was in `index.ts`, and not in AppServices. The brief didn't move it. It is a candidate for AppServices once it pushes (R3.3). Refs #99 --- .dependency-cruiser.severity.json | 4 +- package.json | 2 +- ...seam.test.ts => app-services.seam.test.ts} | 0 .../{session.test.ts => app-services.test.ts} | 22 +- src/main/app/{session.ts => app-services.ts} | 14 +- src/main/app/bootstrap.ts | 61 ++++ src/main/app/dialogs.ts | 81 +++++ src/main/app/index.ts | 291 +----------------- src/main/app/ipc.ts | 116 +++++++ src/main/app/window.ts | 45 +++ src/main/config/config-file.ts | 8 +- src/main/config/index.ts | 1 + src/main/config/ipc.ts | 62 ++++ src/main/config/settings-schema.ts | 4 +- src/main/dcs/dcs-relay.ts | 4 +- src/main/dcs/index.ts | 1 + src/main/dcs/ipc.ts | 55 ++++ src/main/dcs/stream-log.ts | 4 +- src/main/events/event-journal.ts | 5 +- src/main/events/index.ts | 1 + src/main/events/ipc.ts | 28 ++ src/main/gateway/gateway-connection.ts | 10 +- src/main/gateway/index.ts | 1 + src/main/gateway/ipc.ts | 18 ++ src/main/gateway/serial-traffic-monitor.ts | 4 +- .../calibration/hid-calibration-service.ts | 4 +- src/main/hid/calibration/index.ts | 3 +- src/main/hid/calibration/ipc.ts | 31 ++ src/main/hid/hid-reader.test.ts | 2 +- src/main/hid/hid-report.ts | 32 +- src/main/hid/hid-service.ts | 4 +- src/main/hid/index.ts | 1 + src/main/hid/ipc.ts | 8 + src/main/index.ts | 4 +- src/main/nodes/index.ts | 4 + src/main/nodes/ipc.ts | 26 ++ src/main/nodes/node-hub.ts | 6 +- src/main/test-harness/services.ts | 71 ++--- src/shared/app/ipc.ts | 24 ++ tsconfig.json | 9 +- tsconfig.test.json | 12 + 41 files changed, 697 insertions(+), 386 deletions(-) rename src/main/app/{session.seam.test.ts => app-services.seam.test.ts} (100%) rename src/main/app/{session.test.ts => app-services.test.ts} (98%) rename src/main/app/{session.ts => app-services.ts} (95%) create mode 100644 src/main/app/bootstrap.ts create mode 100644 src/main/app/dialogs.ts create mode 100644 src/main/app/ipc.ts create mode 100644 src/main/app/window.ts create mode 100644 src/main/config/ipc.ts create mode 100644 src/main/dcs/ipc.ts create mode 100644 src/main/events/ipc.ts create mode 100644 src/main/gateway/ipc.ts create mode 100644 src/main/hid/calibration/ipc.ts create mode 100644 src/main/hid/ipc.ts create mode 100644 src/main/nodes/ipc.ts create mode 100644 tsconfig.test.json diff --git a/.dependency-cruiser.severity.json b/.dependency-cruiser.severity.json index 52f696f..e42c196 100644 --- a/.dependency-cruiser.severity.json +++ b/.dependency-cruiser.severity.json @@ -9,8 +9,8 @@ "firmwareLeaf": "error", "formatLeaf": "error", "kitLeaf": "warn", - "topicsMain": "warn", + "topicsMain": "error", "topicsRenderer": "warn", - "deepMain": "warn", + "deepMain": "error", "deepRenderer": "warn" } diff --git a/package.json b/package.json index 644dddf..c465963 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:win": "npm run build && electron-builder --win", "build:mac": "npm run build && electron-builder --mac", "preview": "electron-vite preview", - "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.test.json", "lint": "eslint .", "lint:deps": "depcruise src --config .dependency-cruiser.cjs --output-type err-long", "format": "prettier --write .", diff --git a/src/main/app/session.seam.test.ts b/src/main/app/app-services.seam.test.ts similarity index 100% rename from src/main/app/session.seam.test.ts rename to src/main/app/app-services.seam.test.ts diff --git a/src/main/app/session.test.ts b/src/main/app/app-services.test.ts similarity index 98% rename from src/main/app/session.test.ts rename to src/main/app/app-services.test.ts index 570113e..da356f5 100644 --- a/src/main/app/session.test.ts +++ b/src/main/app/app-services.test.ts @@ -6,7 +6,7 @@ import type { LogEvent } from '@shared/events' import type { NodeModel } from '@shared/nodes' import { statusLine } from '../../test-fixtures/nodes' -// Session pulls in electron (debug log path), the serial port, the DCS transport and node-hid. +// AppServices pulls in electron (debug log path), the serial port, the DCS transport and node-hid. // Only the seams matter here, so each is replaced with something a test can drive directly. vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) @@ -126,7 +126,7 @@ vi.mock('../hid/hid-reader', () => ({ } })) -const { Session } = await import('./session') +const { AppServices } = await import('./app-services') const { deviceToDcs, exportToDevice, relayConfigChanged } = await import('../dcs/relay-rules') const { PortNotFoundError } = await import('../gateway/serial-bridge') const { nodeRosterRequest } = await import('../nodes/node-status-wire') @@ -135,7 +135,7 @@ const { DEFAULT_CONFIG } = await import('@shared/config') const ascii = (s: string) => Buffer.from(s, 'ascii') function bridgeSession() { - const session = new Session(() => {}) + const session = new AppServices(() => {}) session.settings.update({ sourceMode: 'bridge' }) session.open() FakeSerial.last!.openCb('/dev/fake') // a Bridge relay starts only with the board connected @@ -146,7 +146,7 @@ function bridgeSession() { return { session, serial, transport } } -describe('Session de-mux boundary', () => { +describe('AppServices de-mux boundary', () => { beforeEach(() => { FakeSerial.last = undefined FakeTransport.last = undefined @@ -212,7 +212,7 @@ describe('node telemetry tap', () => { vi.useFakeTimers() try { const pushes: [string, unknown][] = [] - const session = new Session((c, p) => pushes.push([c, p])) + const session = new AppServices((c, p) => pushes.push([c, p])) session.settings.update({ sourceMode: 'bridge' }) session.open() const serial = FakeSerial.last! @@ -262,7 +262,7 @@ describe('HID reporting latency', () => { // reference HID tester in the first place. vi.useFakeTimers() const hidEvents: boolean[][] = [] - const session = new Session((ch, payload) => { + const session = new AppServices((ch, payload) => { if (ch === 'hid:report') hidEvents.push([...(payload as { buttons: boolean[] }).buttons]) }) session.settings.update({ sourceMode: 'bridge' }) @@ -316,9 +316,11 @@ describe('HID recovery after an unplug', () => { type Push = [string, unknown] /** A session with the link open (app launched), the relay not started, and a recorder of pushes. */ -function launched(config: Parameters['settings']['update']>[0] = {}) { +function launched( + config: Parameters['settings']['update']>[0] = {} +) { const pushes: Push[] = [] - const session = new Session((c, p) => pushes.push([c, p])) + const session = new AppServices((c, p) => pushes.push([c, p])) session.settings.update(config) session.open() const serial = FakeSerial.last! @@ -681,7 +683,7 @@ describe('Gateway health (#94)', () => { FakeTransport.last = undefined }) - const rows = (session: InstanceType) => + const rows = (session: InstanceType) => session.journal.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) /** The Gateway's own row (node 0, kind 03, 42 °C), with these heartbeat flags. */ @@ -785,7 +787,7 @@ describe('event log (#64)', () => { FakeTransport.last = undefined }) - const rows = (session: InstanceType) => + const rows = (session: InstanceType) => session.journal.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) it('moves link errors out of the stream log into client events', () => { diff --git a/src/main/app/session.ts b/src/main/app/app-services.ts similarity index 95% rename from src/main/app/session.ts rename to src/main/app/app-services.ts index c8a7790..83b7d1d 100644 --- a/src/main/app/session.ts +++ b/src/main/app/app-services.ts @@ -1,13 +1,15 @@ // The main process's services, built and wired: the Gateway link (gateway/), the nodes (nodes/), // HID and calibration (hid/), and the DCS side — the relay, its network end, the stream log, the // stats and the recorder (dcs/), the settings (config/) and the event log (events/). Each lives -// with its owner; Session only builds and connects them, and answers the rehydrate bundle. +// with its owner; AppServices only builds and connects them, and answers the rehydrate bundle. +// It holds no rules and no state (UD-F): it is the seam the contract tests build with the edges +// faked (test-harness/services.ts). // // The device link is not started or stopped by the relay. It is up whenever the board is plugged // in (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and // whether it reaches the device. import type { DeviceStatus } from '@shared/gateway' -import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' +import type { Emit, RelayStatus } from '@shared/app' import { app } from 'electron' import { DcsConnection, DcsRelay, SessionRecorder, Stats, StreamLog } from '../dcs' import { SettingsService } from '../config' @@ -19,9 +21,7 @@ import { NodeHub, NodeRoster } from '../nodes' import { debugLog, setDebugEnabled } from './debug-log' import { storeFiles, storePaths, type StorePaths } from './user-data' -type Emit = (channel: C, payload: PushChannels[C]) => void - -export class Session { +export class AppServices { /** The Gateway's USB serial port. Up independently of the relay. */ readonly connection: GatewayConnection /** The port's text lines, read: node lines to NodeHub, panel commands to the log. */ @@ -49,8 +49,8 @@ export class Session { private readonly stats = new Stats() /** - * `paths`: the files, from app/index.ts. The default resolves the same paths the - * same way, for callers that build a Session on its own. + * `paths`: the files, from app/bootstrap.ts. The default resolves the same paths + * the same way, for callers that build an AppServices on its own. */ constructor(emit: Emit, paths: StorePaths = storePaths(() => app.getPath('userData'))) { const { mappingFile, cacheFile } = storeFiles(paths) diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts new file mode 100644 index 0000000..0902a7b --- /dev/null +++ b/src/main/app/bootstrap.ts @@ -0,0 +1,61 @@ +// App launch and quit: the userData override, the services, the window and the IPC, in the order +// electron needs them. +import { app, BrowserWindow, ipcMain } from 'electron' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { ConfigTransfer } from '../config' +import { loadCalCache, saveCalCache } from '../hid/calibration' +import { AppServices } from './app-services' +import { dialogs } from './dialogs' +import { registerIpc } from './ipc' +import { storeFiles, storePaths, userDataOverride } from './user-data' +import { createWindow, emit } from './window' + +export function start(): void { + // Before anything touches userData: dev worktrees get their own folder (#81). + const userDataDir = userDataOverride({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + env: process.env, + cwd: process.cwd() + }) + if (userDataDir) { + mkdirSync(userDataDir, { recursive: true }) // setPath throws on a missing folder + app.setPath('userData', userDataDir) + } + + /** The files, handed to the topics that own them (P6: only app asks electron). */ + const paths = storePaths(() => app.getPath('userData')) + /** The same files as ports, for ConfigTransfer's calibration section. */ + const { cacheFile } = storeFiles(paths) + + const services = new AppServices(emit, paths) + + const transfer = new ConfigTransfer({ + getConfig: () => services.settings.get(), + setConfig: (config) => services.settings.apply(config), + nodes: services.nodes, + loadCalCache: () => loadCalCache(cacheFile), + saveCalCache: (cache) => saveCalCache(cache, cacheFile), + backupDir: () => join(app.getPath('userData'), 'backups'), + clientVersion: () => app.getVersion() + }) + + app.whenReady().then(() => { + services.settings.load() // seed from persisted settings + registerIpc(ipcMain, { services, transfer, dialogs }) + createWindow() + services.open() // the device link is up from launch, relaying or not (#69) + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) + }) + + app.on('window-all-closed', () => { + services.relay.stop() + if (process.platform !== 'darwin') app.quit() + }) + + // Releases the port: the link otherwise holds it for as long as the app runs. + app.on('will-quit', () => services.shutdown()) +} diff --git a/src/main/app/dialogs.ts b/src/main/app/dialogs.ts new file mode 100644 index 0000000..fcab071 --- /dev/null +++ b/src/main/app/dialogs.ts @@ -0,0 +1,81 @@ +// The save and open dialogs the intents show, as one `Dialogs` port: each topic's ipc.ts declares +// the dialogs it needs, and this answers them with electron's. Each resolves to the picked path, +// or undefined when the dialog was canceled or came back without one. +import { dialog } from 'electron' +import type { ConfigDialogs } from '../config' +import type { DcsDialogs } from '../dcs' +import type { EventsDialogs } from '../events' + +export type Dialogs = ConfigDialogs & DcsDialogs & EventsDialogs + +const CONFIG_FILTER = [{ name: 'OpenSkyhawk configuration', extensions: ['json'] }] +/** Generous: a full export is a few KB. Anything bigger is not one. */ +const CONFIG_MAX_BYTES = 5 * 1024 * 1024 + +const CAPTURE_FILTER = [{ name: 'DCS-BIOS capture', extensions: ['json'] }] + +export const dialogs: Dialogs = { + configMaxBytes: CONFIG_MAX_BYTES, + + async saveConfig(defaultPath) { + const res = await dialog.showSaveDialog({ + title: 'Export configuration', + defaultPath, + filters: CONFIG_FILTER + }) + if (res.canceled || !res.filePath) return undefined + return res.filePath + }, + + async openConfig() { + const res = await dialog.showOpenDialog({ + title: 'Import configuration', + properties: ['openFile'], + filters: CONFIG_FILTER + }) + const path = res.filePaths[0] + if (res.canceled || !path) return undefined + return path + }, + + async saveLog() { + const res = await dialog.showSaveDialog({ + title: 'Export log', + defaultPath: `skyhawk-log-${Date.now()}.tsv`, + filters: [{ name: 'Log', extensions: ['tsv', 'txt'] }] + }) + if (res.canceled || !res.filePath) return undefined + return res.filePath + }, + + async saveEvents() { + const res = await dialog.showSaveDialog({ + title: 'Export events', + defaultPath: `skyhawk-events-${Date.now()}.tsv`, + filters: [{ name: 'Events', extensions: ['tsv', 'txt'] }] + }) + if (res.canceled || !res.filePath) return undefined + return res.filePath + }, + + async saveCapture() { + const res = await dialog.showSaveDialog({ + title: 'Record DCS-BIOS capture', + defaultPath: `skyhawk-capture-${Date.now()}.json`, + filters: CAPTURE_FILTER + }) + if (res.canceled || !res.filePath) return undefined + return res.filePath + }, + + async openCapture() { + const res = await dialog.showOpenDialog({ + title: 'Open DCS-BIOS capture', + properties: ['openFile'], + filters: CAPTURE_FILTER + }) + const path = res.filePaths[0] + if (res.canceled || !path) return undefined + return path + } +} diff --git a/src/main/app/index.ts b/src/main/app/index.ts index 0aef3bd..3b1dcf7 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -1,289 +1,2 @@ -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { CTRL, type PushChannel, type PushChannels } from '@shared/app' -import type { AppConfig, ConfigExportResult, ConfigImportOpen } from '@shared/config' -import type { CalCacheEntry, CalCommitAxis } from '@shared/hid/calibration' -import type { CaptureState, ReplayLoad } from '@shared/dcs' -import type { HidAvailability } from '@shared/hid' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' -import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' -import { ConfigTransfer } from '../config' -import { listSerialPorts } from '../gateway' -import { loadCalCache, saveCalCache } from '../hid/calibration' -import { Session } from './session' -import { debugLog, debugLogPath } from './debug-log' -import { storeFiles, storePaths, userDataOverride } from './user-data' - -// Before anything touches userData: dev worktrees get their own folder (#81). -const userDataDir = userDataOverride({ - isPackaged: app.isPackaged, - appPath: app.getAppPath(), - env: process.env, - cwd: process.cwd() -}) -if (userDataDir) { - mkdirSync(userDataDir, { recursive: true }) // setPath throws on a missing folder - app.setPath('userData', userDataDir) -} - -/** The files, handed to the topics that own them (P6: only app asks electron). */ -const paths = storePaths(() => app.getPath('userData')) -/** The same files as ports, for ConfigTransfer's calibration section. */ -const { cacheFile } = storeFiles(paths) - -/** Report indices the firmware catalogues, derived from HIDControls.h. */ -function hidAvailability(): HidAvailability { - const axes: number[] = [] - const hats: number[] = [] - const buttons: number[] = [] - for (const c of HID_CONTROLS) { - if ( - c.id >= HID_ID.AXIS_MIN && - c.id <= HID_ID.AXIS_MAX && - c.id - HID_ID.AXIS_MIN < HID_REPORT_LAYOUT.axes.count - ) { - axes.push(c.id - HID_ID.AXIS_MIN) - } else if ( - c.id >= HID_ID.HAT_MIN && - c.id <= HID_ID.HAT_MAX && - c.id - HID_ID.HAT_MIN < HID_REPORT_LAYOUT.hats.count - ) { - hats.push(c.id - HID_ID.HAT_MIN) - } else if ( - c.id >= HID_ID.BUTTON_MIN && - c.id <= HID_ID.BUTTON_MAX && - c.id - HID_ID.BUTTON_MIN < HID_REPORT_LAYOUT.buttons.count - ) { - buttons.push(c.id - HID_ID.BUTTON_MIN) - } - } - return { axes, hats, buttons } -} - -let mainWindow: BrowserWindow | null = null - -function emit(channel: C, payload: PushChannels[C]): void { - mainWindow?.webContents.send(channel, payload) -} - -const session = new Session(emit, paths) - -const transfer = new ConfigTransfer({ - getConfig: () => session.settings.get(), - setConfig: (config) => session.settings.apply(config), - nodes: session.nodes, - loadCalCache: () => loadCalCache(cacheFile), - saveCalCache: (cache) => saveCalCache(cache, cacheFile), - backupDir: () => join(app.getPath('userData'), 'backups'), - clientVersion: () => app.getVersion() -}) - -const CONFIG_FILTER = [{ name: 'OpenSkyhawk configuration', extensions: ['json'] }] -/** Generous: a full export is a few KB. Anything bigger is not one. */ -const CONFIG_MAX_BYTES = 5 * 1024 * 1024 - -const CAPTURE_FILTER = [{ name: 'DCS-BIOS capture', extensions: ['json'] }] - -function registerIpc(): void { - ipcMain.handle(CTRL.configGet, () => session.settings.get()) - ipcMain.handle(CTRL.configSet, (_e, patch: Partial) => session.settings.apply(patch)) - ipcMain.handle(CTRL.configExport, async (): Promise => { - const res = await dialog.showSaveDialog({ - title: 'Export configuration', - defaultPath: transfer.defaultFileName(), - filters: CONFIG_FILTER - }) - if (res.canceled || !res.filePath) return {} - try { - writeFileSync(res.filePath, transfer.exportText()) - return { path: res.filePath } - } catch (err) { - return { error: `Could not write the file: ${err instanceof Error ? err.message : err}` } - } - }) - ipcMain.handle(CTRL.configImportOpen, async (): Promise => { - const res = await dialog.showOpenDialog({ - title: 'Import configuration', - properties: ['openFile'], - filters: CONFIG_FILTER - }) - const path = res.filePaths[0] - if (res.canceled || !path) return { status: 'canceled' } - try { - if (statSync(path).size > CONFIG_MAX_BYTES) { - return { status: 'error', error: 'This file is too large to be a configuration file.' } - } - return transfer.preview(readFileSync(path, 'utf8'), path) - } catch (err) { - return { - status: 'error', - error: `Could not read the file: ${err instanceof Error ? err.message : err}` - } - } - }) - ipcMain.handle(CTRL.configImportApply, (_e, token: string, sections: unknown, backup: unknown) => - transfer.apply(token, sections, backup) - ) - ipcMain.handle(CTRL.relayStart, () => session.relay.start()) - ipcMain.handle(CTRL.relayStop, () => session.relay.stop()) - ipcMain.handle(CTRL.relayStatus, () => session.status()) - ipcMain.handle(CTRL.deviceConnect, () => session.connection.connect()) - ipcMain.handle(CTRL.deviceDisconnect, () => session.connection.disconnect()) - ipcMain.handle(CTRL.hidAvailability, () => hidAvailability()) - ipcMain.handle(CTRL.nodesRefresh, () => session.nodes.requestNodes()) - ipcMain.handle(CTRL.nodesGet, () => session.nodes.snapshot()) - ipcMain.handle(CTRL.nodeMappingSet, (_e, id: number, patch: MappingPatch) => - session.nodes.setMapping(id, patch) - ) - ipcMain.handle(CTRL.nodeMappingAdopt, (_e, id: number, spec: AdoptSpec) => - session.nodes.adopt(id, spec) - ) - ipcMain.handle(CTRL.nodeMappingForget, (_e, id: number) => session.nodes.forget(id)) - ipcMain.handle(CTRL.nodeMappingResetNode, (_e, id: number) => session.nodes.resetBoard(id)) - ipcMain.handle(CTRL.nodeMappingReset, (_e, opts?: ResetOptions) => session.nodes.resetAll(opts)) - ipcMain.handle(CTRL.nodeMappingResetPreview, () => session.nodes.previewReset()) - ipcMain.handle(CTRL.nodeRegistry, () => session.nodes.registry()) - ipcMain.handle(CTRL.nodeMappingHide, (_e, id: number, on: boolean) => session.nodes.hide(id, on)) - ipcMain.handle(CTRL.serialMonitor, (_e, on: boolean) => - session.serialMonitor.setSerialMonitor(on) - ) - - // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: - // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the - // timeout-vs-nack distinction the UI's failure messaging depends on. - ipcMain.handle(CTRL.calHello, () => session.calibration.calHello()) - ipcMain.handle(CTRL.calRead, () => session.calibration.calRead()) - ipcMain.handle(CTRL.calSessionOpen, (_e, axisIdx: number) => - session.calibration.calSessionOpen(axisIdx) - ) - ipcMain.handle(CTRL.calStreamSelect, (_e, axisIdx: number) => - session.calibration.calStreamSelect(axisIdx) - ) - ipcMain.handle(CTRL.calCommit, (_e, axis: CalCommitAxis) => session.calibration.calCommit(axis)) - ipcMain.handle(CTRL.calReset, (_e, idx: number) => session.calibration.calReset(idx)) - ipcMain.handle(CTRL.calCacheRead, () => session.calibration.calCacheRead()) - ipcMain.handle(CTRL.calCacheStore, (_e, axes: CalCacheEntry[]) => - session.calibration.calCacheStore(axes) - ) - ipcMain.handle(CTRL.calCacheDrop, (_e, idx: number) => session.calibration.calCacheDrop(idx)) - ipcMain.handle(CTRL.calSessionClose, () => session.calibration.calSessionClose()) - - ipcMain.handle(CTRL.logExport, async (_e, text: string) => { - const res = await dialog.showSaveDialog({ - title: 'Export log', - defaultPath: `skyhawk-log-${Date.now()}.tsv`, - filters: [{ name: 'Log', extensions: ['tsv', 'txt'] }] - }) - if (res.canceled || !res.filePath) return {} - writeFileSync(res.filePath, text) - return { path: res.filePath } - }) - - ipcMain.handle(CTRL.eventsGet, () => session.journal.snapshot()) - ipcMain.handle(CTRL.eventsClear, () => session.journal.clear()) - ipcMain.handle(CTRL.eventsExport, async () => { - const text = session.journal.exportTsv() // the rows as they are when asked - const res = await dialog.showSaveDialog({ - title: 'Export events', - defaultPath: `skyhawk-events-${Date.now()}.tsv`, - filters: [{ name: 'Events', extensions: ['tsv', 'txt'] }] - }) - if (res.canceled || !res.filePath) return {} - writeFileSync(res.filePath, text) - return { path: res.filePath } - }) - - ipcMain.handle(CTRL.debugDumpPorts, async () => { - const ports = await listSerialPorts() - debugLog('serial.list', ports, true) // force-write regardless of the toggle - return { path: debugLogPath(), count: ports.length } - }) - ipcMain.handle(CTRL.debugReveal, () => { - shell.showItemInFolder(debugLogPath()) - }) - - ipcMain.handle(CTRL.captureToggle, async (): Promise => { - if (session.recorder.isRecording()) { - const { path, events } = session.recorder.stopRecording() - return { recording: false, path, events } - } - const res = await dialog.showSaveDialog({ - title: 'Record DCS-BIOS capture', - defaultPath: `skyhawk-capture-${Date.now()}.json`, - filters: CAPTURE_FILTER - }) - if (res.canceled || !res.filePath) return { recording: false } - session.recorder.startRecording(res.filePath) - return { recording: true, path: res.filePath } - }) - - ipcMain.handle(CTRL.replayOpen, async (): Promise => { - const res = await dialog.showOpenDialog({ - title: 'Open DCS-BIOS capture', - properties: ['openFile'], - filters: CAPTURE_FILTER - }) - const path = res.filePaths[0] - if (res.canceled || !path) return { loaded: false } - try { - const info = session.relay.loadReplay(path) - return { loaded: true, path, events: info.events, durationMs: info.durationMs } - } catch { - return { loaded: false } - } - }) -} - -function createWindow(): void { - const win = new BrowserWindow({ - width: 1280, - height: 820, - minWidth: 940, - minHeight: 600, - show: false, - backgroundColor: '#0a0e1a', - title: 'OpenSkyhawk Client', - autoHideMenuBar: true, - webPreferences: { - preload: join(__dirname, '../preload/index.js'), - sandbox: false - } - }) - mainWindow = win - - win.on('ready-to-show', () => win.show()) - win.on('closed', () => { - if (mainWindow === win) mainWindow = null - }) - - win.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url) - return { action: 'deny' } - }) - - // electron-vite sets ELECTRON_RENDERER_URL in dev; load the built file otherwise. - if (process.env.ELECTRON_RENDERER_URL) { - void win.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - void win.loadFile(join(__dirname, '../renderer/index.html')) - } -} - -app.whenReady().then(() => { - session.settings.load() // seed from persisted settings - registerIpc() - createWindow() - session.open() // the device link is up from launch, relaying or not (#69) - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() - }) -}) - -app.on('window-all-closed', () => { - session.relay.stop() - if (process.platform !== 'darwin') app.quit() -}) - -// Releases the port: the link otherwise holds it for as long as the app runs. -app.on('will-quit', () => session.shutdown()) +// app: launches the main process. Exports only the entry (arch-phase0-v2 §2.1). +export { start } from './bootstrap' diff --git a/src/main/app/ipc.ts b/src/main/app/ipc.ts new file mode 100644 index 0000000..0641136 --- /dev/null +++ b/src/main/app/ipc.ts @@ -0,0 +1,116 @@ +// Every renderer → main intent, registered on ipcMain. Each topic registers its own (its ipc.ts); +// app answers the three that span topics: the relay:status rehydrate bundle and the debug tools. +// +// The registration order is today's, kept on purpose (R2.3c): it interleaves topics (config, dcs, +// app, gateway, hid, nodes, gateway, calibration, dcs, events, app, dcs), so the topics register +// into a collector and IPC_ORDER hands the handlers to ipcMain in that order. +import { shell } from 'electron' +import { APP_CTRL, type CtrlChannel, type IpcListener, type IpcRegistrar } from '@shared/app' +import { registerConfigIpc, type ConfigTransfer } from '../config' +import { registerDcsIpc } from '../dcs' +import { registerEventsIpc } from '../events' +import { listSerialPorts, registerGatewayIpc } from '../gateway' +import { registerHidIpc } from '../hid' +import { registerCalibrationIpc } from '../hid/calibration' +import { registerNodesIpc } from '../nodes' +import type { AppServices } from './app-services' +import { debugLog, debugLogPath } from './debug-log' +import type { Dialogs } from './dialogs' + +/** Every intent channel, in the order ipcMain registers it. */ +const IPC_ORDER = [ + 'config:get', + 'config:set', + 'config:export', + 'config:import-open', + 'config:import-apply', + 'relay:start', + 'relay:stop', + 'relay:status', + 'device:connect', + 'device:disconnect', + 'hid:availability', + 'nodes:refresh', + 'nodes:get', + 'node-mapping:set', + 'node-mapping:adopt', + 'node-mapping:forget', + 'node-mapping:reset-node', + 'node-mapping:reset', + 'node-mapping:reset-preview', + 'nodes:registry', + 'node-mapping:hide', + 'serial:monitor', + 'cal:hello', + 'cal:read', + 'cal:session-open', + 'cal:stream-select', + 'cal:commit', + 'cal:reset', + 'cal:cache-read', + 'cal:cache-store', + 'cal:cache-drop', + 'cal:session-close', + 'log:export', + 'events:get', + 'events:clear', + 'events:export', + 'debug:dump-ports', + 'debug:reveal', + 'capture:toggle', + 'replay:open' +] as const satisfies readonly CtrlChannel[] + +/** IPC_ORDER's type when it lists every channel; otherwise the channels it leaves out. */ +type EveryChannel = [Exclude] extends [ + never +] + ? T + : { unordered: Exclude } +// A CTRL channel missing from IPC_ORDER fails the typecheck here, and names the channel. +const order: EveryChannel = IPC_ORDER + +export interface IpcDeps { + services: AppServices + transfer: ConfigTransfer + dialogs: Dialogs +} + +export function registerIpc(ipcMain: IpcRegistrar, deps: IpcDeps): void { + const { services, transfer, dialogs } = deps + const handlers = new Map() + const ipc: IpcRegistrar = { + handle(channel, listener) { + // As ipcMain does: one handler per channel. + if (handlers.has(channel)) throw new Error(`a second handler for ${channel}`) + handlers.set(channel, listener) + } + } + + registerConfigIpc(ipc, { settings: services.settings, transfer, dialogs }) + registerDcsIpc(ipc, { relay: services.relay, recorder: services.recorder, dialogs }) + registerGatewayIpc(ipc, { + connection: services.connection, + serialMonitor: services.serialMonitor + }) + registerHidIpc(ipc) + registerNodesIpc(ipc, { nodes: services.nodes }) + registerCalibrationIpc(ipc, { calibration: services.calibration }) + registerEventsIpc(ipc, { journal: services.journal, dialogs }) + + ipc.handle(APP_CTRL.relayStatus, () => services.status()) + ipc.handle(APP_CTRL.debugDumpPorts, async () => { + const ports = await listSerialPorts() + debugLog('serial.list', ports, true) // force-write regardless of the toggle + return { path: debugLogPath(), count: ports.length } + }) + ipc.handle(APP_CTRL.debugReveal, () => { + shell.showItemInFolder(debugLogPath()) + }) + + for (const channel of order) { + const listener = handlers.get(channel) + if (!listener) throw new Error(`no handler for ${channel}`) + ipcMain.handle(channel, listener) + } +} diff --git a/src/main/app/window.ts b/src/main/app/window.ts new file mode 100644 index 0000000..2bd8510 --- /dev/null +++ b/src/main/app/window.ts @@ -0,0 +1,45 @@ +// The app's one window, and `emit`: every main → renderer push goes through it. +import { BrowserWindow, shell } from 'electron' +import { join } from 'node:path' +import type { PushChannel, PushChannels } from '@shared/app' + +let mainWindow: BrowserWindow | null = null + +export function emit(channel: C, payload: PushChannels[C]): void { + mainWindow?.webContents.send(channel, payload) +} + +export function createWindow(): void { + const win = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 940, + minHeight: 600, + show: false, + backgroundColor: '#0a0e1a', + title: 'OpenSkyhawk Client', + autoHideMenuBar: true, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + mainWindow = win + + win.on('ready-to-show', () => win.show()) + win.on('closed', () => { + if (mainWindow === win) mainWindow = null + }) + + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url) + return { action: 'deny' } + }) + + // electron-vite sets ELECTRON_RENDERER_URL in dev; load the built file otherwise. + if (process.env.ELECTRON_RENDERER_URL) { + void win.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + void win.loadFile(join(__dirname, '../renderer/index.html')) + } +} diff --git a/src/main/config/config-file.ts b/src/main/config/config-file.ts index 538a605..bd07dc0 100644 --- a/src/main/config/config-file.ts +++ b/src/main/config/config-file.ts @@ -24,12 +24,8 @@ import type { CachedAxis, CalCache } from '@shared/hid/calibration' import type { MappedNode, NodeDefaults, NodeMappingFile, PsuSetting } from '@shared/nodes' import { PSU_PRESETS } from '@shared/nodes/legacy-rules' import { onOff, plural, PSU_LIMITS } from '@shared/format' -// Deep imports of pure models, not the topic indexes: those also export services (NodeHub, -// CalLink) that need Node types, and the renderer's test helpers import this file (and typecheck -// it without them). -import { sanitizeCache } from '../hid/calibration/cache-rules' -import { effectiveMapping, importMapping, sanitizeNodeMapping } from '../nodes/mapping-rules' -import { sanitizePsu } from '../nodes/psu-limits' +import { sanitizeCache } from '../hid/calibration' +import { effectiveMapping, importMapping, sanitizeNodeMapping, sanitizePsu } from '../nodes' /** File format this build writes. A higher version came from a newer client. */ export const CONFIG_FILE_VERSION = 1 diff --git a/src/main/config/index.ts b/src/main/config/index.ts index 1d4d72d..96c3d16 100644 --- a/src/main/config/index.ts +++ b/src/main/config/index.ts @@ -3,3 +3,4 @@ export { SettingsService } from './settings-service' export { ConfigTransfer } from './config-transfer' export { JSON_POLICY, jsonFile, type JsonFile, type JsonPolicy, type JsonRead } from './json-store' +export { registerConfigIpc, type ConfigDialogs } from './ipc' diff --git a/src/main/config/ipc.ts b/src/main/config/ipc.ts new file mode 100644 index 0000000..8474576 --- /dev/null +++ b/src/main/config/ipc.ts @@ -0,0 +1,62 @@ +// config's intents: the settings (config:get / config:set) and the configuration file's export and +// import. app/ipc.ts registers them, in its order. +import { readFileSync, statSync, writeFileSync } from 'node:fs' +import type { IpcRegistrar } from '@shared/app' +import { + CONFIG_CTRL, + type AppConfig, + type ConfigExportResult, + type ConfigImportOpen +} from '@shared/config' +import type { ConfigTransfer } from './config-transfer' +import type { SettingsService } from './settings-service' + +/** The dialogs config's intents open (app/dialogs.ts). Each resolves to the picked path, if any. */ +export interface ConfigDialogs { + saveConfig(defaultPath: string): Promise + openConfig(): Promise + /** The largest file an import reads. */ + readonly configMaxBytes: number +} + +export interface ConfigIpcDeps { + settings: SettingsService + transfer: ConfigTransfer + dialogs: ConfigDialogs +} + +export function registerConfigIpc(ipc: IpcRegistrar, deps: ConfigIpcDeps): void { + const { settings, transfer, dialogs } = deps + ipc.handle(CONFIG_CTRL.configGet, () => settings.get()) + ipc.handle(CONFIG_CTRL.configSet, (_e, patch: Partial) => settings.apply(patch)) + ipc.handle(CONFIG_CTRL.configExport, async (): Promise => { + const filePath = await dialogs.saveConfig(transfer.defaultFileName()) + if (!filePath) return {} + try { + writeFileSync(filePath, transfer.exportText()) + return { path: filePath } + } catch (err) { + return { error: `Could not write the file: ${err instanceof Error ? err.message : err}` } + } + }) + ipc.handle(CONFIG_CTRL.configImportOpen, async (): Promise => { + const path = await dialogs.openConfig() + if (!path) return { status: 'canceled' } + try { + if (statSync(path).size > dialogs.configMaxBytes) { + return { status: 'error', error: 'This file is too large to be a configuration file.' } + } + return transfer.preview(readFileSync(path, 'utf8'), path) + } catch (err) { + return { + status: 'error', + error: `Could not read the file: ${err instanceof Error ? err.message : err}` + } + } + }) + ipc.handle( + CONFIG_CTRL.configImportApply, + (_e, token: string, sections: unknown, backup: unknown) => + transfer.apply(token, sections, backup) + ) +} diff --git a/src/main/config/settings-schema.ts b/src/main/config/settings-schema.ts index 6af15e8..0a9fe97 100644 --- a/src/main/config/settings-schema.ts +++ b/src/main/config/settings-schema.ts @@ -8,9 +8,7 @@ import { type DcsTransport, type SourceMode } from '@shared/config' -// Deep import of a pure model, not '../nodes': that index also exports NodeHub, which needs Node -// types, and the renderer's test helpers reach this file through config-file. -import { sanitizePsu } from '../nodes/psu-limits' +import { sanitizePsu } from '../nodes' /** Coerce arbitrary (e.g. persisted/untrusted) data into a valid AppConfig. */ export function sanitizeConfig(raw: unknown): AppConfig { diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts index 45ee790..d0ab49a 100644 --- a/src/main/dcs/dcs-relay.ts +++ b/src/main/dcs/dcs-relay.ts @@ -7,7 +7,7 @@ // (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and // whether it reaches the device. import type { RelayResult, RelayState } from '@shared/dcs' -import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' +import type { Emit, RelayStatus } from '@shared/app' import type { LogEventInput } from '@shared/events' import { isLinkUp, type DeviceStatus } from '@shared/gateway' import { DcsBiosProtocol } from './bios-protocol' @@ -20,8 +20,6 @@ import { Decoder, flushTelemetry } from './sim-telemetry-decoder' import { STATS_MS, type Stats } from './stream-stats' import type { StreamLog } from './stream-log' -type Emit = (channel: C, payload: PushChannels[C]) => void - const TELEMETRY_MS = 200 /** The Gateway link, as the relay uses it (GatewayConnection). */ diff --git a/src/main/dcs/index.ts b/src/main/dcs/index.ts index cfcd308..99539a3 100644 --- a/src/main/dcs/index.ts +++ b/src/main/dcs/index.ts @@ -4,3 +4,4 @@ export { DcsRelay } from './dcs-relay' export { SessionRecorder } from './session-recorder' export { Stats } from './stream-stats' export { StreamLog } from './stream-log' +export { registerDcsIpc, type DcsDialogs } from './ipc' diff --git a/src/main/dcs/ipc.ts b/src/main/dcs/ipc.ts new file mode 100644 index 0000000..fe02aab --- /dev/null +++ b/src/main/dcs/ipc.ts @@ -0,0 +1,55 @@ +// dcs's intents: relay Start / Stop, the Log tab's export, recording a capture and opening one to +// replay. app/ipc.ts registers them, in its order (they are not registered together). +import { writeFileSync } from 'node:fs' +import type { IpcRegistrar } from '@shared/app' +import { DCS_CTRL, type CaptureState, type ReplayLoad } from '@shared/dcs' +import type { DcsRelay } from './dcs-relay' +import type { SessionRecorder } from './session-recorder' + +/** The dialogs dcs's intents open (app/dialogs.ts). Each resolves to the picked path, if any. */ +export interface DcsDialogs { + saveLog(): Promise + saveCapture(): Promise + openCapture(): Promise +} + +export interface DcsIpcDeps { + relay: DcsRelay + recorder: SessionRecorder + dialogs: DcsDialogs +} + +export function registerDcsIpc(ipc: IpcRegistrar, deps: DcsIpcDeps): void { + const { relay, recorder, dialogs } = deps + ipc.handle(DCS_CTRL.relayStart, () => relay.start()) + ipc.handle(DCS_CTRL.relayStop, () => relay.stop()) + + ipc.handle(DCS_CTRL.logExport, async (_e, text: string) => { + const filePath = await dialogs.saveLog() + if (!filePath) return {} + writeFileSync(filePath, text) + return { path: filePath } + }) + + ipc.handle(DCS_CTRL.captureToggle, async (): Promise => { + if (recorder.isRecording()) { + const { path, events } = recorder.stopRecording() + return { recording: false, path, events } + } + const filePath = await dialogs.saveCapture() + if (!filePath) return { recording: false } + recorder.startRecording(filePath) + return { recording: true, path: filePath } + }) + + ipc.handle(DCS_CTRL.replayOpen, async (): Promise => { + const path = await dialogs.openCapture() + if (!path) return { loaded: false } + try { + const info = relay.loadReplay(path) + return { loaded: true, path, events: info.events, durationMs: info.durationMs } + } catch { + return { loaded: false } + } + }) +} diff --git a/src/main/dcs/stream-log.ts b/src/main/dcs/stream-log.ts index fa2b755..fc7fd76 100644 --- a/src/main/dcs/stream-log.ts +++ b/src/main/dcs/stream-log.ts @@ -2,12 +2,10 @@ // the export, batched to the renderer on a 33 ms flush. The same flush pushes the event log's new // rows first (its `events:batch`), as it did in Session. import type { LogRow } from '@shared/dcs' -import type { PushChannel, PushChannels } from '@shared/app' +import type { Emit } from '@shared/app' import type { DeviceLine } from '../gateway' import type { Stats } from './stream-stats' -type Emit = (channel: C, payload: PushChannels[C]) => void - /** The event log, as this flush drives it (EventJournal). */ export interface StreamLogEvents { /** Push the event log's new rows (`events:batch`). */ diff --git a/src/main/events/event-journal.ts b/src/main/events/event-journal.ts index eb245b6..d769f7c 100644 --- a/src/main/events/event-journal.ts +++ b/src/main/events/event-journal.ts @@ -3,12 +3,11 @@ // relay's rows (DcsRelay), client errors, and the Gateway link's state rows. The rows themselves // are made where they happen (nodes, gateway/link-events.ts, dcs/relay-events.ts); this only // records them, dedupes the repeats, and pushes what is new on the stream log's flush. +import type { Emit } from '@shared/app' import type { EventsPushChannels, LogEvent, LogEventInput } from '@shared/events' import type { DeviceState } from '@shared/gateway' import { clientErrorEvent, EventLog, eventsToTsv } from './event-log' -type Emit = (channel: C, payload: EventsPushChannels[C]) => void - /** Where client errors also go (app's debug log). */ export type JournalLog = (tag: string, data?: unknown) => void @@ -21,7 +20,7 @@ export class EventJournal { private lastErr = new Map() constructor( - private readonly emit: Emit, + private readonly emit: Emit, private readonly log: JournalLog ) {} diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 8f23c41..0f82a9d 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -2,3 +2,4 @@ // (arch-phase0-v2 §2.1). export { EventJournal } from './event-journal' export { faultLabel, STATUS_FLAGS, type StatusFlag } from './status-flags' +export { registerEventsIpc, type EventsDialogs } from './ipc' diff --git a/src/main/events/ipc.ts b/src/main/events/ipc.ts new file mode 100644 index 0000000..54cf424 --- /dev/null +++ b/src/main/events/ipc.ts @@ -0,0 +1,28 @@ +// events' intents: the event log (#64) read, cleared and exported. app/ipc.ts registers them. +import { writeFileSync } from 'node:fs' +import type { IpcRegistrar } from '@shared/app' +import { EVENTS_CTRL } from '@shared/events' +import type { EventJournal } from './event-journal' + +/** The dialog events' export opens (app/dialogs.ts). Resolves to the picked path, if any. */ +export interface EventsDialogs { + saveEvents(): Promise +} + +export interface EventsIpcDeps { + journal: EventJournal + dialogs: EventsDialogs +} + +export function registerEventsIpc(ipc: IpcRegistrar, deps: EventsIpcDeps): void { + const { journal, dialogs } = deps + ipc.handle(EVENTS_CTRL.eventsGet, () => journal.snapshot()) + ipc.handle(EVENTS_CTRL.eventsClear, () => journal.clear()) + ipc.handle(EVENTS_CTRL.eventsExport, async () => { + const text = journal.exportTsv() // the rows as they are when asked + const filePath = await dialogs.saveEvents() + if (!filePath) return {} + writeFileSync(filePath, text) + return { path: filePath } + }) +} diff --git a/src/main/gateway/gateway-connection.ts b/src/main/gateway/gateway-connection.ts index 1d6429a..1123652 100644 --- a/src/main/gateway/gateway-connection.ts +++ b/src/main/gateway/gateway-connection.ts @@ -11,7 +11,7 @@ // Bundled together, there was no node status, PDU telemetry, HID or calibration unless DCS was // being relayed, and stopping the relay tore the port down with it. import { isLinkUp, type DeviceStatus, type GatewayStatus } from '@shared/gateway' -import type { PushChannel, PushChannels } from '@shared/app' +import type { Emit } from '@shared/app' import { findSimGatewayPort, listSerialPorts, @@ -22,8 +22,6 @@ import { type Log } from './serial-bridge' -type Emit = (channel: C, payload: PushChannels[C]) => void - /** * The calibration frame filter on the incoming byte path (hid/calibration's HidCalibrationService * supplies it). GatewayConnection runs it on every chunk before anything else sees the bytes. @@ -43,9 +41,9 @@ export type LinkTimer = (ms: number, fn: () => void) => void * * At each moment GatewayConnection calls its riders in the order they were added with `ride()`, * at exactly the point where that rider's code sat inside DeviceLink before the split, so the - * order of side effects is unchanged. Session adds the serial monitor, calibration, HID, the node - * layer (NodeHub's rider) and the line tap, in that order: see app/session.ts for which rider takes - * part in which moment. + * order of side effects is unchanged. AppServices adds the serial monitor, calibration, HID, the + * node layer (NodeHub's rider) and the line tap, in that order: see app/app-services.ts for which + * rider takes part in which moment. */ export interface LinkRider { /** diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts index 0c978cc..0c68246 100644 --- a/src/main/gateway/index.ts +++ b/src/main/gateway/index.ts @@ -12,3 +12,4 @@ export { GatewayWatch } from './gateway-health' export { encodeExportFrame } from './link-framing' export { deviceEvent } from './link-events' export { listSerialPorts } from './serial-bridge' +export { registerGatewayIpc } from './ipc' diff --git a/src/main/gateway/ipc.ts b/src/main/gateway/ipc.ts new file mode 100644 index 0000000..b9958d3 --- /dev/null +++ b/src/main/gateway/ipc.ts @@ -0,0 +1,18 @@ +// gateway's intents: Connect / Disconnect and the Serial tab's monitor. app/ipc.ts registers them, +// in its order (they are not registered together). +import type { IpcRegistrar } from '@shared/app' +import { GATEWAY_CTRL } from '@shared/gateway' +import type { GatewayConnection } from './gateway-connection' +import type { SerialTrafficMonitor } from './serial-traffic-monitor' + +export interface GatewayIpcDeps { + connection: GatewayConnection + serialMonitor: SerialTrafficMonitor +} + +export function registerGatewayIpc(ipc: IpcRegistrar, deps: GatewayIpcDeps): void { + const { connection, serialMonitor } = deps + ipc.handle(GATEWAY_CTRL.deviceConnect, () => connection.connect()) + ipc.handle(GATEWAY_CTRL.deviceDisconnect, () => connection.disconnect()) + ipc.handle(GATEWAY_CTRL.serialMonitor, (_e, on: boolean) => serialMonitor.setSerialMonitor(on)) +} diff --git a/src/main/gateway/serial-traffic-monitor.ts b/src/main/gateway/serial-traffic-monitor.ts index d8bb3b8..57640d3 100644 --- a/src/main/gateway/serial-traffic-monitor.ts +++ b/src/main/gateway/serial-traffic-monitor.ts @@ -2,11 +2,9 @@ // asks (it would otherwise stream the export at full rate); taps GatewayConnection.onTraffic, and // flushes `serial:traffic` on its own timer on the link's list (LinkRider). import type { SerialFrame } from '@shared/gateway' -import type { PushChannel, PushChannels } from '@shared/app' +import type { Emit } from '@shared/app' import type { LinkRider, LinkTimer } from './gateway-connection' -type Emit = (channel: C, payload: PushChannels[C]) => void - const SERIAL_FLUSH_MS = 50 const MAX_SERIAL_BUF = 4000 diff --git a/src/main/hid/calibration/hid-calibration-service.ts b/src/main/hid/calibration/hid-calibration-service.ts index 1b09f60..b5cb11a 100644 --- a/src/main/hid/calibration/hid-calibration-service.ts +++ b/src/main/hid/calibration/hid-calibration-service.ts @@ -16,15 +16,13 @@ import type { CalResult, CalSnapshot } from '@shared/hid/calibration' -import type { PushChannel, PushChannels } from '@shared/app' +import type { Emit } from '@shared/app' import type { CalFrameFilter, LinkRider, LinkTimer } from '../../gateway' import { CalLink, CalNackError, CalTimeoutError, type Log } from './cal-link' import { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' import { boardFor, dropAxis, regressedAxes, storeAxes } from './cache-rules' import { CAL_AXIS_NONE, nackReasonName } from './codec' -type Emit = (channel: C, payload: PushChannels[C]) => void - /** `cal:raw` batches: the serial monitor's cadence, as before the split. */ const CAL_RAW_FLUSH_MS = 50 diff --git a/src/main/hid/calibration/index.ts b/src/main/hid/calibration/index.ts index 83f1b9b..385d8cc 100644 --- a/src/main/hid/calibration/index.ts +++ b/src/main/hid/calibration/index.ts @@ -2,4 +2,5 @@ // (arch-phase0-v2 §2.1). export { HidCalibrationService } from './hid-calibration-service' export { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' -export { mergeCalibration } from './cache-rules' +export { mergeCalibration, sanitizeCache } from './cache-rules' +export { registerCalibrationIpc } from './ipc' diff --git a/src/main/hid/calibration/ipc.ts b/src/main/hid/calibration/ipc.ts new file mode 100644 index 0000000..6713dd4 --- /dev/null +++ b/src/main/hid/calibration/ipc.ts @@ -0,0 +1,31 @@ +// hid/calibration's intents: axis calibration (#46). app/ipc.ts registers them. +import type { IpcRegistrar } from '@shared/app' +import { CALIBRATION_CTRL, type CalCacheEntry, type CalCommitAxis } from '@shared/hid/calibration' +import type { HidCalibrationService } from './hid-calibration-service' + +export interface CalibrationIpcDeps { + calibration: HidCalibrationService +} + +export function registerCalibrationIpc(ipc: IpcRegistrar, deps: CalibrationIpcDeps): void { + const { calibration } = deps + // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: + // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the + // timeout-vs-nack distinction the UI's failure messaging depends on. + ipc.handle(CALIBRATION_CTRL.calHello, () => calibration.calHello()) + ipc.handle(CALIBRATION_CTRL.calRead, () => calibration.calRead()) + ipc.handle(CALIBRATION_CTRL.calSessionOpen, (_e, axisIdx: number) => + calibration.calSessionOpen(axisIdx) + ) + ipc.handle(CALIBRATION_CTRL.calStreamSelect, (_e, axisIdx: number) => + calibration.calStreamSelect(axisIdx) + ) + ipc.handle(CALIBRATION_CTRL.calCommit, (_e, axis: CalCommitAxis) => calibration.calCommit(axis)) + ipc.handle(CALIBRATION_CTRL.calReset, (_e, idx: number) => calibration.calReset(idx)) + ipc.handle(CALIBRATION_CTRL.calCacheRead, () => calibration.calCacheRead()) + ipc.handle(CALIBRATION_CTRL.calCacheStore, (_e, axes: CalCacheEntry[]) => + calibration.calCacheStore(axes) + ) + ipc.handle(CALIBRATION_CTRL.calCacheDrop, (_e, idx: number) => calibration.calCacheDrop(idx)) + ipc.handle(CALIBRATION_CTRL.calSessionClose, () => calibration.calSessionClose()) +} diff --git a/src/main/hid/hid-reader.test.ts b/src/main/hid/hid-reader.test.ts index 07f9d8a..2d638cd 100644 --- a/src/main/hid/hid-reader.test.ts +++ b/src/main/hid/hid-reader.test.ts @@ -15,7 +15,7 @@ describe('HidReader.reopen', () => { // while start()'s open is still awaiting node-hid. Both completing would leave an orphaned // handle emitting reports into a reader that no longer references it. // - // This lives outside session.test.ts on purpose: that file mocks './hid', so the same test + // This lives outside app-services.test.ts on purpose: that file mocks './hid', so the same test // there exercised the stub rather than any of this. const reader = new HidReader() as unknown as Innards let closed = 0 diff --git a/src/main/hid/hid-report.ts b/src/main/hid/hid-report.ts index 8a180e5..ac1af0a 100644 --- a/src/main/hid/hid-report.ts +++ b/src/main/hid/hid-report.ts @@ -5,7 +5,8 @@ // int16_t axes[8] // X Y Z Rx Ry Rz Slider Dial (little-endian) // => 34 bytes, no report id. The numbers come from HID_REPORT_LAYOUT (shared/firmware), which the // sync asserts against that struct. -import { HID_REPORT_LAYOUT } from '@shared/firmware' +import type { HidAvailability } from '@shared/hid' +import { HID_CONTROLS, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' export const HID_REPORT_SIZE = HID_REPORT_LAYOUT.size export const HID_AXIS_COUNT = HID_REPORT_LAYOUT.axes.count @@ -66,3 +67,32 @@ export function decodeReport(report: Uint8Array): { return { axes, buttons, hats } } + +/** Report indices the firmware catalogues, derived from HIDControls.h. */ +export function hidAvailability(): HidAvailability { + const axes: number[] = [] + const hats: number[] = [] + const buttons: number[] = [] + for (const c of HID_CONTROLS) { + if ( + c.id >= HID_ID.AXIS_MIN && + c.id <= HID_ID.AXIS_MAX && + c.id - HID_ID.AXIS_MIN < HID_REPORT_LAYOUT.axes.count + ) { + axes.push(c.id - HID_ID.AXIS_MIN) + } else if ( + c.id >= HID_ID.HAT_MIN && + c.id <= HID_ID.HAT_MAX && + c.id - HID_ID.HAT_MIN < HID_REPORT_LAYOUT.hats.count + ) { + hats.push(c.id - HID_ID.HAT_MIN) + } else if ( + c.id >= HID_ID.BUTTON_MIN && + c.id <= HID_ID.BUTTON_MAX && + c.id - HID_ID.BUTTON_MIN < HID_REPORT_LAYOUT.buttons.count + ) { + buttons.push(c.id - HID_ID.BUTTON_MIN) + } + } + return { axes, hats, buttons } +} diff --git a/src/main/hid/hid-service.ts b/src/main/hid/hid-service.ts index e14661e..9bdbdce 100644 --- a/src/main/hid/hid-service.ts +++ b/src/main/hid/hid-service.ts @@ -2,12 +2,10 @@ // lifecycle, the throttled push to the renderer, the rate window, and the reopen when the serial // port comes back. It rides on the Gateway connection (gateway LinkRider) for those moments; its // I/O is the HID reader. -import type { PushChannel, PushChannels } from '@shared/app' +import type { Emit } from '@shared/app' import type { LinkRider, LinkTimer } from '../gateway' import { HidReader } from './hid-reader' -type Emit = (channel: C, payload: PushChannels[C]) => void - /** Port: the debug log, handed in by app (debug.log lives in app). */ export type Log = (tag: string, data?: unknown) => void diff --git a/src/main/hid/index.ts b/src/main/hid/index.ts index 8932bec..60c0628 100644 --- a/src/main/hid/index.ts +++ b/src/main/hid/index.ts @@ -1,2 +1,3 @@ // hid: the SimGateway's HID joystick. Exports only what other topics use (arch-phase0-v2 §2.1). export { HidService } from './hid-service' +export { registerHidIpc } from './ipc' diff --git a/src/main/hid/ipc.ts b/src/main/hid/ipc.ts new file mode 100644 index 0000000..9ab8e4e --- /dev/null +++ b/src/main/hid/ipc.ts @@ -0,0 +1,8 @@ +// hid's intent: which report indices the firmware catalogues. app/ipc.ts registers it. +import type { IpcRegistrar } from '@shared/app' +import { HID_CTRL } from '@shared/hid' +import { hidAvailability } from './hid-report' + +export function registerHidIpc(ipc: IpcRegistrar): void { + ipc.handle(HID_CTRL.hidAvailability, () => hidAvailability()) +} diff --git a/src/main/index.ts b/src/main/index.ts index 1ee6475..336497f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,2 +1,4 @@ // Main-process entry (electron-vite builds out/main/index.js from here). The app lives in app/. -import './app' +import { start } from './app' + +start() diff --git a/src/main/nodes/index.ts b/src/main/nodes/index.ts index 314d6e7..4c1eb5f 100644 --- a/src/main/nodes/index.ts +++ b/src/main/nodes/index.ts @@ -3,3 +3,7 @@ export { NodeHub } from './node-hub' export type { MappingFileIO } from './mapping-store' export { NodeRoster } from './node-status-wire' +// The mapping and PSU rules config's file format reads a section back through. +export { effectiveMapping, importMapping, sanitizeNodeMapping } from './mapping-rules' +export { sanitizePsu } from './psu-limits' +export { registerNodesIpc } from './ipc' diff --git a/src/main/nodes/ipc.ts b/src/main/nodes/ipc.ts new file mode 100644 index 0000000..55567dc --- /dev/null +++ b/src/main/nodes/ipc.ts @@ -0,0 +1,26 @@ +// nodes' intents: the roster, the node data and the node mapping. app/ipc.ts registers them. +import type { IpcRegistrar } from '@shared/app' +import { NODES_CTRL, type AdoptSpec, type MappingPatch, type ResetOptions } from '@shared/nodes' +import type { NodeHub } from './node-hub' + +export interface NodesIpcDeps { + nodes: NodeHub +} + +export function registerNodesIpc(ipc: IpcRegistrar, deps: NodesIpcDeps): void { + const { nodes } = deps + ipc.handle(NODES_CTRL.nodesRefresh, () => nodes.requestNodes()) + ipc.handle(NODES_CTRL.nodesGet, () => nodes.snapshot()) + ipc.handle(NODES_CTRL.nodeMappingSet, (_e, id: number, patch: MappingPatch) => + nodes.setMapping(id, patch) + ) + ipc.handle(NODES_CTRL.nodeMappingAdopt, (_e, id: number, spec: AdoptSpec) => + nodes.adopt(id, spec) + ) + ipc.handle(NODES_CTRL.nodeMappingForget, (_e, id: number) => nodes.forget(id)) + ipc.handle(NODES_CTRL.nodeMappingResetNode, (_e, id: number) => nodes.resetBoard(id)) + ipc.handle(NODES_CTRL.nodeMappingReset, (_e, opts?: ResetOptions) => nodes.resetAll(opts)) + ipc.handle(NODES_CTRL.nodeMappingResetPreview, () => nodes.previewReset()) + ipc.handle(NODES_CTRL.nodeRegistry, () => nodes.registry()) + ipc.handle(NODES_CTRL.nodeMappingHide, (_e, id: number, on: boolean) => nodes.hide(id, on)) +} diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index b87445d..01315c9 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -18,7 +18,7 @@ import type { ResetOptions, ResetPreview } from '@shared/nodes' -import type { PushChannel, PushChannels } from '@shared/app' +import type { Emit } from '@shared/app' import type { LogEventInput } from '@shared/events' import { isLinkUp, type DeviceStatus, type GatewayStatus } from '@shared/gateway' import { GatewayWatch, type LinkRider, type NodeLineSink } from '../gateway' @@ -60,8 +60,6 @@ import { NODE_TELEM_MSG, NodeTelemStore, parseNodeTelem } from './node-telem' import { registryDefaults } from './registry' import { MappingSaveError, MappingStore, type MappingFileIO } from './mapping-store' -type Emit = (channel: C, payload: PushChannels[C]) => void - /** Port: the debug log, handed in by app (debug.log lives in app). */ export type Log = (tag: string, data?: unknown) => void @@ -184,7 +182,7 @@ export class NodeHub implements NodeLineSink { // ── the link's moments (gateway LinkRider) ──────────────────────────────── - /** NodeHub's part of the Gateway connection's moments. Session rides it after HID. */ + /** NodeHub's part of the Gateway connection's moments. AppServices rides it after HID. */ readonly rider: LinkRider = { // Poll the node roster so silent deaths get reconciled — relaying or not. timers: (every) => every(NODES_REFRESH_MS, () => this.requestNodes()), diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 7d7b3c8..441c440 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -1,7 +1,7 @@ // Services harness for SESSION-seam tests (test-only: production code never imports this). // -// `makeServices()` launches the main process the way app/index.ts does — settings loaded from -// config.json, then the device link opened — with fakes standing in for the hardware and the +// `makeServices()` launches the main process the way app/bootstrap.ts does — settings loaded +// from config.json, then the device link opened — with fakes standing in for the hardware and the // outside world: the SimGateway serial port, the DCS transport, HID, the clock and . // // A test sees only what the app shows the outside world: @@ -10,8 +10,8 @@ // - the files main writes under , // - bytes on the fake serial port and the fake DCS socket, // - a clock it advances. -// Never the objects inside. Today the harness wraps `Session`; once Session becomes `AppServices` -// (R2.3) only this file changes, and the tests written against it keep passing as they are. +// Never the objects inside. The harness wraps `AppServices` (R2.3c; it wrapped `Session` before): +// only this file changed, and the tests written against it kept passing as they are. import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -114,8 +114,8 @@ class FakeHid { // Registered before main is imported (below), so main is built against the fakes. Paths are // relative to this file. The mapping, settings and calibration-cache stores are real: they write -// , through the same store paths app/index.ts hands them. electron is still faked for -// app's own use of it (debug.log, the version). +// , through the same store paths app/bootstrap.ts hands them. electron is still faked +// for app's own use of it (debug.log, the version). vi.doMock('electron', () => ({ app: { getPath: () => world.userData, @@ -135,7 +135,7 @@ vi.doMock('../gateway/serial-bridge', () => ({ vi.doMock('../dcs/transports', () => ({ createTransport: () => new FakeTransport() })) vi.doMock('../hid/hid-reader', () => ({ HidReader: FakeHid })) -const { Session } = await import('../app/session') +const { AppServices } = await import('../app/app-services') const { storePaths } = await import('../app/user-data') const { PortNotFoundError } = await import('../gateway/serial-bridge') @@ -201,42 +201,43 @@ function launch(opts: ServicesOptions) { FakeSerial.last = undefined FakeTransport.last = undefined - // app/index.ts: the files, handed to the stores. + // app/bootstrap.ts: the files, handed to the stores. const paths = storePaths(() => world.userData) const pushes: Push[] = [] - const session = new Session((channel, payload) => { + const services = new AppServices((channel, payload) => { pushes.push({ channel, payload } as Push) }, paths) - // app/index.ts registerIpc(): the renderer's requests main answers, by channel. + // app/ipc.ts registerIpc() and the topics' ipc.ts: the renderer's requests main answers. const intents = { - [CTRL.configGet]: () => session.settings.get(), - [CTRL.configSet]: (patch: Partial) => session.settings.apply(patch), - [CTRL.relayStart]: () => session.relay.start(), - [CTRL.relayStop]: () => session.relay.stop(), - [CTRL.relayStatus]: () => session.status(), - [CTRL.deviceConnect]: () => session.connection.connect(), - [CTRL.deviceDisconnect]: () => session.connection.disconnect(), - [CTRL.nodesRefresh]: () => session.nodes.requestNodes(), - [CTRL.nodesGet]: () => session.nodes.snapshot(), - [CTRL.nodeMappingSet]: (id: number, patch: MappingPatch) => session.nodes.setMapping(id, patch), - [CTRL.nodeMappingAdopt]: (id: number, spec: AdoptSpec) => session.nodes.adopt(id, spec), - [CTRL.nodeMappingForget]: (id: number) => session.nodes.forget(id), - [CTRL.nodeMappingResetNode]: (id: number) => session.nodes.resetBoard(id), - [CTRL.nodeMappingReset]: (opts?: ResetOptions) => session.nodes.resetAll(opts), - [CTRL.nodeMappingResetPreview]: () => session.nodes.previewReset(), - [CTRL.nodeRegistry]: () => session.nodes.registry(), - [CTRL.nodeMappingHide]: (id: number, on: boolean) => session.nodes.hide(id, on), - [CTRL.serialMonitor]: (on: boolean) => session.serialMonitor.setSerialMonitor(on), - [CTRL.eventsGet]: () => session.journal.snapshot(), - [CTRL.eventsClear]: () => session.journal.clear() + [CTRL.configGet]: () => services.settings.get(), + [CTRL.configSet]: (patch: Partial) => services.settings.apply(patch), + [CTRL.relayStart]: () => services.relay.start(), + [CTRL.relayStop]: () => services.relay.stop(), + [CTRL.relayStatus]: () => services.status(), + [CTRL.deviceConnect]: () => services.connection.connect(), + [CTRL.deviceDisconnect]: () => services.connection.disconnect(), + [CTRL.nodesRefresh]: () => services.nodes.requestNodes(), + [CTRL.nodesGet]: () => services.nodes.snapshot(), + [CTRL.nodeMappingSet]: (id: number, patch: MappingPatch) => + services.nodes.setMapping(id, patch), + [CTRL.nodeMappingAdopt]: (id: number, spec: AdoptSpec) => services.nodes.adopt(id, spec), + [CTRL.nodeMappingForget]: (id: number) => services.nodes.forget(id), + [CTRL.nodeMappingResetNode]: (id: number) => services.nodes.resetBoard(id), + [CTRL.nodeMappingReset]: (opts?: ResetOptions) => services.nodes.resetAll(opts), + [CTRL.nodeMappingResetPreview]: () => services.nodes.previewReset(), + [CTRL.nodeRegistry]: () => services.nodes.registry(), + [CTRL.nodeMappingHide]: (id: number, on: boolean) => services.nodes.hide(id, on), + [CTRL.serialMonitor]: (on: boolean) => services.serialMonitor.setSerialMonitor(on), + [CTRL.eventsGet]: () => services.journal.snapshot(), + [CTRL.eventsClear]: () => services.journal.clear() } type Intents = typeof intents - // app/index.ts app.whenReady(): seed from persisted settings, then bring the device link up. - session.settings.load() - session.open() + // app/bootstrap.ts app.whenReady(): seed from persisted settings, then bring the device link up. + services.settings.load() + services.open() let quit = false let disposed = false @@ -364,14 +365,14 @@ function launch(opts: ServicesOptions) { /** The window closes (`window-all-closed`): the relay stops, the app keeps running. */ closeWindow(): void { - session.relay.stop() + services.relay.stop() }, /** The app quits (`will-quit`). */ quit(): void { if (quit) return quit = true - session.shutdown() + services.shutdown() }, /** Quit if still running, put real timers back and delete . Idempotent. */ diff --git a/src/shared/app/ipc.ts b/src/shared/app/ipc.ts index 535141c..e962abb 100644 --- a/src/shared/app/ipc.ts +++ b/src/shared/app/ipc.ts @@ -94,6 +94,15 @@ export interface PushChannels export type PushChannel = keyof PushChannels +/** + * A main → renderer push, as AppServices hands it to each service. `Emit` narrows it to some + * channels (EventJournal pushes only `events:batch`); the full `Emit` is assignable to any of them. + */ +export type Emit = ( + channel: C, + payload: PushChannels[C] +) => void + export const IPC = { ...GATEWAY_PUSH, ...DCS_PUSH, @@ -123,6 +132,21 @@ export const CTRL = { ...CALIBRATION_CTRL } as const +/** An intent channel: any CTRL value. */ +export type CtrlChannel = (typeof CTRL)[keyof typeof CTRL] + +/** An intent's handler, as `ipcMain.handle` takes it. Each handler types its own arguments. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ipcMain's own listener shape +export type IpcListener = (event: unknown, ...args: any[]) => unknown + +/** + * Where main answers intents: `ipcMain.handle`, structurally, so a main topic's + * `registerIpc` never imports electron (only main/app does). + */ +export interface IpcRegistrar { + handle(channel: CtrlChannel, listener: IpcListener): void +} + /** The contextBridge surface exposed to the renderer as `window.skyhawk`. */ export interface SkyhawkApi { on(channel: C, cb: (data: PushChannels[C]) => void): () => void diff --git a/tsconfig.json b/tsconfig.json index f0be47c..4991542 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,10 +14,17 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, + "types": [], "paths": { "@shared/*": ["./src/shared/*"], "@renderer/*": ["./src/renderer/src/*"] } }, - "include": ["src/renderer", "src/shared", "src/test-fixtures"] + "include": ["src/renderer", "src/shared"], + "exclude": [ + "src/renderer/test", + "src/renderer/**/*.test.ts", + "src/renderer/**/*.test.tsx", + "src/shared/**/*.test.ts" + ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..44261d6 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +// The renderer's tests and the shared test fixtures: DOM and Node types. They import main's pure +// builders (config-file, gateway-health, ...) and so, through main's topic indexes, its services. +// tsconfig.json leaves these files out and loads no Node types, so the renderer's app code +// can't use them. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/renderer", "src/shared", "src/test-fixtures"], + "exclude": [] +} From e8947b49adcf381994c3884bff2aca9e5b2e7242 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 00:31:08 -0400 Subject: [PATCH 18/28] test: tier-2 component tests (1/2) (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-2 component tests, part 1 of 2: T17–T26 from the Phase 0 component-test plan. **Tests only.** Nothing under `src/renderer/src`, `src/main` or `src/shared` changes. Every scenario goes in through `emitScenario` at today's push and invoke level, so R3 can swap in its new pushes inside the mock and leave these tests as they are. **Totals:** 69 → 79 test files, 906 → 967 tests (+61). The 906 existing tests are unchanged and pass. Lint and lint:deps show 0 errors (the warnings are the same as before). Typecheck passes on all 3 projects. ## Harness additions (`src/renderer/test/`) - **`skyhawk-mock.ts`** - New `Scenario` fields: - `stream` → `log:batch` - `serial` → `serial:traffic` - `calRaw` → `cal:raw` - `hidAvailability` → answers the `getHidAvailability` pull - **`calDevice`**: a scripted Gateway calibration channel that answers the `cal*` invokes the way main's `HidCalibrationService` does: - `offline` ("Gateway is not connected") while the port is closed. - GET_CAL answers with what the device stores, and also pushes `cal:data`. - COMMIT and RESET change what it stores. - Optional `refuse` makes SESSION_OPEN, STREAM_SELECT, COMMIT or RESET return a NACK or a timeout instead of an ack. - The restore cache runs through main's own `cache-rules` (`boardFor`, `regressedAxes`, `storeAxes`, `dropAxis`), keyed by the board serial and compared against the last GET_CAL. - When a port opens on a scripted device, the mock pushes `cal:data`, as main does in `readCalibrationState`. - Without a `calDevice`, every existing default is unchanged. - **`scenarios.ts`** - `reported(id, over)`: a status row with main's `withFaultInfo` applied. - `pduTelemetry(id, pages)`: a PDU's pages, built by main's `NodeTelemStore`. - Row builders: `logEvent`, `streamRow`, `serialFrame`. - Calibration builders: `calSnapshot`, `calDevice`, `calCache`, and `sweeps(...)` (RAW batches for N full sweeps, optionally with a rest position). ## What each test pins | # | File | Pins | Rows | |---|---|---|---| | T17 | `t17-cockpit-plan` | One group per placed PDU on its own console, then that console's No power zone. A panel sits under its PDU even when the PDU is on another console. "N PDUs" appears under a console title. A hidden PDU's boards are drawn under No power zone and come back when it reports. New boards are never placed, and a zone on a PDU not added yet reads No power zone. A state change never reorders tiles. A tile's label drops the console prefix, and its tooltip has the full name. | B64, B10, X2 | | T18 | `t18-node-panel-status` | Fault boxes: healthy (none), offline (one box, whatever was raised last), not seen this session plus the last-seen time, no status yet, degraded with the fault-table label, unknown code "Fault N". Several faults stack in the order bus-off → fault → overheat → error-passive. The status rows for each case. "Not in registry" (C6). "updated N s ago" ticks on fake timers and changes to "last report" once offline. With the link down the panel reads "last known". | B70, B14, F11 | | T19 | `t19-pdu-panel` | An offline PDU gets the fuse sentence only when its boards dropped with it. "No readings received", with the firmware hint only if it never had readings. Raised flags appear as one box with their meanings. UNCAL appears only as a calibration row. Rail rows show avg / min / A / peak A, a negative offset reads 0.00 A, and the W hint is shown. NTC rows cover a value, a fault and unavailable. "Feeds · N" lists the boards, and opening one switches panels. | B71 | | T20 | `t20-cockpit-band-search` | Band counts: known boards plus strays, unknown counted separately, new boards left out. 12 V and 5 V amps, W, lowest 5 V rail, worst CAN with the bus-off board named first, hottest PDU. Peak W holds when the draw falls. With the link down every value is a dash. Search by name or #id is case-insensitive. Issues shows only boards with an issue and combines with search. | B67, B69 | | T21 | `t21-board-events` | Empty state. Only node rows, newest first, one line each, with the count. "View all in Log →" opens Log › Events filtered to Nodes. | B75 | | T22 | `t22-log-events` | Source filter All / Nodes / Client. "Problems only". Search over the source label and the event text. Raw changes the column to "Message (raw)". Pause shows PAUSED and holds rows while the tab count rises; Resume shows them. Clear calls `clearEvents`. The Export footer, and no export when there are no events. | B78, B55 | | T23 | `t23-log-stream` | Direction filter. Search by name or address. Raw shows the address and bytes. Pause. Clear stays on the renderer side. Export sends oldest-first TSV to `exportLog`, shows the footer, and does nothing when the stream is empty. | B77 | | T24 | `t24-serial-tab` | The off text. The toggle calls `setSerialMonitor(true/false)`. Rows from `serial:traffic`, newest first, as hex and ASCII, with `…+N` past 48 bytes. Stopping keeps the rows; Clear empties them. | B79 | | T25 | `t25-hid-tab` | Axes, hats and buttons from `hid:report`. Uncatalogued or undeclared slots show a dash or n/a. Freshness reads live / idle / quiet / no link, and a released port still reads live (#74). CAL/RAW badges and the uncalibrated count. Calibrate is disabled while the link is down, with the reason. The Review & restore banner, which is absent when nothing is lost or the link is down. | B80 | | T26 | `t26-calibration-dialog` | Opening runs SESSION_OPEN on the first uncalibrated axis, and Write is disabled. STREAM_SELECT switches axes. Cancel closes the session and re-reads. While capturing, the capture and rest-position buttons are disabled. Three RAW sweeps produce a draft, and Write sends COMMIT. Write, Cancel, ✕, the axis rail and Re-capture stay disabled through the writing and verifying phases. After that come the done state, the badge from the read-back, the cache store and the notice. An invalid draft names the axis and keeps Write disabled. When the link drops, the dialog pauses: Write and Delete are disabled, axes switch locally with no STREAM_SELECT, and drafts are kept. When the same board reports, the session reopens on the axis on screen. An interrupted sweep restarts and the sweeps already accepted are kept. The restore panel: Not now, the re-offer, and Restore writing the cached values, after which the offer is gone. | B83, B82 | ## Where today's behaviour differs from the contract (pinned as today, with a `// contract says …` comment) 1. **T17 / B64:** a long tile name "…" is CSS `text-overflow` only. The DOM label is the whole short name, so the test pins that text. 2. **T18 / C6:** "Not in registry" is pinned, as C6 describes. No route in the UI leads to a new panel group's own panel: it has no tile, no Attention row and no Connection row. The box can only be reached on a new PDU, through a placed board's Powered by (X2 R3). 3. **T20 / B69:** search and Issues dim tiles only through the CSS class `tile2--dim`; no text or ARIA state changes. The test asserts that class because it is the only thing to observe. This is the one class assertion in the PR. 4. **T23 / B77:** Pause in Log › Stream drops `log:batch` rows that arrive while paused (`store.ts` returns early). Log › Events holds its rows instead. The test pins the drop. 5. **B77, not tested:** Autoscroll only toggles its own button state; nothing in the Log view reads it. 6. **T22 / B78:** the `skyhawk-events-.tsv` name is suggested in main's save dialog and isn't visible to the renderer. The test pins the footer built from the path main returns. 7. **T26 / B76:** the CalibrationDialog has no `role="dialog"` or accessible name. The tests find it by its title text. ## Fail-check (break the behaviour, see red, `git checkout` the file, tree clean) | T | Behaviour broken | Result | |---|---|---| | T17 | `planLayout`: only `pduId === null` boards go to No power zone | red (2) | | T18 | `nodeFaultBoxes`: degraded box dropped | red (2) | | T19 | `pduFaultBoxes`: fuse sentence on every offline PDU | red (1) | | T20 | `healthBand`: strays left out of total | red (1) | | T20 | `tileMatches`: `#id` not stripped | red (1) | | T21 | BoardEvents lists client rows | red (1) | | T22 | `filterEvents` ignores Problems only | red (1) | | T23 | `exportLog` newest first | red (1) | | T24 | `setSerialMonitor` never reaches main | red (1) | | T25 | `freshness`: released port reads "no link" | red (1) | | T26 | Write not disabled while paused | red (1) | | T26 | Cancel not disabled while writing | red (1) | Refs #99 --- src/renderer/test/scenarios.ts | 202 ++++++++++++++- src/renderer/test/skyhawk-mock.ts | 190 ++++++++++++-- src/renderer/test/t17-cockpit-plan.test.tsx | 120 +++++++++ .../test/t18-node-panel-status.test.tsx | 189 ++++++++++++++ src/renderer/test/t19-pdu-panel.test.tsx | 142 +++++++++++ .../test/t20-cockpit-band-search.test.tsx | 128 ++++++++++ src/renderer/test/t21-board-events.test.tsx | 102 ++++++++ src/renderer/test/t22-log-events.test.tsx | 163 ++++++++++++ src/renderer/test/t23-log-stream.test.tsx | 118 +++++++++ src/renderer/test/t24-serial-tab.test.tsx | 72 ++++++ src/renderer/test/t25-hid-tab.test.tsx | 144 +++++++++++ .../test/t26-calibration-dialog.test.tsx | 240 ++++++++++++++++++ 12 files changed, 1791 insertions(+), 19 deletions(-) create mode 100644 src/renderer/test/t17-cockpit-plan.test.tsx create mode 100644 src/renderer/test/t18-node-panel-status.test.tsx create mode 100644 src/renderer/test/t19-pdu-panel.test.tsx create mode 100644 src/renderer/test/t20-cockpit-band-search.test.tsx create mode 100644 src/renderer/test/t21-board-events.test.tsx create mode 100644 src/renderer/test/t22-log-events.test.tsx create mode 100644 src/renderer/test/t23-log-stream.test.tsx create mode 100644 src/renderer/test/t24-serial-tab.test.tsx create mode 100644 src/renderer/test/t25-hid-tab.test.tsx create mode 100644 src/renderer/test/t26-calibration-dialog.test.tsx diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts index 34088e7..6019ec4 100644 --- a/src/renderer/test/scenarios.ts +++ b/src/renderer/test/scenarios.ts @@ -11,9 +11,26 @@ import { import { DEFAULT_CONFIG, type ConfigSnapshot, type ImportPreview } from '@shared/config' import { gatewayStatus } from '../../main/gateway/gateway-health' import type { DeviceState, DeviceStatus, GatewayStatus } from '@shared/gateway' -import type { NodeDefaults, NodeModel, NodeModelEntry, NodeStatus, PduOutage } from '@shared/nodes' +import type { + NodeDefaults, + NodeModel, + NodeModelEntry, + NodeStatus, + NodeTelemSnapshot, + PduOutage, + PduRailPage, + PduStatusPage +} from '@shared/nodes' +import type { LogEvent } from '@shared/events' +import type { LogRow } from '@shared/dcs' +import type { SerialFrame } from '@shared/gateway' +import type { CalCache, CalHello, CalRawSample, CalSnapshot } from '@shared/hid/calibration' +import { CAL_PROTO, TELEM_PAGES } from '@shared/firmware' import { NODE_KIND } from '../../main/nodes/node-status-wire' +import { NodeTelemStore } from '../../main/nodes/node-telem' +import { withFaultInfo } from '../../main/nodes/node-hub' import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' +import type { CalDevice } from './skyhawk-mock' export { modelNode, nodeModel, nodeStatus } @@ -164,3 +181,186 @@ export function importPreview( identical: sections.every((s) => s.rows.length === 0) } } + +// ── node status rows, as main enriches them (`nodes:model` entry `status`) ───────────────── + +/** + * A panel group's status row as main hands it over: kind 01, plus `over`, with the fault table's + * abbr / label / description filled in for a `faultId` the way NodeHub does (withFaultInfo). + */ +export const reported = (nodeId: number, over: Partial = {}): NodeStatus => + withFaultInfo(nodeStatus(nodeId, { nodeKind: NODE_KIND.PANEL_GROUP, ...over })) + +// ── PDU telemetry (`nodes:telemetry`), from main's own telemetry store ───────────────────── + +export interface PduPages { + rail12V?: Partial> + rail5V?: Partial> + status?: Partial> +} + +/** + * What main pushes for a PDU's pages that arrived `ageMs` ago (NodeTelemStore.snapshot): rails + * default to a healthy 12.00 V / 5.00 V at 0 A, the status page to 30.0 °C, calibrated, no peaks. + */ +export function pduTelemetry(nodeId: number, pages: PduPages, ageMs = 0): NodeTelemSnapshot { + const store = new NodeTelemStore() + const rail = (page: PduRailPage['page'], mv: number, over: PduPages['rail12V']) => + store.apply( + { nodeId, kind: 'rail', page, flags: 0, vAvgMv: mv, vMinMv: mv, iAvgMa: 0, ...over }, + 0 + ) + if (pages.rail12V) rail(TELEM_PAGES.PDU_RAIL_12V, 12_000, pages.rail12V) + if (pages.rail5V) rail(TELEM_PAGES.PDU_RAIL_5V, 5_000, pages.rail5V) + if (pages.status) { + store.apply( + { + nodeId, + kind: 'status', + page: TELEM_PAGES.PDU_STATUS, + flags: 0, + ntcTempC: 30, + i12PeakMa: 0, + i5PeakMa: 0, + ...pages.status + }, + 0 + ) + } + return store.snapshot(ageMs)[0]! +} + +// ── the session's event log (`events:batch`) ──────────────────────────────── + +/** One event-log row: node #1 Center_Armament went offline, `id` seconds past 12:00 local. */ +export const logEvent = (id: number, over: Partial = {}): LogEvent => ({ + id, + t: new Date(2026, 9, 16, 12, 0, id).getTime(), + source: 'node', + nodeId: 1, + name: 'Center_Armament', + severity: 'error', + event: 'offline', + detail: 'no heartbeat', + raw: `_NODE_STATUS 01000000000000000080000000${id.toString(16).padStart(2, '0')}`, + ...over +}) + +// ── the DCS-BIOS stream (`log:batch`) and raw serial (`serial:traffic`) ───────────────────── + +/** One stream row: an export from DCS (in) at `address`, at 12:00:00 local unless `over` says. */ +export const streamRow = (address: number, over: Partial = {}): LogRow => ({ + t: new Date(2026, 9, 16, 12, 0, 0).getTime(), + dir: 'in', + address, + value: 0, + ...over +}) + +/** One raw serial chunk, `hex` as main forwards it. */ +export const serialFrame = (dir: SerialFrame['dir'], hex: string, t = 0): SerialFrame => ({ + t: t || new Date(2026, 9, 16, 12, 0, 0).getTime(), + dir, + hex +}) + +// ── axis calibration (#46): the Gateway's calibration channel ───────────────────────────── + +export const CAL_SERIAL = 'E6614103E7452D2F' + +/** HELLO_ACK from today's SimGateway firmware. */ +export const CAL_HELLO: CalHello = { + proto: CAL_PROTO.version, + blobVersion: CAL_PROTO.blobVersion, + axisSlots: CAL_PROTO.axisSlots, + fw: { major: 0, minor: 2, patch: 0 } +} + +export interface CalAxis { + idx: number + calibrated?: boolean + min?: number + centre?: number + max?: number +} + +/** + * What GET_CAL reads back: `axes` declared present (uncalibrated unless they say so; a calibrated + * one defaults to 10000 … 32768 … 55000), every other slot absent; the board's USB serial. + */ +export function calSnapshot(axes: CalAxis[], serialNumber: string = CAL_SERIAL): CalSnapshot { + const rows = Array.from({ length: CAL_PROTO.axisSlots }, (_, idx) => { + const a = axes.find((x) => x.idx === idx) + return { + idx, + controlId: 0x10 + idx, + min: a?.min ?? (a?.calibrated ? 10_000 : 0), + centre: a?.centre ?? (a?.calibrated ? 32_768 : 0), + max: a?.max ?? (a?.calibrated ? 55_000 : 0), + deadzone: 0, + present: !!a, + calibrated: !!a?.calibrated + } + }) + const mask = (f: (r: (typeof rows)[number]) => boolean) => + rows.reduce((m, r) => (f(r) ? m | (1 << r.idx) : m), 0) + return { + presentMask: mask((r) => r.present), + calibratedMask: mask((r) => r.calibrated), + axes: rows, + serialNumber + } +} + +/** A calibration-capable Gateway holding `stored`, with the client's cache file as given. */ +export const calDevice = (stored: CalSnapshot, over: Partial = {}): CalDevice => ({ + hello: CAL_HELLO, + stored, + ...over +}) + +/** cal-cache.json holding `axes` for the board `serial`, confirmed at `confirmedAt`. */ +export const calCache = ( + axes: Record, + confirmedAt = '2026-10-12T21:02:00.000Z', + serial = CAL_SERIAL +): CalCache => ({ version: 1, boards: { [serial]: { confirmedAt, axes } } }) + +/** + * The RAW samples of `count` full sweeps on axis `idx`, from rest to stop `a`, held, then to stop + * `b`, held — each hold corroborated by a second in-band sample 1.6 s later (the capture needs + * 1.5 s and a confirming sample). With `rest`, each sweep also releases to `rest` and settles + * there for the 5 s centre hold. Stamped from `t0`, 100 ms apart where nothing is held. + */ +export function sweeps( + idx: number, + t0: number, + a: number, + b: number, + count = 3, + rest?: number +): CalRawSample[] { + const out: CalRawSample[] = [] + let t = t0 + const at = (raw: number, dt = 100) => { + t += dt + out.push({ t, idx, raw, cal: raw }) + } + let from = 32_768 + for (let i = 0; i < count; i++) { + // Each step times the value it lands on, so it starts from where the last one left off. + at(from) + at(a) + at(a + 10, 1600) + at(a + 10) + at(b) + at(b + 10, 1600) + if (rest !== undefined) { + at(b + 10) + at(rest) + at(rest + 10, 5100) + } + from = rest !== undefined ? rest + 10 : b + 10 + } + return out +} diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 122302d..420d9cf 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -8,7 +8,8 @@ // - it keeps the scenario as main's current state, so the pulls a (re)loaded renderer makes // (`getStatus`, `getNodes`, `getConfig`, `getEvents`, `getNodeRegistry`) answer from it, and // - it pushes each part on today's channel: `device:status`, `relay:state`, `nodes:model`, -// `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`. +// `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`, and the +// stream batches `log:batch`, `serial:traffic` and `cal:raw`. // Tests never set renderer store fields and never push channels by hand, so they assert what the // user sees for a given main-side situation — not how the renderer stores it. // @@ -18,6 +19,14 @@ // today's channels. The change stays inside this file: every component test keeps its scenarios // and its assertions unchanged. // +// The calibration channel is the one intent set with a scripted answer: a `CalDevice` is the +// Gateway's side of the protocol (what HELLO, GET_CAL, SESSION_OPEN, STREAM_SELECT, COMMIT and +// RESET answer), plus the client's restore cache on disk. The `cal*` invokes answer the way +// main's HidCalibrationService does over it — `offline` while the port is closed, GET_CAL also +// pushing `cal:data`, the cache keyed by the board's serial through main's own cache rules — and +// the RAW stream arrives as `calRaw` batches. Nothing in the renderer's calibration controller is +// stubbed. (R3.1 moves the workflow into main; the same CalDevice answers then feed it here.) +// // Intents (renderer → main requests) are `vi.fn`s with harmless defaults, so a test can assert the // call (`expect(sky.forgetNode).toHaveBeenCalledWith(4)`) or override the answer // (`sky.forgetNode.mockResolvedValueOnce({ ok: false, error: '…' })`). The type follows @@ -25,10 +34,24 @@ import { act } from '@testing-library/react' import { vi, type Mock } from 'vitest' import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' -import type { AircraftStatus, RelayState } from '@shared/dcs' -import type { CalSnapshot } from '@shared/hid/calibration' -import type { DeviceStatus } from '@shared/gateway' -import type { HidSnapshot } from '@shared/hid' +import type { AircraftStatus, LogRow, RelayState } from '@shared/dcs' +import type { + CalCache, + CalFailure, + CalHello, + CalRawSample, + CalResult, + CalSnapshot +} from '@shared/hid/calibration' +import type { DeviceStatus, SerialFrame } from '@shared/gateway' +import type { HidAvailability, HidSnapshot } from '@shared/hid' +import { + boardFor, + dropAxis, + emptyCache, + regressedAxes, + storeAxes +} from '../../main/hid/calibration/cache-rules' import type { NodeModel, NodeRegistryInfo, NodeTelemSnapshot } from '@shared/nodes' import type { PushChannel, PushChannels, SkyhawkApi } from '@shared/app' import type { LogEvent } from '@shared/events' @@ -55,6 +78,32 @@ export interface Scenario { aircraft?: AircraftStatus /** The bundled NODE_IDS.md (`getNodeRegistry`; never pushed). */ registry?: NodeRegistryInfo + /** What HIDControls.h catalogues (`getHidAvailability`; never pushed). */ + hidAvailability?: HidAvailability + /** + * The attached Gateway's calibration channel and the client's restore cache: what the `cal*` + * invokes answer while the port is open. Main reads it when the port opens, so it also pushes + * `cal:data` (the stored calibration) unless the scenario gives `cal` itself. + */ + calDevice?: CalDevice + /** DCS-BIOS stream rows, oldest first (`log:batch`). A batch: pushed, never pulled. */ + stream?: LogRow[] + /** Raw serial chunks (`serial:traffic`). A batch: pushed, never pulled. */ + serial?: SerialFrame[] + /** RAW samples for the streamed axis, stamped on arrival in main (`cal:raw`). A batch. */ + calRaw?: CalRawSample[] +} + +/** The Gateway's side of the calibration protocol (#46), as the cal* invokes reach it. */ +export interface CalDevice { + /** HELLO_ACK. */ + hello: CalHello + /** What the device holds: GET_CAL answers with it. COMMIT and RESET change it. */ + stored: CalSnapshot + /** The client's calibration cache file (cal-cache.json), keyed by board serial. */ + cache?: CalCache + /** A request the device refuses (NACK) or never answers (timeout), instead of acknowledging. */ + refuse?: Partial> } type Fns = { [K in keyof SkyhawkApi]: Mock } @@ -94,9 +143,15 @@ const PUSHES: { [K in keyof Scenario]?: PushChannel } = { telemetry: 'nodes:telemetry', events: 'events:batch', cal: 'cal:data', - hid: 'hid:report' + hid: 'hid:report', + stream: 'log:batch', + serial: 'serial:traffic', + calRaw: 'cal:raw' } +/** Main's answer to any calibration exchange while the port is closed (HidCalibrationService). */ +const OFFLINE = { ok: false, kind: 'offline', message: 'Gateway is not connected' } as const + /** Install a fresh mock as `window.skyhawk` (the setup file does this before every test). */ export function installSkyhawkMock(): SkyhawkMock { const listeners = new Map void>>() @@ -129,7 +184,9 @@ export function installSkyhawkMock(): SkyhawkMock { ), getEvents: vi.fn(() => resolve(state.events ?? [])), getNodeRegistry: vi.fn(() => resolve(state.registry ?? EMPTY_REGISTRY)), - getHidAvailability: vi.fn(() => resolve({ axes: [], hats: [], buttons: [] })), + getHidAvailability: vi.fn(() => + resolve(state.hidAvailability ?? { axes: [], hats: [], buttons: [] }) + ), // ── intents: harmless defaults ── setConfig: vi.fn((p: Partial) => @@ -158,16 +215,65 @@ export function installSkyhawkMock(): SkyhawkMock { setSerialMonitor: vi.fn(() => resolve(undefined)), dumpSerialPorts: vi.fn(() => resolve({ path: '/tmp/serial-ports.txt', count: 0 })), revealDebugLog: vi.fn(() => resolve(undefined)), - calHello: vi.fn(() => resolve(calFail)), - calRead: vi.fn(() => resolve(calFail)), - calSessionOpen: vi.fn(() => resolve(calFail)), - calStreamSelect: vi.fn(() => resolve(calFail)), - calCommit: vi.fn(() => resolve(calFail)), - calReset: vi.fn(() => resolve(calFail)), - calSessionClose: vi.fn(() => resolve({ ok: true as const, value: null })), - calCacheRead: vi.fn(() => resolve({ ok: true as const, value: { regressed: [] } })), - calCacheStore: vi.fn(() => resolve({ ok: true as const, value: null })), - calCacheDrop: vi.fn(() => resolve({ ok: true as const, value: null })) + calHello: vi.fn(() => resolve(cal((d) => ok(d.hello)) ?? calFail)), + calRead: vi.fn(() => resolve(cal((d) => ok(readCal(d))) ?? calFail)), + calSessionOpen: vi.fn((axisIdx: number) => + resolve(cal((d) => refused(d, 'session') ?? ok({ timeoutMs: 30_000, axisIdx })) ?? calFail) + ), + calStreamSelect: vi.fn(() => resolve(cal((d) => refused(d, 'stream') ?? ok(null)) ?? calFail)), + calCommit: vi.fn((a) => + resolve( + cal((d) => { + const no = refused(d, 'commit') + if (no) return no + const { idx, min, centre, max } = a + setStored(d, (x) => (x.idx === idx ? { ...x, min, centre, max, calibrated: true } : x)) + return ok(null) + }) ?? calFail + ) + ), + calReset: vi.fn((idx: number) => + resolve( + cal((d) => { + const no = refused(d, 'reset') + if (no) return no + setStored(d, (x) => (idx === 0xff || x.idx === idx ? { ...x, calibrated: false } : x)) + return ok(null) + }) ?? calFail + ) + ), + calSessionClose: vi.fn(() => + resolve(cal(() => ok(null)) ?? { ok: true as const, value: null }) + ), + calCacheRead: vi.fn(() => + resolve( + cal((d) => { + const cache = d.cache ?? emptyCache() + return ok({ board: boardFor(cache, serial(d)), regressed: regressedAxes(cache, lastCal) }) + }, true) ?? { ok: true as const, value: { regressed: [] } } + ) + ), + calCacheStore: vi.fn((axes) => + resolve( + cal((d) => { + const s = serial(d) + if (s && axes.length) { + const entries = Object.fromEntries(axes.map(({ idx, ...a }) => [idx, a])) + setCache(storeAxes(d.cache ?? emptyCache(), s, entries, new Date().toISOString())) + } + return ok(null) + }, true) ?? { ok: true as const, value: null } + ) + ), + calCacheDrop: vi.fn((idx: number) => + resolve( + cal((d) => { + const s = serial(d) + if (s) setCache(dropAxis(d.cache ?? emptyCache(), s, idx === 0xff ? undefined : idx)) + return ok(null) + }, true) ?? { ok: true as const, value: null } + ) + ) } /** A mapping intent main accepted: it answers with the model (and would push it). */ @@ -177,9 +283,57 @@ export function installSkyhawkMock(): SkyhawkMock { for (const cb of listeners.get(ch) ?? []) (cb as (d: PushChannels[C]) => void)(data) } + // ── the calibration channel, as main's HidCalibrationService answers it ── + const ok = (value: T) => ({ ok: true as const, value }) + /** The board's USB serial: what main reads from the port and keys the cache on. */ + const serial = (d: CalDevice) => d.stored.serialNumber + /** GET_CAL as main returns it: the stored calibration with the serial. */ + const snapshotOf = (d: CalDevice): CalSnapshot => structuredClone(d.stored) + /** The snapshot main last read (its `lastCal`): what the cache's regressed axes compare with. */ + let lastCal: CalSnapshot | undefined + /** + * Run `fn` against the scripted device. undefined = no device scripted (today's defaults). The + * device answers only while the port is open; the cache is a local file and answers anyway. + */ + const cal = (fn: (d: CalDevice) => CalResult, local = false): CalResult | undefined => { + const d = state.calDevice + if (!d) return undefined + if (!local && state.device?.state !== 'connected') return OFFLINE + return fn(d) + } + const readCal = (d: CalDevice): CalSnapshot => { + const snap = snapshotOf(d) + lastCal = snap + state = { ...state, cal: snap } + act(() => push('cal:data', snap)) // main's calRead pushes what it read + return snap + } + const setStored = ( + d: CalDevice, + f: (a: CalSnapshot['axes'][number]) => CalSnapshot['axes'][number] + ) => { + const axes = d.stored.axes.map(f) + const calibratedMask = axes.reduce((m, a) => (a.calibrated ? m | (1 << a.idx) : m), 0) + state = { ...state, calDevice: { ...d, stored: { ...d.stored, axes, calibratedMask } } } + } + const setCache = (cache: CalCache) => { + state = { ...state, calDevice: { ...state.calDevice!, cache } } + } + /** The device's answer to a request it was scripted to refuse; undefined = it acknowledges. */ + const refused = (d: CalDevice, req: keyof NonNullable) => { + const f = d.refuse?.[req] + return f && ({ ok: false as const, ...f } satisfies CalResult) + } + const mock = Object.defineProperty(fns, 'scenario', { get: () => state }) as SkyhawkMock Object.assign(mock, { - emitScenario(s: Scenario) { + emitScenario(given: Scenario) { + // The port opened on a calibration-capable Gateway: main reads its calibration (GET_CAL) + // and pushes it, unless the scenario says what main pushed. + const device = given.calDevice ?? state.calDevice + const opened = device && given.device?.state === 'connected' + const s = opened && given.cal === undefined ? { ...given, cal: snapshotOf(device) } : given + if (opened) lastCal = s.cal state = { ...state, ...s } act(() => { for (const [key, ch] of Object.entries(PUSHES) as [keyof Scenario, PushChannel][]) { diff --git a/src/renderer/test/t17-cockpit-plan.test.tsx b/src/renderer/test/t17-cockpit-plan.test.tsx new file mode 100644 index 0000000..90cd7ec --- /dev/null +++ b/src/renderer/test/t17-cockpit-plan.test.tsx @@ -0,0 +1,120 @@ +// T17 — Cockpit › the plan: which group each board is drawn in, and what never moves a tile. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { connected, model, newBoard, offline, panel, pdu, reported } from './scenarios' + +const ZONE = ' — power zone details' + +/** + * The plan as drawn, top to bottom in its Left → Instrument panel → Right order: each group's + * header (`PDU_Left` or `No power zone`), then its tiles' labels indented, symbol included. + */ +function plan(): string[] { + const stage = screen.getByText('Instrument panel').parentElement! + const out: string[] = [] + for (const el of Array.from(stage.children)) { + const title = el.getAttribute('title') ?? '' + if (title.endsWith(ZONE)) out.push(title.slice(0, -ZONE.length)) + else if (el.textContent === 'No power zone') out.push('No power zone') + else if (title.startsWith('#')) out.push(` ${el.textContent}`) + } + return out +} + +describe('T17 Cockpit plan (B64, B10, X2)', () => { + it('one group per placed PDU on its console, then the console’s No power zone; a panel sits under its PDU even on another console', async () => { + await renderScenario(, { + device: connected(), + model: model([ + panel(1, 'Center_Armament', { pduId: 4 }), + panel(2, 'Right_Navigation', { pduId: 4 }), + pdu(3, 'PDU_Left', { console: 'Left' }), + pdu(4, 'PDU_Right', { console: 'Right' }), + pdu(5, 'PDU_Left_Aft', { console: 'Left' }), + panel(10, 'Left_Fuel', { pduId: 3 }), + panel(11, 'Left_Oxygen'), + panel(12, 'Center_Clock'), + panel(13, 'Right_Radar', { pduId: 3 }) + ]) + }) + expect(plan()).toEqual([ + 'PDU_Left', + ' Fuel', + ' Radar', + 'PDU_Left_Aft', + 'No power zone', + ' Oxygen', + 'No power zone', + ' Clock', + 'PDU_Right', + ' Armament', + ' Navigation' + ]) + // A console with more than one PDU says how many under its title. + expect(screen.getByText('Left console')).toHaveTextContent('Left console2 PDUs') + expect(screen.getByText('Right console')).toHaveTextContent(/^Right console$/) + }) + + it('a hidden PDU’s boards keep their link but are drawn under No power zone, and return when it reports (B10)', async () => { + const fed = panel(2, 'Right_Navigation', { pduId: 4 }) + const { sky } = await renderScenario(, { + device: connected(), + model: model([fed], [], { + hidden: [4], + hiddenNodes: [{ nodeId: 4, name: 'PDU_Right', console: 'Right' }] + }) + }) + expect(plan()).toEqual(['No power zone', ' Navigation']) + + // PDU_Right reports again: main un-hides it, and the board is back under it. + sky.emitScenario({ model: model([pdu(4, 'PDU_Right', { console: 'Right' }), fed]) }) + expect(plan()).toEqual(['PDU_Right', ' Navigation']) + }) + + it('new boards on the bus are never placed, and a zone on a PDU not added yet reads No power zone', async () => { + await renderScenario(, { + device: connected(), + model: model([ + panel(2, 'Right_Navigation', { pduId: 41 }), + newBoard(9, 0, { + identified: 'panel', + registry: { name: 'Left_Oxygen', console: 'Left', pduId: null } + }), + newBoard(41, 0, { name: 'PDU_Right_Fwd', kind: 'pdu', identified: 'pdu', console: 'Right' }) + ]) + }) + expect(plan()).toEqual(['No power zone', ' Navigation']) + expect(screen.queryByTitle(/^#9 /)).toBeNull() + expect(screen.queryByTitle(/^PDU_Right_Fwd/)).toBeNull() + }) + + it('a state change never moves a tile: same groups, same order, only the symbols change', async () => { + const boards = (over: Record[2]> = {}) => + model([ + pdu(4, 'PDU_Right', { console: 'Right' }), + panel(2, 'Right_Navigation', { pduId: 4, ...over[2] }), + panel(6, 'Right_Radar', { pduId: 4, ...over[6] }), + panel(7, 'Right_Lights', { pduId: 4, ...over[7] }) + ]) + const { sky } = await renderScenario(, { device: connected(), model: boards() }) + expect(plan()).toEqual(['PDU_Right', ' Navigation', ' Radar', ' Lights']) + + sky.emitScenario({ + model: boards({ 2: offline(), 6: { status: reported(6, { overheat: true }) } }) + }) + expect(plan()).toEqual(['PDU_Right', ' Navigation✕', ' Radar!', ' Lights']) + }) + + it('a tile is labelled with the name less its console prefix; the full name is its tooltip', async () => { + await renderScenario(, { + device: connected(), + model: model([panel(14, 'Center_Armament_Station_Select_Long')]) + }) + // contract says a long name ends in "…" past about two columns; today the DOM label is the + // whole short name and the "…" is drawn by CSS (text-overflow), so the text is pinned here. + expect(plan()).toEqual(['No power zone', ' Armament_Station_Select_Long']) + expect(screen.getByTitle('#14 Center_Armament_Station_Select_Long')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t18-node-panel-status.test.tsx b/src/renderer/test/t18-node-panel-status.test.tsx new file mode 100644 index 0000000..2cb9011 --- /dev/null +++ b/src/renderer/test/t18-node-panel-status.test.tsx @@ -0,0 +1,189 @@ +// T18 — the panel group's side panel, Status tab: the fault boxes and "Reported by the node". +import { describe, expect, it, vi } from 'vitest' +import { act, screen, within } from '@testing-library/react' +import type { NodeModelEntry } from '@shared/nodes' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { + connected, + model, + newBoard, + noStatusYet, + notSeen, + offline, + panel, + reported +} from './scenarios' + +const T0 = new Date(2026, 9, 16, 21, 30, 0).getTime() +const LAST_SEEN = new Date(2026, 9, 14, 21, 14).getTime() + +/** The Cockpit with Right_Navigation (#2) on it, as `over` says, and its panel open. */ +async function openStatus(over: Partial, others: NodeModelEntry[] = []) { + const r = await renderScenario(, { + device: connected(), + model: model([panel(2, 'Right_Navigation', over), ...others]) + }) + await r.user.click(screen.getByTitle(/^#2 Right_Navigation/)) + const sheet = screen.getByRole('dialog', { name: 'Panel group Right_Navigation' }) + expect(within(sheet).getByRole('tab', { name: 'Status' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + return { ...r, sheet } +} + +/** The fault boxes at the top of the tab, in order: `Title — text`. */ +const boxes = (sheet: HTMLElement) => + Array.from(sheet.querySelectorAll('b')) + .filter((b) => b.parentElement!.textContent!.startsWith(`${b.textContent} — `)) + .map((b) => b.parentElement!.textContent) + +/** "Reported by the node": each row's label and value. */ +const rows = (sheet: HTMLElement) => + ['Status', 'Uptime', 'CAN frames rx', 'CAN TEC / REC', 'CAN state', 'MCU temp'].map( + (k) => within(sheet).getByText(k, { ignore: 'button' }).parentElement!.textContent + ) + +const reportedBy = (sheet: HTMLElement) => within(sheet).getByText('Reported by the node') + +describe('T18 Node panel, Status tab (B70, B14, F11)', () => { + it('a healthy board: no box, and what it reports now', async () => { + const { sheet } = await openStatus({}) + expect(boxes(sheet)).toEqual([]) + expect(rows(sheet)).toEqual([ + 'Statushealthy', + 'Uptime00:01:40', + 'CAN frames rx10', + 'CAN TEC / REC0 / 0', + 'CAN stateerror-active', + 'MCU temp30 °C' + ]) + }) + + it('offline: one red box whatever it last raised, and only the last rx count is kept', async () => { + const { sheet } = await openStatus({ + ...offline(), + status: reported(2, { overheat: true, degraded: true, faultId: 1 }) + }) + expect(boxes(sheet)).toEqual([ + 'Offline — missing from the Gateway roster — no heartbeat within its timeout.' + ]) + expect(rows(sheet)).toEqual([ + 'Statusoffline', + 'Uptime—', + 'CAN frames rx10 (last seen)', + 'CAN TEC / REC—', + 'CAN state—', + 'MCU temp—' + ]) + }) + + it('remembered but not seen this session: its own box with the last-seen time (B14)', async () => { + const { sheet } = await openStatus(notSeen(LAST_SEEN)) + expect(screen.getByTitle('#2 Right_Navigation — NOT SEEN THIS SESSION')).toBeInTheDocument() + expect(boxes(sheet)).toEqual([ + 'Not seen this session — last seen 14 Oct, 21:14. It’s remembered from an earlier session. Forget or Hide it if it’s no longer part of your cockpit.' + ]) + expect(rows(sheet)[2]).toBe('CAN frames rx—') + }) + + it('no complete roster yet and it hasn’t reported: "No status yet"', async () => { + const { sheet } = await openStatus(noStatusYet()) + expect(boxes(sheet)).toEqual([ + 'No status yet — no complete roster has arrived since the link opened, and this node has not reported.' + ]) + expect(rows(sheet)[0]).toBe('Statusno status yet') + }) + + it('degraded: the fault table’s label and description; an unknown code reads "Fault N"', async () => { + const known = await openStatus({ status: reported(2, { degraded: true, faultId: 1 }) }) + expect(boxes(known.sheet)).toEqual([ + 'I2C peripheral — an I2C device (OLED/mux/expander) tripped its I2cHealth breaker' + ]) + expect(rows(known.sheet)[0]).toBe('Statusdegraded') + known.unmount() + + const unknown = await openStatus({ status: reported(2, { degraded: true, faultId: 200 }) }) + expect(boxes(unknown.sheet)).toEqual([ + 'Fault 200 — reported by the node; this client has no description for this code.' + ]) + }) + + it('several faults stack in order: bus-off, fault, overheat, error-passive', async () => { + const { sheet } = await openStatus({ + status: reported(2, { + boff: true, + degraded: true, + faultId: 1, + overheat: true, + epvf: true, + tec: 255, + rec: 12 + }) + }) + expect(boxes(sheet)).toEqual([ + 'Bus-off — its CAN controller stopped transmitting after too many errors. Reported by the node.', + 'I2C peripheral — an I2C device (OLED/mux/expander) tripped its I2cHealth breaker', + 'Overheat — the node raised its own temperature trip.', + 'Error-passive — its CAN error counters are high; it still communicates but can only signal errors passively.' + ]) + expect(rows(sheet)).toEqual([ + 'Statusbus-off', + 'Uptime00:01:40', + 'CAN frames rx10', + 'CAN TEC / REC255 / 12', + 'CAN statebus-off', + 'MCU temp30 °C' + ]) + }) + + it('a board waiting to be added reads "Not in registry" (C6 today)', async () => { + // contract C6/X5: a registry board waiting to be added is labelled "Not in registry". Today + // a new board's panel is reached only through a placed board's Powered by (X2 R3): no tile, + // no Attention row and no Connection row leads to a new panel group's own panel. + const { sheet, user } = await openStatus({ pduId: 41 }, [ + newBoard(41, T0, { + name: 'PDU_Right_Fwd', + kind: 'pdu', + identified: 'pdu', + status: reported(41, { nodeKind: 2 }), + registry: { name: 'PDU_Right_Fwd', console: 'Right', pduId: null } + }) + ]) + await user.click(within(sheet).getByRole('button', { name: 'Open PDU_Right_Fwd' })) + const pduSheet = screen.getByRole('dialog', { name: 'PDU PDU_Right_Fwd' }) + expect(boxes(pduSheet)).toEqual([ + 'Not in registry — this NODE_ID is not in the bundled NODE_IDS.md. Name it and place it on the map.' + ]) + }) + + it('"updated N s ago" ticks while it reports; offline it reads "last report"', async () => { + vi.useFakeTimers({ now: T0 }) + const { sheet, sky } = await openStatus({ reportedAt: T0 - 3000 }) + expect(reportedBy(sheet)).toHaveTextContent('Reported by the node· updated 3 s ago') + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(reportedBy(sheet)).toHaveTextContent('Reported by the node· updated 5 s ago') + act(() => { + vi.advanceTimersByTime(60_000) + }) + expect(reportedBy(sheet)).toHaveTextContent('Reported by the node· updated 1 min ago') + + sky.emitScenario({ + model: model([panel(2, 'Right_Navigation', { ...offline(), reportedAt: T0 - 3000 })]) + }) + expect(reportedBy(sheet)).toHaveTextContent('Reported by the node· last report 1 min ago') + }) + + it('with the link down the age reads "last known", and nothing ticks', async () => { + vi.useFakeTimers({ now: T0 }) + const { sheet, sky } = await openStatus({ reportedAt: T0 - 3000 }) + sky.emitScenario({ device: { state: 'reconnecting' } }) + expect(reportedBy(sheet)).toHaveTextContent('Reported by the node· last known') + expect(within(sheet).getByText(/Right console · no power zone/)).toHaveTextContent( + 'Right console · no power zone · last known' + ) + }) +}) diff --git a/src/renderer/test/t19-pdu-panel.test.tsx b/src/renderer/test/t19-pdu-panel.test.tsx new file mode 100644 index 0000000..c95ad40 --- /dev/null +++ b/src/renderer/test/t19-pdu-panel.test.tsx @@ -0,0 +1,142 @@ +// T19 — the PDU's side panel: its fault boxes, rails, board status and the boards it feeds. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes' +import { PDU_RAIL_FLAGS, PDU_STATUS_FLAGS } from '@shared/firmware' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { connected, model, offline, outage, panel, pdu, pduTelemetry } from './scenarios' + +/** + * PDU_Right (#4, Right console, as `over` says) feeding Right_Navigation (#2) and Right_Radar + * (#6, as `radar` says); the PDU's panel open. + */ +async function openPdu( + over: Partial, + telemetry: NodeTelemSnapshot[] = [], + outages: PduOutage[] = [], + radar: Partial = {} +) { + const r = await renderScenario(, { + device: connected(), + model: model( + [ + panel(2, 'Right_Navigation', { pduId: 4 }), + pdu(4, 'PDU_Right', { console: 'Right', ...over }), + panel(6, 'Right_Radar', { pduId: 4, ...radar }) + ], + outages + ), + telemetry + }) + await r.user.click(screen.getByTitle('PDU_Right — power zone details')) + const sheet = screen.getByRole('dialog', { name: 'PDU PDU_Right' }) + return { ...r, sheet } +} + +/** The fault boxes at the top of the Status tab, in order: `Title — text`. */ +const boxes = (sheet: HTMLElement) => + Array.from(sheet.querySelectorAll('b')) + .filter((b) => b.parentElement!.textContent!.startsWith(`${b.textContent} — `)) + .map((b) => b.parentElement!.textContent) + +/** A labelled row's whole text: a rail (`12 V`) or a Board status row. */ +const row = (sheet: HTMLElement, k: string) => + within(sheet).getByText(k, { ignore: 'button' }).parentElement!.textContent + +const live = { readings: 'live' as const, everHadReadings: true } + +describe('T19 PDU panel (B71)', () => { + it('offline: one box, with the blown-fuse sentence only when its boards dropped with it', async () => { + const out = await openPdu(offline(), [], [outage(4, [2, 6], 2)], offline()) + expect(boxes(out.sheet)).toEqual([ + 'Offline — no status or readings from this PDU. Its nodes dropped at the same time — a blown 5 V fuse at the PDU looks like this.' + ]) + out.unmount() + + const alone = await openPdu(offline()) + expect(boxes(alone.sheet)).toEqual(['Offline — no status or readings from this PDU.']) + expect(row(alone.sheet, '12 V')).toBe('12 Vavg — Vmin — V— Apeak — A') + }) + + it('online without readings: "No readings received", with the firmware hint only if it never had any', async () => { + const never = await openPdu({ readings: 'none', everHadReadings: false }) + expect(boxes(never.sheet)).toEqual([ + 'No readings received — the PDU is online but no telemetry has arrived. If it never had readings, the Gateway firmware may be too old to forward PDU telemetry.' + ]) + expect(row(never.sheet, 'Connector temp (NTC)')).toBe('Connector temp (NTC)—') + never.unmount() + + const lapsed = await openPdu({ readings: 'none', everHadReadings: true }) + expect(boxes(lapsed.sheet)).toEqual([ + 'No readings received — the PDU is online but no telemetry has arrived.' + ]) + }) + + it('raised flags are one box carrying their meanings; UNCAL is only a calibration row', async () => { + const { sheet } = await openPdu(live, [ + pduTelemetry(4, { + rail12V: { flags: PDU_RAIL_FLAGS.OVER_CURRENT, iAvgMa: 9000 }, + rail5V: { flags: PDU_RAIL_FLAGS.UNDER_V, vAvgMv: 4710, vMinMv: 4650 }, + status: { flags: PDU_STATUS_FLAGS.TEMP_WARN | PDU_STATUS_FLAGS.UNCAL_VDDA, ntcTempC: 61 } + }) + ]) + expect(boxes(sheet)).toEqual([ + '12V OVER-I · 5V UNDER-V · TEMP WARN — the 12 V rail is drawing sustained current above the over-current threshold; the 5 V rail is below its under-voltage threshold; board temperature at the warning threshold. Raised by the board.' + ]) + expect(row(sheet, 'VDDA calibration')).toBe( + 'VDDA calibrationnot done — readings assume a nominal 3.3 V' + ) + expect(row(sheet, 'Zero calibration')).toBe('Zero calibrationdone') + expect(row(sheet, 'Connector temp (NTC)')).toBe('Connector temp (NTC)61.0 °C') + }) + + it('rails: avg / min / A / peak A per rail, a negative offset reads 0, and the W the client computes', async () => { + const { sheet } = await openPdu(live, [ + pduTelemetry(4, { + rail12V: { vAvgMv: 12_100, vMinMv: 11_950, iAvgMa: 2000 }, + rail5V: { vAvgMv: 5020, vMinMv: 4980, iAvgMa: -20 }, + status: { ntcTempC: 31.5, i12PeakMa: 2300, i5PeakMa: 400 } + }) + ]) + expect(boxes(sheet)).toEqual([]) + expect(within(sheet).getByText('Rails · live')).toBeInTheDocument() + expect(row(sheet, '12 V')).toBe('12 Vavg 12.10 Vmin 11.95 V2.00 Apeak 2.30 A') + expect(row(sheet, '5 V')).toBe('5 Vavg 5.02 Vmin 4.98 V0.00 Apeak 0.40 A') + expect( + within(sheet).getByText( + '3-second window per page. Power (W) is computed here: 24.2 W + 0.0 W.' + ) + ).toBeInTheDocument() + expect(row(sheet, 'Connector temp (NTC)')).toBe('Connector temp (NTC)31.5 °C') + expect(row(sheet, 'MCU temp')).toBe('MCU temp30 °C') + expect(row(sheet, 'VDDA calibration')).toBe('VDDA calibrationdone') + }) + + it('an NTC fault or an unavailable NTC reads as such', async () => { + const fault = await openPdu(live, [ + pduTelemetry(4, { status: { flags: PDU_STATUS_FLAGS.NTC_FAULT, ntcTempC: null } }) + ]) + expect(row(fault.sheet, 'Connector temp (NTC)')).toBe( + 'Connector temp (NTC)fault — NTC open or shorted' + ) + fault.unmount() + + const none = await openPdu(live, [pduTelemetry(4, { status: { ntcTempC: null } })]) + expect(row(none.sheet, 'Connector temp (NTC)')).toBe('Connector temp (NTC)unavailable') + }) + + it('"Feeds · N" lists the boards it powers, and opening one switches to its panel', async () => { + const { sheet, user } = await openPdu({}, [], [], offline()) + await user.click(within(sheet).getByRole('tab', { name: 'Feeds · 2' })) + const list = within(sheet).getByRole('list') + expect( + within(list) + .getAllByRole('listitem') + .map((li) => li.textContent) + ).toEqual(['Right_Navigationonline · #2', '✕Right_RadarOFFLINE · #6']) + await user.click(within(list).getByRole('button', { name: 'Open Right_Navigation' })) + expect(screen.getByRole('dialog', { name: 'Panel group Right_Navigation' })).toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: 'PDU PDU_Right' })).toBeNull() + }) +}) diff --git a/src/renderer/test/t20-cockpit-band-search.test.tsx b/src/renderer/test/t20-cockpit-band-search.test.tsx new file mode 100644 index 0000000..4d138be --- /dev/null +++ b/src/renderer/test/t20-cockpit-band-search.test.tsx @@ -0,0 +1,128 @@ +// T20 — Cockpit › the health band over the plan, and the plan's search and Issues filter. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { PDU_STATUS_FLAGS } from '@shared/firmware' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { + connected, + model, + newBoard, + noStatusYet, + offline, + panel, + pdu, + pduTelemetry, + reported +} from './scenarios' + +/** A band card's whole text, from its label: label, value, limit and sub-line. */ +const card = (label: string) => screen.getByText(label).parentElement!.textContent + +const live = { readings: 'live' as const, everHadReadings: true } + +/** + * Two PDUs in your cockpit with fresh pages (PDU_Right's 5 V current a small negative offset, + * its NTC at the warning threshold), five panel groups — healthy (TEC 5), offline, bus-off, no + * status yet, overheating with REC 96 — a new PDU on the bus with readings of its own, and a + * second Gateway on the bus (a stray). + */ +const scene = () => ({ + device: connected(), + model: model([ + panel(1, 'Center_Armament', { status: reported(1, { tec: 5 }) }), + panel(2, 'Right_Navigation', offline()), + pdu(3, 'PDU_Left', { console: 'Left', ...live }), + pdu(4, 'PDU_Right', { console: 'Right', ...live }), + panel(6, 'Right_Radar', { status: reported(6, { boff: true }) }), + panel(7, 'Left_Oxygen', noStatusYet()), + panel(8, 'Left_Fuel', { status: reported(8, { overheat: true, tec: 30, rec: 96 }) }), + newBoard(9, 0, { identified: 'bridge' }), + newBoard(41, 0, { name: 'PDU_Right_Fwd', kind: 'pdu', identified: 'pdu', ...live }) + ]), + telemetry: [ + pduTelemetry(3, { + rail12V: { iAvgMa: 2000 }, + rail5V: { iAvgMa: 1000 }, + status: { ntcTempC: 35 } + }), + pduTelemetry(4, { + rail12V: { iAvgMa: 1000 }, + rail5V: { vAvgMv: 4900, vMinMv: 4880, iAvgMa: -50 }, + status: { flags: PDU_STATUS_FLAGS.TEMP_WARN, ntcTempC: 48 } + }), + pduTelemetry(41, { rail12V: { iAvgMa: 5000 } }) + ] +}) + +/** A plan tile, by NODE_ID. */ +const tile = (id: number) => screen.getByTitle(new RegExp(`^#${id} `)) +/** + * contract B69 asks for user-visible results; today a tile the search or the Issues filter + * leaves out is only greyed by a CSS class (no text or ARIA state changes), so that class is the + * one thing there is to pin. + */ +const lit = () => [1, 2, 6, 7, 8].filter((id) => !tile(id).classList.contains('tile2--dim')) + +describe('T20 Cockpit health band + search/Issues (B67, B69)', () => { + it('band: your cockpit’s boards plus strays; unknown counted apart; new boards left out', async () => { + await renderScenario(, scene()) + // 7 known + 1 stray; online = 2 healthy + 2 warning + the stray. + expect(screen.getByText('/ 8').parentElement).toHaveTextContent('5/ 8') + expect(card('Nodes online')).toBe('Nodes online3 warning · 2 offline · 1 no status yet') + }) + + it('band: amps and W over in-cockpit PDUs with fresh readings, lowest 5 V, worst CAN, hottest PDU', async () => { + await renderScenario(, scene()) + // 12 V: 2 A + 1 A (the new PDU's 5 A is not yours yet); 5 V: 1 A + a negative offset read as 0. + expect(card('12 V bus')).toBe('12 V bus3.00 A / 5436.0 W · 2 PDUs') + expect(card('5 V bus')).toBe('5 V bus1.00 A / 20low 4.90 V · #4') + expect(card('Total power')).toBe('Total power41.0 W / 650peak 41.0 W') + // The worst counters are #8's REC 96, but a bus-off board is named first. + expect(card('CAN errors')).toBe('CAN errorsREC 96worst #6 · 1 bus-off') + expect(card('Hottest PDU')).toBe('Hottest PDU48.0 °CPDU_Right · connectors') + }) + + it('band: the peak W holds for the session when the draw falls', async () => { + const { sky } = await renderScenario(, scene()) + sky.emitScenario({ + telemetry: [pduTelemetry(3, { rail12V: { iAvgMa: 500 } })] + }) + expect(card('Total power')).toBe('Total power6.0 W / 650peak 41.0 W') + }) + + it('band: with the link down every reading is a dash, and the ring says last known', async () => { + const { sky } = await renderScenario(, scene()) + sky.emitScenario({ device: { state: 'no-device' } }) + expect(card('12 V bus')).toBe('12 V bus— / 54no live readings') + expect(card('CAN errors')).toBe('CAN errors—no live readings') + expect(card('Nodes online')).toMatch(/^Nodes onlinelast known · \d\d:\d\d$/) + }) + + it('search lights the tiles whose name or NODE_ID matches, case-insensitive', async () => { + const { user } = await renderScenario(, scene()) + const find = screen.getByRole('textbox', { name: 'Find panel by name or NODE_ID' }) + expect(lit()).toEqual([1, 2, 6, 7, 8]) + await user.type(find, 'right_') + expect(lit()).toEqual([2, 6]) + await user.clear(find) + await user.type(find, '#8') + expect(lit()).toEqual([8]) + await user.clear(find) + await user.type(find, '7') + expect(lit()).toEqual([7]) + }) + + it('Issues lights only the boards with an issue — not healthy, not unknown — and combines with search', async () => { + const { user } = await renderScenario(, scene()) + const issues = screen.getByRole('button', { name: 'Issues' }) + await user.click(issues) + expect(issues).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false') + expect(lit()).toEqual([2, 6, 8]) + await user.type(screen.getByRole('textbox', { name: 'Find panel by name or NODE_ID' }), 'left') + expect(lit()).toEqual([8]) + await user.click(screen.getByRole('button', { name: 'All' })) + expect(lit()).toEqual([7, 8]) + }) +}) diff --git a/src/renderer/test/t21-board-events.test.tsx b/src/renderer/test/t21-board-events.test.tsx new file mode 100644 index 0000000..f49b41a --- /dev/null +++ b/src/renderer/test/t21-board-events.test.tsx @@ -0,0 +1,102 @@ +// T21 — Cockpit › Board events: the session's node events, and the way into Log › Events. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import { App } from '../src/App' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario } from './render' +import { connected, logEvent, model, panel } from './scenarios' + +/** The Board events card. */ +const card = () => screen.getByText('Board events').parentElement!.parentElement! +const lines = () => + within(card()) + .queryAllByRole('listitem') + .map((li) => li.textContent) + +/** Node events about two boards, a roster summary and a client event, oldest first. */ +const session = () => [ + logEvent(2, { + nodeId: 2, + name: 'Right_Navigation', + severity: 'info', + event: 'online', + detail: undefined + }), + logEvent(3, { + source: 'client', + nodeId: undefined, + name: 'SERIAL', + severity: 'info', + event: 'connected', + detail: '/dev/cu.usbmodem1' + }), + logEvent(4, { + nodeId: undefined, + name: 'Roster', + severity: 'info', + event: 'roster complete', + detail: '2 nodes' + }), + logEvent(5) +] + +describe('T21 BoardEvents (B75)', () => { + it('says so when the session has no board events yet', async () => { + await renderScenario(, { device: connected(), model: model([]) }) + expect(within(card()).getByText('No board events yet this session.')).toBeInTheDocument() + expect(within(card()).getByText('0 events this session')).toBeInTheDocument() + }) + + it('node rows only, newest first, one line each, with the count', async () => { + const { sky } = await renderScenario(, { + device: connected(), + model: model([panel(1, 'Center_Armament')]), + events: [ + logEvent(1, { + source: 'client', + nodeId: undefined, + name: 'DCS', + severity: 'info', + event: 'export stream up' + }) + ] + }) + // A client event is not a board event. + expect(within(card()).getByText('No board events yet this session.')).toBeInTheDocument() + + sky.emitScenario({ events: session() }) + expect(lines()).toEqual([ + '12:00:05Center_Armament offline · no heartbeat', + '12:00:04roster complete · 2 nodes', + '12:00:02Right_Navigation online' + ]) + expect(within(card()).getByText('3 events this session')).toBeInTheDocument() + expect(within(card()).getByTitle('Center_Armament offline · no heartbeat')).toBeInTheDocument() + + sky.emitScenario({ events: [logEvent(6, { nodeId: 2, name: 'Right_Navigation' })] }) + expect(lines()[0]).toBe('12:00:06Right_Navigation offline · no heartbeat') + expect(within(card()).getByText('4 events this session')).toBeInTheDocument() + }) + + it('"View all in Log →" opens Log › Events, filtered to Nodes', async () => { + const { user } = await renderScenario(, { + device: connected(), + model: model([panel(1, 'Center_Armament')]), + events: session() + }) + await user.click(screen.getByRole('tab', { name: 'Cockpit' })) + await user.click(within(card()).getByRole('button', { name: 'View all in Log →' })) + + expect(screen.getByRole('tab', { name: 'Log' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Events 4' })).toHaveAttribute('aria-selected', 'true') + expect(screen.queryByText('Client · SERIAL')).toBeNull() + expect(screen.getByText('Center_Armament')).toBeInTheDocument() + expect( + screen.getByText('3 of 4 events this session · in memory, cleared on restart') + ).toBeInTheDocument() + + // Nodes was the filter: All brings the client row back. + await user.click(screen.getByRole('button', { name: 'All' })) + expect(screen.getByText('Client · SERIAL')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t22-log-events.test.tsx b/src/renderer/test/t22-log-events.test.tsx new file mode 100644 index 0000000..be1c674 --- /dev/null +++ b/src/renderer/test/t22-log-events.test.tsx @@ -0,0 +1,163 @@ +// T22 — Log › Events: the session's event log, its filters, Pause, Clear and Export. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Log } from '../src/tabs/Log' +import { renderScenario } from './render' +import { connected, logEvent } from './scenarios' + +/** Five events, oldest first: node offline, client connected, PDU flag, new board, relay refused. */ +const session = () => [ + logEvent(1), + logEvent(2, { + source: 'client', + nodeId: undefined, + name: 'SERIAL', + severity: 'info', + event: 'connected', + detail: '/dev/cu.usbmodem1', + raw: 'serial: opened /dev/cu.usbmodem1' + }), + logEvent(3, { + nodeId: 4, + name: 'PDU_Right', + severity: 'warn', + event: '5V UNDER-V raised', + detail: '4.71 V', + raw: '_NODE_TELEM 040201661226120000' + }), + logEvent(4, { + nodeId: 9, + name: 'Left_Oxygen', + severity: 'new', + event: 'new on the bus', + detail: 'in NODE_IDS.md' + }), + logEvent(5, { + source: 'client', + nodeId: undefined, + name: 'Relay', + severity: 'error', + event: 'start refused', + detail: 'no board', + raw: 'relay: start refused (bridge mode needs a board)' + }) +] + +/** The listed rows, top to bottom: time, severity glyph, source, message. */ +const rows = () => + screen + .queryAllByLabelText(/^(Offline \/ error|Warning|New|Cleared \/ info)$/) + .map((glyph) => glyph.parentElement!.textContent) + +const footer = (text: string) => screen.getByText(text) + +async function openEvents() { + const r = await renderScenario(, { device: connected(), events: session() }) + await r.user.click(screen.getByRole('tab', { name: 'Events 5' })) + expect(screen.getByRole('tab', { name: 'Events 5' })).toHaveAttribute('aria-selected', 'true') + const button = (name: string) => screen.getByRole('button', { name }) + return { ...r, button } +} + +describe('T22 Log › Events (B78, B55)', () => { + it('lists the session newest first; the source filter picks Nodes or Client', async () => { + const { user, button } = await openEvents() + expect(rows()).toEqual([ + '12:00:05.000✕Client · Relaystart refused · no board', + '12:00:04.000+Left_Oxygennew on the bus · in NODE_IDS.md', + '12:00:03.000!PDU_Right5V UNDER-V raised · 4.71 V', + '12:00:02.000●Client · SERIALconnected · /dev/cu.usbmodem1', + '12:00:01.000✕Center_Armamentoffline · no heartbeat' + ]) + expect(footer('5 events this session · in memory, cleared on restart')).toBeInTheDocument() + + await user.click(button('Nodes')) + expect(rows()).toEqual([ + '12:00:04.000+Left_Oxygennew on the bus · in NODE_IDS.md', + '12:00:03.000!PDU_Right5V UNDER-V raised · 4.71 V', + '12:00:01.000✕Center_Armamentoffline · no heartbeat' + ]) + expect(footer('3 of 5 events this session · in memory, cleared on restart')).toBeInTheDocument() + + await user.click(button('Client')) + expect(rows()).toEqual([ + '12:00:05.000✕Client · Relaystart refused · no board', + '12:00:02.000●Client · SERIALconnected · /dev/cu.usbmodem1' + ]) + }) + + it('"Problems only" keeps the ✕ and ! rows', async () => { + const { user, button } = await openEvents() + await user.click(button('Problems only')) + expect(button('Problems only')).toHaveAttribute('aria-pressed', 'true') + expect(rows().map((r) => r!.slice(12, 13))).toEqual(['✕', '!', '✕']) + }) + + it('search matches the source label and the event text, case-insensitive', async () => { + const { user } = await openEvents() + const search = screen.getByPlaceholderText('Search source or event…') + await user.type(search, 'pdu_right') + expect(rows()).toEqual(['12:00:03.000!PDU_Right5V UNDER-V raised · 4.71 V']) + await user.clear(search) + await user.type(search, 'client · serial') + expect(rows()).toEqual(['12:00:02.000●Client · SERIALconnected · /dev/cu.usbmodem1']) + await user.clear(search) + await user.type(search, 'NO BOARD') + expect(rows()).toEqual(['12:00:05.000✕Client · Relaystart refused · no board']) + expect(footer('1 of 5 events this session · in memory, cleared on restart')).toBeInTheDocument() + }) + + it('Raw shows each row’s source line, under "Message (raw)"', async () => { + const { user, button } = await openEvents() + expect(screen.getByText('Event')).toBeInTheDocument() + await user.click(button('Decoded')) + expect(button('Raw')).toBeInTheDocument() + expect(screen.getByText('Message (raw)')).toBeInTheDocument() + expect(screen.queryByText('Event')).toBeNull() + expect(rows()[0]).toBe( + '12:00:05.000✕Client · Relayrelay: start refused (bridge mode needs a board)' + ) + }) + + it('Pause holds the list (PAUSED) while the tab count rises; Resume shows what arrived', async () => { + const { user, sky, button } = await openEvents() + expect(footer('LIVE')).toBeInTheDocument() + await user.click(button('❚❚ Pause')) + expect(footer('PAUSED')).toBeInTheDocument() + + sky.emitScenario({ + events: [...session(), logEvent(6, { nodeId: 2, name: 'Right_Navigation' })] + }) + expect(screen.getByRole('tab', { name: 'Events 6' })).toBeInTheDocument() + expect(rows()).toHaveLength(5) + expect(screen.queryByText('Right_Navigation')).toBeNull() + + await user.click(button('▶ Resume')) + expect(footer('LIVE')).toBeInTheDocument() + expect(rows()[0]).toBe('12:00:06.000✕Right_Navigationoffline · no heartbeat') + }) + + it('Clear empties the list and asks main to clear its copy', async () => { + const { user, sky, button } = await openEvents() + await user.click(button('Clear')) + expect(sky.clearEvents).toHaveBeenCalledTimes(1) + expect(rows()).toEqual([]) + expect(screen.getByRole('tab', { name: 'Events 0' })).toBeInTheDocument() + }) + + it('Export saves through main and the footer says where and how many rows', async () => { + const { user, sky, button } = await openEvents() + sky.exportEvents.mockResolvedValueOnce({ path: '/Users/me/Documents/skyhawk-events-5.tsv' }) + await user.click(button('Export')) + expect(sky.exportEvents).toHaveBeenCalledTimes(1) + // contract B78: main suggests `skyhawk-events-.tsv` in its save dialog (not visible here). + expect(await screen.findByText('✓ Saved skyhawk-events-5.tsv · 5 rows')).toBeInTheDocument() + }) + + it('Export with no events asks main for nothing', async () => { + const { user, sky, button } = await openEvents() + await user.click(button('Clear')) + await user.click(button('Export')) + expect(sky.exportEvents).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/test/t23-log-stream.test.tsx b/src/renderer/test/t23-log-stream.test.tsx new file mode 100644 index 0000000..2ca32ca --- /dev/null +++ b/src/renderer/test/t23-log-stream.test.tsx @@ -0,0 +1,118 @@ +// T23 — Log › Stream: the DCS-BIOS stream, its filters, Raw, Pause, Clear and Export. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Log } from '../src/tabs/Log' +import { renderScenario } from './render' +import { connected, streamRow } from './scenarios' + +const T = (s: number) => new Date(2026, 9, 16, 12, 0, s).getTime() + +/** Three rows main forwarded, oldest first: two exports from DCS, one command to it. */ +const batch = () => [ + streamRow(0x1a0, { t: T(1), name: 'MASTER_ARM', value: 1 }), + streamRow(0x84c2, { t: T(2), name: 'ARC51_FREQ', value: '251.00' }), + streamRow(0x1a2, { + t: T(3), + dir: 'out', + name: 'GUN_ARM', + value: 'TOGGLE', + raw: 'GUN_ARM TOGGLE' + }) +] + +/** The listed rows, top to bottom: time, direction, control, value. */ +const rows = () => + screen.queryAllByText(/^(IN ▸|◂ OUT)$/).map((dir) => dir.parentElement!.textContent) + +async function openStream() { + const r = await renderScenario(, { device: connected(), stream: batch() }) + expect(screen.getByRole('tab', { name: 'Stream 3' })).toHaveAttribute('aria-selected', 'true') + const button = (name: string) => screen.getByRole('button', { name }) + return { ...r, button } +} + +describe('T23 Log › Stream (B77)', () => { + it('lists the stream newest first; the direction filter picks In or Out', async () => { + const { user, button } = await openStream() + expect(rows()).toEqual([ + '12:00:03.000◂ OUTGUN_ARMTOGGLE', + '12:00:02.000IN ▸ARC51_FREQ251.00', + '12:00:01.000IN ▸MASTER_ARM1' + ]) + expect(screen.getByText('3 rows · 0 frames/s')).toBeInTheDocument() + await user.click(button('In')) + expect(rows()).toEqual(['12:00:02.000IN ▸ARC51_FREQ251.00', '12:00:01.000IN ▸MASTER_ARM1']) + await user.click(button('Out')) + expect(rows()).toEqual(['12:00:03.000◂ OUTGUN_ARMTOGGLE']) + await user.click(button('All')) + expect(rows()).toHaveLength(3) + }) + + it('search matches the control name or the address, case-insensitive', async () => { + const { user } = await openStream() + const search = screen.getByPlaceholderText('Search name or address…') + await user.type(search, 'arm') + expect(rows()).toEqual(['12:00:03.000◂ OUTGUN_ARMTOGGLE', '12:00:01.000IN ▸MASTER_ARM1']) + await user.clear(search) + await user.type(search, '0x84c2') + expect(rows()).toEqual(['12:00:02.000IN ▸ARC51_FREQ251.00']) + expect(screen.getByText('1 rows · 0 frames/s')).toBeInTheDocument() + }) + + it('Raw shows the address and the raw bytes, under "Address"', async () => { + const { user, button } = await openStream() + expect(screen.getByText('Control')).toBeInTheDocument() + await user.click(button('Decoded')) + expect(screen.getByText('Address')).toBeInTheDocument() + expect(screen.queryByText('Control')).toBeNull() + expect(rows()).toEqual([ + '12:00:03.000◂ OUT0x01A2GUN_ARM TOGGLE', + '12:00:02.000IN ▸0x84C284 C2 00 00', + '12:00:01.000IN ▸0x01A001 A0 01 00' + ]) + }) + + it('Pause freezes the list: rows that arrive while paused are dropped, not held (today)', async () => { + const { user, sky, button } = await openStream() + await user.click(button('❚❚ Pause')) + expect(screen.getByText('PAUSED')).toBeInTheDocument() + sky.emitScenario({ stream: [streamRow(0x1a4, { t: T(4), name: 'FLAPS', value: 2 })] }) + expect(rows()).toHaveLength(3) + + await user.click(button('▶ Resume')) + expect(screen.getByText('LIVE')).toBeInTheDocument() + // contract B77 lists Pause; today the renderer drops a stream batch that lands while paused + // (Log › Events holds its rows instead), so the paused row never shows. + expect(rows()).toHaveLength(3) + sky.emitScenario({ stream: [streamRow(0x1a6, { t: T(5), name: 'HOOK', value: 1 })] }) + expect(rows()[0]).toBe('12:00:05.000IN ▸HOOK1') + }) + + it('Clear empties the view on this side only', async () => { + const { user, sky, button } = await openStream() + await user.click(button('Clear')) + expect(rows()).toEqual([]) + expect(screen.getByRole('tab', { name: 'Stream 0' })).toBeInTheDocument() + expect(sky.clearEvents).not.toHaveBeenCalled() + }) + + it('Export hands main the stream as TSV, oldest first, and the footer says where', async () => { + const { user, sky, button } = await openStream() + sky.exportLog.mockResolvedValueOnce({ path: '/Users/me/Documents/skyhawk-stream.tsv' }) + await user.click(button('Export')) + expect(sky.exportLog).toHaveBeenCalledWith( + 'time\tdir\tcontrol\taddress\tvalue\traw\n' + + '12:00:01.000\tin\tMASTER_ARM\t0x01A0\t1\t01 A0 01 00\n' + + '12:00:02.000\tin\tARC51_FREQ\t0x84C2\t251.00\t84 C2 00 00\n' + + '12:00:03.000\tout\tGUN_ARM\t0x01A2\tTOGGLE\tGUN_ARM TOGGLE\n' + ) + expect(await screen.findByText('✓ Saved skyhawk-stream.tsv · 3 rows')).toBeInTheDocument() + }) + + it('Export with an empty stream asks main for nothing', async () => { + const { user, sky, button } = await openStream() + await user.click(button('Clear')) + await user.click(button('Export')) + expect(sky.exportLog).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/test/t24-serial-tab.test.tsx b/src/renderer/test/t24-serial-tab.test.tsx new file mode 100644 index 0000000..d06937b --- /dev/null +++ b/src/renderer/test/t24-serial-tab.test.tsx @@ -0,0 +1,72 @@ +// T24 — the Serial tab: the raw serial monitor, its rows and Clear. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Serial } from '../src/tabs/Serial' +import { renderScenario } from './render' +import { connected, serialFrame } from './scenarios' + +const T = (s: number) => new Date(2026, 9, 16, 12, 0, s).getTime() + +/** The listed chunks, top to bottom: time, direction, hex, ASCII. */ +const rows = () => + screen.queryAllByText(/^(TX ▸|◂ RX)$/).map((dir) => dir.parentElement!.textContent) + +const hex = (text: string) => Buffer.from(text, 'latin1').toString('hex') + +describe('T24 Serial tab (B79)', () => { + it('off: says how to start it', async () => { + await renderScenario(, { device: connected() }) + expect( + screen.getByText('Serial monitor is off. Start it to see raw bytes both ways.') + ).toBeInTheDocument() + expect(screen.getByText('OFF')).toBeInTheDocument() + expect(screen.getByText('0 frames')).toBeInTheDocument() + }) + + it('the toggle asks main to start and stop monitoring', async () => { + const { user, sky } = await renderScenario(, { device: connected() }) + await user.click(screen.getByRole('button', { name: 'Start monitor' })) + expect(sky.setSerialMonitor).toHaveBeenLastCalledWith(true) + expect(screen.getByText('MONITORING')).toBeInTheDocument() + expect( + screen.getByText('Monitoring — no serial traffic yet. (TX/RX needs a connected Gateway.)') + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '● Monitoring' })) + expect(sky.setSerialMonitor).toHaveBeenLastCalledWith(false) + expect(screen.getByText('OFF')).toBeInTheDocument() + }) + + it('lists what main forwards, newest first, as hex and ASCII', async () => { + const { user, sky } = await renderScenario(, { device: connected() }) + await user.click(screen.getByRole('button', { name: 'Start monitor' })) + sky.emitScenario({ + serial: [ + serialFrame('tx', hex('Hello'), T(1)), + serialFrame('rx', `${hex('_NODE_STATUS')}0d0a`, T(2)) + ] + }) + expect(rows()).toEqual([ + '12:00:02.000◂ RX5F 4E 4F 44 45 5F 53 54 41 54 55 53 0D 0A_NODE_STATUS··', + '12:00:01.000TX ▸48 65 6C 6C 6FHello' + ]) + expect(screen.getByText('2 frames · TX host→device · RX device→host')).toBeInTheDocument() + expect(screen.getByText('2 frames')).toBeInTheDocument() + + // A long chunk shows its first 48 bytes and how many more there were. + sky.emitScenario({ serial: [serialFrame('rx', '41'.repeat(50), T(3))] }) + expect(rows()[0]).toBe(`12:00:03.000◂ RX${Array(48).fill('41').join(' ')} …+2${'A'.repeat(48)}`) + }) + + it('stopping keeps what was listed; Clear empties it', async () => { + const { user, sky } = await renderScenario(, { device: connected() }) + await user.click(screen.getByRole('button', { name: 'Start monitor' })) + sky.emitScenario({ serial: [serialFrame('tx', hex('Hi'), T(1))] }) + await user.click(screen.getByRole('button', { name: '● Monitoring' })) + expect(rows()).toEqual(['12:00:01.000TX ▸48 69Hi']) + + await user.click(screen.getByRole('button', { name: 'Clear' })) + expect(rows()).toEqual([]) + expect(screen.getByText('0 frames')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t25-hid-tab.test.tsx b/src/renderer/test/t25-hid-tab.test.tsx new file mode 100644 index 0000000..2d63b60 --- /dev/null +++ b/src/renderer/test/t25-hid-tab.test.tsx @@ -0,0 +1,144 @@ +// T25 — the HID tab: live axes, hats and buttons, report freshness, calibration badges and offers. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import type { HidSnapshot } from '@shared/hid' +import { Hid } from '../src/tabs/Hid' +import { renderScenario } from './render' +import { calCache, calDevice, calSnapshot, connected, link } from './scenarios' + +/** What HIDControls.h catalogues: Roll, Pitch, Throttle; hat 0; buttons 0–3. */ +const catalogue = { axes: [0, 1, 2], hats: [0], buttons: [0, 1, 2, 3] } + +/** One HID report: Roll and Rudder off centre, hat 0 east, buttons 0 and 2 down, 25 Hz. */ +const report = (over: Partial = {}): HidSnapshot => ({ + axes: [1200, -32768, 0, 500, 0, 0, 0, 0], + buttons: Array.from({ length: 128 }, (_, i) => i === 0 || i === 2), + hats: [3, 0, 0, 0], + ageMs: 100, + rateHz: 25, + ...over +}) + +/** An axis row's whole text, from its label: label, value, badge. */ +const axis = (label: string) => screen.getByText(label).parentElement!.textContent +/** A hat's whole text, from its label. */ +const hat = (n: number) => screen.getByText(`HAT ${n}`).parentElement!.textContent +/** The Reports card's freshness label and rate. */ +const reports = () => screen.getByText('Reports').parentElement!.textContent + +describe('T25 HID tab (B80)', () => { + it('axes, hats and buttons from the latest report; uncatalogued indices dim to a dash', async () => { + await renderScenario(, { + device: connected(), + hidAvailability: catalogue, + hid: report() + }) + expect(axis('Roll')).toBe('Roll1200') + expect(axis('Pitch')).toBe('Pitch-32768') + expect(axis('Throttle')).toBe('Throttle0') + // Rudder reports 500, but HIDControls.h has no Rudder slot this build lists. + expect(axis('Rudder')).toBe('Rudder—') + expect(screen.getByText('int16 · ±32768')).toBeInTheDocument() + expect(hat(0)).toBe('HAT 0E') + expect(hat(1)).toBe('HAT 1n/a') + expect(screen.getByText(/4 mapped/).parentElement).toHaveTextContent( + '128 × 1-bit · 4 mapped · 2 lit' + ) + expect(reports()).toBe('Reportslive25 Hz') + }) + + it('freshness: live, idle, quiet by age while the board is there; "no link" only when it isn’t', async () => { + const { sky } = await renderScenario(, { device: connected(), hid: report() }) + expect(reports()).toBe('Reportslive25 Hz') + sky.emitScenario({ hid: report({ ageMs: 2 * 60_000, rateHz: 0 }) }) + expect(reports()).toBe('Reportsidle0 Hz') + sky.emitScenario({ hid: report({ ageMs: 10 * 60_000, rateHz: 0 }) }) + expect(reports()).toBe('Reportsquiet0 Hz') + // The port released (Disconnect): the joystick still works, so it is not "no link" (#74). + sky.emitScenario({ device: link('disconnected'), hid: report() }) + expect(reports()).toBe('Reportslive25 Hz') + sky.emitScenario({ device: link('no-device') }) + expect(reports()).toBe('Reportsno link25 Hz') + }) + + it('calibration badges come from what the device reports; the header counts what is left', async () => { + await renderScenario(, { + device: connected(), + hidAvailability: catalogue, + hid: report(), + calDevice: calDevice(calSnapshot([{ idx: 0, calibrated: true }, { idx: 1 }])) + }) + expect(axis('Roll')).toBe('Roll1200CAL') + expect(axis('Pitch')).toBe('Pitch-32768RAW') + // Catalogued, but this Gateway's sketch doesn't declare it. + expect(axis('Throttle')).toBe('Throttle—') + expect(screen.getByText('2 of 8 slots · device-reported')).toBeInTheDocument() + expect(screen.getByText('1 axis uncalibrated')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'CAL' })).toHaveAttribute( + 'title', + 'Calibrate this axis' + ) + expect(screen.getByRole('button', { name: 'Calibrate' })).toBeEnabled() + }) + + it('Calibrate is greyed out while the serial link is down, and says why', async () => { + await renderScenario(, { + device: link('no-device'), + hidAvailability: catalogue, + hid: report(), + calDevice: calDevice(calSnapshot([{ idx: 0, calibrated: true }])) + }) + const calibrate = screen.getByRole('button', { name: 'Calibrate' }) + expect(calibrate).toBeDisabled() + expect(calibrate).toHaveAttribute( + 'title', + 'Calibration needs the serial link — Connect on the Connection tab' + ) + expect(screen.getByText('Calibration needs the serial link')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Connect on the Connection tab' })).toBeEnabled() + // No snapshot without the link: no badges, and the live view still reads the joystick. + expect(axis('Roll')).toBe('Roll1200') + }) + + it('offers "Review & restore" when the device lost an axis this client holds a confirmed copy of', async () => { + const { user } = await renderScenario(, { + device: connected(), + hidAvailability: catalogue, + hid: report(), + calDevice: calDevice(calSnapshot([{ idx: 0, calibrated: true }, { idx: 1 }]), { + cache: calCache({ 1: { min: 12_000, centre: 33_000, max: 52_000, selfCentring: true } }) + }) + }) + expect( + await screen.findByText( + /^Pitch has no calibration on the device, but this client holds a copy the device confirmed on .+\.$/ + ) + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Review & restore…' })) + expect(await screen.findByText('Restore calibration to the device')).toBeInTheDocument() + expect(screen.getByText('Axis Calibration · Pitch')).toBeInTheDocument() + }) + + it('no offer when the device still holds every cached axis, and none while the link is down', async () => { + const pitch = { min: 12_000, centre: 33_000, max: 52_000, selfCentring: true } + const held = await renderScenario(, { + device: connected(), + hidAvailability: catalogue, + calDevice: calDevice(calSnapshot([{ idx: 1, calibrated: true }]), { + cache: calCache({ 1: pitch }) + }) + }) + expect(screen.getByText('all axes calibrated')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Review & restore…' })).toBeNull() + held.unmount() + + const { sky } = await renderScenario(, { + device: connected(), + hidAvailability: catalogue, + calDevice: calDevice(calSnapshot([{ idx: 1 }]), { cache: calCache({ 1: pitch }) }) + }) + expect(await screen.findByRole('button', { name: 'Review & restore…' })).toBeInTheDocument() + sky.emitScenario({ device: link('no-device') }) + expect(screen.queryByRole('button', { name: 'Review & restore…' })).toBeNull() + }) +}) diff --git a/src/renderer/test/t26-calibration-dialog.test.tsx b/src/renderer/test/t26-calibration-dialog.test.tsx new file mode 100644 index 0000000..d28848d --- /dev/null +++ b/src/renderer/test/t26-calibration-dialog.test.tsx @@ -0,0 +1,240 @@ +// T26 — the axis calibration dialog, opened from the HID tab and driven by the device's answers. +// +// Nothing in the renderer is stubbed: the dialog's own controller talks to the mock's scripted +// Gateway (CalDevice: SESSION_OPEN / STREAM_SELECT / COMMIT / GET_CAL answers and the restore +// cache), and the axis moves by RAW batches on `cal:raw`, the way main forwards them. +import { describe, expect, it, vi } from 'vitest' +import { act, screen, within } from '@testing-library/react' +import type { CalDevice } from './skyhawk-mock' +import { Hid } from '../src/tabs/Hid' +import { renderScenario } from './render' +import { calCache, calDevice, calSnapshot, connected, link, sweeps } from './scenarios' + +const T0 = new Date(2026, 9, 16, 21, 0, 0).getTime() +const PAUSED = + 'Serial link closed — calibration is paused; nothing more reaches the device. Connect on the Connection tab to continue (captured sweeps are kept).' + +/** Roll (axis 0) calibrated 10000 … 32768 … 55000; Pitch (axis 1) declared, not calibrated. */ +const gatewayWith = (over: Partial = {}) => + calDevice(calSnapshot([{ idx: 0, calibrated: true }, { idx: 1 }]), over) + +/** The dialog sheet (it has no dialog role today), from its title. */ +const sheet = () => screen.getByText(/^Axis Calibration · /).closest('header')!.parentElement! +const inSheet = () => within(sheet()) +const button = (name: string | RegExp) => inSheet().getByRole('button', { name }) +/** The header's link line: `Gateway · connected · signed ±32767`. */ +const linkLine = () => inSheet().getByText(/^Gateway ·/).textContent +/** The paused notice's whole text; undefined when it isn't shown. */ +const pausedNote = () => inSheet().queryByText('Serial link closed')?.parentElement?.textContent +/** The rail's axis button for `label`: its name, slot, unwritten-work dot and badge. */ +const axisButton = (label: string) => button(new RegExp(`^${label}`)) + +/** The HID tab on a calibration-capable Gateway, and Calibrate clicked. */ +async function openDialog(device = gatewayWith()) { + const r = await renderScenario(, { + device: connected(), + hidAvailability: { axes: [0, 1, 2], hats: [], buttons: [] }, + calDevice: device + }) + await r.user.click(screen.getByRole('button', { name: 'Calibrate' })) + await screen.findByText('Axis Calibration · Pitch') + return r +} + +/** Advance the fake clock and let the device's answers land. */ +const wait = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms)) + +describe('T26 CalibrationDialog (B83, B82)', () => { + it('opens a session on the first uncalibrated axis; Write is greyed out with nothing captured', async () => { + const { sky, user } = await openDialog() + expect(sky.calSessionOpen).toHaveBeenCalledWith(1) + expect(linkLine()).toBe('Gateway · connected · signed ±32767') + expect(axisButton('Roll')).toHaveTextContent('RollAxis 0CAL') + expect(axisButton('Pitch')).toHaveTextContent('PitchAxis 1RAW') + expect( + inSheet().getByText( + '2 of 8 slots · device-reported. Edits are kept when you switch axes and written together.' + ) + ).toBeInTheDocument() + expect(button('Write')).toBeDisabled() + expect(button('Start travel capture')).toBeEnabled() + expect(inSheet().queryByRole('button', { name: 'Delete calibration' })).toBeNull() + + // Roll: the stored calibration, Re-capture and Delete. + await user.click(axisButton('Roll')) + expect(await inSheet().findByText('Axis Calibration · Roll')).toBeInTheDocument() + expect(sky.calStreamSelect).toHaveBeenCalledWith(0) + expect(button('Re-capture travel')).toBeEnabled() + expect(button('Delete calibration')).toBeEnabled() + expect(inSheet().getByText('Recorded travel').parentElement).toHaveTextContent( + 'Recorded travel69%45,000 countsGood' + ) + + // Cancel closes the session and re-reads what the device holds. + sky.calRead.mockClear() + await user.click(button('Cancel')) + expect(sky.calSessionClose).toHaveBeenCalledTimes(1) + expect(screen.queryByText(/^Axis Calibration · /)).toBeNull() + expect(sky.calRead).toHaveBeenCalled() + }) + + it('while capturing, the capture and rest-position buttons are greyed out', async () => { + const { user } = await openDialog() + await user.click(button('Start travel capture')) + expect(button('Start travel capture')).toBeDisabled() + expect(button('Self-centring')).toBeDisabled() + expect(button('None')).toBeDisabled() + expect(button('Write')).toBeDisabled() + expect(inSheet().getByText('Sweep 1 of 3')).toBeInTheDocument() + expect(inSheet().getByText('3 sweeps remaining')).toBeInTheDocument() + expect(inSheet().getByText('Sweep the axis all the way to either stop')).toBeInTheDocument() + }) + + it('three sweeps make a draft; Write sends it, and everything is greyed out until the device confirms', async () => { + vi.useFakeTimers({ now: T0 }) + const { sky, user } = await openDialog() + await user.click(button('None')) + expect( + inSheet().getByText('No rest position — centre is the midpoint of the captured travel.') + ).toBeInTheDocument() + await user.click(button('Start travel capture')) + sky.emitScenario({ calRaw: sweeps(1, Date.now(), 10_000, 55_000) }) + + expect(inSheet().getByText('3 of 3 sweeps captured')).toBeInTheDocument() + expect( + inSheet().getByText('Captured, not written — nothing reaches the device until you write.') + ).toBeInTheDocument() + expect(axisButton('Pitch')).toHaveTextContent('PitchAxis 1●RAW') + expect(button('Write 1 axis')).toBeEnabled() + + await user.click(button('Write 1 axis')) + expect(sky.calCommit).toHaveBeenCalledWith({ idx: 1, min: 10_000, centre: 32_500, max: 55_000 }) + expect(inSheet().getByText('Writing Pitch to the device…')).toBeInTheDocument() + expect(button('Writing…')).toBeDisabled() + expect(button('Cancel')).toBeDisabled() + expect(button('✕')).toBeDisabled() + expect(axisButton('Roll')).toBeDisabled() + expect(button('Re-capture travel')).toBeDisabled() + + await wait(700) + expect(inSheet().getByText('Reading Pitch back from the device…')).toBeInTheDocument() + await wait(700) + expect( + inSheet().getByText('Stored on the device — read back and confirmed.') + ).toBeInTheDocument() + expect(axisButton('Pitch')).toHaveTextContent('PitchAxis 1CAL') + expect(button('Write')).toBeDisabled() + expect(button('Cancel')).toBeEnabled() + expect(sky.calCacheStore).toHaveBeenCalledWith([ + { idx: 1, min: 10_000, centre: 32_500, max: 55_000, selfCentring: false } + ]) + + await wait(3000) + expect( + inSheet().getByText( + 'Pitch written to the device and read back. The axis now reports through this calibration.' + ) + ).toBeInTheDocument() + }) + + it('a draft the device would refuse names the axis and keeps Write greyed out', async () => { + const { sky, user } = await openDialog() + // Self-centring (the default), and the axis comes to rest past its upper stop. + await user.click(button('Start travel capture')) + sky.emitScenario({ calRaw: sweeps(1, Date.now(), 10_000, 55_000, 3, 60_000) }) + expect(inSheet().getByText('Pitch: centre must fall between min and max')).toBeInTheDocument() + expect(button('Write 1 axis')).toBeDisabled() + }) + + it('the link drops: paused, nothing can be sent, drafts kept; it resumes when the board reports', async () => { + const { sky, user } = await openDialog() + await user.click(button('None')) + await user.click(button('Start travel capture')) + sky.emitScenario({ calRaw: sweeps(1, Date.now(), 10_000, 55_000) }) + expect(button('Write 1 axis')).toBeEnabled() + + sky.emitScenario({ device: link('reconnecting') }) + expect(linkLine()).toBe('Gateway · serial link closed · signed ±32767') + expect(pausedNote()).toBe(PAUSED) + expect(button('Write 1 axis')).toBeDisabled() + expect(button('Re-capture travel')).toBeDisabled() + expect(axisButton('Pitch')).toHaveTextContent('PitchAxis 1●RAW') + + // Axes still switch, locally: nothing goes to the device while paused. + await user.click(axisButton('Roll')) + expect(inSheet().getByText('Axis Calibration · Roll')).toBeInTheDocument() + expect(sky.calStreamSelect).not.toHaveBeenCalled() + expect(button('Delete calibration')).toBeDisabled() + + // The same board reports again: the session reopens on the axis on screen. + sky.emitScenario({ device: connected() }) + expect(await inSheet().findByText('connected')).toBeInTheDocument() + expect(linkLine()).toBe('Gateway · connected · signed ±32767') + expect(sky.calSessionOpen).toHaveBeenLastCalledWith(0) + expect(sky.calSessionOpen).toHaveBeenCalledTimes(2) + expect(pausedNote()).toBeUndefined() + expect(button('Write 1 axis')).toBeEnabled() + expect(button('Delete calibration')).toBeEnabled() + }) + + it('a sweep interrupted by the link is started again on resume; accepted sweeps are kept', async () => { + const { sky, user } = await openDialog() + await user.click(button('None')) + await user.click(button('Start travel capture')) + sky.emitScenario({ calRaw: sweeps(1, Date.now(), 10_000, 55_000, 1) }) + expect(inSheet().getByText('2 sweeps remaining')).toBeInTheDocument() + expect(inSheet().getByText('Sweep 2 of 3')).toBeInTheDocument() + + sky.emitScenario({ device: link('reconnecting') }) + // A paused capture asks for no movement. + expect(inSheet().queryByText('Sweep the axis all the way to either stop')).toBeNull() + expect(button('Start travel capture')).toBeDisabled() + + sky.emitScenario({ device: connected() }) + expect( + await inSheet().findByText('Sweep the axis all the way to either stop') + ).toBeInTheDocument() + expect(inSheet().getByText('2 sweeps remaining')).toBeInTheDocument() + }) + + it('restore: the panel lists the cached copy; Not now folds it to a re-offer; Restore writes it', async () => { + vi.useFakeTimers({ now: T0 }) + const cached = { min: 12_000, centre: 33_000, max: 52_000, selfCentring: true } + const { sky, user } = await renderScenario(, { + device: connected(), + hidAvailability: { axes: [0, 1], hats: [], buttons: [] }, + calDevice: gatewayWith({ cache: calCache({ 1: cached }) }) + }) + await user.click(await screen.findByRole('button', { name: 'Review & restore…' })) + await screen.findByText('Axis Calibration · Pitch') + expect(inSheet().getByText('Restore calibration to the device')).toBeInTheDocument() + expect(inSheet().getByText(/^confirmed .+/)).toBeInTheDocument() + expect(inSheet().getByText('centre measured').parentElement).toHaveTextContent( + 'Pitch-20,768 … 232 … 19,232centre measured' + ) + expect(inSheet().getByText(/Only axes the device has lost are written/)).toHaveTextContent( + 'min / centre / max per axis, for board E6614103E7452D2F. Only axes the device has lost are written; anything it still holds is left alone.' + ) + + await user.click(button('Not now')) + expect(inSheet().queryByText('Restore calibration to the device')).toBeNull() + await user.click(button('1 axis has a saved copy this device no longer holds — review')) + expect(inSheet().getByText('Restore calibration to the device')).toBeInTheDocument() + + await user.click(button('Restore 1 axis')) + expect(sky.calCommit).toHaveBeenCalledWith({ idx: 1, min: 12_000, centre: 33_000, max: 52_000 }) + // Writing: the offer is out of the way. + expect(inSheet().queryByText('Restore calibration to the device')).toBeNull() + await wait(1400) + expect( + inSheet().getByText('Stored on the device — read back and confirmed.') + ).toBeInTheDocument() + // The device holds it now: nothing left to offer. + await wait(3000) + expect( + inSheet().queryByRole('button', { name: /saved copy this device no longer holds/ }) + ).toBeNull() + expect(inSheet().queryByText('Restore calibration to the device')).toBeNull() + expect(axisButton('Pitch')).toHaveTextContent('PitchAxis 1CAL') + }) +}) From 424790bc1dd04dcc7f9e662e11957c109636ce19 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 00:51:33 -0400 Subject: [PATCH 19/28] test: tier-2 component tests (2/2) (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-2 component tests, part 2 of 2: T27–T35 from the Phase 0 component-test plan, plus contract row **B2** at the HUB seam. **Tests only.** Nothing under `src/renderer/src`, `src/main` (outside `*.test.ts`) or `src/shared` changes. Every renderer scenario goes in through `emitScenario` at today's push and invoke level. **Totals:** 79 → 88 test files, 967 → 1056 tests (+89). The 967 existing tests are unchanged and pass. Lint and lint:deps show 0 errors (the warnings are the same production-file warnings as before). Typecheck passes on all 3 projects. ## Harness additions (`src/renderer/test/`) - **`skyhawk-mock.ts`**: two new `Scenario` fields for the relay's ticks: `gauges` → `telemetry:tick` and `stats` → `stats:tick`. - **`scenarios.ts`**, each answering at main's level: - `simTelemetry(fill)`: the five readouts from main's own DCS-BIOS `Decoder`, after DCS exported the given fraction of each output's range. - `streamStats(over)`: one stats tick, idle by default. - `bundledRegistry()`: what main answers `getNodeRegistry` with (`NodeHub.registry()`). - `resetPreview(file)`: what main answers `previewNodeReset` with for a given node-mapping.json (`NodeHub.previewReset()`). ## What each test pins | # | File | Pins | Rows | |---|---|---|---| | T27 | `t27-overview-hero-gauges` | Link tile: the value, sub-line and colour for each device state × relay (relaying / stream stopped / "relay waiting — resumes when it connects"). The Link and Cockpit tiles open their tabs. Aircraft tile: NONE, "_ACFT_NAME · metadata" vs "inferred · address range", gold plus the A-4E-C-only banner when the aircraft is unsupported. DCS Link tile: TCP / Multicast / Unicast with their sub-lines. Gauges: five idle "—" readouts, then the tick's values. Fuel is gold, and red under 25 %; the other ring colours are pinned too. Stream Health from `stats:tick`; Errors red once > 0; the CMD line. | B56, B57 | | T28 | `t28-connection-relay-controls` | Device card: only the VID/PID **target** before a board; identity, VID/PID, port and CDC + HID when connected; Disconnect / Connect / Retry call main; the busy note; the auto-connect-off note's Settings › Connection link; greyed out while scanning. Relay controls: mode buttons save the mode and show their hints; Replay's Load capture… and the loaded-file line; the Drive Gateway toggle saves and blocks Start without a board; host, port and transport saved as typed or picked; Record disabled unless relaying, its hints and the running count; Start disabled when blocked, with the waitnote; the waiting / ended waitnotes. | B61, B62 | | T29 | `t29-settings-power-supply` | NX650 reads 54 A / 20 A / 650 W, read-only, and typing into it changes nothing. Custom shows the saved limits, editable, and saves a typed number. Custom keeps its values across a switch to the preset and back. An emptied field saves no limit. A non-number ("abc", "-5", "0", "20000", "1e3") is marked invalid, kept on screen and never saved. | B87 | | T30 | `t30-settings-reset` | Reset… is disabled when nothing differs and no adopted board shows, and when the only adopted board is hidden (X3). It is enabled when a board differs or an adopted board shows. The row description. The review lists main's preview rows old → new, with the adopted board under "Also forget adopted nodes". Reset sends `resetNodeMapping({ forgetAdopted: false })`; ticking the option sends `{ forgetAdopted: true }`, and the button label follows. With only an adopted board, nothing can be reset until the option is ticked. A refusal shows main's error and keeps the review open. Cancel calls nothing. | B89, B24 | | T31 | `t31-settings-connection-diagnostics` | Connection opens first and shows the saved mode, host and port. Host and port are saved as typed. The auto-connect toggle saves the setting both ways; the renderer doesn't connect itself, main does. Record session calls `toggleCapture` and shows the count. Diagnostics: the debug toggle saves `debugMode`; Dump calls `dumpSerialPorts` and shows where the ports went; Reveal calls `revealDebugLog`. | B86, B91 | | T32 | `t32-settings-about` | Version `v`, app id, licence. "NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway" with the commit. The registry dialog lists every NODE_ID with the bridge as the Gateway, and On the bus reads serial link / yes / offline / hidden / —. It follows the next push. Unplugged, the Gateway reads "no device" but CAN nodes still read "yes" (R5). | B92, X7 | | T33 | `t33-dialog-focus` | One test file over a Settings review Dialog and a Cockpit side-panel Sheet. Focus goes to the first control on open. Tab goes through the controls and wraps, and Shift+Tab wraps back. Esc, Close and a scrim click each close it, and focus returns to the opener (Reset… / the tile). | B76 | | T34 | `t34-overview-cockpit-agreement` | Integration in `App`: one `nodes:model` push, then Overview, Cockpit and Connection. Each tab shows the same offline and warning counts. The exception is a stray, which only the Cockpit band counts (X1). Without the stray all three agree exactly. The next push moves all three together. | X1, B58, B63, B67 | | T35 | `t35-settings-hidden-after-push` | Settings follows pushes through its own `useNodeModel` copy. A hidden board that reports again drops off, leaving the empty state and Un-hide all disabled. A board hidden elsewhere appears. The last push wins. Only the deterministic path is pinned; the C5 / F10 race is not. | B88, F10 | | B2 | `src/main/nodes/node-hub.seam.test.ts` | HUB seam: adopt 41 on today's bundle, then a "later client" whose NODE_IDS.md lists 41 starts on the same node-mapping.json. 41 is still in your cockpit with your name, console and PDU, online. It isn't "new on the bus". The adoption is kept on disk, plus `lastSeen`. Off the bus it stays, offline. The later bundle is a `vi.mock` of `@shared/firmware` whose `NODE_NAMES` is today's plus 41; the other tests in the file see today's bundle. | B2 | B2 had no seam test before: only the (C) `mapping-rules.test.ts` "adopted, then in the registry" case. ## Where today's behaviour differs from the contract (pinned as today, with a `// contract says …` comment) 1. **B2:** the contract says the model keeps `source: 'adopted'`. Today main reports the board as `source: 'registry'`. It stays in your cockpit with your settings. Because your name differs from the registry's, it is `differs: true` and counts in `differingCount`. So Reset… now offers to rename it to the registry name, which the C test also shows (`resetNode` → `Left_Oxy`). 2. **T28 / B61:** the contract says the "busy" note carries a Settings link. Today the busy note has none. Only the released-with-auto-connect-off note links to Settings › Connection. 3. **T31 vs B62:** Settings › Connection's **Record session** row is enabled while the relay is stopped. The Connection tab's Record card is disabled unless relaying. The two surfaces disagree. 4. **T27 / B56 (not a contract conflict, noted):** for Unicast, the DCS Link tile's sub-line reads the multicast endpoint (`239.255.50.10:5010 · local`). 5. **T27 / B57 (noted):** the idle gauge placeholders carry different units from the live readouts. Before the first tick IAS and Press Alt read `% FS`; after it they read `kt` and `ft`. 6. **Known conflicts, pinned as planned:** X1 (T34; a stray isn't counted on Overview or Connection), X3 (T30; Reset… disabled when the only adopted board is hidden), R5 / X7 (T32; CAN rows read "yes" while unplugged). 7. **T33:** the CalibrationDialog shares the focus handling but has no `role="dialog"` or accessible name (known), so T33 covers the Dialog and the Sheet only. ## Fail-check (break the behaviour, see red, `git checkout` the file, tree clean) | T | Behaviour broken | Result | |---|---|---| | T27 | `Overview`: fuel red under 20 % instead of 25 % | red (1) | | T27 | `Overview`: Errors red only above 5 | red (1) | | T28 | `startBlocked` always false | red (2) | | T28 | Connection Record never disabled | red (1) | | T29 | PSU preset fields not read-only | red (2) | | T29 | a refused PSU value is saved anyway | red (1) | | T30 | Reset… ignores adopted boards | red (2) | | T30 | ResetDialog always sends `forgetAdopted: false` | red (2) | | T31 | Dump button calls Reveal | red (1) | | T32 | `registryOnBus`: no "hidden" | red (1) | | T32 | `registryOnBus`: CAN "yes" only while the link is up (fixes R5) | red (2) | | T33 | `useModalFocus`: focus not returned to the opener | red (6) | | T33 | `useModalFocus`: Tab doesn't wrap | red (2) | | T34 | `nodesSummary`: healthy = online (warnings counted twice) | red (2) | | T34 | Overview counts new boards as strays (fixes X1) | red (2) | | T35 | `useNodeModel` stops following `nodes:model` | red (3) | | B2 | `inCockpit`: only `added` counts (an adopted entry stops counting once it is in the registry) | red (1) | After each run, `git status` was clean. Refs #99 --- src/main/nodes/node-hub.seam.test.ts | 73 +++++- src/renderer/test/scenarios.ts | 64 ++++- src/renderer/test/skyhawk-mock.ts | 19 +- .../test/t27-overview-hero-gauges.test.tsx | 220 ++++++++++++++++ .../t28-connection-relay-controls.test.tsx | 241 ++++++++++++++++++ .../test/t29-settings-power-supply.test.tsx | 118 +++++++++ src/renderer/test/t30-settings-reset.test.tsx | 166 ++++++++++++ ...1-settings-connection-diagnostics.test.tsx | 120 +++++++++ src/renderer/test/t32-settings-about.test.tsx | 105 ++++++++ src/renderer/test/t33-dialog-focus.test.tsx | 152 +++++++++++ .../t34-overview-cockpit-agreement.test.tsx | 125 +++++++++ .../t35-settings-hidden-after-push.test.tsx | 78 ++++++ 12 files changed, 1471 insertions(+), 10 deletions(-) create mode 100644 src/renderer/test/t27-overview-hero-gauges.test.tsx create mode 100644 src/renderer/test/t28-connection-relay-controls.test.tsx create mode 100644 src/renderer/test/t29-settings-power-supply.test.tsx create mode 100644 src/renderer/test/t30-settings-reset.test.tsx create mode 100644 src/renderer/test/t31-settings-connection-diagnostics.test.tsx create mode 100644 src/renderer/test/t32-settings-about.test.tsx create mode 100644 src/renderer/test/t33-dialog-focus.test.tsx create mode 100644 src/renderer/test/t34-overview-cockpit-agreement.test.tsx create mode 100644 src/renderer/test/t35-settings-hidden-after-push.test.tsx diff --git a/src/main/nodes/node-hub.seam.test.ts b/src/main/nodes/node-hub.seam.test.ts index 0373eb5..1f0f706 100644 --- a/src/main/nodes/node-hub.seam.test.ts +++ b/src/main/nodes/node-hub.seam.test.ts @@ -1,7 +1,7 @@ -// HUB-seam contract tests (Phase 0, rows B8, B10, B33): NodeHub's public API — statusLine, +// HUB-seam contract tests (Phase 0, rows B2, B8, B10, B33): NodeHub's public API — statusLine, // telemetry, flush, snapshot and the mutators — with the mapping store kept in memory. They pin // what the app does today, so the refactor can show it still does. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LogEventInput } from '@shared/events' import type { NodeMappingFile } from '@shared/nodes' import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' @@ -9,6 +9,16 @@ import { statusRow } from '../../test-fixtures/nodes' const disk: { file: NodeMappingFile; saves: number } = { file: { version: 3, nodes: {} }, saves: 0 } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path +// The bundled NODE_IDS.md, as a hub reads it when it is built. B2 builds a "later client" whose +// bundle lists one more NODE_ID; every other test sees today's bundle unchanged. +const bundle = vi.hoisted(() => ({ + names: {} as Record +})) +vi.mock('@shared/firmware', async (importOriginal) => { + const real = await importOriginal() + Object.assign(bundle.names, real.NODE_NAMES) + return { ...real, NODE_NAMES: bundle.names } +}) vi.mock('./mapping-file', () => ({ MappingSaveError: class MappingSaveError extends Error {}, loadNodeMapping: () => ({ file: disk.file }), @@ -56,6 +66,65 @@ beforeEach(() => { disk.saves = 0 }) +describe('B2 an adopted board stays in your cockpit once a later NODE_IDS.md lists it', () => { + afterEach(() => { + delete bundle.names[41] + }) + + it('a later client still has it, with what you set, online and offline — not new', () => { + // This client: 41 isn't in NODE_IDS.md. It reports, and you adopt it. + const today = hub() + today.status(1, PANEL, 0) + today.status(41, PANEL, 0) + today.end(2, 0) + today.flush(0) + expect(today.node(41, 0)?.source).toBe('new') + const spec = { name: 'Left_Oxygen', console: 'Left', pduId: 3, kind: 'panel' } as const + expect(today.h.adopt(41, spec).ok).toBe(true) + expect(today.node(41, 0)).toMatchObject({ source: 'adopted', name: 'Left_Oxygen' }) + const adopted = structuredClone(disk.file.nodes[41]) + + // A later client bundles a NODE_IDS.md that lists 41 under another name, and starts on the + // same node-mapping.json. + bundle.names[41] = { name: 'Left_Oxy', console: 'Left' } + const later = hub() + later.status(1, PANEL, 0) + later.status(41, PANEL, 0) + later.end(2, 0) + later.flush(0) + // contract B2 says the model keeps source `adopted`; today main reports it as a registry + // board (`registry`), which still keeps it in your cockpit with your settings. + expect(later.node(41, 0)).toMatchObject({ + source: 'registry', + name: 'Left_Oxygen', // your name, not the registry's + console: 'Left', + pduId: 3, + availability: 'online', + differs: true + }) + expect(later.h.snapshot(0).model.differingCount, 'your name differs from the registry').toBe(1) + expect( + later.events.filter((e) => e.nodeId === 41 && e.event === 'new on the bus'), + 'not news' + ).toEqual([]) + expect(disk.file.nodes[41], 'the adoption is kept; seen, as a board in your cockpit').toEqual({ + ...adopted, + lastSeen: new Date(0).toISOString() + }) + + // Off the bus it stays in your cockpit, offline — it doesn't drop out. + const afterRestart = hub() + afterRestart.status(1, PANEL, 5000) + afterRestart.end(1, 5000) + afterRestart.flush(5000) + expect(afterRestart.node(41, 5000)).toMatchObject({ + source: 'registry', + name: 'Left_Oxygen', + availability: 'offline' + }) + }) +}) + describe('B8 Forget removes everything; a forgotten board that reports is new again', () => { it('a registry board: its entry is gone, and back on the bus it is new, pre-filled from NODE_IDS.md', () => { // Added, renamed, placed, given a power zone, hidden, and seen before. Offline now. diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts index 6019ec4..472028a 100644 --- a/src/renderer/test/scenarios.ts +++ b/src/renderer/test/scenarios.ts @@ -13,22 +13,26 @@ import { gatewayStatus } from '../../main/gateway/gateway-health' import type { DeviceState, DeviceStatus, GatewayStatus } from '@shared/gateway' import type { NodeDefaults, + NodeMappingFile, NodeModel, NodeModelEntry, NodeStatus, NodeTelemSnapshot, PduOutage, PduRailPage, - PduStatusPage + PduStatusPage, + NodeRegistryInfo, + ResetPreview } from '@shared/nodes' import type { LogEvent } from '@shared/events' -import type { LogRow } from '@shared/dcs' +import type { LogRow, StatsSnapshot, TelemetryReadout } from '@shared/dcs' import type { SerialFrame } from '@shared/gateway' import type { CalCache, CalHello, CalRawSample, CalSnapshot } from '@shared/hid/calibration' -import { CAL_PROTO, TELEM_PAGES } from '@shared/firmware' -import { NODE_KIND } from '../../main/nodes/node-status-wire' +import { A4EC_OUTPUTS, CAL_PROTO, TELEM_PAGES } from '@shared/firmware' +import { Decoder } from '../../main/dcs/sim-telemetry-decoder' +import { NODE_KIND, NodeRoster } from '../../main/nodes/node-status-wire' import { NodeTelemStore } from '../../main/nodes/node-telem' -import { withFaultInfo } from '../../main/nodes/node-hub' +import { NodeHub, withFaultInfo } from '../../main/nodes/node-hub' import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' import type { CalDevice } from './skyhawk-mock' @@ -139,6 +143,26 @@ export const model = ( over: Partial = {} ): NodeModel => ({ ...nodeModel(nodes, outages), ...over }) +// ── main's node hub over a mapping file: the bundled registry and the reset review ───────── + +/** Main's NodeHub over node-mapping.json holding `file`, with no device link. */ +const hubOver = (file: NodeMappingFile = { version: 3, nodes: {} }) => + new NodeHub( + () => {}, + new NodeRoster(), + () => {}, + { mappingFile: { read: () => ({ kind: 'read', data: file }), write: () => undefined } } + ) + +/** The bundled NODE_IDS.md as main answers `getNodeRegistry` (NodeHub.registry). */ +export const bundledRegistry = (): NodeRegistryInfo => hubOver().registry() + +/** + * The review main answers `previewNodeReset` with while node-mapping.json holds `file` + * (NodeHub.previewReset over the bundled registry). + */ +export const resetPreview = (file: NodeMappingFile): ResetPreview => hubOver(file).previewReset() + // ── config import (`openConfigImport`'s answer) ───────────────────────────── /** This computer's setup as main snapshots it: the default config, no mapping, no calibration. */ @@ -230,6 +254,36 @@ export function pduTelemetry(nodeId: number, pages: PduPages, ageMs = 0): NodeTe return store.snapshot(ageMs)[0]! } +// ── the relay's ticks: sim telemetry (`telemetry:tick`) and stream stats (`stats:tick`) ───── + +/** + * What main's telemetry tick pushes once DCS has exported `fill` (0 … 1 of each output's range) + * for the given A-4E-C output ids: the five readouts, from main's own decoder. An output DCS + * hasn't exported reads NaN ("—"). + */ +export function simTelemetry(fill: Record = {}): TelemetryReadout[] { + const decoder = new Decoder() + for (const [id, f] of Object.entries(fill)) { + const o = A4EC_OUTPUTS.find((x) => x.id === id) + if (!o) throw new Error(`simTelemetry: no A-4E-C output ${id}`) + decoder.handle(o.address, (Math.round(f * o.max) << o.shift) & o.mask, 0) + } + return decoder.telemetrySnapshot() +} + +/** One stats tick: an idle stream (all zero, no command yet) plus `over`. */ +export const streamStats = (over: Partial = {}): StatsSnapshot => ({ + bytesInPerSec: 0, + bytesOutPerSec: 0, + framesPerSec: 0, + commandsPerSec: 0, + commandsTotal: 0, + errors: 0, + uptimeSec: 0, + reconnects: 0, + ...over +}) + // ── the session's event log (`events:batch`) ──────────────────────────────── /** One event-log row: node #1 Center_Armament went offline, `id` seconds past 12:00 local. */ diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 420d9cf..ffba60f 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -8,8 +8,9 @@ // - it keeps the scenario as main's current state, so the pulls a (re)loaded renderer makes // (`getStatus`, `getNodes`, `getConfig`, `getEvents`, `getNodeRegistry`) answer from it, and // - it pushes each part on today's channel: `device:status`, `relay:state`, `nodes:model`, -// `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`, and the -// stream batches `log:batch`, `serial:traffic` and `cal:raw`. +// `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`, the relay's +// ticks `telemetry:tick` and `stats:tick`, and the stream batches `log:batch`, +// `serial:traffic` and `cal:raw`. // Tests never set renderer store fields and never push channels by hand, so they assert what the // user sees for a given main-side situation — not how the renderer stores it. // @@ -34,7 +35,13 @@ import { act } from '@testing-library/react' import { vi, type Mock } from 'vitest' import { DEFAULT_CONFIG, type AppConfig } from '@shared/config' -import type { AircraftStatus, LogRow, RelayState } from '@shared/dcs' +import type { + AircraftStatus, + LogRow, + RelayState, + StatsSnapshot, + TelemetryReadout +} from '@shared/dcs' import type { CalCache, CalFailure, @@ -76,6 +83,10 @@ export interface Scenario { hid?: HidSnapshot /** The aircraft DCS reports (`aircraft:changed`). */ aircraft?: AircraftStatus + /** The five sim-telemetry readouts of the last telemetry tick (`telemetry:tick`). */ + gauges?: TelemetryReadout[] + /** The relay's last stats tick (`stats:tick`). */ + stats?: StatsSnapshot /** The bundled NODE_IDS.md (`getNodeRegistry`; never pushed). */ registry?: NodeRegistryInfo /** What HIDControls.h catalogues (`getHidAvailability`; never pushed). */ @@ -139,6 +150,8 @@ const PUSHES: { [K in keyof Scenario]?: PushChannel } = { device: 'device:status', relay: 'relay:state', aircraft: 'aircraft:changed', + gauges: 'telemetry:tick', + stats: 'stats:tick', model: 'nodes:model', telemetry: 'nodes:telemetry', events: 'events:batch', diff --git a/src/renderer/test/t27-overview-hero-gauges.test.tsx b/src/renderer/test/t27-overview-hero-gauges.test.tsx new file mode 100644 index 0000000..088407c --- /dev/null +++ b/src/renderer/test/t27-overview-hero-gauges.test.tsx @@ -0,0 +1,220 @@ +// T27 — Overview: the hero tiles (Link, Aircraft, DCS Link), the sim-telemetry gauges and the +// Stream Health cards. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { DeviceState } from '@shared/gateway' +import type { Scenario } from './skyhawk-mock' +import { Overview } from '../src/tabs/Overview' +import { App } from '../src/App' +import { renderScenario } from './render' +import { connected, link, simTelemetry, streamStats } from './scenarios' + +/** A hero tile by its label: the whole tile (label, value, sub-line). */ +const tile = (label: string) => screen.getByText(label).parentElement! +/** The tile's value line, which carries the tile's colour. */ +const value = (label: string) => tile(label).children[1] as HTMLElement +/** The tile's sub-line. */ +const sub = (label: string) => tile(label).children[2]!.textContent + +/** A gauge by its label: its readout and unit, and the colour of its ring. */ +function gauge(label: string) { + const g = screen.getByText(label).parentElement! + return { + text: g.querySelector('.gauge__center')!.textContent, + ring: g.querySelectorAll('circle')[1]!.getAttribute('stroke') + } +} + +/** A Stream Health card's value, by its label. */ +const health = (label: string) => screen.getByText(label).nextElementSibling as HTMLElement + +describe('T27 Overview hero tiles and gauges (B56, B57)', () => { + it.each<[string, Scenario, string, string, string]>([ + ['no board, stopped', {}, 'No device', 'stream stopped', 'var(--muted)'], + [ + 'connected, relaying', + { device: connected(), relay: { running: true, stream: 'connected' } }, + 'Connected', + 'Gateway · relaying', + 'var(--green)' + ], + [ + 'connected, stopped', + { device: connected() }, + 'Connected', + 'Gateway · stream stopped', + 'var(--green)' + ], + [ + 'reconnecting while relaying', + { device: link('reconnecting'), relay: { running: true, stream: 'connected' } }, + 'Reconnecting', + 'relay waiting — resumes when it connects', + 'var(--gold)' + ], + ['scanning', { device: link('scanning') }, 'Scanning', 'stream stopped', 'var(--blue-2)'], + [ + 'released', + { device: link('disconnected') }, + 'Disconnected', + 'stream stopped', + 'var(--muted)' + ], + ['port error', { device: link('error') }, 'Error', 'stream stopped', 'var(--red)'] + ])( + 'Link tile, %s: the device link, with the relay underneath', + async (_, scenario, v, s, color) => { + await renderScenario(, scenario) + expect(value('Link')).toHaveTextContent(v) + expect(sub('Link')).toBe(s) + expect(value('Link').style.color).toBe(color) + } + ) + + it('every device state has its Link value', async () => { + const states: [DeviceState, string][] = [ + ['connected', 'Connected'], + ['scanning', 'Scanning'], + ['reconnecting', 'Reconnecting'], + ['no-device', 'No device'], + ['disconnected', 'Disconnected'], + ['error', 'Error'] + ] + const { sky } = await renderScenario() + for (const [state, label] of states) { + sky.emitScenario({ device: link(state) }) + expect(value('Link')).toHaveTextContent(label) + } + }) + + it('Link tile opens the Connection tab; Cockpit tile the Cockpit tab', async () => { + const { user } = await renderScenario(, { device: connected() }) + await user.click(screen.getByRole('button', { name: /^Link/ })) + expect(screen.getByRole('tab', { name: 'Connection' })).toHaveAttribute('aria-selected', 'true') + await user.click(screen.getByRole('tab', { name: 'Overview' })) + await user.click(screen.getByRole('button', { name: /^Cockpit/ })) + expect(screen.getByRole('tab', { name: 'Cockpit' })).toHaveAttribute('aria-selected', 'true') + }) + + it('Aircraft tile: NONE until DCS names one, by metadata', async () => { + const { sky } = await renderScenario(, { device: connected() }) + expect(value('Aircraft')).toHaveTextContent('NONE') + expect(sub('Aircraft')).toBe('_ACFT_NAME · metadata') + sky.emitScenario({ aircraft: { name: 'A-4E-C', inferred: false, supported: true } }) + expect(value('Aircraft')).toHaveTextContent('A-4E-C') + expect(sub('Aircraft')).toBe('_ACFT_NAME · metadata') + expect(value('Aircraft').style.color).toBe('var(--text)') + }) + + it('Aircraft tile: an aircraft inferred from the address range says so', async () => { + await renderScenario(, { + aircraft: { name: 'A-4E-C', inferred: true, supported: true } + }) + expect(value('Aircraft')).toHaveTextContent('A-4E-C') + expect(sub('Aircraft')).toBe('inferred · address range') + }) + + it('Aircraft tile: another aircraft is gold, with the A-4E-C-only banner', async () => { + await renderScenario(, { + aircraft: { name: 'F-16C_50', inferred: false, supported: false } + }) + expect(value('Aircraft')).toHaveTextContent('F-16C_50') + expect(value('Aircraft').style.color).toBe('var(--gold)') + expect( + screen.getByText((_, el) => + el?.classList.contains('warn-banner') + ? el.textContent === + '⚠ F-16C_50 loaded — named decode is A-4E-C only. Relaying & stats continue; the log shows raw addresses.' + : false + ) + ).toBeInTheDocument() + }) + + it.each([ + ['tcp-to-host', 'TCP', '10.0.0.7:7779 · remote'], + ['loopback-multicast', 'Multicast', '239.255.50.10:5010 · local'], + // The sub-line reads the multicast endpoint for Unicast too, today. + ['unicast-listen', 'Unicast', '239.255.50.10:5010 · local'] + ] as const)('DCS Link tile, %s: "%s"', async (transport, v, s) => { + await renderScenario(, { + config: { transport, host: '10.0.0.7', commandPort: 7779 } + }) + expect(value('DCS Link')).toHaveTextContent(v) + expect(sub('DCS Link')).toBe(s) + }) + + it('gauges: five idle readouts, "—", until the first telemetry tick', async () => { + await renderScenario(, { device: connected() }) + expect(['RPM', 'IAS', 'Flap', 'Press Alt', 'Fuel'].map((l) => gauge(l).text)).toEqual([ + '—% RPM', + '—% FS', + '—% DN', + '—% FS', + '—% QTY' + ]) + }) + + it('gauges: the values of the last tick; one DCS hasn’t exported stays "—"', async () => { + await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' }, + gauges: simTelemetry({ RPM: 0.85, D_FLAPS_IND: 0.5, D_FUEL: 0.6 }) + }) + expect(['RPM', 'IAS', 'Flap', 'Press Alt', 'Fuel'].map((l) => gauge(l).text)).toEqual([ + '85% RPM', + '—kt', + '50% DN', + '—ft', + '60% QTY' + ]) + }) + + it('Fuel is gold, and red under 25 %', async () => { + const { sky } = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' }, + gauges: simTelemetry({ D_FUEL: 0.25 }) + }) + expect(gauge('Fuel')).toEqual({ text: '25% QTY', ring: 'var(--gold)' }) + sky.emitScenario({ gauges: simTelemetry({ D_FUEL: 0.24 }) }) + expect(gauge('Fuel')).toEqual({ text: '24% QTY', ring: 'var(--red)' }) + // The other rings keep their own colours. + expect(gauge('RPM').ring).toBe('var(--blue)') + expect(gauge('Flap').ring).toBe('var(--blue-2)') + expect(gauge('Press Alt').ring).toBe('var(--blue-2)') + }) + + it('Stream Health: the stats tick; errors red once there is one', async () => { + const { sky } = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' }, + stats: streamStats({ + bytesInPerSec: 2048, + bytesOutPerSec: 300, + framesPerSec: 30, + uptimeSec: 3725, + reconnects: 2, + commandsPerSec: 3, + commandsTotal: 1234, + lastCommand: 'FLAPS_SW 1' + }) + }) + expect( + ['Bytes In', 'Bytes Out', 'Frames', 'Errors', 'Uptime', 'Reconnects'].map( + (l) => health(l).textContent + ) + ).toEqual(['2.0 KB/s', '300 B/s', '30/s', '0', '01:02:05', '2']) + expect(health('Errors').style.color).toBe('var(--text)') + expect(screen.getByText(/^CMD/)).toHaveTextContent('CMD 3/s · 1,234 total · last FLAPS_SW 1') + + sky.emitScenario({ stats: streamStats({ errors: 3 }) }) + expect(health('Errors')).toHaveTextContent('3') + expect(health('Errors').style.color).toBe('var(--red)') + }) + + it('no stats yet: zeros, and no last command', async () => { + await renderScenario() + expect(within(screen.getByText(/^CMD/)).getByText('—')).toBeInTheDocument() + expect(health('Errors').style.color).toBe('var(--text)') + }) +}) diff --git a/src/renderer/test/t28-connection-relay-controls.test.tsx b/src/renderer/test/t28-connection-relay-controls.test.tsx new file mode 100644 index 0000000..eb4b8cc --- /dev/null +++ b/src/renderer/test/t28-connection-relay-controls.test.tsx @@ -0,0 +1,241 @@ +// T28 — Connection: the device card and the relay controls (source mode, capture, DCS link, +// record, Start / Stop). +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { USB_IDENTITY } from '@shared/firmware' +import { Connection } from '../src/tabs/Connection' +import { App } from '../src/App' +import { renderScenario, settle } from './render' +import { connected, link, PORT } from './scenarios' + +const HINT = { + bridge: + 'Full relay — Gateway serial ↔ DCS-BIOS, plus HID. Replaces socat / connect-serial-port.cmd.', + monitor: + 'Read DCS-BIOS off the LAN to inspect/test the stream — nothing is sent to the device. The device link, HID and node status stay up.', + replay: + 'No DCS — feed the parser/UI from a recorded capture; optionally drive the Gateway over the same link.' +} + +/** The board's USB ids as the card prints them, e.g. `0x2E8A / 0x4134` (from the synced identity). */ +const hex4 = (n: number) => `0x${n.toString(16).toUpperCase().padStart(4, '0')}` +const VID_PID = `${hex4(USB_IDENTITY.vid)} / ${hex4(USB_IDENTITY.pid)}` + +/** A device-card row's value, by its key. */ +const devRow = (k: string) => screen.getByText(k).nextElementSibling!.textContent +const startBtn = () => screen.getByRole('button', { name: /^(Start|Stop) relay$/ }) +/** The line under Start / Stop. */ +const waitnote = () => startBtn().nextElementSibling?.textContent ?? null +const mode = (m: 'Bridge' | 'Monitor' | 'Replay') => screen.getByRole('button', { name: m }) +/** What the last settings save sent main. */ +const lastSaved = (sky: Awaited>['sky']) => + sky.setConfig.mock.lastCall?.[0] + +describe('T28 Connection device card and relay controls (B61, B62)', () => { + it('device card, no board yet: only the VID/PID target', async () => { + await renderScenario(, { device: link('no-device') }) + expect(devRow('Identity')).toBe('— no device') + expect(devRow('VID / PID')).toBe(`${VID_PID} · target`) + expect(devRow('Serial port')).toBe('waiting for the board…') + expect(devRow('Interfaces')).toBe('—') + expect(screen.getByText('No device')).toBeInTheDocument() + }) + + it('device card, connected: identity, VID/PID, port, CDC + HID; Disconnect asks main', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + expect(devRow('Identity')).toBe(USB_IDENTITY.product) + expect(devRow('VID / PID')).toBe(VID_PID) + expect(devRow('Serial port')).toBe(PORT) + expect(devRow('Interfaces')).toBe('CDC serial + HID') + await user.click(screen.getByRole('button', { name: 'Disconnect' })) + expect(sky.disconnectDevice).toHaveBeenCalledTimes(1) + }) + + it('device card, released: Connect asks main; auto-connect off links to Settings › Connection', async () => { + const { sky, user } = await renderScenario(, { + device: link('disconnected'), + config: { autoConnect: false } + }) + await user.click(screen.getByRole('tab', { name: 'Connection' })) + expect( + screen.getByText(/^Automatic connection is off, so the port stays free for other tools/) + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Connect' })) + expect(sky.connectDevice).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'Settings › Connection' })) + expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true') + }) + + it('device card, port busy: Retry, and the "open in another app" note', async () => { + const { sky, user } = await renderScenario(, { + device: link('error', { detail: 'Resource busy' }) + }) + expect(devRow('Serial port')).toBe(`${PORT} · busy`) + expect( + screen.getByText(`${PORT} is open in another app (socat?). Close it, then Retry.`) + ).toBeInTheDocument() + // contract B61 says the "busy" note carries a Settings link; today only the released-with- + // auto-connect-off note does (above), and the busy note has none. + expect(screen.queryByRole('button', { name: 'Settings › Connection' })).toBeNull() + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(sky.connectDevice).toHaveBeenCalledTimes(1) + }) + + it('device card, scanning: the button is greyed out', async () => { + await renderScenario(, { device: link('scanning') }) + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeDisabled() + }) + + it('source mode buttons: each shows its hint and saves the mode', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + expect(screen.getByText(HINT.monitor)).toBeInTheDocument() + await user.click(mode('Bridge')) + expect(screen.getByText(HINT.bridge)).toBeInTheDocument() + expect(lastSaved(sky)).toMatchObject({ sourceMode: 'bridge' }) + await user.click(mode('Replay')) + expect(screen.getByText(HINT.replay)).toBeInTheDocument() + expect(lastSaved(sky)).toMatchObject({ sourceMode: 'replay' }) + }) + + it('Replay: Load capture… asks main, and the loaded file is ready to play', async () => { + const { sky, user } = await renderScenario(, { + device: connected(), + config: { sourceMode: 'replay' } + }) + expect( + screen.getByText( + 'No capture loaded. Load a recorded session to replay it with no DCS running.' + ) + ).toBeInTheDocument() + // Replay has no Record card. + expect(screen.queryByRole('button', { name: /recording/ })).toBeNull() + sky.openReplay.mockResolvedValueOnce({ + loaded: true, + path: '/Users/me/captures/carrier-trap.skycap', + events: 1200, + durationMs: 45_300 + }) + await user.click(screen.getByRole('button', { name: 'Load capture…' })) + await settle() + expect(sky.openReplay).toHaveBeenCalledTimes(1) + expect(screen.getByText('carrier-trap.skycap').parentElement).toHaveTextContent( + 'carrier-trap.skycap · 1200 events · 45.3s — press Start to play.' + ) + }) + + it('Replay: the Drive Gateway toggle saves, and without a board it blocks Start', async () => { + const { sky, user } = await renderScenario(, { + device: link('no-device'), + config: { sourceMode: 'replay' } + }) + const drive = screen.getByText('Drive Gateway').parentElement!.nextElementSibling! + expect(drive).toHaveAttribute('aria-pressed', 'false') + expect(startBtn()).toBeEnabled() + await user.click(drive) + expect(drive).toHaveAttribute('aria-pressed', 'true') + expect(lastSaved(sky)).toMatchObject({ replayDriveSerial: true }) + expect(startBtn()).toBeDisabled() + expect(waitnote()).toBe( + 'Connect the Gateway to play the capture to it, or turn Drive Gateway off.' + ) + }) + + it('DCS link: host, port and transport are saved as typed or picked', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + const host = screen.getByText('DCS host').nextElementSibling as HTMLInputElement + const port = screen.getByText('Port').nextElementSibling as HTMLInputElement + expect([host.value, port.value]).toEqual(['127.0.0.1', '7778']) + await user.clear(host) + await user.type(host, '10.0.0.7') + expect(lastSaved(sky)).toMatchObject({ host: '10.0.0.7', commandPort: 7778 }) + await user.clear(port) + await user.type(port, '7779') + expect(lastSaved(sky)).toMatchObject({ host: '10.0.0.7', commandPort: 7779 }) + + await user.click(screen.getByRole('button', { name: /^Loopback multicast/ })) + expect(lastSaved(sky)).toMatchObject({ transport: 'loopback-multicast' }) + await user.click(screen.getByRole('button', { name: /^Unicast listen/ })) + expect(lastSaved(sky)).toMatchObject({ transport: 'unicast-listen' }) + await user.click(screen.getByRole('button', { name: /^TCP to host/ })) + expect(lastSaved(sky)).toMatchObject({ transport: 'tcp-to-host' }) + }) + + it('Record is greyed out until the relay runs', async () => { + const { sky } = await renderScenario(, { device: connected() }) + expect(screen.getByRole('button', { name: 'Start recording…' })).toBeDisabled() + expect(screen.getByText('Start the relay first to enable recording.')).toBeInTheDocument() + sky.emitScenario({ relay: { running: true, stream: 'connected' } }) + expect(screen.getByRole('button', { name: 'Start recording…' })).toBeEnabled() + expect( + screen.getByText('Capture a live session to replay later with no DCS running.') + ).toBeInTheDocument() + }) + + it('Record while relaying asks main, and shows the running count', async () => { + const { sky, user } = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' } + }) + sky.toggleCapture.mockResolvedValueOnce({ recording: true, events: 42 }) + await user.click(screen.getByRole('button', { name: 'Start recording…' })) + await settle() + expect(sky.toggleCapture).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'Stop recording · 42 events' })).toBeEnabled() + expect( + screen.getByText( + 'Recording live DCS-BIOS stream. Stop to save — replay the file on any machine with no DCS.' + ) + ).toBeInTheDocument() + }) + + it('Bridge without a board: Start is greyed out, with the reason under it', async () => { + await renderScenario(, { + device: link('no-device'), + config: { sourceMode: 'bridge' } + }) + expect(startBtn()).toHaveTextContent('Start relay') + expect(startBtn()).toBeDisabled() + expect(waitnote()).toBe('Connect the Gateway to start the relay.') + }) + + it('Monitor needs no board: Start asks main; relaying, the button stops it', async () => { + const { sky, user } = await renderScenario(, { device: link('no-device') }) + expect(startBtn()).toBeEnabled() + expect(waitnote()).toBeNull() + await user.click(startBtn()) + await settle() + expect(sky.startRelay).toHaveBeenCalledTimes(1) + expect(startBtn()).toHaveTextContent('Stop relay') + await user.click(startBtn()) + expect(sky.stopRelay).toHaveBeenCalledTimes(1) + }) + + it('relaying but waiting: the waitnote says for what', async () => { + const { sky } = await renderScenario(, { + device: connected(), + config: { sourceMode: 'bridge' }, + relay: { running: true, stream: 'connecting' } + }) + expect(startBtn()).toHaveTextContent('Stop relay') + expect(startBtn()).toBeEnabled() + expect(waitnote()).toBe('Relaying — waiting for DCS-BIOS (connecting).') + sky.emitScenario({ + device: link('reconnecting'), + relay: { running: true, stream: 'connected' } + }) + // Still relaying: Stop stays available while it waits for the board. + expect(startBtn()).toBeEnabled() + expect(waitnote()).toBe('Relaying — waiting for the Gateway. Resumes when it connects.') + sky.emitScenario({ device: connected() }) + expect(waitnote()).toBeNull() + }) + + it('a replay that ran out: "Replay ended"', async () => { + await renderScenario(, { + device: connected(), + config: { sourceMode: 'replay' }, + relay: { running: true, stream: 'ended' } + }) + expect(waitnote()).toBe('Replay ended — Stop, or load another capture.') + }) +}) diff --git a/src/renderer/test/t29-settings-power-supply.test.tsx b/src/renderer/test/t29-settings-power-supply.test.tsx new file mode 100644 index 0000000..ed358b0 --- /dev/null +++ b/src/renderer/test/t29-settings-power-supply.test.tsx @@ -0,0 +1,118 @@ +// T29 — Settings › Cockpit › Power supply: the NX650 preset or limits typed by hand. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { PsuSetting } from '@shared/nodes' +import { Settings } from '../src/tabs/Settings' +import { renderScenario, settle } from './render' +import { connected } from './scenarios' + +const LIMITS = ['12 V rail limit', '5 V rail limit', 'Total power'] as const + +/** Settings on its Cockpit section, with `psu` saved. */ +async function openPowerSupply(psu?: PsuSetting) { + const r = await renderScenario(, { + device: connected(), + ...(psu && { config: { psu } }) + }) + await r.user.click(screen.getByRole('button', { name: /^Cockpit/ })) + await settle() + const group = screen.getByRole('group', { name: 'Power supply' }) + const preset = within(group).getByRole('button', { name: 'NX650' }) + const custom = within(group).getByRole('button', { name: 'Custom' }) + const field = (l: (typeof LIMITS)[number]) => screen.getByRole('textbox', { name: l }) + const values = () => LIMITS.map((l) => (field(l) as HTMLInputElement).value) + /** The psu setting of the last save sent to main. */ + const saved = () => r.sky.setConfig.mock.lastCall?.[0].psu + return { ...r, preset, custom, field, values, saved } +} + +describe('T29 Settings › Cockpit › Power supply (B87)', () => { + it('NX650: 54 A / 20 A / 650 W, read-only, from the preset', async () => { + const { preset, custom, field, values } = await openPowerSupply() + expect(preset).toHaveAttribute('aria-pressed', 'true') + expect(custom).toHaveAttribute('aria-pressed', 'false') + expect(values()).toEqual(['54', '20', '650']) + for (const l of LIMITS) expect(field(l)).toHaveAttribute('readonly') + expect(screen.getAllByText('From the NX650 preset')).toHaveLength(3) + expect( + screen.getByText('GOLDEN FIELD NX650 — the project’s supply. Display only; no alerts.') + ).toBeInTheDocument() + }) + + it('typing into a preset field changes nothing', async () => { + const { sky, user, field, values } = await openPowerSupply() + await user.type(field('Total power'), '9') + expect(values()).toEqual(['54', '20', '650']) + expect(sky.setConfig).not.toHaveBeenCalled() + }) + + it('Custom shows the saved limits, editable; typing saves the number', async () => { + const { user, custom, field, values, saved } = await openPowerSupply({ + mode: 'preset', + preset: 'nx650', + custom: { a12: 40, w: 500 } + }) + await user.click(custom) + expect(saved()).toEqual({ mode: 'custom', preset: 'nx650', custom: { a12: 40, w: 500 } }) + expect(values()).toEqual(['40', '', '500']) + for (const l of LIMITS) expect(field(l)).not.toHaveAttribute('readonly') + expect(screen.getByText('Combined current of all 5 V outputs (PSU label)')).toBeInTheDocument() + + await user.type(field('5 V rail limit'), '15') + expect(saved()).toEqual({ + mode: 'custom', + preset: 'nx650', + custom: { a12: 40, a5: 15, w: 500 } + }) + }) + + it('Custom keeps its values across a switch to the preset and back', async () => { + const { user, preset, custom, field, values } = await openPowerSupply({ + mode: 'custom', + preset: 'nx650', + custom: { a12: 40 } + }) + await user.type(field('Total power'), '480') + await user.click(preset) + expect(values()).toEqual(['54', '20', '650']) + await user.click(custom) + expect(values()).toEqual(['40', '', '480']) + }) + + it('emptying a field saves no limit for it', async () => { + const { user, field, saved } = await openPowerSupply({ + mode: 'custom', + preset: 'nx650', + custom: { a12: 40, a5: 15 } + }) + await user.clear(field('12 V rail limit')) + expect(saved()).toEqual({ mode: 'custom', preset: 'nx650', custom: { a5: 15 } }) + expect(field('12 V rail limit')).toHaveAttribute('aria-invalid', 'false') + }) + + it('a non-number is refused: marked invalid, kept on screen, never saved', async () => { + const { sky, user, field, saved } = await openPowerSupply({ + mode: 'custom', + preset: 'nx650', + custom: { a12: 40 } + }) + await user.clear(field('12 V rail limit')) + const saves = sky.setConfig.mock.calls.length + await user.type(field('12 V rail limit'), 'abc') + expect(field('12 V rail limit')).toHaveValue('abc') + expect(field('12 V rail limit')).toHaveAttribute('aria-invalid', 'true') + expect(sky.setConfig.mock.calls.length, 'nothing saved for "abc"').toBe(saves) + expect(saved()?.custom).toEqual({}) + }) + + it.each(['-5', '0', '20000', '1e3'])('"%s" is refused too', async (typed) => { + const { user, field, saved } = await openPowerSupply({ + mode: 'custom', + preset: 'nx650', + custom: {} + }) + await user.type(field('Total power'), typed) + expect(field('Total power')).toHaveAttribute('aria-invalid', 'true') + expect(saved()?.custom.w).not.toBe(Number(typed)) + }) +}) diff --git a/src/renderer/test/t30-settings-reset.test.tsx b/src/renderer/test/t30-settings-reset.test.tsx new file mode 100644 index 0000000..2985e65 --- /dev/null +++ b/src/renderer/test/t30-settings-reset.test.tsx @@ -0,0 +1,166 @@ +// T30 — Settings › Cockpit › Reset node mapping…: the row, and the review before anything resets. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeMappingFile, NodeModel } from '@shared/nodes' +import { Settings } from '../src/tabs/Settings' +import { renderScenario, settle } from './render' +import { connected, model, modelNode, panel, pdu, resetPreview } from './scenarios' + +/** + * node-mapping.json: Right_Navigation (#2) renamed "Nav" and moved to PDU_Left (#3), and Left_Trim + * (#41) adopted onto the Left console under PDU_Left. + */ +const FILE: NodeMappingFile = { + version: 3, + nodes: { + 2: { added: true, name: 'Nav', pduId: 3 }, + 3: { added: true }, + 41: { name: 'Left_Trim', console: 'Left', kind: 'panel', pduId: 3 } + } +} +const adoptedTrim = () => + modelNode(41, { name: 'Left_Trim', console: 'Left', pduId: 3, source: 'adopted' }) + +/** The model main pushes for FILE, as the Cockpit shows it. */ +const fileModel = () => + model([panel(2, 'Nav', { pduId: 3, differs: true }), pdu(3, 'PDU_Left'), adoptedTrim()], [], { + differingCount: 1 + }) + +/** Settings on its Cockpit section; main answers the review for `file`. */ +async function openCockpit(m: NodeModel, file: NodeMappingFile = FILE) { + const r = await renderScenario(, { device: connected(), model: m }) + r.sky.previewNodeReset.mockResolvedValue(resetPreview(file)) + await r.user.click(screen.getByRole('button', { name: /^Cockpit/ })) + await settle() + const resetBtn = () => screen.getByRole('button', { name: 'Reset…' }) + const rowDesc = () => screen.getByText('Reset node mapping').nextElementSibling!.textContent + const openReview = async () => { + await r.user.click(resetBtn()) + await settle() + return screen.getByRole('dialog', { name: 'Reset node mapping' }) + } + return { ...r, resetBtn, rowDesc, openReview } +} + +/** The review's change rows: `node · field`, old → new. */ +const changes = (dlg: HTMLElement) => + within(dlg) + .getAllByText('→') + .map((arrow) => arrow.parentElement!.textContent) + +describe('T30 Settings › Cockpit › Reset… (B89, B24)', () => { + it('everything matches NODE_IDS.md and nothing is adopted: greyed out', async () => { + const { resetBtn, rowDesc } = await openCockpit( + model([panel(1, 'Center_Armament'), pdu(3, 'PDU_Left')]) + ) + expect(rowDesc()).toBe('Every node matches NODE_IDS.md') + expect(resetBtn()).toBeDisabled() + }) + + it('a board differs: "1 node differs …", and Reset… is enabled', async () => { + const { resetBtn, rowDesc } = await openCockpit( + model([panel(2, 'Nav', { differs: true })], [], { differingCount: 1 }) + ) + expect(rowDesc()).toBe( + '1 node differs from NODE_IDS.md — return them to their registry name, console and PDU' + ) + expect(resetBtn()).toBeEnabled() + }) + + it('nothing differs but an adopted board is in the Cockpit: enabled (it can be forgotten)', async () => { + const { resetBtn, rowDesc } = await openCockpit( + model([panel(1, 'Center_Armament'), adoptedTrim()]) + ) + expect(rowDesc()).toBe('Every node matches NODE_IDS.md') + expect(resetBtn()).toBeEnabled() + }) + + it('the only adopted board is hidden: greyed out (X3 today)', async () => { + // contract X3: main counts a hidden board as in your cockpit, so Reset would offer to forget + // it; today the UI looks only at the boards it shows, and Reset… stays greyed out. + const { resetBtn } = await openCockpit( + model([panel(1, 'Center_Armament')], [], { + hidden: [41], + hiddenNodes: [{ nodeId: 41, name: 'Left_Trim', console: 'Left' }] + }) + ) + expect(resetBtn()).toBeDisabled() + }) + + it('the review lists each field old → new, from main’s preview', async () => { + const { sky, openReview } = await openCockpit(fileModel()) + const dlg = await openReview() + expect(sky.previewNodeReset).toHaveBeenCalled() + expect(within(dlg).getByText('2 changes')).toBeInTheDocument() + expect(changes(dlg)).toEqual([ + 'Right_Navigation · nameNav→Right_Navigation', + 'Right_Navigation · power zonePDU_Left→PDU_Right', + '#41 Left_TrimLeft · PDU_Left→New on the bus' + ]) + expect(within(dlg).getByText('1 node · not in the registry')).toBeInTheDocument() + expect( + within(dlg).getByRole('checkbox', { name: 'Also forget adopted nodes' }) + ).toHaveAttribute('aria-checked', 'false') + expect( + within(dlg).getByText('Hidden boards and the power supply are not changed.') + ).toBeInTheDocument() + expect(within(dlg).getByRole('button', { name: 'Reset 1 node' })).toBeEnabled() + }) + + it('Reset asks main to reset, keeping adopted boards; the review closes', async () => { + const { sky, user, openReview } = await openCockpit(fileModel()) + const dlg = await openReview() + await user.click(within(dlg).getByRole('button', { name: 'Reset 1 node' })) + await settle() + expect(sky.resetNodeMapping).toHaveBeenCalledTimes(1) + expect(sky.resetNodeMapping).toHaveBeenCalledWith({ forgetAdopted: false }) + expect(screen.queryByRole('dialog', { name: 'Reset node mapping' })).toBeNull() + }) + + it('"Also forget adopted" → resetNodeMapping({ forgetAdopted: true })', async () => { + const { sky, user, openReview } = await openCockpit(fileModel()) + const dlg = await openReview() + const forget = within(dlg).getByRole('checkbox', { name: 'Also forget adopted nodes' }) + await user.click(forget) + expect(forget).toHaveAttribute('aria-checked', 'true') + await user.click(within(dlg).getByRole('button', { name: 'Reset 1 node + forget 1' })) + await settle() + expect(sky.resetNodeMapping).toHaveBeenCalledWith({ forgetAdopted: true }) + }) + + it('only an adopted board: nothing to reset until "Also forget" is ticked', async () => { + const file: NodeMappingFile = { version: 3, nodes: { 41: FILE.nodes[41]! } } + const { sky, user, openReview } = await openCockpit( + model([panel(1, 'Center_Armament'), adoptedTrim()]), + file + ) + const dlg = await openReview() + expect( + within(dlg).getByText('None — every registry node already matches NODE_IDS.md.') + ).toBeInTheDocument() + expect(within(dlg).getByRole('button', { name: 'Reset' })).toBeDisabled() + await user.click(within(dlg).getByRole('checkbox', { name: 'Also forget adopted nodes' })) + await user.click(within(dlg).getByRole('button', { name: 'Forget 1 adopted node' })) + await settle() + expect(sky.resetNodeMapping).toHaveBeenCalledWith({ forgetAdopted: true }) + }) + + it('a refused reset shows main’s error and keeps the review open', async () => { + const { sky, user, openReview } = await openCockpit(fileModel()) + sky.resetNodeMapping.mockResolvedValueOnce({ ok: false, error: 'Node mapping is read-only' }) + const dlg = await openReview() + await user.click(within(dlg).getByRole('button', { name: 'Reset 1 node' })) + await settle() + expect(within(dlg).getByText('Node mapping is read-only')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Reset node mapping' })).toBeInTheDocument() + }) + + it('Cancel closes the review without asking main', async () => { + const { sky, user, openReview } = await openCockpit(fileModel()) + const dlg = await openReview() + await user.click(within(dlg).getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Reset node mapping' })).toBeNull() + expect(sky.resetNodeMapping).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/test/t31-settings-connection-diagnostics.test.tsx b/src/renderer/test/t31-settings-connection-diagnostics.test.tsx new file mode 100644 index 0000000..aa2c265 --- /dev/null +++ b/src/renderer/test/t31-settings-connection-diagnostics.test.tsx @@ -0,0 +1,120 @@ +// T31 — Settings › Connection and Settings › Diagnostics. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import type { AppConfig } from '@shared/config' +import { Settings } from '../src/tabs/Settings' +import { renderScenario, settle } from './render' +import { connected, link } from './scenarios' + +/** A settings row's control, by the row's label. */ +const control = (label: string) => + screen.getByText(label).parentElement!.nextElementSibling!.firstElementChild as HTMLElement +/** A settings row's description, by the row's label. */ +const desc = (label: string) => screen.getByText(label).nextElementSibling!.textContent + +async function openSettings(section?: RegExp, config?: Partial) { + const r = await renderScenario(, { + device: link('disconnected'), + ...(config && { config }) + }) + if (section) { + await r.user.click(screen.getByRole('button', { name: section })) + await settle() + } + /** What the last settings save sent main. */ + const saved = () => r.sky.setConfig.mock.lastCall?.[0] + return { ...r, saved } +} + +describe('T31 Settings › Connection + Diagnostics (B86, B91)', () => { + it('Connection is the section that opens; it shows the saved source mode, host and port', async () => { + await openSettings(undefined, { sourceMode: 'bridge', host: '10.0.0.7', commandPort: 7779 }) + expect(screen.getByRole('button', { name: /^Connection/ })).toHaveAttribute( + 'aria-current', + 'page' + ) + expect(control('Source mode')).toHaveTextContent('bridge') + expect(control('DCS host')).toHaveValue('10.0.0.7') + expect(control('Command port')).toHaveValue('7779') + }) + + it('the DCS host and command port are saved as typed', async () => { + const { user, saved } = await openSettings() + await user.clear(control('DCS host')) + await user.type(control('DCS host'), '192.168.1.20') + expect(saved()).toMatchObject({ host: '192.168.1.20' }) + await user.clear(control('Command port')) + await user.type(control('Command port'), '7780') + expect(saved()).toMatchObject({ host: '192.168.1.20', commandPort: 7780 }) + }) + + it('Connect automatically: the toggle saves the setting either way', async () => { + // Turning it on while the link is down also connects at once — main does that when the + // setting arrives (SESSION seam); the renderer only saves it. + const { sky, user, saved } = await openSettings(undefined, { autoConnect: false }) + const toggle = control('Connect automatically') + expect(toggle).toHaveAttribute('aria-pressed', 'false') + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + expect(saved()).toMatchObject({ autoConnect: true }) + expect(sky.connectDevice).not.toHaveBeenCalled() + await user.click(toggle) + expect(saved()).toMatchObject({ autoConnect: false }) + }) + + it('Record session asks main, and says it is recording', async () => { + const { sky, user } = await openSettings() + expect(desc('Record session')).toBe('Capture the DCS-BIOS stream to a file for replay') + // contract B62 has Record disabled unless relaying (the Connection tab's Record card); today + // this Settings row offers it while the relay is stopped too. + expect(screen.getByRole('button', { name: '● Record' })).toBeEnabled() + sky.toggleCapture.mockResolvedValueOnce({ recording: true, events: 7 }) + await user.click(screen.getByRole('button', { name: '● Record' })) + await settle() + expect(sky.toggleCapture).toHaveBeenCalledTimes(1) + expect(desc('Record session')).toBe('Recording · 7 events…') + expect(screen.getByRole('button', { name: '■ Stop' })).toBeInTheDocument() + }) + + it('Diagnostics: the debug-logging toggle saves the setting', async () => { + const { user, saved } = await openSettings(/^Diagnostics/) + const toggle = control('Debug logging') + expect(toggle).toHaveAttribute('aria-pressed', 'false') + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + expect(saved()).toMatchObject({ debugMode: true }) + }) + + it('Diagnostics: Dump asks main, and says where the ports went', async () => { + const { sky, user } = await openSettings(/^Diagnostics/) + expect(desc('Dump serial ports')).toBe( + 'Write every detected serial port (all USB fields) to the log' + ) + sky.dumpSerialPorts.mockResolvedValueOnce({ + path: '/Users/me/Library/Logs/debug.log', + count: 3 + }) + await user.click(screen.getByRole('button', { name: 'Dump' })) + await settle() + expect(sky.dumpSerialPorts).toHaveBeenCalledTimes(1) + expect(desc('Dump serial ports')).toBe('Wrote 3 port(s) → /Users/me/Library/Logs/debug.log') + }) + + it('Diagnostics: Reveal asks main to show the debug log', async () => { + const { sky, user } = await openSettings(/^Diagnostics/) + await user.click(screen.getByRole('button', { name: 'Reveal' })) + expect(sky.revealDebugLog).toHaveBeenCalledTimes(1) + }) + + it('a saved debug mode shows on', async () => { + await openSettings(/^Diagnostics/, { debugMode: true }) + expect(control('Debug logging')).toHaveAttribute('aria-pressed', 'true') + }) + + it('the link state does not grey anything out', async () => { + const { sky } = await openSettings(/^Diagnostics/) + sky.emitScenario({ device: connected() }) + expect(screen.getByRole('button', { name: 'Dump' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Reveal' })).toBeEnabled() + }) +}) diff --git a/src/renderer/test/t32-settings-about.test.tsx b/src/renderer/test/t32-settings-about.test.tsx new file mode 100644 index 0000000..9414188 --- /dev/null +++ b/src/renderer/test/t32-settings-about.test.tsx @@ -0,0 +1,105 @@ +// T32 — Settings › About: version, ids, and the node registry with its On the bus column. +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeModel } from '@shared/nodes' +import { Settings } from '../src/tabs/Settings' +import pkg from '../../../package.json' +import { renderScenario, settle } from './render' +import { bundledRegistry, connected, link, model, offline, panel, pdu } from './scenarios' + +/** + * On the bus now: Center_Armament (#1) and PDU_Left (#3); PDU_Right (#4) offline; PDU_Center + * (#5) hidden; Right_Navigation (#2) not reported at all. + */ +const bus = () => + model([panel(1, 'Center_Armament'), pdu(3, 'PDU_Left'), pdu(4, 'PDU_Right', offline())], [], { + hidden: [5], + hiddenNodes: [{ nodeId: 5, name: 'PDU_Center', console: 'Center' }] + }) + +/** A settings row's value, by the row's label. */ +const value = (label: string) => + screen.getByText(label).parentElement!.nextElementSibling!.textContent + +async function openAbout(m: NodeModel = bus(), device = connected()) { + const r = await renderScenario(, { device, model: m, registry: bundledRegistry() }) + await r.user.click(screen.getByRole('button', { name: /^About/ })) + await settle() + const openRegistry = async () => { + await r.user.click(screen.getByRole('button', { name: 'View…' })) + await settle() + return screen.getByRole('dialog', { name: 'Node registry' }) + } + return { ...r, openRegistry } +} + +/** Each registry row: `#id`, name, console, On the bus. */ +const rows = (dlg: HTMLElement) => + within(dlg) + .getAllByText(/^#\d+$/) + .map((id) => id.parentElement!.textContent) + +describe('T32 Settings › About (B92, X7)', () => { + it('version, app id and licence', async () => { + await openAbout() + expect(value('Version')).toBe(`v${pkg.version}`) + expect(value('App id')).toBe('com.openskyhawk.client') + expect(value('License')).toBe('GPL-2.0-only') + }) + + it('the registry row: what NODE_IDS.md holds, and its commit', async () => { + await openAbout() + expect( + screen.getByText('NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway') + ).toBeInTheDocument() + expect(value('Node registry')).toBe('a78dd90View…') + }) + + it('the registry dialog: every NODE_ID, the bridge as the Gateway, On the bus from the roster', async () => { + const { openRegistry } = await openAbout() + const dlg = await openRegistry() + expect(within(dlg).getByText('NODE_IDS.md @ a78dd90 · 2026-10-16')).toBeInTheDocument() + expect(rows(dlg)).toEqual([ + '#0Gateway—serial link', + '#1Center_ArmamentCenteryes', + '#2Right_NavigationRight—', + '#3PDU_LeftLeftyes', + '#4PDU_RightRightoffline', + '#5PDU_CenterCenterhidden' + ]) + }) + + it('the dialog follows the next roster push', async () => { + const { sky, openRegistry } = await openAbout() + const dlg = await openRegistry() + sky.emitScenario({ + model: model([panel(1, 'Center_Armament', offline()), panel(2, 'Right_Navigation')]) + }) + expect(rows(dlg).slice(1, 3)).toEqual([ + '#1Center_ArmamentCenteroffline', + '#2Right_NavigationRightyes' + ]) + }) + + it('unplugged: the Gateway reads "no device", but CAN nodes still read "yes" (R5 today)', async () => { + // contract X7/R5: on the bus means now; today the CAN rows keep the last roster while the + // link is down (they ignore it), and only the Gateway row follows the link. + const { sky, openRegistry } = await openAbout() + const dlg = await openRegistry() + sky.emitScenario({ device: link('no-device') }) + expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenteryes']) + }) + + it('opened while unplugged: the same', async () => { + const { openRegistry } = await openAbout(bus(), link('disconnected')) + const dlg = await openRegistry() + expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenteryes']) + }) + + it('Close shuts the dialog', async () => { + const { user, openRegistry } = await openAbout() + const dlg = await openRegistry() + await user.click(within(dlg).getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog', { name: 'Node registry' })).toBeNull() + }) +}) diff --git a/src/renderer/test/t33-dialog-focus.test.tsx b/src/renderer/test/t33-dialog-focus.test.tsx new file mode 100644 index 0000000..6e2170f --- /dev/null +++ b/src/renderer/test/t33-dialog-focus.test.tsx @@ -0,0 +1,152 @@ +// T33 — focus in the modal surfaces: a Settings review dialog and a Cockpit side panel. Both use +// the same handling (useModalFocus); this pins it at each. The CalibrationDialog shares it too but +// has no role="dialog" or accessible name today, so it can't be reached the same way (known gap). +import { describe, expect, it } from 'vitest' +import { screen, within } from '@testing-library/react' +import type { NodeMappingFile } from '@shared/nodes' +import { Settings } from '../src/tabs/Settings' +import { Cockpit } from '../src/tabs/Cockpit' +import { renderScenario, settle } from './render' +import { connected, model, modelNode, panel, pdu, resetPreview } from './scenarios' + +/** Right_Navigation renamed (one reset row) and Left_Trim (#41) adopted: four controls in the review. */ +const FILE: NodeMappingFile = { + version: 3, + nodes: { + 2: { added: true, name: 'Nav' }, + 41: { name: 'Left_Trim', console: 'Left', kind: 'panel' } + } +} + +/** Settings › Cockpit with Reset… focused and clicked: the review open. */ +async function openReview() { + const r = await renderScenario(, { + device: connected(), + model: model( + [ + panel(2, 'Nav', { differs: true }), + modelNode(41, { name: 'Left_Trim', console: 'Left', source: 'adopted' }) + ], + [], + { differingCount: 1 } + ) + }) + r.sky.previewNodeReset.mockResolvedValue(resetPreview(FILE)) + await r.user.click(screen.getByRole('button', { name: /^Cockpit/ })) + await settle() + const opener = screen.getByRole('button', { name: 'Reset…' }) + await r.user.click(opener) + await settle() + const dlg = screen.getByRole('dialog', { name: 'Reset node mapping' }) + return { ...r, opener, dlg } +} + +/** The Cockpit with Right_Navigation's side panel opened from its tile. */ +async function openSheet() { + const r = await renderScenario(, { + device: connected(), + model: model([panel(2, 'Right_Navigation', { pduId: 4 }), pdu(4, 'PDU_Right')]) + }) + const opener = screen.getByTitle(/^#2 Right_Navigation/) + await r.user.click(opener) + const sheet = screen.getByRole('dialog', { name: 'Panel group Right_Navigation' }) + return { ...r, opener, sheet } +} + +/** The controls Tab moves through inside `box`, in order. */ +const tabStops = (box: HTMLElement) => [ + ...box.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) +] + +describe('T33 Dialog focus (B76)', () => { + it('dialog: focus goes to its first control on open', async () => { + const { dlg } = await openReview() + expect(within(dlg).getByRole('button', { name: 'Close' })).toHaveFocus() + }) + + it('dialog: Tab goes through its controls and wraps; Shift+Tab wraps back', async () => { + const { user, dlg } = await openReview() + const close = within(dlg).getByRole('button', { name: 'Close' }) + const forget = within(dlg).getByRole('checkbox', { name: 'Also forget adopted nodes' }) + const cancel = within(dlg).getByRole('button', { name: 'Cancel' }) + const reset = within(dlg).getByRole('button', { name: 'Reset 1 node' }) + expect(tabStops(dlg)).toEqual([close, forget, cancel, reset]) + + await user.tab() + expect(forget).toHaveFocus() + await user.tab() + expect(cancel).toHaveFocus() + await user.tab() + expect(reset).toHaveFocus() + await user.tab() + expect(close, 'wraps to the first').toHaveFocus() + await user.tab({ shift: true }) + expect(reset, 'wraps back to the last').toHaveFocus() + }) + + it('dialog: Esc closes it, and focus returns to Reset…', async () => { + const { user, opener } = await openReview() + await user.keyboard('{Escape}') + expect(screen.queryByRole('dialog', { name: 'Reset node mapping' })).toBeNull() + expect(opener).toHaveFocus() + }) + + it('dialog: Close closes it, and focus returns to Reset…', async () => { + const { user, opener, dlg } = await openReview() + await user.click(within(dlg).getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog', { name: 'Reset node mapping' })).toBeNull() + expect(opener).toHaveFocus() + }) + + it('dialog: a click on the scrim closes it, and focus returns to Reset…', async () => { + const { user, opener, container } = await openReview() + // The scrim has no role or name: its class is the only way to reach it. + await user.click(container.querySelector('.im__scrim')!) + expect(screen.queryByRole('dialog', { name: 'Reset node mapping' })).toBeNull() + expect(opener).toHaveFocus() + }) + + it('side panel: focus goes to its first control on open', async () => { + const { sheet } = await openSheet() + expect(tabStops(sheet)[0]).toHaveFocus() + expect(tabStops(sheet)[0]).toHaveTextContent('Close') + }) + + it('side panel: Tab wraps inside it, both ways', async () => { + const { user, sheet } = await openSheet() + const stops = tabStops(sheet) + expect(stops.length).toBeGreaterThan(2) + for (let i = 1; i < stops.length; i++) { + await user.tab() + expect(stops[i]).toHaveFocus() + } + await user.tab() + expect(stops[0], 'wraps to the first').toHaveFocus() + await user.tab({ shift: true }) + expect(stops.at(-1), 'wraps back to the last').toHaveFocus() + }) + + it('side panel: Esc closes it, and focus returns to the tile', async () => { + const { user, opener } = await openSheet() + await user.keyboard('{Escape}') + expect(screen.queryByRole('dialog', { name: 'Panel group Right_Navigation' })).toBeNull() + expect(opener).toHaveFocus() + }) + + it('side panel: Close closes it, and focus returns to the tile', async () => { + const { user, opener, sheet } = await openSheet() + await user.click(within(sheet).getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog', { name: 'Panel group Right_Navigation' })).toBeNull() + expect(opener).toHaveFocus() + }) + + it('side panel: a click on the scrim closes it, and focus returns to the tile', async () => { + const { user, opener, container } = await openSheet() + // The scrim has no role or name: its class is the only way to reach it. + await user.click(container.querySelector('.sheet__scrim')!) + expect(screen.queryByRole('dialog', { name: 'Panel group Right_Navigation' })).toBeNull() + expect(opener).toHaveFocus() + }) +}) diff --git a/src/renderer/test/t34-overview-cockpit-agreement.test.tsx b/src/renderer/test/t34-overview-cockpit-agreement.test.tsx new file mode 100644 index 0000000..a20ba19 --- /dev/null +++ b/src/renderer/test/t34-overview-cockpit-agreement.test.tsx @@ -0,0 +1,125 @@ +// T34 — one node model, three surfaces: the Overview, the Cockpit and the Connection tab count the +// same boards the same way — except a stray, which only the Cockpit counts (X1 today). +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { App } from '../src/App' +import { renderScenario } from './render' +import { + connected, + model, + newBoard, + noStatusYet, + offline, + outage, + panel, + pdu, + reported +} from './scenarios' + +/** + * Six boards in your cockpit: Center_Armament (#1) and PDU_Left (#3) healthy, Left_Oxygen (#7) + * overheating, PDU_Right (#4) down with Right_Navigation (#2), Left_Trim (#8) no status yet. On the + * bus too: a second Gateway (#9), a stray the Cockpit warns about at once. + */ +const scene = () => + model( + [ + panel(1, 'Center_Armament'), + panel(2, 'Right_Navigation', { pduId: 4, ...offline() }), + pdu(3, 'PDU_Left'), + pdu(4, 'PDU_Right', offline()), + panel(7, 'Left_Oxygen', { status: reported(7, { overheat: true }) }), + panel(8, 'Left_Trim', noStatusYet()), + newBoard(9, 0, { identified: 'bridge' }) + ], + [outage(4, [2], 1)] + ) + +const tab = (name: string) => screen.getByRole('tab', { name }) + +/** The Overview's Cockpit tile and attention banner. */ +function overview() { + const tile = screen.getByRole('button', { name: /^Cockpit/ }) + return { + value: tile.children[1]!.textContent, + sub: tile.children[2]!.textContent, + banner: screen.queryByRole('status')?.textContent ?? null + } +} + +/** The Cockpit band's Nodes online card: `online/ total`, then its sub-line. */ +function cockpit() { + const label = screen.getByText('Nodes online') + return [ + label.parentElement!.previousElementSibling!.textContent, + label.nextElementSibling!.textContent + ] +} + +/** Connection's Connected nodes counts. */ +const connection = () => + ['healthy', 'warning', 'offline'].map((l) => screen.getByText(l).previousSibling!.textContent) + +describe('T34 Overview ↔ Cockpit ↔ Connection agreement (X1, B58, B63, B67)', () => { + it('one nodes:model push: the same offline and warning counts on every tab, but the stray', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + sky.emitScenario({ model: scene() }) + + // Overview: 3 of 6 up; 2 offline and 1 warning. The stray is not counted (X1 today). + expect(overview()).toEqual({ + value: '✕3 / 6', + sub: 'nodes up · — W · 2 PDUs', + banner: '⚠ 3 nodes need attention — 2 offline (PDU_Right + 1 node) · 1 warning. Open Cockpit' + }) + + // Cockpit: the same 2 offline and 1 no status yet; the stray adds one up, one warning and one + // to the total (contract D1 says every tab counts it; today only the Cockpit does — X1). + await user.click(tab('Cockpit')) + expect(cockpit()).toEqual(['4/ 7', '2 warning · 2 offline · 1 no status yet']) + + // Connection: 3 up = 2 healthy + 1 warning, 2 offline — the Overview's numbers, no stray. + await user.click(tab('Connection')) + expect(connection()).toEqual(['2', '1', '2']) + expect(screen.getByText('PDUs · — W').parentElement).toHaveTextContent('2PDUs · — W') + expect( + screen.queryByRole('button', { name: 'Open Node 9 in Cockpit' }), + 'no row for the stray' + ).toBeNull() + }) + + it('without the stray, all three agree exactly', async () => { + const noStray = () => { + const m = scene() + return { ...m, nodes: m.nodes.filter((n) => n.nodeId !== 9) } + } + const { sky, user } = await renderScenario(, { device: connected() }) + sky.emitScenario({ model: noStray() }) + expect(overview().value).toBe('✕3 / 6') + await user.click(tab('Cockpit')) + expect(cockpit()).toEqual(['3/ 6', '1 warning · 2 offline · 1 no status yet']) + await user.click(tab('Connection')) + expect(connection()).toEqual(['2', '1', '2']) + }) + + it('the next push moves all three together', async () => { + const { sky, user } = await renderScenario(, { device: connected(), model: scene() }) + // PDU_Right and Right_Navigation come back; Left_Oxygen cools down. + sky.emitScenario({ + model: model([ + panel(1, 'Center_Armament'), + panel(2, 'Right_Navigation', { pduId: 4 }), + pdu(3, 'PDU_Left'), + pdu(4, 'PDU_Right'), + panel(7, 'Left_Oxygen'), + panel(8, 'Left_Trim', noStatusYet()), + newBoard(9, 0, { identified: 'bridge' }) + ]) + }) + expect(overview()).toEqual({ value: '5 / 6', sub: 'nodes up · — W · 2 PDUs', banner: null }) + await user.click(tab('Cockpit')) + expect(cockpit()).toEqual(['6/ 7', '1 warning · 0 offline · 1 no status yet']) + await user.click(tab('Connection')) + expect(connection()).toEqual(['5', '0', '0']) + expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/test/t35-settings-hidden-after-push.test.tsx b/src/renderer/test/t35-settings-hidden-after-push.test.tsx new file mode 100644 index 0000000..b6a9a5e --- /dev/null +++ b/src/renderer/test/t35-settings-hidden-after-push.test.tsx @@ -0,0 +1,78 @@ +// T35 — Settings › Cockpit › Hidden boards follows main's pushes: a hidden board that reports +// again drops off the list. Settings reads the model through its own subscription (useNodeModel), +// a second copy beside the store's; this pins the deterministic path only — pull, then pushes in +// order. The pull-vs-push race (C5 / F10) is not pinned. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Settings } from '../src/tabs/Settings' +import { renderScenario, settle } from './render' +import { connected, model, offline, panel } from './scenarios' + +const EMPTY = 'No hidden boards. Hide in an offline board’s Cockpit panel lists it here.' + +/** Right_Radar (#8) hidden while offline. */ +const radarHidden = () => + model([panel(1, 'Center_Armament')], [], { + hidden: [8], + hiddenNodes: [{ nodeId: 8, name: 'Right_Radar', console: 'Right' }] + }) + +async function openCockpit() { + const r = await renderScenario(, { device: connected(), model: radarHidden() }) + await r.user.click(screen.getByRole('button', { name: /^Cockpit/ })) + await settle() + return r +} + +const unhideButtons = () => screen.queryAllByRole('button', { name: 'Un-hide' }) + +describe('T35 Settings › Cockpit › Hidden boards after a push (B88, F10)', () => { + it('a hidden board that reports again drops off the list', async () => { + const { sky } = await openCockpit() + expect(screen.getByText('#8')).toBeInTheDocument() + expect(unhideButtons()).toHaveLength(1) + + // It reports: main clears its hidden flag and pushes it back on the plan. + sky.emitScenario({ model: model([panel(1, 'Center_Armament'), panel(8, 'Right_Radar')]) }) + expect(screen.queryByText('#8')).toBeNull() + expect(unhideButtons()).toHaveLength(0) + expect(screen.getByText(EMPTY)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Un-hide all' })).toBeDisabled() + }) + + it('a board hidden elsewhere (the Cockpit, a config import) appears without reopening Settings', async () => { + const { sky } = await openCockpit() + sky.emitScenario({ + model: model([panel(1, 'Center_Armament')], [], { + hidden: [2, 8], + hiddenNodes: [ + { nodeId: 2, name: 'Right_Navigation', console: 'Right' }, + { nodeId: 8, name: 'Right_Radar', console: 'Right' } + ] + }) + }) + expect(unhideButtons()).toHaveLength(2) + expect(screen.getByText('#2').parentElement).toHaveTextContent( + '#2Right_NavigationRight console · returns when it reports again' + ) + }) + + it('the last push wins: hidden, shown, hidden again', async () => { + const { sky } = await openCockpit() + sky.emitScenario({ model: model([panel(1, 'Center_Armament'), panel(8, 'Right_Radar')]) }) + expect(screen.queryByText('#8')).toBeNull() + sky.emitScenario({ model: radarHidden() }) + expect(screen.getByText('#8')).toBeInTheDocument() + }) + + it('a push that still lists it hidden keeps the row', async () => { + const { sky } = await openCockpit() + sky.emitScenario({ + model: model([panel(1, 'Center_Armament', offline())], [], { + hidden: [8], + hiddenNodes: [{ nodeId: 8, name: 'Right_Radar', console: 'Right' }] + }) + }) + expect(screen.getByText('#8')).toBeInTheDocument() + }) +}) From 39c052c9b4b57bc1fbe058f3b46466de3315d7ab Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 01:44:41 -0400 Subject: [PATCH 20/28] refactor(calibration): workflow moves to the hid calibration service (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3.1 of the structure rewrite (#101). The calibration workflow runs in main. The dialog and the HID tab draw what main pushes, and send the user's actions. ## What moved | Before | After | |---|---| | `src/renderer/src/calibration-controller.ts` (`CalibrationController`, `CalibrationApi`) | `src/main/hid/calibration/calibration-workflow.ts` (`CalibrationWorkflow`, `CalibrationCommands`). Moved verbatim: same methods and logic. Timers are injected (`WorkflowTimers`, default `SYSTEM_TIMERS`). Its state types (`DraftAxis`, `WritePhase`, `WriteProgress`, `WorkflowState`, which was the controller's `CalibrationState`) moved to the contract. | | `src/renderer/src/calibration-controller.test.ts` | `src/main/hid/calibration/calibration-workflow.test.ts`. Ported 1:1: the import paths changed, `CalibrationController` became `CalibrationWorkflow`, and `CalibrationApi` became `CalibrationCommands`. | | `src/shared/hid/calibration/legacy-rules.ts` (+ test) | `src/main/hid/calibration/axis-capture.ts` (+ test). Moved verbatim, minus the `@deprecated` tags. The legacy file is deleted. | | `CalibrationDialog.tsx`: `TRAVEL_BANDS`, `travelQuality`, the prompt's step meaning and direction inference, and every gate | `src/main/hid/calibration/calibration-view-model.ts` (`dialogState`). Each derivation is verbatim. | | `tabs/Hid.tsx`: badges, uncalibrated count, offer banner gate, offer axis, Calibrate shown/enabled, default axis | `calibration-view-model.ts` (`tabState`) | | `useCalibration`: the controller's lifetime (built on HID-tab mount, disposed on unmount), `cal:raw` → ingest, linkChanged before deviceChanged, offer re-read per device report, the start sequence (open → restore panel → shown) and the close sequence (session close → hidden → re-read → offer); `store.ts`: the snapshot dropped whenever the link isn't up; the dialog's `startRaw` anchor | `src/main/hid/calibration/workflow-host.ts` (`CalibrationWorkflowHost`), hosted by `HidCalibrationService` (`.workflow`). It keeps one view per mounted HID tab (`cal:attach`). AppServices feeds it the link state from `onStatus`, after `device:status` has gone out. | | — | `src/renderer/src/calibration-view.ts`: `toDisplay`/`fmt`/`fmtSpan`/`pct` and `promptText` (the prompt's words) | The renderer's calibration code is wording only. The two UI-local states stay in the dialog: the two-click Delete arming and the 1.2 s "Captured" beat. ## The workflow's lifetime: `cal:attach` (the lifecycle intent) Before R3.1, the HID tab owned the controller: `useCalibration` built it on mount and disposed it on unmount. `cal:attach(on)` replaces that ownership. `Hid.tsx` sends it from a `useEffect` at the same two moments. - **`attach(true)`** builds a fresh `CalibrationWorkflow`. It then runs what the mount's effects ran, in their order: `linkChanged`, `deviceChanged`, and the restore-offer read. The offer starts empty until that read lands, as the hook's state did. - **`attach(false)`** calls `dispose()`. The ticker and the hold/notice timers stop, and RAW is dropped while nothing is attached. As with the renderer's controller, work already under way keeps running. - **A renderer reload** never runs the unmount, and the old page's controller died with the page. - `relay:status` (a renderer's first pull) and a second `attach(true)` without a detach both mark the old view gone: it is disposed, and its pending commands never answer. - So nothing the old page started reaches the device again, and the rehydrate carries no dialog. Without this, a dialog, its live reading and its pending timers would have outlived a tab switch or a reload. That would be a behaviour change. ## `CalibrationState` (`cal:workflow`) ```ts interface CalibrationState { tab: { snapshot?: CalSnapshot // the device's report while the link is up (was store.cal) badges: Partial> uncalibrated: number offer?: { confirmedAt: string; axes: number[] } showOffer: boolean // offer && link up && an axis present offerAxis?: number // Review & restore opens here calibrate: { shown: boolean; enabled: boolean; axis?: number } } dialog?: { // present while the dialog is shown workflow: WorkflowState // the workflow's own state, verbatim present: CalAxis[]; draft?: DraftAxis; stored?: CalAxis writing: boolean; invalid: { axis; reason } | null; showInvalid: boolean shown?: CaptureResult; travel: TravelQuality | null; selfCentring: boolean prompt: { step; holding; side: 'upper' | 'lower' | null; awaitingMovement } | null dirty: number[]; strip: boolean; stepIndex: 0 | 1; sweepsRemaining?: number holdAtStops: boolean; hold?: { remainingMs; progress }; sweeps: SweepCount convergence: { min; max; short }[]; restore: 'panel' | 'reoffer' | null; notice: boolean capture: { action: 'redo' | 'start'; disabled; recapture } disabled: { close; axisSwitch; rest; write }; remove: { shown; disabled } } } ``` `CALIBRATION_IDLE` (contract) is the launch state the renderer starts from. A main test pins the host's first state equal to it. ## Intents ↔ old methods | Intent | Was | |---|---| | `cal:open`(axis, review?) | `useCalibration.start` → `controller.open` + `setRestoreOpen` + shown | | `cal:close` | `useCalibration.close` → `controller.close` + hidden + `calRead` + `calCacheRead` | | `cal:select-axis`(axis) | `selectAxis` | | `cal:start-capture` | `startCapture` | | `cal:retry` | `retry` | | `cal:set-self-centring`(on) | `setSelfCentring` | | `cal:save` | `save` | | `cal:restore` | `restore` | | `cal:delete-axis`(idx) | `deleteAxis` | | `cal:set-restore-open`(open) | `setRestoreOpen` | | `cal:attach`(on) | lifecycle: `useCalibration`'s controller construction on mount, `dispose()` on unmount | The other public members are main-internal now: - `ingest` takes RAW batches from the service's 50 ms flush; - `linkChanged` and `deviceChanged` are driven by the host; - `subscribe` and `snapshot` feed the push; - `selfCentring`, `dirtyAxes` and `invalidAxis` are folded into the state; - `dispose` is called by `cal:attach(false)`. The renderer calls the intents through `useStore().calibrate`. ## API guard diff (`ipc.compat.test-d.ts`, updated on purpose) - **SkyhawkApi:** - removed `calHello`, `calRead`, `calSessionOpen`, `calStreamSelect`, `calCommit`, `calReset`, `calSessionClose`, `calCacheRead`, `calCacheStore`, `calCacheDrop`; - added `calOpen`, `calClose`, `calSelectAxis`, `calStartCapture`, `calRetry`, `calSetSelfCentring`, `calSave`, `calRestore`, `calDeleteAxis`, `calSetRestoreOpen`, `calAttach` (all `Promise`). - **CTRL:** the ten `cal:*` protocol channels are replaced by the ten workflow channels and `cal:attach`. - **PushChannels / IPC:** `cal:raw` is removed (main-internal) and `cal:workflow` → `CalibrationState` is added. `cal:data` is unchanged (still pushed; the renderer no longer listens). - **RelayStatus** (now frozen in the guard too): `cal?: CalSnapshot` → `cal?: CalibrationState`. - **IPC_ORDER:** the eleven new channels are appended after `replay:open`. Every pre-existing handler keeps its relative order. ## Mock `skyhawk-mock.ts` hosts a real `CalibrationWorkflowHost`, on the test's clock and timers. - Its commands are the scripted-`CalDevice` `cal*` spies. They are unchanged, and are now spies on the mock rather than on `SkyhawkApi`. - The intents answer through the host, including `calAttach`, and `getStatus` runs main's `relay:status` reload rule. - `emitScenario` hands the link state to the host after `device:status` and the report after `cal:data`, and RAW batches go to the host. `getStatus` returns the host's state. No other test file changed. ## Proofs - **Component tests:** T1–T35 are green, with no test-file diff. The only change under `src/renderer/test/` is `skyhawk-mock.ts`. - **Ported controller test:** - 57 cases, same as before. The case-name diff is the describe prefix only (`CalibrationController …` → `CalibrationWorkflow …`). - Same expected values. The diff is imports, the class/interface names and nothing else. - **workflow-host.test.ts:** besides the 7 ported store cases, 5 lifetime cases: - nothing to act on and RAW dropped before mount; - a remount starts fresh; - an unmount stops the timers; - a second mount without an unmount leaves the old view behind; - after a renderer load, the old view's pending re-read never reaches the device. - **store.test.ts calibration block:** the store keeps 5 tests, now on `calibration.tab.snapshot` with the same expectations. The 7 that pinned the drop-on-link-down rule moved with the rule to `workflow-host.test.ts`, with the same cases and expectations. - **Main trace** (r23c AppServices harness + 7 new dialog flows, 37 scenarios), base vs head: - **Non-calibration and protocol scenarios (30):** byte-identical once the `emit cal:workflow` lines (new) and `emit cal:raw` lines (now main-internal) are removed. - **Dialog flows**, driven at base by the old controller + useCalibration emulation over the old `cal:*` handlers, and at head through the intent handlers: - the flows: capture → save → verify, restore, delete axis, port close mid-write + resume + Cancel inside the held confirmation, link drop mid-capture + resume, HID-tab unmount mid-capture and mid-write + remount, renderer reload mid-write + new page; - result: device traffic (serial writes, device replies, cache file writes, final files) is byte-identical, and so is every other line (timers included) apart from those pushes. - **Render compare:** identical `innerHTML` base vs head in all 56 states: - **HID tab:** link down/up, offer. - **Restore:** panel, re-offer, writing, verifying, done, notice. - **Switch and delete:** axis switch, delete armed, erased. - **Capture:** prompts incl. holding, awaiting confirmation and centre; rejected + redo; draft; rest toggle. - **Link and write:** paused/resumed; the write phases; invalid draft. - **Refusals:** commit, session and stream. - **Lifetime:** a tab switch mid-dialog and back; a reload mid-dialog. - **Boot probe (R2.3c):** the only diffs are channels, and `debug.log` differs by timestamps only. - Removed (10): `cal:hello`, `cal:read`, `cal:session-open`, `cal:stream-select`, `cal:commit`, `cal:reset`, `cal:cache-read`, `cal:cache-store`, `cal:cache-drop`, `cal:session-close`. - Added at the end (11): `cal:open`, `cal:close`, `cal:select-axis`, `cal:start-capture`, `cal:retry`, `cal:set-self-centring`, `cal:save`, `cal:restore`, `cal:delete-axis`, `cal:set-restore-open`, `cal:attach`. - Every other channel keeps its relative order; the count goes from 40 to 41. ## Checks - `npm run typecheck` (3 projects): clean. - `npm run lint`: 0 errors. The warnings fall from 32 to 31 (P5: −2 CalibrationDialog, +1 the store's one calibration accessor). - `npm run lint:deps`: 0 errors, 9 warnings (down from 11). P4-legacy-rules goes from 10 to 8 (CalibrationDialog and calibration-controller are gone); T2-renderer stays at 1. - `npx vitest run`: 89 files, 1064 tests. - `npm run build`: OK. ## Left for R3.4 - `cal:data` is still pushed and still in the contract. The renderer no longer listens: the report reaches it inside `cal:workflow`. Remove it with the dead-code pass. Refs #100 --- src/main/app/app-services.ts | 7 +- src/main/app/ipc.ts | 33 +- .../hid/calibration/axis-capture.test.ts} | 4 +- .../hid/calibration/axis-capture.ts} | 35 +- .../hid/calibration/calibration-view-model.ts | 187 +++++++++ .../calibration/calibration-workflow.test.ts} | 162 ++++---- .../hid/calibration/calibration-workflow.ts} | 192 +++------ .../calibration/hid-calibration-service.ts | 40 +- src/main/hid/calibration/ipc.ts | 39 +- .../hid/calibration/workflow-host.test.ts | 160 ++++++++ src/main/hid/calibration/workflow-host.ts | 365 ++++++++++++++++++ src/preload/index.ts | 42 +- src/renderer/src/CalibrationDialog.tsx | 306 +++------------ src/renderer/src/calibration-view.ts | 66 ++++ src/renderer/src/store.test.ts | 56 +-- src/renderer/src/store.ts | 70 +++- src/renderer/src/tabs/Hid.tsx | 63 +-- src/renderer/test/skyhawk-mock.ts | 99 +++-- src/shared/app/ipc.compat.test-d.ts | 73 ++-- src/shared/app/ipc.ts | 74 ++-- src/shared/hid/calibration/ipc.ts | 293 +++++++++++++- 21 files changed, 1587 insertions(+), 779 deletions(-) rename src/{shared/hid/calibration/legacy-rules.test.ts => main/hid/calibration/axis-capture.test.ts} (99%) rename src/{shared/hid/calibration/legacy-rules.ts => main/hid/calibration/axis-capture.ts} (92%) create mode 100644 src/main/hid/calibration/calibration-view-model.ts rename src/{renderer/src/calibration-controller.test.ts => main/hid/calibration/calibration-workflow.test.ts} (90%) rename src/{renderer/src/calibration-controller.ts => main/hid/calibration/calibration-workflow.ts} (78%) create mode 100644 src/main/hid/calibration/workflow-host.test.ts create mode 100644 src/main/hid/calibration/workflow-host.ts create mode 100644 src/renderer/src/calibration-view.ts diff --git a/src/main/app/app-services.ts b/src/main/app/app-services.ts index 83b7d1d..9596a2a 100644 --- a/src/main/app/app-services.ts +++ b/src/main/app/app-services.ts @@ -89,7 +89,7 @@ export class AppServices { }) // The wiring order is load-bearing (R2.2b): it is the order DeviceLink ran these in before the // split. At each of the connection's moments its riders run in the order added: - // start's timers link tick (200, its own) → serial flush (50) → cal:raw (50) → + // start's timers link tick (200, its own) → serial flush (50) → calibration RAW (50) → // HID rate (1000) → roster poll (5000); then the HID reader // port opens `opening`: node resets, then the line assembler → connected status → // `connected`: roster request → `opened`: calibration read, HID reopen @@ -144,6 +144,9 @@ export class AppServices { if (s.state !== 'error') this.journal.forget('SERIAL') this.journal.linkState(s.state, (last) => deviceEvent(last, s, Date.now())) }) + // The calibration workflow follows the link (pauses on a drop, drops the device's report), + // after the link's own reactions and after `device:status` has gone out (R3.1). + this.connection.onStatus((s) => this.calibration.linkStatus(s.state)) // A settings change, in one listener so its order is explicit: auto-connect, then the relay. this.settings.store.subscribe((config, prev) => { if (prev.autoConnect !== config.autoConnect) { @@ -170,6 +173,6 @@ export class AppServices { status(): RelayStatus { const device: DeviceStatus = this.connection.deviceStatus() const { running, relay } = this.relay.status() - return { running, relay, device, cal: this.calibration.calSnapshot() } + return { running, relay, device, cal: this.calibration.workflow.state() } } } diff --git a/src/main/app/ipc.ts b/src/main/app/ipc.ts index 0641136..2245c7c 100644 --- a/src/main/app/ipc.ts +++ b/src/main/app/ipc.ts @@ -2,7 +2,7 @@ // app answers the three that span topics: the relay:status rehydrate bundle and the debug tools. // // The registration order is today's, kept on purpose (R2.3c): it interleaves topics (config, dcs, -// app, gateway, hid, nodes, gateway, calibration, dcs, events, app, dcs), so the topics register +// app, gateway, hid, nodes, gateway, dcs, events, app, dcs, calibration), so the topics register // into a collector and IPC_ORDER hands the handlers to ipcMain in that order. import { shell } from 'electron' import { APP_CTRL, type CtrlChannel, type IpcListener, type IpcRegistrar } from '@shared/app' @@ -41,16 +41,6 @@ const IPC_ORDER = [ 'nodes:registry', 'node-mapping:hide', 'serial:monitor', - 'cal:hello', - 'cal:read', - 'cal:session-open', - 'cal:stream-select', - 'cal:commit', - 'cal:reset', - 'cal:cache-read', - 'cal:cache-store', - 'cal:cache-drop', - 'cal:session-close', 'log:export', 'events:get', 'events:clear', @@ -58,7 +48,19 @@ const IPC_ORDER = [ 'debug:dump-ports', 'debug:reveal', 'capture:toggle', - 'replay:open' + 'replay:open', + // The calibration workflow's intents (R3.1), after every channel that was here before them. + 'cal:open', + 'cal:close', + 'cal:select-axis', + 'cal:start-capture', + 'cal:retry', + 'cal:set-self-centring', + 'cal:save', + 'cal:restore', + 'cal:delete-axis', + 'cal:set-restore-open', + 'cal:attach' ] as const satisfies readonly CtrlChannel[] /** IPC_ORDER's type when it lists every channel; otherwise the channels it leaves out. */ @@ -98,7 +100,12 @@ export function registerIpc(ipcMain: IpcRegistrar, deps: IpcDeps): void { registerCalibrationIpc(ipc, { calibration: services.calibration }) registerEventsIpc(ipc, { journal: services.journal, dialogs }) - ipc.handle(APP_CTRL.relayStatus, () => services.status()) + ipc.handle(APP_CTRL.relayStatus, () => { + // A renderer's first pull on (re)load: a calibration view from before it went with the old + // page, as its controller did (R3.1). + services.calibration.workflow.rendererLoaded() + return services.status() + }) ipc.handle(APP_CTRL.debugDumpPorts, async () => { const ports = await listSerialPorts() debugLog('serial.list', ports, true) // force-write regardless of the toggle diff --git a/src/shared/hid/calibration/legacy-rules.test.ts b/src/main/hid/calibration/axis-capture.test.ts similarity index 99% rename from src/shared/hid/calibration/legacy-rules.test.ts rename to src/main/hid/calibration/axis-capture.test.ts index a0869a8..e9f097b 100644 --- a/src/shared/hid/calibration/legacy-rules.test.ts +++ b/src/main/hid/calibration/axis-capture.test.ts @@ -12,8 +12,8 @@ import { retrySweep, sweepsRemaining, tick -} from './legacy-rules' -import type { CaptureState } from './ipc' +} from './axis-capture' +import type { CaptureState } from '@shared/hid/calibration' const C = DEFAULT_CAPTURE_CONFIG diff --git a/src/shared/hid/calibration/legacy-rules.ts b/src/main/hid/calibration/axis-capture.ts similarity index 92% rename from src/shared/hid/calibration/legacy-rules.ts rename to src/main/hid/calibration/axis-capture.ts index c755a04..b73ce00 100644 --- a/src/shared/hid/calibration/legacy-rules.ts +++ b/src/main/hid/calibration/axis-capture.ts @@ -1,6 +1,4 @@ -// Renderer still calls these; they move to main in R3.x and this file disappears. -// -// Main imports them from here too, so there is one copy. (Was shared/axis-capture.ts.) +// (Was shared/hid/calibration/legacy-rules.ts until the calibration workflow moved to main, R3.1.) // // Axis travel capture (pure, no Node/Electron deps, fully testable). // @@ -24,9 +22,13 @@ // The SEQ counter cannot substitute for that. A gap is only visible on the next arriving frame, // and in the case that matters no next frame arrives. -import type { CaptureConfig, CapturedSweep, CaptureResult, CaptureState } from './ipc' +import type { + CaptureConfig, + CapturedSweep, + CaptureResult, + CaptureState +} from '@shared/hid/calibration' -/** @deprecated Renderer still calls this; it moves to main in R3.1. */ export const DEFAULT_CAPTURE_CONFIG: CaptureConfig = { bandCounts: 300, endpointHoldMs: 1500, @@ -40,8 +42,6 @@ export const DEFAULT_CAPTURE_CONFIG: CaptureConfig = { /** * Unsigned 0–65535, as the wire and device storage use — and as the dialog displays. The client * applies no offset anywhere, so there is nothing to convert on the way in or out. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export interface CaptureSample { /** Stamped on arrival in main, not at render time — batching would otherwise skew dwell. */ @@ -49,7 +49,6 @@ export interface CaptureSample { raw: number } -/** @deprecated Renderer still calls this; it moves to main in R3.1. */ export function beginCapture( now: number, /** Defaults to true for every axis; see CaptureState.selfCentring. */ @@ -84,8 +83,6 @@ function holdTarget(s: CaptureState): number { /** * Progress of the current hold, 0..1 — for a progress indicator, not a decision. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function holdProgress(s: CaptureState): number { if (s.phase !== 'capturing' || s.ref === null) return 0 @@ -98,8 +95,6 @@ export function holdProgress(s: CaptureState): number { * Floors at zero rather than going negative: once the time is up the hold is waiting on * corroboration or on the movement gate, and a countdown running below zero would suggest the * clock is what is holding things up. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function holdRemainingMs(s: CaptureState): number { if (s.phase !== 'capturing' || s.ref === null) return holdTarget(s) @@ -113,8 +108,6 @@ export function holdRemainingMs(s: CaptureState): number { * it because `awaitingMovement` cannot answer that question: that flag is only raised once a * full hold has elapsed with too little travel, so for the first second of every step it reads * false whether or not the user has touched the axis. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function hasTravelled(s: CaptureState): boolean { return s.movedBy >= s.config.minTravelCounts @@ -161,8 +154,6 @@ function judgeSweep(s: CaptureState, sweep: CapturedSweep): string | null { * Centre is the mean of the release samples. A settled axis emits almost nothing, so no single * hold can beat the ±160-count uncertainty floor; averaging independent releases is the only * thing that improves on it. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function reconcile(s: CaptureState): CaptureResult { const min = Math.min(...s.accepted.map((a) => a.min)) @@ -185,8 +176,6 @@ export function reconcile(s: CaptureState): CaptureResult { /** * The default centre: half way between the endpoints. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function midpoint(min: number, max: number): number { return Math.round((min + max) / 2) @@ -271,8 +260,6 @@ function commitStep(s: CaptureState, value: number): CaptureState { * * Essential rather than a convenience: a held axis emits nothing, so if dwell were only * evaluated on arrival, a perfectly steady hold would never complete. The UI ticks this. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function tick(s: CaptureState, now: number): CaptureState { if (s.phase !== 'capturing') return { ...s, now } @@ -330,8 +317,6 @@ function restCluster(s: CaptureState): number { /** * Feed one RAW sample. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function push(s: CaptureState, sample: CaptureSample): CaptureState { if (s.phase !== 'capturing') return { ...s, now: sample.t } @@ -375,8 +360,6 @@ export function push(s: CaptureState, sample: CaptureSample): CaptureState { /** * Acknowledge a rejected sweep and redo it. Accepted sweeps are kept. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function retrySweep(s: CaptureState, now: number): CaptureState { if (s.phase !== 'rejected') return s @@ -390,8 +373,6 @@ export function retrySweep(s: CaptureState, now: number): CaptureState { * For a sweep that was interrupted rather than rejected — the serial link closing mid-capture. * Its samples stop at the close and the gap is not visible in the data, so the half-finished * sweep cannot be trusted to continue: a hold that spans the outage would be timed across it. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function restartSweep(s: CaptureState, now: number): CaptureState { return { @@ -416,8 +397,6 @@ export function restartSweep(s: CaptureState, now: number): CaptureState { /** * Sweeps still needed. Rejected attempts do not count toward it. - * - * @deprecated Renderer still calls this; it moves to main in R3.1. */ export function sweepsRemaining(s: CaptureState): number { return Math.max(0, s.config.sweepsRequired - s.accepted.length) diff --git a/src/main/hid/calibration/calibration-view-model.ts b/src/main/hid/calibration/calibration-view-model.ts new file mode 100644 index 0000000..31edd0f --- /dev/null +++ b/src/main/hid/calibration/calibration-view-model.ts @@ -0,0 +1,187 @@ +// What the HID tab and the calibration dialog show, decided from the workflow's state (R3.1). Each +// derivation is the one the renderer made before the workflow moved to main (CalibrationDialog.tsx, +// tabs/Hid.tsx), kept verbatim; the renderer now only words and draws the result. +import { HID_REPORT_LAYOUT } from '@shared/firmware' +import type { + CalBadge, + CalibrationDialogState, + CalibrationTabState, + CalSnapshot, + CapturePrompt, + TravelQuality, + WorkflowState +} from '@shared/hid/calibration' +import { + DEFAULT_CAPTURE_CONFIG, + hasTravelled, + holdProgress, + holdRemainingMs, + sweepsRemaining +} from './axis-capture' + +/** 65535: the full unsigned span, from the HID report's axis range. */ +const AXIS_SPAN = HID_REPORT_LAYOUT.axes.max - HID_REPORT_LAYOUT.axes.min + +/** Descriptive, never an error: 40% is the expected healthy result for a short-arc axis. */ +const TRAVEL_BANDS = [ + { at: 0.6, label: 'Good', tone: 'ok' }, + { at: 0.3, label: 'Workable', tone: '' }, + { at: 0, label: 'Low', tone: 'warn' } +] as const + +export function travelQuality(min?: number, max?: number): TravelQuality | null { + if (min === undefined || max === undefined) return null + const counts = max - min + const band = TRAVEL_BANDS.find((b) => counts / AXIS_SPAN >= b.at)! + return { counts, pct: Math.round((counts / AXIS_SPAN) * 100), ...band } +} + +/** + * The live prompt during a capture: what it asks for, not how it is worded. + * + * Two states per step, because they ask for opposite things and reading the wrong one wastes a + * sweep: **travelling** (gold — keep moving, the bar is not counting yet) and **holding** + * (green — stop moving, the bar is the dwell timer). The split is `hasTravelled`, not + * `awaitingMovement`: that flag only rises after a full hold has already elapsed, so it reads + * false for the first second of every step whether or not the user has touched the axis. + * + * Direction words are derived, never assumed. `startRaw` is where the axis sat when capture + * opened, so "upper" means the raw count went up from there — which stays correct on an + * inverted axis and on a short-arc axis that never crosses the midpoint. Before the axis has + * moved at all in the first step there is no evidence either way, so the copy stays neutral. + */ +export function prompt(s: WorkflowState, startRaw: number | null): CapturePrompt | null { + const cap = s.capture + if (!cap || cap.phase !== 'capturing') return null + const holding = hasTravelled(cap) + const dir = (v: number) => (startRaw === null || v >= startRaw ? 'upper' : 'lower') + + if (cap.step === 'centre') { + return { step: 'centre', holding, side: null, awaitingMovement: cap.awaitingMovement } + } + + // stopA has no direction until the axis moves; stopB is whichever way stopA was not. + const side = + cap.step === 'stopA' + ? s.live && cap.movedBy > 0 + ? dir(s.live.raw) + : null + : cap.current.stopA !== undefined + ? dir(cap.current.stopA) === 'upper' + ? 'lower' + : 'upper' + : null + + return { step: cap.step, holding, side, awaitingMovement: cap.awaitingMovement } +} + +/** What the workflow answers for the axis on screen (its own methods). */ +export interface WorkflowAnswers { + invalid: { axis: number; reason: string } | null + selfCentring: boolean + dirty: number[] + /** Where the axis sat when capture opened — the only honest anchor for "upper"/"lower". */ + startRaw: number | null +} + +/** The dialog's gates and derived values (CalibrationDialog.tsx before R3.1). */ +export function dialogState(s: WorkflowState, a: WorkflowAnswers): CalibrationDialogState { + const present = s.device?.axes.filter((x) => x.present) ?? [] + const draft = s.drafts[s.axis] + const stored = s.device?.axes[s.axis] + const cap = s.capture + // 'done' is the held confirmation, not work in progress. Treating it as writing left the + // footer reading "Writing…" beside a strip saying the device had already confirmed the write, + // and kept Cancel and Delete disabled for three seconds after there was anything to wait for. + const writing = !!s.write && s.write.phase !== 'done' + const invalid = a.invalid + const shown = draft ?? (stored?.calibrated ? stored : undefined) + const travel = travelQuality(shown?.min, shown?.max) + // A paused capture is frozen, so "push it to its stop" would ask for movement nothing records. + const p = s.paused ? null : prompt(s, a.startRaw) + const dirty = a.dirty + // A rejected sweep is still step 1: nothing was captured, and Redo continues the same capture. + const inTravel = cap?.phase === 'capturing' || cap?.phase === 'rejected' + const recapture = !!(draft || stored?.calibrated) + + return { + workflow: s, + present, + draft, + stored, + writing, + invalid, + showInvalid: !!invalid && !writing, + shown, + travel, + selfCentring: a.selfCentring, + prompt: p, + dirty, + strip: !!(cap || draft), + stepIndex: inTravel ? 0 : 1, + sweepsRemaining: inTravel && cap ? sweepsRemaining(cap) : undefined, + holdAtStops: cap?.phase === 'capturing' && !cap.awaitingMovement, + hold: p && cap ? { remainingMs: holdRemainingMs(cap), progress: holdProgress(cap) } : undefined, + sweeps: + cap?.phase === 'capturing' + ? { kind: 'capturing', sweep: cap.accepted.length + 1, of: cap.config.sweepsRequired } + : draft + ? { kind: 'captured', of: DEFAULT_CAPTURE_CONFIG.sweepsRequired } + : { kind: 'per-axis', of: DEFAULT_CAPTURE_CONFIG.sweepsRequired }, + convergence: + cap && cap.accepted.length > 0 + ? cap.accepted.map((w) => { + // Accepted sweeps are within slack by construction, so this flags the ones that + // still contribute nothing under widest-wins rather than reporting a fault. + const widest = Math.max(...cap.accepted.map((x) => x.max - x.min)) + const short = widest - (w.max - w.min) > widest * cap.config.shortfallFraction + return { min: w.min, max: w.max, short } + }) + : [], + restore: s.restorable && !s.write && !s.paused ? (s.restoreOpen ? 'panel' : 'reoffer') : null, + notice: !!s.notice && !s.write, + capture: + cap?.phase === 'rejected' + ? { action: 'redo', disabled: s.paused, recapture } + : { + action: 'start', + disabled: writing || cap?.phase === 'capturing' || s.paused, + recapture + }, + disabled: { + close: writing, + axisSwitch: writing || s.switchingTo !== undefined, + rest: writing || cap?.phase === 'capturing', + write: writing || !dirty.length || !!invalid || s.paused + }, + remove: { shown: !!stored?.calibrated && !writing, disabled: s.paused } + } +} + +/** The HID tab's calibration parts (tabs/Hid.tsx before R3.1). */ +export function tabState( + snapshot: CalSnapshot | undefined, + linkUp: boolean, + offer: { confirmedAt: string; axes: number[] } | undefined +): CalibrationTabState { + const present = snapshot ? snapshot.axes.filter((a) => a.present) : [] + const uncalibrated = present.filter((a) => !a.calibrated).length + // Calibration is a property of the attached device, so badges follow presentMask alone. + const badges: Partial> = {} + snapshot?.axes.forEach((a, i) => { + if (a?.present) badges[i] = a.calibrated ? 'cal' : 'raw' + }) + return { + snapshot, + badges, + uncalibrated, + offer, + showOffer: !!offer && linkUp && present.length > 0, + offerAxis: offer?.axes[0], + calibrate: { + shown: present.length > 0 || !linkUp, + enabled: linkUp, + axis: present.find((a) => !a.calibrated)?.idx ?? present[0]?.idx + } + } +} diff --git a/src/renderer/src/calibration-controller.test.ts b/src/main/hid/calibration/calibration-workflow.test.ts similarity index 90% rename from src/renderer/src/calibration-controller.test.ts rename to src/main/hid/calibration/calibration-workflow.test.ts index d0889fa..815c6fe 100644 --- a/src/renderer/src/calibration-controller.test.ts +++ b/src/main/hid/calibration/calibration-workflow.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DEFAULT_CAPTURE_CONFIG, midpoint } from '@shared/hid/calibration/legacy-rules' +import { DEFAULT_CAPTURE_CONFIG, midpoint } from './axis-capture' import type { CachedBoard, CalCacheEntry, @@ -7,7 +7,7 @@ import type { CalResult, CalSnapshot } from '@shared/hid/calibration' -import { CalibrationController, type CalibrationApi } from './calibration-controller' +import { CalibrationWorkflow, type CalibrationCommands } from './calibration-workflow' const C = DEFAULT_CAPTURE_CONFIG @@ -38,7 +38,7 @@ function fakeDevice() { const fail: { commit?: CalResult; read?: CalResult; reset?: CalResult } = {} - const api: CalibrationApi = { + const api: CalibrationCommands = { calRead: async () => { calls.push('read') return fail.read ?? ok(snapshot(stored)) @@ -124,7 +124,7 @@ function samples(idx: number, values: number[], step = 200): CalRawSample[] { * unless the step registered real movement, which is what stops a resting axis being captured * as a stop. */ -async function sweep(c: CalibrationController, lo: number, hi: number, centre?: number) { +async function sweep(c: CalibrationWorkflow, lo: number, hi: number, centre?: number) { const moveTo = (from: number, to: number, ms = C.endpointHoldMs + 200) => { c.ingest(samples(0, [from, to, to + 150])) clock += ms @@ -136,7 +136,7 @@ async function sweep(c: CalibrationController, lo: number, hi: number, centre?: await Promise.resolve() } -describe('CalibrationController', () => { +describe('CalibrationWorkflow', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -153,7 +153,7 @@ describe('CalibrationController', () => { detail: 9, message: 'refused' }) - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(9) expect(c.snapshot().failure?.kind).toBe('nack') expect(c.snapshot().busy).toBe(false) @@ -162,7 +162,7 @@ describe('CalibrationController', () => { it('ignores samples for an axis it is not showing', () => { // A frame can be in flight across a STREAM_SELECT; RAW carries idx so it can be discarded. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) c.startCapture() c.ingest(samples(3, [50000, 50100])) expect(c.snapshot().live, 'another axis must not drive the readout').toBeUndefined() @@ -171,7 +171,7 @@ describe('CalibrationController', () => { it('captures a full axis and banks it as a draft, writing nothing', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) c.startCapture() await sweep(c, 13000, 51000, 32000) @@ -194,7 +194,7 @@ describe('CalibrationController', () => { // capture that had just finished. Here the clock advances without the interval firing, so // every commit lands on an arriving sample. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) c.startCapture() await sweep(c, 13000, 51000, 32000) @@ -218,7 +218,7 @@ describe('CalibrationController', () => { // streaming: every sample dropped on idx, so the readout and capture look dead. Worse, a // retry of the same axis would early-return as a no-op, with no way out. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) d.api.calStreamSelect = async () => ({ ok: false, @@ -249,7 +249,7 @@ describe('CalibrationController', () => { // The ACK proves the device accepted the selection; a sample proves data is flowing. Kept // separate because the node emits only on change, so a steady axis may send nothing at all. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) await c.selectAxis(1) expect(c.snapshot().streaming, 'accepted, but nothing has arrived yet').toBe(false) @@ -268,7 +268,7 @@ describe('CalibrationController', () => { // midpoint that looks exactly as plausible as the rest position three sweeps had measured — // silently, and with no way to tell the difference. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) c.startCapture() // An off-centre rest position on purpose: a rig that rests exactly at the midpoint would @@ -288,7 +288,7 @@ describe('CalibrationController', () => { it('keeps drafts when switching axes', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) c.startCapture() for (let i = 0; i < 3; i++) await sweep(c, 13000, 51000, 32000) @@ -300,7 +300,7 @@ describe('CalibrationController', () => { }) }) -describe('CalibrationController writing', () => { +describe('CalibrationWorkflow writing', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -308,7 +308,7 @@ describe('CalibrationController writing', () => { afterEach(() => vi.useRealTimers()) /** Seed drafts without driving a capture, so write tests stay about writing. */ - function withDrafts(c: CalibrationController, drafts: Record) { + function withDrafts(c: CalibrationWorkflow, drafts: Record) { for (const [idx, [min, centre, max]] of Object.entries(drafts)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(c as any).state.drafts[Number(idx)] = { min, centre, max, selfCentring: true } @@ -317,7 +317,7 @@ describe('CalibrationController writing', () => { it('commits one axis at a time and verifies each by reading back', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) @@ -343,7 +343,7 @@ describe('CalibrationController writing', () => { it('caches what the device confirmed, including the rest position it cannot hold', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) c.setSelfCentring(false) @@ -358,7 +358,7 @@ describe('CalibrationController writing', () => { it('remembers the rest position across a dialog close, since the device cannot hold it', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) c.setSelfCentring(false) @@ -367,7 +367,7 @@ describe('CalibrationController writing', () => { await c.close() // A fresh controller, as if the app had been restarted. - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) expect(c2.selfCentring(0), 'not silently back to the self-centring default').toBe(false) }) @@ -377,7 +377,7 @@ describe('CalibrationController writing', () => { // the client asked for. A commit that ACKs and then reads back differently is a failure, and // caching it would preserve client optimism as though it were device state. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) d.fail.read = ok(snapshot({ 0: { min: 1, centre: 2, max: 3 } })) @@ -390,7 +390,7 @@ describe('CalibrationController writing', () => { it('forgets an axis the user deletes, so it is not offered back', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -403,7 +403,7 @@ describe('CalibrationController writing', () => { it('offers a restore only for axes the device has lost', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -413,7 +413,7 @@ describe('CalibrationController writing', () => { // Axis 0 vanishes from the device without being deleted — a reflash, or a failed CRC. Axis 1 // is untouched, and must not be offered: the device's own copy is the authority. d.loseFromDevice(0) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) expect(c2.snapshot().restorable?.axes).toEqual([0]) expect(c2.snapshot().restoreOpen, 'reviewing is a separate, deliberate step').toBe(false) @@ -421,7 +421,7 @@ describe('CalibrationController writing', () => { it('restores by replaying a commit, and verifies it like any other write', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) c.setSelfCentring(false) @@ -430,7 +430,7 @@ describe('CalibrationController writing', () => { await c.close() d.loseFromDevice(0) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) d.calls.length = 0 await c2.restore() @@ -453,7 +453,7 @@ describe('CalibrationController writing', () => { // sketch's HIDAxis registrations rather than the blob, so the axes stay present and go // uncalibrated together — and the offer has to cover all of them, in one write queue. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -462,7 +462,7 @@ describe('CalibrationController writing', () => { d.loseFromDevice(0) d.loseFromDevice(1) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) expect(c2.snapshot().restorable?.axes).toEqual([0, 1]) @@ -484,7 +484,7 @@ describe('CalibrationController writing', () => { // messages — which name the axis being written — are gone before they can be read, and a // two-axis write looks like one flash. Every other test passes 0 for this. const d = fakeDevice() - const c = new CalibrationController(d.api, () => clock, 700) + const c = new CalibrationWorkflow(d.api, () => clock, 700) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) @@ -515,7 +515,7 @@ describe('CalibrationController writing', () => { // Found on the rig: the offer survived a successful restore, so the client went on inviting // the user to write older numbers over the ones it had just confirmed. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -524,7 +524,7 @@ describe('CalibrationController writing', () => { d.loseFromDevice(0) d.loseFromDevice(1) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) expect(c2.snapshot().restorable?.axes).toEqual([0, 1]) @@ -537,7 +537,7 @@ describe('CalibrationController writing', () => { // The other way out of the regressed set. The device holds it either way, so an offer to // write the older copy over it would be actively harmful rather than merely stale. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -546,7 +546,7 @@ describe('CalibrationController writing', () => { d.loseFromDevice(0) d.loseFromDevice(1) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) withDrafts(c2, { 0: [11000, 30000, 49000] }) await c2.save() @@ -558,7 +558,7 @@ describe('CalibrationController writing', () => { // Multi-axis restore is the common shape after a corruption, so a failure part-way through // is a real case rather than a contrived one. It must not read as "nothing was restored". const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -567,7 +567,7 @@ describe('CalibrationController writing', () => { d.loseFromDevice(0) d.loseFromDevice(1) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) let commits = 0 const realCommit = d.api.calCommit @@ -586,7 +586,7 @@ describe('CalibrationController writing', () => { // "Not now" has to work, and the offer has to survive it: the device is still missing // something, so dismissing is a deferral rather than an answer. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -594,7 +594,7 @@ describe('CalibrationController writing', () => { await c.close() d.loseFromDevice(0) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) c2.setRestoreOpen(true) expect(c2.snapshot().restoreOpen).toBe(true) @@ -612,7 +612,7 @@ describe('CalibrationController writing', () => { // it lost selfCentring on the way through, a second loss would restore numbers whose meaning // had quietly changed. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) c.setSelfCentring(false) @@ -621,7 +621,7 @@ describe('CalibrationController writing', () => { await c.close() d.loseFromDevice(0) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) await c2.restore() await vi.advanceTimersByTimeAsync(3000) @@ -630,7 +630,7 @@ describe('CalibrationController writing', () => { it('leaves an axis the device still holds alone', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -638,7 +638,7 @@ describe('CalibrationController writing', () => { await c.close() d.loseFromDevice(0) - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) d.calls.length = 0 await c2.restore() @@ -648,14 +648,14 @@ describe('CalibrationController writing', () => { it('does not offer a restore when the device still holds everything', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() await vi.advanceTimersByTimeAsync(3000) await c.close() - const c2 = new CalibrationController(d.api, now, 0) + const c2 = new CalibrationWorkflow(d.api, now, 0) await c2.open(0) expect(c2.snapshot().restorable).toBeUndefined() }) @@ -664,7 +664,7 @@ describe('CalibrationController writing', () => { // The dialog shows nothing after an erase — same numbers, one badge changed on another // screen. Without a word the user cannot tell a successful delete from a no-op. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -677,7 +677,7 @@ describe('CalibrationController writing', () => { it('drives state from the read-back, not from what was sent', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -694,7 +694,7 @@ describe('CalibrationController writing', () => { // green badge on an axis the device knows nothing about. const d = fakeDevice() d.api.calCommit = async () => ok(null) // acknowledges, stores nothing - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -712,7 +712,7 @@ describe('CalibrationController writing', () => { detail: 1, message: 'endpoints out of order' } - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 1: [50000, 100, 200] }) await c.save() @@ -730,7 +730,7 @@ describe('CalibrationController writing', () => { d.stored[a.idx] = { min: a.min, centre: a.centre, max: a.max } // it DID land return { ok: false, kind: 'timeout', message: 'no reply within 2000 ms' } } - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -754,7 +754,7 @@ describe('CalibrationController writing', () => { const d = fakeDevice() d.stored[0] = { min: 20000, centre: 32000, max: 45000 } // an older calibration d.api.calCommit = async () => ({ ok: false, kind: 'timeout', message: 'no reply' }) - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) // wider, and different @@ -770,7 +770,7 @@ describe('CalibrationController writing', () => { it('keeps the draft when a timed-out commit did NOT land', async () => { const d = fakeDevice() d.api.calCommit = async () => ({ ok: false, kind: 'timeout', message: 'no reply' }) - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000] }) await c.save() @@ -783,7 +783,7 @@ describe('CalibrationController writing', () => { const real = d.api.calCommit d.api.calCommit = async (a) => a.idx === 1 ? { ok: false, kind: 'timeout', message: 'no reply' } : real(a) - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 32000, 51000], 1: [14000, 33000, 52000] }) await c.save() @@ -794,7 +794,7 @@ describe('CalibrationController writing', () => { it('blocks a write that the device would refuse, naming which axis', () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) withDrafts(c, { 0: [13000, 32000, 51000], 2: [40000, 100, 200] }) expect(c.invalidAxis()).toEqual({ axis: 2, reason: 'centre must fall between min and max' }) }) @@ -804,7 +804,7 @@ describe('CalibrationController writing', () => { // first. Tying it to a draft meant the first capture on every axis ran the centre step // regardless, and the setting only became available once it was too late to matter. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) expect(c.selfCentring(), 'defaults to capturing a rest position').toBe(true) @@ -817,7 +817,7 @@ describe('CalibrationController writing', () => { it('a non-centring capture never asks for a release, and derives the midpoint', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) c.setSelfCentring(false) c.startCapture() @@ -832,7 +832,7 @@ describe('CalibrationController writing', () => { it('remembers the choice per axis', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) c.setSelfCentring(false) await c.selectAxis(1) @@ -842,7 +842,7 @@ describe('CalibrationController writing', () => { it('re-derives centre when an axis is marked non-centring', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) withDrafts(c, { 0: [13000, 20000, 51000] }) c.setSelfCentring(false) @@ -850,7 +850,7 @@ describe('CalibrationController writing', () => { }) }) -describe('CalibrationController delete', () => { +describe('CalibrationWorkflow delete', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -860,7 +860,7 @@ describe('CalibrationController delete', () => { it('deletes and confirms by reading back', async () => { const d = fakeDevice() d.stored[0] = { min: 13000, centre: 32000, max: 51000 } - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) await c.deleteAxis(0) expect(c.snapshot().device?.axes[0]?.calibrated).toBe(false) @@ -871,7 +871,7 @@ describe('CalibrationController delete', () => { // same rule the write path follows. const d = fakeDevice() d.stored[0] = { min: 13000, centre: 32000, max: 51000 } - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) d.fail.read = { ok: false, kind: 'timeout', message: 'no reply' } await c.deleteAxis(0) @@ -882,7 +882,7 @@ describe('CalibrationController delete', () => { const d = fakeDevice() d.stored[0] = { min: 13000, centre: 32000, max: 51000 } d.api.calReset = async () => ok(null) // acknowledges, erases nothing - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) await c.deleteAxis(0) expect(c.snapshot().failure?.message).toMatch(/still reports this axis as calibrated/) @@ -902,7 +902,7 @@ describe('CalibrationController delete', () => { detail: 0xff, message: 'the flash write failed' } - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0) await c.deleteAxis(0) expect(c.snapshot().failure).toMatchObject({ reasonName: 'NO_STORAGE', axis: 0 }) @@ -910,12 +910,12 @@ describe('CalibrationController delete', () => { }) }) -describe('CalibrationController device identity', () => { +describe('CalibrationWorkflow device identity', () => { it('drops drafts when a different board appears', () => { // Endpoints are per-unit. Carrying one board's captures onto another would produce a // plausible-looking but wrong calibration, which fails open rather than closed. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(c as any).state.drafts[0] = { min: 1, centre: 2, max: 3, selfCentring: true } c.deviceChanged({ ...snapshot({}), serialNumber: 'BOARD-B' }) @@ -924,7 +924,7 @@ describe('CalibrationController device identity', () => { it('keeps drafts when the same board reports again', () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) c.deviceChanged(snapshot({})) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(c as any).state.drafts[0] = { min: 1, centre: 2, max: 3, selfCentring: true } @@ -933,7 +933,7 @@ describe('CalibrationController device identity', () => { }) }) -describe('CalibrationController serial link (#74)', () => { +describe('CalibrationWorkflow serial link (#74)', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -941,24 +941,24 @@ describe('CalibrationController serial link (#74)', () => { afterEach(() => vi.useRealTimers()) /** What the HID tab does when the link drops: the store clears the snapshot in the same go. */ - const closeLink = (c: CalibrationController) => { + const closeLink = (c: CalibrationWorkflow) => { c.linkChanged(false) c.deviceChanged(undefined) } /** Reconnect: link state first, the board's report once GET_CAL answers. */ - const reopenLink = async (c: CalibrationController, snap: CalSnapshot = snapshot({})) => { + const reopenLink = async (c: CalibrationWorkflow, snap: CalSnapshot = snapshot({})) => { c.linkChanged(true) c.deviceChanged(snap) await vi.advanceTimersByTimeAsync(0) } /** Take the axis to its first stop and hold it, without letting the hold finish. */ - const startHoldAt = (c: CalibrationController, from: number, to: number) => { + const startHoldAt = (c: CalibrationWorkflow, from: number, to: number) => { c.ingest(samples(0, [from, to, to + 150])) } it('pauses rather than cancels: drafts, accepted sweeps and the board are kept', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) c.startCapture() await sweep(c, 13000, 51000, 32000) @@ -978,7 +978,7 @@ describe('CalibrationController serial link (#74)', () => { // Silence is how a hold completes. A ticker left running would bank whatever the axis held // when the link dropped, timed across the outage. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) c.startCapture() startHoldAt(c, 32000, 51000) @@ -991,7 +991,7 @@ describe('CalibrationController serial link (#74)', () => { it('ignores samples flushed after the close', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) c.startCapture() closeLink(c) @@ -1002,7 +1002,7 @@ describe('CalibrationController serial link (#74)', () => { it('sends nothing to the device while paused', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) d.stored[0] = { min: 1000, centre: 30000, max: 60000 } await c.open(0, snapshot(d.stored)) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1022,7 +1022,7 @@ describe('CalibrationController serial link (#74)', () => { it('switches axes locally while paused, and resumes on the one on screen', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) closeLink(c) d.calls.length = 0 @@ -1039,7 +1039,7 @@ describe('CalibrationController serial link (#74)', () => { // The device lost its session with the port. Reopening on link-up alone would resume before // knowing whether the board that came back is the one the drafts were captured on. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) closeLink(c) d.calls.length = 0 @@ -1057,7 +1057,7 @@ describe('CalibrationController serial link (#74)', () => { it('restarts the interrupted sweep on resume, keeping the accepted ones', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) c.startCapture() await sweep(c, 13000, 51000, 32000) @@ -1078,7 +1078,7 @@ describe('CalibrationController serial link (#74)', () => { it('discards the drafts if a different board comes back', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(c as any).state.drafts[0] = { min: 1, centre: 2, max: 3, selfCentring: true } @@ -1092,7 +1092,7 @@ describe('CalibrationController serial link (#74)', () => { // Board A's cache holds axis 1, which A has lost, and axis 0 as non-centring. Restoring that // onto board B would pass the read-back and get cached under B's serial. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await d.api.calCacheStore([ { idx: 0, min: 1000, centre: 30000, max: 60000, selfCentring: false }, { idx: 1, min: 1111, centre: 22222, max: 55555, selfCentring: true } @@ -1125,7 +1125,7 @@ describe('CalibrationController serial link (#74)', () => { // The open fails without reaching the cache. Resuming without it would treat a throttle the // cache knows as non-centring as self-centring, and a write would cache that over it. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await d.api.calCacheStore([ { idx: 0, min: 1000, centre: 30000, max: 60000, selfCentring: false } ]) @@ -1154,7 +1154,7 @@ describe('CalibrationController serial link (#74)', () => { // Unlike a failed open, nothing about reconnecting answers it, and "stored before stopping" // is still true. const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(c as any).state.drafts[0] = { min: 1000, centre: 30000, max: 60000, selfCentring: true } @@ -1171,7 +1171,7 @@ describe('CalibrationController serial link (#74)', () => { it('stays paused and says why when the session will not reopen', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) closeLink(c) d.api.calSessionOpen = async () => ({ ok: false, kind: 'timeout', message: 'no reply' }) @@ -1182,7 +1182,7 @@ describe('CalibrationController serial link (#74)', () => { it('opens paused if the link closed first, and reads the cache once it resumes', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) c.linkChanged(false) await c.open(0, snapshot({})) expect(c.snapshot().open).toBe(true) @@ -1195,7 +1195,7 @@ describe('CalibrationController serial link (#74)', () => { it('does not stay paused after the dialog closes and the link returns', async () => { const d = fakeDevice() - const c = new CalibrationController(d.api, now, 0) + const c = new CalibrationWorkflow(d.api, now, 0) await c.open(0, snapshot({})) closeLink(c) await c.close() diff --git a/src/renderer/src/calibration-controller.ts b/src/main/hid/calibration/calibration-workflow.ts similarity index 78% rename from src/renderer/src/calibration-controller.ts rename to src/main/hid/calibration/calibration-workflow.ts index da7aa84..4aee37e 100644 --- a/src/renderer/src/calibration-controller.ts +++ b/src/main/hid/calibration/calibration-workflow.ts @@ -1,8 +1,9 @@ // Orchestration for the calibration dialog: session lifecycle, sample routing, and the write // sequence. Everything the dialog needs to *decide* lives here; the dialog renders and calls in. +// (Was the renderer's CalibrationController until R3.1; HidCalibrationService hosts it now.) // -// Depends on an injected API rather than window.skyhawk directly, so the whole flow is testable -// without Electron or a device. +// Depends on injected commands and timers rather than the service and the clock directly, so the +// whole flow is testable without Electron or a device. import { beginCapture, midpoint, @@ -10,7 +11,7 @@ import { restartSweep, retrySweep, tick -} from '@shared/hid/calibration/legacy-rules' +} from './axis-capture' import type { CachedBoard, CalCacheEntry, @@ -19,12 +20,15 @@ import type { CalRawSample, CalResult, CalSnapshot, - CaptureResult, - CaptureState + CaptureState, + WorkflowState } from '@shared/hid/calibration' -/** Only the calls this controller makes — narrower than SkyhawkApi, and trivial to fake. */ -export interface CalibrationApi { +/** + * The calibration exchanges the workflow sequences: HidCalibrationService's own cal* commands, + * called directly. Narrower than the service, and trivial to fake. + */ +export interface CalibrationCommands { calRead(): Promise> calSessionOpen(axisIdx: number): Promise> calStreamSelect(axisIdx: number): Promise> @@ -36,125 +40,23 @@ export interface CalibrationApi { calCacheDrop(idx: number): Promise> } -/** Endpoints a user has captured or edited but not yet written. */ -export interface DraftAxis extends CaptureResult { - /** Whether this axis returns to rest. Defaults true; the user can override per axis. */ - selfCentring: boolean - /** - * The rest position as measured, kept even while a midpoint is in use. - * - * `centre` is the value that will be written, so switching to "no rest position" replaces it. - * Without somewhere to keep the measurement, that switch is destructive and switching back - * does not undo it: the user gets a midpoint that looks exactly as plausible as the rest - * position they spent three sweeps capturing. Undefined when the capture never measured one. - */ - capturedCentre?: number -} - -export type WritePhase = - /** Sending COMMIT for one axis. */ - | 'writing' - /** - * Re-reading after the ACK. - * - * A separate phase because an acknowledgement only means "received". The read-back is what - * proves "stored", and the badges are driven from it rather than from what was sent. - */ - | 'verifying' - /** - * Every axis stored, held on screen so the user can see it. - * - * The work is finished here — this phase exists only because the whole save takes a few - * hundred milliseconds, and a result that vanishes as fast as it appeared is indistinguishable - * from nothing having happened. - */ - | 'done' - -export interface WriteProgress { - phase: WritePhase - /** Axes queued for this save, in order. */ - queue: number[] - /** Index within `queue` currently being written. */ - at: number - /** Axes the device has confirmed storing, this save. */ - stored: number[] +/** setTimeout / setInterval and their clears: the wall clock unless a test drives its own. */ +export interface WorkflowTimers { + setTimeout(fn: () => void, ms: number): unknown + clearTimeout(id: unknown): void + setInterval(fn: () => void, ms: number): unknown + clearInterval(id: unknown): void } -export interface CalibrationState { - open: boolean - /** Axis the dialog is showing, and the one the device is streaming. */ - axis: number - /** Latest snapshot the device confirmed. Badges and values come from here, never from drafts. */ - device?: CalSnapshot - /** Per-axis edits not yet written. Survives switching axes. */ - drafts: Record - /** - * Whether each axis returns to rest, chosen by the user. - * - * Held separately from the drafts because the choice has to be made *before* capturing — it - * decides whether the flow has a third point at all. Keying it to a draft would mean the first - * capture on every axis ran the centre step regardless, and the setting only became reachable - * once it was too late to matter. Defaults to true; see DraftAxis.selfCentring. - */ - restPosition: Record - /** - * Axis a STREAM_SELECT is in flight for, if any. - * - * `axis` does not move until the device acknowledges. Switching optimistically would leave us - * filtering for an axis the gateway is not streaming — every sample dropped on `idx`, so the - * readout and capture look dead — and retrying the same axis would early-return as a no-op. - */ - switchingTo?: number - /** - * Whether a sample for the current axis has actually arrived since it was selected. - * - * The ACK proves the device accepted the selection; this proves data is flowing. Kept separate - * and non-blocking because the node emits only on change: a steady axis may send nothing at - * all, so gating the UI on it would read as a hang. - */ - streaming: boolean - /** Live capture for the axis on screen, absent when not capturing. */ - capture?: CaptureState - /** Most recent sample for the streamed axis, for the live readout. */ - live?: { raw: number; cal: number } - write?: WriteProgress - /** Why the last operation failed. Cleared when the user acts again. */ - failure?: CalFailure & { axis?: number } - /** Axes that did store before a failure stopped the queue — worth naming in the UI. */ - storedBeforeFailure?: number[] - /** - * A confirmation to hold on screen for a few seconds. - * - * Every one of these operations finishes in well under a second — a COMMIT plus a read-back is - * two round trips against a 2 s timeout, and the erase is ~28 ms. Clearing the state the - * instant the work is done means the only evidence anything happened is a flicker, which reads - * as "nothing happened" rather than "done". Success needs to stay put long enough to be read. - */ - notice?: { kind: 'written' | 'erased'; axes: number[] } - /** - * Axes the cache holds that the device has lost, and the copy itself. - * - * Present does not mean acted on. This is an *offer* — restoring automatically onto a board - * whose calibration went missing would be right most of the time and catastrophic the rest, - * because the one case that produces a missing calibration and a populated cache is also the - * case where the board might have been swapped. Wrong endpoints fail open and stay invisible. - */ - restorable?: { board: CachedBoard; axes: number[] } - /** Whether the restore offer is expanded for review. Never restores without this. */ - restoreOpen: boolean - /** - * The serial link is closed, so nothing can reach the device (#74). - * - * Calibration rides on the serial port while the joystick does not, so the dialog can outlive - * the link. Paused is not cancelled: drafts, accepted sweeps and the last snapshot are kept, - * and the session is reopened when the same board comes back. Set from the moment the link - * drops until that reopen is acknowledged — not merely until the link is up again. - */ - paused: boolean - busy: boolean +/** The global timers, looked up at each call so a test's fake timers apply. */ +export const SYSTEM_TIMERS: WorkflowTimers = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (id) => clearTimeout(id as ReturnType), + setInterval: (fn, ms) => setInterval(fn, ms), + clearInterval: (id) => clearInterval(id as ReturnType) } -const emptyState = (): CalibrationState => ({ +const emptyState = (): WorkflowState => ({ open: false, axis: 0, drafts: {}, @@ -171,12 +73,12 @@ const emptyState = (): CalibrationState => ({ */ const boardOf = (s?: CalSnapshot) => s?.serialNumber ?? null -export class CalibrationController { +export class CalibrationWorkflow { private state = emptyState() - private listeners = new Set<(s: CalibrationState) => void>() - private ticker?: ReturnType - private holdTimer?: ReturnType - private noticeTimer?: ReturnType + private listeners = new Set<(s: WorkflowState) => void>() + private ticker?: unknown + private holdTimer?: unknown + private noticeTimer?: unknown /** The serial link as last reported, kept outside `state` so it survives open() resetting it. */ private linkUp = true private resuming = false @@ -189,10 +91,10 @@ export class CalibrationController { * The failure a session open reported, kept by identity so a later successful reopen can take * it down. Any other failure — a write, a delete — replaces the object and so is left showing. */ - private sessionFailure?: CalibrationState['failure'] + private sessionFailure?: WorkflowState['failure'] constructor( - private readonly api: CalibrationApi, + private readonly api: CalibrationCommands, /** Injected so tests can drive time; defaults to the wall clock. */ private readonly now: () => number = () => Date.now(), /** @@ -206,7 +108,9 @@ export class CalibrationController { * Tests pass 0. The delay is deliberately not part of what they assert, and paying it on * every save would make the suite slow for nothing. */ - private readonly phaseMinMs: number = 700 + private readonly phaseMinMs: number = 700, + /** Injected so tests (and the component tests' mock) can drive time. */ + private readonly timers: WorkflowTimers = SYSTEM_TIMERS ) {} /** Run something, then wait out whatever is left of the minimum display time. */ @@ -214,20 +118,20 @@ export class CalibrationController { const started = this.now() const result = await fn() const left = this.phaseMinMs - (this.now() - started) - if (left > 0) await new Promise((r) => setTimeout(r, left)) + if (left > 0) await new Promise((r) => this.timers.setTimeout(r, left)) return result } - subscribe(fn: (s: CalibrationState) => void): () => void { + subscribe(fn: (s: WorkflowState) => void): () => void { this.listeners.add(fn) return () => this.listeners.delete(fn) } - snapshot(): CalibrationState { + snapshot(): WorkflowState { return this.state } - private set(patch: Partial): void { + private set(patch: Partial): void { this.state = { ...this.state, ...patch } for (const fn of this.listeners) fn(this.state) } @@ -272,7 +176,7 @@ export class CalibrationController { // rest position it does not have. const cache = await this.api.calCacheRead() const restPosition = { ...this.state.restPosition } - let restorable: CalibrationState['restorable'] + let restorable: WorkflowState['restorable'] if (cache.ok && cache.value.board) { for (const [key, a] of Object.entries(cache.value.board.axes)) { restPosition[Number(key)] = a.selfCentring @@ -426,7 +330,7 @@ export class CalibrationController { */ private startTicking(): void { this.stopTicking() - this.ticker = setInterval(() => { + this.ticker = this.timers.setInterval(() => { const c = this.state.capture if (!c || c.phase !== 'capturing' || this.state.paused) return const next = tick(c, this.now()) @@ -435,7 +339,7 @@ export class CalibrationController { } private stopTicking(): void { - if (this.ticker) clearInterval(this.ticker) + if (this.ticker) this.timers.clearInterval(this.ticker) this.ticker = undefined } @@ -450,7 +354,7 @@ export class CalibrationController { * capture the user just finished. That was intermittent by construction — the same axis * completes down either path depending on when the last sample lands. */ - private advanceCapture(capture: CaptureState | undefined, extra: Partial = {}) { + private advanceCapture(capture: CaptureState | undefined, extra: Partial = {}) { if (capture?.phase === 'complete' && capture.result) { this.set({ ...extra, @@ -606,11 +510,11 @@ export class CalibrationController { */ private hold(then: () => void): void { this.clearHold() - this.holdTimer = setTimeout(then, 3000) + this.holdTimer = this.timers.setTimeout(then, 3000) } private clearHold(): void { - if (this.holdTimer) clearTimeout(this.holdTimer) + if (this.holdTimer) this.timers.clearTimeout(this.holdTimer) this.holdTimer = undefined } @@ -620,10 +524,10 @@ export class CalibrationController { * Carries which axes rather than a sentence: the wording is the dialog's business, and the * controller has no reason to reach into the renderer's label table. */ - private flash(notice: NonNullable): void { - if (this.noticeTimer) clearTimeout(this.noticeTimer) + private flash(notice: NonNullable): void { + if (this.noticeTimer) this.timers.clearTimeout(this.noticeTimer) this.set({ notice }) - this.noticeTimer = setTimeout(() => this.set({ notice: undefined }), 6000) + this.noticeTimer = this.timers.setTimeout(() => this.set({ notice: undefined }), 6000) } /** Does the device now hold exactly what this draft asked for? */ @@ -797,7 +701,7 @@ export class CalibrationController { dispose(): void { this.stopTicking() this.clearHold() - if (this.noticeTimer) clearTimeout(this.noticeTimer) + if (this.noticeTimer) this.timers.clearTimeout(this.noticeTimer) this.noticeTimer = undefined this.listeners.clear() } diff --git a/src/main/hid/calibration/hid-calibration-service.ts b/src/main/hid/calibration/hid-calibration-service.ts index b5cb11a..4a59eb2 100644 --- a/src/main/hid/calibration/hid-calibration-service.ts +++ b/src/main/hid/calibration/hid-calibration-service.ts @@ -1,7 +1,8 @@ // Axis calibration (#46) over the SimGateway's serial port, as the app runs it (moved out of // DeviceLink, R2.2a): a CalLink per port, the read-on-open that drives the HID tab's badges, the -// cal* commands as discriminated CalResults, the restore cache, `cal:raw` batching, the -// last-confirmed snapshot and the export gate. +// cal* commands as discriminated CalResults, the restore cache, RAW batching, the last-confirmed +// snapshot and the export gate. It hosts the calibration workflow (R3.1): the cal* commands are +// the workflow's, RAW batches feed it, and it pushes `cal:workflow`. // // It rides on the Gateway connection (gateway LinkRider) for the port's moments, and is the link's // calibration frame filter (CalFrameFilter): GatewayConnection runs it on every incoming chunk, and @@ -17,13 +18,15 @@ import type { CalSnapshot } from '@shared/hid/calibration' import type { Emit } from '@shared/app' +import type { DeviceState } from '@shared/gateway' import type { CalFrameFilter, LinkRider, LinkTimer } from '../../gateway' import { CalLink, CalNackError, CalTimeoutError, type Log } from './cal-link' import { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' import { boardFor, dropAxis, regressedAxes, storeAxes } from './cache-rules' import { CAL_AXIS_NONE, nackReasonName } from './codec' +import { CalibrationWorkflowHost } from './workflow-host' -/** `cal:raw` batches: the serial monitor's cadence, as before the split. */ +/** RAW batches (`cal:raw` before R3.1): the serial monitor's cadence, as before the split. */ const CAL_RAW_FLUSH_MS = 50 /** @@ -49,15 +52,37 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { * longer attached — or, worse, a different one after a reconnect. */ private lastCal?: CalSnapshot + /** The calibration dialog's workflow, over this service's own commands. */ + readonly workflow: CalibrationWorkflowHost constructor( private readonly emit: Emit, private readonly deps: HidCalibrationDeps - ) {} + ) { + this.workflow = new CalibrationWorkflowHost( + { + calRead: () => this.calRead(), + calSessionOpen: (axisIdx) => this.calSessionOpen(axisIdx), + calStreamSelect: (axisIdx) => this.calStreamSelect(axisIdx), + calCommit: (axis) => this.calCommit(axis), + calReset: (idx) => this.calReset(idx), + calSessionClose: () => this.calSessionClose(), + calCacheRead: async () => this.calCacheRead(), + calCacheStore: async (axes) => this.calCacheStore(axes), + calCacheDrop: async (idx) => this.calCacheDrop(idx) + }, + (state) => this.emit('cal:workflow', state) + ) + } + + /** The link's state changed (GatewayConnection's onStatus). */ + linkStatus(state: DeviceState): void { + this.workflow.linkStatus(state) + } // ── the link's moments (LinkRider) ───────────────────────────────────────── - /** `cal:raw` batching, on the link's timer list. */ + /** RAW batching, on the link's timer list. */ timers(every: LinkTimer): void { every(CAL_RAW_FLUSH_MS, () => this.flushCalRaw()) } @@ -165,7 +190,7 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { }) } - /** Convert a CalLink rejection into the discriminated result the renderer can branch on. */ + /** Convert a CalLink rejection into the discriminated result the workflow can branch on. */ private async calRun(fn: (link: CalLink) => Promise): Promise> { if (!this.cal) { return { ok: false, kind: 'offline', message: 'Gateway is not connected' } @@ -202,6 +227,7 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { const snap: CalSnapshot = { ...r.value, serialNumber: this.calSerialNumber } this.lastCal = snap this.emit('cal:data', snap) + this.workflow.reported(snap) return { ok: true, value: snap } } return r @@ -288,6 +314,6 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { private flushCalRaw(): void { if (this.calRawBuf.length === 0) return - this.emit('cal:raw', this.calRawBuf.splice(0, this.calRawBuf.length)) + this.workflow.ingest(this.calRawBuf.splice(0, this.calRawBuf.length)) } } diff --git a/src/main/hid/calibration/ipc.ts b/src/main/hid/calibration/ipc.ts index 6713dd4..988aecf 100644 --- a/src/main/hid/calibration/ipc.ts +++ b/src/main/hid/calibration/ipc.ts @@ -1,6 +1,8 @@ -// hid/calibration's intents: axis calibration (#46). app/ipc.ts registers them. +// hid/calibration's intents: the calibration dialog's actions (#46). app/ipc.ts registers them. +// The protocol exchanges they drive are main-internal (HidCalibrationService); main answers each +// action by pushing `cal:workflow`. import type { IpcRegistrar } from '@shared/app' -import { CALIBRATION_CTRL, type CalCacheEntry, type CalCommitAxis } from '@shared/hid/calibration' +import { CALIBRATION_CTRL } from '@shared/hid/calibration' import type { HidCalibrationService } from './hid-calibration-service' export interface CalibrationIpcDeps { @@ -8,24 +10,23 @@ export interface CalibrationIpcDeps { } export function registerCalibrationIpc(ipc: IpcRegistrar, deps: CalibrationIpcDeps): void { - const { calibration } = deps - // Axis calibration (#46). Every one returns a discriminated CalResult rather than throwing: - // an ipcRenderer.invoke rejection flattens to a plain Error, which would lose the - // timeout-vs-nack distinction the UI's failure messaging depends on. - ipc.handle(CALIBRATION_CTRL.calHello, () => calibration.calHello()) - ipc.handle(CALIBRATION_CTRL.calRead, () => calibration.calRead()) - ipc.handle(CALIBRATION_CTRL.calSessionOpen, (_e, axisIdx: number) => - calibration.calSessionOpen(axisIdx) + const { workflow } = deps.calibration + ipc.handle(CALIBRATION_CTRL.calOpen, (_e, axis: number, review?: boolean) => + workflow.open(axis, review) ) - ipc.handle(CALIBRATION_CTRL.calStreamSelect, (_e, axisIdx: number) => - calibration.calStreamSelect(axisIdx) + ipc.handle(CALIBRATION_CTRL.calClose, () => workflow.close()) + ipc.handle(CALIBRATION_CTRL.calSelectAxis, (_e, axis: number) => workflow.selectAxis(axis)) + ipc.handle(CALIBRATION_CTRL.calStartCapture, () => workflow.startCapture()) + ipc.handle(CALIBRATION_CTRL.calRetry, () => workflow.retry()) + ipc.handle(CALIBRATION_CTRL.calSetSelfCentring, (_e, selfCentring: boolean) => + workflow.setSelfCentring(selfCentring) ) - ipc.handle(CALIBRATION_CTRL.calCommit, (_e, axis: CalCommitAxis) => calibration.calCommit(axis)) - ipc.handle(CALIBRATION_CTRL.calReset, (_e, idx: number) => calibration.calReset(idx)) - ipc.handle(CALIBRATION_CTRL.calCacheRead, () => calibration.calCacheRead()) - ipc.handle(CALIBRATION_CTRL.calCacheStore, (_e, axes: CalCacheEntry[]) => - calibration.calCacheStore(axes) + ipc.handle(CALIBRATION_CTRL.calSave, () => workflow.save()) + ipc.handle(CALIBRATION_CTRL.calRestore, () => workflow.restore()) + ipc.handle(CALIBRATION_CTRL.calDeleteAxis, (_e, idx: number) => workflow.deleteAxis(idx)) + ipc.handle(CALIBRATION_CTRL.calSetRestoreOpen, (_e, open: boolean) => + workflow.setRestoreOpen(open) ) - ipc.handle(CALIBRATION_CTRL.calCacheDrop, (_e, idx: number) => calibration.calCacheDrop(idx)) - ipc.handle(CALIBRATION_CTRL.calSessionClose, () => calibration.calSessionClose()) + // The HID tab's mount and unmount: the workflow's lifetime (useCalibration's, before R3.1). + ipc.handle(CALIBRATION_CTRL.calAttach, (_e, on: boolean) => workflow.attach(on)) } diff --git a/src/main/hid/calibration/workflow-host.test.ts b/src/main/hid/calibration/workflow-host.test.ts new file mode 100644 index 0000000..4437c76 --- /dev/null +++ b/src/main/hid/calibration/workflow-host.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CALIBRATION_IDLE, type CalibrationState, type CalSnapshot } from '@shared/hid/calibration' +import type { CalibrationCommands } from './calibration-workflow' +import { CalibrationWorkflowHost } from './workflow-host' + +const snapshot = (calibratedMask: number, serial = 'BOARD-A'): CalSnapshot => ({ + presentMask: 0b11, + calibratedMask, + axes: Array.from({ length: 8 }, (_, idx) => ({ + idx, + controlId: idx < 2 ? 0x10 + idx : 0, + min: 0, + centre: 0, + max: 0, + deadzone: 0, + present: idx < 2, + calibrated: (calibratedMask & (1 << idx)) !== 0 + })), + serialNumber: serial +}) + +const ok = (value: T) => Promise.resolve({ ok: true as const, value }) + +/** A Gateway that answers everything and a cache that holds nothing. */ +const commands: CalibrationCommands = { + calRead: () => ok(snapshot(0)), + calSessionOpen: (axisIdx) => ok({ timeoutMs: 30_000, axisIdx }), + calStreamSelect: () => ok(null), + calCommit: () => ok(null), + calReset: () => ok(null), + calSessionClose: () => ok(null), + calCacheRead: () => ok({ regressed: [] }), + calCacheStore: () => ok(null), + calCacheDrop: () => ok(null) +} + +function host() { + const pushed: CalibrationState[] = [] + const h = new CalibrationWorkflowHost(commands, (s) => pushed.push(s)) + return { h, pushed, snap: () => h.state().tab.snapshot } +} + +describe('CalibrationWorkflowHost', () => { + it('starts in the state the renderer starts from', () => { + const { h, pushed } = host() + expect(h.state()).toEqual(CALIBRATION_IDLE) + expect(pushed, 'nothing is pushed before anything happens').toEqual([]) + }) +}) + +// The workflow's lifetime is the HID tab's mount (`cal:attach`), as useCalibration's controller's +// was: a remount, or a renderer reload, starts from a fresh one. +describe('the HID-tab view (cal:attach)', () => { + afterEach(() => vi.useRealTimers()) + + const sample = (raw: number) => [{ t: Date.now(), idx: 1, raw, cal: raw }] + + async function opened() { + const spied = { ...commands, calRead: vi.fn(commands.calRead) } + const pushed: CalibrationState[] = [] + const h = new CalibrationWorkflowHost(spied, (s) => pushed.push(s)) + h.linkStatus('connected') + h.reported(snapshot(0b01)) + h.attach(true) + await h.open(1) + return { h, spied, pushed } + } + + it('has nothing to act on, and drops RAW, until the tab mounts', async () => { + const { h } = host() + await h.open(1) + h.ingest(sample(40_000)) + expect(h.state().dialog).toBeUndefined() + }) + + it('a remount starts a fresh workflow: nothing from the last view comes back', async () => { + const { h } = await opened() + h.ingest(sample(40_000)) + expect(h.state().dialog?.workflow.live?.raw).toBe(40_000) + h.attach(false) + expect(h.state().dialog, 'unmounted: no dialog').toBeUndefined() + h.ingest(sample(41_000)) + h.attach(true) + await h.open(1) + expect(h.state().dialog?.workflow.live, 'the earlier reading is gone').toBeUndefined() + }) + + it('an unmount disposes the workflow: its ticker and timers stop', async () => { + vi.useFakeTimers() + const { h } = await opened() + h.startCapture() + expect(vi.getTimerCount()).toBeGreaterThan(0) + h.attach(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('a reload (a mount with the old view never unmounted) leaves the old view behind', async () => { + const { h } = await opened() + expect(h.state().dialog).toBeDefined() + h.attach(true) + expect(h.state().dialog).toBeUndefined() + }) + + it('a renderer load drops the old view; what it started never reaches the device again', async () => { + const { h, spied } = await opened() + const closing = h.close() + h.rendererLoaded() + expect(h.state().dialog).toBeUndefined() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(spied.calRead, 'the dead page never asked for its re-read').not.toHaveBeenCalled() + void closing + }) +}) + +// The store's calibration lifecycle before R3.1 (renderer store.test.ts): the device's report is +// held while the link is up and dropped whenever it is not. The rule moved here with the workflow. +describe('calibration state lifecycle', () => { + it('starts with no snapshot, which is not the same as uncalibrated', () => { + const { snap } = host() + expect(snap()).toBeUndefined() + }) + + it('takes the snapshot from the device report', () => { + const { h, snap } = host() + h.reported(snapshot(0b01)) + expect(snap()?.calibratedMask).toBe(0b01) + }) + + // Clearing only on `no-device` would leave the previous board's badges on screen through a + // reconnect -- and permanently, if the next board runs firmware that never sends cal:data. + for (const state of ['reconnecting', 'error', 'no-device', 'scanning', 'disconnected'] as const) { + it(`drops the snapshot when the device goes ${state}`, () => { + const { h, snap } = host() + h.reported(snapshot(0b01)) + expect(snap()).toBeDefined() + h.linkStatus(state) + expect(snap(), `${state} means the snapshot cannot be vouched for`).toBeUndefined() + }) + } + + it('keeps the snapshot while the link stays up', () => { + const { h, snap } = host() + h.reported(snapshot(0b01)) + h.linkStatus('connected') + expect(snap()?.calibratedMask).toBe(0b01) + }) + + it('replaces one board’s snapshot with the next board’s, never merging them', () => { + const { h, snap } = host() + h.reported(snapshot(0b11, 'BOARD-A')) + h.linkStatus('reconnecting') + expect(snap()).toBeUndefined() + h.linkStatus('connected') + h.reported(snapshot(0b00, 'BOARD-B')) + expect(snap()?.serialNumber).toBe('BOARD-B') + expect(snap()?.calibratedMask).toBe(0b00) + }) +}) diff --git a/src/main/hid/calibration/workflow-host.ts b/src/main/hid/calibration/workflow-host.ts new file mode 100644 index 0000000..ea6842b --- /dev/null +++ b/src/main/hid/calibration/workflow-host.ts @@ -0,0 +1,365 @@ +// The calibration workflow as the app runs it (R3.1): a CalibrationWorkflow per HID-tab view, the +// dialog's lifetime around it, and the `cal:workflow` push. What the renderer did around its +// controller before the move lives here, in the same order: +// - useCalibration: the controller's lifetime (built when the HID tab mounts, disposed when it +// unmounts — now `attach`), the start sequence (open → restore panel → dialog shown), the close +// sequence (session close → dialog gone → re-read → offer), the restore offer read on mount and +// on every device report, the link handed to the controller before the report; +// - the store's rule that the report is dropped whenever the link is not up (store.ts); +// - where the axis sat when a capture opened (the prompt's direction anchor). +// +// HidCalibrationService hosts one, over its own cal* commands. The component tests' mock hosts one +// over a scripted Gateway, so the dialog under test runs this code. +import { isLinkUp, type DeviceState } from '@shared/gateway' +import type { + CachedBoard, + CalibrationState, + CalRawSample, + CalResult, + CalSnapshot +} from '@shared/hid/calibration' +import { + CalibrationWorkflow, + type CalibrationCommands, + type WorkflowTimers +} from './calibration-workflow' +import { dialogState, tabState } from './calibration-view-model' + +type Offer = { confirmedAt: string; axes: number[] } + +/** The HID tab's restore offer from a cache read: the axes the device lost, if any. */ +const offerOf = (r: CalResult<{ board?: CachedBoard; regressed: number[] }> | undefined) => + r?.ok && r.value.board && r.value.regressed.length + ? { confirmedAt: r.value.board.confirmedAt, axes: r.value.regressed } + : undefined + +export interface WorkflowHostOptions { + /** The workflow's clock; the wall clock by default. */ + now?: () => number + /** Shortest time each write phase stays on screen; 700 ms by default. */ + phaseMinMs?: number + timers?: WorkflowTimers +} + +/** + * One HID-tab view: what useCalibration held for as long as the tab was mounted — the controller, + * whether the dialog is shown, the restore offer — plus the dialog's direction anchor. + */ +interface View { + workflow: CalibrationWorkflow + /** The commands as this view reaches them; they stop answering once the renderer is gone. */ + commands: CalibrationCommands + /** The renderer that owned this view was reloaded: nothing it started may continue. */ + gone: boolean + /** The dialog is on screen: from the end of the start sequence to the end of close's. */ + dialogOpen: boolean + offer?: Offer + startRaw: number | null + /** close() is between the workflow's reset and taking the dialog down. */ + closing: boolean +} + +export class CalibrationWorkflowHost { + /** The HID tab's view while it is mounted (attached); none otherwise. */ + private view?: View + /** The device's latest report while the link is up (the store's `cal` before R3.1). */ + private snapshot?: CalSnapshot + private linkUp = false + /** Answers to a superseded offer read are dropped (the renderer effect's `live` flag). */ + private offerSeq = 0 + private depth = 0 + private dirty = false + private current: CalibrationState + + constructor( + private readonly commands: CalibrationCommands, + private readonly push: (state: CalibrationState) => void, + private readonly opts: WorkflowHostOptions = {} + ) { + this.current = this.derive() + } + + /** What was last pushed: the `relay:status` rehydrate bundle's calibration. */ + state(): CalibrationState { + return this.current + } + + // ── the HID tab's view (`cal:attach`) ────────────────────────────────────── + + /** + * The HID tab mounted (`true`) or unmounted (`false`): where useCalibration built and disposed + * its controller. + * + * A mount builds a fresh workflow, hands it the link and the report, and reads the restore offer, + * as the mount's effects did. An unmount disposes it — the ticker and the hold and notice timers + * stop, RAW is dropped — but, as with the renderer's controller, what was already under way runs + * on. A mount while a view is still attached means the renderer that owned it was reloaded + * without unmounting: that view is gone, as the old renderer's controller was. + */ + attach(on: boolean): void { + if (this.view) { + if (on) this.gone(this.view) + else this.view.workflow.dispose() + this.view = undefined + this.offerSeq++ + } + if (on) this.mount() + this.publish() + } + + /** + * A renderer (re)loaded (`relay:status`, its first pull). A view from before it belonged to the + * renderer that went away, so it goes with it, as the controller died with the page. + */ + rendererLoaded(): void { + if (!this.view) return + this.gone(this.view) + this.view = undefined + this.offerSeq++ + this.publish() + } + + private mount(): void { + const life = { gone: false } + const commands = this.viewCommands(() => life.gone) + const w = new CalibrationWorkflow( + commands, + this.opts.now, + this.opts.phaseMinMs, + this.opts.timers + ) + const v: View = { + workflow: w, + commands, + get gone() { + return life.gone + }, + set gone(g: boolean) { + life.gone = g + }, + dialogOpen: false, + startRaw: null, + closing: false + } + this.view = v + w.subscribe((s) => { + if (this.view !== v) return + // close(): the workflow's reset lands one step before the dialog is taken down. Both go out + // as one push, as the dialog unmounted on the reset's render before. + if (v.closing && !s.open) { + this.dirty = true + return + } + this.publish() + }) + // The mount's effects, in their order: the link, then the report, then the offer. + this.batch(() => { + w.linkChanged(this.linkUp) + w.deviceChanged(this.snapshot) + }) + void this.refreshOffer() + } + + /** A dead page's controller never hears back: its calls from here on never answer. */ + private gone(v: View): void { + v.gone = true + v.workflow.dispose() + } + + private viewCommands(gone: () => boolean): CalibrationCommands { + const c = this.commands + const call = + (f: (...a: A) => Promise) => + (...a: A): Promise => + gone() ? new Promise(() => {}) : f(...a) + return { + calRead: call(() => c.calRead()), + calSessionOpen: call((i: number) => c.calSessionOpen(i)), + calStreamSelect: call((i: number) => c.calStreamSelect(i)), + calCommit: call((a: Parameters[0]) => c.calCommit(a)), + calReset: call((i: number) => c.calReset(i)), + calSessionClose: call(() => c.calSessionClose()), + calCacheRead: call(() => c.calCacheRead()), + calCacheStore: call((a: Parameters[0]) => + c.calCacheStore(a) + ), + calCacheDrop: call((i: number) => c.calCacheDrop(i)) + } + } + + // ── what main tells it ───────────────────────────────────────────────────── + + /** + * The link's state changed (`device:status`). + * + * Order matters: the link going down drops the report in the same step, and the workflow must + * already be paused when that empty report arrives — otherwise it reads as a different board + * and the captured sweeps are thrown away. + */ + linkStatus(state: DeviceState): void { + const up = isLinkUp(state) + this.batch(() => { + if (up !== this.linkUp) { + this.linkUp = up + this.view?.workflow.linkChanged(up) + this.publish() + } + // Calibration describes the gateway currently attached, so it is only trustworthy while + // the link is up. Everything else — reconnecting after a close, error, no-device, + // disconnected — means the snapshot may describe a board that is gone. Clearing only on + // `no-device` would leave the old board's badges on screen through a reconnect, and if + // the next board runs firmware predating the calibration protocol it never sends + // `cal:data`, so they would never be corrected. + if (!up) this.setSnapshot(undefined) + }) + } + + /** The device reported its stored calibration (GET_CAL, as `cal:data` pushes it). */ + reported(snap: CalSnapshot): void { + this.setSnapshot(snap) + } + + /** A batch of RAW samples (`cal:raw`'s batches before R3.1); dropped with no view attached. */ + ingest(samples: CalRawSample[]): void { + this.view?.workflow.ingest(samples) + } + + // ── intents (a mounted HID tab's; nothing to act on without one) ─────────── + + /** Open the dialog on `axis`; `review` expands the restore offer. */ + async open(axis: number, review = false): Promise { + const v = this.view + if (!v) return + await v.workflow.open(axis, this.snapshot) + if (this.view !== v) return + this.batch(() => { + v.workflow.setRestoreOpen(review) + // A fresh dialog: its direction anchor starts unset, as the dialog's own ref did. + v.startRaw = null + v.dialogOpen = true + this.publish() + }) + } + + /** Close the session and the dialog, then re-read what the device holds and the offer. */ + async close(): Promise { + const v = this.view + if (!v) return + v.closing = true + try { + await v.workflow.close() + } finally { + v.closing = false + } + v.dialogOpen = false + if (this.view === v) this.publish() + const r = await v.commands.calRead() + if (r.ok) this.setSnapshot(r.value) + const c = await v.commands.calCacheRead() + if (this.view !== v) return + v.offer = offerOf(c) + this.publish() + } + + selectAxis(axis: number): Promise { + return this.view?.workflow.selectAxis(axis) ?? Promise.resolve() + } + + startCapture(): void { + this.view?.workflow.startCapture() + } + + retry(): void { + this.view?.workflow.retry() + } + + setSelfCentring(selfCentring: boolean): void { + this.view?.workflow.setSelfCentring(selfCentring) + } + + save(): Promise { + return this.view?.workflow.save() ?? Promise.resolve() + } + + restore(): Promise { + return this.view?.workflow.restore() ?? Promise.resolve() + } + + deleteAxis(idx: number): Promise { + return this.view?.workflow.deleteAxis(idx) ?? Promise.resolve() + } + + setRestoreOpen(open: boolean): void { + this.view?.workflow.setRestoreOpen(open) + } + + // ── internals ────────────────────────────────────────────────────────────── + + /** + * A new device report: the view's workflow takes it, then the restore offer is re-read. + * + * The offer needs no session: GET_CAL already ran to drive the badges, and the cache is a local + * file. So it can appear on the HID tab with no dialog open — which is the point, since the user + * has no reason to open one for an axis they think is calibrated. + */ + private setSnapshot(next: CalSnapshot | undefined): void { + if (next === this.snapshot) return + this.snapshot = next + const v = this.view + this.batch(() => { + v?.workflow.deviceChanged(next) + this.publish() + }) + if (v) void this.refreshOffer() + } + + private async refreshOffer(): Promise { + const v = this.view! + const seq = ++this.offerSeq + const r = await v.commands.calCacheRead() + if (seq !== this.offerSeq || this.view !== v) return + v.offer = offerOf(r) + this.publish() + } + + /** Changes inside `fn` go out as one push. */ + private batch(fn: () => void): void { + this.depth++ + try { + fn() + } finally { + this.depth-- + } + if (this.depth === 0 && this.dirty) this.publish() + } + + private publish(): void { + if (this.depth > 0) { + this.dirty = true + return + } + this.dirty = false + this.current = this.derive() + this.push(this.current) + } + + private derive(): CalibrationState { + const v = this.view + const tab = tabState(this.snapshot, this.linkUp, v?.offer) + if (!v || !v.dialogOpen) return { tab } + const w = v.workflow + const s = w.snapshot() + // Where the axis sat when capture opened — the only honest anchor for "upper"/"lower". + if (!s.capture) v.startRaw = null + else if (v.startRaw === null && s.live) v.startRaw = s.live.raw + return { + tab, + dialog: dialogState(s, { + invalid: w.invalidAxis(), + selfCentring: w.selfCentring(), + dirty: w.dirtyAxes(), + startRaw: v.startRaw + }) + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index f7a95f6..efc753a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,14 +14,6 @@ import type { ConfigImportApplyResult, ConfigImportOpen } from '@shared/config' -import type { - CachedBoard, - CalCacheEntry, - CalCommitAxis, - CalHello, - CalResult, - CalSnapshot -} from '@shared/hid/calibration' import type { CaptureState, ExportResult, RelayResult, ReplayLoad } from '@shared/dcs' import type { HidAvailability } from '@shared/hid' import type { NodeMappingResult, NodeRegistryInfo, NodeSnapshot, ResetPreview } from '@shared/nodes' @@ -77,26 +69,20 @@ const api: SkyhawkApi = { setSerialMonitor: (on: boolean) => ipcRenderer.invoke(CTRL.serialMonitor, on) as Promise, dumpSerialPorts: () => ipcRenderer.invoke(CTRL.debugDumpPorts) as Promise, revealDebugLog: () => ipcRenderer.invoke(CTRL.debugReveal) as Promise, - calHello: () => ipcRenderer.invoke(CTRL.calHello) as Promise>, - calRead: () => ipcRenderer.invoke(CTRL.calRead) as Promise>, - calSessionOpen: (axisIdx: number) => - ipcRenderer.invoke(CTRL.calSessionOpen, axisIdx) as Promise< - CalResult<{ timeoutMs: number; axisIdx: number }> - >, - calStreamSelect: (axisIdx: number) => - ipcRenderer.invoke(CTRL.calStreamSelect, axisIdx) as Promise>, - calCommit: (axis: CalCommitAxis) => - ipcRenderer.invoke(CTRL.calCommit, axis) as Promise>, - calReset: (idx: number) => ipcRenderer.invoke(CTRL.calReset, idx) as Promise>, - calCacheRead: () => - ipcRenderer.invoke(CTRL.calCacheRead) as Promise< - CalResult<{ board?: CachedBoard; regressed: number[] }> - >, - calCacheStore: (axes: CalCacheEntry[]) => - ipcRenderer.invoke(CTRL.calCacheStore, axes) as Promise>, - calCacheDrop: (idx: number) => - ipcRenderer.invoke(CTRL.calCacheDrop, idx) as Promise>, - calSessionClose: () => ipcRenderer.invoke(CTRL.calSessionClose) as Promise> + calOpen: (axis: number, review?: boolean) => + ipcRenderer.invoke(CTRL.calOpen, axis, review) as Promise, + calClose: () => ipcRenderer.invoke(CTRL.calClose) as Promise, + calSelectAxis: (axis: number) => ipcRenderer.invoke(CTRL.calSelectAxis, axis) as Promise, + calStartCapture: () => ipcRenderer.invoke(CTRL.calStartCapture) as Promise, + calRetry: () => ipcRenderer.invoke(CTRL.calRetry) as Promise, + calSetSelfCentring: (selfCentring: boolean) => + ipcRenderer.invoke(CTRL.calSetSelfCentring, selfCentring) as Promise, + calSave: () => ipcRenderer.invoke(CTRL.calSave) as Promise, + calRestore: () => ipcRenderer.invoke(CTRL.calRestore) as Promise, + calDeleteAxis: (idx: number) => ipcRenderer.invoke(CTRL.calDeleteAxis, idx) as Promise, + calSetRestoreOpen: (open: boolean) => + ipcRenderer.invoke(CTRL.calSetRestoreOpen, open) as Promise, + calAttach: (on: boolean) => ipcRenderer.invoke(CTRL.calAttach, on) as Promise } contextBridge.exposeInMainWorld('skyhawk', api) diff --git a/src/renderer/src/CalibrationDialog.tsx b/src/renderer/src/CalibrationDialog.tsx index c751042..70cc2d6 100644 --- a/src/renderer/src/CalibrationDialog.tsx +++ b/src/renderer/src/CalibrationDialog.tsx @@ -1,152 +1,32 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { - DEFAULT_CAPTURE_CONFIG, - hasTravelled, - holdProgress, - holdRemainingMs, - sweepsRemaining -} from '@shared/hid/calibration/legacy-rules' +import { useEffect, useRef, useState } from 'react' import { CAL_PROTO, HID_REPORT_LAYOUT } from '@shared/firmware' -import type { CalSnapshot } from '@shared/hid/calibration' -import { CalibrationController, type CalibrationState } from './calibration-controller' -import { AXIS_LABELS, useStore } from './store' - -/** 32768: the unsigned -> signed offset, from the HID report's axis range. */ -const AXIS_OFFSET = -HID_REPORT_LAYOUT.axes.min -/** 65535: the full unsigned span, from the HID report's axis range. */ -const AXIS_SPAN = HID_REPORT_LAYOUT.axes.max - HID_REPORT_LAYOUT.axes.min - -/** - * Display units: signed ±32767, matching what DCS shows and what the HID tab reads off the - * report. The wire, storage, capture and COMMIT all stay unsigned 0–65535 — that is the device's - * language — so this is the single place the two meet. - * - * **Display only. Never call this on a value being committed.** The offset applied twice, or - * zero times, still yields plausible in-range numbers and fails silently. - */ -const toDisplay = (unsigned: number) => unsigned - AXIS_OFFSET -const fmt = (n: number) => toDisplay(n).toLocaleString('en-US') -/** A span, not a position — no offset applies. */ -const fmtSpan = (n: number) => n.toLocaleString('en-US') -const pct = (v: number) => Math.max(0, Math.min(100, (v / AXIS_SPAN) * 100)).toFixed(2) + '%' +import type { CalibrationDialogState } from '@shared/hid/calibration' +import { AXIS_OFFSET, AXIS_SPAN, fmt, fmtSpan, pct, promptText } from './calibration-view' +import { AXIS_LABELS, type CalibrationIntents } from './store' type Point = 'stopA' | 'stopB' | 'centre' -/** Descriptive, never an error: 40% is the expected healthy result for a short-arc axis. */ -const TRAVEL_BANDS = [ - { at: 0.6, label: 'Good', tone: 'ok' }, - { at: 0.3, label: 'Workable', tone: '' }, - { at: 0, label: 'Low', tone: 'warn' } -] as const - -function travelQuality(min?: number, max?: number) { - if (min === undefined || max === undefined) return null - const counts = max - min - const band = TRAVEL_BANDS.find((b) => counts / AXIS_SPAN >= b.at)! - return { counts, pct: Math.round((counts / AXIS_SPAN) * 100), ...band } -} - /** - * The live prompt during a capture. - * - * Two states per step, because they ask for opposite things and reading the wrong one wastes a - * sweep: **travelling** (gold — keep moving, the bar is not counting yet) and **holding** - * (green — stop moving, the bar is the dwell timer). The split is `hasTravelled`, not - * `awaitingMovement`: that flag only rises after a full hold has already elapsed, so it reads - * false for the first second of every step whether or not the user has touched the axis. - * - * Direction words are derived, never assumed. `startRaw` is where the axis sat when capture - * opened, so "upper" means the raw count went up from there — which stays correct on an - * inverted axis and on a short-arc axis that never crosses the midpoint. Before the axis has - * moved at all in the first step there is no evidence either way, so the copy stays neutral. + * The axis calibration dialog. Main runs the workflow and decides everything shown here (`d`, the + * pushed CalibrationDialogState); the dialog draws it, words it, and sends the user's actions. */ -function prompt(s: CalibrationState, startRaw: number | null) { - const cap = s.capture - if (!cap || cap.phase !== 'capturing') return null - const holding = hasTravelled(cap) - const dir = (v: number) => (startRaw === null || v >= startRaw ? 'upper' : 'lower') - - if (cap.step === 'centre') { - return { - arrow: holding ? '■' : '↓', - text: holding ? 'Let it settle — hands off' : 'Release the axis and let it return to rest', - note: holding - ? 'The axis is settled, so the device has stopped reporting. That is normal — the dwell timer runs on the clock.' - : 'Take your hand off it completely; a hand resting on the axis is a held position.', - holding - } - } - - // stopA has no direction until the axis moves; stopB is whichever way stopA was not. - const side = - cap.step === 'stopA' - ? s.live && cap.movedBy > 0 - ? dir(s.live.raw) - : null - : cap.current.stopA !== undefined - ? dir(cap.current.stopA) === 'upper' - ? 'lower' - : 'upper' - : null - - if (holding) { - return { - arrow: '■', - text: side ? `HOLD it at the ${side} stop` : 'HOLD it against the stop', - note: 'The axis is settled, so the device has stopped reporting. That is normal — the dwell timer runs on the clock.', - holding - } - } - return { - arrow: side === 'lower' ? '▼' : side === 'upper' ? '▲' : '↕', - text: !side - ? 'Sweep the axis all the way to either stop' - : cap.step === 'stopA' - ? `Push the axis to its ${side.toUpperCase()} stop` - : `Now ${side === 'lower' ? 'pull' : 'push'} it to its ${side.toUpperCase()} stop`, - note: cap.awaitingMovement - ? 'Nothing has moved yet — take the axis all the way to the mechanical stop.' - : 'Keep moving steadily until you reach the stop.', - holding - } -} - export function CalibrationDialog({ - controller, - onClose + d, + calibrate }: { - controller: CalibrationController - onClose: () => void + d: CalibrationDialogState + calibrate: CalibrationIntents }) { - const [s, setS] = useState(() => controller.snapshot()) - useEffect(() => controller.subscribe(setS), [controller]) - - const present = useMemo(() => s.device?.axes.filter((a) => a.present) ?? [], [s.device]) - const draft = s.drafts[s.axis] - const stored = s.device?.axes[s.axis] + const s = d.workflow + const { present, writing, invalid, shown, travel, selfCentring, dirty } = d const cap = s.capture - // 'done' is the held confirmation, not work in progress. Treating it as writing left the - // footer reading "Writing…" beside a strip saying the device had already confirmed the write, - // and kept Cancel and Delete disabled for three seconds after there was anything to wait for. - const writing = !!s.write && s.write.phase !== 'done' - const invalid = controller.invalidAxis() - const shown = draft ?? (stored?.calibrated ? stored : undefined) - const travel = travelQuality(shown?.min, shown?.max) - const selfCentring = controller.selfCentring() - // Where the axis sat when capture opened — the only honest anchor for "upper"/"lower". - const startRaw = useRef(null) - if (!cap) startRaw.current = null - else if (startRaw.current === null && s.live) startRaw.current = s.live.raw - // A paused capture is frozen, so "push it to its stop" would ask for movement nothing records. - const p = s.paused ? null : prompt(s, startRaw.current) - const dirty = controller.dirtyAxes() + const p = d.prompt && promptText(d.prompt) + const onClose = () => calibrate.close() // Centre is not a stage the user navigates to — it is captured inside the same sweep as the // stops, and naming it as step 2 promised a separate thing to do that never arrives. const steps = ['Full travel', 'Review & write'] - // A rejected sweep is still step 1: nothing was captured, and Redo continues the same capture. - const inTravel = cap?.phase === 'capturing' || cap?.phase === 'rejected' - const stepIndex = inTravel ? 0 : 1 + const stepIndex = d.stepIndex /** * Delete is armed by the first click and performed by the second. @@ -212,12 +92,12 @@ export function CalibrationDialog({ DCS link paused - - {(cap || draft) && ( + {d.strip && (

    @@ -245,8 +125,8 @@ export function CalibrationDialog({ @@ -377,29 +257,29 @@ export function CalibrationDialog({
    - {cap?.phase === 'rejected' ? ( + {d.capture.action === 'redo' ? ( ) : ( )} - {cap?.phase === 'capturing' - ? `Sweep ${cap.accepted.length + 1} of ${cap.config.sweepsRequired}` - : draft - ? `${DEFAULT_CAPTURE_CONFIG.sweepsRequired} of ${DEFAULT_CAPTURE_CONFIG.sweepsRequired} sweeps captured` - : `${DEFAULT_CAPTURE_CONFIG.sweepsRequired} sweeps per axis`} + {d.sweeps.kind === 'capturing' + ? `Sweep ${d.sweeps.sweep} of ${d.sweeps.of}` + : d.sweeps.kind === 'captured' + ? `${d.sweeps.of} of ${d.sweeps.of} sweeps captured` + : `${d.sweeps.of} sweeps per axis`}
    @@ -418,12 +298,12 @@ export function CalibrationDialog({ : cap.awaitingConfirmation ? 'waiting for a sample' : p.holding - ? `hold ${(holdRemainingMs(cap) / 1000).toFixed(1)} s more` + ? `hold ${(d.hold!.remainingMs / 1000).toFixed(1)} s more` : ''}
    - +
    {cap.awaitingConfirmation @@ -440,14 +320,11 @@ export function CalibrationDialog({
    )} - {cap && cap.accepted.length > 0 && ( + {d.convergence.length > 0 && (
    Sweep convergence
    - {cap.accepted.map((w, i) => { - // Accepted sweeps are within slack by construction, so this flags the ones that - // still contribute nothing under widest-wins rather than reporting a fault. - const widest = Math.max(...cap.accepted.map((a) => a.max - a.min)) - const short = widest - (w.max - w.min) > widest * cap.config.shortfallFraction + {d.convergence.map((w, i) => { + const short = w.short return (
    Sweep {i + 1} @@ -504,7 +381,7 @@ export function CalibrationDialog({
    )} - {s.restorable && s.restoreOpen && !s.write && !s.paused && ( + {d.restore === 'panel' && s.restorable && (
    Restore calibration to the device @@ -536,13 +413,10 @@ export function CalibrationDialog({ {s.device?.serialNumber ?? 'unknown'}. Only axes the device has lost are written; anything it still holds is left alone. - - @@ -550,15 +424,15 @@ export function CalibrationDialog({
    )} - {s.restorable && !s.restoreOpen && !s.write && !s.paused && ( - )} - {s.notice && !s.write && ( + {d.notice && s.notice && (
    {s.notice.kind === 'written' ? '✓' : '⌫'} @@ -600,35 +474,35 @@ export function CalibrationDialog({ )}