diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..1db9e0f --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,181 @@ +// 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 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/. +// +// Severities live ONLY in .dependency-cruiser.severity.json, one key per rule family. Each phase +// flipped its own families from "warn" to "error"; since R3.4b every family is "error". +// 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/', + '^src/main/test-harness/' + ] + }, + // 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..489f4b6 --- /dev/null +++ b/.dependency-cruiser.severity.json @@ -0,0 +1,15 @@ +{ + "process": "error", + "crossProcess": "error", + "electron": "error", + "rendererShared": "error", + "contracts": "error", + "cycles": "error", + "firmwareLeaf": "error", + "formatLeaf": "error", + "kitLeaf": "error", + "topicsMain": "error", + "topicsRenderer": "error", + "deepMain": "error", + "deepRenderer": "error" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac69d93..f330480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,11 @@ 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 + 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/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5d60fda --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,244 @@ +# Architecture + +A guide for contributors: where code lives, which way it may depend, and where a new feature +goes. For what the app does and why, see [PRD.md](./PRD.md). + +The short version: **main decides, the renderer draws.** Main owns the hardware, the files and +every rule. It pushes finished state to the renderer. The renderer turns that state into words +and pixels, and sends the user's intents back. + +## The process wall + +The app is an Electron app with three processes' worth of code, plus code they share. + +| Folder | Runs in | What it holds | +| --------------- | -------------------- | ------------------------------------------------------------------------------------------------- | +| `src/main/` | Node (Electron main) | The serial port, HID, the DCS-BIOS network link, files in ``, and every rule about them | +| `src/preload/` | The preload script | The typed bridge `window.skyhawk`, and nothing else | +| `src/renderer/` | The browser window | React views, one store per topic, presentation helpers | +| `src/shared/` | Both | The IPC contract, the generated firmware reference and pure formatting helpers | + +The wall is enforced (see [Boundary lint](#boundary-lint)): + +- the renderer never imports main or preload, and main never imports the renderer; +- shared code imports nothing from a process; +- the renderer imports only the shared contracts and the two shared leaves (`shared/firmware`, + `shared/format`); +- only `main/app/` and the preload import `electron`. + +## The IPC contract + +Each topic has a contract file, `src/shared//ipc.ts`. It declares: + +- **push channels** (main → renderer): the channel name and the exact shape of its payload; +- **intents** (renderer → main, invoke and reply): the channel name and what it answers; +- the few constants those shapes are defined by, and each pushed state's starting value (what + the renderer shows before main has said anything). + +`src/shared/app/ipc.ts` assembles the topic contracts into one surface: `SkyhawkApi` (what +`window.skyhawk` offers), `PushChannels`, `IPC` (push names) and `CTRL` (intent names). It also +owns the two things that span topics: the `relay:status` rehydrate bundle and the debug intents. + +`src/shared/app/ipc.compat.test-d.ts` is the **API guard**: a frozen copy of the whole surface. +`npm run typecheck` fails if the live types drift from it. A PR that changes the API on purpose +updates the frozen copy in the same diff and says why in its header. Channel strings never +change silently. + +## Topics + +Inside main and inside the renderer, code is grouped by topic. The topics are the same on both +sides: + +| Topic | About | +| ----------------- | -------------------------------------------------------------------------------------------------------------- | +| `app` | Launch, wiring, the window, IPC registration (main); the shell and tabs that compose several topics (renderer) | +| `gateway` | The SimGateway USB serial link, its state and the Gateway's own health, the serial monitor | +| `dcs` | The DCS-BIOS relay, its network end, the stream log, stats, recording and replay | +| `nodes` | The CAN node roster, PDU telemetry, the node mapping and the Cockpit snapshot | +| `hid` | The HID joystick report | +| `hid/calibration` | Axis calibration: the protocol, the workflow and the restore cache | +| `events` | The event log | +| `config` | The settings, and configuration export and import | + +The renderer also has `app/kit/`: UI primitives (`Dialog`, `Sheet`, `Row`, icons) and the nav +store. It is a leaf that every renderer topic may use. + +### `index.ts` is the only door + +Every topic folder has an `index.ts`. It exports what other topics may use, and nothing else. A +file outside a topic imports only that topic's `index.ts`, never a file inside it. A topic's own +files import each other freely. + +### Allowed directions + +A topic may import only the topics in its row, plus the shared contracts, `shared/firmware`, +`shared/format` and (in the renderer) `app/kit`. The table is the same in main and the renderer. + +| From | May import | +| ----------------- | -------------------------- | +| `app` | every topic | +| `config` | `nodes`, `hid/calibration` | +| `nodes` | `gateway`, `events` | +| `dcs` | `gateway`, `events` | +| `hid/calibration` | `hid`, `gateway` | +| `hid` | `gateway` | +| `gateway` | `events` | +| `events` | nothing | + +There are no import cycles anywhere, type-only imports included. When two topics need the same +thing, it belongs to the lower one, or to a contract. + +## Roles + +Each file plays one role. + +| Role | Where | What it does | Example | +| -------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Model** | main | Pure rules. No I/O; time is passed in. Stateful classes count if they do no I/O. | `main/gateway/gateway-health.ts` `gatewayStatus`: the worst Gateway problem from a status row | +| **Service** | main | Stateful, with side effects: owns a resource, calls the models, pushes the result. Never re-derives a model's rule. | `main/nodes/node-hub.ts` `NodeHub`: the roster, the mapping file and the `nodes:model` / `cockpit:snapshot` pushes | +| **View-model** | renderer | Presentation only: pushed state into words, CSS classes and geometry. Decides nothing. | `renderer/src/gateway/gateway-view.ts` `gatewayView`: the pill class, popover title and Health row | +| **View** | renderer | React components that read a store and call its intents. | `renderer/src/gateway/DevicePopover.tsx` | + +Some main files build what a push carries from the models' answers, purely: the Cockpit +snapshot (`main/nodes/cockpit-snapshot/`), the relay's facts (`main/dcs/relay-rules.ts`), the +calibration view (`main/hid/calibration/calibration-view-model.ts`). They are models: they +decide, and the renderer only words what they decided. + +**One owner per rule.** Each rule lives in exactly one place, and every surface that needs its +answer reads it from there. If the Overview and the Cockpit must agree on a count, both read the +same field of the same push. + +## Who owns what + +| Topic | Main owner | Pushes | Renderer store | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------- | +| `gateway` | `GatewayConnection` (the port and its state), `SerialTrafficMonitor`; the Gateway's health rules (`gateway-health`) | `device:status`, `serial:traffic` | `useGatewayStore` | +| `dcs` | `DcsRelay` (Start/Stop, source mode, what the relay's surfaces show), `DcsConnection`, `StreamLog`, `Stats`, `SessionRecorder` | `relay:state`, `relay:facts`, `aircraft:changed`, `telemetry:tick`, `stats:tick`, `log:batch` | `useDcsStore` | +| `nodes` | `NodeHub` (roster, telemetry, mapping; it also feeds the Gateway's health from the node status rows), its `MappingStore`, and the Cockpit snapshot host | `nodes:model`, `cockpit:snapshot` | `useNodesStore` | +| `hid` | `HidService` | `hid:report` | `useHidStore` | +| `hid/calibration` | `HidCalibrationService` and the workflow it hosts | `cal:workflow` | `useCalibrationStore` | +| `events` | `EventJournal` | `events:batch` | `useEventsStore` | +| `config` | `SettingsService` (the settings), `ConfigTransfer` (export / import) | `config:changed` | `useConfigStore` | +| `app` | `AppServices` (builds and wires the services; no rules, no state) | — | `useNav` (in `app/kit`) | + +## How a screen gets its data + +``` +main service ──emit──▶ push:batch ──▶ preload ──▶ ipc-adapter ──▶ topic store ──▶ view + ▲ │ + └───────────────── intent (invoke) ◀── topic store intent ◀── user action ◀────┘ +``` + +1. A main service changes something and calls `emit(channel, payload)` with the finished state. +2. The push travels in a `push:batch` (below) and the preload hands it to the subscribers of its + channel. +3. `renderer/src/app/ipc-adapter.ts` is the only renderer file that touches `window.skyhawk`. It + subscribes every push channel and routes each push to its topic store's `FromMain` + handler. +4. The store holds what main pushed. Views read the store and render it through their + view-models. +5. A user action calls an intent on the topic store (`forgetNode`, `toggleRelay`, …). The store + calls main through the binding the adapter gave it (`bind`). Main acts, then pushes the + new state. The renderer never edits its own copy of main's state to fake the result. + +When the renderer loads (or reloads in dev while main keeps running) the adapter also pulls the +current picture: `relay:status`, `nodes:get`, `config:get`, `events:get`, `hid:availability`. + +## `push:batch`: one frame per main turn + +One main event often fans out to several pushes. A link drop, for example, changes +`device:status`, `cal:workflow`, `cockpit:snapshot` and `relay:facts`. Sent one by one, the +renderer could draw between them and show half the change. + +So main never sends a push on its own channel. `main/app/push-batch.ts` collects every push made +during one synchronous main turn and sends them as one `push:batch` message, in order, from a +microtask at the end of the turn. The preload hands that message's pushes to their subscribers in +one task, and React draws them as one frame. Nothing is merged or dropped. `SkyhawkApi.on(channel, +cb)` is still the per-channel subscription; the batching is invisible to stores and views. + +## Firmware values come from `npm run sync` + +Protocol numbers, report layouts, node names, flag bits and USB identity come from the +OpenSkyhawk firmware repo. `scripts/sync-a4ec.ts` (`npm run sync`) reads them from a pinned +firmware commit and writes `src/shared/firmware/*.generated.ts`. + +- Import them from `@shared/firmware`; never type a firmware value by hand. +- Never edit a generated file. Change the generator (or the pinned commit) and run `npm run sync`. +- CI re-runs the sync and fails if the committed files differ. +- The one hand-curated exception is the DCS-BIOS common-metadata address (`_ACFT_NAME`), which + the firmware repo does not carry; it lives in `main/dcs/bios-protocol.ts`. + +## The settings store + +`SettingsService` (`main/config/`) owns the live settings: one zustand vanilla store under +`subscribeWithSelector`, loaded from `config.json` and saved back on each change. Services that +need settings are handed the read-only `SettingsStore` view of that same instance. They read it +with `getState()` when they need a value, and react with `subscribe(selector, listener)`. + +Only `SettingsService` writes it. A change from the renderer is an intent (`config:set`, +`config:edit`); main builds the new settings, saves them and pushes `config:changed`. + +Other ports are injected the same way. `AppServices` hands each service what it needs (the +`` file paths, the debug log, the `emit` function, the Gateway link) so no service +imports `app/` or asks electron itself. + +## Adding a feature + +Work from the wire inwards, then outwards to the view. + +| Piece | Where it goes | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| A new firmware value | The firmware repo first, then the generator in `scripts/sync-a4ec.ts`, then `npm run sync` | +| The rule (what it means) | A model in `src/main//`, pure, with unit tests beside it | +| The state it changes, and when | The topic's service in `src/main//`, which calls the model and pushes | +| The pushed shape, a new channel or intent | `src/shared//ipc.ts`; update the API guard on purpose | +| Wiring between services | `main/app/app-services.ts` only | +| A new intent's handler | The topic's `main//ipc.ts` (`registerIpc`), plus its slot in `main/app/ipc.ts`'s order | +| Holding the push in the renderer | The topic store's `FromMain`, routed in `app/ipc-adapter.ts` | +| Words, classes, geometry | A view-model beside the view, in `src/renderer/src//` | +| The component | `src/renderer/src//` (or `app/` if it composes several topics) | +| Shared formatting both sides use | `src/shared/format/` | + +If the renderer needs to decide something, that decision belongs in main: push the answer +instead. If another topic needs your code, export it from your `index.ts`, and check the +direction table first. + +## Boundary lint + +Two tools enforce the structure. Both run in CI. + +- **`npm run lint:deps`** runs dependency-cruiser with `.dependency-cruiser.cjs`. It checks the + process wall, the contract and leaf rules, the topic directions, the "index.ts only" rule, and + cycles. +- **ESLint rule P5** (`eslint.config.mjs`, part of `npm run lint`) checks the one thing a + dependency graph can't see: only `renderer/src/app/ipc-adapter.ts` may touch `window.skyhawk`. + +Each dependency-cruiser rule family takes its severity from +`.dependency-cruiser.severity.json`, one key per family. Every family is `error`. A new rule can +start at `warn` there while existing code is brought in line, then move to `error`. + +## Tests + +| Layer | Runs | Looks at | Where | +| ------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | +| **Unit** | node | One model or service, directly | `*.test.ts` beside the code | +| **Seam** | node | The whole main process through `makeServices()` (`main/test-harness/services.ts`), with the serial port, DCS transport, HID, clock and `` faked. A test sees only pushes, intents, files and wire bytes. | `*.seam.test.ts` | +| **Component** | jsdom | A rendered view, through the real IPC adapter and stores | `src/renderer/test/t*.test.tsx` | +| **API guard** | typecheck | The IPC surface's types | `src/shared/app/ipc.compat.test-d.ts` | + +**Component tests describe main's world, not the renderer's state.** A test builds a `Scenario` +(the link, the relay, the node model, the config, …) and calls `emitScenario`. The mock +(`src/renderer/test/skyhawk-mock.ts`) pushes it the way main does, on the real channels and in +`push:batch` turns, and it runs **main's own builders** for the pushes main derives: the Cockpit +snapshot host, the calibration workflow host, the relay's facts, the HID facts. So the component +under test draws what main would really push. Tests never set store fields and never push by +hand; they assert roles and visible text, not pixels or internal calls. Shared builders for +scenarios are in `src/renderer/test/scenarios.ts` and `src/test-fixtures/`. + +**Structure changes come with proofs.** A refactor that must not change behaviour also proves it, +outside the test suite: a **render proof** records every DOM state each component test passes +through, before and after, and compares the sequences; a **trace proof** drives main through +scripted scenarios and records every push, wire byte, timer and file write, before and after. The +PR body reports both. + +Run everything with `npm test`, `npm run lint`, `npm run lint:deps` and `npm run typecheck`. diff --git a/PRD.md b/PRD.md index 962919a..cef2317 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). @@ -122,19 +122,31 @@ Electron, TypeScript, React, Vite (or electron-forge + Vite) for build; `node-se ## 9. Repo layout (SkyHawkClient) +How the pieces fit together, and where a new feature goes: [ARCHITECTURE.md](./ARCHITECTURE.md). + ``` SkyHawkClient/ -├── PRD.md ← this document (committed first) -├── .husky/pre-commit ← lint-staged: eslint + prettier + tsc --noEmit +├── PRD.md ← this document +├── ARCHITECTURE.md ← contributor guide to the structure below +├── .husky/ ← pre-commit: lint-staged (eslint --fix + prettier); commit-msg: commitlint ├── .github/workflows/ -│ ├── ci.yml ← PR/push: sync-check, lint, typecheck, vitest (+ win/mac build smoke) -│ └── 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 -├── 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) +│ ├── ci.yml ← PR/push: sync freshness, lint, boundary lint, typecheck, vitest, build +│ ├── release-pr.yml ← release-please: opens / updates the release PR +│ └── release-please.yml ← on release: build Win + Mac artifacts → GitHub Release +├── .dependency-cruiser.cjs ← process + topic boundary rules (`npm run lint:deps`) +├── .dependency-cruiser.severity.json ← one severity per rule family +├── src/main// ← main process: app, config, dcs, events, gateway, hid, +│ hid/calibration, nodes; each topic's index.ts is its only door +├── src/preload/index.ts ← the typed bridge: `window.skyhawk`, `push:batch` delivery +├── src/renderer/src// ← renderer: one store per topic, its views; app/ is the shell and +│ the tabs, app/ipc-adapter.ts the only file that talks to main +├── src/shared//ipc.ts ← IPC contract per topic; src/shared/app/ipc.ts assembles them +├── src/shared/firmware/ ← generated firmware reference (`npm run sync`), never hand-edited +├── src/shared/format/ ← pure text / unit / time formatting both processes use +├── src/test-fixtures/ ← shared test builders (nodes, config) +├── scripts/sync-a4ec.ts ← firmware sync (also a CI freshness step) +├── package.json electron.vite.config.ts tsconfig*.json eslint.config.mjs .prettierrc +└── README.md ``` ## 10. Milestones (Lean MVP) diff --git a/README.md b/README.md index 682ee74..6a3a522 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,16 @@ and from the installed app. Delete `.userdata/` to start clean. Set `SKYHAWK_USE to use another folder; it also works for a packaged build, e.g. to try a release against a scratch folder. Packaged builds without it use the normal OS app-data folder. +## Contributing + +[ARCHITECTURE.md](./ARCHITECTURE.md) explains how the code is laid out: the main / renderer +wall, the IPC contract, the topic folders and which way they may depend, and where each piece +of a new feature goes. + ## 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/eslint.config.mjs b/eslint.config.mjs index 91539a2..25ab889 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. An error since R3.4b. + files: ['src/renderer/**/*.{ts,tsx}'], + ignores: ['src/renderer/src/app/ipc-adapter.ts', 'src/renderer/test/**'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + 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..0d3a57c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,15 @@ "@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", "@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", @@ -31,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", @@ -48,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", @@ -299,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", @@ -344,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", @@ -763,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", @@ -1623,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", @@ -2466,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", @@ -2953,10 +3303,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 +3324,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", @@ -3228,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", @@ -3489,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", @@ -4037,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", @@ -4056,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", @@ -4123,7 +4562,14 @@ } } }, - "node_modules/decompress-response": { + "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", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", @@ -4208,6 +4654,74 @@ "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/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", @@ -4260,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", @@ -4569,6 +5090,33 @@ "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/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", @@ -5867,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", @@ -5982,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", @@ -6023,6 +6594,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 +6691,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 +6818,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 +6894,26 @@ "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-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", @@ -6552,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", @@ -6671,6 +7341,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", @@ -7139,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", @@ -7170,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", @@ -7280,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", @@ -7863,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", @@ -8091,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", @@ -8128,6 +8883,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 +9021,55 @@ "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/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", @@ -8274,6 +9092,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 +9364,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", @@ -8571,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", @@ -8578,10 +9429,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 +9678,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 +9895,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", @@ -9048,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", @@ -9096,6 +9978,27 @@ "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", + "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", @@ -9220,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", @@ -9250,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", @@ -9271,6 +10220,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", @@ -9454,7 +10434,6 @@ "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=20.18.1" } @@ -9741,6 +10720,32 @@ "@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", + "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", @@ -9754,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", @@ -9902,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", @@ -9911,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 138f5cf..c465963 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "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 .", "test": "vitest run", "sync": "tsx scripts/sync-a4ec.ts", @@ -32,10 +33,15 @@ "@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", "@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", @@ -44,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/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index c2ef6f1..2842414 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -1,14 +1,20 @@ /** - * 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): * 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. + * 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) @@ -28,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 = { @@ -38,11 +44,13 @@ 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, '..') -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, @@ -191,6 +199,62 @@ 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. + * 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, 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(' | ') + // ── A-4E-C controls ────────────────────────────────────────────────────────── interface RawOutput { @@ -436,6 +500,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 +527,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 +537,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 +589,26 @@ 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') + + // 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') @@ -522,7 +626,7 @@ function genNodeStatus(header: string): string { ` /** * Reserved DCS-BIOS identifiers for PanelBridge node-status reporting (#86). - * The decoder in shared/nodes.ts is written for protoVersion; reference.test.ts + * The decoder in main/nodes/node-status-wire.ts is written for protoVersion; reference.test.ts * asserts this matches SUPPORTED_NODE_PROTO so a firmware bump fails loudly. */ export const NODE_STATUS = { @@ -532,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} @@ -545,8 +684,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 @@ -566,7 +708,7 @@ ${faultEntries} /** * Reserved DCS-BIOS identifier for PanelBridge node telemetry (#216): one message per TELEM_n - * frame, forwarded unread. The decoder in shared/node-telem.ts is written for protoVersion; + * frame, forwarded unread. The decoder in main/nodes/node-telem.ts is written for protoVersion; * reference.test.ts asserts this matches SUPPORTED_TELEM_PROTO. */ export const NODE_TELEM = { @@ -574,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)} @@ -588,21 +757,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 +823,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( @@ -678,39 +860,89 @@ function genAxisCal(header: string, cpp: 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)`, - `${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 main/hid/calibration/codec.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')}, + /** 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 +/** 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 +958,7 @@ ${typeEntries} } as const export interface CalNackInfo { - name: string + name: CalNackName macro: string label: string } @@ -753,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} ` ) } @@ -789,12 +1026,82 @@ 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 { 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 { @@ -806,7 +1113,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/app/app-services.seam.test.ts b/src/main/app/app-services.seam.test.ts new file mode 100644 index 0000000..3a8aa01 --- /dev/null +++ b/src/main/app/app-services.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/app' +import { eventText } from '@shared/format' +import type { NodeMappingFile } from '@shared/nodes' +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: the journal'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/app/app-services.test.ts similarity index 85% rename from src/main/session.test.ts rename to src/main/app/app-services.test.ts index d9405c6..ed427e0 100644 --- a/src/main/session.test.ts +++ b/src/main/app/app-services.test.ts @@ -1,10 +1,12 @@ 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' - -// Session pulls in electron (debug log path), the serial port, the DCS transport and node-hid. +import { CAL_TYPE, buildFrame } from '../hid/calibration/codec' +import type { StreamRow } from '@shared/dcs' +import { eventText } from '@shared/format' +import type { LogEvent } from '@shared/events' +import type { CockpitSnapshot, NodeModel } from '@shared/nodes' +import { statusLine } from '../../test-fixtures/nodes' + +// 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' } })) @@ -73,7 +75,7 @@ class FakeTransport { } } -vi.mock('./serial', () => ({ +vi.mock('../gateway/serial-bridge', () => ({ SerialBridge: FakeSerial, PortNotFoundError: class extends Error {}, SIMGATEWAY_VID: 0x2e8a, @@ -81,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-file', () => ({ // Center_Armament and Right_Navigation are in this cockpit (#93: added). loadNodeMapping: () => ({ file: { version: 3, nodes: { 1: { added: true }, 2: { added: true } } } @@ -91,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[] = [] @@ -124,26 +126,27 @@ 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 { 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') +const { DEFAULT_CONFIG } = await import('@shared/config') const ascii = (s: string) => Buffer.from(s, 'ascii') function bridgeSession() { - const session = new Session(() => {}) - session.setConfig({ sourceMode: 'bridge' }) + 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 - session.start() + session.relay.start() const serial = FakeSerial.last! const transport = FakeTransport.last! transport.sent.length = 0 // drop the roster request seeded on open return { session, serial, transport } } -describe('Session de-mux boundary', () => { +describe('AppServices de-mux boundary', () => { beforeEach(() => { FakeSerial.last = undefined FakeTransport.last = undefined @@ -209,26 +212,32 @@ describe('node telemetry tap', () => { vi.useFakeTimers() try { const pushes: [string, unknown][] = [] - const session = new Session((c, p) => pushes.push([c, p])) - session.setConfig({ sourceMode: 'bridge' }) + const session = new AppServices((c, p) => pushes.push([c, p])) + session.settings.update({ sourceMode: 'bridge' }) 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 + // Each take of the pages redraws the Cockpit: NodeHub sends its snapshot as it takes them. + const snaps = () => + pushes.filter(([c]) => c === 'cockpit:snapshot').map(([, p]) => p as CockpitSnapshot) + const before = snaps().length + // A real bridge line: OpenSkyhawk node_telem.cpp:13 @ 22b6a87e (node 3, 12 V rail). const line = '_NODE_TELEM 030100E02E182EBC02\n' serial.dataCb(ascii(line)) expect(Buffer.concat(transport.sent)).toEqual(ascii(line)) // relay unchanged vi.advanceTimersByTime(250) - const logged = pushes.filter(([c]) => c === 'log:batch').flatMap(([, p]) => p as LogRow[]) + const logged = pushes.filter(([c]) => c === 'log:batch').flatMap(([, p]) => p as StreamRow[]) expect(logged.map((r) => r.name)).not.toContain('_NODE_TELEM') - const telem = pushes.filter(([c]) => c === 'nodes:telemetry') - expect(telem).toHaveLength(1) - expect(telem[0]![1]).toEqual([ + const perTake = snaps().length - before // the snapshots one take of the pages sends + expect(perTake).toBeGreaterThan(0) + // What `nodes:get` answers: the pages, taken by the node layer. + expect(session.nodes.snapshot().telemetry).toEqual([ expect.objectContaining({ nodeId: 3, isPdu: true, live: true }) ]) const model = pushes.filter(([c]) => c === 'nodes:model').at(-1)?.[1] as NodeModel @@ -237,12 +246,12 @@ describe('node telemetry tap', () => { kind: 'pdu' }) - // A second frame inside the same second waits for the throttle. + // A second frame inside the same second waits for the throttle, then is taken once. serial.dataCb(ascii(line)) vi.advanceTimersByTime(250) - expect(pushes.filter(([c]) => c === 'nodes:telemetry')).toHaveLength(1) + expect(snaps()).toHaveLength(before + perTake) vi.advanceTimersByTime(1000) - expect(pushes.filter(([c]) => c === 'nodes:telemetry')).toHaveLength(2) + expect(snaps()).toHaveLength(before + 2 * perTake) session.shutdown() } finally { vi.useRealTimers() @@ -259,14 +268,14 @@ 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.setConfig({ sourceMode: 'bridge' }) + session.settings.update({ sourceMode: 'bridge' }) session.open() - session.start() + session.relay.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! @@ -297,7 +306,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! @@ -312,18 +321,13 @@ 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] = {}) { +function launched( + config: Parameters['settings']['update']>[0] = {} +) { const pushes: Push[] = [] - const session = new Session((c, p) => pushes.push([c, p])) - session.setConfig(config) + const session = new AppServices((c, p) => pushes.push([c, p])) + session.settings.update(config) session.open() const serial = FakeSerial.last! const of = (channel: string) => pushes.filter(([c]) => c === channel).map(([, p]) => p) @@ -331,8 +335,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(() => { @@ -355,7 +359,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() }) @@ -363,13 +367,13 @@ 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') // 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') @@ -406,11 +410,14 @@ 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() + const before = of('cockpit:snapshot').length serial.dataCb(ascii('_NODE_TELEM 030100E02E182EBC02\n')) vi.advanceTimersByTime(250) - expect(of('nodes:telemetry')).toHaveLength(1) + // The pages are taken (`nodes:get`), and the Cockpit snapshot goes out with them. + expect(session.nodes.snapshot().telemetry).toEqual([expect.objectContaining({ nodeId: 3 })]) + expect(of('cockpit:snapshot').length).toBeGreaterThan(before) session.shutdown() } finally { vi.useRealTimers() @@ -424,36 +431,18 @@ describe('device link is independent of the relay (#69)', () => { serial.openCb('/dev/fake') vi.advanceTimersByTime(250) const models = of('nodes:model').length - const telem = of('nodes:telemetry').length serial.closeCb() + const snapshots = of('cockpit:snapshot').length // the close itself redraws the Cockpit serial.dataCb(ascii('_NODE_TELEM 030100E02E182EBC02\n')) // a late chunk vi.advanceTimersByTime(2000) expect(of('nodes:model'), 'no empty model pushed on close').toHaveLength(models) - expect(of('nodes:telemetry')).toHaveLength(telem) + expect(of('cockpit:snapshot'), 'the late pages are not taken').toHaveLength(snapshots) session.shutdown() } finally { vi.useRealTimers() } }) - 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 { @@ -480,7 +469,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') @@ -489,10 +478,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() @@ -515,7 +504,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') @@ -526,7 +515,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() @@ -535,13 +524,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.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() + session.connection.connect() expect(FakeSerial.last).not.toBe(serial) expect(session.status().device.state).toBe('scanning') session.shutdown() @@ -549,19 +538,19 @@ 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() }) it('does not reconnect a released link on Start', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') - session.link.disconnect() - expect(session.start().ok).toBe(false) + session.connection.disconnect() + 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() @@ -569,24 +558,24 @@ 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 + session.settings.update({ host: '10.0.0.2' }) // rebuilds the relay expect(session.status().running, 'only Stop ends a relay').toBe(true) session.shutdown() }) @@ -594,7 +583,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() @@ -610,7 +599,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])) @@ -623,7 +612,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])) @@ -640,7 +629,7 @@ describe('relay forwarding by source mode (#69)', () => { serial.openCb('/dev/fake') serial.dataCb(ascii('MASTER_ARM 1\n')) vi.advanceTimersByTime(50) - const logged = of('log:batch').flat() as LogRow[] + const logged = of('log:batch').flat() as StreamRow[] expect(logged).toContainEqual(expect.objectContaining({ name: 'MASTER_ARM', value: '1' })) expect(FakeTransport.last).toBeUndefined() session.shutdown() @@ -672,9 +661,9 @@ 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 }) + session.settings.update({ autoConnect: false }) expect(FakeTransport.last, 'toggling auto-connect must not restart the relay').toBe(first) session.shutdown() }) @@ -683,13 +672,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') @@ -703,8 +692,8 @@ describe('Gateway health (#94)', () => { FakeTransport.last = undefined }) - const rows = (session: InstanceType) => - session.events.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) + 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. */ const gatewayLine = (flags = 0): string => @@ -807,8 +796,8 @@ describe('event log (#64)', () => { FakeTransport.last = undefined }) - const rows = (session: InstanceType) => - session.events.snapshot().map((e) => `${e.source}:${e.name}: ${eventText(e)}`) + 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', () => { vi.useFakeTimers() @@ -840,7 +829,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() @@ -872,7 +861,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')) @@ -889,7 +878,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 @@ -909,8 +898,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', @@ -923,9 +912,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/app-services.ts b/src/main/app/app-services.ts new file mode 100644 index 0000000..50b4971 --- /dev/null +++ b/src/main/app/app-services.ts @@ -0,0 +1,213 @@ +// 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; 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 { Emit, RelayStatus } from '@shared/app' +import { app } from 'electron' +import { DcsConnection, DcsRelay, SessionRecorder, Stats, StreamLog } from '../dcs' +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, setDebugEnabled } from './debug-log' +import { storeFiles, storePaths, type StorePaths } from './user-data' + +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. */ + 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 + /** 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. */ + 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 readonly stats = new Stats() + + /** + * `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) + this.settings = new SettingsService({ paths, debugMode: (on) => setDebugEnabled(on), emit }) + this.journal = new EventJournal(emit, debugLog) + this.calibration = new HidCalibrationService(emit, { + write: (data) => this.connection.write(data), + serialNumber: () => this.connection.usbSerialNumber(), + cacheFile, + log: debugLog + }) + this.hid = new HidService(emit, debugLog) + this.connection = new GatewayConnection(emit, { + log: debugLog, + frames: this.calibration, + health: () => this.nodes.gatewayHealth(), + serialMonitor: () => this.serialMonitor.shown, + canRefresh: () => this.nodes.canRefresh() + }) + this.nodes = new NodeHub(emit, new NodeRoster(), (events) => this.journal.report(events), { + mappingFile, + log: debugLog, + link: this.connection + }) + this.serialMonitor = new SerialTrafficMonitor(emit) + this.lines = new DeviceLineTap(this.nodes) + this.recorder = new SessionRecorder() + this.streamLog = new StreamLog(emit, this.stats, this.journal) + this.dcs = new DcsConnection() + this.relay = new DcsRelay(emit, { + settings: this.settings.store, + saveSettings: () => void this.settings.edit({}), + device: this.connection, + dcs: this.dcs, + stats: this.stats, + log: this.streamLog, + recorder: this.recorder, + events: this.journal + }) + // The wiring order is load-bearing (R2.2b): it is the order these ran 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) → 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 + // 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 + // The DCS side keeps its order from before the split too (R2.3a). None of its constructors has + // a side effect. + // open the stream log's flush (33) first, then the connection's start + // relay Start Bridge / driven Replay refused unless the link is up → a running relay + // 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) + // 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`) + // The page's serial-monitor flag rides on `device:status` (R3.3): setting it pushes the status. + this.serialMonitor.onShownChange(() => this.connection.pushStatus()) + 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 they + // were handed on in before the split. + this.connection.onBytes((bytes) => this.relay.fromDevice(bytes)) + this.connection.onBytes((bytes) => this.lines.push(bytes)) + // 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 before the split. + const onError = (tag: string, message: string): void => { + this.stats.error() + this.journal.error(tag, message) + } + this.connection.onError(onError) + this.nodes.onError(onError) + 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.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)) + // The HID tab's live axes follow what calibration reports the device declares (R3.3), after + // `cal:workflow` has gone out, as the tab re-rendered on it. + this.calibration.onWorkflow((st) => this.hid.calibrationChanged(st.tab.snapshot)) + // The Cockpit snapshot follows the link too (strays and Attention only while it is up), after + // `device:status` has gone out, as the Cockpit re-rendered on it (R3.2). + this.connection.onStatus(() => this.nodes.linkStatus()) + // So do the relay's facts (blocked, waiting for the Gateway, the Link tile), R3.3. + this.connection.onStatus(() => this.relay.inputsChanged()) + // A settings change, in one listener so its order is explicit: auto-connect, then the relay. + this.settings.store.subscribe((config, prev) => { + if (prev.autoConnect !== config.autoConnect) { + this.connection.setAutoConnect(config.autoConnect) + } + this.relay.reconfigure(prev, config) + // The relay's facts follow the settings (the mode, Drive Gateway, the transport), R3.3. + this.relay.inputsChanged() + }) + } + + /** 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.settings.get().autoConnect) + } + + /** App quit. */ + shutdown(): void { + this.relay.stop() + this.connection.shutdown() + this.streamLog.close() + this.nodes.dispose() + } + + /** + * A renderer (re)loaded (`relay:status`, its first pull): what the page held for itself starts + * over, as its store did (N1) — the link's "last known" time and the serial monitor's flag. + */ + rendererLoaded(): void { + this.connection.rendererLoaded() + this.serialMonitor.rendererLoaded() + // …and it has the HID report and its live axes now, as it had its catalogue from a pull. + this.hid.rendererLoaded() + // …and the recording and the capture it shows. + this.relay.rendererLoaded() + } + + /** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */ + status(): RelayStatus { + const device = this.connection.statusPush() + const { running, relay } = this.relay.status() + return { + running, + relay, + relayFacts: this.relay.facts(), + device, + cal: this.calibration.workflow.state() + } + } +} diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts new file mode 100644 index 0000000..4e20504 --- /dev/null +++ b/src/main/app/bootstrap.ts @@ -0,0 +1,62 @@ +// 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, false), + imported: () => services.settings.imported(), + 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/debug.ts b/src/main/app/debug-log.ts similarity index 94% rename from src/main/debug.ts rename to src/main/app/debug-log.ts index 2c57fc0..3cd087b 100644 --- a/src/main/debug.ts +++ b/src/main/app/debug-log.ts @@ -11,10 +11,6 @@ export function setDebugEnabled(value: boolean): void { enabled = value } -export function isDebugEnabled(): boolean { - return enabled -} - export function debugLogPath(): string { return join(app.getPath('userData'), 'debug.log') } 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 new file mode 100644 index 0000000..3b1dcf7 --- /dev/null +++ b/src/main/app/index.ts @@ -0,0 +1,2 @@ +// 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..599a272 --- /dev/null +++ b/src/main/app/ipc.ts @@ -0,0 +1,133 @@ +// 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, 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' +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', + 'log:export', + 'events:get', + 'events:clear', + 'events:export', + 'debug:dump-ports', + 'debug:reveal', + 'capture:toggle', + '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', + // The node-mapping sequences (R3.2b), after every channel that was here before them. + 'node-mapping:add-all', + 'node-mapping:unhide', + // A form's settings change and the relay's on/off (R3.3), after every channel here before them. + 'config:edit', + 'relay:toggle' +] 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, () => { + // 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() + // …and the Cockpit snapshot starts over from what main holds, as the old page's store did (R3.2). + services.nodes.rendererLoaded() + // …and what the old page's store held for itself goes with it (R3.3, N1). + services.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 + 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/push-batch.test.ts b/src/main/app/push-batch.test.ts new file mode 100644 index 0000000..7574662 --- /dev/null +++ b/src/main/app/push-batch.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { deliverPushes, type PushBatch, type PushChannel } from '@shared/app' +import { batchPushes } from './push-batch' + +const STATS = { + bytesInPerSec: 0, + bytesOutPerSec: 0, + framesPerSec: 0, + commandsPerSec: 0, + commandsTotal: 0, + errors: 0, + uptimeSec: 0, + reconnects: 0 +} + +/** A batching emit over a recording `send`. */ +function rig() { + const sent: PushBatch[] = [] + const emit = batchPushes((b) => sent.push(b)) + return { sent, emit } +} + +const channels = (b: PushBatch) => b.map((p) => p.channel) + +describe('batchPushes (R3.4c)', () => { + it('sends every push of one turn as one batch, in the order main made them', async () => { + const { sent, emit } = rig() + emit('device:status', { state: 'connected' } as never) + emit('cal:workflow', { tab: {} } as never) + emit('cockpit:snapshot', { rev: 1 } as never) + emit('relay:facts', { mode: 'bridge' } as never) + expect(sent).toEqual([]) // nothing leaves before the turn is over + await Promise.resolve() + expect(sent.map(channels)).toEqual([ + ['device:status', 'cal:workflow', 'cockpit:snapshot', 'relay:facts'] + ]) + }) + + it('keeps each payload as given, a channel pushed twice included (the later one last)', async () => { + const { sent, emit } = rig() + const first = { tab: { n: 1 } } as never + const second = { tab: { n: 2 } } as never + emit('cal:workflow', first) + emit('cal:workflow', second) + await Promise.resolve() + expect(sent).toHaveLength(1) + expect(sent[0]![0]!.payload).toBe(first) + expect(sent[0]![1]!.payload).toBe(second) + }) + + it('a push in a later turn — a timer, or after an await — is a batch of its own', async () => { + const { sent, emit } = rig() + emit('stats:tick', STATS) + await Promise.resolve() + emit('stats:tick', STATS) + await new Promise((r) => setTimeout(r, 0)) + emit('relay:state', { running: true }) + await Promise.resolve() + expect(sent.map(channels)).toEqual([['stats:tick'], ['stats:tick'], ['relay:state']]) + }) + + it('goes out ahead of the reply an intent handler returns after pushing', async () => { + // ipcMain.handle replies from a continuation on the handler's result: after this flush. + const { sent, emit } = rig() + const order: string[] = [] + const handler = async () => { + emit('relay:state', { running: true }) + return { ok: true } + } + const reply = Promise.resolve(handler()).then(() => order.push(`reply after ${sent.length}`)) + await reply + expect(order).toEqual(['reply after 1']) + }) + + it('waits on no timer: the batch is out before a zero-delay timeout fires', async () => { + const { sent, emit } = rig() + let atTimer = -1 + setTimeout(() => (atTimer = sent.length), 0) + emit('relay:state', { running: false }) + await new Promise((r) => setTimeout(r, 0)) + expect(atTimer).toBe(1) + }) +}) + +describe('deliverPushes (R3.4c)', () => { + it('hands each push to its channel’s subscribers, in the batch’s order', () => { + const seen: string[] = [] + const subs = new Map void)[]>([ + ['device:status', [() => seen.push('status a'), () => seen.push('status b')]], + ['relay:facts', [() => seen.push('facts')]] + ]) + const batch = [ + { channel: 'relay:facts', payload: {} }, + { channel: 'device:status', payload: {} }, + { channel: 'stats:tick', payload: STATS }, // nobody subscribed: skipped + { channel: 'relay:facts', payload: {} } + ] as PushBatch + deliverPushes(batch, (ch) => subs.get(ch)) + expect(seen).toEqual(['facts', 'status a', 'status b', 'facts']) + }) + + it('a subscriber that throws costs only its own push; the error is rethrown at the end', () => { + const seen: string[] = [] + const boom = new Error('boom') + const subs = new Map void)[]>([ + [ + 'device:status', + [ + () => { + throw boom + } + ] + ], + ['relay:facts', [() => seen.push('facts')]] + ]) + const batch = [ + { channel: 'device:status', payload: {} }, + { channel: 'relay:facts', payload: {} } + ] as PushBatch + expect(() => deliverPushes(batch, (ch) => subs.get(ch))).toThrow(boom) + expect(seen).toEqual(['facts']) + }) +}) diff --git a/src/main/app/push-batch.ts b/src/main/app/push-batch.ts new file mode 100644 index 0000000..091d866 --- /dev/null +++ b/src/main/app/push-batch.ts @@ -0,0 +1,31 @@ +// One IPC message per main turn (R3.4c): every push made during one synchronous run goes out as a +// single `push:batch`. +// +// Main decides; the renderer draws what it is told. When one main event fans out to several +// pushes (a link change: `device:status`, `cal:workflow`, `cockpit:snapshot`, `relay:facts`), each +// sent as its own IPC message, the renderer committed between them and could show part of the +// change — the link down while the Cockpit still reads live. Batched, they arrive as one message, +// the preload hands them to their subscribers in one task, and React draws them as one frame. +// +// The flush is a microtask queued at a turn's first push: after the turn's synchronous code, so +// every push of that turn is in it, and before anything the turn scheduled with a timer or I/O — +// so no push waits on a timer. An intent's reply is sent from a continuation queued once its +// handler returns or its promise settles, after the pushes the handler made, so a push still +// reaches the renderer ahead of the reply that follows it, as it did when each push was sent at +// once. The order of the pushes, and each payload, are main's own; nothing is merged or dropped. +import type { Emit, PushBatch, PushItem } from '@shared/app' + +/** An `Emit` that collects each turn's pushes and hands them to `send` as one batch. */ +export function batchPushes(send: (batch: PushBatch) => void): Emit { + let turn: PushItem[] | undefined + return (channel, payload) => { + if (!turn) { + const batch: PushItem[] = (turn = []) + queueMicrotask(() => { + turn = undefined + send(batch) + }) + } + turn.push({ channel, payload } as PushItem) + } +} diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts new file mode 100644 index 0000000..03ceeb9 --- /dev/null +++ b/src/main/app/pushed-rules.seam.test.ts @@ -0,0 +1,159 @@ +// R3.3 SESSION-seam tests: the rules the renderer used to work out from main's pushes, now pushed +// by their owners — the link's facts (device:status), the relay's (relay:facts), the HID report's +// (hid:report) and the settings (config:changed) — through the services harness. R3.4a adds the +// link's action and Refresh (device:status), the Record visibility (relay:facts) and the PSU mode +// switch (config:edit). +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import { makeServices } from '../test-harness/services' + +const monitor = { paths: { files: { 'config.json': { sourceMode: 'monitor' } } } } + +describe('device:status carries the link’s facts', () => { + it('up, the board present, busy; the last-known time stamped on a drop', () => { + const app = makeServices(monitor) + expect(app.lastPush('device:status')).toMatchObject({ + state: 'scanning', + linkUp: false, + boardPresent: false, + busy: false, + serialMonitor: false + }) + app.serial.open() + expect(app.lastPush('device:status')).toMatchObject({ linkUp: true, boardPresent: true }) + expect(app.lastPush('device:status')?.linkDownAt).toBeUndefined() + const at = app.clock.now() + app.serial.close() + expect(app.lastPush('device:status')).toMatchObject({ + state: 'reconnecting', + linkUp: false, + linkDownAt: at + }) + app.serial.fail('Resource busy') + expect(app.lastPush('device:status')).toMatchObject({ + state: 'error', + busy: true, + linkDownAt: at + }) + }) + + it('the serial monitor’s flag comes back on device:status; a reloaded page starts over (N1)', () => { + const app = makeServices(monitor) + app.serial.open() + const n = app.pushesOf('device:status').length + app.invoke(CTRL.serialMonitor, true) + expect(app.pushesOf('device:status')).toHaveLength(n + 1) + expect(app.lastPush('device:status')).toMatchObject({ state: 'connected', serialMonitor: true }) + app.serial.close() + const reloaded = app.invoke(CTRL.relayStatus) + expect(reloaded.device).toMatchObject({ serialMonitor: false }) + expect(reloaded.device.linkDownAt, 'no last-known time for a reloaded page').toBeUndefined() + }) +}) + +describe('relay:facts: what the relay’s surfaces show', () => { + it('Bridge with no board: Start blocked; the board arriving lifts it', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) + expect(app.lastPush('relay:facts')?.stopped.startBlocked).toBe('bridge') + app.serial.open() + expect(app.lastPush('relay:facts')?.stopped.startBlocked).toBeUndefined() + expect(app.lastPush('relay:facts')?.running.waiting).toBe('dcs') + }) + + it('toggle: Start saves the settings first; a refused Start pushes no relay state', () => { + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) + const states = app.pushesOf('relay:state').length + expect(app.invoke(CTRL.relayToggle, false)).toEqual({ + ok: false, + error: 'Gateway is not connected' + }) + expect(app.pushesOf('relay:state')).toHaveLength(states) + expect(app.files.json('config.json')).toMatchObject({ sourceMode: 'bridge', commandPort: 7778 }) + expect(app.lastPush('config:changed')?.source).toBe('edit') + app.serial.open() + expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true }) + expect(app.lastPush('relay:state')).toEqual({ running: true, stream: 'connecting' }) + expect(app.invoke(CTRL.relayToggle, true)).toEqual({ ok: true }) + expect(app.lastPush('relay:state')).toEqual({ running: false }) + }) + + it('a form’s edit: built over the settings held, the port as typed; the facts follow', () => { + const app = makeServices(monitor) + app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) + expect(app.lastPush('relay:facts')?.endpoint).toEqual({ + pill: { text: 'unicast listen' }, + overview: { text: '239.255.50.10:5010 · local' } + }) + app.invoke(CTRL.configEdit, { port: '77a' }) + expect(app.files.json('config.json')).toMatchObject({ + transport: 'unicast-listen', + commandPort: 7778 + }) + expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7778) + }) +}) + +describe('R3.4a: the last renderer decisions, pushed by their owners', () => { + it('device:status: the link’s action, and Refresh only while a roster request can go', () => { + const app = makeServices(monitor) + expect(app.lastPush('device:status')).toMatchObject({ + state: 'scanning', + action: { offer: 'disconnect', intent: 'disconnect', disabled: true }, + canRefresh: false + }) + app.serial.open() + expect(app.lastPush('device:status')).toMatchObject({ + state: 'connected', + action: { offer: 'disconnect', intent: 'disconnect' }, + canRefresh: true + }) + app.serial.close() + app.serial.fail('EIO') + expect(app.lastPush('device:status')).toMatchObject({ + state: 'error', + action: { offer: 'retry', intent: 'connect' }, + canRefresh: false + }) + app.invoke(CTRL.deviceDisconnect) + expect(app.lastPush('device:status')).toMatchObject({ + state: 'disconnected', + action: { offer: 'connect', intent: 'connect' }, + canRefresh: false + }) + }) + + it('relay:facts: Record hidden and Capture shown in Replay, together; Settings’ row always', () => { + const app = makeServices(monitor) + expect(app.lastPush('relay:facts')).toMatchObject({ + recordShown: { connection: true, settings: true }, + captureShown: false + }) + app.invoke(CTRL.configEdit, { sourceMode: 'replay' }) + expect(app.lastPush('relay:facts')).toMatchObject({ + recordShown: { connection: false, settings: true }, + captureShown: true + }) + }) + + it('config:edit: a PSU mode switch and a typed limit over the setting held; refused = nothing', () => { + const custom = { a12: 40, w: 500 } + const app = makeServices({ + paths: { + files: { + 'config.json': { sourceMode: 'monitor', psu: { mode: 'preset', preset: 'nx650', custom } } + } + } + }) + app.invoke(CTRL.configEdit, { psuMode: 'custom' }) + const psu = { mode: 'custom', preset: 'nx650', custom } + expect(app.files.json('config.json')).toMatchObject({ psu }) + expect(app.lastPush('config:changed')).toMatchObject({ source: 'edit', config: { psu } }) + const n = app.pushesOf('config:changed').length + app.invoke(CTRL.configEdit, { psuLimit: { key: 'a5', text: '15' } }) + expect(app.files.json('config.json')).toMatchObject({ + psu: { custom: { a12: 40, a5: 15, w: 500 } } + }) + app.invoke(CTRL.configEdit, { psuLimit: { key: 'a5', text: '15x' } }) + expect(app.pushesOf('config:changed'), 'a refused limit pushes nothing').toHaveLength(n + 1) + }) +}) 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/app/user-data.ts b/src/main/app/user-data.ts new file mode 100644 index 0000000..c1ec416 --- /dev/null +++ b/src/main/app/user-data.ts @@ -0,0 +1,50 @@ +// Where lives. Packaged builds keep Electron's default; dev builds use +// /.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' + +export interface UserDataInputs { + isPackaged: boolean + /** app.getAppPath(): the repo root in dev. */ + appPath: string + env: Record + cwd: string +} + +/** The folder to pass to app.setPath('userData'), or null to keep the default. */ +export function userDataOverride({ isPackaged, appPath, env, cwd }: UserDataInputs): string | null { + const fromEnv = env[USER_DATA_ENV]?.trim() + if (fromEnv) return resolve(cwd, fromEnv) + 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 + +/** + * 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/app/window.ts b/src/main/app/window.ts new file mode 100644 index 0000000..2a348d6 --- /dev/null +++ b/src/main/app/window.ts @@ -0,0 +1,49 @@ +// 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 { PUSH_BATCH, type Emit } from '@shared/app' +import { batchPushes } from './push-batch' + +let mainWindow: BrowserWindow | null = null + +/** + * Every push: collected per main turn and sent as one `push:batch` message (R3.4c), so the + * renderer applies a turn's pushes together. A window that is gone by the flush gets nothing, as + * before. + */ +export const emit: Emit = batchPushes((batch) => mainWindow?.webContents.send(PUSH_BATCH, batch)) + +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/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/cal-cache.ts b/src/main/cal-cache.ts deleted file mode 100644 index f6d1abd..0000000 --- a/src/main/cal-cache.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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 -// 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, type CalCache } from '@shared/cal-cache' - -function cachePath(): string { - return join(app.getPath('userData'), 'calibration-cache.json') -} - -export function loadCalCache(): CalCache { - try { - const p = cachePath() - if (!existsSync(p)) return emptyCache() - return sanitizeCache(JSON.parse(readFileSync(p, 'utf8'))) - } catch { - return emptyCache() - } -} - -export function saveCalCache(cache: CalCache): void { - try { - writeFileSync(cachePath(), JSON.stringify(cache, null, 2)) - } catch { - // best-effort; see the note at the top of this file - } -} diff --git a/src/shared/config-file.test.ts b/src/main/config/config-file.test.ts similarity index 94% rename from src/shared/config-file.test.ts rename to src/main/config/config-file.test.ts index 9b3135e..f56f1cc 100644 --- a/src/shared/config-file.test.ts +++ b/src/main/config/config-file.test.ts @@ -1,19 +1,11 @@ import { describe, expect, it } from 'vitest' -import { - applyToConfig, - buildConfigFile, - CONFIG_FORMAT, - DEFAULT_APP_SETTINGS, - diffImport, - mergeCalibration, - parseConfigFile, - type ConfigSnapshot, - type SectionId -} 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 { 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 78% rename from src/shared/config-file.ts rename to src/main/config/config-file.ts index e2d0631..d1d0c16 100644 --- a/src/shared/config-file.ts +++ b/src/main/config/config-file.ts @@ -9,50 +9,42 @@ // 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 { sanitizeCache, type CachedAxis, type CalCache } from './cal-cache' +import { sanitizeConfig } from './settings-schema' +import { + CONFIG_FORMAT, + type AppConfig, + type AppSettings, + type ConfigFile, + type ConfigSnapshot, + type DiffRow, + type SectionDiff, + type SectionId +} from '@shared/config' +import type { CachedAxis, CalCache } from '@shared/hid/calibration' +import type { MappedNode, NodeDefaults, NodeMappingFile, PsuSetting } from '@shared/nodes' +import { onOff, plural, PSU_LIMITS } from '@shared/format' +import { sanitizeCache } from '../hid/calibration' import { effectiveMapping, importMapping, + isPlainObject, + PSU_PRESETS, sanitizeNodeMapping, - type MappedNode, - type NodeDefaults, - type NodeMappingFile -} from './node-mapping' -import { PSU_PRESETS, sanitizePsu, type PsuLimits, type PsuSetting } from './psu' + sanitizePsu +} from '../nodes' -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 -} +const CONFIG_FILE_VERSION = 1 -export type SectionId = keyof ConfigSnapshot export const SECTION_IDS: SectionId[] = ['app', 'nodeMapping', 'powerSupply', 'calibration'] -export const SECTION_TITLES: Record = { +const SECTION_TITLES: Record = { app: 'App settings', nodeMapping: 'Node mapping', powerSupply: 'Power supply', 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 @@ -84,9 +76,6 @@ export interface ParsedConfig { export type ParseResult = { ok: true; config: ParsedConfig } | { ok: false; error: string } -const isObject = (v: unknown): v is Record => - typeof v === 'object' && v !== null && !Array.isArray(v) - const NOT_CONFIG = 'This is not an OpenSkyhawk Client configuration file.' /** @@ -100,17 +89,17 @@ export function parseConfigFile(text: string): ParseResult { } catch { return { ok: false, error: `${NOT_CONFIG} It is not valid JSON.` } } - if (!isObject(raw) || raw.format !== CONFIG_FORMAT) return { ok: false, error: NOT_CONFIG } + if (!isPlainObject(raw) || raw.format !== CONFIG_FORMAT) return { ok: false, error: NOT_CONFIG } const version = raw.version if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { return { ok: false, error: `${NOT_CONFIG} Its version is missing.` } } - if (!isObject(raw.sections)) return { ok: false, error: `${NOT_CONFIG} It has no sections.` } + if (!isPlainObject(raw.sections)) return { ok: false, error: `${NOT_CONFIG} It has no sections.` } const sections: Partial = {} const skipped: string[] = [] for (const [key, value] of Object.entries(raw.sections)) { - if (!isObject(value) || !(SECTION_IDS as string[]).includes(key)) { + if (!isPlainObject(value) || !(SECTION_IDS as string[]).includes(key)) { skipped.push(key) continue } @@ -131,26 +120,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') - const APP_FIELDS: { key: keyof AppSettings; label: string; show?: (v: never) => string }[] = [ { key: 'sourceMode', label: 'Source mode' }, { key: 'transport', label: 'DCS transport' }, @@ -173,19 +142,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 @@ -315,23 +278,22 @@ export function diffImport( return out } -// ── 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. + * What applying the calibration section does, said in the review (ImportDialog.tsx before R3.3): + * it adds to the client's copy and never writes to the device. */ -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 } +const CAL_NOTE = + 'Adds these values to the client’s calibration copy — nothing is written to the device here. If the Gateway has lost calibration, the usual Review & restore offer appears on the HID tab; axes the device still holds are never overwritten.' + +/** The review's sections with what applying each does, where it says (a changing calibration). */ +export function withSectionNotes(sections: SectionDiff[]): SectionDiff[] { + return sections.map((s) => + s.id === 'calibration' && s.rows.length > 0 ? { ...s, note: CAL_NOTE } : s + ) } +// ── apply ──────────────────────────────────────────────────────────────────── + /** The AppConfig after applying the picked sections of an import: PSU and the rest separately. */ export function applyToConfig( config: AppConfig, @@ -344,6 +306,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/main/config/config-transfer.seam.test.ts b/src/main/config/config-transfer.seam.test.ts new file mode 100644 index 0000000..3103b93 --- /dev/null +++ b/src/main/config/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, 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) => ({ + 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/config-transfer.test.ts b/src/main/config/config-transfer.test.ts similarity index 88% rename from src/main/config-transfer.test.ts rename to src/main/config/config-transfer.test.ts index de65ecc..01b65ab 100644 --- a/src/main/config-transfer.test.ts +++ b/src/main/config/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' +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({ @@ -109,6 +110,17 @@ describe('ConfigTransfer', () => { expect(t.defaultFileName()).toBe('skyhawk-config-2026-10-16.json') }) + it('an export says what came of it: saved, cancelled or failed (R3.4a)', () => { + const t = transfer() + const file = join(dir, 'out.json') + expect(t.exportTo(file)).toEqual({ outcome: 'saved', path: file }) + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ client: '0.2.0' }) + expect(t.exportTo(undefined)).toEqual({ outcome: 'canceled' }) + const r = t.exportTo(join(dir, 'missing', 'out.json')) + expect(r.outcome).toBe('failed') + expect(r.outcome === 'failed' && r.error).toMatch(/^Could not write the file: ENOENT/) + }) + it('an invalid file is refused and leaves nothing to apply', () => { const t = transfer() expect(t.preview('{}', '/x/a.json')).toEqual({ diff --git a/src/main/config-transfer.ts b/src/main/config/config-transfer.ts similarity index 77% rename from src/main/config-transfer.ts rename to src/main/config/config-transfer.ts index cb9a5c1..35ab260 100644 --- a/src/main/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. @@ -9,29 +9,36 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { basename, join } from 'node:path' import type { AppConfig, + ConfigExportResult, ConfigImportApplyResult, ConfigImportOpen, - NodeMappingResult -} from '@shared/ipc' + ConfigSnapshot, + SectionId +} from '@shared/config' +import type { NodeDefaults, NodeMappingFile, NodeMappingResult } from '@shared/nodes' import { - appSettings, applyToConfig, + appSettings, buildConfigFile, diffImport, - mergeCalibration, parseConfigFile, SECTION_IDS, - type ConfigSnapshot, - type ParsedConfig, - type SectionId -} from '@shared/config-file' -import type { CalCache } from '@shared/cal-cache' -import type { NodeDefaults, NodeMappingFile } from '@shared/node-mapping' + withSectionNotes, + type ParsedConfig +} 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 /** Apply and persist a whole AppConfig (the config:set path). */ setConfig(config: AppConfig): AppConfig + /** + * An import was applied (R3.3): the renderer is told the settings, and its forms start over + * from them — as they did from the import's answer, whatever sections it held. + */ + imported?(): void nodes: { mappingFile(): NodeMappingFile registryDefaults(): Map @@ -48,8 +55,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 } @@ -84,6 +89,23 @@ export class ConfigTransfer { return `skyhawk-config-${this.now().toISOString().slice(0, 10)}.json` } + /** + * Write the export to the file the save dialog picked, and say what came of it (R3.4a: the + * renderer read that from which field was set). No file: the dialog was cancelled. + */ + exportTo(filePath: string | undefined): ConfigExportResult { + if (!filePath) return { outcome: 'canceled' } + try { + writeFileSync(filePath, this.exportText()) + return { outcome: 'saved', path: filePath } + } catch (err) { + return { + outcome: 'failed', + error: `Could not write the file: ${err instanceof Error ? err.message : err}` + } + } + } + /** Validate a file's text and preview it against the current setup. */ preview(text: string, path: string): ConfigImportOpen { const parsed = parseConfigFile(text) @@ -92,11 +114,13 @@ export class ConfigTransfer { return { status: 'error', error: parsed.error } } const { config } = parsed - const sections = diffImport( - this.deps.nodes.registryDefaults(), - this.snapshot(), - config.sections, - this.deps.nodes.onlineIds() + const sections = withSectionNotes( + diffImport( + this.deps.nodes.registryDefaults(), + this.snapshot(), + config.sections, + this.deps.nodes.onlineIds() + ) ) const token = randomUUID() this.pending = { token, config } @@ -143,8 +167,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)}` } } } @@ -160,6 +183,7 @@ export class ConfigTransfer { config = this.deps.setConfig(applyToConfig(config, imported, picked)) } this.pending = undefined + this.deps.imported?.() const applied = SECTION_IDS.filter((id) => picked.has(id)) return { ok: true, applied, ...(backupPath ? { backupPath } : {}), config } } diff --git a/src/main/config/index.ts b/src/main/config/index.ts new file mode 100644 index 0000000..96c3d16 --- /dev/null +++ b/src/main/config/index.ts @@ -0,0 +1,6 @@ +// config: settings and the configuration file transfer. Exports only what other topics use +// (arch-phase0-v2 §2.1). +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..50f431c --- /dev/null +++ b/src/main/config/ipc.ts @@ -0,0 +1,59 @@ +// 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 } from 'node:fs' +import type { IpcRegistrar } from '@shared/app' +import { + CONFIG_CTRL, + type AppConfig, + type ConfigEdit, + 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.view()) + ipc.handle(CONFIG_CTRL.configSet, (_e, patch: Partial) => settings.apply(patch)) + ipc.handle(CONFIG_CTRL.configEdit, (_e, edit: ConfigEdit) => settings.edit(edit)) + ipc.handle( + CONFIG_CTRL.configExport, + async (): Promise => + transfer.exportTo(await dialogs.saveConfig(transfer.defaultFileName())) + ) + 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/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-edit.test.ts b/src/main/config/settings-edit.test.ts new file mode 100644 index 0000000..ad0fdc6 --- /dev/null +++ b/src/main/config/settings-edit.test.ts @@ -0,0 +1,147 @@ +// R3.3: the settings forms' save, built in main (store.ts buildConfig before), and the settings +// pushed back on `config:changed`. +import { mkdtempSync, 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, + PSU_VIEW_DEFAULT, + type ConfigChanged, + type SectionDiff +} from '@shared/config' +import { storePaths } from '../app/user-data' +import { withSectionNotes } from './config-file' +import { buildConfig, editedConfig, formOf, savedEdit, withPsuLimit } from './settings-form' +import { SettingsService } from './settings-service' + +describe('the forms’ save (buildConfig)', () => { + it('saves every field the forms edit; a port that isn’t a number saves as 7778 (N5)', () => { + const form = formOf(DEFAULT_CONFIG) + expect(buildConfig({ ...form, port: '7779' }).commandPort).toBe(7779) + for (const junk of ['', '77a', 'abc', '0']) { + expect(buildConfig({ ...form, port: junk }).commandPort, junk).toBe(7778) + } + expect(Object.keys(buildConfig(form)).sort()).toEqual( + [ + 'attentionBanner', + 'autoConnect', + 'commandPort', + 'debugMode', + 'host', + 'psu', + 'replayDriveSerial', + 'sourceMode', + 'transport' + ].sort() + ) + }) + + it('read-modify-write: a change over the settings held, the rest as saved', () => { + const current = { ...DEFAULT_CONFIG, host: '10.0.0.7', commandPort: 7790 } + expect(editedConfig(current, { sourceMode: 'bridge' })).toMatchObject({ + sourceMode: 'bridge', + host: '10.0.0.7', + commandPort: 7790 + }) + expect(editedConfig(current, { port: '77a' }).commandPort).toBe(7778) + expect(editedConfig(current, {})).toEqual(buildConfig(formOf(current))) + }) + + it('a PSU mode switch keeps the preset and the custom limits (R3.4a: `{ ...psu, mode }`)', () => { + const psu = { mode: 'preset' as const, preset: 'nx650' as const, custom: { a12: 40, w: 500 } } + const current = { ...DEFAULT_CONFIG, psu } + expect(editedConfig(current, { psuMode: 'custom' }).psu).toEqual({ ...psu, mode: 'custom' }) + const custom = { ...current, psu: { ...psu, mode: 'custom' as const } } + expect(editedConfig(custom, { psuMode: 'preset' }).psu).toEqual(psu) + expect(Object.keys(editedConfig(current, { psuMode: 'custom' }))).not.toContain('psuMode') + }) + + it('a PSU limit as typed: refused, removed when empty, set otherwise (R3.4a: onType)', () => { + const psu = { mode: 'custom' as const, preset: 'nx650' as const, custom: { a12: 40, w: 500 } } + expect(withPsuLimit(psu, 'a5', '15')).toEqual({ ...psu, custom: { a12: 40, a5: 15, w: 500 } }) + expect(withPsuLimit(psu, 'a12', '')).toEqual({ ...psu, custom: { w: 500 } }) + expect(withPsuLimit(psu, 'a12', ' ')).toEqual({ ...psu, custom: { w: 500 } }) + for (const junk of ['abc', '-5', '0', '20000', '1e3', '4.']) { + expect(withPsuLimit(psu, 'w', junk), junk).toBeUndefined() + } + expect(psu.custom, 'the setting held is never changed in place').toEqual({ a12: 40, w: 500 }) + const current = { ...DEFAULT_CONFIG, psu } + expect(savedEdit(current, { psuLimit: { key: 'w', text: '400' } })?.psu).toEqual({ + ...psu, + custom: { a12: 40, w: 400 } + }) + expect( + savedEdit(current, { psuLimit: { key: 'w', text: 'x' } }), + 'nothing to save' + ).toBeUndefined() + expect(savedEdit(current, { host: 'h' })).toEqual(editedConfig(current, { host: 'h' })) + }) +}) + +describe('SettingsService: edits saved and pushed back (config:changed)', () => { + let dir = '' + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skyhawk-settings-edit-')) + }) + afterEach(() => rmSync(dir, { recursive: true, force: true })) + + function service() { + const pushed: ConfigChanged[] = [] + const s = new SettingsService({ + paths: storePaths(() => dir), + debugMode: () => {}, + emit: (_ch, payload) => pushed.push(payload) + }) + return { s, pushed } + } + + it('an edit is built over the settings, saved, and comes back as an edit', () => { + const { s, pushed } = service() + s.update({ host: '10.0.0.7' }) + const cfg = s.edit({ port: '7781' }) + expect(cfg).toMatchObject({ host: '10.0.0.7', commandPort: 7781 }) + expect(JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'))).toMatchObject({ + commandPort: 7781 + }) + expect(pushed).toEqual([{ config: cfg, psu: PSU_VIEW_DEFAULT, source: 'edit' }]) + }) + + it('a refused PSU limit saves nothing and pushes nothing (R3.4a)', () => { + const { s, pushed } = service() + s.edit({ psu: { mode: 'custom', preset: 'nx650', custom: { w: 400 } } }) + const before = readFileSync(join(dir, 'config.json'), 'utf8') + expect(s.edit({ psuLimit: { key: 'w', text: '40x' } })).toBe(s.get()) + expect(pushed).toHaveLength(1) + expect(readFileSync(join(dir, 'config.json'), 'utf8')).toBe(before) + s.edit({ psuLimit: { key: 'w', text: '' } }) + expect(pushed.at(-1)?.config.psu.custom).toEqual({}) + }) + + it('the power supply comes back resolved; an import says so, once it is applied', () => { + const { s, pushed } = service() + s.edit({ psu: { mode: 'custom', preset: 'nx650', custom: { w: 400 } } }) + expect(pushed.at(-1)?.psu).toMatchObject({ name: 'PSU', limits: { w: 400 } }) + s.apply({ host: '10.0.0.2' }, false) + expect(pushed, 'an import’s own save is not pushed').toHaveLength(1) + s.imported() + expect(pushed.at(-1)).toMatchObject({ source: 'import', config: { host: '10.0.0.2' } }) + expect(s.view()).toEqual({ config: s.get(), psu: pushed.at(-1)!.psu }) + }) +}) + +describe('the import review’s section notes (ImportDialog.tsx before R3.3)', () => { + const sec = (id: SectionDiff['id'], rows: number): SectionDiff => ({ + id, + title: id, + summary: '', + rows: Array.from({ length: rows }, () => ({ k: 'k', from: 'a', to: 'b' })) + }) + + it('a changing calibration section says it never writes to the device; nothing else does', () => { + const out = withSectionNotes([sec('calibration', 2), sec('calibration', 0), sec('app', 1)]) + expect(out[0]!.note).toMatch(/^Adds these values to the client’s calibration copy/) + expect(out[1]!.note).toBeUndefined() + expect(out[2]!.note).toBeUndefined() + }) +}) diff --git a/src/main/config/settings-form.ts b/src/main/config/settings-form.ts new file mode 100644 index 0000000..68f2dfd --- /dev/null +++ b/src/main/config/settings-form.ts @@ -0,0 +1,82 @@ +// The settings as the forms edit them (pure, no Node/Electron deps): what the renderer's store held +// and rebuilt into a settings save before R3.3 (store.ts buildConfig). Main builds the save now, +// over what it holds; the forms send only what changed (ConfigEdit), the command port as typed. +import type { AppConfig, ConfigEdit } from '@shared/config' +import { parsePsuLimit, type PsuLimits, type PsuSetting } from '@shared/nodes' + +/** The edited fields, with the command port as text (what the Connection forms show). */ +export type ConfigForm = Required> + +/** The forms' fields as the settings hold them: the port as its digits. */ +export function formOf(cfg: AppConfig): ConfigForm { + return { + sourceMode: cfg.sourceMode, + transport: cfg.transport, + host: cfg.host, + port: String(cfg.commandPort), + autoConnect: cfg.autoConnect, + replayDriveSerial: cfg.replayDriveSerial, + debugMode: cfg.debugMode, + attentionBanner: cfg.attentionBanner, + psu: cfg.psu + } +} + +/** + * The settings save the forms make: every field they edit, saved whole. A port that isn't a + * number, or is empty, saves as 7778 — while the field keeps showing what was typed (N5). + */ +export function buildConfig(s: ConfigForm): Partial { + return { + sourceMode: s.sourceMode, + transport: s.transport, + host: s.host, + commandPort: Number(s.port) || 7778, + autoConnect: s.autoConnect, + replayDriveSerial: s.replayDriveSerial, + debugMode: s.debugMode, + attentionBanner: s.attentionBanner, + psu: s.psu + } +} + +/** + * A form's change, read-modify-write: the forms' fields as the settings hold them, the change over + * them, built into the save (buildConfig). A PSU mode switch keeps the rest of the setting — the + * preset and the custom limits — as it is (R3.4a: Settings › Cockpit's `{ ...psu, mode }`). + */ +export function editedConfig(current: AppConfig, edit: ConfigEdit): Partial { + const { psuMode: mode, psuLimit: _limit, ...fields } = edit + const form = { ...formOf(current), ...fields } + return buildConfig(mode === undefined ? form : { ...form, psu: { ...form.psu, mode } }) +} + +/** + * One PSU limit as typed, over the setting (R3.4a: Settings › Cockpit's onType before). A value + * that is not a limit is refused (undefined: nothing is saved); empty removes the limit; anything + * else sets it. The field keeps showing what was typed either way: that text is the form's. + */ +export function withPsuLimit( + psu: PsuSetting, + k: keyof PsuLimits, + text: string +): PsuSetting | undefined { + const n = parsePsuLimit(text) + if (n === null) return undefined + const custom = { ...psu.custom } + if (n === undefined) delete custom[k] + else custom[k] = n + return { ...psu, custom } +} + +/** + * A form's change as SettingsService saves it (`config:edit`): editedConfig, with a typed PSU limit + * applied over the setting held. undefined = refused, nothing to save — as the form sent nothing + * for it before. + */ +export function savedEdit(current: AppConfig, edit: ConfigEdit): Partial | undefined { + const { psuLimit: limit, ...rest } = edit + if (limit === undefined) return editedConfig(current, rest) + const psu = withPsuLimit(current.psu, limit.key, limit.text) + return psu && editedConfig(current, { ...rest, psu }) +} diff --git a/src/shared/config.test.ts b/src/main/config/settings-schema.test.ts similarity index 95% rename from src/shared/config.test.ts rename to src/main/config/settings-schema.test.ts index a05411f..5ce4e5a 100644 --- a/src/shared/config.test.ts +++ b/src/main/config/settings-schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_CONFIG, sanitizeConfig } from './ipc' +import { DEFAULT_CONFIG } from '@shared/config' +import { sanitizeConfig } from './settings-schema' describe('sanitizeConfig', () => { it('falls back to defaults for empty / junk input', () => { diff --git a/src/main/config/settings-schema.ts b/src/main/config/settings-schema.ts new file mode 100644 index 0000000..0a9fe97 --- /dev/null +++ b/src/main/config/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 '@shared/config' +import { sanitizePsu } from '../nodes' + +/** 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/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 new file mode 100644 index 0000000..9735dc6 --- /dev/null +++ b/src/main/config/settings-service.ts @@ -0,0 +1,127 @@ +// 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 type { Emit } from '@shared/app' +import { + DEFAULT_CONFIG, + type AppConfig, + type ConfigChanged, + type ConfigEdit, + type ConfigView, + type SettingsStore +} from '@shared/config' +import { resolvePsu } from '../nodes' +import { sanitizeConfig } from './settings-schema' +import { savedEdit } from './settings-form' +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 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 { + configFile(paths).write(config) +} + +/** A settings store of its own, starting at `initial` (a copy). Not a singleton: each owner builds one. */ +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 + /** The renderer push (`config:changed`, R3.3); none, for an owner that only stores settings. */ + emit?: Emit<'config:changed'> +} + +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() + } + + /** The settings and what they resolve to (`config:get`, R3.3). */ + view(): ConfigView { + const config = this.get() + return { config, psu: resolvePsu(config.psu) } + } + + /** + * 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, a form's edit and a config + * import. The renderer is told (`config:changed`) unless `push` is off — an import tells it once + * it is done (imported()). + */ + apply(patch: Partial, push = true): AppConfig { + const config = this.update(patch) + this.deps.debugMode(config.debugMode) + saveConfig(config, this.deps.paths) + if (push) this.changed('edit') + return config + } + + /** + * A form's change (`config:edit`, R3.3): what the renderer's store rebuilt into a save before — + * read-modify-write over the settings held here, the port as typed (settings-form.ts). + */ + edit(edit: ConfigEdit): AppConfig { + const save = savedEdit(this.get(), edit) + // A refused PSU limit saves and pushes nothing: the form sent nothing for it before (R3.4a). + return save === undefined ? this.get() : this.apply(save) + } + + /** A config import is applied: the forms start over from the settings, as from its answer. */ + imported(): void { + this.changed('import') + } + + private changed(source: ConfigChanged['source']): void { + this.deps.emit?.('config:changed', { ...this.view(), source }) + } + + /** 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/shared/dcsbios.test.ts b/src/main/dcs/bios-protocol.test.ts similarity index 90% rename from src/shared/dcsbios.test.ts rename to src/main/dcs/bios-protocol.test.ts index 4c42f78..0114153 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, 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[] { @@ -65,12 +59,6 @@ describe('StringRegion', () => { }) }) -describe('formatCommand', () => { - it('builds an identifier + arg line', () => { - expect(formatCommand('MASTER_ARM', 1)).toBe('MASTER_ARM 1\n') - }) -}) - describe('parseCommand', () => { it('splits identifier from arg on the first space', () => { expect(parseCommand('TACAN_CHAN_SEL 64')).toEqual({ identifier: 'TACAN_CHAN_SEL', arg: '64' }) diff --git a/src/shared/dcsbios.ts b/src/main/dcs/bios-protocol.ts similarity index 68% rename from src/shared/dcsbios.ts rename to src/main/dcs/bios-protocol.ts index 6e7da42..f3f653d 100644 --- a/src/shared/dcsbios.ts +++ b/src/main/dcs/bios-protocol.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 @@ -129,51 +125,23 @@ export class StringRegion { } } -/** Build a DCS-BIOS command line ("IDENTIFIER ARG\n"). */ -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 86% rename from src/shared/capture.test.ts rename to src/main/dcs/capture-file.test.ts index fe39146..f316fc2 100644 --- a/src/shared/capture.test.ts +++ b/src/main/dcs/capture-file.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-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 81% rename from src/shared/capture.ts rename to src/main/dcs/capture-file.ts index 8353b35..7ae152b 100644 --- a/src/shared/capture.ts +++ b/src/main/dcs/capture-file.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 '@shared/dcs' export interface CaptureFile extends CaptureMeta { format: 'skyhawk-capture/1' 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..79d8ee6 --- /dev/null +++ b/src/main/dcs/dcs-relay.ts @@ -0,0 +1,304 @@ +// 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 { RecordState, RelayFacts, RelayResult, RelayState, ReplayLoad } from '@shared/dcs' +import type { Emit, RelayStatus } from '@shared/app' +import type { LogEventInput } from '@shared/events' +import type { DeviceStatus } from '@shared/gateway' +import { isLinkUp } from '../gateway' +import { DcsBiosProtocol } from './bios-protocol' +import type { DcsConnection } from './dcs-connection' +import { relayContext, relayEvents } from './relay-events' +import { + captureView, + deviceToDcs, + exportToDevice, + relayConfigChanged, + relayFacts, + replayView, + 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' + +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 +} + +/** The settings, as the relay reads them: the SettingsStore, read when it needs them. */ +interface RelaySettings { + getState(): RelayOptions +} + +export interface DcsRelayDeps { + /** The settings the relay is built from. */ + settings: RelaySettings + /** + * Save the settings as they stand: Start's first step (the renderer saved its forms before + * asking for Start; config:edit with nothing changed, R3.3). + */ + saveSettings(): void + 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[] = [] + /** The recording and the capture as this page set them (N1: a reloaded page starts over). */ + private recordingView: RelayFacts['recording'] = { on: false } + private replayLoaded?: RelayFacts['replay'] + /** The `relay:facts` last pushed, to push only a change. */ + private factsKey?: string + 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.settings.getState() + } + + /** + * 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 } + } + } + + /** + * The Relay pill and the Start / Stop button (`relay:toggle`, R3.3): what the renderer's store + * did on a click — Stop if it showed the relay running, else save the settings, then Start. + * `relaying` is what the renderer showed; it takes back its own pending Start if refused. + */ + toggle(relaying: boolean): RelayResult { + if (relaying) return this.stop() + this.deps.saveSettings() + return this.start() + } + + /** 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 } + } + + // ── what the relay's surfaces show (relay:facts, R3.3) ───────────────────── + + /** The facts as they stand: the rehydrate bundle's, and what `relay:facts` pushes. */ + facts(): RelayFacts { + const deviceState = this.device.deviceStatus().state + return relayFacts({ + config: this.config, + stream: this.relay.stream, + deviceState, + linkUp: isLinkUp(deviceState), + recording: this.recordingView, + replay: this.replayLoaded + }) + } + + /** The link or the settings changed (AppServices): push the facts if they changed with them. */ + inputsChanged(): void { + this.pushFacts() + } + + /** The capture toggle answered (`capture:toggle`): the Record controls follow its answer. */ + showCapture(state: RecordState): void { + this.recordingView = captureView(state) + this.pushFacts() + } + + /** A capture was opened (`replay:open`): the Capture card follows its answer. */ + showReplay(load: ReplayLoad): void { + this.replayLoaded = replayView(load, this.replayLoaded) + this.pushFacts() + } + + /** + * A renderer (re)loaded (`relay:status`): the recording and the capture it shows start over, as + * its store did, whatever main is doing (N1). The bundle carries the facts. + */ + rendererLoaded(): void { + this.recordingView = { on: false } + this.replayLoaded = undefined + this.factsKey = JSON.stringify(this.facts()) + } + + private pushFacts(): void { + const facts = this.facts() + const key = JSON.stringify(facts) + if (key === this.factsKey) return + this.factsKey = key + this.emit('relay:facts', facts) + } + + // ── 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) + this.pushFacts() + } +} diff --git a/src/main/dcs/index.ts b/src/main/dcs/index.ts new file mode 100644 index 0000000..99539a3 --- /dev/null +++ b/src/main/dcs/index.ts @@ -0,0 +1,7 @@ +// dcs: the DCS-BIOS side of the relay. Exports only what other topics use (arch-phase0-v2 §2.1). +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' +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..e272559 --- /dev/null +++ b/src/main/dcs/ipc.ts @@ -0,0 +1,67 @@ +// 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 RecordState, 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.relayToggle, (_e, relaying: boolean) => relay.toggle(relaying)) + + ipc.handle(DCS_CTRL.logExport, async (_e, text: string) => { + const filePath = await dialogs.saveLog() + if (!filePath) return {} + writeFileSync(filePath, text) + return { path: filePath } + }) + + // The Record controls and the Capture card show the answer (relay:facts, R3.3). + ipc.handle(DCS_CTRL.captureToggle, async (): Promise => { + const state = await toggleCapture() + relay.showCapture(state) + return state + }) + const toggleCapture = 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 load = await openReplay() + relay.showReplay(load) + return load + }) + const openReplay = 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/relay-events.ts b/src/main/dcs/relay-events.ts new file mode 100644 index 0000000..fb592d0 --- /dev/null +++ b/src/main/dcs/relay-events.ts @@ -0,0 +1,83 @@ +// Relay events (pure, no Node/Electron deps): the event-log rows the relay and the DCS-BIOS stream +// make as they start, stop, come up and drop. +import type { AppConfig, SourceMode } from '@shared/config' +import { EXPORT_MULTICAST_ADDR, EXPORT_PORT, type RelayState } from '@shared/dcs' +import type { 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 +} + +/** 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', + 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/relay-rules.test.ts b/src/main/dcs/relay-rules.test.ts new file mode 100644 index 0000000..967f0ba --- /dev/null +++ b/src/main/dcs/relay-rules.test.ts @@ -0,0 +1,139 @@ +// R3.3: what the relay's surfaces show, decided here and pushed on relay:facts (the renderer +// decided them before). The wording stays in the renderer; gateway/link-view.test.ts runs these +// rules through it. +import { describe, expect, it } from 'vitest' +import { DEFAULT_CONFIG } from '@shared/config' +import { RELAY_FACTS_IDLE } from '@shared/dcs' +import { + canRecord, + captureView, + overviewEndpoint, + overviewLink, + captureShown, + pillEndpoint, + recordShown, + relayFacts, + relayNote, + relayTip, + replayView +} from './relay-rules' + +const facts = (over: Partial[0]> = {}) => + relayFacts({ + config: DEFAULT_CONFIG, + deviceState: 'no-device', + linkUp: false, + recording: { on: false }, + ...over + }) + +describe('relayFacts', () => { + it('the renderer starts from the facts for the default settings, no device, stopped', () => { + expect(facts()).toEqual(RELAY_FACTS_IDLE) + }) + + it('each side of the on/off: a pending Start reads as running, with the stream not up yet', () => { + const f = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'bridge' } }) + expect(f.stopped).toMatchObject({ tip: 'stopped', startBlocked: 'bridge', waiting: 'none' }) + expect(f.running).toMatchObject({ tip: 'waiting-device', waiting: 'device' }) + expect(f.running.startBlocked, 'a running relay is never blocked').toBeUndefined() + const replay = facts({ + config: { ...DEFAULT_CONFIG, sourceMode: 'replay', replayDriveSerial: true } + }) + expect(replay.stopped.startBlocked).toBe('replay') + expect(replay.running.tip).toBe('replay-playing') + }) + + it('carries the mode and the transport it was judged against (R3.4c)', () => { + const f = facts({ + config: { ...DEFAULT_CONFIG, sourceMode: 'replay', transport: 'unicast-listen' } + }) + expect(f).toMatchObject({ mode: 'replay', transport: 'unicast-listen', captureShown: true }) + expect(f.endpoint.pill).toEqual(pillEndpoint('unicast-listen')) + }) +}) + +describe('N3: waiting for the Gateway is Bridge only; a driven Replay is blocked, never waiting', () => { + it.each(['monitor', 'replay'] as const)('%s, no board, running', (mode) => { + const o = { + mode, + relaying: true, + stream: 'connected' as const, + deviceState: 'no-device' as const + } + expect(relayNote(o)).toBe('none') + expect(relayTip(o)).not.toBe('waiting-device') + }) +}) + +describe('N2: the Overview says "relay waiting" whenever a relay runs with the link down', () => { + it('in every mode, Monitor included', () => { + expect(overviewLink(true, false)).toBe('waiting') + expect(overviewLink(true, true)).toBe('gateway-relaying') + expect(overviewLink(false, true)).toBe('gateway-stopped') + expect(overviewLink(false, false)).toBe('stopped') + }) +}) + +describe('X9: each surface’s own endpoint label', () => { + it('the pill: host and port as typed, the multicast group, or "unicast listen"', () => { + expect(pillEndpoint('tcp-to-host')).toEqual({ hostPort: { suffix: '' } }) + expect(pillEndpoint('loopback-multicast')).toEqual({ text: '239.255.50.10:5010' }) + expect(pillEndpoint('unicast-listen')).toEqual({ text: 'unicast listen' }) + }) + + it('the Overview: host and port as typed, else the multicast group — for Unicast too', () => { + expect(overviewEndpoint('tcp-to-host')).toEqual({ hostPort: { suffix: ' · remote' } }) + expect(overviewEndpoint('loopback-multicast')).toEqual({ text: '239.255.50.10:5010 · local' }) + expect(overviewEndpoint('unicast-listen')).toEqual({ text: '239.255.50.10:5010 · local' }) + }) +}) + +describe('X12: each surface’s own Record rule', () => { + it('the Connection card only while relaying; Settings › Connection whenever', () => { + expect(canRecord(true)).toEqual({ connection: true, settings: true }) + expect(canRecord(false)).toEqual({ connection: false, settings: true }) + }) +}) + +describe('X12-style: each surface’s own Record visibility (R3.4a)', () => { + it('the Connection card hidden in Replay; Settings › Connection’s row always shown', () => { + expect(recordShown('bridge')).toEqual({ connection: true, settings: true }) + expect(recordShown('monitor')).toEqual({ connection: true, settings: true }) + expect(recordShown('replay')).toEqual({ connection: false, settings: true }) + const f = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'replay' } }) + expect(f.recordShown, 'pushed with the facts, the same for both sides').toEqual({ + connection: false, + settings: true + }) + }) + + it('the Capture card is the Record card’s mirror, in the same facts', () => { + for (const mode of ['bridge', 'monitor', 'replay'] as const) { + expect(captureShown(mode), mode).toBe(mode === 'replay') + const f = facts({ config: { ...DEFAULT_CONFIG, sourceMode: mode } }) + expect(f.captureShown, mode).toBe(!f.recordShown.connection) + } + }) +}) + +describe('the recording and the capture, as the page set them', () => { + it('from the toggle’s and the open’s answers; a capture that did not load keeps the last', () => { + expect(captureView({ recording: true, path: '/a' })).toEqual({ on: true, events: undefined }) + expect(captureView({ recording: false, path: '/a', events: 9 })).toEqual({ + on: false, + events: 9 + }) + const one = replayView( + { loaded: true, path: '/c/a.skycap', events: 3, durationMs: 1500 }, + undefined + ) + expect(one).toEqual({ file: '/c/a.skycap', events: 3, durationMs: 1500 }) + expect(replayView({ loaded: false }, one)).toBe(one) + expect(replayView({ loaded: true, path: '/b' }, one)).toEqual({ + file: '/b', + events: 0, + durationMs: 0 + }) + }) +}) diff --git a/src/main/dcs/relay-rules.ts b/src/main/dcs/relay-rules.ts new file mode 100644 index 0000000..beddc1c --- /dev/null +++ b/src/main/dcs/relay-rules.ts @@ -0,0 +1,245 @@ +// Relay rules (pure, no Node/Electron deps): which way the stream goes in each source mode, which +// settings a running relay is built from, and what the relay's surfaces show (R3.3). +import type { AppConfig, DcsTransport, SourceMode } from '@shared/config' +import type { + RecordState, + EndpointLabel, + OverviewLink, + RelayFacts, + RelaySurfaces, + RelayTip, + RelayWaiting, + ReplayLoad, + StreamState +} from '@shared/dcs' +import type { DeviceState } from '@shared/gateway' + +/** 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)[] + +/** 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]) +} + +// ── what the relay's surfaces show (R3.3) ──────────────────────────────────── +// Moved from the renderer (the title bar, Connection, Overview and Settings › Connection): +// DcsRelay decides these and pushes them on `relay:facts`; the renderer +// keeps the wording. `relaying` is the on/off as the surface shows it: the renderer's pending +// Start or Stop reads at once, before main answers, so each fact comes for both. + +interface RelayOpts { + mode: SourceMode + relaying: boolean + stream?: StreamState + deviceState: DeviceState +} + +/** Running, but Bridge has no device to deliver to. */ +const waitingForDevice = (o: RelayOpts): boolean => + o.relaying && o.mode === 'bridge' && o.deviceState !== 'connected' + +/** Running, but the DCS-BIOS stream is not up. Replay has no DCS to wait for. */ +const waitingForDcs = (o: RelayOpts): boolean => + o.relaying && o.mode !== 'replay' && o.stream !== 'connected' + +/** + * Which tooltip the relay pill shows: where the DCS-BIOS stream stands. A relay keeps running + * through a drop, so while it is waiting the tooltip says what for, rather than looking broken. + */ +export function relayTip(opts: RelayOpts): RelayTip { + if (opts.mode === 'replay') { + if (!opts.relaying) return 'replay-stopped' + if (opts.stream === 'ended') return 'replay-ended' + return 'replay-playing' + } + if (!opts.relaying) return 'stopped' + if (waitingForDevice(opts)) return 'waiting-device' + if (waitingForDcs(opts)) return 'waiting-dcs' + return 'delivering' +} + +/** What a running relay is waiting for: the line under the Connection tab's Start / Stop button. */ +export function relayNote(opts: RelayOpts): RelayWaiting { + if (waitingForDevice(opts)) return 'device' + if (waitingForDcs(opts)) return 'dcs' + if (opts.relaying && opts.mode === 'replay' && opts.stream === 'ended') return 'replay-ended' + return 'none' +} + +/** + * Start is refused while a mode that delivers to the board has no board to deliver to: Bridge, and + * Replay driving the SimGateway. Monitor and an undriven Replay never touch it. The same rule as + * DcsRelay.start(), which is the one that actually enforces it. + */ +export function startBlocked(opts: { + mode: SourceMode + replayDriveSerial: boolean + deviceState: DeviceState +}): boolean { + const needsBoard = opts.mode === 'bridge' || (opts.mode === 'replay' && opts.replayDriveSerial) + return needsBoard && opts.deviceState !== 'connected' +} + +/** + * A running relay that is not delivering end to end — shown degraded rather than green. It keeps + * running and resumes on its own; this only stops the pill claiming data is flowing when it is not. + * Bridge also needs the device; Monitor and Replay never use it. + */ +export function relayWaiting(opts: RelayOpts): boolean { + if (!opts.relaying) return false + return ( + waitingForDevice(opts) || + waitingForDcs(opts) || + (opts.mode === 'replay' && opts.stream === 'ended') + ) +} + +/** + * The Overview Link tile's sub-line. "Waiting" whenever a relay runs with the link down, whatever + * the mode — Monitor included, which never uses the link (N2). + */ +export function overviewLink(relaying: boolean, linkUp: boolean): OverviewLink { + return linkUp + ? relaying + ? 'gateway-relaying' + : 'gateway-stopped' + : relaying + ? 'waiting' + : 'stopped' +} + +/** + * The Relay pill's endpoint (X9, its own variant): the host and port as typed for TCP, the + * multicast group for loopback, `unicast listen` for unicast. + */ +export function pillEndpoint(transport: DcsTransport): EndpointLabel { + return transport === 'tcp-to-host' + ? { hostPort: { suffix: '' } } + : transport === 'loopback-multicast' + ? { text: '239.255.50.10:5010' } + : { text: 'unicast listen' } +} + +/** + * The Overview DCS Link tile's endpoint (X9, its own variant): the multicast group for Unicast + * too, as it reads today. + */ +export function overviewEndpoint(transport: DcsTransport): EndpointLabel { + return transport === 'tcp-to-host' + ? { hostPort: { suffix: ' · remote' } } + : { text: '239.255.50.10:5010 · local' } +} + +/** + * May a recording start (X12, each surface its own variant): the Connection tab's Record card + * only while relaying; Settings › Connection's Record whenever. + */ +export function canRecord(relaying: boolean): RelaySurfaces['canRecord'] { + return { connection: relaying, settings: true } +} + +/** + * Is a Record control shown at all (R3.4a, each surface its own variant like canRecord): the + * Connection tab's Record card is hidden in Replay (the renderer's rule before); Settings › + * Connection's Record row always shows. + */ +export function recordShown(mode: SourceMode): RelayFacts['recordShown'] { + return { connection: mode !== 'replay', settings: true } +} + +/** + * Is the Connection tab's Capture card shown (R3.4a: the renderer's rule before): Replay only. The + * mirror of the Record card's rule, beside it, so both come from the same mode in the same push. + */ +export function captureShown(mode: SourceMode): boolean { + return mode === 'replay' +} + +/** The Record control as this page last set it: from the capture toggle's answer. */ +export function captureView(state: RecordState): RelayFacts['recording'] { + return { on: state.recording, events: state.events } +} + +/** The capture this page loaded: from the replay open's answer, kept when nothing loaded. */ +export function replayView(load: ReplayLoad, prev: RelayFacts['replay']): RelayFacts['replay'] { + if (!load.loaded) return prev + return { file: load.path!, events: load.events ?? 0, durationMs: load.durationMs ?? 0 } +} + +/** What DcsRelay judges the surfaces against. */ +export interface RelayInputs { + config: Pick + stream?: StreamState + deviceState: DeviceState + /** The link is up (the gateway's isLinkUp). */ + linkUp: boolean + recording: RelayFacts['recording'] + replay?: RelayFacts['replay'] +} + +/** The surfaces for one state of the on/off. */ +function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces { + const o = { mode: i.config.sourceMode, relaying, stream: i.stream, deviceState: i.deviceState } + const blocked = + !relaying && + startBlocked({ + mode: i.config.sourceMode, + replayDriveSerial: i.config.replayDriveSerial, + deviceState: i.deviceState + }) + return { + waiting: relayNote(o), + tip: relayTip(o), + ...(blocked ? { startBlocked: i.config.sourceMode === 'replay' ? 'replay' : 'bridge' } : {}), + overviewLink: overviewLink(relaying, i.linkUp), + canRecord: canRecord(relaying) + } +} + +/** `relay:facts`: both sides of the on/off, the endpoints, the recording and the capture. */ +export function relayFacts(i: RelayInputs): RelayFacts { + return { + mode: i.config.sourceMode, + transport: i.config.transport, + running: surfaces(i, true), + stopped: surfaces(i, false), + ...(i.stream !== undefined ? { stream: i.stream } : {}), + deviceState: i.deviceState, + endpoint: { + pill: pillEndpoint(i.config.transport), + overview: overviewEndpoint(i.config.transport) + }, + recording: i.recording, + recordShown: recordShown(i.config.sourceMode), + captureShown: captureShown(i.config.sourceMode), + ...(i.replay ? { replay: i.replay } : {}) + } +} diff --git a/src/main/replay.test.ts b/src/main/dcs/replay-source.test.ts similarity index 91% rename from src/main/replay.test.ts rename to src/main/dcs/replay-source.test.ts index 73ed008..be7fbf2 100644 --- a/src/main/replay.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' -import type { CaptureFile } from '@shared/capture' +import { ReplaySource } from './replay-source' +import type { CaptureFile } from './capture-file' const file: CaptureFile = { format: 'skyhawk-capture/1', diff --git a/src/main/replay.ts b/src/main/dcs/replay-source.ts similarity index 53% rename from src/main/replay.ts rename to src/main/dcs/replay-source.ts index 8f6f46f..b26ea08 100644 --- a/src/main/replay.ts +++ b/src/main/dcs/replay-source.ts @@ -1,37 +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 @shared/capture -// format so captures are portable. -import { readFileSync, writeFileSync } from 'node:fs' -import { - captureDurationMs, - parseCapture, - serializeCapture, - type CaptureEvent, - type CaptureFile, - type CaptureMeta -} from '@shared/capture' - -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..87610d2 --- /dev/null +++ b/src/main/dcs/session-recorder.ts @@ -0,0 +1,51 @@ +// 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() + + 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/decode.test.ts b/src/main/dcs/sim-telemetry-decoder.test.ts similarity index 56% rename from src/main/decode.test.ts rename to src/main/dcs/sim-telemetry-decoder.test.ts index 11f5064..e0d509a 100644 --- a/src/main/decode.test.ts +++ b/src/main/dcs/sim-telemetry-decoder.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Decoder, extractValue } from './decode' +import { TELEMETRY_IDLE } from '@shared/dcs' +import { Decoder, extractValue, gaugeTone, idleTelemetry } from './sim-telemetry-decoder' describe('extractValue', () => { it('masks and shifts', () => { @@ -44,3 +45,22 @@ describe('Decoder telemetry', () => { expect(ids).toEqual(['RPM', 'D_IAS_DEG', 'D_FLAPS_IND', 'ALT_FT', 'D_FUEL']) }) }) + +describe('the gauges’ alert level (Overview.tsx gaugeColor before R3.3)', () => { + it('fuel goes to its alert under 25 %; flap and altitude are the secondary rings', () => { + expect(gaugeTone('D_FUEL', 0.25)).toBe('fuel') + expect(gaugeTone('D_FUEL', 0.24)).toBe('fuel-low') + expect(gaugeTone('D_FLAPS_IND', 0.5)).toBe('secondary') + expect(gaugeTone('ALT_FT', 0)).toBe('secondary') + expect(gaugeTone('RPM', 0.9)).toBe('primary') + }) + + it('X10: the idle readouts are their own spec, and the renderer starts from them', () => { + expect(idleTelemetry()).toEqual(TELEMETRY_IDLE) + const live = new Decoder().telemetrySnapshot() + expect(idleTelemetry().map((r) => r.id)).not.toEqual(live.map((r) => r.id)) + // The idle Press Alt keeps the plain ring; the live one is a secondary ring. + expect(idleTelemetry()[3]).toMatchObject({ id: 'D_ALT_NEEDLE', tone: 'primary' }) + expect(live[3]).toMatchObject({ id: 'ALT_FT', tone: 'secondary' }) + }) +}) diff --git a/src/main/decode.ts b/src/main/dcs/sim-telemetry-decoder.ts similarity index 64% rename from src/main/decode.ts rename to src/main/dcs/sim-telemetry-decoder.ts index df86063..221b84f 100644 --- a/src/main/decode.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -1,9 +1,14 @@ // 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_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 { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' +import { A4EC_ADDRESS_RANGE, StringRegion, ACFT_NAME_ADDRESS, ACFT_NAME_MAX } from './bios-protocol' +import { + AIRCRAFT_NONE, + type AircraftStatus, + type GaugeTone, + type StreamRow, + type TelemetryReadout +} from '@shared/dcs' interface TelemetrySpec { id: string @@ -11,6 +16,36 @@ interface TelemetrySpec { unit: string } +/** + * A gauge ring's alert level (the Overview's gaugeColor before R3.3): fuel goes to its alert under + * 25 %; the flap and altitude rings are the secondary ones. Keyed on the readout's id, so the idle + * placeholders (X10: `D_ALT_NEEDLE`, not `ALT_FT`) keep the plain ring they had. + */ +export function gaugeTone(id: string, pct: number): GaugeTone { + if (id === 'D_FUEL') return pct < 0.25 ? 'fuel-low' : 'fuel' + return id === 'D_FLAPS_IND' || id === 'ALT_FT' ? 'secondary' : 'primary' +} + +/** + * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives — the + * decoder's idle variant, moved from the renderer's store (X10). Its ids, labels and units + * drifted from the live readouts below (TELEMETRY): IAS and Press Alt read `% FS`, and Press Alt + * keys on `D_ALT_NEEDLE`. Each surface keeps its own until the X10 fix: the renderer starts from + * the contract's TELEMETRY_IDLE, which is this (main/dcs/sim-telemetry-decoder.test.ts). + */ +const IDLE_TELEMETRY: TelemetrySpec[] = [ + { id: 'RPM', label: 'RPM', unit: '% RPM' }, + { id: 'D_IAS_DEG', label: 'IAS', unit: '% FS' }, + { id: 'D_FLAPS_IND', label: 'Flap', unit: '% DN' }, + { id: 'D_ALT_NEEDLE', label: 'Press Alt', unit: '% FS' }, + { id: 'D_FUEL', label: 'Fuel', unit: '% QTY' } +] + +/** The idle readouts: NaN values ("—"), empty rings, each with its ring's level. */ +export function idleTelemetry(): TelemetryReadout[] { + return IDLE_TELEMETRY.map((t) => ({ ...t, value: NaN, pct: 0, tone: gaugeTone(t.id, 0) })) +} + // Three altimeter drum addresses encode digit positions 0–9.999 as 0–65535. // ALT_FT is a virtual telemetry id computed from them; it has no DCS-BIOS address. const ALT_DRUM_IDS = new Set(['D_ALT_10K', 'D_ALT_1K', 'D_ALT_100S']) @@ -80,7 +115,7 @@ export class Decoder { } /** Apply one write; returns the decoded log rows it produced (may be empty). */ - handle(address: number, raw: number, t: number): LogRow[] { + handle(address: number, raw: number, t: number): StreamRow[] { if (this.name.update(address, raw)) { const n = this.name.value() if (n !== this.lastName) { @@ -98,7 +133,7 @@ export class Decoder { const outputs = A4EC_OUTPUTS_BY_ADDRESS.get(address) if (!outputs) return [] - const rows: LogRow[] = [] + const rows: StreamRow[] = [] for (const o of outputs) { const value = extractValue(raw, o.mask, o.shift) rows.push({ t, dir: 'in', address, name: o.id, value }) @@ -123,7 +158,7 @@ export class Decoder { if (this.sawA4ecAddress) { return { name: 'A-4E-C', inferred: true, supported: true } } - return { name: 'NONE', inferred: false, supported: true } + return { ...AIRCRAFT_NONE } } telemetrySnapshot(): TelemetryReadout[] { @@ -132,7 +167,14 @@ export class Decoder { const raw = this.telemetry.get('D_IAS_DEG') ?? NaN const kt = Number.isNaN(raw) ? NaN : iasRawToKnots(raw) const pct = Number.isNaN(kt) ? 0 : Math.max(0, Math.min(1, kt / IAS_MAX_KT)) - return { id: 'D_IAS_DEG', label: t.label, value: kt, pct, unit: t.unit } + return { + id: 'D_IAS_DEG', + label: t.label, + value: kt, + pct, + unit: t.unit, + tone: gaugeTone('D_IAS_DEG', pct) + } } if (t.id === 'ALT_FT') { const d10k = this.altDrums.get('D_ALT_10K') @@ -145,13 +187,33 @@ export class Decoder { Math.floor((d100s / 65535) * 10) * 100 : NaN const pct = Number.isNaN(alt) ? 0 : Math.max(0, Math.min(1, alt / ALT_FT_MAX)) - return { id: 'ALT_FT', label: t.label, value: alt, pct, unit: t.unit } + return { + id: 'ALT_FT', + label: t.label, + value: alt, + pct, + unit: t.unit, + tone: gaugeTone('ALT_FT', pct) + } } const raw = this.telemetry.get(t.id) ?? NaN const max = this.telemetryMax.get(t.id) ?? 0xffff const pct = Number.isNaN(raw) ? 0 : Math.max(0, Math.min(1, raw / max)) const value = Number.isNaN(raw) ? NaN : Math.round(pct * 100) - return { id: t.id, label: t.label, value, pct, unit: t.unit } + return { id: t.id, label: t.label, value, pct, unit: t.unit, tone: gaugeTone(t.id, pct) } }) } } + +/** 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..56da3ac --- /dev/null +++ b/src/main/dcs/stream-log.ts @@ -0,0 +1,61 @@ +// 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 pushes the event log's new +// rows first (its `events:batch`). +import type { StreamRow } from '@shared/dcs' +import type { Emit } from '@shared/app' +import type { DeviceLine } from '../gateway' +import type { Stats } from './stream-stats' + +/** The event log, as this flush drives it (EventJournal). */ +export interface StreamLogEvents { + /** Push the event log's new rows (`events:batch`). */ + flush(): void +} + +const LOG_FLUSH_MS = 33 +const MAX_BATCH = 250 + +export class StreamLog { + private logBuf: StreamRow[] = [] + private logTimer?: ReturnType + + constructor( + private readonly emit: Emit, + private readonly stats: Stats, + /** The event log whose new rows ride this flush. */ + private readonly events: StreamLogEvents + ) {} + + /** 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: StreamRow[]): void { + for (const r of rows) this.logBuf.push(r) + } + + flushLog(): void { + 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/dcs/stream-stats.seam.test.ts b/src/main/dcs/stream-stats.seam.test.ts new file mode 100644 index 0000000..2dc2017 --- /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 and survives a move of the +// relay's owners. +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/stats.ts b/src/main/dcs/stream-stats.ts similarity index 93% rename from src/main/stats.ts rename to src/main/dcs/stream-stats.ts index 1f5aa42..14893fc 100644 --- a/src/main/stats.ts +++ b/src/main/dcs/stream-stats.ts @@ -1,7 +1,10 @@ // 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' + +/** The stats tick: one `stats:tick` a second while a relay runs. */ +export const STATS_MS = 1000 export class Stats { private bytesIn = 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/net.ts b/src/main/dcs/transports.ts similarity index 87% rename from src/main/net.ts rename to src/main/dcs/transports.ts index 1441bbf..a8f23be 100644 --- a/src/main/net.ts +++ b/src/main/dcs/transports.ts @@ -3,8 +3,11 @@ // 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' +import type { AppConfig } from '@shared/config' + +/** The settings a transport is built from. */ +export type TransportOptions = Pick export interface Transport { start(): 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/device-link.ts b/src/main/device-link.ts deleted file mode 100644 index 9c62823..0000000 --- a/src/main/device-link.ts +++ /dev/null @@ -1,694 +0,0 @@ -// 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 relay (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 { 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' -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 { CAL_NACK, CAL_AXIS_NONE } from '@shared/calibration' -import { - boardFor, - dropAxis, - regressedAxes, - storeAxes, - type CachedAxis, - type CachedBoard -} from '@shared/cal-cache' -import { CalLink, CalNackError, CalTimeoutError } from './callink' -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' - -type Emit = (channel: C, payload: PushChannels[C]) => void - -/** 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 - -/** 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 - -function subscribe(set: Set, cb: T): Unsubscribe { - set.add(cb) - 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() - 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[] = [] - - 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) { - this.nodes = new NodeHub(emit, this.roster, (events) => this.report(events)) - } - - private report(events: LogEventInput[]): void { - if (events.length) for (const cb of this.eventCbs) cb(events) - } - - // ── events ───────────────────────────────────────────────────────────────── - - /** - * 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. - */ - 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) - } - - /** Each change of link state, also pushed to the renderer as `device:status`. */ - onStatus(cb: (status: DeviceStatus) => void): Unsubscribe { - return subscribe(this.statusCbs, cb) - } - - /** Link faults worth showing the user. "No board plugged in" is a state, not one of these. */ - onError(cb: (tag: string, message: string) => void): Unsubscribe { - 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. */ - start(autoConnect: boolean): void { - 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() - 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) => debugLog('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 - this.started = false - } - - /** - * Turning auto-connect on connects now if the link is down. Turning it off leaves an open link - * alone — it only stops the client taking the port on its own from here on. - */ - setAutoConnect(on: boolean): void { - const was = this.autoConnect - this.autoConnect = on - if (this.started && on && !was && !this.serial) this.connect() - } - - /** - * Open the port, looking for the board until it appears. A no-op while already open or looking - * — except after an error, where it is the Retry: start over with a fresh port. - */ - connect(): void { - if (this.serial && this.status.state !== 'error') return - 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) - 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 }) - ) - s.onData((chunk) => this.onSerialData(chunk)) - s.onMonitor((dir, chunk) => this.onSerialTraffic(dir, chunk)) - s.onOpen((path) => this.onOpen(path)) - s.onClose(() => this.onClose()) - s.onError((err) => { - if (err instanceof PortNotFoundError) { - // The normal state of an unplugged board. Still looking; not an error. - this.setStatus({ state: 'no-device' }) - return - } - this.raise('SERIAL', err.message) - this.setStatus({ state: 'error', portPath: s.portPath(), detail: err.message }) - }) - this.setStatus({ state: 'scanning' }) - s.start() - } - - /** - * Close the serial port and keep it closed — through a replug — until connect(). HID stays up: - * releasing the port is for other tools that need the CDC, and the HID interface is shared. - * Calibration rides on the serial port, so it is unavailable until the link is back. - */ - disconnect(): void { - this.nodes.linkClosed() - this.teardown() - this.setStatus({ state: 'disconnected' }) - } - - /** 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 - this.serial?.stop() - this.serial = undefined - this.gateway.reset() - } - - 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 - this.setStatus({ - state: 'connected', - portPath: path, - vid: SIMGATEWAY_VID, - pid: SIMGATEWAY_PID, - gateway: this.gateway.status() - }) - 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() - } - - 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 - if (!this.autoConnect) { - // Opted out of the client taking the port on its own: a drop leaves it down. - this.disconnect() - return - } - this.setStatus({ state: 'reconnecting' }) - } - - // ── output ───────────────────────────────────────────────────────────────── - - /** Write to the device. A no-op while the port is not open. */ - write(data: Buffer): void { - 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 - 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 - } - - calSnapshot(): CalSnapshot | undefined { - return this.lastCal - } - - // ── input ────────────────────────────────────────────────────────────────── - - private onSerialData(chunk: Buffer): void { - // De-multiplex BEFORE anything else sees the chunk. Two reasons, and the first is the one - // 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. - this.fromDevice(this.cal ? this.cal.ingest(chunk) : chunk) - } - - /** - * Everything downstream of the de-mux: hand the bytes to the relay, assemble and tap lines. - * - * 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 - * stream. Dropping them would breach the byte-preservation guarantee the whole de-mux design - * rests on. They must not be fed back through the de-mux, which is why this starts below it. - */ - 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) { - debugLog('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-rules); 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)) - 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)) - } - - // ── 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. - * - * 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 - * 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()) - if (!isLinkUp(this.status.state)) return - this.nodes.flush(this.roster.takeDirty()) - this.report(this.gateway.tick(Date.now(), this.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) - } - } - - /** - * 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) { - debugLog('cal.absent', `no calibration channel: ${hello.message}`) - return - } - const { proto, blobVersion, fw } = hello.value - debugLog('cal.hello', { - proto, - blobVersion, - fw: `${fw.major}.${fw.minor}.${fw.patch}`, - serial: this.calSerialNumber ?? null - }) - const read = await this.calRead() - if (!read.ok) { - debugLog('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', { - 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 = - Object.entries(CAL_NACK).find(([, v]) => v === err.reason)?.[0] ?? - `0x${err.reason.toString(16)}` - 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() - 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(), serial, entries, new Date().toISOString())) - 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(), serial, idx === CAL_AXIS_NONE ? undefined : idx)) - 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. - if ( - prev.state === status.state && - prev.portPath === status.portPath && - prev.detail === status.detail - ) { - return - } - this.status = status - this.emit('device:status', status) - for (const cb of this.statusCbs) cb(status) - } - - private raise(tag: string, message: string): void { - for (const cb of this.errorCbs) cb(tag, message) - } -} diff --git a/src/main/events/event-journal.ts b/src/main/events/event-journal.ts new file mode 100644 index 0000000..d0bc8cb --- /dev/null +++ b/src/main/events/event-journal.ts @@ -0,0 +1,83 @@ +// The event log's owner (#64): hosts the app run's EventLog (its own service since 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 { 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' + +/** Where client errors also go (app's debug log). */ +export type JournalLog = (tag: string, data?: unknown) => void + +export class EventJournal { + /** 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) 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() + } + + /** events:export: every row as it is now, as TSV. */ + exportTsv(): string { + return eventsToTsv(this.events.snapshot()) + } +} 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 94% rename from src/shared/events.test.ts rename to src/main/events/events.test.ts index de37754..b5711e6 100644 --- a/src/shared/events.test.ts +++ b/src/main/events/events.test.ts @@ -1,36 +1,17 @@ import { describe, expect, it } from 'vitest' -import { - clientErrorEvent, - deviceEvent, - EventLog, - EVENTS_TSV_HEADER, - eventsToTsv, - eventText, - 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' - -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 -}) +import { clientErrorEvent, EventLog, EVENTS_TSV_HEADER, eventsToTsv } from './event-log' +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_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' +import { nodeStatus } from '../../test-fixtures/nodes' + +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 => ({ @@ -391,14 +372,6 @@ describe('EventLog', () => { }) }) -describe('isProblem', () => { - it('keeps errors and warnings only', () => { - expect( - ['error', 'warn', 'new', 'info'].map((s) => isProblem({ severity: s as never })) - ).toEqual([true, true, false, false]) - }) -}) - describe('eventsToTsv', () => { it('writes every row oldest first with the fixed columns; tabs in fields become spaces', () => { const t = new Date(2026, 9, 16, 21, 14, 7, 412).getTime() diff --git a/src/main/events/index.ts b/src/main/events/index.ts new file mode 100644 index 0000000..0f82a9d --- /dev/null +++ b/src/main/events/index.ts @@ -0,0 +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 { 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/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-line-tap.ts b/src/main/gateway/device-line-tap.ts new file mode 100644 index 0000000..7753676 --- /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 (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' + +/** 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/gateway-connection.ts b/src/main/gateway/gateway-connection.ts new file mode 100644 index 0000000..829b57e --- /dev/null +++ b/src/main/gateway/gateway-connection.ts @@ -0,0 +1,432 @@ +// 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 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 (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. +import type { DeviceStatus, DeviceStatusPush, GatewayStatus } from '@shared/gateway' +import type { Emit } from '@shared/app' +import { shownStatus } from './gateway-health' +import { isLinkUp, LinkDownClock, linkFacts } from './link-state' +import { + findSimGatewayPort, + listSerialPorts, + PortNotFoundError, + SerialBridge, + SIMGATEWAY_PID, + SIMGATEWAY_VID, + type Log +} from './serial-bridge' + +/** + * 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. + */ +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, and the link's moments it is told about. + * + * At each moment GatewayConnection calls its riders in the order they were added with `ride()`, + * at exactly the point where that rider's code ran before the split (R2.2), so the + * 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 { + /** + * 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 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 + /** 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 `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 GatewayConnection needs from app (P6: it never asks electron): the debug log + * (SerialBridge), the calibration frame filter, the Gateway's health as the node layer follows + * it (NodeHub drives GatewayWatch, UD-C), which the connected status carries, whether the + * page's serial monitor is on (SerialTrafficMonitor), which every status push carries (R3.3), and + * whether a roster request can go out (NodeHub's guard), which every status push carries too as + * Refresh's availability (R3.4a). + */ +export interface GatewayConnectionDeps { + log: Log + frames: CalFrameFilter + health: () => GatewayStatus | undefined + serialMonitor?: () => boolean + canRefresh?: () => boolean +} + +/** The link's push tick: node status, node model and the HID snapshot. */ +const LINK_FLUSH_MS = 200 + +type Unsubscribe = () => void + +function subscribe(set: Set, cb: T): Unsubscribe { + set.add(cb) + return () => set.delete(cb) +} + +export class GatewayConnection { + private serial?: SerialBridge + private status: DeviceStatus = { state: 'disconnected' } + /** The Cockpit's "last known" time, as the page's store kept it (R3.3). */ + private readonly linkDown = new LinkDownClock() + private autoConnect = true + private started = false + private timers: ReturnType[] = [] + /** In the order added: see LinkRider. */ + private readonly riders: LinkRider[] = [] + + private readonly bytesCbs = new Set<(bytes: Buffer) => 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: GatewayConnectionDeps + ) {} + + /** Add a rider. Riders are called in the order added; see LinkRider. */ + ride(rider: LinkRider): void { + this.riders.push(rider) + } + + // ── events ───────────────────────────────────────────────────────────────── + + /** + * 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. Subscribers are + * called in the order they subscribed. + */ + onBytes(cb: (bytes: Buffer) => void): Unsubscribe { + return subscribe(this.bytesCbs, 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`. */ + onStatus(cb: (status: DeviceStatus) => void): Unsubscribe { + return subscribe(this.statusCbs, cb) + } + + /** Link faults worth showing the user. "No board plugged in" is a state, not one of these. */ + onError(cb: (tag: string, message: string) => void): Unsubscribe { + return subscribe(this.errorCbs, cb) + } + + // ── lifecycle ────────────────────────────────────────────────────────────── + + /** App launch: start the link's timers and, unless the user opted out, the link itself. */ + start(autoConnect: boolean): void { + if (this.started) return + this.started = true + this.autoConnect = autoConnect + this.timers = [] + const every: LinkTimer = (ms, fn) => { + this.timers.push(setInterval(fn, ms)) + } + every(LINK_FLUSH_MS, () => this.flushLink()) + // 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' }) + } + + /** App quit. */ + shutdown(): void { + 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() + for (const r of this.riders) r.stop?.() // HID: the pending push, the reader + this.started = false + } + + /** + * Turning auto-connect on connects now if the link is down. Turning it off leaves an open link + * alone — it only stops the client taking the port on its own from here on. + */ + setAutoConnect(on: boolean): void { + const was = this.autoConnect + this.autoConnect = on + if (this.started && on && !was && !this.serial) this.connect() + } + + /** + * Open the port, looking for the board until it appears. A no-op while already open or looking + * — except after an error, where it is the Retry: start over with a fresh port. + */ + connect(): void { + if (this.serial && this.status.state !== 'error') return + 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, this.deps.log) + 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) => { + for (const cb of this.trafficCbs) cb(dir, chunk) + }) + s.onOpen((path) => this.onOpen(path)) + s.onClose(() => this.onClose()) + s.onError((err) => { + if (err instanceof PortNotFoundError) { + // The normal state of an unplugged board. Still looking; not an error. + this.setStatus({ state: 'no-device' }) + return + } + this.raise('SERIAL', err.message) + this.setStatus({ state: 'error', portPath: s.portPath(), detail: err.message }) + }) + this.setStatus({ state: 'scanning' }) + s.start() + } + + /** + * Close the serial port and keep it closed — through a replug — until connect(). HID stays up: + * releasing the port is for other tools that need the CDC, and the HID interface is shared. + * Calibration rides on the serial port, so it is unavailable until the link is back. + */ + disconnect(): void { + for (const r of this.riders) r.closing?.() // nodes: when the boards were last seen (#93) + this.teardown() + this.setStatus({ state: 'disconnected' }) + } + + /** Release the serial port and what rides on it. */ + private teardown(): void { + for (const r of this.riders) r.release?.() // calibration: its CalLink and what it held + this.serial?.stop() + this.serial = undefined + 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. + // 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.deps.health() + }) + 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 { + 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) { + // Opted out of the client taking the port on its own: a drop leaves it down. + this.disconnect() + return + } + this.setStatus({ state: 'reconnecting' }) + } + + // ── output ───────────────────────────────────────────────────────────────── + + /** Write to the device. A no-op while the port is not open. */ + write(data: Buffer): void { + this.serial?.write(data) + } + + deviceStatus(): DeviceStatus { + return this.status + } + + /** The status with what main concludes from it, as the last `device:status` carried it (R3.3). */ + statusPush(): DeviceStatusPush { + return this.withFacts(this.status) + } + + /** + * A renderer (re)loaded (`relay:status`): the page's own link facts start over, as its store + * did — no "last known" time until the link next drops (N1). + */ + rendererLoaded(): void { + this.linkDown.pageLoaded(this.status.state) + } + + /** Something `device:status` carries changed without the link changing (the serial monitor). */ + pushStatus(): void { + this.emitStatus(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 { + // De-multiplex BEFORE anything else sees the chunk. Two reasons, and the first is the one + // 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 line tap's assembler. + this.fromDevice(this.deps.frames.ingest(chunk)) + } + + /** + * 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 + * stream. Dropping them would breach the byte-preservation guarantee the whole de-mux design + * rests on. They must not be fed back through the de-mux, which is why this starts below it. + */ + private fromDevice(rest: Buffer): void { + if (rest.length === 0) return + for (const cb of this.bytesCbs) cb(rest) + } + + // ── renderer pushes ──────────────────────────────────────────────────────── + + /** + * 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 + * computed during an unplug would only repeat what the link state already says. + */ + private flushLink(): void { + for (const r of this.riders) r.flush?.() // HID: its snapshot, link up or not + if (!isLinkUp(this.status.state)) return + 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.emitStatus(this.status) + } + + private setStatus(status: DeviceStatus): void { + const prev = this.status + // "Not found" repeats every retry; only a change is news to the renderer. + if ( + prev.state === status.state && + prev.portPath === status.portPath && + prev.detail === status.detail + ) { + return + } + this.status = status + this.emitStatus(status) + for (const cb of this.statusCbs) cb(status) + } + + /** + * Every `device:status`: the status, the Gateway's health only while the link is up + * (gateway-health.ts shownStatus), and what it means (link-state.ts), judged per push. + */ + private emitStatus(status: DeviceStatus): void { + this.linkDown.pushed(status.state) + this.emit('device:status', this.withFacts(status)) + } + + private withFacts(status: DeviceStatus): DeviceStatusPush { + return { + ...shownStatus(status), + ...linkFacts( + status, + { + linkDownAt: this.linkDown.value, + serialMonitor: this.deps.serialMonitor?.() ?? false + }, + this.deps.canRefresh?.() ?? false + ) + } + } + + private raise(tag: string, message: string): void { + for (const cb of this.errorCbs) cb(tag, message) + } +} diff --git a/src/shared/gateway.test.ts b/src/main/gateway/gateway-health.test.ts similarity index 87% rename from src/shared/gateway.test.ts rename to src/main/gateway/gateway-health.test.ts index 7dd6b9e..231bceb 100644 --- a/src/shared/gateway.test.ts +++ b/src/main/gateway/gateway-health.test.ts @@ -1,24 +1,19 @@ 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' - -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 -}) +import { + GATEWAY_NO_ANSWER_MS, + GatewayWatch, + gatewayEvents, + gatewayStatus, + shownHealth, + shownStatus +} 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 }) const st = (over: Partial = {}, notResponding = false) => gatewayStatus(node0(over), notResponding)! // a row always has a status @@ -252,3 +247,26 @@ describe('GatewayWatch — its own row', () => { expect(short(w.report(node0(), 'r', 3))).toEqual(['Gateway: bus-off cleared [info]']) }) }) + +describe('what the surfaces may show (R3.4a: gateway-view.ts before)', () => { + it('not responding is about the link; nothing else is', () => { + expect(gatewayStatus(undefined, true)?.aboutLink).toBe(true) + for (const over of [{}, { boff: true }, { overheat: true }, { epvf: true }, { faultId: 9 }]) { + expect(st(over).aboutLink, JSON.stringify(over)).toBeUndefined() + } + }) + + it('the health only while the link is up; device:status leaves it out while down', () => { + const g = st({ boff: true }) + expect(shownHealth(g, true)).toBe(g) + expect(shownHealth(g, false)).toBeUndefined() + const up = { state: 'connected' as const, portPath: '/dev/x', gateway: g } + expect(shownStatus(up)).toBe(up) + expect(shownStatus({ ...up, state: 'reconnecting' })).toEqual({ + state: 'reconnecting', + portPath: '/dev/x' + }) + const none = { state: 'no-device' as const } + expect(shownStatus(none)).toBe(none) + }) +}) diff --git a/src/shared/gateway.ts b/src/main/gateway/gateway-health.ts similarity index 81% rename from src/shared/gateway.ts rename to src/main/gateway/gateway-health.ts index 812d176..35e8c47 100644 --- a/src/shared/gateway.ts +++ b/src/main/gateway/gateway-health.ts @@ -1,56 +1,44 @@ // 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 LogEventInput, type StatusFlag } from './events' -import type { NodeStatus } from './nodes' +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 { DeviceStatus, GatewayKind, GatewayStatus } from '@shared/gateway' +import { faultLabel, STATUS_FLAGS, type StatusFlag } from '../events' +import { isLinkUp } from './link-state' /** 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)! -/** `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 (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 => @@ -76,7 +64,9 @@ export function gatewayStatus( alert: { head: 'Gateway not responding', text: `no answer for ${SECS} s while its port is open. Unplug its USB cable and plug it back in.` - } + }, + // Not responding is about the link, not something it reports: the popover's title says it. + aboutLink: true } } if (!row) return undefined @@ -118,13 +108,34 @@ 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 return { severity: 'ok', kind: 'ok', label: withTemp('OK', row) } } +/** + * The Gateway's health as the surfaces may show it (R3.4a: gateway-view.ts's gate before). Nothing + * while the link is down: the link state says it all, and the last report would describe a board + * that may be gone. + */ +export function shownHealth( + gateway: GatewayStatus | undefined, + linkUp: boolean +): GatewayStatus | undefined { + return linkUp ? gateway : undefined +} + +/** The status as `device:status` carries it: the Gateway's health left out while the link is down. */ +export function shownStatus(status: DeviceStatus): DeviceStatus { + if (status.gateway === undefined || shownHealth(status.gateway, isLinkUp(status.state))) { + return status + } + const { gateway: _down, ...link } = status + return link +} + // ── event log ─────────────────────────────────────────────────────────────── const RANK: Record = { diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts new file mode 100644 index 0000000..e69813f --- /dev/null +++ b/src/main/gateway/index.ts @@ -0,0 +1,16 @@ +// gateway: the SimGateway USB link. Exports only what other topics use (arch-phase0-v2 §2.1). +export { + GatewayConnection, + type CalFrameFilter, + type GatewayConnectionDeps, + type LinkRider, + type LinkTimer +} from './gateway-connection' +export { DeviceLineTap, type DeviceLine, type NodeLineSink } from './device-line-tap' +export { SerialTrafficMonitor } from './serial-traffic-monitor' +export { isLinkUp } from './link-state' +export { GatewayWatch, shownHealth } 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/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/main/gateway/link-state.test.ts b/src/main/gateway/link-state.test.ts new file mode 100644 index 0000000..3b6af8a --- /dev/null +++ b/src/main/gateway/link-state.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { DEVICE_IDLE, type DeviceState } from '@shared/gateway' +import { + isBoardPresent, + isBusy, + isLinkUp, + linkAction, + LinkDownClock, + linkFacts +} from './link-state' + +const STATES: DeviceState[] = [ + 'connected', + 'scanning', + 'reconnecting', + 'no-device', + 'disconnected', + 'error' +] + +describe('link facts (R3.3: decided here, pushed on device:status)', () => { + it('up only while connected; the board present while connected, erroring or released', () => { + expect(STATES.filter(isLinkUp)).toEqual(['connected']) + expect(STATES.filter(isBoardPresent)).toEqual(['connected', 'disconnected', 'error']) + }) + + it('names another program holding the port as busy', () => { + for (const d of ['Resource busy', 'Access denied', 'port in use', 'ACCESS']) { + expect(isBusy(d), d).toBe(true) + } + for (const d of [undefined, '', 'EIO', 'Port not found']) expect(isBusy(d), d).toBe(false) + }) + + it('the renderer starts from the idle facts: no device, nothing concluded', () => { + const idle = { state: 'no-device' as const } + expect({ ...idle, ...linkFacts(idle, { serialMonitor: false }) }).toEqual(DEVICE_IDLE) + }) +}) + +describe('the Cockpit’s "last known" time (store.ts before R3.3)', () => { + it('stamped on an up → down push only, cleared while up, kept otherwise', () => { + let now = 1000 + const clock = new LinkDownClock(() => now) + expect(clock.pushed('scanning')).toBeUndefined() + expect(clock.pushed('connected')).toBeUndefined() + now = 2000 + expect(clock.pushed('reconnecting')).toBe(2000) + now = 3000 + expect(clock.pushed('no-device'), 'only the up → down transition sets it').toBe(2000) + expect(clock.pushed('connected')).toBeUndefined() + // A push that changes nothing about the link (the Gateway's health) keeps it. + now = 4000 + expect(clock.pushed('disconnected')).toBe(4000) + expect(clock.pushed('disconnected')).toBe(4000) + }) + + it('a reloaded page has none until the link next drops (N1)', () => { + let now = 1000 + const clock = new LinkDownClock(() => now) + clock.pushed('connected') + clock.pushed('reconnecting') + expect(clock.value).toBe(1000) + clock.pageLoaded('reconnecting') + expect(clock.value).toBeUndefined() + now = 2000 + expect(clock.pushed('no-device'), 'down → down is no drop').toBeUndefined() + expect(clock.pushed('connected')).toBeUndefined() + expect(clock.pushed('reconnecting')).toBe(2000) + // Reloaded while up: the next drop stamps. + clock.pushed('connected') + clock.pageLoaded('connected') + now = 3000 + expect(clock.pushed('error')).toBe(3000) + }) +}) + +describe('the link’s action (R3.4a: the renderer’s deviceView table before)', () => { + it('Connect when released, Retry on error, Disconnect otherwise — not while mid-open', () => { + expect(Object.fromEntries(STATES.map((s) => [s, linkAction(s)]))).toEqual({ + connected: { offer: 'disconnect', intent: 'disconnect' }, + scanning: { offer: 'disconnect', intent: 'disconnect', disabled: true }, + reconnecting: { offer: 'disconnect', intent: 'disconnect' }, + 'no-device': { offer: 'disconnect', intent: 'disconnect' }, + disconnected: { offer: 'connect', intent: 'connect' }, + error: { offer: 'retry', intent: 'connect' } + }) + }) + + it('every push carries it, and Refresh’s availability as the node layer hands it in', () => { + const up = { state: 'connected' as const } + expect(linkFacts(up, { serialMonitor: false }, true)).toMatchObject({ + action: { offer: 'disconnect', intent: 'disconnect' }, + canRefresh: true + }) + expect(linkFacts(up, { serialMonitor: false }).canRefresh, 'no node layer').toBe(false) + }) +}) diff --git a/src/main/gateway/link-state.ts b/src/main/gateway/link-state.ts new file mode 100644 index 0000000..a70915b --- /dev/null +++ b/src/main/gateway/link-state.ts @@ -0,0 +1,126 @@ +// What the link's state means (pure, no Node/Electron deps): up, the board plugged in, the port held +// by another program, and the facts `device:status` carries to the renderer (R3.3). Moved from the +// renderer and from the gateway contract (isLinkUp) so the +// link's owner decides them once; the renderer only reads what is pushed. +import type { DeviceLinkFacts, DeviceState, DeviceStatus, LinkAction } from '@shared/gateway' + +/** + * 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' +} + +/** + * Whether the board can be sending HID reports at all, judged from the serial link's state. + * + * The joystick is a second interface on the same USB device and is read alongside DCS, so it + * does not need the serial port open (#69, #74). What it needs is the board plugged in, and the + * link state is the only evidence of that the renderer has: + * + * connected — open, so plugged in + * error — typically "port busy": another tool holds the CDC, the board is there + * disconnected — released by Disconnect or with auto-connect off; normally still plugged + * no-device / scanning / reconnecting — not found, or not yet: nothing can arrive + * + * The one case this misreads is an unplug while auto-connect is off: the link was already + * released, nobody is looking for the port, so it stays `disconnected` and the card ages to + * "quiet" instead of saying "no link". Nothing on the serial side can see that unplug, and the + * HID reader does not reliably either — it is the accepted cost of not polling for the board. + */ +const BOARD_PRESENT: ReadonlySet = new Set(['connected', 'error', 'disconnected']) + +/** The board is attached, as far as the link can tell (BOARD_PRESENT). */ +export function isBoardPresent(state: DeviceState): boolean { + return BOARD_PRESENT.has(state) +} + +/** A failure to open that means another program holds the port. */ +export function isBusy(detail?: string): boolean { + return !!detail && /busy|denied|in use|access/i.test(detail) +} + +/** + * The link's one action in each state (R3.4a: the renderer's deviceView table): Connect when + * released, Retry after an error, Disconnect otherwise — not while the port is mid-open. + */ +export function linkAction(state: DeviceState): LinkAction { + const disconnect = { offer: 'disconnect', intent: 'disconnect' } as const + const connect = { offer: 'connect', intent: 'connect' } as const + switch (state) { + case 'connected': + return disconnect + case 'scanning': + // While the port is mid-open there is nothing sensible to interrupt. + return { ...disconnect, disabled: true } + case 'reconnecting': + return disconnect + case 'no-device': + return disconnect + case 'disconnected': + return connect + case 'error': + return { offer: 'retry', intent: 'connect' } + } +} + +/** + * "When the link last went down after being up", as the renderer's store kept it for the + * Cockpit's "last known" time, judged per `device:status` push: stamped on an up → down change, + * cleared while up, otherwise left as it was. The page's own: a renderer that (re)loads starts + * with none until the next drop (N1), so `pageLoaded` forgets it. + */ +export class LinkDownClock { + private wasUp = false + private at?: number + + constructor(private readonly now: () => number = Date.now) {} + + /** One `device:status` push: the renderer's old handler, step for step. */ + pushed(state: DeviceState): number | undefined { + const wasUp = this.wasUp + if (wasUp && !isLinkUp(state)) this.at = this.now() + else if (isLinkUp(state)) this.at = undefined + this.wasUp = isLinkUp(state) + return this.at + } + + /** The time as the last push carried it. */ + get value(): number | undefined { + return this.at + } + + /** + * A renderer (re)loaded: its store started with no time, and took the link's state from the + * rehydrate bundle (`relay:status`), which is where its next push is judged from. + */ + pageLoaded(state: DeviceState): void { + this.at = undefined + this.wasUp = isLinkUp(state) + } +} + +/** + * What `device:status` carries besides the status itself. `canRefresh` is NodeHub's (its roster + * request guard), handed in: none without a node layer. + */ +export function linkFacts( + status: DeviceStatus, + page: { linkDownAt?: number; serialMonitor: boolean }, + canRefresh = false +): DeviceLinkFacts { + return { + linkUp: isLinkUp(status.state), + boardPresent: isBoardPresent(status.state), + busy: isBusy(status.detail), + ...(page.linkDownAt !== undefined ? { linkDownAt: page.linkDownAt } : {}), + serialMonitor: page.serialMonitor, + action: linkAction(status.state), + canRefresh + } +} 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 91% rename from src/main/serial.ts rename to src/main/gateway/serial-bridge.ts index a9fe4e5..b7a072f 100644 --- a/src/main/serial.ts +++ b/src/main/gateway/serial-bridge.ts @@ -2,10 +2,13 @@ // 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 { debugLog } from './debug' +import { USB_IDENTITY } from '@shared/firmware' -export const SIMGATEWAY_VID = 0x2e8a -export const SIMGATEWAY_PID = 0x4134 +/** 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 const VID_HEX = SIMGATEWAY_VID.toString(16).padStart(4, '0') const PID_HEX = SIMGATEWAY_PID.toString(16).padStart(4, '0') @@ -17,7 +20,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 /** @@ -40,7 +43,7 @@ export async function findSimGatewayPort(): Promise { return match?.path } -export interface SerialLink { +interface SerialLink { start(): void stop(): void write(data: Buffer): void @@ -78,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 } @@ -168,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() @@ -176,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 @@ -189,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() }) @@ -201,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/gateway/serial-traffic-monitor.ts b/src/main/gateway/serial-traffic-monitor.ts new file mode 100644 index 0000000..c969c14 --- /dev/null +++ b/src/main/gateway/serial-traffic-monitor.ts @@ -0,0 +1,66 @@ +// 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 { Emit } from '@shared/app' +import type { LinkRider, LinkTimer } from './gateway-connection' + +const SERIAL_FLUSH_MS = 50 +const MAX_SERIAL_BUF = 4000 + +export class SerialTrafficMonitor implements LinkRider { + private serialMonitor = false + private serialBuf: SerialFrame[] = [] + /** + * The Serial tab's toggle as the page last set it, which `device:status` carries (R3.3). The + * page's own: a renderer that (re)loads shows the monitor off while main keeps it running — and + * its traffic coming — until the toggle is pressed (N1), as when the page held the flag. + */ + private shownOn = false + private changed?: () => void + + constructor(private readonly emit: Emit) {} + + /** The page's monitor flag (`device:status`). */ + get shown(): boolean { + return this.shownOn + } + + /** Told each time the page's flag is set: GatewayConnection pushes it (AppServices). */ + onShownChange(cb: () => void): void { + this.changed = cb + } + + /** A renderer (re)loaded: its flag starts off; the monitor itself keeps running (N1). */ + rendererLoaded(): void { + this.shownOn = false + } + + /** 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 = [] + // The page set its toggle: what it shows comes back on `device:status` (R3.3). + this.shownOn = on + this.changed?.() + } + + /** 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/shared/axis-capture.test.ts b/src/main/hid/calibration/axis-capture.test.ts similarity index 99% rename from src/shared/axis-capture.test.ts rename to src/main/hid/calibration/axis-capture.test.ts index 86d187d..e9f097b 100644 --- a/src/shared/axis-capture.test.ts +++ b/src/main/hid/calibration/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 '@shared/hid/calibration' const C = DEFAULT_CAPTURE_CONFIG diff --git a/src/shared/axis-capture.ts b/src/main/hid/calibration/axis-capture.ts similarity index 62% rename from src/shared/axis-capture.ts rename to src/main/hid/calibration/axis-capture.ts index 3ed138d..a02c637 100644 --- a/src/shared/axis-capture.ts +++ b/src/main/hid/calibration/axis-capture.ts @@ -1,3 +1,5 @@ +// (In shared until the calibration workflow moved to main, R3.1.) +// // 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,84 +22,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 '@shared/hid/calibration' export const DEFAULT_CAPTURE_CONFIG: CaptureConfig = { bandCounts: 300, @@ -119,115 +49,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. */ @@ -260,7 +81,9 @@ 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. + */ 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)) @@ -351,7 +174,9 @@ 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. + */ export function midpoint(min: number, max: number): number { return Math.round((min + max) / 2) } @@ -490,7 +315,9 @@ 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. + */ export function push(s: CaptureState, sample: CaptureSample): CaptureState { if (s.phase !== 'capturing') return { ...s, now: sample.t } @@ -531,7 +358,9 @@ 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. + */ export function retrySweep(s: CaptureState, now: number): CaptureState { if (s.phase !== 'rejected') return s return restartSweep(s, now) @@ -566,7 +395,9 @@ 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. + */ export function sweepsRemaining(s: CaptureState): number { return Math.max(0, s.config.sweepsRequired - s.accepted.length) } 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 ac8955d..676785b 100644 --- a/src/shared/cal-cache.test.ts +++ b/src/main/hid/calibration/cache-rules.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it } from 'vitest' -import type { CalSnapshot } from './ipc' +import type { CachedAxis, CalCache, CalSnapshot } from '@shared/hid/calibration' import { boardFor, dropAxis, emptyCache, regressedAxes, sanitizeCache, - storeAxes, - type CachedAxis, - type CalCache -} from './cal-cache' + storeAxes +} 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 85% rename from src/shared/cal-cache.ts rename to src/main/hid/calibration/cache-rules.ts index b32b241..8bc3458 100644 --- a/src/shared/cal-cache.ts +++ b/src/main/hid/calibration/cache-rules.ts @@ -13,39 +13,9 @@ // reverts to identity passthrough, reporting `calibratedMask = 0` rather than serving garbage. The // loss is always visible. -import type { CalSnapshot } from './ipc' +import type { CachedAxis, CachedBoard, CalCache, CalSnapshot } from '@shared/hid/calibration' -/** - * 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 -} - -export const CAL_CACHE_VERSION = 1 +const CAL_CACHE_VERSION = 1 export function emptyCache(): CalCache { return { version: CAL_CACHE_VERSION, boards: {} } @@ -164,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 new file mode 100644 index 0000000..43b51fe --- /dev/null +++ b/src/main/hid/calibration/cache-store.ts @@ -0,0 +1,33 @@ +// Persist the calibration cache to /calibration-cache.json (#46, part 5). +// +// 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. +// +// 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: 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(io: CacheFileIO): CalCache { + try { + const r = io.read() + return r.kind === 'read' ? sanitizeCache(r.data) : emptyCache() + } catch { + return emptyCache() + } +} + +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/callink.test.ts b/src/main/hid/calibration/cal-link.test.ts similarity index 98% rename from src/main/callink.test.ts rename to src/main/hid/calibration/cal-link.test.ts index 78b06cf..222b848 100644 --- a/src/main/callink.test.ts +++ b/src/main/hid/calibration/cal-link.test.ts @@ -1,10 +1,10 @@ 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. +// app/debug-log.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 90% rename from src/main/callink.ts rename to src/main/hid/calibration/cal-link.ts index 6503ff9..3062ad4 100644 --- a/src/main/callink.ts +++ b/src/main/hid/calibration/cal-link.ts @@ -1,14 +1,14 @@ // 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 -// matching, timeouts, session upkeep, and the export-gate signal Session consumes. +// § Calibration Protocol (USB CDC). The codec itself is in codec.ts; this adds SEQ +// matching, timeouts, session upkeep, and the export-gate signal the relay consumes. // -// It does NOT own the serial port. Session does, and hands bytes in via ingest(); this returns +// It does NOT own the serial port. The Gateway connection does; HidCalibrationService hands bytes +// in via ingest(), and this returns // what should carry on to DCS and the line assembler. import { CAL_AXIS_NONE, - CAL_NACK, CAL_PROTO_VERSION, CAL_TYPE, CalDemux, @@ -21,28 +21,26 @@ import { decodeSessionAck, encodeCommit, encodeReset, + nackRefusalName, type CalData, type CalFrame, type HelloAck, type RawSample -} from '@shared/calibration' -import { debugLog } from './debug' +} from './codec' +import { CAL_PROTO } from '@shared/firmware' + +/** 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 -/** 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 -/** 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 +49,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,15 +86,16 @@ 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. */ - private deviceTimeoutMs = 30_000 + private deviceTimeoutMs: number = CAL_PROTO.sessionTimeoutMs constructor( private readonly write: (data: Buffer) => void, - private readonly onRaw: (sample: RawSample) => void + private readonly onRaw: (sample: RawSample) => void, + private readonly log: Log = () => {} ) {} /** @@ -137,7 +136,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) @@ -304,7 +303,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) } @@ -313,7 +312,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/calibration-view-model.ts b/src/main/hid/calibration/calibration-view-model.ts new file mode 100644 index 0000000..8e02ad2 --- /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 (the dialog and the +// HID tab), 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 + +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. + */ +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 (the renderer's 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 89% rename from src/renderer/src/calibration-controller.test.ts rename to src/main/hid/calibration/calibration-workflow.test.ts index eb1dbf9a..815c6fe 100644 --- a/src/renderer/src/calibration-controller.test.ts +++ b/src/main/hid/calibration/calibration-workflow.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 { CalibrationController, type CalibrationApi } from './calibration-controller' -import type { CachedBoard } from '@shared/cal-cache' -import type { CalCacheEntry } from '@shared/ipc' +import { DEFAULT_CAPTURE_CONFIG, midpoint } from './axis-capture' +import type { + CachedBoard, + CalCacheEntry, + CalRawSample, + CalResult, + CalSnapshot +} from '@shared/hid/calibration' +import { CalibrationWorkflow, type CalibrationCommands } from './calibration-workflow' const C = DEFAULT_CAPTURE_CONFIG @@ -34,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)) @@ -120,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 @@ -132,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() @@ -149,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) @@ -158,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() @@ -167,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) @@ -190,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) @@ -214,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, @@ -245,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) @@ -264,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 @@ -284,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) @@ -296,7 +300,7 @@ describe('CalibrationController', () => { }) }) -describe('CalibrationController writing', () => { +describe('CalibrationWorkflow writing', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -304,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 } @@ -313,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] }) @@ -339,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) @@ -354,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) @@ -363,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) }) @@ -373,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 } })) @@ -386,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() @@ -399,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() @@ -409,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) @@ -417,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) @@ -426,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() @@ -449,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() @@ -458,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]) @@ -480,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] }) @@ -511,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() @@ -520,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]) @@ -533,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() @@ -542,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() @@ -554,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() @@ -563,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 @@ -582,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() @@ -590,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) @@ -608,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) @@ -617,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) @@ -626,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() @@ -634,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() @@ -644,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() }) @@ -660,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() @@ -673,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() @@ -690,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() @@ -708,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() @@ -726,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() @@ -750,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 @@ -766,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() @@ -779,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() @@ -790,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' }) }) @@ -800,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) @@ -813,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() @@ -828,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) @@ -838,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) @@ -846,7 +850,7 @@ describe('CalibrationController writing', () => { }) }) -describe('CalibrationController delete', () => { +describe('CalibrationWorkflow delete', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -856,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) @@ -867,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) @@ -878,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/) @@ -898,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 }) @@ -906,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' }) @@ -920,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 } @@ -929,7 +933,7 @@ describe('CalibrationController device identity', () => { }) }) -describe('CalibrationController serial link (#74)', () => { +describe('CalibrationWorkflow serial link (#74)', () => { beforeEach(() => { clock = 0 vi.useFakeTimers() @@ -937,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) @@ -974,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) @@ -987,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) @@ -998,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 @@ -1018,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 @@ -1035,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 @@ -1053,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) @@ -1074,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 } @@ -1088,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 } @@ -1121,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 } ]) @@ -1150,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 } @@ -1167,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' }) @@ -1178,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) @@ -1191,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 3c9624f..7e89d27 100644 --- a/src/renderer/src/calibration-controller.ts +++ b/src/main/hid/calibration/calibration-workflow.ts @@ -1,30 +1,34 @@ // 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, push as pushSample, restartSweep, retrySweep, - tick, - type CaptureResult, - type CaptureState -} from '@shared/axis-capture' + tick +} from './axis-capture' import type { + CachedBoard, CalCacheEntry, CalCommitAxis, CalFailure, CalRawSample, CalResult, - CalSnapshot -} from '@shared/ipc' -import type { CachedBoard } from '@shared/cal-cache' + CalSnapshot, + 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. */ +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/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 75% rename from src/shared/calibration.ts rename to src/main/hid/calibration/codec.ts index a4c1545..2af888c 100644 --- a/src/shared/calibration.ts +++ b/src/main/hid/calibration/codec.ts @@ -12,51 +12,59 @@ // 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_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_TYPES, + type CalNackName +} from '@shared/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 - -/** CAL_DATA, the largest legal payload — and the bound on how much a receiver may buffer. */ -export const CAL_MAX_PAYLOAD = 82 +const CAL_ENVELOPE_BYTES = CAL_PROTO.envelopeBytes /** 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. 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. */ +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 +72,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,25 +106,10 @@ 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 +export const CAL_MAX_FRAME = CAL_PROTO.maxFrame // ── CRC ────────────────────────────────────────────────────────────────────── @@ -295,6 +302,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,15 +317,16 @@ 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]! } } } -export interface CalAxis { +interface CalAxis { idx: number /** CAN controlId, 0x0000 for an absent slot. The client infers self-centring from this. */ controlId: number @@ -339,18 +350,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 +377,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 +397,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,29 +414,31 @@ 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) -export const encodeSessionClose = (seq: number) => buildFrame(CAL_TYPE.SESSION_CLOSE, seq) -export const encodeKeepalive = (seq: number) => buildFrame(CAL_TYPE.KEEPALIVE, seq) /** 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) - -/** 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.SESSION_OPEN, seq, CAL_SESSION_OPEN_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 +451,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/main/hid/calibration/hid-calibration-service.ts b/src/main/hid/calibration/hid-calibration-service.ts new file mode 100644 index 0000000..a6c615d --- /dev/null +++ b/src/main/hid/calibration/hid-calibration-service.ts @@ -0,0 +1,328 @@ +// Axis calibration (#46) over the SimGateway's serial port, as the app runs it (its own service +// since 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, 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 +// the codec's CalDemux inside CalLink takes the frames out. +import type { + CachedAxis, + CachedBoard, + CalCacheEntry, + CalCommitAxis, + CalHello, + CalRawSample, + CalResult, + CalibrationState, + 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' + +/** RAW batches (`cal:raw` before R3.1): 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 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 +} + +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 + /** The calibration dialog's workflow, over this service's own commands. */ + readonly workflow: CalibrationWorkflowHost + private readonly workflowCbs = new Set<(state: CalibrationState) => void>() + + 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) + for (const cb of this.workflowCbs) cb(state) + } + ) + } + + /** Each `cal:workflow` push, after it went out (HidService follows the device's presentMask). */ + onWorkflow(cb: (state: CalibrationState) => void): void { + this.workflowCbs.add(cb) + } + + /** The link's state changed (GatewayConnection's onStatus). */ + linkStatus(state: DeviceState): void { + this.workflow.linkStatus(state) + } + + // ── the link's moments (LinkRider) ───────────────────────────────────────── + + /** 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.deps.serialNumber() + 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') + }) + } + + /** 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' } + } + 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 report it to the workflow, 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.workflow.reported(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.workflow.ingest(this.calRawBuf.splice(0, this.calRawBuf.length)) + } +} diff --git a/src/main/hid/calibration/index.ts b/src/main/hid/calibration/index.ts new file mode 100644 index 0000000..385d8cc --- /dev/null +++ b/src/main/hid/calibration/index.ts @@ -0,0 +1,6 @@ +// hid/calibration: the axis calibration channel and its cache. Exports only what other topics use +// (arch-phase0-v2 §2.1). +export { HidCalibrationService } from './hid-calibration-service' +export { loadCalCache, saveCalCache, type CacheFileIO } from './cache-store' +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..988aecf --- /dev/null +++ b/src/main/hid/calibration/ipc.ts @@ -0,0 +1,32 @@ +// 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 } 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 { workflow } = deps.calibration + ipc.handle(CALIBRATION_CTRL.calOpen, (_e, axis: number, review?: boolean) => + workflow.open(axis, review) + ) + 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.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) + ) + // 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..17e4839 --- /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 (the renderer's store tests): 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 answers GET_CAL. + 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..4851f48 --- /dev/null +++ b/src/main/hid/calibration/workflow-host.ts @@ -0,0 +1,370 @@ +// 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 type { DeviceState } from '@shared/gateway' +import { isLinkUp } from '../../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 answers + // GET_CAL, so they would never be corrected. + if (!up) this.setSnapshot(undefined) + }) + } + + /** The device reported its stored calibration (GET_CAL, as calRead reads 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 { + const v = this.view + return v ? v.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 { + const v = this.view + return v ? v.workflow.save() : Promise.resolve() + } + + restore(): Promise { + const v = this.view + return v ? v.workflow.restore() : Promise.resolve() + } + + deleteAxis(idx: number): Promise { + const v = this.view + return v ? v.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/main/hid/hid-facts.test.ts b/src/main/hid/hid-facts.test.ts new file mode 100644 index 0000000..706f15d --- /dev/null +++ b/src/main/hid/hid-facts.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import type { CalSnapshot } from '@shared/hid/calibration' +import { FRESH_MS, hidFacts, RECENT_MS } from './hid-facts' + +/** GET_CAL's answer with `present` slots declared. */ +const snap = (present: number[]): CalSnapshot => ({ + presentMask: present.reduce((m, i) => m | (1 << i), 0), + calibratedMask: 0, + axes: Array.from({ length: 8 }, (_, idx) => ({ + idx, + controlId: 0x10 + idx, + min: 0, + centre: 0, + max: 0, + deadzone: 0, + present: present.includes(idx), + calibrated: false + })), + serialNumber: 'BOARD-A' +}) + +describe('hidFacts (Hid.tsx before R3.3)', () => { + it('bands the report by its age', () => { + expect( + [0, FRESH_MS - 1, FRESH_MS, RECENT_MS - 1, RECENT_MS].map( + (a) => hidFacts(a, undefined, []).band + ) + ).toEqual(['live', 'live', 'idle', 'idle', 'quiet']) + }) + + it('before the device reports: the catalogue alone, nothing declared', () => { + const f = hidFacts(0, undefined, [0, 1, 2]) + expect(f.present).toBe(0) + expect(f.axisLive).toEqual([true, true, true, false, false, false, false, false]) + }) + + it('once it reports: catalogued and declared, and how many it declares', () => { + const f = hidFacts(0, snap([0, 1, 5]), [0, 1, 2]) + expect(f.present).toBe(3) + // Throttle (2) is catalogued but not declared; slot 5 is declared but not catalogued. + expect(f.axisLive).toEqual([true, true, false, false, false, false, false, false]) + }) +}) diff --git a/src/main/hid/hid-facts.ts b/src/main/hid/hid-facts.ts new file mode 100644 index 0000000..abf1d02 --- /dev/null +++ b/src/main/hid/hid-facts.ts @@ -0,0 +1,58 @@ +// What a HID report means for the HID tab (pure, no Node/Electron deps): how fresh it is, and which +// axis slots are live (R3.3). Moved from the renderer so HidService decides +// them; `hid:report` carries them. +import type { CalSnapshot } from '@shared/hid/calibration' +import type { HidFreshness, HidReportFacts } from '@shared/hid' +import { HID_REPORT_LAYOUT } from '@shared/firmware' + +/** + * How recently the device last said anything, as a band. + * + * **Never a fault while the board is attached, deliberately.** The gateway sends a HID report only + * when a control changes, so silence is the normal resting state of a cockpit nobody is touching — + * and a fault colour would cry wolf every time the user stopped moving. The fault is reserved for + * the one case that really is wrong: the board is not there, so nothing *could* arrive — and that + * is the link's call (`device:status` boardPresent), not the report's age. + * + * The thresholds are minutes rather than seconds for the same reason — an axis untouched for a + * minute says nothing about the hardware. They started at 10 and 20 minutes and came down to 1 + * and 5 after bench use: at ten minutes the readout effectively never changed, so it carried no + * information for the cost of a card. + */ +export const FRESH_MS = 60 * 1000 +export const RECENT_MS = 5 * 60 * 1000 + +/** The band for a report `ageMs` old (the board present). */ +export function freshnessBand(ageMs: number): HidFreshness { + if (ageMs < FRESH_MS) return 'live' + if (ageMs < RECENT_MS) return 'idle' + return 'quiet' +} + +/** + * The report's band, and the axis slots the tab shows live. + * + * Two different notions of "this axis exists", answering different questions: + * catalogued (`availAxes`) — what HIDControls.h catalogues, i.e. what the report layout can carry + * presentMask (`snap`) — what THIS gateway's sketch actually declares + * + * An axis is live only if both are true. A catalogued slot the device never declared reads a + * constant 0, and rendering that as a value implies a reading that does not exist — so it gets + * the same dimmed em-dash treatment as an uncatalogued slot. Until the device tells us + * (no gateway, or firmware predating the protocol) we fall back to the catalogue alone. + */ +export function hidFacts( + ageMs: number, + snap: CalSnapshot | undefined, + availAxes: readonly number[] +): HidReportFacts { + const catalogued = new Set(availAxes) + const present = snap ? snap.axes.filter((a) => a.present) : [] + const axisLive = (i: number) => + catalogued.has(i) && (snap ? (snap.axes[i]?.present ?? false) : true) + return { + band: freshnessBand(ageMs), + present: present.length, + axisLive: Array.from({ length: HID_REPORT_LAYOUT.axes.count }, (_, i) => axisLive(i)) + } +} diff --git a/src/main/hid.test.ts b/src/main/hid/hid-reader.test.ts similarity index 90% rename from src/main/hid.test.ts rename to src/main/hid/hid-reader.test.ts index afe65fd..2d638cd 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 = { @@ -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.ts b/src/main/hid/hid-reader.ts similarity index 97% rename from src/main/hid.ts rename to src/main/hid/hid-reader.ts index 9acf857..1111864 100644 --- a/src/main/hid.ts +++ b/src/main/hid/hid-reader.ts @@ -12,9 +12,9 @@ import { HID_BUTTON_COUNT, HID_HAT_COUNT, HID_REPORT_SIZE -} from '@shared/hid' -import type { HidSnapshot } from '@shared/ipc' -import { SIMGATEWAY_PID, SIMGATEWAY_VID } from './serial' +} 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. */ 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/shared/hid.test.ts b/src/main/hid/hid-report.test.ts similarity index 82% rename from src/shared/hid.test.ts rename to src/main/hid/hid-report.test.ts index fead74c..af99a1a 100644 --- a/src/shared/hid.test.ts +++ b/src/main/hid/hid-report.test.ts @@ -1,14 +1,7 @@ import { describe, it, expect } from 'vitest' -import { decodeAxis, isButtonPressed, decodeHat, decodeReport, HID_REPORT_SIZE } from './hid' +import { isButtonPressed, decodeHat, decodeReport, HID_REPORT_SIZE } from './hid-report' describe('HID decode', () => { - it('decodeAxis sign-extends a 16-bit field', () => { - expect(decodeAxis(0x0000)).toBe(0) - expect(decodeAxis(0x7fff)).toBe(32767) - expect(decodeAxis(0x8000)).toBe(-32768) - expect(decodeAxis(0xffff)).toBe(-1) - }) - it('isButtonPressed reads the correct bit', () => { const b = new Uint8Array(16) b[0] = 0b0000_0001 // button 0 diff --git a/src/main/hid/hid-report.ts b/src/main/hid/hid-report.ts new file mode 100644 index 0000000..829b423 --- /dev/null +++ b/src/main/hid/hid-report.ts @@ -0,0 +1,93 @@ +// Pure HID-report decode helpers. Layout is fixed by the SimGateway firmware +// (Firmware/Libraries/SimGateway/SimGateway.cpp:17-21): +// 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. The numbers come from HID_REPORT_LAYOUT (shared/firmware), which the +// sync asserts against that struct. +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 +export const HID_BUTTON_COUNT = HID_REPORT_LAYOUT.buttons.count +export const HID_HAT_COUNT = HID_REPORT_LAYOUT.hats.count + +const BUTTONS_OFFSET = HID_REPORT_LAYOUT.buttons.offset +const HATS_OFFSET = HID_REPORT_LAYOUT.hats.offset +const AXES_OFFSET = HID_REPORT_LAYOUT.axes.offset + +/** Button n lives at byte n/8, bit n%8 of the buttons[] block. */ +export function isButtonPressed(buttons: Uint8Array, n: number): boolean { + if (n < 0 || n >= HID_BUTTON_COUNT) return false + return (buttons[n >> 3]! & (1 << (n & 7))) !== 0 +} + +/** + * USB hat nibble: 0..7 = N/NE/E/SE/S/SW/W/NW, value >= 8 (e.g. 0xF) = null/centred. + * Returns a HAT_DIRS index: 0 = centre, 1..8 = N..NW. + */ +export function decodeHat(nibble: number): number { + return nibble <= HID_REPORT_LAYOUT.hats.centeredAtOrAbove - 1 ? nibble + 1 : 0 +} + +/** Decode a full 34-byte HID report into axes/buttons/hats. */ +export function decodeReport(report: Uint8Array): { + axes: number[] + buttons: boolean[] + hats: number[] +} { + 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 + HID_REPORT_LAYOUT.buttons.bytes + ) + const buttons: boolean[] = [] + for (let i = 0; i < HID_BUTTON_COUNT; i++) buttons.push(isButtonPressed(buttonsBlock, i)) + + const hats: number[] = [] + for (let i = 0; i < HID_HAT_COUNT; i++) { + const byte = report[HATS_OFFSET + (i >> 1)]! + const nibble = i & 1 ? (byte >> 4) & 0xf : byte & 0xf + hats.push(decodeHat(nibble)) + } + + const view = new DataView(report.buffer, report.byteOffset, report.byteLength) + const axes: number[] = [] + for (let i = 0; i < HID_AXIS_COUNT; i++) { + axes.push(view.getInt16(AXES_OFFSET + i * 2, true)) + } + + 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 new file mode 100644 index 0000000..415b988 --- /dev/null +++ b/src/main/hid/hid-service.ts @@ -0,0 +1,133 @@ +// The SimGateway's HID joystick, as the app runs it (its own service since 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 Gateway connection (gateway LinkRider) for those moments; its +// I/O is the HID reader. +import type { Emit } from '@shared/app' +import type { HidReport } from '@shared/hid' +import type { CalSnapshot } from '@shared/hid/calibration' +import type { LinkRider, LinkTimer } from '../gateway' +import { hidFacts } from './hid-facts' +import { HidReader } from './hid-reader' +import { hidAvailability } from './hid-report' + +/** 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 + /** What the device declares, as the HID tab last had it from calibration (`cal:workflow`). */ + private calSnapshot?: CalSnapshot + /** What HIDControls.h catalogues: the report indices the tab can show (fixed per build). */ + private readonly catalogued = hidAvailability().axes + + 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.report(this.hid)) + } + + /** + * Calibration pushed what the device declares (`cal:workflow`, AppServices): the live axes + * follow it now rather than at the next report, as the tab re-rendered on that push (R3.3). + */ + calibrationChanged(snapshot: CalSnapshot | undefined): void { + if (snapshot === this.calSnapshot) return + this.calSnapshot = snapshot + this.flush() + } + + /** + * A renderer (re)loaded (`relay:status`): it gets the report and its live axes now, as it had + * its catalogue from its first pull, rather than at the next tick (R3.3). + */ + rendererLoaded(): void { + this.flush() + } + + /** The reader's snapshot and what it means for the HID tab (hid-facts.ts). */ + private report(hid: HidReader): HidReport { + const snap = hid.snapshot() + return { ...snap, ...hidFacts(snap.ageMs, this.calSnapshot, this.catalogued) } + } + + /** 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.report(this.hid)) + return + } + if (this.hidPushTimer) return + this.hidPushTimer = setTimeout(() => { + this.hidPushTimer = undefined + if (!this.hid) return + this.hidPushedAt = Date.now() + this.emit('hid:report', this.report(this.hid)) + }, HID_PUSH_MS - since) + } +} diff --git a/src/main/hid/index.ts b/src/main/hid/index.ts new file mode 100644 index 0000000..60c0628 --- /dev/null +++ b/src/main/hid/index.ts @@ -0,0 +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 d947d1b..336497f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,295 +1,4 @@ -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 { eventsToTsv } from '@shared/events' -import { HID_CONTROLS, HID_ID } from './reference/hid-controls.generated' -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' +// Main-process entry (electron-vite builds out/main/index.js from here). The app lives in app/. +import { start } from './app' -// 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 < 8) { - 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) { - 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 - ) { - 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()) +start() diff --git a/src/main/node-mapping.ts b/src/main/node-mapping.ts deleted file mode 100644 index 4776053..0000000 --- a/src/main/node-mapping.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Persist the node mapping to /node-mapping.json (shape and rules: shared/node-mapping.ts). -// -// 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, - type NodeMappingFile -} from '@shared/node-mapping' - -/** 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 -} - -function mappingPath(): string { - return join(app.getPath('userData'), 'node-mapping.json') -} - -const empty = (): NodeMappingFile => ({ ...EMPTY_MAPPING, nodes: {} }) -const message = (err: unknown) => (err instanceof Error ? err.message : String(err)) - -export function loadNodeMapping(): LoadedMapping { - const p = mappingPath() - 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: ${message(err)}` } - } - - 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-${new Date().toISOString().replace(/[:.]/g, '-')}` - 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)}` - } - } - } - - 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' } - } - 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): void { - const p = mappingPath() - 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: ${message(err)}`) - } -} diff --git a/src/main/nodes/cockpit-host.test.ts b/src/main/nodes/cockpit-host.test.ts new file mode 100644 index 0000000..3e370ff --- /dev/null +++ b/src/main/nodes/cockpit-host.test.ts @@ -0,0 +1,213 @@ +// The Cockpit snapshot host (R3.2): the peak W the renderer's store tracked (its two store test +// cases, ported with the same pushes and expected values), when it pushes, and the stray timer the +// Cockpit tab ran (nextAt + 50 ms). +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + COCKPIT_IDLE, + type CockpitSnapshot, + type NodeModel, + type NodeTelemSnapshot +} from '@shared/nodes' +import type { DeviceStatus } from '@shared/gateway' +import { modelNode, nodeModel } from '../../test-fixtures/nodes' +import { CockpitSnapshotHost, STRAY_RECHECK_MS } from './cockpit-host' +import { buildCockpitSnapshot } from './cockpit-snapshot' +import { IDENTIFY_WAIT_MS } from './cockpit-snapshot/attention' + +const EMPTY_MODEL: NodeModel = { + nodes: [], + outages: [], + differingCount: 0, + hidden: [], + hiddenNodes: [], + rosterComplete: false +} + +function rig(device: DeviceStatus = { state: 'connected' }) { + const pushed: CockpitSnapshot[] = [] + const link = { device } + const host = new CockpitSnapshotHost((s) => pushed.push(s), { device: () => link.device }) + return { host, pushed, link } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('node state (#63) — the peak W, from the store', () => { + const pduModel: NodeModel = { + ...EMPTY_MODEL, + rosterComplete: true, + nodes: [ + { + nodeId: 3, + name: 'PDU_Left', + console: 'Left', + pduId: null, + kind: 'pdu', + source: 'registry', + availability: 'online', + readings: 'live', + differs: false + } + ] + } + const telem = (i12Ma: number): NodeTelemSnapshot[] => [ + { + nodeId: 3, + isPdu: true, + rail12V: { + kind: 'rail', + page: 1, + flags: 0, + vAvgMv: 12_000, + vMinMv: 11_900, + iAvgMa: i12Ma, + ageMs: 0, + stale: false + }, + other: [], + live: true + } + ] + + it('tracks the session peak of the computed total power', () => { + const { host } = rig() + host.modelPushed(pduModel) + host.telemetryPushed(telem(2000)) + expect(host.state().band.peakW).toBeCloseTo(24) + host.telemetryPushed(telem(1000)) + expect(host.state().band.peakW).toBeCloseTo(24) + }) + + it('peaks over the same PDUs as the Total card: an unadopted PDU does not count', () => { + // Review of #80: one registry PDU and one still in "New on the bus", 29 W each, read + // "Total 29.0 W · peak 58.0 W" — a peak the card could never show. + const withNew: NodeModel = { + ...pduModel, + nodes: [ + ...pduModel.nodes, + { ...pduModel.nodes[0]!, nodeId: 42, name: 'Node 42', console: null, source: 'new' } + ] + } + const { host } = rig() + host.modelPushed(withNew) + host.telemetryPushed([...telem(2417), { ...telem(2417)[0]!, nodeId: 42 }]) + expect(host.state().band.peakW).toBeCloseTo(29, 0) + }) + + it('a renderer reload starts the peak over from what main holds now', () => { + const { host } = rig() + host.modelPushed(pduModel) + host.telemetryPushed(telem(2000)) + host.rendererLoaded(pduModel, telem(1000)) + expect(host.state().band.peakW).toBeCloseTo(12) + }) +}) + +describe('CockpitSnapshotHost', () => { + it('starts idle: the launch state equals what the builder makes of no model', () => { + const { host, pushed } = rig({ state: 'no-device' }) + expect(host.state()).toBe(COCKPIT_IDLE) + expect(pushed).toEqual([]) + const built = buildCockpitSnapshot({ + telemetry: [], + device: { state: 'no-device' }, + peakW: 0, + now: 0, + rev: 0 + }) + expect(built).toEqual({ snapshot: COCKPIT_IDLE }) + }) + + it('pushes after each model, telemetry, link change and renderer load, with a rising rev', () => { + const { host, pushed, link } = rig({ state: 'no-device' }) + host.modelPushed(nodeModel([modelNode(1)])) + host.telemetryPushed([]) + link.device = { state: 'connected' } + host.linkChanged() + host.rendererLoaded(nodeModel([modelNode(1)]), []) + expect(pushed.map((s) => [s.rev, s.live, s.views.length])).toEqual([ + [1, false, 1], + [2, false, 1], + [3, true, 1], + [4, true, 1] + ]) + expect(host.state()).toBe(pushed[3]) + }) + + it('lists the registry its source answers with, in Settings’ rows (R3.2b)', () => { + const pushed: CockpitSnapshot[] = [] + const registry = { + file: 'Firmware/NODE_IDS.md', + commit: 'abc1234', + date: '2026-10-01', + nodes: [{ nodeId: 1, name: 'Center_Armament', console: 'Center', kind: 'panel' as const }] + } + const host = new CockpitSnapshotHost((s) => pushed.push(s), { + device: () => ({ state: 'connected' }), + registry: () => registry + }) + host.modelPushed(nodeModel([modelNode(1)])) + expect(pushed[0]!.settings.registry).toEqual({ + summary: 'NODE_IDS.md · 1 node: 0 PDUs, 1 panel group', + rows: [ + { + nodeId: 1, + name: 'Center_Armament', + console: 'Center', + onBus: { text: 'yes', dot: 'on' }, + unloaded: { text: '—', dot: '' } + } + ] + }) + }) + + it('a board still identifying: rebuilt nextAt + 50 ms later, when it becomes a stray', () => { + vi.useFakeTimers({ now: 10_000 }) + const { host, pushed } = rig() + const fresh = modelNode(9, { source: 'new', console: null, firstSeenAt: 10_000 }) + host.modelPushed(nodeModel([fresh])) + expect(pushed.at(-1)!.attention.items).toEqual([]) + const due = 10_000 + IDENTIFY_WAIT_MS + STRAY_RECHECK_MS + vi.advanceTimersByTime(due - 10_000 - 1) + expect(pushed).toHaveLength(1) + vi.advanceTimersByTime(1) + expect(pushed).toHaveLength(2) + expect(Date.now()).toBe(due) + expect(pushed[1]!.attention.items.map((i) => i.flag)).toEqual([ + 'UNIDENTIFIED · can’t be adopted' + ]) + // Nothing left identifying: no timer. + vi.advanceTimersByTime(60_000) + expect(pushed).toHaveLength(2) + }) + + it('keeps one timer per nextAt, as the renderer effect did, and drops it once nothing waits', () => { + vi.useFakeTimers({ now: 0 }) + const { host, pushed } = rig() + const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0 }) + host.modelPushed(nodeModel([a])) + host.telemetryPushed([]) // same nextAt: the timer stays + vi.advanceTimersByTime(2000) + host.modelPushed(nodeModel([{ ...a, identified: 'panel' }])) // adoptable now: nothing waits + vi.advanceTimersByTime(10_000) + expect(pushed).toHaveLength(3) + host.dispose() + }) +}) + +describe('CockpitSnapshotHost — the strays’ clock', () => { + it('a push inside the 50 ms after a board is due is judged at the old time, as the tab was', () => { + vi.useFakeTimers({ now: 0 }) + const { host, pushed } = rig() + const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0 }) + host.modelPushed(nodeModel([a])) + vi.advanceTimersByTime(IDENTIFY_WAIT_MS + 20) + host.telemetryPushed([]) + expect(pushed.at(-1)!.attention.items).toEqual([]) + vi.advanceTimersByTime(STRAY_RECHECK_MS - 20) + expect(pushed.at(-1)!.attention.items.map((i) => i.nodeId)).toEqual([9]) + host.dispose() + }) +}) diff --git a/src/main/nodes/cockpit-host.ts b/src/main/nodes/cockpit-host.ts new file mode 100644 index 0000000..9b171be --- /dev/null +++ b/src/main/nodes/cockpit-host.ts @@ -0,0 +1,148 @@ +// The Cockpit snapshot as the app runs it (R3.2): what the renderer kept and re-rendered on before +// the move, mirrored here so `cockpit:snapshot` goes out at the same moments with the same inputs. +// - The renderer's store held the last pushed model and telemetry, and the session's peak W +// (setNodes: each push, and the renderer's first pull, raise it); a reload started it over. +// - The Cockpit re-rendered on each of those pushes and on `device:status`, and its stray timer +// re-rendered it `nextAt` + 50 ms after a board still identifying was due. Its clock for the +// strays (its `now` state) moved only then: a push in between was judged at the old time. +// NodeHub hosts one; the component tests' mock hosts one over the scenario, so the Cockpit under +// test draws what this pushes. +import type { DeviceStatus } from '@shared/gateway' +import { + COCKPIT_IDLE, + type CockpitSnapshot, + type NodeModel, + type NodeRegistryInfo, + type NodeTelemSnapshot +} from '@shared/nodes' +import { buildCockpitSnapshot, sessionPeakW } from './cockpit-snapshot' + +/** The renderer's stray timer fired this long after `nextAt` (the Cockpit tab's, before R3.2). */ +export const STRAY_RECHECK_MS = 50 + +export interface CockpitTimers { + setTimeout(fn: () => void, ms: number): unknown + clearTimeout(id: unknown): void +} + +/** The global timers, looked up at each call (so a test's fake timers apply). */ +const SYSTEM_TIMERS: CockpitTimers = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (id) => clearTimeout(id as ReturnType) +} + +/** What the snapshot reads at build time besides the pushes it is handed. */ +export interface CockpitSource { + /** The link as last pushed on `device:status`, the Gateway's health included. */ + device(): DeviceStatus + /** The bundled NODE_IDS registry, as `nodes:registry` answers it (Settings › About). */ + registry?(): NodeRegistryInfo +} + +export interface CockpitHostOptions { + now?: () => number + timers?: CockpitTimers +} + +export class CockpitSnapshotHost { + private model?: NodeModel + private telemetry: NodeTelemSnapshot[] = [] + private peakW = 0 + private rev = 0 + private current: CockpitSnapshot = COCKPIT_IDLE + /** The `nextAt` the stray timer is set for (React's effect dependency before R3.2). */ + private strayAt?: number + /** + * The time strays are judged at: the first snapshot's, a renderer load's, then each time the stray + * timer fires (the Cockpit's `now`, which only its timer moved). + */ + private strayClock?: number + private strayTimer?: unknown + private readonly now: () => number + private readonly timers: CockpitTimers + + constructor( + private readonly push: (snapshot: CockpitSnapshot) => void, + private readonly source: CockpitSource, + opts: CockpitHostOptions = {} + ) { + this.now = opts.now ?? (() => Date.now()) + this.timers = opts.timers ?? SYSTEM_TIMERS + } + + /** `nodes:model` goes out (NodeHub calls this just before it). */ + modelPushed(model: NodeModel): void { + this.model = model + this.peakW = sessionPeakW(this.peakW, model, this.telemetry) + this.publish() + } + + /** NodeHub took the PDU pages (once a second at most, on change). */ + telemetryPushed(telemetry: NodeTelemSnapshot[]): void { + this.telemetry = telemetry + this.peakW = sessionPeakW(this.peakW, this.model, telemetry) + this.publish() + } + + /** `device:status` went out: the link's state or the Gateway's health changed. */ + linkChanged(): void { + this.publish() + } + + /** + * A renderer (re)loaded (`relay:status`, its first pull): its store starts over from what main + * holds now — the model and telemetry its `nodes:get` read — and its peak with them. + */ + rendererLoaded(model: NodeModel, telemetry: NodeTelemSnapshot[]): void { + this.model = model + this.telemetry = telemetry + this.peakW = sessionPeakW(0, model, telemetry) + this.strayClock = this.now() + this.publish() + } + + /** The last snapshot pushed. */ + state(): CockpitSnapshot { + return this.current + } + + /** Stop the stray timer (shutdown). */ + dispose(): void { + if (this.strayTimer !== undefined) this.timers.clearTimeout(this.strayTimer) + this.strayTimer = undefined + this.strayAt = undefined + } + + private publish(): void { + this.strayClock ??= this.now() + const { snapshot, nextAt } = buildCockpitSnapshot({ + model: this.model, + telemetry: this.telemetry, + device: this.source.device(), + peakW: this.peakW, + registry: this.source.registry?.(), + now: this.strayClock, + rev: ++this.rev + }) + this.current = snapshot + this.push(snapshot) + this.schedule(nextAt) + } + + /** A new `nextAt`: the old timer goes, and one is set for it — as the renderer's effect did. */ + private schedule(nextAt: number | undefined): void { + if (nextAt === this.strayAt) return + if (this.strayTimer !== undefined) this.timers.clearTimeout(this.strayTimer) + this.strayTimer = undefined + this.strayAt = nextAt + if (nextAt === undefined) return + this.strayTimer = this.timers.setTimeout( + () => { + this.strayTimer = undefined + this.strayClock = this.now() + this.publish() + }, + Math.max(0, nextAt - this.now()) + STRAY_RECHECK_MS + ) + } +} diff --git a/src/renderer/src/cockpit-adopt.test.ts b/src/main/nodes/cockpit-snapshot/adopt.test.ts similarity index 54% rename from src/renderer/src/cockpit-adopt.test.ts rename to src/main/nodes/cockpit-snapshot/adopt.test.ts index 1603a69..cb71fe8 100644 --- a/src/renderer/src/cockpit-adopt.test.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.test.ts @@ -1,63 +1,26 @@ +// Ported from the renderer (R3.2): the Adopt dialog's queue, evidence, +// adoptability, pre-fill and PDU choices, and the strays, now built in main. Same cases, same +// expected values. import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' -import { TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' -import { nodeViews } from './cockpit' -import { - adoptEvidence, - adoptPduChoices, - adoptability, - adoptQueue, - adoptSpec, - identState, - keptQueue, - nextSelection, - prefill, - registrySpecs, - seenAgo, - strayFlag, - 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 -}) +import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes' +import { modelNode, nodeModel as model, nodeStatus } from '../../../test-fixtures/nodes' +import { TELEM_PAGE } from '../node-telem' +import { adoptEvidence, adoptPduChoices, adoptability, adoptQueue, prefill } from './adopt' +import { strayFlag, strayNew } from './attention' +import { nodeViews } from './views' + +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, @@ -102,15 +65,6 @@ describe('adoptQueue', () => { }) }) -describe('seenAgo', () => { - it('says how long ago this session first saw it', () => { - 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('adoptEvidence', () => { it('a panel group it reported: status only, healthy', () => { const [v] = nodeViews( @@ -160,56 +114,6 @@ describe('adoptEvidence', () => { }) }) -describe('adoptSpec', () => { - it('incomplete until name, console and (for a panel) powered-by are set', () => { - expect(adoptSpec('panel', { name: ' ', console: 'Left', pduId: 3 })).toBeNull() - expect(adoptSpec('panel', { name: 'Left_Lights', console: '', pduId: 3 })).toBeNull() - expect(adoptSpec('panel', { name: 'Left_Lights', console: 'Left', pduId: '' })).toBeNull() - }) - - it('a panel takes its PDU, or None', () => { - expect(adoptSpec('panel', { name: ' Left_Lights ', console: 'Left', pduId: 3 })).toEqual({ - name: 'Left_Lights', - console: 'Left', - pduId: 3, - kind: 'panel' - }) - expect(adoptSpec('panel', { name: 'Spare', console: 'Right', pduId: null })?.pduId).toBeNull() - }) - - it('a PDU asks only name and console, and never has a power zone', () => { - expect(adoptSpec('pdu', { name: 'PDU_Aft', console: 'Right', pduId: '' })).toEqual({ - name: 'PDU_Aft', - console: 'Right', - pduId: null, - kind: 'pdu' - }) - }) -}) - -describe('identState', () => { - it('blue while reporting, red when it drops, green when it is back', () => { - expect(identState(true, false)).toBe('new') - expect(identState(false, true)).toBe('off') - expect(identState(true, true)).toBe('on') - }) -}) - -describe('nextSelection', () => { - const order = [41, 42, 43] - - it('after Adopt or Ignore: the one after it, else the first left', () => { - expect(nextSelection(order, [41, 43], 42, 'done')).toBe(43) - expect(nextSelection(order, [41, 42], 43, 'done')).toBe(41) - expect(nextSelection(order, [], 41, 'done')).toBeNull() - }) - - it('Later moves on and keeps it queued; past the end there is nothing', () => { - expect(nextSelection(order, order, 41, 'later')).toBe(42) - expect(nextSelection(order, order, 43, 'later')).toBeNull() - }) -}) - describe('adoptability', () => { it('only a PDU or a panel group can be adopted', () => { expect(adoptability(fresh(42, { identified: 'pdu' }))).toEqual({ kind: 'pdu', badge: 'PDU' }) @@ -266,35 +170,6 @@ describe('adoptPduChoices (#93 re-review J)', () => { }) }) -describe('keptQueue (#93 re-review)', () => { - const at = (nodes: NodeModelEntry[]) => nodeViews(model(nodes), []) - const ids = (q: ReturnType) => - q.map((v) => [v.entry.nodeId, v.entry.availability]) - - it('keeps a row it showed that dropped out of the model — offline, in its place', () => { - const first = at([fresh(1, { firstSeenAt: 0 }), fresh(41, { firstSeenAt: 500 })]) - const shown = new Map(first.map((v) => [v.entry.nodeId, v])) - // Board 1 is power-cycled: a registry board not added leaves the model while it is off. - const now = at([fresh(41, { firstSeenAt: 500 })]) - expect(ids(keptQueue(now, shown, new Set()))).toEqual([ - [1, 'offline'], - [41, 'online'] - ]) - // It comes back: live again. - expect(ids(keptQueue(first, shown, new Set()))).toEqual([ - [1, 'online'], - [41, 'online'] - ]) - }) - - it('drops a row once it was added, whether it is still in the model or not', () => { - const live = at([fresh(1), fresh(41)]) - const shown = new Map(live.map((v) => [v.entry.nodeId, v])) - expect(ids(keptQueue(live, shown, new Set([1])))).toEqual([[41, 'online']]) - expect(ids(keptQueue(at([fresh(41)]), shown, new Set([1])))).toEqual([[41, 'online']]) - }) -}) - describe('registry onboarding (#93)', () => { const reg = (id: number, over: Partial = {}) => fresh(id, { @@ -325,55 +200,6 @@ describe('registry onboarding (#93)', () => { expect(ev.detected).toBe('panel group (reported by the board)') expect(ev.registry).toBe('NODE_IDS.md: Center_Armament, Instrument panel') }) - - it('"Add all": registry panel groups only, linked only to a PDU already in your cockpit', () => { - const pduIn = fresh(5, { - source: 'registry', - kind: 'pdu', - identified: 'pdu', - name: 'PDU_Center' - }) - const nav = reg(2, { - name: 'Right_Navigation', - registry: { name: 'Right_Navigation', console: 'Right', pduId: 4, pduName: 'PDU_Right' } - }) - const newPdu = reg(3, { - kind: 'pdu', - identified: 'pdu', - registry: { name: 'PDU_Left', console: 'Left', pduId: null } - }) - const queue = nodeViews(model([reg(1), nav, newPdu, fresh(41, { identified: 'panel' })]), []) - const views = nodeViews(model([pduIn, reg(1), nav, newPdu]), []) - expect(registrySpecs(queue, views)).toEqual([ - { nodeId: 1, spec: { name: 'Center_Armament', console: 'Center', pduId: 5, kind: 'panel' } }, - // PDU_Right isn't in your cockpit: no silent link. - { - nodeId: 2, - spec: { name: 'Right_Navigation', console: 'Right', pduId: null, kind: 'panel' } - } - ]) // the PDU (3) and the unregistered board (41) are added one by one - }) - - it('"Add all" takes a form you changed — None, a name, even a PDU not added', () => { - const queue = nodeViews(model([reg(1), reg(2, { name: 'Right_Navigation' })]), []) - const views = nodeViews( - model([fresh(5, { source: 'registry', kind: 'pdu', name: 'PDU_Center' })]), - [] - ) - const edits = new Map([ - [1, { name: 'Arm', console: 'Center', pduId: null }], // None, on purpose - [2, { name: 'Nav', console: 'Right', pduId: 4 }] // its PDU, chosen though not added - ]) - expect( - registrySpecs(queue, views, edits).map((b) => [b.nodeId, b.spec.name, b.spec.pduId]) - ).toEqual([ - [1, 'Arm', null], - [2, 'Nav', 4] - ]) - // A form left unfinished ("Choose…") is not added in bulk. - const half = new Map([[1, { name: 'Arm', console: 'Center', pduId: '' as const }]]) - expect(registrySpecs(queue, views, half).map((b) => b.nodeId)).toEqual([2]) - }) }) describe('strayNew', () => { diff --git a/src/main/nodes/cockpit-snapshot/adopt.ts b/src/main/nodes/cockpit-snapshot/adopt.ts new file mode 100644 index 0000000..696dfa6 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/adopt.ts @@ -0,0 +1,192 @@ +// The Adopt dialog's facts (moved from the renderer, R3.2; since #93 the +// onboarding for every board not in your cockpit): the queue and its order, what each new NODE_ID +// is seen doing, whether it can be adopted, the registry pre-fill and the PDU it would be added +// under. The form itself (what you type) and the dialog's own memory stay in the renderer. +import { + consoleLabel, + type AdoptBadge, + type AdoptForm, + type AdoptRow, + type Evidence, + type NodeKind, + type NodeModelEntry, + type NodeView +} from '@shared/nodes' +import { amps, fmtUptime, volts } from '@shared/format' +import { nodeFaultTag } from '../faults' +import { reportedIdentity } from '../node-kind' +import { displayCurrentMa } from '../node-telem' +import { powerZoneOptions } from './panels' + +/** + * New NODE_IDs, oldest first seen first, then by NODE_ID. Not-yet-seen times sort last. Every new + * node is listed — still identifying, or a kind that can't be adopted — so you can see what each + * one is; only a PDU or a panel group can be adopted (adoptability()). + */ +export function adoptQueue(views: NodeView[]): NodeView[] { + const at = (v: NodeView) => v.entry.firstSeenAt ?? Number.POSITIVE_INFINITY + return views + .filter((v) => v.entry.source === 'new') + .sort((a, b) => at(a) - at(b) || a.entry.nodeId - b.entry.nodeId) +} + +/** + * The Adopt form's Powered by list (#93): the console's PDUs in your cockpit; the chosen one if it is + * in your cockpit on another console, marked "(other console)"; and — only when the chosen PDU is + * not in your cockpit at all — that PDU as `pending`, which the form marks "(not added)". + */ +export function adoptPduChoices( + views: NodeView[], + console: string, + pduId: number | null | '', + e: NodeModelEntry +): { zones: { value: number; label: string }[]; pending?: string } { + const chosen = typeof pduId === 'number' ? pduId : null + const zones = powerZoneOptions(views, console, chosen) + if (chosen === null || zones.some((z) => z.value === chosen)) return { zones } + const waiting = views.find((v) => v.entry.nodeId === chosen) + const name = + waiting?.entry.name ?? + ((e.registry?.pduId === chosen && e.registry.pduName) || `PDU #${chosen}`) + return { zones, pending: name } +} + +export interface Adoptability { + /** Set only when it can be adopted: what it identified as. */ + kind?: NodeKind + badge: AdoptBadge + /** Why it can't be adopted (yet), shown in place of the form. */ + note?: string +} + +/** + * Whether a new node can be adopted, by what it has identified as (entry.identified, the shared + * resolver). A whitelist: a PDU or a panel group can; still identifying, a bridge, or a kind this + * client doesn't know can't (it shows in Attention while it's on the bus, #93). + */ +export function adoptability(e: NodeModelEntry): Adoptability { + switch (e.identified) { + case 'pdu': + return { kind: 'pdu', badge: 'PDU' } + case 'panel': + return { kind: 'panel', badge: 'PANEL GROUP' } + case 'bridge': + return { + badge: 'BRIDGE', + note: '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.' + } + case 'other': + return { + badge: 'UNKNOWN TYPE', + note: `It reports a board type this client doesn’t know (${e.status?.nodeKind ?? '?'}), 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.` + } + default: + return { + badge: 'IDENTIFYING', + note: 'Identifying… waiting for the board to report its type. If it doesn’t within a few seconds, it shows in Attention as an issue.' + } + } +} + +/** "Detected as": the kind and the signal it came from. */ +function detectedAs(e: NodeModelEntry): string { + const said = reportedIdentity(e.status?.nodeKind) + // Not reported (yet): only PDU pages tell — never the NODE_ID or its NODE_IDS.md entry (#93). + const how = said ? 'reported by the board' : 'PDU telemetry seen' + switch (e.identified) { + case 'pdu': + return `PDU (${how})` + case 'panel': + return 'panel group (reported by the board)' + case 'bridge': + return 'Gateway (reported by the board)' + case 'other': + return `unknown board type ${e.status?.nodeKind ?? ''} (reported by the board)`.trim() + default: + return 'not known yet' + } +} + +const rail = (label: string, p?: { vAvgMv: number; iAvgMa: number }) => + 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 { + const e = v.entry + const st = e.status + const detected = detectedAs(e) + const registry = e.registry + ? `NODE_IDS.md: ${e.registry.name}, ${consoleLabel(e.registry.console)}` + : 'not in NODE_IDS.md' + if (e.availability !== 'online' || !st) { + return { detected, reporting: 'not reporting right now', registry } + } + const { rail12V, rail5V } = v.readings + if (e.kind === 'pdu' && (rail12V || rail5V)) { + const rails = [rail('12 V', rail12V), rail('5 V', rail5V)].filter(Boolean).join(', ') + return { detected, reporting: `telemetry: ${rails}`, registry } + } + const faults = [ + st.boff && 'bus-off', + st.degraded && nodeFaultTag(st), + st.overheat && 'overheat', + st.epvf && 'error-passive' + ].filter((f): f is string => !!f) + return { + detected, + reporting: `status only · up ${fmtUptime(st.uptimeSec)} · ${faults.length ? faults.join(', ') : 'healthy'}`, + registry + } +} + +/** + * Where the form starts (#93): a registry board pre-filled from NODE_IDS.md — name, console and its + * default PDU — so adding it is one click; a NODE_ID the registry does not know starts blank. + */ +export function prefill(e: NodeModelEntry, kind: NodeKind): AdoptForm { + const r = e.registry + if (!r) return { name: '', console: '', pduId: '' } + return { name: r.name, console: r.console, pduId: kind === 'pdu' ? null : r.pduId } +} + +/** + * "Add all N panel groups from NODE_IDS.md" (#93): an untouched registry panel group is added with + * its defaults, keeping its default PDU only if that PDU is already in your cockpit (`views`), else + * None — the PDU choice stays explicit (X4 co-located: D7 today, a hidden PDU is not "in your + * cockpit" here). undefined = not one Add all takes (a PDU, or not in NODE_IDS.md). + */ +function addAllPduId(row: NodeView, views: NodeView[]): number | null | undefined { + const inCockpit = (id: number | null) => + id !== null && + views.some((v) => v.entry.nodeId === id && v.entry.kind === 'pdu' && v.entry.source !== 'new') + if (!row.entry.registry || adoptability(row.entry).kind !== 'panel') return undefined + const form = prefill(row.entry, 'panel') + return typeof form.pduId === 'number' && inCockpit(form.pduId) ? form.pduId : null +} + +/** The dialog's queue, each row with what the dialog shows for it. */ +export function adoptRows(views: NodeView[]): AdoptRow[] { + return adoptQueue(views).map((view) => { + const e = view.entry + const able = adoptability(e) + const row: AdoptRow = { + view, + badge: able.badge, + evidence: adoptEvidence(view), + // keptQueue's offline copy of a row that left the model, as the dialog shows it. + goneEvidence: adoptEvidence({ ...view, entry: { ...e, availability: 'offline' } }), + // Only a node identified as a PDU or a panel group gets the form; anything else is shown as-is. + prefill: able.kind ? prefill(e, able.kind) : prefill({ ...e, registry: undefined }, 'panel') + } + if (able.kind) row.kind = able.kind + if (able.note !== undefined) row.note = able.note + const r = e.registry + if (r && r.pduId !== null) { + const { pending } = adoptPduChoices(views, r.console, r.pduId, e) + if (pending !== undefined) row.pending = { pduId: r.pduId, name: pending } + } + const bulk = addAllPduId(view, views) + if (bulk !== undefined) row.addAllPduId = bulk + return row + }) +} diff --git a/src/main/nodes/cockpit-snapshot/attention.ts b/src/main/nodes/cockpit-snapshot/attention.ts new file mode 100644 index 0000000..7645c50 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/attention.ts @@ -0,0 +1,156 @@ +// Cockpit › Attention (moved from the renderer, R3.2): which boards are items and in what order, +// the strays (new boards that can't be adopted), the Gateway's own item, the cap and the "New on +// the bus" count. +import type { GatewayStatus } from '@shared/gateway' +import type { + AttentionItem, + CockpitAttention, + NodeModel, + NodeModelEntry, + NodeView, + PduOutage, + StrayItem +} from '@shared/nodes' +import { lastSeenLabel } from '@shared/format' +import { shownHealth } from '../../gateway' +import { isReportableNew } from '../node-model' +import { adoptability } from './adopt' + +/** "1 node affected" / "3 nodes affected" — a PDU outage, wherever it is shown. */ +export function affected(n: number): string { + return `${n} node${n === 1 ? '' : 's'} affected` +} + +/** + * Most severe first: PDU outages, then offline (incl. bus-off), then warnings — each by NODE_ID. + * Nodes that dropped with their PDU are folded into its outage item. New boards that can be + * adopted have their own prompt; ones that can't (`strays`) are warnings, after the rest. A PDU + * without readings is not an issue (its panel explains). + */ +export function attentionItems( + views: NodeView[], + outages: PduOutage[], + strays: StrayItem[] = [] +): AttentionItem[] { + const folded = new Set(outages.flatMap((o) => o.nodeIds)) + const items: (AttentionItem & { rank: number })[] = [] + for (const v of views) { + const { state, flag } = v.health + if (state !== 'off' && state !== 'warn') continue + const outage = outages.find((o) => o.pduId === v.entry.nodeId) + if (!outage && folded.has(v.entry.nodeId)) continue + const e = v.entry + const seen = + e.notSeen && e.lastSeenAt !== undefined ? ` · last seen ${lastSeenLabel(e.lastSeenAt)}` : '' + items.push({ + key: `n-${e.nodeId}`, + nodeId: e.nodeId, + name: e.name, + state, + flag: outage ? `OFFLINE · ${affected(outage.nodeIds.length)}` : `${flag ?? ''}${seen}`, + outage, + rank: outage ? 0 : state === 'off' ? 1 : 2 + }) + } + for (const s of strays) { + items.push({ + key: `s-${s.nodeId}`, + nodeId: s.nodeId, + name: `NODE_ID ${s.nodeId}`, + state: 'warn', + flag: s.flag, + stray: true, + rank: 3 + }) + } + return items + .sort((a, b) => a.rank - b.rank || a.nodeId - b.nodeId) + .map(({ rank: _rank, ...i }) => i) +} + +/** How many Attention items show before "+N more". */ +export const ATTENTION_SHOWN = 3 + +/** + * "New on the bus · N": new NODE_IDs on the bus that have identified as a PDU or a panel group. + * One still identifying is not news yet (isReportableNew — the Overview and event log agree). + */ +export function newCount(model: NodeModel): number { + return model.nodes.filter(isReportableNew).length +} + +/** + * A normal board reports its kind ~1 s after it appears. Until this has passed, an unidentified + * new board is simply still identifying — not an issue — so it never flashes a warning. + */ +export const IDENTIFY_WAIT_MS = 5000 + +/** + * New boards on the bus that can't be adopted (#93): a kind that can't be adopted (a second + * SimGateway, a type this client doesn't know), or still unidentified after IDENTIFY_WAIT_MS. A + * board on the bus is active, so it can't be hidden — it shows in Attention as a warning instead. + * `nextAt` = when one still identifying will cross the line. + */ +export function strayNew(views: NodeView[], now: number): { strays: NodeView[]; nextAt?: number } { + const strays: NodeView[] = [] + let nextAt: number | undefined + for (const v of views) { + const e = v.entry + if (e.source !== 'new' || e.availability !== 'online' || adoptability(e).kind) continue + const due = (e.firstSeenAt ?? 0) + IDENTIFY_WAIT_MS + if (e.identified || due <= now) strays.push(v) + else nextAt = Math.min(nextAt ?? due, due) + } + return nextAt === undefined ? { strays } : { strays, nextAt } +} + +/** A stray's Attention line: what it is, and that it can't be adopted. */ +export function strayFlag(e: NodeModelEntry): string { + const what = + e.identified === 'bridge' + ? 'SECOND GATEWAY' + : e.identified + ? 'UNKNOWN BOARD TYPE' + : 'UNIDENTIFIED' + return `${what} · can’t be adopted` +} + +/** + * The Gateway's own problem (#94), as its Attention item: none while the link is down (the link + * state says it all — gateway-health's shownHealth) or while it is healthy; err → off, else warn. + */ +function gatewayAttention( + gateway: GatewayStatus | undefined, + linkUp: boolean +): CockpitAttention['gateway'] { + const g = shownHealth(gateway, linkUp) + if (!g || g.severity === 'ok') return undefined + return { flag: g.label, state: g.severity === 'err' ? 'off' : 'warn' } +} + +/** + * The Attention card. Strays and items only while the link is up (X2 co-located: the Cockpit + * says nothing about boards on the bus while unplugged); the Gateway's item takes a shown slot + * first; "New on the bus" counts whatever the last model said. + */ +export function cockpitAttention( + model: NodeModel | undefined, + views: NodeView[], + outages: PduOutage[], + strays: StrayItem[], + live: boolean, + gatewayStatus: GatewayStatus | undefined +): CockpitAttention { + const items = live ? attentionItems(views, outages, strays) : [] + const gateway = gatewayAttention(gatewayStatus, live) + const count = items.length + (gateway ? 1 : 0) + const out: CockpitAttention = { + items, + shown: items.slice(0, ATTENTION_SHOWN - (gateway ? 1 : 0)), + count, + more: Math.max(0, count - ATTENTION_SHOWN), + newCount: model ? newCount(model) : 0 + } + if (gateway) out.gateway = gateway + return out +} diff --git a/src/main/nodes/cockpit-snapshot/band.ts b/src/main/nodes/cockpit-snapshot/band.ts new file mode 100644 index 0000000..e4e7432 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/band.ts @@ -0,0 +1,122 @@ +// The health band (moved from the renderer, R3.2): node counts, the rails' amps and +// watts, the lowest 5 V rail, the hottest PDU, the worst CAN counters and the session's peak W. +import type { CockpitBand, HealthBand, NodeView, PduRailPage } from '@shared/nodes' +import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' +import { displayCurrentMa } from '../node-telem' + +const railW = (p?: PduRailPage) => (p ? (p.vAvgMv / 1000) * (displayCurrentMa(p.iAvgMa) / 1000) : 0) + +/** + * Total W the client computes from the fresh rails (V × A per rail). null = no readings at all. + * Placed nodes only: a PDU still waiting in "New on the bus" reports readings but is not on the + * plan, and the Total card and its session peak must add up the same PDUs. + */ +export function totalWatts(views: NodeView[]): number | null { + let w: number | null = null + for (const v of views) { + if (v.entry.source === 'new') continue + const { rail12V, rail5V } = v.readings + if (rail12V || rail5V) w = (w ?? 0) + railW(rail12V) + railW(rail5V) + } + return w +} + +export function healthBand(views: NodeView[], strays = 0): HealthBand { + const known = views.filter((v) => v.entry.source !== 'new') + const pdus = known.filter((v) => v.entry.kind === 'pdu') + const b: HealthBand = { + online: + known.filter((v) => v.health.state === 'on' || v.health.state === 'warn').length + strays, + total: known.length + strays, + warn: known.filter((v) => v.health.state === 'warn').length + strays, + off: known.filter((v) => v.health.state === 'off').length, + unknown: known.filter((v) => v.health.state === 'unknown').length, + amps12: null, + amps5: null, + watts12: null, + watts: totalWatts(known), + pdusWithout: 0, + pdusWith: 0, + busOff: 0 + } + const add = (cur: number | null, ma: number) => (cur ?? 0) + displayCurrentMa(ma) / 1000 + for (const p of pdus) { + const { rail12V, rail5V, status } = p.readings + if (rail12V || rail5V || status) b.pdusWith++ + else b.pdusWithout++ + if (rail12V) { + b.amps12 = add(b.amps12, rail12V.iAvgMa) + b.watts12 = (b.watts12 ?? 0) + railW(rail12V) + } + if (rail5V) { + b.amps5 = add(b.amps5, rail5V.iAvgMa) + const volts = rail5V.vAvgMv / 1000 + if (!b.low5 || volts < b.low5.volts) { + b.low5 = { volts, nodeId: p.entry.nodeId, name: p.entry.name } + } + } + if (status && status.ntcTempC !== null && (!b.hottest || status.ntcTempC > b.hottest.tempC)) { + b.hottest = { + tempC: status.ntcTempC, + nodeId: p.entry.nodeId, + name: p.entry.name, + level: + status.flags & PDU_FLAG.TEMP_CRIT + ? 'crit' + : status.flags & PDU_FLAG.TEMP_WARN + ? 'warn' + : 'ok' + } + } + } + for (const v of known) { + const st = v.entry.status + if (v.entry.availability !== 'online' || !st) continue + if (st.boff) { + b.busOff++ + b.busOffBoard ??= { nodeId: v.entry.nodeId, name: v.entry.name } + } + const worst = b.worstCan ? Math.max(b.worstCan.tec, b.worstCan.rec) : -1 + if (Math.max(st.tec, st.rec) > worst) { + b.worstCan = { tec: st.tec, rec: st.rec, nodeId: v.entry.nodeId, name: v.entry.name } + } + } + return b +} + +/** + * The CAN errors card's sub-line. A bus-off board is the worst, whatever its counters say (a + * bus-off node can read TEC 0), so it is named first. Otherwise a board is named only when its + * counters are above 0; "no errors" only when nothing is bus-off and every count is 0. + */ +export function canErrorsSub(b: HealthBand): string { + const bus = `${b.busOff} bus-off` + if (b.busOffBoard) return `worst #${b.busOffBoard.nodeId} · ${bus}` + const c = b.worstCan + if (!c) return bus + return Math.max(c.tec, c.rec) > 0 ? `worst #${c.nodeId} · ${bus}` : `no errors · ${bus}` +} + +/** Who the CAN card's "worst" is, for its tooltip; undefined when it names no board. */ +export function canErrorsWorst(b: HealthBand): string | undefined { + if (b.busOffBoard) return b.busOffBoard.name + const c = b.worstCan + return c && Math.max(c.tec, c.rec) > 0 ? c.name : undefined +} + +/** + * The Cockpit's band (X3 co-located: the Cockpit counts its strays — online, warning — the + * Overview's tile does not), the CAN card's value (REC only when above TEC) and the peak W. + */ +export function cockpitBand(views: NodeView[], strays: number, peakW: number): CockpitBand { + const band = healthBand(views, strays) + const can = band.worstCan + const out: CockpitBand = { band, canSub: canErrorsSub(band), peakW } + if (can) { + out.canValue = + can.rec > can.tec ? { counter: 'REC', value: can.rec } : { counter: 'TEC', value: can.tec } + } + const worst = canErrorsWorst(band) + if (worst !== undefined) out.canWorst = worst + return out +} diff --git a/src/renderer/src/cockpit.test.ts b/src/main/nodes/cockpit-snapshot/cockpit.test.ts similarity index 51% rename from src/renderer/src/cockpit.test.ts rename to src/main/nodes/cockpit-snapshot/cockpit.test.ts index 2f5a126..076d7eb 100644 --- a/src/renderer/src/cockpit.test.ts +++ b/src/main/nodes/cockpit-snapshot/cockpit.test.ts @@ -1,60 +1,20 @@ +// Ported from the renderer's cockpit.test.ts (R3.2): the Cockpit's node views, Attention, health +// band and "New on the bus" count, now built in main. Same cases, same expected values. import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' -import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' +import type { NodeModelEntry, NodeTelemSnapshot, PduOutage } from '@shared/nodes' import { - STATE_SYMBOL, - affected, - attentionItems, - canErrorsSub, - canErrorsWorst, - consoleKey, - fmtPct, - headroom, - healthBand, - lastSeenLabel, - newCount, - nodeHealth, - nodeViews, - outageHint, - pduAlertFlags, - planLayout, - psuName, - shortName, - tileMatches, - tileWidth, - 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 -}) + modelNode as node, + nodeModel as model, + nodeStatus as status +} from '../../../test-fixtures/nodes' +import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' +import { TELEM_PAGE } from '../node-telem' +import { lastSeenLabel } from '@shared/format' +import { pduAlertFlags } from '../faults' +import { affected, attentionItems, newCount } from './attention' +import { canErrorsSub, canErrorsWorst, healthBand, totalWatts } from './band' +import { consoleKey } from './plan-zones' +import { nodeHealth, nodeViews } from './views' const pdu = (nodeId: number, console: string, over: Partial = {}) => node(nodeId, { name: `PDU_${console}`, console, kind: 'pdu', readings: 'live', ...over }) @@ -101,20 +61,7 @@ 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: '' }) - }) - it('offline wins over every flag', () => { const e = node(1, { availability: 'offline', status: status(1, { degraded: true }) }) expect(nodeHealth(e)).toEqual({ state: 'off', flag: 'OFFLINE' }) @@ -192,28 +139,6 @@ describe('nodeViews', () => { }) }) -describe('tileMatches', () => { - const [ok, bad] = nodeViews( - model([ - node(1, { name: 'Center_Armament' }), - node(2, { name: 'Right_Navigation', availability: 'offline' }) - ]), - [] - ) - - it('matches name (case-insensitive) or NODE_ID, with or without #', () => { - expect(tileMatches(ok!, 'armam', false)).toBe(true) - expect(tileMatches(ok!, '#1', false)).toBe(true) - expect(tileMatches(ok!, '1', false)).toBe(true) - expect(tileMatches(ok!, 'nav', false)).toBe(false) - }) - - it('Issues keeps only nodes with a problem', () => { - expect(tileMatches(ok!, '', true)).toBe(false) - expect(tileMatches(bad!, '', true)).toBe(true) - }) -}) - describe('consoleKey', () => { it('maps NODE_IDS consoles; anything else lands on the instrument panel', () => { expect(consoleKey('Left')).toBe('L') @@ -224,160 +149,6 @@ describe('consoleKey', () => { }) }) -describe('shortName', () => { - it('drops the console prefix however it is joined, never the whole name', () => { - expect(shortName('Center_Armament')).toBe('Armament') - expect(shortName('Center Armament')).toBe('Armament') - expect(shortName('Left-Fuel')).toBe('Fuel') - expect(shortName('Centerline')).toBe('Centerline') - expect(shortName('Center')).toBe('Center') - }) -}) - -describe('planLayout', () => { - const nodes = [ - node(1, { name: 'Center_Armament', console: 'Center', pduId: 5 }), - node(2, { name: 'Right_Navigation', console: 'Right', pduId: 4 }), - pdu(3, 'Left'), - pdu(4, 'Right'), - pdu(5, 'Center'), - pdu(6, 'Center'), - node(7, { name: 'Center_Clock', console: 'Center', pduId: 6 }), - node(8, { name: 'Left_Loose', console: 'Left', pduId: null }), - node(41, { source: 'new', console: null }) - ] - - it('one group per PDU on its console, then nodes without a power zone', () => { - const l = planLayout(nodeViews(model(nodes), []), []) - expect( - l.groups.map((g) => [g.key, g.console, g.tiles.map((t) => t.view.entry.nodeId)]) - ).toEqual([ - ['pdu-3', 'L', []], - ['none-L', 'L', [8]], - ['pdu-5', 'C', [1]], - ['pdu-6', 'C', [7]], - ['pdu-4', 'R', [2]] - ]) - expect(l.consoles.find((c) => c.key === 'C')!.sub).toBe('2 PDUs') - expect(l.consoles.find((c) => c.key === 'L')!.sub).toBe('') - }) - - it('places a panel under its PDU even when its own console differs', () => { - const l = planLayout( - nodeViews(model([pdu(4, 'Right'), node(1, { console: 'Center', pduId: 4 })]), []), - [] - ) - expect(l.groups).toHaveLength(1) - expect(l.groups[0]!.tiles[0]!.view.entry.nodeId).toBe(1) - }) - - it('tiles fit their names, from the column width up to ~2 columns, then "…" (#93)', () => { - expect(tileWidth('Armament', 99, 206)).toBe(99) // short: the column width - expect(tileWidth('Fuel_Quantity_Gauge', 99, 206)).toBe(172) // room for the symbol always kept - expect(tileWidth('Angle_Of_Attack_Indexer_Lights', 99, 206)).toBe(206) // capped - }) - - it('a board changing state never moves a tile (#93 re-review I)', () => { - const names = ['Left_ARC51_UHF', 'Left_Fuel', 'Left_Trim'] - const at = (state: Partial) => - planLayout( - nodeViews( - model([ - pdu(3, 'Left'), - ...names.map((name, i) => node(10 + i, { name, pduId: 3, ...(i === 0 ? state : {}) })) - ]), - [] - ), - [] - ).groups[0]!.tiles.map((t) => [t.x, t.y, t.w]) - expect(at({ availability: 'offline' })).toEqual(at({})) // ✕ appears: same places, same widths - }) - - it('tiles flow left to right and wrap inside their group', () => { - const names = ['Left_Engine', 'Left_ARC51_UHF_Radio', 'Left_Fuel', 'Left_Trim', 'Left_Gear'] - const l = planLayout( - nodeViews( - model([pdu(3, 'Left'), ...names.map((name, i) => node(10 + i, { name, pduId: 3 }))]), - [] - ), - [] - ) - const g = l.groups[0]! - // Left console: 200 wide, two 96-px columns. ARC51 needs 145, so it takes its row. - expect(g.tiles.map((t) => [t.short, t.x - g.x, t.y - g.tiles[0]!.y, t.w])).toEqual([ - ['Engine', 0, 0, 96], - ['ARC51_UHF_Radio', 0, 42, 145], - ['Fuel', 0, 84, 96], - ['Trim', 104, 84, 96], - ['Gear', 0, 126, 96] - ]) - for (const t of g.tiles) expect(t.x + t.w).toBeLessThanOrEqual(g.x + g.w) - }) - - it('never places new nodes', () => { - const l = planLayout(nodeViews(model(nodes), []), []) - expect(l.groups.flatMap((g) => g.tiles).some((t) => t.view.entry.nodeId === 41)).toBe(false) - }) - - it('strips the console prefix from tile names', () => { - const l = planLayout(nodeViews(model(nodes), []), []) - expect(l.groups.find((g) => g.key === 'pdu-5')!.tiles[0]!.short).toBe('Armament') - }) - - it('lays tiles out in the console’s columns, row by row', () => { - const left = Array.from({ length: 3 }, (_, i) => node(10 + i, { console: 'Left', pduId: 3 })) - const g = planLayout(nodeViews(model([pdu(3, 'Left'), ...left]), []), []).groups[0]! - const [a, b, c] = g.tiles - expect(a!.y).toBe(b!.y) - expect(b!.x).toBeGreaterThan(a!.x) - expect(c!.x).toBe(a!.x) - expect(c!.y).toBeGreaterThan(a!.y) - }) - - it('draws one feed line per PDU, gold when it raises a rail flag', () => { - const l = planLayout( - nodeViews(model(nodes), [telem(4, { flags5: RAIL_FLAG.UNDER_V }), telem(3)]), - [] - ) - expect(l.wires).toHaveLength(4) - expect(l.wires.filter((w) => w.alert)).toHaveLength(1) - }) - - it('grows the stage when a console overflows', () => { - const many = Array.from({ length: 30 }, (_, i) => node(10 + i, { console: 'Left', pduId: 3 })) - const l = planLayout(nodeViews(model([pdu(3, 'Left'), ...many]), []), []) - expect(l.height).toBeGreaterThan(600) - expect(l.psu.y + 52).toBe(l.height) - }) - - it('adds the outage hint under the dropped PDU', () => { - const o: PduOutage = { pduId: 6, nodeIds: [7], fedCount: 1, at: 1 } - const l = planLayout(nodeViews(model(nodes, [o]), []), [o]) - const g = l.groups.find((x) => x.key === 'pdu-6')! - expect(g.outage).toBe(o) - expect(g.hint!.text).toBe( - 'The only node on this PDU lost power with it — a blown 5 V fuse at the PDU looks like this.' - ) - }) -}) - -describe('outageHint', () => { - it('says "N of M" when only some fed nodes dropped', () => { - expect(outageHint({ pduId: 3, nodeIds: [1, 2, 3], fedCount: 4, at: 0 })).toBe( - '3 of 4 nodes on this PDU lost power together — a blown 5 V fuse at the PDU looks like this.' - ) - }) - - it('says "All N" when every fed node dropped, and "The only node" for one', () => { - expect(outageHint({ pduId: 3, nodeIds: [1, 2], fedCount: 2, at: 0 })).toBe( - 'All 2 nodes on this PDU lost power together — a blown 5 V fuse at the PDU looks like this.' - ) - expect(outageHint({ pduId: 3, nodeIds: [1], fedCount: 1, at: 0 })).toBe( - 'The only node on this PDU lost power with it — a blown 5 V fuse at the PDU looks like this.' - ) - }) -}) - describe('attentionItems', () => { it('orders outages, then offline, then warnings, each by NODE_ID', () => { const nodes = [ @@ -517,46 +288,6 @@ describe('newCount', () => { }) }) -describe('headroom', () => { - it('is null when the limit is unknown, so the card keeps its plain total', () => { - expect(headroom(2.5, undefined)).toBeNull() - expect(headroom(null, undefined)).toBeNull() - }) - - it('keeps the limit with an empty bar when there is no reading', () => { - expect(headroom(null, 54)).toEqual({ limit: 54, pct: null, bar: 0 }) - }) - - it('measures the value against the limit', () => { - expect(headroom(0, 20)).toEqual({ limit: 20, pct: 0, bar: 0 }) - expect(headroom(5, 20)).toEqual({ limit: 20, pct: 25, bar: 25 }) - expect(headroom(650, 650)).toEqual({ limit: 650, pct: 100, bar: 100 }) - }) - - it('caps the bar but not the share when over the limit', () => { - expect(headroom(22.4, 20)).toMatchObject({ bar: 100 }) - expect(headroom(22.4, 20)?.pct).toBeCloseTo(112) - }) -}) - -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('psuName', () => { - it('names a preset by its model and a custom supply plainly', () => { - expect(psuName({ mode: 'preset', preset: 'nx650', custom: {} })).toBe('NX650') - expect(psuName({ mode: 'custom', preset: 'nx650', custom: { w: 500 } })).toBe('PSU') - }) -}) - describe('affected', () => { it('reads "1 node affected", and the plural from 2', () => { expect(affected(1)).toBe('1 node affected') diff --git a/src/renderer/src/connection-summary.test.ts b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts similarity index 68% rename from src/renderer/src/connection-summary.test.ts rename to src/main/nodes/cockpit-snapshot/connection-summary.test.ts index e90ac63..009796d 100644 --- a/src/renderer/src/connection-summary.test.ts +++ b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts @@ -1,40 +1,17 @@ +// Ported from the renderer's connection-summary.test.ts (R3.2b): Connection › Connected nodes, now +// built in main (summaries.ts). Same cases, same expected values; the one that also checks the +// board symbols (a renderer table) stays in the renderer's node-view.test.ts. import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/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 -}) +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 '../node-telem' +import { fmtTime, lastSeenLabel } from '@shared/format' +import { nodesSummary } from './summaries' +import { nodeViews } from './views' const pdu = (nodeId: number, console: string, over: Partial = {}) => node(nodeId, { name: `PDU_${console}`, console, kind: 'pdu', readings: 'live', ...over }) @@ -59,15 +36,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[] = [], @@ -118,26 +86,6 @@ describe('nodesSummary counts', () => { }) describe('nodesSummary rows', () => { - it('shows the 3 most severe in Cockpit Attention order, with symbols', () => { - const nodes = [ - node(1, { status: status(1, { epvf: true }) }), - node(2, { availability: 'offline', offlineAt: T }), - node(9, { status: status(9, { boff: true }) }), - node(10, { status: status(10, { overheat: true }) }), - node(11) - ] - const s = summary(nodes) - const att = attentionItems(nodeViews(model(nodes), []), []) - expect(s.rows.map((r) => r.nodeId)).toEqual(att.slice(0, 3).map((i) => i.nodeId)) - expect(s.rows.map((r) => [r.nodeId, r.chip, r.tone, STATE_SYMBOL[r.state]])).toEqual([ - [2, 'OFFLINE', 'err', '✕'], - [9, 'BUS-OFF', 'err', '✕'], - [1, 'ERR-PASSIVE', 'warn', '!'] - ]) - expect(s.more).toBe(1) - expect(s.footer).toBe('+1 more · healthy nodes hidden') - }) - it('a board not seen this session: no false "offline since", but when it was last seen (#93)', () => { const t = Date.UTC(2026, 9, 14, 21, 13) const s = summary([ diff --git a/src/main/nodes/cockpit-snapshot/index.ts b/src/main/nodes/cockpit-snapshot/index.ts new file mode 100644 index 0000000..4d77b69 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/index.ts @@ -0,0 +1,119 @@ +// The Cockpit snapshot (arch-phase0-v2 §1.2, R3.2): everything the Cockpit tab decides, built once +// in main and pushed on `cockpit:snapshot`. Pure: the time and the revision are handed in. +// +// Where the Cockpit and another surface answer differently today, this builder keeps the Cockpit's +// own answer as its own function (X1–X8 option a, co-located, not unified — the fix track does +// that): +// X1 hidden in cockpit the PDU choices, Add all and the plan see the boards on the plan only: +// a hidden PDU is not "in your cockpit" here (adopt.ts addAllPduId, +// panels.ts powerZoneOptions / firstPduOn, plan-zones.ts planZones) +// X2 on the bus unplugged strays and Attention items only while the link is up +// (attention.ts cockpitAttention, here) +// X3 strays counted in the Cockpit's band, online and warning (band.ts +// cockpitBand); the "New on the bus" count is isReportableNew +// (attention.ts newCount); strays wait IDENTIFY_WAIT_MS (strayNew) +// X4 zone choices powerZoneOptions / firstPduOn (panel Settings), adoptPduChoices +// "(not added)" and addAllPduId (Adopt), poweredBy any source (panels.ts +// boardPanels), No power zone for a PDU not on the plan (planZones) +// X5 Forget / Hide offered once the model says offline (panels.ts boardActions); main's +// own guard is NodeHub.onlineRefusal +// X6 fault order the tile and Attention (views.ts nodeHealth), the fault boxes +// (panels.ts nodeFaultBoxes / pduFaultBoxes), the Adopt evidence +// (adopt.ts adoptEvidence) +// X7 fed boards a PDU's Feeds and what Forget unlinks (panels.ts boardPanels); an +// outage's fed count is power-zone.ts pduOutages +// X8 registry wording NOT IN REGISTRY / "Not in registry" for a new board (views.ts, +// panels.ts) +// +// The Overview, Connection and Settings read their own sections (R3.2b), each with its own answer +// where it differs from the Cockpit's — the same option a, co-located: +// X1 in cockpit Settings' Reset… misses a hidden adopted board (registry-rows.ts +// settingsNodes) +// X2 on the bus unplugged the Overview's banner and notice only while the link is up; Connected +// nodes and the registry's CAN rows read the last model whatever the link +// (summaries.ts, registry-rows.ts registryOnBus) +// X3 strays the Overview and Connection count none (summaries.ts: healthBand and +// attentionItems with no strays); the notice is isReportableNew +// (newDevices) +// X4 zone a Connected nodes row names its zone's PDU wherever it is, else "no +// power zone" (summaries.ts nodesSummary) +// X8 registry wording the registry's bridge row is "Gateway" (registry-rows.ts registryName) +import type { DeviceStatus } from '@shared/gateway' +import { isLinkUp } from '../../gateway' +import type { + CockpitSnapshot, + NodeModel, + NodeRegistryInfo, + NodeTelemSnapshot, + StrayItem +} from '@shared/nodes' +import { adoptRows } from './adopt' +import { cockpitAttention, strayFlag, strayNew } from './attention' +import { cockpitBand, totalWatts } from './band' +import { boardPanels, zoneChoices } from './panels' +import { planZones } from './plan-zones' +import { settingsNodes } from './registry-rows' +import { nodesSummary, overviewSummary } from './summaries' +import { nodeViews } from './views' + +export interface CockpitSnapshotInput { + /** The node model as last pushed (`nodes:model`); undefined before the first. */ + model?: NodeModel + /** The PDU pages as NodeHub last took them. */ + telemetry: NodeTelemSnapshot[] + /** The link and the Gateway's health, as last pushed (`device:status`). */ + device: DeviceStatus + /** The highest total W this session so far (sessionPeakW). */ + peakW: number + /** The bundled NODE_IDS registry (`nodes:registry`), for Settings › About. */ + registry?: NodeRegistryInfo + now: number + rev: number +} + +export interface CockpitBuild { + snapshot: CockpitSnapshot + /** When a new board still identifying becomes a stray: build again then (strayNew's nextAt). */ + nextAt?: number +} + +export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { + const views = i.model ? nodeViews(i.model, i.telemetry) : [] + const outages = i.model?.outages ?? [] + const live = isLinkUp(i.device.state) + // New boards that can't be adopted are active on the bus, so they are warnings (#93) — once an + // unidentified one has had IDENTIFY_WAIT_MS to report its kind, so a normal board never flashes. + const stray = strayNew(views, i.now) + const strays: StrayItem[] = live + ? stray.strays.map((v) => ({ nodeId: v.entry.nodeId, flag: strayFlag(v.entry) })) + : [] + const snapshot: CockpitSnapshot = { + rev: i.rev, + live, + views, + outages, + plan: planZones(views, outages), + band: cockpitBand(views, strays.length, i.peakW), + attention: cockpitAttention(i.model, views, outages, strays, live, i.device.gateway), + panels: boardPanels(views, outages), + zones: zoneChoices(views), + adopt: adoptRows(views), + overview: overviewSummary(views, outages, live), + nodesCard: nodesSummary(views, outages), + settings: settingsNodes(i.model, i.registry, live) + } + return stray.nextAt === undefined ? { snapshot } : { snapshot, nextAt: stray.nextAt } +} + +/** + * The session's peak W after one more push (the renderer store's rule before R3.2): the total over + * the placed PDUs' fresh rails, never going down. + */ +export function sessionPeakW( + peak: number, + model: NodeModel | undefined, + telemetry: NodeTelemSnapshot[] +): number { + const w = model ? (totalWatts(nodeViews(model, telemetry)) ?? 0) : 0 + return Math.max(peak, w) +} diff --git a/src/renderer/src/overview.test.ts b/src/main/nodes/cockpit-snapshot/overview-summary.test.ts similarity index 79% rename from src/renderer/src/overview.test.ts rename to src/main/nodes/cockpit-snapshot/overview-summary.test.ts index 7b03fcd..354e158 100644 --- a/src/renderer/src/overview.test.ts +++ b/src/main/nodes/cockpit-snapshot/overview-summary.test.ts @@ -1,39 +1,24 @@ +// Ported from the renderer (R3.2b): the Overview's Cockpit tile, Attention +// banner and new-devices count, now built in main (summaries.ts). Same cases, same expected values; +// the notice's wording (newDevicesText) stays in the renderer with its own two cases. import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry, PduOutage } from '@shared/node-rules' -import type { NodeStatus } from '@shared/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 -}) +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 '../node-telem' +import { attentionItems } from './attention' +import { healthBand } from './band' +import { + attentionSummary, + cockpitTile, + newDevices, + overviewSummary, + reportedKind +} from './summaries' +import { nodeViews } from './views' const pdu = (nodeId: number, over: Partial = {}) => node(nodeId, { @@ -68,15 +53,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)) @@ -237,26 +213,6 @@ describe('newDevices', () => { }) describe('newDevicesText', () => { - it('lists the reported kinds', () => { - expect(newDevicesText({ count: 1, panels: 0, pdus: 1, registry: 0 })).toEqual({ - head: '1 new device on the bus', - detail: '1 PDU, not in NODE_IDS.md.' - }) - expect(newDevicesText({ count: 3, panels: 1, pdus: 2, registry: 0 })).toEqual({ - head: '3 new devices on the bus', - detail: '1 panel group, 2 PDUs, not in NODE_IDS.md.' - }) - }) - - it('a registry board waiting to be added is from NODE_IDS.md, not "not in" it (#93)', () => { - expect(newDevicesText({ count: 1, panels: 1, pdus: 0, registry: 1 }).detail).toBe( - '1 panel group from NODE_IDS.md, not added to your cockpit yet.' - ) - expect(newDevicesText({ count: 3, panels: 2, pdus: 1, registry: 2 }).detail).toBe( - '2 panel groups, 1 PDU, not added to your cockpit yet — 2 from NODE_IDS.md.' - ) - }) - it('counts the registry boards among them', () => { const reg = fresh(1, { identified: 'panel', @@ -270,3 +226,29 @@ describe('newDevicesText', () => { }) }) }) + +// New in R3.2b: the section the snapshot pushes, composed as the Overview tab composed it. +describe('overviewSummary', () => { + const off = node(2, { availability: 'offline' }) + const waiting = fresh(42, { kind: 'pdu', identified: 'pdu', everHadReadings: true }) + + it('is the tile, the banner and the notice over the band and items without strays', () => { + const views = nodeViews(model([node(1), off, waiting]), []) + const band = healthBand(views) + expect(overviewSummary(views, [], true)).toEqual({ + tile: cockpitTile(band, true), + banner: attentionSummary(band, attentionItems(views, [])), + newDevices: newDevices(views, true) + }) + expect(overviewSummary(views, [], true).banner).not.toBeNull() + }) + + it('has no banner and no notice while the link is down; the tile says so', () => { + const views = nodeViews(model([node(1), off, waiting]), []) + expect(overviewSummary(views, [], false)).toEqual({ + tile: { value: '—', sub: 'no live node status', tone: 'none' }, + banner: null, + newDevices: null + }) + }) +}) diff --git a/src/renderer/src/cockpit-panels.test.ts b/src/main/nodes/cockpit-snapshot/panels.test.ts similarity index 85% rename from src/renderer/src/cockpit-panels.test.ts rename to src/main/nodes/cockpit-snapshot/panels.test.ts index e677a37..336cb5d 100644 --- a/src/renderer/src/cockpit-panels.test.ts +++ b/src/main/nodes/cockpit-snapshot/panels.test.ts @@ -1,52 +1,29 @@ +// Ported from the renderer (R3.2): what each board's side panel says and +// offers, now built in main. Same cases, same expected values. import { describe, expect, it } from 'vitest' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' -import type { NodeStatus } from '@shared/nodes' -import { PDU_FLAG, RAIL_FLAG, TELEM_PAGE, type NodeTelemSnapshot } from '@shared/node-telem' -import { lastSeenLabel, nodeViews } from './cockpit' +import type { NodeModelEntry, NodeStatus, NodeTelemSnapshot } from '@shared/nodes' +import { CONSOLE_OPTIONS, consoleLabel } from '@shared/nodes' +import { modelNode, nodeModel as model, nodeStatus } from '../../../test-fixtures/nodes' +import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' +import { TELEM_PAGE } from '../node-telem' +import { lastSeenLabel } from '@shared/format' import { boardActions, - CONSOLE_OPTIONS, - consoleLabel, firstPduOn, - mappingPatch, nodeFaultBoxes, nodeStatusRows, pduBoardRows, pduFaultBoxes, pduRailRows, - powerZoneOptions, - reportAge -} from './cockpit-panels' + powerZoneOptions +} from './panels' +import { nodeViews } from './views' -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,27 +75,8 @@ 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', () => { - it('ticks between the bridge’s 5 s reports, and says when it stopped', () => { - const e = node(1, { reportedAt: 10_000 }) - expect(reportAge(e, 13_400)).toBe('updated 3 s ago') - expect(reportAge(e, 130_000)).toBe('updated 2 min ago') - expect(reportAge({ ...e, availability: 'offline' }, 40_000)).toBe('last report 30 s ago') - expect(reportAge(node(1), 40_000)).toBe('') // never reported this session - }) -}) - describe('boardActions', () => { const off = { availability: 'offline' as const } @@ -450,26 +408,4 @@ describe('placement', () => { expect(firstPduOn(views, 'Center')).toBe(5) expect(firstPduOn(views, 'Right')).toBeNull() }) - - it('Save sends only what changed', () => { - const e = node(1, { name: 'Center_Armament', console: 'Center', pduId: 5 }) - expect(mappingPatch(e, { name: 'Center_Armament ', console: 'Center', pduId: 5 })).toEqual({}) - expect(mappingPatch(e, { name: 'Arm', console: 'Left', pduId: 3 })).toEqual({ - name: 'Arm', - console: 'Left', - pduId: 3 - }) - expect(mappingPatch(e, { name: 'Center_Armament', console: 'Center', pduId: null })).toEqual({ - pduId: null - }) - }) - - it('a PDU renames and moves console, never takes a power zone (#93)', () => { - const p = pdu(3, 'Left') - expect(mappingPatch(p, { name: 'PDU_Port', console: 'Right', pduId: 4 })).toEqual({ - name: 'PDU_Port', - console: 'Right' - }) - expect(mappingPatch(p, { name: p.name, console: 'Left', pduId: 4 })).toEqual({}) - }) }) diff --git a/src/renderer/src/cockpit-panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts similarity index 70% rename from src/renderer/src/cockpit-panels.ts rename to src/main/nodes/cockpit-snapshot/panels.ts index ee631e9..ba662dd 100644 --- a/src/renderer/src/cockpit-panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -1,33 +1,29 @@ -// 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 { nodeFaultTag } from '@shared/nodes' -import { lastSeenLabel, pduAlertFlags, type NodeView } from './cockpit' -import { fmtUptime } from './store' - -const DASH = '—' +// The Cockpit's side panels (moved from the renderer, R3.2; +// one design for every board since #93): which actions a board offers, what its fault boxes say, +// the rows each panel shows, which PDU feeds a panel group and which boards a PDU feeds, and the +// power zones a panel can be given. Every value is something a board reported or the client +// computed, labelled as such. +import { DASH, fmtUptime, lastSeenLabel } from '@shared/format' +import { + CONSOLE_OPTIONS, + type BoardAction, + type BoardActions, + type BoardPanel, + type FaultBox, + type NodeModelEntry, + type NodeView, + type PduOutage, + type PduRailPage, + type RailRow, + type Row, + type ZoneChoices +} from '@shared/nodes' +import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' +import { nodeFaultTag, pduAlertFlags, pduFlagMeanings } from '../faults' +import { displayCurrentMa } from '../node-telem' // ── board actions ─────────────────────────────────────────────────────────── -/** A footer button: shown, and when it can't be used, why (its tooltip). */ -export interface BoardAction { - disabled: boolean - tip: string -} - -/** The footer every board panel shares (#93), besides Save. */ -export interface BoardActions { - /** Registry boards only: back to the NODE_IDS.md name, console and power zone. */ - reset?: BoardAction - forget: BoardAction - hide: BoardAction - /** The line under the footer: what Forget and Hide do here, or why they can't. */ - note: string -} - const ONLINE_NOTE = 'Online — it’s active on the bus, so it can’t be removed. Forget and Hide work once it’s offline.' /** No complete roster yet: whether it is on the bus is not known — not "online" (#93 review). */ @@ -36,7 +32,9 @@ const NO_ROSTER_NOTE = /** * Same rules for a panel group and a PDU (#93). Hide and Forget only tidy the display, and a board - * that is online is active on the bus, so both wait until it is offline (main refuses them too). + * that is online is active on the bus, so both wait until it is offline (main refuses them too — + * X5 co-located: this offers them only once the model says offline; NodeHub.onlineRefusal refuses + * only while the roster lists the board present). * `fed` = boards in your cockpit this PDU feeds (Forget unlinks them; Hide keeps the links). */ export function boardActions(e: NodeModelEntry, fed = 0): BoardActions { @@ -96,26 +94,8 @@ function notSeenBox(e: NodeModelEntry): FaultBox { } } -/** - * How fresh "Reported by the node" is: the bridge resends the roster every 5 s, so the values - * change in steps — this ticks between them instead of inventing values. "updated 3 s ago" while it - * reports; "last report 2 min ago" once it stopped; nothing when this session never heard from it. - */ -export function reportAge(e: NodeModelEntry, now: number): string { - if (e.reportedAt === undefined) return '' - const s = Math.max(0, Math.floor((now - e.reportedAt) / 1000)) - const ago = s < 60 ? `${s} s ago` : `${Math.floor(s / 60)} min ago` - return e.availability === 'online' ? `updated ${ago}` : `last report ${ago}` -} - // ── fault box ─────────────────────────────────────────────────────────────── -export interface FaultBox { - tone: 'warn' | 'err' | 'new' - title: string - text: string -} - /** * The boxes at the top of the node panel's Status tab, stacked; empty when there is nothing to * explain. New, no-status-yet and offline each stand alone. Otherwise every flag the node raised @@ -182,34 +162,6 @@ export function nodeFaultBoxes(v: NodeView): FaultBox[] { return out } -/** Flag meanings as NodeStatus.h states them. */ -const RAIL_MEANING: [number, string][] = [ - [RAIL_FLAG.UNDER_V, 'below its under-voltage threshold'], - [RAIL_FLAG.OVER_V, 'above its over-voltage threshold'], - [RAIL_FLAG.OVER_CURRENT, 'drawing sustained current above the over-current threshold'], - [RAIL_FLAG.FUSE_OPEN, 'reading ~0 V while the node is powered — fuse blown'], - [RAIL_FLAG.SENSOR_FAULT, 'reading implausibly on its voltage or current channel'] -] -const STATUS_MEANING: [number, string][] = [ - [PDU_FLAG.TEMP_CRIT, 'board temperature at the critical threshold'], - [PDU_FLAG.TEMP_WARN, 'board temperature at the warning threshold'], - [PDU_FLAG.NTC_FAULT, 'NTC channel open or shorted'] -] - -/** What each raised PDU flag means, in plain words. */ -export function pduFlagMeanings(v: NodeView): string[] { - const out: string[] = [] - const rail = (label: string, p?: PduRailPage) => { - for (const [bit, m] of RAIL_MEANING) - if (p && p.flags & bit) out.push(`the ${label} rail is ${m}`) - } - rail('12 V', v.readings.rail12V) - rail('5 V', v.readings.rail5V) - const s = v.readings.status - for (const [bit, m] of STATUS_MEANING) if (s && s.flags & bit) out.push(m) - return out -} - /** * The boxes at the top of the PDU panel, stacked. Offline says it all on its own. Otherwise the * PDU's own node faults (the same as any node's) come first, then its rail / status flags, then @@ -253,11 +205,6 @@ export function pduFaultBoxes(v: NodeView, outage: boolean): FaultBox[] { // ── rows ──────────────────────────────────────────────────────────────────── -export interface Row { - k: string - v: string -} - /** "Reported by the node" on the Status tab. An offline node keeps what it last said. */ export function nodeStatusRows(v: NodeView): Row[] { const st = v.entry.status @@ -291,17 +238,6 @@ export function nodeStatusRows(v: NodeView): Row[] { ] } -export interface RailRow { - rail: '12 V' | '5 V' - avg: string - min: string - amps: string - peak: string - /** Client-computed V × A; '' when there is nothing to compute. */ - watts: string - flagged: boolean -} - /** "Rails · live" on the PDU panel: per rail avg V / min V / A / peak A over the 3 s window. */ export function pduRailRows(v: NodeView): RailRow[] { const show = v.entry.availability === 'online' && !v.noReadings @@ -351,18 +287,7 @@ export function pduBoardRows(v: NodeView): Row[] { ] } -// ── placement (Settings tab) ──────────────────────────────────────────────── - -/** NODE_IDS.md console names, as the mapping stores them. */ -export const CONSOLE_OPTIONS = [ - { value: 'Left', label: 'Left console' }, - { value: 'Center', label: 'Instrument panel' }, - { value: 'Right', label: 'Right console' } -] as const - -export function consoleLabel(console: string | null): string { - return CONSOLE_OPTIONS.find((c) => c.value === console)?.label ?? console ?? DASH -} +// ── power zones ───────────────────────────────────────────────────────────── /** * The PDUs a panel on `console` can be fed by: the placed PDUs on that console (a PDU on the left @@ -393,17 +318,91 @@ export function firstPduOn(views: NodeView[], console: string): number | null { return pdu ? pdu.entry.nodeId : null } -export interface PlacementForm { - name: string - console: string - pduId: number | null +/** + * Every Power zone list the forms can ask for (X4 co-located: the panel's Settings tab and the + * Adopt form both read these, through `powerZoneOptions` and `firstPduOn`): for each console a + * form can hold — the three NODE_IDS.md consoles, none yet, and any console a board or its + * NODE_IDS.md row names — with no zone (or one not in your cockpit), and with each PDU in your + * cockpit as the stored zone. + */ +export function zoneChoices(views: NodeView[]): ZoneChoices { + const consoles = new Set(['', ...CONSOLE_OPTIONS.map((c) => c.value)]) + for (const v of views) { + if (v.entry.console !== null) consoles.add(v.entry.console) + if (v.entry.registry) consoles.add(v.entry.registry.console) + } + const placed = views + .filter((v) => v.entry.kind === 'pdu' && v.entry.source !== 'new') + .map((v) => v.entry.nodeId) + const out: ZoneChoices = { options: {}, first: {} } + for (const c of consoles) { + const byZone: Record = { + '': powerZoneOptions(views, c, null) + } + for (const id of placed) byZone[id] = powerZoneOptions(views, c, id) + out.options[c] = byZone + out.first[c] = firstPduOn(views, c) + } + return out +} + +// ── the panels ────────────────────────────────────────────────────────────── + +/** A fed board's state in the Feeds list: its flag, else online / no status yet. */ +function feedState(v: NodeView): string { + const e = v.entry + if (e.notSeen) return 'not seen this session' + if (v.health.flag) return v.health.flag + return v.health.state === 'unknown' ? 'no status yet' : 'online' } -/** Only what changed, so Save writes nothing when nothing did. A PDU has no power zone. */ -export function mappingPatch(e: NodeModelEntry, f: PlacementForm): MappingPatch { - const p: MappingPatch = {} - if (f.name.trim() !== e.name) p.name = f.name.trim() - if (f.console !== (e.console ?? '')) p.console = f.console - if (e.kind === 'panel' && f.pduId !== e.pduId) p.pduId = f.pduId - return p +/** + * Every board's side panel. A panel group is powered by the PDU its zone names, placed or not (X4 + * co-located: the plan puts it under that PDU only once the PDU is placed). A PDU feeds the panel + * groups in your cockpit whose zone it is; that count is what Forget says it unlinks (X7 + * co-located: an outage counts the fed boards seen this session, pduOutages). + */ +export function boardPanels(views: NodeView[], outages: PduOutage[]): Record { + const out: Record = {} + for (const v of views) { + const e = v.entry + const placed = e.source !== 'new' + let p: BoardPanel + if (e.kind === 'pdu') { + const feeds = views.filter( + (n) => n.entry.kind === 'panel' && n.entry.source !== 'new' && n.entry.pduId === e.nodeId + ) + p = { + placed, + faults: pduFaultBoxes( + v, + outages.some((o) => o.pduId === e.nodeId) + ), + actions: boardActions(e, feeds.length), + rails: pduRailRows(v), + board: pduBoardRows(v), + feeds: feeds.map((n) => ({ nodeId: n.entry.nodeId, detail: feedState(n) })), + sharedWith: views + .filter( + (o) => + o.entry.kind === 'pdu' && + o.entry.source !== 'new' && + o.entry.console === e.console && + o.entry.nodeId !== e.nodeId + ) + .map((o) => o.entry.name) + } + } else { + const pdu = views.find((o) => o.entry.kind === 'pdu' && o.entry.nodeId === e.pduId) + p = { + placed, + faults: nodeFaultBoxes(v), + actions: boardActions(e), + status: nodeStatusRows(v) + } + if (pdu) p.poweredBy = pdu.entry.nodeId + } + out[e.nodeId] = p + } + return out } diff --git a/src/main/nodes/cockpit-snapshot/plan-zones.ts b/src/main/nodes/cockpit-snapshot/plan-zones.ts new file mode 100644 index 0000000..6ecaff7 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/plan-zones.ts @@ -0,0 +1,80 @@ +// The plan's grouping (moved from the renderer's planLayout, R3.2): which boards are +// drawn, the power zone each sits in and on which console, the outage under a PDU, and which feed +// lines go gold. The renderer lays these groups out (plan-layout.ts); it decides none of them. +import type { + ConsoleKey, + NodeView, + PduOutage, + PduReadings, + PlanConsole, + PlanGroupHead, + PlanZone +} from '@shared/nodes' + +/** NODE_IDS.md consoles are `Left` / `Center` / `Right`; anything else lands on the panel. */ +export function consoleKey(console: string | null): ConsoleKey { + const c = (console ?? '').toLowerCase() + if (c.startsWith('left')) return 'L' + if (c.startsWith('right')) return 'R' + return 'C' +} + +function railAlert(r: PduReadings): boolean { + return !!(r.rail12V?.flags || r.rail5V?.flags) +} + +/** + * A PDU group's header: OFFLINE overrides its flags; a rail value shows only while the PDU is up + * with readings, gold while the PDU flags that rail. + */ +function groupHead(p: NodeView): PlanGroupHead { + const off = p.health.state === 'off' + const dash = off || p.noReadings + const r12 = p.readings.rail12V + const r5 = p.readings.rail5V + const head: PlanGroupHead = { + off, + flags: off ? ['OFFLINE'] : p.health.state === 'warn' ? (p.health.flag ?? '').split(' · ') : [], + noReadings: p.noReadings && !off + } + if (!dash && r12) head.rail12V = { mv: r12.vAvgMv, alert: !!r12.flags } + if (!dash && r5) head.rail5V = { mv: r5.vAvgMv, alert: !!r5.flags } + return head +} + +/** + * One group per PDU on the PDU's console, then the console's nodes without a power zone. A panel + * sits under the PDU that feeds it, wherever its own console is; new nodes are not placed (they + * wait in "New on the bus"). X4 co-located: a panel whose zone is a PDU not on the plan (none, or + * not in your cockpit) is in its own console's No power zone. + */ +export function planZones(views: NodeView[], outages: PduOutage[]): PlanConsole[] { + const pdus = views.filter((v) => v.entry.kind === 'pdu' && v.entry.source !== 'new') + const pduIds = new Set(pdus.map((p) => p.entry.nodeId)) + const panels = views.filter((v) => v.entry.kind === 'panel' && v.entry.source !== 'new') + return (['L', 'C', 'R'] as const).map((key) => { + const zones: PlanZone[] = [] + const place = (pdu: NodeView | null, members: NodeView[]) => { + const outage = pdu ? outages.find((o) => o.pduId === pdu.entry.nodeId) : undefined + const z: PlanZone = { key: pdu ? `pdu-${pdu.entry.nodeId}` : `none-${key}`, pdu, members } + if (pdu) z.head = groupHead(pdu) + if (outage) z.outage = outage + zones.push(z) + } + const on = pdus.filter((p) => consoleKey(p.entry.console) === key) + for (const pdu of on) { + place( + pdu, + panels.filter((p) => p.entry.pduId === pdu.entry.nodeId) + ) + } + const loose = panels.filter( + (p) => + consoleKey(p.entry.console) === key && + (p.entry.pduId === null || !pduIds.has(p.entry.pduId)) + ) + if (loose.length) place(null, loose) + // One feed line per PDU, gold while it raises a rail flag. + return { key, zones, feeds: on.map((p) => railAlert(p.readings)) } + }) +} diff --git a/src/main/nodes/cockpit-snapshot/registry-rows.test.ts b/src/main/nodes/cockpit-snapshot/registry-rows.test.ts new file mode 100644 index 0000000..0c47f13 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/registry-rows.test.ts @@ -0,0 +1,102 @@ +// Ported from the renderer's settings-view.test.ts (R3.2b): the registry rows Settings › About +// shows, now built in main (registry-rows.ts). Same cases, same expected values. +import { describe, expect, it } from 'vitest' +import type { NodeModel, NodeModelEntry, NodeRegistryInfo } from '@shared/nodes' +import { registryName, registryOnBus, registrySummary, settingsNodes } from './registry-rows' + +const REG: NodeRegistryInfo = { + file: 'Firmware/NODE_IDS.md', + commit: '22b6a87e73068ca2bfd964d067af1247771d5854', + date: '2026-10-15', + nodes: [ + { nodeId: 0, name: 'PanelBridge', console: '—', kind: 'bridge' }, + { nodeId: 1, name: 'Center_Armament', console: 'Center', kind: 'panel' }, + { nodeId: 2, name: 'Right_Navigation', console: 'Right', kind: 'panel' }, + { nodeId: 3, name: 'PDU_Left', console: 'Left', kind: 'pdu' }, + { nodeId: 4, name: 'PDU_Right', console: 'Right', kind: 'pdu' }, + { nodeId: 5, name: 'PDU_Center', console: 'Center', kind: 'pdu' } + ] +} + +describe('registry', () => { + it('summarises the bundled registry', () => { + expect(registrySummary(REG)).toBe('NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway') + }) + + it('names the bridge row Gateway, whatever NODE_IDS.md calls it (#94)', () => { + expect(REG.nodes.map(registryName)[0]).toBe('Gateway') + expect(REG.nodes.slice(1).map(registryName)).toEqual(REG.nodes.slice(1).map((n) => n.name)) + }) + + it('"On the bus" comes from the link and the roster, and says when there is no data', () => { + const e = (nodeId: number, availability: NodeModelEntry['availability']) => + ({ nodeId, availability }) as NodeModelEntry + const model = { + nodes: [e(1, 'online'), e(2, 'offline'), e(3, 'unknown')], + hidden: [5] + } as unknown as NodeModel + const col = (id: number, m?: NodeModel, up = true) => registryOnBus(id, m, up) + expect(col(0)).toEqual({ text: 'serial link', dot: 'on' }) + expect(col(0, model, false)).toEqual({ text: 'no device', dot: '' }) + expect([1, 2, 3, 4, 5].map((id) => col(id, model).text)).toEqual([ + 'yes', + 'offline', + '—', + '—', // never reported: not in your cockpit (#93) + 'hidden' + ]) + expect(col(1, undefined).text).toBe('—') + }) +}) + +// New in R3.2b: the section the snapshot pushes, as CockpitSection and AboutSection read the model. +describe('settingsNodes', () => { + const entry = (nodeId: number, over: Partial = {}) => + ({ nodeId, availability: 'online', source: 'registry', ...over }) as NodeModelEntry + const m = (nodes: NodeModelEntry[], over: Partial = {}) => + ({ nodes, hidden: [], hiddenNodes: [], differingCount: 0, ...over }) as unknown as NodeModel + + it('Reset… is offered when a board differs, or an adopted board is on the plan', () => { + expect(settingsNodes(m([entry(1)]), REG, true)).toMatchObject({ differing: 0, canReset: false }) + expect(settingsNodes(m([entry(1)], { differingCount: 2 }), REG, true)).toMatchObject({ + differing: 2, + canReset: true + }) + expect(settingsNodes(m([entry(41, { source: 'adopted' })]), REG, true).canReset).toBe(true) + }) + + it('misses an adopted board that is hidden (X1 today), and has nothing before a model', () => { + const hiddenAdopted = m([entry(1)], { + hidden: [41], + hiddenNodes: [{ nodeId: 41, name: 'Left_Trim', console: 'Left' }] + }) + expect(settingsNodes(hiddenAdopted, REG, true)).toMatchObject({ + canReset: false, + hidden: [{ nodeId: 41, name: 'Left_Trim', console: 'Left' }] + }) + expect(settingsNodes(undefined, REG, true)).toMatchObject({ + differing: 0, + canReset: false, + hidden: [] + }) + }) + + it('lists each registry row with its name, console and On the bus, before and after the model', () => { + const s = settingsNodes(m([entry(1), entry(2, { availability: 'offline' })]), REG, false) + expect(s.registry.summary).toBe(registrySummary(REG)) + expect( + s.registry.rows.map((r) => [r.nodeId, r.name, r.console, r.onBus.text, r.unloaded.text]) + ).toEqual([ + [0, 'Gateway', '—', 'no device', 'no device'], + [1, 'Center_Armament', 'Center', 'yes', '—'], + [2, 'Right_Navigation', 'Right', 'offline', '—'], + [3, 'PDU_Left', 'Left', '—', '—'], + [4, 'PDU_Right', 'Right', '—', '—'], + [5, 'PDU_Center', 'Center', '—', '—'] + ]) + }) + + it('has no rows before main knows the registry (the launch snapshot)', () => { + expect(settingsNodes(undefined, undefined, false).registry).toEqual({ summary: '', rows: [] }) + }) +}) diff --git a/src/main/nodes/cockpit-snapshot/registry-rows.ts b/src/main/nodes/cockpit-snapshot/registry-rows.ts new file mode 100644 index 0000000..c8ac812 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/registry-rows.ts @@ -0,0 +1,79 @@ +// What Settings shows about the node mapping (moved from the renderer's Settings sections, +// R3.2b): Reset…'s gate, the Hidden boards list, and About › Node registry — its +// summary line and each row's "On the bus". +// +// Each keeps Settings' own answer where it differs from the Cockpit's (co-located, not unified): +// X1 in cockpit Reset… counts an adopted board only while it is on the plan: a hidden adopted +// board is missed (anyAdopted) +// X2 on the bus a CAN row reads the last model whatever the link; only the Gateway row follows +// the link (registryOnBus) +// X8 wording the bridge row is "Gateway", whatever NODE_IDS.md calls it (registryName) +import { GATEWAY_NODE_ID } from '@shared/firmware' +import { plural } from '@shared/format' +import type { NodeModel, NodeRegistryInfo, OnBus, RegistryRow, SettingsNodes } from '@shared/nodes' + +/** + * A registry row's name as the client shows it: the bridge row is the Gateway (#94), whatever + * NODE_IDS.md calls it (the firmware name, PanelBridge). + */ +export function registryName(n: NodeRegistryInfo['nodes'][number]): string { + return n.kind === 'bridge' ? 'Gateway' : n.name +} + +/** "NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway". */ +export function registrySummary(r: NodeRegistryInfo): string { + const pdus = r.nodes.filter((n) => n.kind === 'pdu').length + const panels = r.nodes.filter((n) => n.kind === 'panel').length + const parts = [plural(pdus, 'PDU'), plural(panels, 'panel group')] + const bridge = r.nodes.find((n) => n.kind === 'bridge') + if (bridge) parts.push(registryName(bridge)) + const file = r.file.split('/').pop() + return `${file} · ${plural(r.nodes.length, 'node')}: ${parts.join(', ')}` +} + +/** + * Registry list "On the bus" column. PanelBridge is the serial link itself; a CAN node is what + * the roster says — "—" before any roster, since that is no data rather than "absent". + */ +export function registryOnBus( + nodeId: number, + model: NodeModel | undefined, + linkUp: boolean +): OnBus { + 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) + if (e?.availability === 'online') return { text: 'yes', dot: 'on' } + if (e?.availability === 'offline') return { text: 'offline', dot: 'off' } + return { text: '—', dot: '' } +} + +/** + * Settings' sections, as CockpitSection and AboutSection computed them from the model: the + * differing count, Reset… enabled when something differs or any adopted board is on the plan + * (X1), the hidden boards, and the registry rows. No registry yet (the launch snapshot): no rows. + */ +export function settingsNodes( + model: NodeModel | undefined, + registry: NodeRegistryInfo | undefined, + live: boolean +): SettingsNodes { + const differing = model?.differingCount ?? 0 + const anyAdopted = model?.nodes.some((n) => n.source === 'adopted') ?? false + const rows = (registry?.nodes ?? []).map( + (n): RegistryRow => ({ + nodeId: n.nodeId, + name: registryName(n), + console: n.console, + onBus: registryOnBus(n.nodeId, model, live), + unloaded: registryOnBus(n.nodeId, undefined, live) + }) + ) + return { + differing, + canReset: !(differing === 0 && !anyAdopted), + hidden: model?.hiddenNodes ?? [], + registry: { summary: registry ? registrySummary(registry) : '', rows } + } +} diff --git a/src/main/nodes/cockpit-snapshot/summaries.ts b/src/main/nodes/cockpit-snapshot/summaries.ts new file mode 100644 index 0000000..aacca3c --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/summaries.ts @@ -0,0 +1,192 @@ +// The Overview's and Connection's reading of the Cockpit's picture (moved from the renderer, +// R3.2b): the Overview's Cockpit tile, its +// Attention banner and new-devices notice, and Connection › Connected nodes. The client adds no +// thresholds of its own. +// +// Each keeps its own answer where it differs from the Cockpit's (co-located, not unified): +// X3 strays neither counts a new board that can't be adopted: the band is healthBand(views) +// with no strays, the items attentionItems(views, outages) with none +// X2 link the banner and the notice only while the link is up (the tile says "no live node +// status"); Connected nodes reads the last model whatever the link (its tab shows +// the link's hint instead while it is down) +// X4 zone a Connected nodes row names the PDU its zone says, wherever that PDU is, else "no +// power zone" +import { hhmmss, lastSeenLabel, plural } from '@shared/format' +import type { + AttentionItem, + AttentionSummary, + CockpitTile, + HealthBand, + NewDevices, + NodeModelEntry, + NodesSummary, + NodeView, + OverviewSummary, + PduOutage, + SummaryRow +} from '@shared/nodes' +import { isAdoptableKind } from '../node-kind' +import { isReportableNew } from '../node-model' +import { affected, ATTENTION_SHOWN, attentionItems } from './attention' +import { healthBand } from './band' + +// ── Overview › Cockpit tile ───────────────────────────────────────────────── + +/** `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' } + if (band.total === 0) return { value: '—', sub: 'no nodes known', tone: 'none' } + if (band.unknown === band.total) return { value: '—', sub: 'no status yet', tone: 'none' } + const pdus = band.pdusWith + band.pdusWithout + const sub = [ + 'nodes up', + band.watts === null ? '— W' : `${band.watts.toFixed(1)} W`, + ...(pdus ? [plural(pdus, 'PDU')] : []) + ].join(' · ') + const symbol = band.off > 0 ? 'off' : band.warn > 0 ? 'warn' : undefined + return { + value: `${band.online} / ${band.total}`, + sub, + symbol, + tone: symbol ? 'issue' : band.unknown > 0 ? 'none' : 'ok' + } +} + +// ── Overview › Attention banner ───────────────────────────────────────────── + +/** How many names the banner lists per kind before "+N more". */ +const BANNER_NAMES = 3 + +/** A PDU outage reads as the PDU plus the nodes that dropped with it. */ +const itemName = (i: AttentionItem) => + i.outage ? `${i.name} + ${plural(i.outage.nodeIds.length, 'node')}` : i.name + +function names(items: AttentionItem[]): string { + const shown = items.slice(0, BANNER_NAMES).map(itemName) + const more = items.length - shown.length + return `(${shown.join(', ')}${more > 0 ? ` +${more} more` : ''})` +} + +/** + * The banner while a board reports a problem; null when none does. Counts every known node that + * is offline or warning (a PDU outage counts each node in it) and names the most severe kind + * present — the offline ones, else the warnings — in Attention order. + */ +export function attentionSummary( + band: HealthBand, + items: AttentionItem[] +): AttentionSummary | null { + const n = band.off + band.warn + if (n === 0 || items.length === 0) return null + const off = items.filter((i) => i.state === 'off') + const warn = items.filter((i) => i.state === 'warn') + const parts: string[] = [] + if (band.off > 0) parts.push(`${band.off} offline ${names(off)}`) + if (band.warn > 0) { + const w = plural(band.warn, 'warning') + parts.push(band.off > 0 ? w : `${w} ${names(warn)}`) + } + return { + head: `${plural(n, 'node')} need${n === 1 ? 's' : ''} attention`, + detail: parts.join(' · ') + } +} + +// ── Overview › new devices ────────────────────────────────────────────────── + +/** + * The adoptable kind a node has identified as (node-kind.ts resolveKind: its own announcement, PDU + * telemetry, then what was stored). Never inferred from its NODE_ID. null = not identified yet, or a + * kind that can't be adopted (a bridge, or one this client doesn't know). + */ +export function reportedKind(e: NodeModelEntry): 'pdu' | 'panel' | null { + return isAdoptableKind(e.identified) ? e.identified : null +} + +/** + * NODE_IDs waiting to be adopted that are on the bus now and have said what they are, by kind. + * A node is not news until it has identified itself (no "unknown" count), and one that left the + * bus is not "on the bus". null = nothing to show, including while the link is down. + */ +export function newDevices(views: NodeView[], live: boolean): NewDevices | null { + if (!live) return null + const fresh = views.filter((v) => isReportableNew(v.entry)) + const kinds = fresh.map((v) => reportedKind(v.entry)) + const panels = kinds.filter((k) => k === 'panel').length + const pdus = kinds.filter((k) => k === 'pdu').length + const registry = fresh.filter((v) => v.entry.registry).length + return panels + pdus ? { count: panels + pdus, panels, pdus, registry } : null +} + +/** + * The Overview's tile, banner and notice, as the Overview tab computed them: the band and the items + * without strays (X3), the banner and the notice only while the link is up. + */ +export function overviewSummary( + views: NodeView[], + outages: PduOutage[], + live: boolean +): OverviewSummary { + const band = healthBand(views) + return { + tile: cockpitTile(band, live), + banner: live ? attentionSummary(band, attentionItems(views, outages)) : null, + newDevices: newDevices(views, live) + } +} + +// ── Connection › Connected nodes ──────────────────────────────────────────── + +export function nodesSummary(views: NodeView[], outages: PduOutage[]): NodesSummary { + const band = healthBand(views) + const known = views.filter((v) => v.entry.source !== 'new') + const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry])) + const items = attentionItems(views, outages) + const healthy = band.online - band.warn + + const rows = items.slice(0, ATTENTION_SHOWN).map((i): SummaryRow => { + const e = byId.get(i.nodeId)! + const meta = [`#${i.nodeId}`] + if (i.outage) meta.push(affected(i.outage.nodeIds.length)) + else if (e.kind !== 'pdu') { + const feed = e.pduId === null ? undefined : byId.get(e.pduId) + meta.push(feed ? feed.name : 'no power zone') + } + if (e.notSeen) { + 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 ${hhmmss(e.offlineAt)}`) + } + return { + nodeId: i.nodeId, + name: i.name, + state: i.state, + chip: i.outage || e.notSeen ? 'OFFLINE' : i.flag, + tone: i.state === 'off' ? 'err' : 'warn', + meta: meta.join(' · ') + } + }) + + const more = Math.max(0, items.length - ATTENTION_SHOWN) + const footer = [ + more > 0 && `+${more} more`, + band.unknown > 0 && `${band.unknown} no status yet`, + healthy > 0 && 'healthy nodes hidden' + ] + .filter(Boolean) + .join(' · ') + + return { + known: known.length, + healthy, + warn: band.warn, + off: band.off, + unknown: band.unknown, + pdus: known.filter((v) => v.entry.kind === 'pdu').length, + watts: band.watts, + rows, + more, + footer + } +} diff --git a/src/main/nodes/cockpit-snapshot/views.ts b/src/main/nodes/cockpit-snapshot/views.ts new file mode 100644 index 0000000..56a2036 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/views.ts @@ -0,0 +1,54 @@ +// Per-board health, flag and readings (moved from the renderer, R3.2): what each node +// reads as on the plan, computed once per snapshot from the node model and the telemetry. The +// client applies no thresholds — a warning is always a flag a board raised itself. +import type { + NodeHealth, + NodeModel, + NodeModelEntry, + NodeTelemSnapshot, + NodeView, + PduReadings +} from '@shared/nodes' +import { nodeFaultTag, pduAlertFlags } from '../faults' + +/** A PDU's pages that are fresh right now. A stale page is not a reading. */ +function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings { + const r: PduReadings = {} + if (t?.rail12V && !t.rail12V.stale) r.rail12V = t.rail12V + if (t?.rail5V && !t.rail5V.stale) r.rail5V = t.rail5V + if (t?.status && !t.status.stale) r.status = t.status + return r +} + +/** 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' } + if (e.availability === 'unknown') return { state: 'unknown' } + if (e.notSeen) return { state: 'off', flag: 'NOT SEEN THIS SESSION' } + if (e.availability === 'offline') return { state: 'off', flag: 'OFFLINE' } + const st = e.status + if (st?.boff) return { state: 'off', flag: 'BUS-OFF' } + const flags: string[] = [] + if (st?.degraded) flags.push(nodeFaultTag(st)) + if (st?.overheat) flags.push('OVERHEAT') + if (st?.epvf) flags.push('ERR-PASSIVE') + if (e.kind === 'pdu') flags.push(...pduAlertFlags(r)) + return flags.length ? { state: 'warn', flag: flags.join(' · ') } : { state: 'on' } +} + +export function nodeViews(model: NodeModel, telemetry: NodeTelemSnapshot[]): NodeView[] { + const telem = new Map(telemetry.map((t) => [t.nodeId, t])) + return model.nodes.map((entry) => { + // An offline PDU has no readings, even in the seconds before its last pages go stale. + const readings = + entry.kind === 'pdu' && entry.availability === 'online' + ? freshReadings(telem.get(entry.nodeId)) + : {} + return { + entry, + health: nodeHealth(entry, readings), + readings, + noReadings: entry.kind === 'pdu' && entry.readings === 'none' + } + }) +} diff --git a/src/main/nodes/faults.ts b/src/main/nodes/faults.ts new file mode 100644 index 0000000..6e6f173 --- /dev/null +++ b/src/main/nodes/faults.ts @@ -0,0 +1,95 @@ +// 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 on the Cockpit, what it means, and the reading that goes with it. +import type { EventSeverity } from '@shared/events' +import { amps, volts } from '@shared/format' +import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' +import type { NodeStatus, NodeView, PduRailPage, PduReadings } from '@shared/nodes' +import { displayCurrentMa } from './node-telem' + +/** 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) +} + +/** 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}` +} + +// ── the Cockpit's flag text (moved from the renderer, R3.2) ── + +const RAIL_FLAG_TEXT: [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'] +] +/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state, not alerts. */ +const STATUS_FLAG_TEXT: [number, string][] = [ + [PDU_FLAG.TEMP_CRIT, 'TEMP CRIT'], + [PDU_FLAG.TEMP_WARN, 'TEMP WARN'], + [PDU_FLAG.NTC_FAULT, 'NTC FAULT'] +] + +/** The alert flags a PDU raises on its fresh pages, e.g. `5V UNDER-V`. Empty = healthy. */ +export function pduAlertFlags(r: PduReadings): string[] { + const out: string[] = [] + const rail = (label: string, p?: PduRailPage) => { + for (const [bit, text] of RAIL_FLAG_TEXT) if (p && p.flags & bit) out.push(`${label} ${text}`) + } + rail('12V', r.rail12V) + rail('5V', r.rail5V) + for (const [bit, text] of STATUS_FLAG_TEXT) if (r.status && r.status.flags & bit) out.push(text) + return out +} + +/** Flag meanings as NodeStatus.h states them. */ +const RAIL_MEANING: [number, string][] = [ + [RAIL_FLAG.UNDER_V, 'below its under-voltage threshold'], + [RAIL_FLAG.OVER_V, 'above its over-voltage threshold'], + [RAIL_FLAG.OVER_CURRENT, 'drawing sustained current above the over-current threshold'], + [RAIL_FLAG.FUSE_OPEN, 'reading ~0 V while the node is powered — fuse blown'], + [RAIL_FLAG.SENSOR_FAULT, 'reading implausibly on its voltage or current channel'] +] +const STATUS_MEANING: [number, string][] = [ + [PDU_FLAG.TEMP_CRIT, 'board temperature at the critical threshold'], + [PDU_FLAG.TEMP_WARN, 'board temperature at the warning threshold'], + [PDU_FLAG.NTC_FAULT, 'NTC channel open or shorted'] +] + +/** What each raised PDU flag means, in plain words. */ +export function pduFlagMeanings(v: NodeView): string[] { + const out: string[] = [] + const rail = (label: string, p?: PduRailPage) => { + for (const [bit, m] of RAIL_MEANING) + if (p && p.flags & bit) out.push(`the ${label} rail is ${m}`) + } + rail('12 V', v.readings.rail12V) + rail('5 V', v.readings.rail5V) + const s = v.readings.status + for (const [bit, m] of STATUS_MEANING) if (s && s.flags & bit) out.push(m) + return out +} diff --git a/src/main/nodes/index.ts b/src/main/nodes/index.ts new file mode 100644 index 0000000..8125922 --- /dev/null +++ b/src/main/nodes/index.ts @@ -0,0 +1,14 @@ +// nodes: the CAN node picture and the node mapping. Exports only what other topics use +// (arch-phase0-v2 §2.1). +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, + isPlainObject, + sanitizeNodeMapping +} from './mapping-rules' +export { PSU_PRESETS, resolvePsu, 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..c64430e --- /dev/null +++ b/src/main/nodes/ipc.ts @@ -0,0 +1,34 @@ +// 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 AdoptRequest, + 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)) + ipc.handle(NODES_CTRL.nodeMappingAddAll, (_e, bulk: AdoptRequest[]) => nodes.addAll(bulk)) + ipc.handle(NODES_CTRL.nodeMappingUnhide, (_e, ids: number[]) => nodes.unhide(ids)) +} 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/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 217f79f..ee899a1 100644 --- a/src/shared/node-mapping.test.ts +++ b/src/main/nodes/mapping-rules.test.ts @@ -9,17 +9,15 @@ import { importMapping, MappingError, markSeen, - registryDefaults, resetMapping, resetNode, resetPreview, sanitizeNodeMapping, setHidden, - setNodeMapping, - type AdoptSpec, - type MappingPatch, - type NodeMappingFile -} from './node-mapping' + setNodeMapping +} 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 80% rename from src/shared/node-mapping.ts rename to src/main/nodes/mapping-rules.ts index dd9029c..0e0ea8c 100644 --- a/src/shared/node-mapping.ts +++ b/src/main/nodes/mapping-rules.ts @@ -1,15 +1,29 @@ // 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, 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 // 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 { isAdoptableKind, type NodeIdentity } from './node-kind' - -export type NodeKind = 'pdu' | 'panel' +import { MAX_NODE_ID } from '@shared/firmware' +import type { + AdoptedRow, + AdoptSpec, + MappedNode, + MappingPatch, + NodeDefaults, + NodeIdentity, + NodeKind, + NodeMappingFile, + NodeOverride, + ResetOptions, + ResetPreview, + ResetRow +} from '@shared/nodes' +import { isAdoptableKind } from './node-kind' +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 @@ -18,60 +32,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 @@ -83,8 +43,8 @@ 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 +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 => { if (typeof v !== 'string') return undefined @@ -92,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 @@ -162,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 @@ -233,14 +152,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 {} @@ -270,9 +181,10 @@ function withNode( /** * These arrive over IPC from the renderer, where types are only a promise. Anything that is not a * plain object (null, a string, an array) is refused here as a MappingError, so the caller gets - * `{ ok: false }` rather than a TypeError surfacing as a rejected invoke. + * `{ ok: false }` rather than a TypeError surfacing as a rejected invoke. The config import's file + * check uses the same test (config-file.ts). */ -const isPlainObject = (v: unknown): v is Record => +export const isPlainObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) /** @@ -470,19 +382,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) - .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 @@ -514,32 +413,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/main/nodes/mapping-sequence.test.ts b/src/main/nodes/mapping-sequence.test.ts new file mode 100644 index 0000000..5fcc686 --- /dev/null +++ b/src/main/nodes/mapping-sequence.test.ts @@ -0,0 +1,70 @@ +// "Add all" and "Un-hide all" (R3.2b): the loops the Adopt dialog and Settings ran, one board at a +// time — in order, each awaited before the next, stopping at the first refusal. +import { describe, expect, it, vi } from 'vitest' +import type { NodeMappingResult } from '@shared/nodes' +import { nodeModel } from '../../test-fixtures/nodes' +import { addAllInTurn, unhideInTurn } from './mapping-sequence' + +const OK: NodeMappingResult = { ok: true, model: nodeModel([]) } +const spec = { name: 'X', console: 'Left', pduId: null, kind: 'panel' } as const + +describe('addAllInTurn', () => { + it('adopts each in order, the next only once the last has answered', async () => { + const order: string[] = [] + const adopt = vi.fn(async (id: number) => { + order.push(`start ${id}`) + await Promise.resolve() + order.push(`end ${id}`) + return OK + }) + const r = await addAllInTurn( + [ + { nodeId: 2, spec }, + { nodeId: 7, spec } + ], + adopt + ) + expect(r).toEqual({ ok: true, done: [2, 7] }) + expect(order).toEqual(['start 2', 'end 2', 'start 7', 'end 7']) + }) + + it('stops at the first refusal: the ones before it are done, the rest not tried', async () => { + const adopt = vi.fn( + (id: number): NodeMappingResult => + id === 7 ? { ok: false, error: 'Name cannot be empty' } : OK + ) + const r = await addAllInTurn( + [2, 7, 9].map((nodeId) => ({ nodeId, spec })), + adopt + ) + expect(r).toEqual({ ok: false, done: [2], nodeId: 7, error: 'Name cannot be empty' }) + expect(adopt.mock.calls.map(([id]) => id)).toEqual([2, 7]) + }) + + it('no answer counts as done, as the dialog read it', async () => { + expect(await addAllInTurn([{ nodeId: 2, spec }], () => undefined)).toEqual({ + ok: true, + done: [2] + }) + }) +}) + +describe('unhideInTurn', () => { + it('un-hides each in order and stops at the first refusal', async () => { + const hide = vi.fn( + (id: number): NodeMappingResult => + id === 30 ? { ok: false, error: 'Node mapping is read-only' } : OK + ) + expect(await unhideInTurn([8, 30, 41], hide)).toEqual({ + ok: false, + done: [8], + nodeId: 30, + error: 'Node mapping is read-only' + }) + expect(hide.mock.calls).toEqual([ + [8, false], + [30, false] + ]) + expect(await unhideInTurn([8], hide)).toEqual({ ok: true, done: [8] }) + }) +}) diff --git a/src/main/nodes/mapping-sequence.ts b/src/main/nodes/mapping-sequence.ts new file mode 100644 index 0000000..e232125 --- /dev/null +++ b/src/main/nodes/mapping-sequence.ts @@ -0,0 +1,40 @@ +// Mapping changes one board at a time (R3.2b): the Adopt dialog's "Add all" and Settings' "Un-hide +// all". The renderer ran these loops itself, one invoke per board (AdoptDialog `addAll`, +// CockpitSection `unhide`); NodeHub runs them now, the same way: in the order given, each change +// awaited before the next, stopping at the first refusal. Each change pushes the model as it goes +// (NodeHub.update), just as each invoke did. +// +// Today's defects stay as they are (fix track): Add all adds what it can before a refusal and the +// rest are not tried; Un-hide all stops at the first board it can't un-hide. +import type { AdoptRequest, AdoptSpec, NodeBatchResult, NodeMappingResult } from '@shared/nodes' + +/** One change, as NodeHub (or an invoke) answers it. undefined = no answer (counts as done). */ +type Answer = NodeMappingResult | undefined | Promise + +/** "Add all": adopt each board in turn; the first refusal stops it. */ +export async function addAllInTurn( + bulk: AdoptRequest[], + adopt: (nodeId: number, spec: AdoptSpec) => Answer +): Promise { + const added: number[] = [] + for (const b of bulk) { + const r = await adopt(b.nodeId, b.spec) + if (r && !r.ok) return { ok: false, done: added, nodeId: b.nodeId, error: r.error } + added.push(b.nodeId) + } + return { ok: true, done: added } +} + +/** "Un-hide" / "Un-hide all": show each board again in turn; the first refusal stops it. */ +export async function unhideInTurn( + ids: number[], + hide: (nodeId: number, on: boolean) => Answer +): Promise { + const done: number[] = [] + for (const id of ids) { + const r = await hide(id, false) + if (r && !r.ok) return { ok: false, done, nodeId: id, error: r.error } + done.push(id) + } + return { ok: true, done } +} diff --git a/src/main/nodes/mapping-store.seam.test.ts b/src/main/nodes/mapping-store.seam.test.ts new file mode 100644 index 0000000..8291283 --- /dev/null +++ b/src/main/nodes/mapping-store.seam.test.ts @@ -0,0 +1,90 @@ +// 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' +import type { NodeMappingFile } from '@shared/nodes' +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), 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-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)) +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/node-mapping.test.ts b/src/main/nodes/mapping-store.test.ts similarity index 83% rename from src/main/node-mapping.test.ts rename to src/main/nodes/mapping-store.test.ts index 1fb6e04..7560a23 100644 --- a/src/main/node-mapping.test.ts +++ b/src/main/nodes/mapping-store.test.ts @@ -2,12 +2,19 @@ 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 './mapping-rules' +import type { NodeMappingFile } from '@shared/nodes' +import { storeFiles, 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), as the +// node-mapping.json port app wires (the JSON store under the file's policy). +const io = storeFiles(storePaths(() => userData.dir)).mappingFile -const { loadNodeMapping, saveNodeMapping, MappingSaveError } = await import('./node-mapping') +const store = await import('./mapping-file') +const { MappingSaveError } = store +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 new file mode 100644 index 0000000..1f4d9d9 --- /dev/null +++ b/src/main/nodes/mapping-store.ts @@ -0,0 +1,88 @@ +// 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 { + loadNodeMapping, + MappingSaveError, + saveNodeMapping, + type LoadedMapping, + type MappingFileIO +} from './mapping-file' + +export { MappingSaveError, type LoadedMapping, type MappingFileIO } from './mapping-file' + +/** 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') + } +} + +export interface MappingStoreDeps { + file?: MappingFileIO + log?: Log + /** The mapping in memory changed (saved, or kept unsaved by `persist`). */ + onCommit?: () => void +} + +export class MappingStore { + private loaded?: LoadedMapping + private readonly io: MappingFileIO + private readonly log: Log + private readonly onCommit: () => void + + constructor(deps: MappingStoreDeps = {}) { + this.io = deps.file ?? NO_FILE + this.log = deps.log ?? (() => {}) + this.onCommit = deps.onCommit ?? (() => {}) + } + + /** 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 { + 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. + * 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) + } + + private commit(next: NodeMappingFile): void { + this.store().file = next + this.onCommit() + } +} 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 new file mode 100644 index 0000000..1f0f706 --- /dev/null +++ b/src/main/nodes/node-hub.seam.test.ts @@ -0,0 +1,236 @@ +// 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 { 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' +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 }), + 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('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. + 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-hub.test.ts b/src/main/nodes/node-hub.test.ts similarity index 88% rename from src/main/node-hub.test.ts rename to src/main/nodes/node-hub.test.ts index 94e7894..9fca83b 100644 --- a/src/main/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/node-mapping' -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 @@ -12,7 +12,7 @@ const disk: { saves: 0 } vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) // debug log path -vi.mock('./node-mapping', () => { +vi.mock('./mapping-file', () => { class MappingSaveError extends Error {} return { MappingSaveError, @@ -221,6 +221,65 @@ describe('NodeHub', () => { expect(disk.file.nodes[2]).toEqual({ added: true, lastSeen: '1970-01-01T00:00:09.000Z' }) }) + it('Add all (R3.2b) adopts each board in turn, each pushed as it lands; a refusal stops it', async () => { + const { h, roster, pushes } = hub() + // 41 and 42 report as panel groups; 43 has not said what it is. + for (const id of [41, 42, 43]) { + const kind = id === 43 ? '00' : '01' + roster.applyMessage( + NODE_MSG, + `${id.toString(16).toUpperCase()}010000000000000080000000${kind}`, + 0 + ) + } + h.flush(roster.takeDirty(), 0) + const spec = (name: string) => ({ name, console: 'Left', pduId: null, kind: 'panel' }) as const + pushes.length = 0 + const r = await h.addAll([ + { nodeId: 41, spec: spec('A') }, + { nodeId: 43, spec: spec('C') }, + { nodeId: 42, spec: spec('B') } + ]) + expect(r).toEqual({ + ok: false, + done: [41], + nodeId: 43, + error: 'Its board type is not known yet' + }) + expect(Object.keys(disk.file.nodes).map(Number)).toEqual([1, 2, 3, 4, 5, 41]) + // One adopt's pushes, as one node-mapping:adopt invoke made them; 42 was not tried. + expect(pushes.map(([c]) => c)).toEqual(['cockpit:snapshot', 'nodes:model']) + + pushes.length = 0 + expect(await h.addAll([{ nodeId: 42, spec: spec('B') }])).toEqual({ ok: true, done: [42] }) + expect(pushes.map(([c]) => c)).toEqual(['cockpit:snapshot', 'nodes:model']) + }) + + it('Un-hide all (R3.2b) un-hides each board in turn, each pushed as it lands', async () => { + disk.file = { + version: 3, + nodes: { 1: { added: true, hidden: true }, 2: { added: true, hidden: true } } + } + const { h, pushes } = hub() + expect(await h.unhide([2, 1])).toEqual({ ok: true, done: [2, 1] }) + expect(disk.file.nodes).toEqual({ 1: { added: true }, 2: { added: true } }) + expect(pushes.map(([c]) => c)).toEqual([ + 'cockpit:snapshot', + 'nodes:model', + 'cockpit:snapshot', + 'nodes:model' + ]) + disk.file = { version: 3, nodes: { 1: { added: true, hidden: true } } } + disk.readOnly = 'node-mapping.json was written by a newer client' + const ro = hub().h + expect(await ro.unhide([1, 2])).toEqual({ + ok: false, + done: [], + nodeId: 1, + error: 'Not saved: node-mapping.json was written by a newer client' + }) + }) + it('hiding a new node is refused and writes nothing', () => { const { h } = hub() expect(h.hide(41, true)).toEqual({ diff --git a/src/main/node-hub.ts b/src/main/nodes/node-hub.ts similarity index 50% rename from src/main/node-hub.ts rename to src/main/nodes/node-hub.ts index d352628..8bf8cc2 100644 --- a/src/main/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -1,16 +1,30 @@ -// 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 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). It also hosts the Cockpit snapshot (R3.2): built from +// each node push and the link, and pushed with them on `cockpit:snapshot`. import type { + AdoptRequest, + AdoptSpec, + MappedNode, + MappingPatch, + NodeDefaults, + NodeIdentity, + NodeBatchResult, + 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' -import { NodeTelemStore, parseNodeTelem } from '@shared/node-telem' -import { NodeEventTracker, type LogEventInput } from '@shared/events' + NodeStatus, + ResetOptions, + ResetPreview +} from '@shared/nodes' +import type { Emit } from '@shared/app' +import type { LogEventInput } from '@shared/events' +import type { DeviceStatus, GatewayStatus } from '@shared/gateway' +import { GatewayWatch, type LinkRider, type NodeLineSink } from '../gateway' import { adoptNode, clearReportedHidden, @@ -19,43 +33,84 @@ import { importMapping, MappingError, markSeen, - registryDefaults, resetMapping, resetNode, resetPreview, setHidden, - setNodeMapping, - type AdoptSpec, - type MappedNode, - type MappingPatch, - type NodeDefaults, - type NodeMappingFile, - type ResetOptions, - 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' + setNodeMapping +} from './mapping-rules' import { - loadNodeMapping, - MappingSaveError, - saveNodeMapping, - type LoadedMapping -} from './node-mapping' -import { debugLog } from './debug' + NODE_FAULT_CODES, + NODE_NAMES, + NODE_REGISTRY_SOURCE, + type NodeFaultName +} from '@shared/firmware' +import { buildNodeModel } from './node-model' +import { NodeEventTracker } from './node-events' +import { isAdoptableKind, resolveKind } from './node-kind' +import { + BRIDGE_NODE_ID, + canRequestRoster, + 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' +import { CockpitSnapshotHost } from './cockpit-host' +import { addAllInTurn, unhideInTurn } from './mapping-sequence' + +/** Port: the debug log, handed in by app (debug.log lives in app). */ +export type Log = (tag: string, data?: unknown) => void + +/** + * 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: () => {} +} -type Emit = (channel: C, payload: PushChannels[C]) => void +/** 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. */ +/** Shortest gap between takes of the PDU pages. ~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. */ /** * 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 +119,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, @@ -73,10 +128,16 @@ 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 loaded?: LoadedMapping private telemPushedAt = 0 private modelDirty = true /** Present NODE_IDs at the last roster change, to tell "came back" from "still listed". */ @@ -94,20 +155,155 @@ export class NodeHub { }) private mappedFor?: NodeMappingFile private mappedCache = new Map() + /** The node mapping and its file. */ + private readonly store: MappingStore + /** The Cockpit tab's picture (R3.2), pushed on `cockpit:snapshot` with each node push. */ + private readonly cockpit: CockpitSnapshotHost constructor( private readonly emit: Emit, private readonly roster: NodeRoster, - private readonly onEvents: (events: LogEventInput[]) => void = () => {} - ) {} - - /** Read lazily: the userData path is only safe to touch once the app is up. */ - private store(): LoadedMapping { - return (this.loaded ??= loadNodeMapping()) + 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, + onCommit: () => { + this.modelDirty = true + } + }) + this.cockpit = new CockpitSnapshotHost((snapshot) => this.emit('cockpit:snapshot', snapshot), { + device: () => this.link.deviceStatus(), + registry: () => this.registry() + }) } private mapping(): NodeMappingFile { - return this.store().file + 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. 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()), + // 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()) + this.cockpit.linkChanged() + } + } + } + + /** `device:status` went out with a new link state (the connection's onStatus). */ + linkStatus(): void { + this.cockpit.linkChanged() + } + + /** + * A renderer (re)loaded (`relay:status`, its first pull): the Cockpit snapshot starts over from + * what main holds now, as the renderer's store did from its `nodes:get`. + */ + rendererLoaded(now = Date.now()): void { + const { model, telemetry } = this.snapshot(now) + this.cockpit.rendererLoaded(model, telemetry) + } + + /** App quit: the Cockpit's stray timer stops. */ + dispose(): void { + this.cockpit.dispose() + } + + /** The Gateway's health as it stands, for the connected status. */ + gatewayHealth(): GatewayStatus | undefined { + return this.gateway.status() + } + + /** A roster request can go out now (canRequestRoster): Connection's Refresh (R3.4a). */ + canRefresh(): boolean { + return canRequestRoster(this.link.attached, this.link.deviceStatus().state) + } + + /** Ask PanelBridge for the full node roster (inject the request export to the serial). */ + requestNodes(): void { + if (!this.canRefresh()) 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. */ @@ -147,12 +343,17 @@ export class NodeHub { return this.mappedCache } - /** Session tick. Pushes telemetry at most once per NODE_TELEM_PUSH_MS, the model on change. */ + /** + * The link's tick. Takes the PDU pages at most once per NODE_TELEM_PUSH_MS (the Cockpit snapshot + * and the model follow them), and pushes the model on change. + */ flush(rosterChanged: boolean, now = Date.now()): void { let changed = rosterChanged || this.modelDirty if (now - this.telemPushedAt >= NODE_TELEM_PUSH_MS && this.telem.takeChanged(now)) { this.telemPushedAt = now - this.emit('nodes:telemetry', this.telem.snapshot(now)) + // The Cockpit's snapshot follows the pages; the renderer reads them only through it (R3.4d: + // the `nodes:telemetry` push it no longer listened to is gone). + this.cockpit.telemetryPushed(this.telem.snapshot(now)) changed = true } if (rosterChanged) this.rosterTransitions(now) @@ -165,7 +366,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() @@ -184,6 +385,19 @@ export class NodeHub { return this.update((f) => adoptNode(f, this.defaults, nodeId, spec, this.kindOf)) } + /** + * "Add all" (R3.2b, the Adopt dialog's loop until then): adopt each board in turn, as one adopt + * each — every one pushes the model as it lands — stopping at the first refusal. + */ + addAll(bulk: AdoptRequest[]): Promise { + return addAllInTurn(bulk, (nodeId, spec) => this.adopt(nodeId, spec)) + } + + /** "Un-hide" / "Un-hide all" (R3.2b, Settings' loop until then): the same, one un-hide each. */ + unhide(ids: number[]): Promise { + return unhideInTurn(ids, (nodeId, on) => this.hide(nodeId, on)) + } + /** One registry board back to its NODE_IDS.md defaults. */ resetBoard(nodeId: number): NodeMappingResult { return this.update((f) => resetNode(f, this.defaults, nodeId)) @@ -290,6 +504,7 @@ export class NodeHub { private pushModel(now = Date.now()): NodeModel { const model = this.model(now) this.modelDirty = false + this.cockpit.modelPushed(model) // just ahead of the push it mirrors this.emit('nodes:model', model) return model } @@ -314,47 +529,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 - debugLog('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.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/shared/node-kind.test.ts b/src/main/nodes/node-kind.test.ts similarity index 100% rename from src/shared/node-kind.test.ts rename to src/main/nodes/node-kind.test.ts diff --git a/src/shared/node-kind.ts b/src/main/nodes/node-kind.ts similarity index 82% rename from src/shared/node-kind.ts rename to src/main/nodes/node-kind.ts index 8356d96..66e73a3 100644 --- a/src/shared/node-kind.ts +++ b/src/main/nodes/node-kind.ts @@ -1,14 +1,25 @@ // 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 { NodeKind } from './node-mapping' +import { NODE_KINDS as NODE_KIND } from '@shared/firmware' +import type { NodeIdentity, NodeKind } from '@shared/nodes' + +export interface KindSignals { + /** The board's own announcement (NodeStatus.nodeKind, v3). */ + reported?: number + /** It has sent a PDU `_NODE_TELEM` page this session — only a PDU does. */ + pduTelemetry?: boolean + /** What the registry or an adoption stored. */ + stored?: NodeKind +} /** - * 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. + * The strongest signal wins: what the board announces, then PDU telemetry, then what was stored. + * undefined = nothing solid yet (a new node before it reports its kind). */ -export type NodeIdentity = 'pdu' | 'panel' | 'bridge' | 'other' +export function resolveKind(s: KindSignals): NodeIdentity | undefined { + return reportedIdentity(s.reported) ?? (s.pduTelemetry ? 'pdu' : undefined) ?? s.stored +} /** The kind a status row reports (NodeStatus.nodeKind); undefined = not reported (0) or no row. */ export function reportedIdentity(nodeKind: number | undefined): NodeIdentity | undefined { @@ -27,23 +38,6 @@ export function reportedIdentity(nodeKind: number | undefined): NodeIdentity | u } } -export interface KindSignals { - /** The board's own announcement (NodeStatus.nodeKind, v3). */ - reported?: number - /** It has sent a PDU `_NODE_TELEM` page this session — only a PDU does. */ - pduTelemetry?: boolean - /** What the registry or an adoption stored. */ - stored?: NodeKind -} - -/** - * The strongest signal wins: what the board announces, then PDU telemetry, then what was stored. - * undefined = nothing solid yet (a new node before it reports its kind). - */ -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 94% rename from src/shared/node-rules.test.ts rename to src/main/nodes/node-model.test.ts index b043f58..7cb69d9 100644 --- a/src/shared/node-rules.test.ts +++ b/src/main/nodes/node-model.test.ts @@ -1,15 +1,10 @@ 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, type NodeMappingFile } from './node-mapping' -import type { NodeSeen, NodeStatus } from './nodes' -import type { NodeTelemSnapshot } from './node-telem' +import { buildNodeModel, isReportableNew, type NodeModelInput } from './node-model' +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: '—' }, @@ -20,21 +15,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, @@ -438,15 +420,6 @@ describe('your cockpit (#93)', () => { }) }) -describe('cameOnline', () => { - it('lists only nodes that were not present at the previous roster change', () => { - expect(cameOnline(new Set(), [st(1), st(2, false)])).toEqual([1]) // first roster - expect(cameOnline(new Set([1]), [st(1), st(2)])).toEqual([2]) // 1 still present - expect(cameOnline(new Set([1]), [st(1, false)])).toEqual([]) // gone - expect(cameOnline(new Set(), [st(1)])).toEqual([1]) // back after being gone - }) -}) - describe('board kind (#344)', () => { const kind = (id: number, k: number, present = true) => ({ ...st(id, present), nodeKind: k }) diff --git a/src/main/nodes/node-model.ts b/src/main/nodes/node-model.ts new file mode 100644 index 0000000..a7b7353 --- /dev/null +++ b/src/main/nodes/node-model.ts @@ -0,0 +1,138 @@ +// 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 type { + HiddenNode, + NodeAvailability, + NodeDefaults, + NodeKind, + NodeMappingFile, + NodeModel, + NodeModelEntry, + NodeSeen, + NodeStatus, + NodeTelemSnapshot +} from '@shared/nodes' +import { differingCount, effectiveMapping } from './mapping-rules' +import { isAdoptableKind, resolveKind } from './node-kind' +import { BRIDGE_NODE_ID } from './node-status-wire' +import { hasFreshPduPage } from './node-telem' +import { pduOutages } from './power-zone' + +export interface NodeModelInput { + roster: NodeStatus[] + seen: ReadonlyMap + rosterCompleteAt?: number + telemetry: NodeTelemSnapshot[] + defaults: Map + file: NodeMappingFile +} + +/** + * Your cockpit (#93): the boards you added (registry) or adopted, remembered in node-mapping.json, + * plus whatever is on the bus now that isn't in it yet — shown as new, a registry one pre-filled. + * A registry board never added is a dictionary entry: not drawn, not counted, never offline. A registry board that never reported is a dictionary entry only: + * not drawn, not counted, never offline. + */ +export function buildNodeModel(i: NodeModelInput): NodeModel { + const mapping = effectiveMapping(i.defaults, i.file) + const roster = new Map(i.roster.map((n) => [n.nodeId, n])) + const telem = new Map(i.telemetry.map((t) => [t.nodeId, t])) + const complete = i.rosterCompleteAt !== undefined + const known = [...mapping.values()].filter((m) => m.known).map((m) => m.nodeId) + // Not in your cockpit, registry or not: shown as new only while it is on the bus. The roster + // keeps absent rows; one that dropped (or that you just forgot) comes back when it reports. + const waiting = [...roster.values()].filter((n) => n.present).map((n) => n.nodeId) + // PDU pages are a report too: a registry or adopted PDU sending them now is on the bus. Pages are + // kept (only marked stale), so one that stopped sending — dropped, or forgotten — is not. + const sending = [...telem.values()] + .filter((t) => mapping.get(t.nodeId)?.known || (mapping.has(t.nodeId) && hasFreshPduPage(t))) + .map((t) => t.nodeId) + // The bridge's own row (node 0, v3) is this client's link, not a cockpit node. + const ids = [...new Set([...known, ...waiting, ...sending])] + .filter((id) => id !== BRIDGE_NODE_ID) + .sort((a, b) => a - b) + + const nodes: NodeModelEntry[] = [] + const hidden: number[] = [] + const hiddenNodes: HiddenNode[] = [] + for (const nodeId of ids) { + const mapped = mapping.get(nodeId) + const m = mapped?.known ? mapped : undefined + if (m?.hidden) { + hidden.push(nodeId) + hiddenNodes.push({ nodeId, name: m.name, console: m.console }) + continue + } + const st = roster.get(nodeId) + const seen = i.seen.get(nodeId) + const t = telem.get(nodeId) + const source = m ? m.source : 'new' + const identified = resolveKind({ + reported: st?.nodeKind, + pduTelemetry: t?.isPdu, + // Only a board in your cockpit has a stored kind. A new one — registry boards included — is + // identified by what it reports (or PDU pages), never by its NODE_ID (#93 re-review C). + stored: m?.kind + }) + const kind: NodeKind = identified === 'pdu' ? 'pdu' : 'panel' + // A board missing from the first complete roster is offline — there is no "waiting". + const availability: NodeAvailability = st + ? st.present + ? 'online' + : 'offline' + : complete + ? 'offline' + : 'unknown' + const e: NodeModelEntry = { + nodeId, + name: mapped?.name ?? `Node ${nodeId}`, + console: m ? m.console : null, + pduId: m && kind !== 'pdu' ? m.pduId : null, + kind, + source, + availability, + differs: m?.differs ?? false + } + if (availability === 'offline') { + if (st) { + e.offlineAt = seen?.offlineAt ?? i.rosterCompleteAt + e.offlineVia = seen?.offlineVia ?? 'roster' + } else e.notSeen = true // remembered, not listed since the link opened + } + const lastSeenAt = m?.lastSeen ? Date.parse(m.lastSeen) : NaN + if (!Number.isNaN(lastSeenAt)) e.lastSeenAt = lastSeenAt + if (identified) e.identified = identified + if (st) e.status = st + if (seen?.reportedAt !== undefined) e.reportedAt = seen.reportedAt + if (source === 'new' && seen) e.firstSeenAt = seen.firstSeenAt + if (source === 'new' && mapped) { + e.registry = { name: mapped.name, console: mapped.console, pduId: mapped.pduId } + const pdu = mapped.pduId === null ? undefined : mapping.get(mapped.pduId) + if (pdu) e.registry.pduName = pdu.name + } + if (kind === 'pdu') { + e.everHadReadings = t?.isPdu ?? false + if (availability === 'online') e.readings = t && hasFreshPduPage(t) ? 'live' : 'none' + } + nodes.push(e) + } + + return { + nodes, + outages: pduOutages(nodes), + differingCount: differingCount(i.defaults, i.file), + hidden, + hiddenNodes, + rosterComplete: complete + } +} + +/** + * 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 91% rename from src/shared/nodes.test.ts rename to src/main/nodes/node-status-wire.test.ts index 89c4737..da8a156 100644 --- a/src/shared/nodes.test.ts +++ b/src/main/nodes/node-status-wire.test.ts @@ -1,33 +1,21 @@ import { describe, expect, it } from 'vitest' import { + canRequestRoster, + cleanNodeLine, + isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, NodeRoster, - ROSTER_REPLY_MS, - cleanNodeLine, - isLegacyNodeStatus, - parseNodeStatus, nodeRosterRequest, - nodeFaultTag, - type NodeStatus -} from './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 -}) + parseNodeStatus, + ROSTER_REPLY_MS +} from './node-status-wire' +import { nodeFaultTag } from './faults' +import type { NodeStatus } from '@shared/nodes' +import { nodeStatus } from '../../test-fixtures/nodes' + +const node = (over: Partial): NodeStatus => + nodeStatus(1, { uptimeSec: 0, rxCount: 0, ...over }) describe('parseNodeStatus', () => { it('decodes a v3 row (28 hex, nodeKind last)', () => { @@ -116,6 +104,16 @@ describe('nodeRosterRequest', () => { }) }) +describe('canRequestRoster (R3.4a: Connection’s Refresh)', () => { + it('only with a port held and the link up', () => { + expect(canRequestRoster(true, 'connected')).toBe(true) + expect(canRequestRoster(false, 'connected'), 'released at shutdown').toBe(false) + for (const s of ['scanning', 'reconnecting', 'no-device', 'error', 'disconnected'] as const) { + expect(canRequestRoster(true, s), s).toBe(false) + } + }) +}) + describe('NodeRoster', () => { it('keeps a removed node, marked offline', () => { const r = new NodeRoster() diff --git a/src/shared/nodes.ts b/src/main/nodes/node-status-wire.ts similarity index 56% rename from src/shared/nodes.ts rename to src/main/nodes/node-status-wire.ts index 21cf9f5..a6f1775 100644 --- a/src/shared/nodes.ts +++ b/src/main/nodes/node-status-wire.ts @@ -1,92 +1,80 @@ // 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). -import { encodeExportFrame } from './dcsbios' +// Wire contract: NodeStatus.h NODE_STATUS_* (generated into shared/firmware; reference.test +// asserts the decoder's protoVersion matches). +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 '@shared/firmware' +import type { DeviceState } from '@shared/gateway' +import type { NodeSeen, NodeStatus } from '@shared/nodes' +import { encodeExportFrame, isLinkUp } from '../gateway' /** 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 +/** 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 +const NODE_REQ_ADDR = NODE_STATUS.reqAddress -/** 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 +/** hFlags bit masks (NODE_HEALTH_FLAGS in the firmware reference). */ +const HFLAG_OVERHEAT = NODE_HEALTH_FLAGS.OVERHEAT +const HFLAG_DEGRADED = NODE_HEALTH_FLAGS.DEGRADED -/** 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 +/** 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 - * 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 { - nodeId: number // 0 = the bridge's own row (v3), 1..63 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 (sentinel 0x80) - 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}` -} - /** 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 (nodeId > 63) 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 + 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(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) } } @@ -115,27 +103,20 @@ export function nodeRosterRequest(): Uint8Array { return encodeExportFrame(NODE_REQ_ADDR, 1) } +/** + * A roster request can go out — the poll, the seed on connect, and Refresh: a port is held and the + * link is up. NodeHub's guard; `device:status` carries it as Refresh's availability (R3.4a). + */ +export function canRequestRoster(attached: boolean, state: DeviceState): boolean { + return attached && isLinkUp(state) +} + /** * How long after a roster request a `_NODE_STATUS` still counts as part of its reply. The bridge * answers at once (a burst of lines at 250 kbaud); a second is generous. */ 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/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 336a75a..e22a831 100644 --- a/src/shared/node-telem.test.ts +++ b/src/main/nodes/node-telem.test.ts @@ -1,15 +1,14 @@ import { describe, expect, it } from 'vitest' +import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' import { displayCurrentMa, hasFreshPduPage, NodeTelemStore, parseNodeTelem, - PDU_FLAG, - RAIL_FLAG, TELEM_PAGE, - TELEM_STALE_MS, - type NodeTelem + TELEM_STALE_MS } from './node-telem' +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 53% rename from src/shared/node-telem.ts rename to src/main/nodes/node-telem.ts index dc12a90..786a68a 100644 --- a/src/shared/node-telem.ts +++ b/src/main/nodes/node-telem.ts @@ -1,89 +1,41 @@ // 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 { + MAX_NODE_ID, + NODE_TELEM, + NODE_TELEM_FIELD, + NODE_TELEM_ROW_BYTES, + NTC_TEMP_UNAVAILABLE, + PDU_RAIL_FIELD, + PDU_STATUS_FIELD, + TELEM_PAGES +} 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 -/** 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 -/** - * 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. - */ -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 +/** TelemPage ids (TELEM_PAGES). Global and append-only; 0x00 is invalid. */ +export const TELEM_PAGE = TELEM_PAGES /** - * 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. + * 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 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 const TELEM_STALE_MS = 10_000 -export type TelemPageData = PduRailPage | PduStatusPage | UnknownTelemPage -export type NodeTelem = { nodeId: number } & TelemPageData +// 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 /** True for the page ids only a PDU sends — what marks a node as a PDU. */ -export function isPduPage(page: number): boolean { +function isPduPage(page: number): boolean { return ( page === TELEM_PAGE.PDU_RAIL_12V || page === TELEM_PAGE.PDU_RAIL_5V || @@ -91,17 +43,14 @@ 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]{18}$/.test(hex)) return null - const nodeId = parseInt(hex.slice(0, 2), 16) - if (nodeId < 1 || nodeId > 63) return null - const b = (i: number) => parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16) // frame byte i + 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) => 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) @@ -114,25 +63,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() + } } /** @@ -143,20 +98,9 @@ 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 +/** iAvgMa for display: residual negative offset reads as 0. */ +export function displayCurrentMa(ma: number): number { + return Math.max(0, ma) } interface Entry { diff --git a/src/main/nodes/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts new file mode 100644 index 0000000..c2cdbe3 --- /dev/null +++ b/src/main/nodes/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 { DEFAULT_PSU, type NodeMappingFile } from '@shared/nodes' +import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' +import { + importMapping, + setNodeMapping, + adoptNode, + effectiveMapping, + EMPTY_MAPPING +} 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: {} } } +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('./mapping-file', () => { + 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/main/nodes/power-zone.ts b/src/main/nodes/power-zone.ts new file mode 100644 index 0000000..42e3e5e --- /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. */ +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 58% rename from src/shared/psu.test.ts rename to src/main/nodes/psu-limits.test.ts index 7cd6a08..ab3f6db 100644 --- a/src/shared/psu.test.ts +++ b/src/main/nodes/psu-limits.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_PSU, effectivePsuLimits, parsePsuLimit, PSU_PRESETS, sanitizePsu } from './psu' +import { effectivePsuLimits, PSU_PRESETS, psuName, resolvePsu, sanitizePsu } from './psu-limits' +import { DEFAULT_PSU, parsePsuLimit } from '@shared/nodes' +import { PSU_VIEW_DEFAULT } from '@shared/config' describe('sanitizePsu', () => { it('falls back to the default for junk', () => { @@ -24,6 +26,25 @@ describe('effectivePsuLimits', () => { }) }) +describe('psuName', () => { + it('names a preset by its model and a custom supply plainly', () => { + expect(psuName({ mode: 'preset', preset: 'nx650', custom: {} })).toBe('NX650') + expect(psuName({ mode: 'custom', preset: 'nx650', custom: { w: 500 } })).toBe('PSU') + }) +}) + +describe('resolvePsu (R3.3: what config:changed carries)', () => { + it('the preset, the limits the Cockpit uses and the PSU box name; the default as the renderer starts', () => { + expect(resolvePsu(DEFAULT_PSU)).toEqual(PSU_VIEW_DEFAULT) + const custom = { mode: 'custom' as const, preset: 'nx650' as const, custom: { w: 500 } } + expect(resolvePsu(custom)).toEqual({ + name: 'PSU', + limits: { w: 500 }, + preset: PSU_PRESETS.nx650 + }) + }) +}) + describe('parsePsuLimit', () => { it('reads a positive number, treats empty as unknown, and refuses the rest', () => { expect(parsePsuLimit(' 54 ')).toBe(54) diff --git a/src/shared/psu.ts b/src/main/nodes/psu-limits.ts similarity index 52% rename from src/shared/psu.ts rename to src/main/nodes/psu-limits.ts index 5bcde6b..5f95561 100644 --- a/src/shared/psu.ts +++ b/src/main/nodes/psu-limits.ts @@ -1,25 +1,17 @@ // 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 -} +// config.json as AppConfig.psu. Display only — the client never alerts on them. What a setting +// comes to — the preset, the limits, the PSU box's name — is resolved here and pushed with the +// settings (SettingsService, R3.3); the renderer only shows it. + +import type { PsuView } from '@shared/config' +import { + DEFAULT_PSU, + isLimit, + type PsuLimits, + type PsuPreset, + type PsuPresetId, + type PsuSetting +} from '@shared/nodes' /** Supplies bundled with the client. Limits are read-only in Settings. */ export const PSU_PRESETS: Record = { @@ -28,20 +20,20 @@ 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 +/** 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 } -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 +/** 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' +} -const isLimit = (v: unknown): v is number => - typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= PSU_LIMIT_MAX +/** What a setting comes to, for Settings › Cockpit and the Cockpit (`config:changed`). */ +export function resolvePsu(psu: PsuSetting): PsuView { + return { name: psuName(psu), limits: effectivePsuLimits(psu), preset: PSU_PRESETS[psu.preset] } +} function sanitizeLimits(raw: unknown): PsuLimits { const r = (raw && typeof raw === 'object' ? raw : {}) as Record @@ -62,20 +54,3 @@ export function sanitizePsu(raw: unknown): PsuSetting { 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/main/nodes/registry.ts b/src/main/nodes/registry.ts new file mode 100644 index 0000000..f45a17d --- /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. */ +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/reference/dcsbios-metadata.ts b/src/main/reference/dcsbios-metadata.ts deleted file mode 100644 index 1a0b22f..0000000 --- a/src/main/reference/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/reference/node-status.generated.ts b/src/main/reference/node-status.generated.ts deleted file mode 100644 index a054e0d..0000000 --- a/src/main/reference/node-status.generated.ts +++ /dev/null @@ -1,140 +0,0 @@ -// AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 -// Firmware/Libraries/NodeStatus/NodeStatus.h (NODE_STATUS contract v3, NODE_TELEM contract v1) - -/** - * Reserved DCS-BIOS identifiers for PanelBridge node-status reporting (#86). - * The decoder in shared/nodes.ts is written for protoVersion; reference.test.ts - * asserts this matches SUPPORTED_NODE_PROTO so a firmware bump fails loudly. - */ -export const NODE_STATUS = { - protoVersion: 3, - reqAddress: 0x86fe, - msgName: '_NODE_STATUS', - endMsgName: '_NODE_STATUS_END' -} as const - -/** HEALTH_n `flags` bits — from NodeHealthFlag (NodeStatus.h). Decoder masks against these. */ -export const NODE_HEALTH_FLAGS = { - OVERHEAT: 0x1, - DEGRADED: 0x2 -} as const - -/** - * HEALTH_n `nodeKind` (byte 5, the last _NODE_STATUS field) — from NodeKind (NodeStatus.h). - * Announced by the board's firmware role, never derived from the NODE_ID. Append-only; 0 = unknown. - */ -export const NODE_KINDS = { - UNKNOWN: 0x0, - PANEL_GROUP: 0x1, - PDU: 0x2, - BRIDGE: 0x3 -} as const - -export interface NodeFaultInfo { - name: string - abbr: string - label: string - description: string -} - -/** - * HEALTH_n `faultId` dictionary — from NodeFaultCode (NodeStatus.h). id -> human label - * (SkyHawkClient#40 render) + the firmware comment as a description. `label` is derived from - * the enum name; `description` is verbatim from the header. Append-only, mirrors the firmware. - * - * Partial: parseNodeStatus() passes through ANY wire faultId, incl. reserved/newer codes this - * build doesn't know — indexing returns `NodeFaultInfo | undefined`, so callers must `?.` + fall back. - */ -export const NODE_FAULT_CODES: Partial> = { - 0: { name: 'NONE', abbr: 'NONE', label: 'None', description: '' }, - 1: { - name: 'I2C_PERIPHERAL', - abbr: 'I2C', - label: 'I2C peripheral', - description: 'an I2C device (OLED/mux/expander) tripped its I2cHealth breaker' - }, - 2: { - name: 'OVER_VOLTAGE', - abbr: 'OVER-V', - label: 'Over voltage', - description: 'rail over-voltage (PDU)' - }, - 3: { - name: 'UNDER_VOLTAGE', - abbr: 'UNDER', - label: 'Under voltage', - description: 'rail under-voltage (PDU)' - }, - 4: { - name: 'SHORT_CIRCUIT', - abbr: 'SHORT', - label: 'Short circuit', - description: 'rail short / over-current (PDU)' - }, - 5: { - name: 'HOST_LINK_LOST', - abbr: 'HOST', - label: 'Host link lost', - description: 'PanelBridge lost the host serial link' - }, - 6: { - name: 'FUSE_OPEN', - abbr: 'FUSE', - label: 'Fuse open', - description: 'rail fuse blown — rail read ~0 V while the node is powered (PDU)' - }, - 7: { - name: 'OVER_TEMPERATURE', - abbr: 'OVER-T', - label: 'Over temperature', - description: 'board temperature at the critical threshold (PDU NTC)' - }, - 8: { - name: 'SENSOR_FAULT', - abbr: 'SENSOR', - label: 'Sensor fault', - description: 'a measurement channel reads implausibly (railed / open / shorted)' - }, - 9: { - name: 'DUPLICATE_NODE_ID', - abbr: 'DUPLICATE', - label: 'Duplicate node id', - description: "another board transmits on this node's HB_n — two boards share a NODE_ID (#344)" - } -} as const - -/** - * Reserved DCS-BIOS identifier for PanelBridge node telemetry (#216): one message per TELEM_n - * frame, forwarded unread. The decoder in shared/node-telem.ts is written for protoVersion; - * reference.test.ts asserts this matches SUPPORTED_TELEM_PROTO. - */ -export const NODE_TELEM = { - protoVersion: 1, - msgName: '_NODE_TELEM' -} as const - -/** TELEM_n page ids — from TelemPage (NodeStatus.h). Global and append-only; 0x00 is invalid. */ -export const TELEM_PAGES = { - PDU_RAIL_12V: 0x1, - PDU_RAIL_5V: 0x2, - PDU_STATUS: 0x3 -} as const - -/** PDU_RAIL_* page byte 1 — from PduRailFlag (NodeStatus.h). All zero = healthy. */ -export const PDU_RAIL_FLAGS = { - UNDER_V: 0x1, - OVER_V: 0x2, - OVER_CURRENT: 0x4, - FUSE_OPEN: 0x8, - SENSOR_FAULT: 0x10 -} as const - -/** PDU_STATUS page byte 1 — from PduStatusFlag (NodeStatus.h). All zero = healthy, calibrated. */ -export const PDU_STATUS_FLAGS = { - UNCAL_VDDA: 0x1, - UNCAL_ZERO: 0x2, - TEMP_WARN: 0x4, - TEMP_CRIT: 0x8, - NTC_FAULT: 0x10 -} as const diff --git a/src/main/reference/reference.test.ts b/src/main/reference/reference.test.ts deleted file mode 100644 index 60eb384..0000000 --- a/src/main/reference/reference.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from 'vitest' -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 { - 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' -import * as cal from '@shared/calibration' - -describe('a4ec-controls.generated', () => { - it('has outputs, each with a numeric address', () => { - expect(A4EC_OUTPUTS.length).toBeGreaterThan(300) - for (const o of A4EC_OUTPUTS) expect(Number.isInteger(o.address)).toBe(true) - }) - - it('indexes outputs by address', () => { - const sample = A4EC_OUTPUTS[0]! - const bucket = A4EC_OUTPUTS_BY_ADDRESS.get(sample.address) - expect(bucket).toBeDefined() - expect(bucket!.some((o) => o.id === sample.id)).toBe(true) - }) - - it('indexes inputs by id for command lookup', () => { - const sample = A4EC_INPUTS[0]! - expect(A4EC_INPUTS_BY_ID.get(sample.id)).toBeDefined() - }) -}) - -describe('hid-controls.generated', () => { - it('maps the axis controlIds', () => { - expect(HID_CONTROLS.length).toBeGreaterThan(0) - expect(HID_CONTROLS_BY_ID.get(0x10)?.label).toBe('Roll') - }) - - it('carries the routing ranges', () => { - expect(HID_ID.AXIS_MIN).toBe(0x10) - expect(HID_ID.BUTTON_MAX).toBe(0xaf) - expect(HID_ID.HID_MAX).toBe(0xff) - }) -}) - -describe('hid-report-layout.generated', () => { - it('is the fixed 34-byte layout', () => { - expect(HID_REPORT_LAYOUT.size).toBe(34) - expect(HID_REPORT_LAYOUT.buttons.offset).toBe(0) - expect(HID_REPORT_LAYOUT.hats.offset).toBe(16) - expect(HID_REPORT_LAYOUT.axes.offset).toBe(18) - expect(HID_REPORT_LAYOUT.axes.usages).toHaveLength(8) - }) - - it('declares signed axes (no 0x8000 bias)', () => { - expect(HID_REPORT_LAYOUT.axes.signed).toBe(true) - expect(HID_REPORT_LAYOUT.axes.min).toBe(-32768) - }) -}) - -describe('node-names.generated', () => { - it('maps NODE_ID 0 to PanelBridge and includes registered panels', () => { - expect(NODE_NAMES[0]?.name).toBe('PanelBridge') - expect(Object.keys(NODE_NAMES).length).toBeGreaterThan(0) - }) -}) - -describe('dcsbios-metadata', () => { - it('pins _ACFT_NAME at 0x0000, 24 bytes', () => { - expect(ACFT_NAME.address).toBe(0x0000) - expect(ACFT_NAME.maxLength).toBe(24) - }) -}) - -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)', () => { - // Each id -> name + badge tag. A code appended at 0x09+ still passes; swapping or renaming an - // existing one (which would put the wrong badge on a degraded node) does not. - const pinned: Record = { - 0x00: { name: 'NONE', abbr: 'NONE' }, - 0x01: { name: 'I2C_PERIPHERAL', abbr: 'I2C' }, - 0x02: { name: 'OVER_VOLTAGE', abbr: 'OVER-V' }, - 0x03: { name: 'UNDER_VOLTAGE', abbr: 'UNDER' }, - 0x04: { name: 'SHORT_CIRCUIT', abbr: 'SHORT' }, - 0x05: { name: 'HOST_LINK_LOST', abbr: 'HOST' }, - 0x06: { name: 'FUSE_OPEN', abbr: 'FUSE' }, - 0x07: { name: 'OVER_TEMPERATURE', abbr: 'OVER-T' }, - 0x08: { name: 'SENSOR_FAULT', abbr: 'SENSOR' } - } - for (const [id, want] of Object.entries(pinned)) { - expect(NODE_FAULT_CODES[Number(id)], `fault 0x${Number(id).toString(16)}`).toMatchObject(want) - } - expect(NODE_FAULT_CODES[1]?.label).toBe('I2C peripheral') - // every entry carries a non-empty label except NONE - for (const [id, e] of Object.entries(NODE_FAULT_CODES)) { - if (Number(id) !== 0) expect(e?.label.length).toBeGreaterThan(0) - } - // an unknown/reserved id (parseNodeStatus passes any faultId through) is undefined, not a crash — - // the Partial type forces callers to ?.-guard. - expect(NODE_FAULT_CODES[0x99]).toBeUndefined() - }) - - 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) - }) -}) - -// 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. -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. -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('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) - } - }) - - 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. - expect(CAL_PAYLOAD_LEN[cal.CAL_TYPE.COMMIT]).toBe(9) - }) -}) diff --git a/src/main/session.ts b/src/main/session.ts deleted file mode 100644 index f6515ae..0000000 --- a/src/main/session.ts +++ /dev/null @@ -1,340 +0,0 @@ -// 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 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 { - clientErrorEvent, - deviceEvent, - EventLog, - 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 { 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' - -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 device link: serial, calibration, HID and nodes. Up independently of the relay. */ - readonly link: DeviceLink - 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[] = [] - - constructor(private readonly emit: Emit) { - this.parser = new DcsBiosProtocol((addr, val) => this.onWrite(addr, val)) - this.link = new DeviceLink(emit) - this.link.onBytes((bytes) => this.fromDevice(bytes)) - this.link.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) => { - this.stats.error() - this.logError(tag, message) - }) - this.link.onNodeEvents((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 - // 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 - }) - } - - /** 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) - this.link.start(this.config.autoConnect) - } - - /** App quit. */ - shutdown(): void { - this.stop() - this.link.shutdown() - if (this.logTimer) clearInterval(this.logTimer) - this.logTimer = undefined - } - - getConfig(): AppConfig { - return this.config - } - - setConfig(patch: Partial): AppConfig { - const prev = this.config - this.config = { ...prev, ...patch } - if (prev.autoConnect !== this.config.autoConnect) { - this.link.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() - } - 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.link.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.link.exportGated) this.link.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.link.deviceStatus() - return { running: this.running, relay: this.relay, device, cal: this.link.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/settings.ts b/src/main/settings.ts deleted file mode 100644 index cffcb0e..0000000 --- a/src/main/settings.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Persist AppConfig to /config.json so settings survive restarts. -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' - -function configPath(): string { - return join(app.getPath('userData'), 'config.json') -} - -export function loadConfig(): AppConfig { - try { - const p = configPath() - if (!existsSync(p)) return { ...DEFAULT_CONFIG } - return sanitizeConfig(JSON.parse(readFileSync(p, 'utf8'))) - } catch { - return { ...DEFAULT_CONFIG } - } -} - -export function saveConfig(config: AppConfig): void { - try { - writeFileSync(configPath(), JSON.stringify(config, null, 2)) - } catch { - // best-effort; a read-only userData dir shouldn't crash the app - } -} diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts new file mode 100644 index 0000000..714e5dd --- /dev/null +++ b/src/main/test-harness/services.ts @@ -0,0 +1,396 @@ +// Services harness for SESSION-seam tests (test-only: production code never imports this). +// +// `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: +// - 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. The harness wraps `AppServices` (R2.3c): when the services moved, 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' +import { onTestFinished, vi } from 'vitest' +import { CTRL, type PushChannel, type PushChannels } from '@shared/app' +import type { AppConfig, ConfigEdit } from '@shared/config' +import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' + +// ── 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. The mapping, settings and calibration-cache stores are real: they write +// , 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, + getVersion: () => '0.0.0-test', + isPackaged: false + } +})) +vi.doMock('../gateway/serial-bridge', () => ({ + 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('../dcs/transports', () => ({ createTransport: () => new FakeTransport() })) +vi.doMock('../hid/hid-reader', () => ({ HidReader: FakeHid })) + +const { AppServices } = await import('../app/app-services') +const { storePaths } = await import('../app/user-data') +const { PortNotFoundError } = await import('../gateway/serial-bridge') + +// ── 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. */ +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 + + // app/bootstrap.ts: the files, handed to the stores. + const paths = storePaths(() => world.userData) + + const pushes: Push[] = [] + const services = new AppServices((channel, payload) => { + pushes.push({ channel, payload } as Push) + }, paths) + + // app/ipc.ts registerIpc() and the topics' ipc.ts: the renderer's requests main answers. + const intents = { + [CTRL.configGet]: () => services.settings.view(), + [CTRL.configSet]: (patch: Partial) => services.settings.apply(patch), + [CTRL.configEdit]: (edit: ConfigEdit) => services.settings.edit(edit), + [CTRL.relayStart]: () => services.relay.start(), + [CTRL.relayStop]: () => services.relay.stop(), + [CTRL.relayToggle]: (relaying: boolean) => services.relay.toggle(relaying), + // A renderer's first pull on (re)load: what the old page held goes with it (app/ipc.ts). + [CTRL.relayStatus]: () => { + services.calibration.workflow.rendererLoaded() + services.nodes.rendererLoaded() + services.rendererLoaded() + return 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/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 + + 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 { + services.relay.stop() + }, + + /** The app quits (`will-quit`). */ + quit(): void { + if (quit) return + quit = true + services.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/main/user-data.ts b/src/main/user-data.ts deleted file mode 100644 index 3cda82c..0000000 --- a/src/main/user-data.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Where lives. Packaged builds keep Electron's default; dev builds use -// /.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' - -export const USER_DATA_ENV = 'SKYHAWK_USER_DATA' - -export interface UserDataInputs { - isPackaged: boolean - /** app.getAppPath(): the repo root in dev. */ - appPath: string - env: Record - cwd: string -} - -/** The folder to pass to app.setPath('userData'), or null to keep the default. */ -export function userDataOverride({ isPackaged, appPath, env, cwd }: UserDataInputs): string | null { - const fromEnv = env[USER_DATA_ENV]?.trim() - if (fromEnv) return resolve(cwd, fromEnv) - if (!isPackaged) return join(appPath, '.userdata') - return null -} diff --git a/src/preload/index.ts b/src/preload/index.ts index 2ba7d40..d380bf3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,43 +2,57 @@ import { contextBridge, ipcRenderer } from 'electron' import type { LogEvent } from '@shared/events' import { CTRL, - type AppConfig, - type CalCacheEntry, - type CalCommitAxis, - type CalHello, - type CalResult, - type CalSnapshot, - type CaptureState, - type ConfigExportResult, - type ConfigImportApplyResult, - type ConfigImportOpen, + deliverPushes, + PUSH_BATCH, type DebugDumpResult, - type ExportResult, - type HidAvailability, - type NodeMappingResult, - type NodeRegistryInfo, - type NodeSnapshot, + type PushBatch, 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' +import type { + AppConfig, + ConfigExportResult, + ConfigImportApplyResult, + ConfigImportOpen, + ConfigView +} from '@shared/config' +import type { RecordState, ExportResult, RelayResult, ReplayLoad } from '@shared/dcs' +import type { HidAvailability } from '@shared/hid' +import type { + NodeBatchResult, + NodeMappingResult, + NodeRegistryInfo, + NodeSnapshot, + ResetPreview +} from '@shared/nodes' + +// Each push channel's subscribers, in the order they subscribed. +const subscribers = new Map void>>() + +// Main sends every push of one turn as one `push:batch` message (R3.4c). Its pushes reach their +// channels' subscribers in main's order, all inside this one IPC event, so the renderer applies +// them in one task and React commits them as one frame. +ipcRenderer.on(PUSH_BATCH, (_e: unknown, batch: PushBatch) => + deliverPushes(batch, (channel) => subscribers.get(channel)) +) // Typed bridge: main -> renderer push subscriptions + renderer -> main control. const api: SkyhawkApi = { on(channel: C, cb: (data: PushChannels[C]) => void): () => void { - const listener = (_e: unknown, data: PushChannels[C]): void => cb(data) - ipcRenderer.on(channel, listener) + // A wrapper per subscription, as ipcRenderer.on made one: the same callback twice is two. + const listener = (data: PushChannels[C]): void => cb(data) + const set = subscribers.get(channel) ?? new Set() + set.add(listener as (data: never) => void) + subscribers.set(channel, set) return () => { - ipcRenderer.removeListener(channel, listener) + set.delete(listener as (data: never) => void) } }, - getConfig: () => ipcRenderer.invoke(CTRL.configGet) as Promise, + getConfig: () => ipcRenderer.invoke(CTRL.configGet) as Promise, setConfig: (patch) => ipcRenderer.invoke(CTRL.configSet, patch) as Promise, + editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise, exportConfig: () => ipcRenderer.invoke(CTRL.configExport) as Promise, openConfigImport: () => ipcRenderer.invoke(CTRL.configImportOpen) as Promise, applyConfigImport: (token, sections, backup) => @@ -50,6 +64,8 @@ const api: SkyhawkApi = { ) as Promise, startRelay: () => ipcRenderer.invoke(CTRL.relayStart) as Promise, stopRelay: () => ipcRenderer.invoke(CTRL.relayStop) as Promise, + toggleRelay: (relaying: boolean) => + ipcRenderer.invoke(CTRL.relayToggle, relaying) as Promise, getStatus: () => ipcRenderer.invoke(CTRL.relayStatus) as Promise, connectDevice: () => ipcRenderer.invoke(CTRL.deviceConnect) as Promise, disconnectDevice: () => ipcRenderer.invoke(CTRL.deviceDisconnect) as Promise, @@ -57,7 +73,7 @@ const api: SkyhawkApi = { getEvents: () => ipcRenderer.invoke(CTRL.eventsGet) as Promise, clearEvents: () => ipcRenderer.invoke(CTRL.eventsClear) as Promise, exportEvents: () => ipcRenderer.invoke(CTRL.eventsExport) as Promise, - toggleCapture: () => ipcRenderer.invoke(CTRL.captureToggle) as Promise, + toggleCapture: () => ipcRenderer.invoke(CTRL.captureToggle) as Promise, openReplay: () => ipcRenderer.invoke(CTRL.replayOpen) as Promise, getHidAvailability: () => ipcRenderer.invoke(CTRL.hidAvailability) as Promise, refreshNodes: () => ipcRenderer.invoke(CTRL.nodesRefresh) as Promise, @@ -76,29 +92,27 @@ const api: SkyhawkApi = { getNodeRegistry: () => ipcRenderer.invoke(CTRL.nodeRegistry) as Promise, hideNode: (nodeId, on) => ipcRenderer.invoke(CTRL.nodeMappingHide, nodeId, on) as Promise, + addAllNodes: (bulk) => + ipcRenderer.invoke(CTRL.nodeMappingAddAll, bulk) as Promise, + unhideNodes: (nodeIds) => + ipcRenderer.invoke(CTRL.nodeMappingUnhide, nodeIds) as Promise, 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/App.tsx b/src/renderer/src/app/App.tsx similarity index 66% rename from src/renderer/src/App.tsx rename to src/renderer/src/app/App.tsx index 133ec37..e6752aa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,14 +1,16 @@ import { useEffect } from 'react' -import { useStore, type TabId } from './store' -import { DevicePopover } from './DevicePopover' -import { RelayPill } from './RelayPill' -import { Overview } from './tabs/Overview' -import { Connection } from './tabs/Connection' -import { Cockpit } from './tabs/Cockpit' -import { Log } from './tabs/Log' -import { Serial } from './tabs/Serial' -import { Hid } from './tabs/Hid' -import { Settings } from './tabs/Settings' +import { useDcsStore } from '../dcs' +import { SerialTab } from '../gateway' +import { initBridge } from './ipc-adapter' +import { useNav, type TabId } from './kit' +import { DevicePopover } from './titlebar/DevicePopover' +import { RelayPill } from './titlebar/RelayPill' +import { Overview } from './overview/Overview' +import { Connection } from './connection/Connection' +import { Cockpit } from './cockpit/Cockpit' +import { Log } from './log/Log' +import { HidTab } from './hid/HidTab' +import { Settings } from './settings/Settings' const TABS: { id: TabId; label: string }[] = [ { id: 'overview', label: 'Overview' }, @@ -21,12 +23,12 @@ const TABS: { id: TabId; label: string }[] = [ ] export function App() { - const tab = useStore((s) => s.tab) - const sourceMode = useStore((s) => s.sourceMode) - const set = useStore((s) => s.set) - const initBridge = useStore((s) => s.initBridge) + const tab = useNav((s) => s.tab) + // The mode the relay's facts were judged against (R3.4c), as the Connection tab's segment shows it. + const sourceMode = useDcsStore((s) => s.relayFacts.mode) + const set = useNav((s) => s.set) - useEffect(() => initBridge(), [initBridge]) + useEffect(() => initBridge(), []) return (

diff --git a/src/renderer/src/assets/logo.png b/src/renderer/src/app/assets/logo.png similarity index 100% rename from src/renderer/src/assets/logo.png rename to src/renderer/src/app/assets/logo.png diff --git a/src/renderer/src/app/cockpit/Cockpit.tsx b/src/renderer/src/app/cockpit/Cockpit.tsx new file mode 100644 index 0000000..aa004c0 --- /dev/null +++ b/src/renderer/src/app/cockpit/Cockpit.tsx @@ -0,0 +1,9 @@ +import { useConfigStore } from '../../config' +import { CockpitTab } from '../../nodes' + +/** The Cockpit tab (nodes), drawn against the PSU setting as main resolves it (config). */ +export function Cockpit() { + // The PSU setting as main resolves it (`config:changed`, R3.3). + const psuView = useConfigStore((s) => s.psuView) + return +} diff --git a/src/renderer/src/app/connection/Connection.tsx b/src/renderer/src/app/connection/Connection.tsx new file mode 100644 index 0000000..800d2e1 --- /dev/null +++ b/src/renderer/src/app/connection/Connection.tsx @@ -0,0 +1,116 @@ +import { useConfigStore, viewTransport, type SourceMode, type TransportId } from '../../config' +import { CaptureCard, RecordCard, RelayStart, useDcsStore } from '../../dcs' +import { DeviceCard } from '../../gateway' +import { NodesCard } from '../../nodes' + +const HINTS: Record = { + 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.' +} + +const TRANSPORTS: { id: TransportId; title: string; detail: string }[] = [ + { id: 'tcp', title: 'TCP to host', detail: ':7778 · remote' }, + { id: 'loop', title: 'Loopback multicast', detail: '239.255.50.10:5010 · local' }, + { id: 'uni', title: 'Unicast listen', detail: 'BIOSConfig.lua send_address' } +] + +/** + * The Connection tab: the device card (gateway) and the connected nodes (nodes) on the left; the + * source mode and DCS link settings (config) between the relay's own controls (dcs) on the right. + */ +export function Connection() { + const s = useConfigStore() + const setConfigField = useConfigStore((x) => x.setConfigField) + // The mode and the transport the relay's facts were judged against (R3.4c): the segment, its + // hint and the radios flip in the same push as the cards, Start and the pill, once main has + // taken the click. + const facts = useDcsStore((x) => x.relayFacts) + const transport = viewTransport(facts.transport) + + return ( +
+
+ + + +
+ + {/* controls */} +
+
+
+ Source Mode +
+
+ {(['bridge', 'monitor', 'replay'] as SourceMode[]).map((m) => ( + + ))} +
+
{HINTS[facts.mode]}
+
+ + {/* The Capture and Record cards flip together, in the same relay:facts push (R3.4a). */} + setConfigField({ replayDriveSerial: !s.replayDriveSerial })} + /> + +
+
+ DCS Link +
+
+
+
DCS host
+ setConfigField({ host: e.target.value })} + /> +
+
+
Port
+ setConfigField({ port: e.target.value })} + /> +
+
+
Transport
+
+ {TRANSPORTS.map((t) => ( + + ))} +
+
+ + + + +
+
+ ) +} diff --git a/src/renderer/src/app/hid/HidTab.tsx b/src/renderer/src/app/hid/HidTab.tsx new file mode 100644 index 0000000..2d37209 --- /dev/null +++ b/src/renderer/src/app/hid/HidTab.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react' +import { HidPanel } from '../../hid' +import { CalibrationDialog, useCalibrationStore } from '../../hid/calibration' + +/** + * The HID tab: the live HID panel (hid) with axis calibration (hid/calibration) over it — badges, + * the restore offer and the dialog. Composed here because hid never imports hid/calibration. + */ +export function HidTab() { + const calibration = useCalibrationStore((s) => s.calibration) + const calibrate = useCalibrationStore((s) => s.calibrate) + // The calibration workflow lives as long as this tab is mounted (main builds and disposes it). + const { attach } = calibrate + useEffect(() => { + attach(true) + return () => attach(false) + }, [attach]) + + return ( + + } + cal={calibration.tab} + onCalibrate={calibrate.open} + /> + ) +} diff --git a/src/renderer/src/app/index.ts b/src/renderer/src/app/index.ts new file mode 100644 index 0000000..ede6eba --- /dev/null +++ b/src/renderer/src/app/index.ts @@ -0,0 +1,2 @@ +// app (R3.4b): the shell and the tabs that compose several topics. The renderer entry imports this. +export { App } from './App' diff --git a/src/renderer/src/store.test.ts b/src/renderer/src/app/ipc-adapter.test.ts similarity index 59% rename from src/renderer/src/store.test.ts rename to src/renderer/src/app/ipc-adapter.test.ts index 38da21e..38abe85 100644 --- a/src/renderer/src/store.test.ts +++ b/src/renderer/src/app/ipc-adapter.test.ts @@ -1,14 +1,40 @@ 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 { CALIBRATION_IDLE, type CalibrationState, 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' +import type { DeviceStatus, DeviceStatusPush } from '@shared/gateway' +import type { HidSnapshot } from '@shared/hid' +import { DEFAULT_CONFIG, PSU_VIEW_DEFAULT } from '@shared/config' +import { isLinkUp, LinkDownClock, linkFacts } from '../../../main/gateway/link-state' +import { relayFacts } from '../../../main/dcs/relay-rules' +import { hidFacts } from '../../../main/hid/hid-facts' // 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. type Listener = (data: unknown) => void const listeners = new Map() -let status: RelayStatus = { + +/** + * What main's pushes and its rehydrate bundle are built from, as the cases give them: since R3.3 + * main adds what it concludes (the link's facts) on the way out, as the fake below does with + * main's own builders. + */ +interface Given { + 'device:status': DeviceStatus + 'hid:report': HidSnapshot +} +type GivenPushes = Omit & Given +type GivenStatus = Omit & { device: DeviceStatus } + +/** Main's GatewayConnection: the page's "last known" time, judged per push (link-state.ts). */ +let linkDown = new LinkDownClock() +const withFacts = (d: DeviceStatus): DeviceStatusPush => ({ + ...d, + ...linkFacts(d, { linkDownAt: linkDown.value, serialMonitor: false }) +}) + +let status: GivenStatus = { running: false, relay: { running: false }, device: { state: 'no-device' } @@ -34,34 +60,68 @@ function fakeApi() { listeners.set(ch, [...(listeners.get(ch) ?? []), cb]) return () => {} }, - getStatus: () => Promise.resolve(status), + getStatus: () => { + linkDown.pageLoaded(status.device.state) // a (re)loaded page starts over (N1) + return Promise.resolve({ + ...status, + device: withFacts(status.device), + // main's DcsRelay, over the default settings and this status + relayFacts: relayFacts({ + config: DEFAULT_CONFIG, + stream: status.relay.stream, + deviceState: status.device.state, + linkUp: isLinkUp(status.device.state), + recording: { on: false } + }) + }) + }, getNodes: () => Promise.resolve(nodesSnap), getEvents: () => Promise.resolve(eventsSnap), clearEvents: () => { eventsCleared++ return Promise.resolve() }, + // The settings and what they resolve to (`config:get` since R3.3). getConfig: () => Promise.resolve({ - sourceMode: 'bridge', - transport: 'tcp-to-host', - host: '127.0.0.1', - commandPort: 7778, - listenPort: 5010, - autoConnect: true, - replayDriveSerial: false, - debugMode: false + config: { + sourceMode: 'bridge', + transport: 'tcp-to-host', + host: '127.0.0.1', + commandPort: 7778, + listenPort: 5010, + autoConnect: true, + replayDriveSerial: false, + debugMode: false + }, + psu: PSU_VIEW_DEFAULT }), getHidAvailability: () => Promise.resolve({ axes: [0, 1], hats: [], buttons: [] }), setConfig: (patch: unknown) => Promise.resolve(patch), + editConfig: (edit: unknown) => Promise.resolve(edit), + // main's DcsRelay.toggle over the two intents below + toggleRelay: (relaying: boolean) => + relaying + ? Promise.resolve({ ok: true }) + : Promise.resolve({}).then(() => Promise.resolve(startResult)), startRelay: () => Promise.resolve(startResult), stopRelay: () => Promise.resolve({ ok: true }) } } -/** Deliver a main -> renderer push, as the preload bridge would. */ -function push(ch: C, data: PushChannels[C]) { - for (const cb of listeners.get(ch) ?? []) cb(data) +/** Deliver a main -> renderer push, as main builds it and the preload bridge delivers it. */ +function push(ch: C, data: GivenPushes[C]) { + let out: unknown = data + if (ch === 'device:status') { + const d = data as DeviceStatus + linkDown.pushed(d.state) + out = withFacts(d) + } + if (ch === 'hid:report') { + const h = data as HidSnapshot + out = { ...h, ...hidFacts(h.ageMs, undefined, [0, 1]) } // main's HidService, no device report + } + for (const cb of listeners.get(ch) ?? []) cb(out) } const snapshot = (calibratedMask: number, serial = 'BOARD-A'): CalSnapshot => ({ @@ -80,23 +140,55 @@ const snapshot = (calibratedMask: number, serial = 'BOARD-A'): CalSnapshot => ({ serialNumber: serial }) +/** What main pushes on `cal:workflow` with `snap` reported (only the snapshot matters here). */ +const calibration = (snap: CalSnapshot): CalibrationState => ({ + tab: { ...CALIBRATION_IDLE.tab, snapshot: snap } +}) + /** * A fresh module instance per test. * - * store.ts guards against duplicate IPC subscriptions with a module-level flag, so reusing the - * import would make initBridge() a no-op after the first test — and a reload is exactly what is + * The IPC adapter guards against duplicate IPC subscriptions with a module-level flag, so reusing + * the import would make initBridge() a no-op after the first test — and a reload is exactly what is * under test here. + * + * Since R3.4b the renderer's state is split across the topic stores; the cases read it as one, the + * way the single store held it (the Log tab's Pause is the events slice's `togglePause`). */ async function freshStore() { listeners.clear() + linkDown = new LinkDownClock() vi.resetModules() ;(globalThis as { window?: unknown }).window = { skyhawk: fakeApi() } - const mod = await import('./store') - const { useStore } = mod - await useStore.getState().initBridge() + const { initBridge } = await import('./ipc-adapter') + const { useNav } = await import('./kit') + const { useGatewayStore } = await import('../gateway') + const { useDcsStore } = await import('../dcs') + const { useNodesStore } = await import('../nodes') + const { useHidStore } = await import('../hid') + const { useCalibrationStore } = await import('../hid/calibration') + const { useEventsStore } = await import('../events') + const { useConfigStore } = await import('../config') + await initBridge() await Promise.resolve() await Promise.resolve() - return useStore + const toggle = (key: 'logPaused' | 'autoscroll' | 'rawMode') => + key === 'logPaused' + ? useEventsStore.getState().togglePause() + : useDcsStore.getState().toggle(key) + return { + getState: () => ({ + ...useNav.getState(), + ...useGatewayStore.getState(), + ...useDcsStore.getState(), + ...useNodesStore.getState(), + ...useHidStore.getState(), + ...useCalibrationStore.getState(), + ...useEventsStore.getState(), + ...useConfigStore.getState(), + toggle + }) + } } describe('calibration state lifecycle', () => { @@ -104,75 +196,41 @@ describe('calibration state lifecycle', () => { status = { running: false, relay: { running: false }, device: { state: 'no-device' } } }) + // The report itself — taken from GET_CAL, dropped whenever the link is not up — is main's since + // R3.1 (main/hid/calibration/workflow-host.test.ts). The store holds what main pushes. it('starts with no snapshot, which is not the same as uncalibrated', async () => { const s = await freshStore() - expect(s.getState().cal).toBeUndefined() + expect(s.getState().calibration.tab.snapshot).toBeUndefined() }) - it('takes the snapshot from a cal:data push', async () => { + it('takes the snapshot from a cal:workflow push', async () => { const s = await freshStore() - push('cal:data', snapshot(0b01)) - expect(s.getState().cal?.calibratedMask).toBe(0b01) - }) - - it('carries the report age, not just the rate', async () => { - // HidReader has always computed ageMs, and the store used to take only rateHz — so the - // freshness signal existed in main and was dropped at the IPC boundary. - const s = await freshStore() - push('hid:report', { axes: [1], buttons: [false], hats: [0], ageMs: 90_000, rateHz: 3 }) - expect(s.getState().hidRate).toBe(3) - expect(s.getState().hidAgeMs).toBe(90_000) + push('cal:workflow', calibration(snapshot(0b01))) + expect(s.getState().calibration.tab.snapshot?.calibratedMask).toBe(0b01) }) it('rehydrates the snapshot on a renderer reload', async () => { // The regression: a reload builds a fresh store while the main-process session keeps - // running. No port-open event follows, so no cal:data is pushed, and without the snapshot + // running. No port-open event follows, so no GET_CAL is read, and without the snapshot // in session status the badges would stay blank indefinitely on a stable link. status = { running: true, relay: { running: true, stream: 'connected' }, device: { state: 'connected', portPath: '/dev/fake' }, - cal: snapshot(0b01) + cal: calibration(snapshot(0b01)) } const s = await freshStore() - expect(s.getState().cal?.calibratedMask, 'reload must recover the badges').toBe(0b01) + expect( + s.getState().calibration.tab.snapshot?.calibratedMask, + 'reload must recover the badges' + ).toBe(0b01) expect(s.getState().deviceState).toBe('connected') }) it('rehydrates nothing when the link is down', async () => { status = { running: false, relay: { running: false }, device: { state: 'no-device' } } const s = await freshStore() - expect(s.getState().cal).toBeUndefined() - }) - - // 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}`, async () => { - const s = await freshStore() - push('cal:data', snapshot(0b01)) - expect(s.getState().cal).toBeDefined() - push('device:status', { state }) - expect(s.getState().cal, `${state} means the snapshot cannot be vouched for`).toBeUndefined() - }) - } - - it('keeps the snapshot while the link stays up', async () => { - const s = await freshStore() - push('cal:data', snapshot(0b01)) - push('device:status', { state: 'connected', portPath: '/dev/fake' }) - expect(s.getState().cal?.calibratedMask).toBe(0b01) - }) - - it('replaces one board’s snapshot with the next board’s, never merging them', async () => { - const s = await freshStore() - push('cal:data', snapshot(0b11, 'BOARD-A')) - push('device:status', { state: 'reconnecting' }) - expect(s.getState().cal).toBeUndefined() - push('device:status', { state: 'connected' }) - push('cal:data', snapshot(0b00, 'BOARD-B')) - expect(s.getState().cal?.serialNumber).toBe('BOARD-B') - expect(s.getState().cal?.calibratedMask).toBe(0b00) + expect(s.getState().calibration.tab.snapshot).toBeUndefined() }) }) @@ -185,15 +243,13 @@ describe('relay state (#69)', () => { const s = await freshStore() push('relay:state', { running: true, stream: 'reconnecting' }) expect(s.getState().relaying).toBe(true) - expect(s.getState().relayStream).toBe('reconnecting') push('device:status', { state: 'connected', portPath: '/dev/fake' }) push('relay:state', { running: false }) expect(s.getState().relaying).toBe(false) - expect(s.getState().relayStream).toBeUndefined() expect(s.getState().deviceState, 'stopping the relay leaves the link up').toBe('connected') }) - it('rehydrates the stream state on a reload', async () => { + it('rehydrates the relay state on a reload', async () => { status = { running: true, relay: { running: true, stream: 'connected' }, @@ -201,7 +257,6 @@ describe('relay state (#69)', () => { } const s = await freshStore() expect(s.getState().relaying).toBe(true) - expect(s.getState().relayStream).toBe('connected') }) }) @@ -271,11 +326,10 @@ describe('node state (#63)', () => { nodesSnap = { model: EMPTY_MODEL, telemetry: [] } }) - it('rehydrates the model and telemetry on a renderer reload', async () => { + it('rehydrates the model on a renderer reload', async () => { nodesSnap = { model: pduModel, telemetry: telem(1000) } const s = await freshStore() expect(s.getState().nodeModel).toBe(pduModel) - expect(s.getState().nodeTelemetry).toHaveLength(1) }) it('keeps the last model when the link drops, and remembers when it dropped', async () => { @@ -291,31 +345,6 @@ describe('node state (#63)', () => { push('device:status', { state: 'connected' }) expect(s.getState().linkDownAt).toBeUndefined() }) - - it('tracks the session peak of the computed total power', async () => { - const s = await freshStore() - push('nodes:model', pduModel) - push('nodes:telemetry', telem(2000)) - expect(s.getState().nodesPeakW).toBeCloseTo(24) - push('nodes:telemetry', telem(1000)) - expect(s.getState().nodesPeakW).toBeCloseTo(24) - }) - - it('peaks over the same PDUs as the Total card: an unadopted PDU does not count', async () => { - // Review of #80: one registry PDU and one still in "New on the bus", 29 W each, read - // "Total 29.0 W · peak 58.0 W" — a peak the card could never show. - const withNew: NodeModel = { - ...pduModel, - nodes: [ - ...pduModel.nodes, - { ...pduModel.nodes[0]!, nodeId: 42, name: 'Node 42', console: null, source: 'new' } - ] - } - const s = await freshStore() - push('nodes:model', withNew) - push('nodes:telemetry', [...telem(2417), { ...telem(2417)[0]!, nodeId: 42 }]) - expect(s.getState().nodesPeakW).toBeCloseTo(29, 0) - }) }) describe('event log (#64)', () => { diff --git a/src/renderer/src/app/ipc-adapter.ts b/src/renderer/src/app/ipc-adapter.ts new file mode 100644 index 0000000..a029504 --- /dev/null +++ b/src/renderer/src/app/ipc-adapter.ts @@ -0,0 +1,84 @@ +// The renderer's one link to main (R3.4b, P5): the only file that reads `window.skyhawk`. +// +// It binds every topic store's intents to main, and `initBridge` (what App does on mount) pulls the +// current picture and subscribes the push channels — the old single store's initBridge, with each +// pull and push handed to the slice it sets, in the same order. +// +// Each subscription below is a route: main sends every push of one turn as one `push:batch` +// message (R3.4c), and the preload hands that message's pushes to these routes in main's order, in +// one task. Each slice's handler runs as it did per message — the later push wins — and React +// commits the whole turn as one frame. +// +// Two places reach across slices, both as they did in the single store: +// - the rehydrate bundle (`relay:status`) sets the relay (dcs), the link (gateway) and the +// calibration (hid/calibration) — three updates, one after another in the same callback, where +// the single store made one; +// - `log:batch` (dcs) drops a batch while the Log tab is paused, reading the pause from the events +// slice at the moment the batch lands (dcs/store.ts, dcs → events). +import { bindConfig, configFromMain } from '../config' +import { bindDcs, dcsFromMain } from '../dcs' +import { bindEvents, eventsFromMain } from '../events' +import { bindGateway, gatewayFromMain } from '../gateway' +import { hidFromMain } from '../hid' +import { bindCalibration, calibrationFromMain } from '../hid/calibration' +import { bindNodes, nodesFromMain } from '../nodes' + +/** Main, read at each call: outside Electron there is none, and every intent is a no-op. */ +const skyhawk = () => window.skyhawk + +bindGateway(skyhawk) +bindDcs(skyhawk) +bindNodes(skyhawk) +bindCalibration(skyhawk) +bindEvents(skyhawk) +bindConfig(skyhawk) + +// Guards against duplicate IPC subscriptions (React StrictMode double-mounts the +// init effect in dev; without this every push — and every log row — lands twice). +let bridgeSubscribed = false + +export function initBridge(): (() => void) | void { + const api = window.skyhawk + if (!api || bridgeSubscribed) return // outside Electron, or already subscribed + bridgeSubscribed = true + + void api.getHidAvailability().then((a) => hidFromMain.availability(a)) + + // Node pushes only fire on change, so a reloaded renderer asks for the current picture. + void api.getNodes().then(({ model }) => nodesFromMain.nodes(model)) + + void api.getConfig().then((v) => configFromMain.config(v)) + + // Events live in main for the whole app run; a reloaded renderer picks the list back up. + void api.getEvents().then((rows) => eventsFromMain.restore(rows)) + + // Rehydrate from the live session: a dev reload resets the renderer but the + // main-process session keeps running, so the UI must not assume "stopped". + void api.getStatus().then((st) => { + dcsFromMain.status(st) + gatewayFromMain.status(st.device) + calibrationFromMain.status(st.cal) + }) + + const unsubs = [ + api.on('hid:report', (h) => hidFromMain.report(h)), + api.on('device:status', (d) => gatewayFromMain.status(d)), + api.on('relay:state', (r) => dcsFromMain.relayState(r)), + api.on('relay:facts', (f) => dcsFromMain.relayFacts(f)), + api.on('cal:workflow', (c) => calibrationFromMain.workflow(c)), + api.on('aircraft:changed', (a) => dcsFromMain.aircraft(a)), + api.on('nodes:model', (m) => nodesFromMain.model(m)), + api.on('cockpit:snapshot', (c) => nodesFromMain.cockpit(c)), + api.on('config:changed', (c) => configFromMain.changed(c)), + api.on('serial:traffic', (frames) => gatewayFromMain.serialTraffic(frames)), + api.on('telemetry:tick', (t) => dcsFromMain.telemetry(t)), + api.on('stats:tick', (st) => dcsFromMain.stats(st)), + api.on('events:batch', (rows) => eventsFromMain.batch(rows)), + api.on('log:batch', (rows) => dcsFromMain.logBatch(rows)) + ] + + return () => { + for (const off of unsubs) off() + bridgeSubscribed = false + } +} diff --git a/src/renderer/src/tabs/settings/Dialog.tsx b/src/renderer/src/app/kit/Dialog.tsx similarity index 96% rename from src/renderer/src/tabs/settings/Dialog.tsx rename to src/renderer/src/app/kit/Dialog.tsx index dcf4fb7..25e467b 100644 --- a/src/renderer/src/tabs/settings/Dialog.tsx +++ b/src/renderer/src/app/kit/Dialog.tsx @@ -1,5 +1,5 @@ import { useRef, type ReactNode } from 'react' -import { useModalFocus } from '../../useModalFocus' +import { useModalFocus } from './use-modal-focus' /** * Modal review dialog (Settings › reset / registry). Esc and the scrim close it; Tab stays inside; diff --git a/src/renderer/src/tabs/settings/Row.tsx b/src/renderer/src/app/kit/Row.tsx similarity index 100% rename from src/renderer/src/tabs/settings/Row.tsx rename to src/renderer/src/app/kit/Row.tsx diff --git a/src/renderer/src/app/kit/Sheet.tsx b/src/renderer/src/app/kit/Sheet.tsx new file mode 100644 index 0000000..b04630f --- /dev/null +++ b/src/renderer/src/app/kit/Sheet.tsx @@ -0,0 +1,42 @@ +import { useRef, type ReactNode } from 'react' +import { useModalFocus } from './use-modal-focus' + +/** + * Side panel under the title bar: Esc, the scrim and Close shut it; focus returns to its opener. + * A column (#93): the header, the board line and the tabs stay put on every tab; only the body + * under them scrolls — or, with `fill`, a list inside it (the PDU's Feeds) takes what is left. + */ +export function Sheet(props: { + label: string + heading: string + onClose: () => void + /** Board head, sub-line and tabs: fixed above the body. */ + top: ReactNode + fill?: boolean + children: ReactNode +}) { + const ref = useRef(null) + useModalFocus(ref, props.onClose) + return ( + <> +
+ + + ) +} diff --git a/src/renderer/src/app/kit/icons.tsx b/src/renderer/src/app/kit/icons.tsx new file mode 100644 index 0000000..d0c900a --- /dev/null +++ b/src/renderer/src/app/kit/icons.tsx @@ -0,0 +1,36 @@ +export function Chevron() { + return ( + + ) +} + +export function Cog() { + return ( + + ) +} diff --git a/src/renderer/src/app/kit/index.ts b/src/renderer/src/app/kit/index.ts new file mode 100644 index 0000000..5d4f774 --- /dev/null +++ b/src/renderer/src/app/kit/index.ts @@ -0,0 +1,14 @@ +// app/kit (R3.4b): the UI primitives and the nav store every topic may use. A leaf: it imports no +// renderer topic (L-kit-leaf). +export { useModalFocus } from './use-modal-focus' +export { Dialog } from './Dialog' +export { Sheet } from './Sheet' +export { Row, SectionHead } from './Row' +export { Chevron, Cog } from './icons' +export { + useNav, + type EventSourceFilter, + type LogView, + type NavState, + type TabId +} from './nav-store' diff --git a/src/renderer/src/app/kit/nav-store.ts b/src/renderer/src/app/kit/nav-store.ts new file mode 100644 index 0000000..6f8a28b --- /dev/null +++ b/src/renderer/src/app/kit/nav-store.ts @@ -0,0 +1,45 @@ +// Where the user is (R3.4b): the tab, the Log tab's sub-tab and source filter, and the targets one +// tab hands another (the Cockpit's side panel and Adopt dialog, the title-bar Device popover). View +// state only; nothing here comes from main. A leaf (app/kit), so every topic may read and set it. +import { create } from 'zustand' +import type { EventSource } from '@shared/events' + +export type TabId = 'overview' | 'connection' | 'cockpit' | 'log' | 'serial' | 'hid' | 'settings' + +export type LogView = 'stream' | 'events' +export type EventSourceFilter = 'all' | EventSource + +export interface NavState { + tab: TabId + /** Log tab sub-tab (#64): the DCS-BIOS stream, or the event log. */ + logView: LogView + eventSource: EventSourceFilter + /** NODE_ID whose Cockpit side panel opens when the Cockpit tab next mounts (#66). */ + cockpitOpen?: number + /** The Cockpit's Adopt dialog opens when the Cockpit tab next mounts (#77). */ + cockpitAdopt?: boolean + /** The title-bar Device popover is open — also opened by Overview's Details and Attention's cog (#94). */ + devicePopOpen: boolean + + set: (patch: Partial) => void + /** Log › Events, with a source filter — the Cockpit's "View all in Log →". */ + showEvents: (source: EventSourceFilter) => void + /** The Cockpit tab with this node's side panel open — Connection's "›". */ + openInCockpit: (nodeId: number) => void + /** The Cockpit tab with the Adopt dialog open — the Overview's new-devices notice. */ + openAdopt: () => void +} + +export const useNav = create((set) => ({ + tab: 'overview', + logView: 'stream', + eventSource: 'all', + devicePopOpen: false, + + set: (patch) => set(patch), + + showEvents: (source) => set({ tab: 'log', logView: 'events', eventSource: source }), + + openInCockpit: (nodeId) => set({ tab: 'cockpit', cockpitOpen: nodeId }), + openAdopt: () => set({ tab: 'cockpit', cockpitAdopt: true }) +})) diff --git a/src/renderer/src/useModalFocus.ts b/src/renderer/src/app/kit/use-modal-focus.ts similarity index 100% rename from src/renderer/src/useModalFocus.ts rename to src/renderer/src/app/kit/use-modal-focus.ts diff --git a/src/renderer/src/app/log/Log.tsx b/src/renderer/src/app/log/Log.tsx new file mode 100644 index 0000000..85de097 --- /dev/null +++ b/src/renderer/src/app/log/Log.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from 'react' +import { fileName } from '@shared/format' +import { StreamView, useDcsStore, type DirFilter } from '../../dcs' +import { EventsView, filterEvents, useEventsStore } from '../../events' +import { useNav, type EventSourceFilter, type LogView } from '../kit' + +/** How long the footer says where an export went. */ +const SAVED_NOTE_MS = 2600 + +/** Most rows either view renders; the rest stay in memory and in the export. */ +const MAX_SHOWN = 1000 + +/** + * The Log tab: the DCS-BIOS stream (dcs) and the session's events (events) under one toolbar; the + * sub-tab and the source filter are where-the-user-is state (nav). + */ +export function Log() { + const nav = useNav() + const dcs = useDcsStore() + const ev = useEventsStore() + const toggle = useDcsStore((x) => x.toggle) + const togglePause = useEventsStore((x) => x.togglePause) + const clearLog = useDcsStore((x) => x.clearLog) + const exportLog = useDcsStore((x) => x.exportLog) + const clearEvents = useEventsStore((x) => x.clearEvents) + const exportEvents = useEventsStore((x) => x.exportEvents) + const [saved, setSaved] = useState() + + useEffect(() => { + if (!saved) return + const t = setTimeout(() => setSaved(undefined), SAVED_NOTE_MS) + return () => clearTimeout(t) + }, [saved]) + + const events = nav.logView === 'events' + const q = dcs.search.trim().toLowerCase() + + let rows = dcs.log + if (dcs.dirFilter !== 'all') rows = rows.filter((r) => r.dir === dcs.dirFilter) + if (q) rows = rows.filter((r) => (r.name + ' ' + r.addrHex).toLowerCase().includes(q)) + + const evRows = filterEvents(ev.events, { + source: nav.eventSource, + problemsOnly: ev.problemsOnly, + search: dcs.search, + pauseMark: ev.logPaused ? ev.eventsPauseMark : undefined + }) + + const doExport = async () => { + const count = events ? ev.events.length : dcs.log.length + const path = await (events ? exportEvents() : exportLog()) + if (path) setSaved(`✓ Saved ${fileName(path)} · ${count.toLocaleString('en-US')} rows`) + } + + const viewBtn = (id: LogView, label: string, n: number) => ( + + ) + + const dirBtn = (id: DirFilter, label: string) => ( + + ) + + const srcBtn = (id: EventSourceFilter, label: string) => ( + + ) + + const total = ev.events.length + const foot = saved + ? saved + : events + ? `${evRows.length === total ? total : `${evRows.length} of ${total}`} events this session · in memory, cleared on restart` + : `${rows.length} rows · ${dcs.fps} frames/s` + + return ( +
+
+ {viewBtn('stream', 'Stream', dcs.log.length)} + {viewBtn('events', 'Events', total)} +
+
+
+
+ + + + + dcs.set({ search: e.target.value })} + placeholder={events ? 'Search source or event…' : 'Search name or address…'} + /> +
+ {events ? ( + <> +
+ {srcBtn('all', 'All')} + {srcBtn('node', 'Nodes')} + {srcBtn('client', 'Client')} +
+ + + ) : ( +
+ {dirBtn('all', 'All')} + {dirBtn('in', 'In')} + {dirBtn('out', 'Out')} +
+ )} +
+ + + + + +
+
+ + {events ? ( +
+ Time + + Source + {dcs.rawMode ? 'Message (raw)' : 'Event'} +
+ ) : ( +
+ Time + Dir + {dcs.rawMode ? 'Address' : 'Control'} + Value +
+ )} + +
+ {events ? ( + + ) : ( + + )} +
+ +
+ {foot} + {ev.logPaused ? 'PAUSED' : 'LIVE'} +
+
+
+ ) +} diff --git a/src/renderer/src/tabs/Overview.tsx b/src/renderer/src/app/overview/Overview.tsx similarity index 62% rename from src/renderer/src/tabs/Overview.tsx rename to src/renderer/src/app/overview/Overview.tsx index 03d3bb0..9c160fd 100644 --- a/src/renderer/src/tabs/Overview.tsx +++ b/src/renderer/src/app/overview/Overview.tsx @@ -1,10 +1,12 @@ -import { useMemo } from 'react' -import { useStore, fmtBytes, fmtUptime, type TabId } from '../store' -import { isLinkUp, type DeviceState } from '@shared/ipc' -import { attentionItems, healthBand, nodeViews } from '../cockpit' -import { attentionSummary, cockpitTile, newDevices, newDevicesText } from '../overview' -import { gatewayView } from '../gateway-view' -import { Dot } from './cockpit/Dot' +import { fmtBytes, fmtUptime } from '@shared/format' +import type { DeviceState } from '@shared/gateway' +import type { GaugeTone, OverviewLink } from '@shared/dcs' +import { useConfigStore, viewTransport } from '../../config' +import { endpointText, relaySide, useDcsStore } from '../../dcs' +import { gatewayView, useGatewayStore } from '../../gateway' +import { Dot, useNodesStore } from '../../nodes' +import { useNav, type TabId } from '../kit' +import { newDevicesText } from './overview-view' import logo from '../assets/logo.png' const C = 289.03 // 2·π·46 @@ -24,6 +26,20 @@ const LINK_COLOR: Partial> = { error: 'var(--red)' } const TILE_TONE = { ok: 'var(--green)', issue: 'var(--gold)', none: 'var(--muted)' } as const +/** The Link tile's sub-line: the relay under the device link (main's call, R3.3). */ +const LINK_SUB: Record = { + 'gateway-relaying': 'Gateway · relaying', + 'gateway-stopped': 'Gateway · stream stopped', + waiting: 'relay waiting — resumes when it connects', + stopped: 'stream stopped' +} +/** A gauge ring's colour, for its level as main judges it (R3.3). */ +const GAUGE_COLOR: Record = { + 'fuel-low': 'var(--red)', + fuel: 'var(--gold)', + secondary: 'var(--blue-2)', + primary: 'var(--blue)' +} function Gauge({ label, @@ -74,35 +90,32 @@ function Gauge({ ) } +/** The Overview tab: a summary of every topic — the link, the relay and stream, the cockpit. */ export function Overview() { - const s = useStore() - const link = s.transport === 'tcp' ? 'TCP' : s.transport === 'loop' ? 'Multicast' : 'Unicast' - const linkSub = - s.transport === 'tcp' ? `${s.host}:${s.port} · remote` : '239.255.50.10:5010 · local' + const cfg = useConfigStore() + const dcs = useDcsStore() + const gw = useGatewayStore() + const nodes = useNodesStore() + const nav = useNav() + // The transport the relay's facts were judged against (R3.4c), as its endpoint below is. + const transport = viewTransport(dcs.relayFacts.transport) + const link = transport === 'tcp' ? 'TCP' : transport === 'loop' ? 'Multicast' : 'Unicast' + // The DCS Link tile's endpoint and the Link tile's sub-line are main's (relay:facts, R3.3). + const linkSub = endpointText(dcs.relayFacts.endpoint.overview, cfg.host, cfg.port) + const relaySub = LINK_SUB[relaySide(dcs.relayFacts, dcs.relaying).overviewLink] - const connected = isLinkUp(s.deviceState) - const acft = s.aircraft.name && s.aircraft.name !== 'NONE' ? s.aircraft.name : 'NONE' - const acftSub = s.aircraft.inferred ? 'inferred · address range' : '_ACFT_NAME · metadata' + const acft = dcs.aircraft.name && dcs.aircraft.name !== 'NONE' ? dcs.aircraft.name : 'NONE' + const acftSub = dcs.aircraft.inferred ? 'inferred · address range' : '_ACFT_NAME · metadata' - // The Cockpit's own picture (#63), summed up for the tile and the banners (#65). - const views = useMemo( - () => (s.nodeModel ? nodeViews(s.nodeModel, s.nodeTelemetry) : []), - [s.nodeModel, s.nodeTelemetry] - ) - const band = useMemo(() => healthBand(views), [views]) - const cockpit = cockpitTile(band, connected) - const attention = useMemo( - () => - connected && s.attentionBanner - ? attentionSummary(band, attentionItems(views, s.nodeModel?.outages ?? [])) - : null, - [connected, s.attentionBanner, band, views, s.nodeModel] - ) + // The Cockpit's own picture (#63), summed up for the tile and the banners (#65): main's snapshot + // (cockpit:snapshot, R3.2b). The banner also follows Settings › Cockpit's toggle. + const summary = nodes.cockpit.overview + const cockpit = summary.tile + const attention = cfg.attentionBanner ? summary.banner : null // Serious Gateway faults only (#94): panel input can't reach DCS. Its own link, so not behind the // board-problems setting. - const gatewayAlert = gatewayView(s.deviceGateway, connected).alert - const waiting = useMemo(() => newDevices(views, connected), [views, connected]) - const waitingText = waiting && newDevicesText(waiting) + const gatewayAlert = gatewayView(gw.deviceGateway).alert + const waitingText = summary.newDevices && newDevicesText(summary.newDevices) const tiles: { label: string @@ -115,22 +128,16 @@ export function Overview() { { // The device link, not the relay (#69); the stream state rides underneath. label: 'Link', - value: LINK_VALUE[s.deviceState], - sub: connected - ? s.relaying - ? 'Gateway · relaying' - : 'Gateway · stream stopped' - : s.relaying - ? 'relay waiting — resumes when it connects' - : 'stream stopped', - color: LINK_COLOR[s.deviceState] ?? 'var(--muted)', + value: LINK_VALUE[gw.deviceState], + sub: relaySub, + color: LINK_COLOR[gw.deviceState] ?? 'var(--muted)', open: { tab: 'connection', title: 'Open the Connection tab' } }, { label: 'Aircraft', value: acft, sub: acftSub, - color: s.aircraft.supported ? 'var(--text)' : 'var(--gold)' + color: dcs.aircraft.supported ? 'var(--text)' : 'var(--gold)' }, { label: 'DCS Link', value: link, sub: linkSub, color: 'var(--blue)' }, { @@ -143,47 +150,48 @@ export function Overview() { } ] - const gaugeColor = (id: string, pct: number): string => { - if (id === 'D_FUEL') return pct < 0.25 ? 'var(--red)' : 'var(--gold)' - return id === 'D_FLAPS_IND' || id === 'ALT_FT' ? 'var(--blue-2)' : 'var(--blue)' - } - const gauges = s.telemetry.map((r) => ({ + const gauges = dcs.telemetry.map((r) => ({ label: r.label, value: Number.isNaN(r.value) ? '—' : Math.round(r.value).toLocaleString(), unit: r.unit, pct: r.pct, - color: gaugeColor(r.id, r.pct) + color: GAUGE_COLOR[r.tone] })) const health = [ { label: 'Bytes In', - value: fmtBytes(s.bytesIn), + value: fmtBytes(dcs.bytesIn), color: 'var(--green)', accent: 'rgba(110,231,160,0.5)' }, { label: 'Bytes Out', - value: fmtBytes(s.bytesOut), + value: fmtBytes(dcs.bytesOut), color: 'var(--blue)', accent: 'rgba(30,143,255,0.5)' }, - { label: 'Frames', value: s.fps + '/s', color: 'var(--text)', accent: 'rgba(255,255,255,0.2)' }, + { + label: 'Frames', + value: dcs.fps + '/s', + color: 'var(--text)', + accent: 'rgba(255,255,255,0.2)' + }, { label: 'Errors', - value: String(s.errors), - color: s.errors > 0 ? 'var(--red)' : 'var(--text)', + value: String(dcs.errors), + color: dcs.errors > 0 ? 'var(--red)' : 'var(--text)', accent: 'rgba(255,255,255,0.2)' }, { label: 'Uptime', - value: fmtUptime(s.uptime), + value: fmtUptime(dcs.uptime), color: 'var(--text)', accent: 'rgba(255,255,255,0.2)' }, { label: 'Reconnects', - value: String(s.reconnects), + value: String(dcs.reconnects), color: 'var(--muted)', accent: 'rgba(255,255,255,0.2)' } @@ -206,9 +214,9 @@ export function Overview() { role: 'button', tabIndex: 0, title: t.open.title, - onClick: () => s.set({ tab: t.open!.tab }), + onClick: () => nav.set({ tab: t.open!.tab }), onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') s.set({ tab: t.open!.tab }) + if (e.key === 'Enter' || e.key === ' ') nav.set({ tab: t.open!.tab }) } })} > @@ -231,7 +239,7 @@ export function Overview() { {gatewayAlert.head} — {gatewayAlert.text} -
@@ -239,7 +247,7 @@ export function Overview() { {attention && (
⚠ {attention.head} — {attention.detail}.{' '} -
@@ -251,15 +259,15 @@ export function Overview() { {waitingText.head} — {waitingText.detail} {/* Opens the Cockpit with its Adopt dialog showing (#77). */} - )} - {!s.aircraft.supported && ( + {!dcs.aircraft.supported && (
- ⚠ {s.aircraft.name} loaded — named decode is A-4E-C only. Relaying & stats + ⚠ {dcs.aircraft.name} loaded — named decode is A-4E-C only. Relaying & stats continue; the log shows raw addresses.
)} @@ -267,8 +275,8 @@ export function Overview() {
Sim Telemetry
- CMD {s.cmdsPerSec}/s · {s.cmdsTotal.toLocaleString()} total · last{' '} - {s.lastCmd} + CMD {dcs.cmdsPerSec}/s · {dcs.cmdsTotal.toLocaleString()} total · last{' '} + {dcs.lastCmd}
diff --git a/src/renderer/src/app/overview/overview-view.test.ts b/src/renderer/src/app/overview/overview-view.test.ts new file mode 100644 index 0000000..30591e8 --- /dev/null +++ b/src/renderer/src/app/overview/overview-view.test.ts @@ -0,0 +1,26 @@ +// The new-devices notice's wording (overview-view.ts). The tile, the banner and the counts are +// main's since R3.2b (main/nodes/cockpit-snapshot/overview-summary.test.ts). +import { describe, expect, it } from 'vitest' +import { newDevicesText } from './overview-view' + +describe('newDevicesText', () => { + it('lists the reported kinds', () => { + expect(newDevicesText({ count: 1, panels: 0, pdus: 1, registry: 0 })).toEqual({ + head: '1 new device on the bus', + detail: '1 PDU, not in NODE_IDS.md.' + }) + expect(newDevicesText({ count: 3, panels: 1, pdus: 2, registry: 0 })).toEqual({ + head: '3 new devices on the bus', + detail: '1 panel group, 2 PDUs, not in NODE_IDS.md.' + }) + }) + + it('a registry board waiting to be added is from NODE_IDS.md, not "not in" it (#93)', () => { + expect(newDevicesText({ count: 1, panels: 1, pdus: 0, registry: 1 }).detail).toBe( + '1 panel group from NODE_IDS.md, not added to your cockpit yet.' + ) + expect(newDevicesText({ count: 3, panels: 2, pdus: 1, registry: 2 }).detail).toBe( + '2 panel groups, 1 PDU, not added to your cockpit yet — 2 from NODE_IDS.md.' + ) + }) +}) diff --git a/src/renderer/src/app/overview/overview-view.ts b/src/renderer/src/app/overview/overview-view.ts new file mode 100644 index 0000000..d21dd07 --- /dev/null +++ b/src/renderer/src/app/overview/overview-view.ts @@ -0,0 +1,23 @@ +// Overview (#65): the wording of the new-devices notice. What the tile, the Attention banner and the +// notice say about your cockpit is main's (cockpit:snapshot overview, R3.2b). +import { plural } from '@shared/format' +import type { NewDevices } from '@shared/nodes' + +/** + * `2 new devices on the bus` + what they are. Since #93 a registry board is new too until you add + * it: `1 panel group from NODE_IDS.md, not added to your cockpit yet.`; unregistered ones are + * `… not in NODE_IDS.md.`; a mix says how many are from NODE_IDS.md. + */ +export function newDevicesText(d: NewDevices): { head: string; detail: string } { + const kinds = [ + ...(d.panels ? [plural(d.panels, 'panel group')] : []), + ...(d.pdus ? [plural(d.pdus, 'PDU')] : []) + ].join(', ') + const detail = + d.registry === 0 + ? `${kinds}, not in NODE_IDS.md.` + : d.registry === d.count + ? `${kinds} from NODE_IDS.md, not added to your cockpit yet.` + : `${kinds}, not added to your cockpit yet — ${d.registry} from NODE_IDS.md.` + return { head: `${plural(d.count, 'new device')} on the bus`, detail } +} diff --git a/src/renderer/src/tabs/settings/AboutSection.tsx b/src/renderer/src/app/settings/AboutSection.tsx similarity index 76% rename from src/renderer/src/tabs/settings/AboutSection.tsx rename to src/renderer/src/app/settings/AboutSection.tsx index f86efb2..1ad04d8 100644 --- a/src/renderer/src/tabs/settings/AboutSection.tsx +++ b/src/renderer/src/app/settings/AboutSection.tsx @@ -1,14 +1,13 @@ import { useEffect, useState } from 'react' -import { isLinkUp, type NodeRegistryInfo } from '@shared/ipc' -import { useStore } from '../../store' -import { Dialog } from './Dialog' -import { Row, SectionHead } from './Row' -import { registryName, registryOnBus, registrySummary } from './settings-view' -import { useNodeModel } from './useNodeModel' +import type { NodeRegistryInfo } from '@shared/nodes' +import { useNodesStore } from '../../nodes' +import { Dialog, Row, SectionHead } from '../kit' +import { useNodesPulled } from './useNodesPulled' function RegistryDialog({ reg, onClose }: { reg: NodeRegistryInfo; onClose: () => void }) { - const model = useNodeModel() - const linkUp = useStore((s) => isLinkUp(s.deviceState)) + const pulled = useNodesPulled() + // Each row as main reads it (cockpit:snapshot settings, R3.2b). + const rows = useNodesStore((s) => s.cockpit.settings.registry.rows) const file = reg.file.split('/').pop() return ( Console On the bus
- {reg.nodes.map((n) => { - const bus = registryOnBus(n.nodeId, model, linkUp) + {rows.map((n) => { + const bus = pulled ? n.onBus : n.unloaded return (
#{n.nodeId} - {registryName(n)} + {n.name} {n.console} {bus.text !== '—' && ( @@ -53,9 +52,10 @@ function RegistryDialog({ reg, onClose }: { reg: NodeRegistryInfo; onClose: () = export function AboutSection() { const [reg, setReg] = useState() const [open, setOpen] = useState(false) + const summary = useNodesStore((s) => s.cockpit.settings.registry.summary) useEffect(() => { - void window.skyhawk?.getNodeRegistry().then(setReg) + void useNodesStore.getState().getNodeRegistry()?.then(setReg) }, []) return ( @@ -71,7 +71,7 @@ export function AboutSection() { GPL-2.0-only - +
{reg && {reg.commit.slice(0, 7)}}
- {LIMITS.map(({ k, label, unit, custom }) => { + {PSU_LIMITS.map(({ key: k, label, unit, hint: custom }) => { const bad = !isPreset && parsePsuLimit(typed[k]) === null return ( @@ -110,16 +95,16 @@ function PowerSupply() { * on its own when it reports. */ function HiddenNodes() { - const model = useNodeModel() + const pulled = useNodesPulled() + const nodes = useNodesStore((s) => s.cockpit.settings) const [error, setError] = useState() - const hidden = model?.hiddenNodes ?? [] + const hidden = pulled ? nodes.hidden : [] + // Main un-hides them one at a time and stops at the first it can't (R3.2b). const unhide = async (ids: number[]) => { setError(undefined) - for (const id of ids) { - const r = await window.skyhawk?.hideNode(id, false) - if (r && !r.ok) return setError(r.error) - } + const r = await useNodesStore.getState().unhideNodes(ids) + if (r && !r.ok) setError(r.error) } return ( @@ -171,12 +156,14 @@ function HiddenNodes() { } export function CockpitSection() { - const model = useNodeModel() - const attentionBanner = useStore((s) => s.attentionBanner) - const setConfigField = useStore((s) => s.setConfigField) + const pulled = useNodesPulled() + const nodes = useNodesStore((s) => s.cockpit.settings) + // The review asks main for a fresh preview each time the model is pushed. + const model = useNodesStore((s) => s.nodeModel) + const attentionBanner = useConfigStore((s) => s.attentionBanner) + const setConfigField = useConfigStore((s) => s.setConfigField) const [resetOpen, setResetOpen] = useState(false) - const differing = model?.differingCount ?? 0 - const anyAdopted = model?.nodes.some((n) => n.source === 'adopted') ?? false + const differing = pulled ? nodes.differing : 0 return ( <> @@ -188,7 +175,7 @@ export function CockpitSection() { - - - + + + )}
) diff --git a/src/renderer/src/tabs/settings/DiagnosticsSection.tsx b/src/renderer/src/app/settings/DiagnosticsSection.tsx similarity index 79% rename from src/renderer/src/tabs/settings/DiagnosticsSection.tsx rename to src/renderer/src/app/settings/DiagnosticsSection.tsx index 73cdfb2..4674fb2 100644 --- a/src/renderer/src/tabs/settings/DiagnosticsSection.tsx +++ b/src/renderer/src/app/settings/DiagnosticsSection.tsx @@ -1,11 +1,11 @@ -import { useStore } from '../../store' -import { Row, SectionHead } from './Row' +import { useConfigStore } from '../../config' +import { Row, SectionHead } from '../kit' export function DiagnosticsSection() { - const s = useStore() - const setConfigField = useStore((x) => x.setConfigField) - const dumpSerialPorts = useStore((x) => x.dumpSerialPorts) - const revealDebugLog = useStore((x) => x.revealDebugLog) + const s = useConfigStore() + const setConfigField = useConfigStore((x) => x.setConfigField) + const dumpSerialPorts = useConfigStore((x) => x.dumpSerialPorts) + const revealDebugLog = useConfigStore((x) => x.revealDebugLog) return ( <> diff --git a/src/renderer/src/tabs/settings/ResetDialog.tsx b/src/renderer/src/app/settings/ResetDialog.tsx similarity index 93% rename from src/renderer/src/tabs/settings/ResetDialog.tsx rename to src/renderer/src/app/settings/ResetDialog.tsx index 014045a..299ce42 100644 --- a/src/renderer/src/tabs/settings/ResetDialog.tsx +++ b/src/renderer/src/app/settings/ResetDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' -import type { ResetPreview } from '@shared/node-mapping' -import type { NodeModel } from '@shared/node-rules' -import { Dialog } from './Dialog' +import type { NodeModel, ResetPreview } from '@shared/nodes' +import { useNodesStore } from '../../nodes' +import { Dialog } from '../kit' import { resetButtonLabel } from './settings-view' /** @@ -16,7 +16,7 @@ export function ResetDialog({ model, onClose }: { model?: NodeModel; onClose: () const [error, setError] = useState() useEffect(() => { - void window.skyhawk?.previewNodeReset().then(setPreview) + void useNodesStore.getState().previewNodeReset()?.then(setPreview) }, [model]) const nodes = preview?.nodeCount ?? 0 @@ -27,7 +27,7 @@ export function ResetDialog({ model, onClose }: { model?: NodeModel; onClose: () const apply = async () => { setBusy(true) setError(undefined) - const r = await window.skyhawk?.resetNodeMapping({ forgetAdopted: forget }) + const r = await useNodesStore.getState().resetNodeMapping({ forgetAdopted: forget }) setBusy(false) if (r?.ok) onClose() else setError(r?.error ?? 'Reset failed') diff --git a/src/renderer/src/tabs/Settings.tsx b/src/renderer/src/app/settings/Settings.tsx similarity index 67% rename from src/renderer/src/tabs/Settings.tsx rename to src/renderer/src/app/settings/Settings.tsx index dd25c65..42a30c8 100644 --- a/src/renderer/src/tabs/Settings.tsx +++ b/src/renderer/src/app/settings/Settings.tsx @@ -1,13 +1,14 @@ import { useState } from 'react' -import { AboutSection } from './settings/AboutSection' -import { CockpitSection } from './settings/CockpitSection' -import { ConfigurationSection } from './settings/ConfigurationSection' -import { ConnectionSection } from './settings/ConnectionSection' -import { DiagnosticsSection } from './settings/DiagnosticsSection' +import { ConfigurationSection } from '../../config' +import { AXIS_LABELS } from '../../hid' +import { AboutSection } from './AboutSection' +import { CockpitSection } from './CockpitSection' +import { ConnectionSection } from './ConnectionSection' +import { DiagnosticsSection } from './DiagnosticsSection' -type SectionId = 'connection' | 'cockpit' | 'config' | 'diag' | 'about' +type SettingsSectionId = 'connection' | 'cockpit' | 'config' | 'diag' | 'about' -const SECTIONS: { id: SectionId; label: string; sub: string }[] = [ +const SECTIONS: { id: SettingsSectionId; label: string; sub: string }[] = [ { id: 'connection', label: 'Connection', sub: 'DCS link, ports' }, { id: 'cockpit', label: 'Cockpit', sub: 'PSU, mapping, alerts' }, { id: 'config', label: 'Configuration', sub: 'export / import' }, @@ -17,7 +18,7 @@ const SECTIONS: { id: SectionId; label: string; sub: string }[] = [ /** Settings: sections on the left (a row below 820 px), the picked one on the right. */ export function Settings() { - const [section, setSection] = useState('connection') + const [section, setSection] = useState('connection') return (
@@ -37,7 +38,7 @@ export function Settings() {
{section === 'connection' && } {section === 'cockpit' && } - {section === 'config' && } + {section === 'config' && } {section === 'diag' && } {section === 'about' && }
diff --git a/src/renderer/src/tabs/settings/settings-view.test.ts b/src/renderer/src/app/settings/settings-view.test.ts similarity index 51% rename from src/renderer/src/tabs/settings/settings-view.test.ts rename to src/renderer/src/app/settings/settings-view.test.ts index a7cce18..b5a3ef9 100644 --- a/src/renderer/src/tabs/settings/settings-view.test.ts +++ b/src/renderer/src/app/settings/settings-view.test.ts @@ -1,17 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { NodeRegistryInfo } from '@shared/ipc' -import type { NodeModel, NodeModelEntry } from '@shared/node-rules' -import { - hiddenRow, - importDoneText, - importRowKey, - importSubtitle, - registryName, - registryOnBus, - registrySummary, - resetButtonLabel, - resetRowDesc -} from './settings-view' +import { hiddenRow, resetButtonLabel, resetRowDesc } from './settings-view' +import { importDoneText, importRowKey, importSubtitle } from '../../config/import-view' describe('reset texts', () => { it('describes the row and labels the confirm button', () => { @@ -25,51 +14,6 @@ describe('reset texts', () => { }) }) -const REG: NodeRegistryInfo = { - file: 'Firmware/NODE_IDS.md', - commit: '22b6a87e73068ca2bfd964d067af1247771d5854', - date: '2026-10-15', - nodes: [ - { nodeId: 0, name: 'PanelBridge', console: '—', kind: 'bridge' }, - { nodeId: 1, name: 'Center_Armament', console: 'Center', kind: 'panel' }, - { nodeId: 2, name: 'Right_Navigation', console: 'Right', kind: 'panel' }, - { nodeId: 3, name: 'PDU_Left', console: 'Left', kind: 'pdu' }, - { nodeId: 4, name: 'PDU_Right', console: 'Right', kind: 'pdu' }, - { nodeId: 5, name: 'PDU_Center', console: 'Center', kind: 'pdu' } - ] -} - -describe('registry', () => { - it('summarises the bundled registry', () => { - expect(registrySummary(REG)).toBe('NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway') - }) - - it('names the bridge row Gateway, whatever NODE_IDS.md calls it (#94)', () => { - expect(REG.nodes.map(registryName)[0]).toBe('Gateway') - expect(REG.nodes.slice(1).map(registryName)).toEqual(REG.nodes.slice(1).map((n) => n.name)) - }) - - it('"On the bus" comes from the link and the roster, and says when there is no data', () => { - const e = (nodeId: number, availability: NodeModelEntry['availability']) => - ({ nodeId, availability }) as NodeModelEntry - const model = { - nodes: [e(1, 'online'), e(2, 'offline'), e(3, 'unknown')], - hidden: [5] - } as unknown as NodeModel - const col = (id: number, m?: NodeModel, up = true) => registryOnBus(id, m, up) - expect(col(0)).toEqual({ text: 'serial link', dot: 'on' }) - expect(col(0, model, false)).toEqual({ text: 'no device', dot: '' }) - expect([1, 2, 3, 4, 5].map((id) => col(id, model).text)).toEqual([ - 'yes', - 'offline', - '—', - '—', // never reported: not in your cockpit (#93) - 'hidden' - ]) - expect(col(1, undefined).text).toBe('—') - }) -}) - describe('import texts', () => { it('subtitle, row keys and the done line', () => { const at = new Date(2026, 9, 12, 21, 2).toISOString() diff --git a/src/renderer/src/app/settings/settings-view.ts b/src/renderer/src/app/settings/settings-view.ts new file mode 100644 index 0000000..80efc63 --- /dev/null +++ b/src/renderer/src/app/settings/settings-view.ts @@ -0,0 +1,40 @@ +// Settings wording (#67), pure so it is unit-tested: what each Cockpit row says. The registry rows +// and Reset…'s gate are main's (cockpit:snapshot settings, R3.2b). The Import rows' wording is +// config/import-view.ts. +import { plural } from '@shared/format' +import type { HiddenNode } from '@shared/nodes' +import { consoleLabel } from '@shared/nodes' + +/** "Reset node mapping" row description. */ +export function resetRowDesc(differing: number): string { + return differing === 0 + ? 'Every node matches NODE_IDS.md' + : `${plural(differing, 'node')} ${differing === 1 ? 'differs' : 'differ'} from NODE_IDS.md — return them to their registry name, console and PDU` +} + +/** + * The confirm button: "Reset 3 nodes", "Reset 3 nodes + forget 1", "Forget 1 adopted node", or a + * plain "Reset" (shown disabled) when there is nothing to do. + */ +export function resetButtonLabel(nodes: number, forget: number): string { + if (nodes === 0) return forget > 0 ? `Forget ${plural(forget, 'adopted node')}` : 'Reset' + const reset = `Reset ${plural(nodes, 'node')}` + return forget > 0 ? `${reset} + forget ${forget}` : reset +} + +export interface ListRow { + nodeId: number + label: string + desc: string +} + +/** One Hidden boards row (#76): the name and console the Cockpit showed for it. */ +export function hiddenRow(n: HiddenNode): ListRow { + return { + nodeId: n.nodeId, + label: n.name, + desc: n.console + ? `${consoleLabel(n.console)} · returns when it reports again` + : 'returns when it reports again' + } +} diff --git a/src/renderer/src/app/settings/useNodesPulled.ts b/src/renderer/src/app/settings/useNodesPulled.ts new file mode 100644 index 0000000..2e1dbc2 --- /dev/null +++ b/src/renderer/src/app/settings/useNodesPulled.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { useNodesStore } from '../../nodes' + +/** + * Whether a Settings section has its node data yet. What it shows comes from main's snapshot + * (cockpit:snapshot settings, R3.2b); this keeps the moment it first shows it. Each section pulled + * the node data when it mounted (`nodes:get`) and showed nothing until the reply — or a newer + * `nodes:model` push — landed: the same pull, the same wait, the same frame without data. + */ +export function useNodesPulled(): boolean { + const pushed = useNodesStore((s) => s.nodeModel) + const [atMount] = useState(pushed) + const [pulled, setPulled] = useState(false) + useEffect(() => { + const pull = useNodesStore.getState().pullNodes() + if (!pull) return + let live = true + void pull.then(() => { + if (live) setPulled(true) + }) + return () => { + live = false + } + }, []) + return pulled || pushed !== atMount +} diff --git a/src/renderer/src/app/titlebar/DevicePopover.tsx b/src/renderer/src/app/titlebar/DevicePopover.tsx new file mode 100644 index 0000000..1cb0bc7 --- /dev/null +++ b/src/renderer/src/app/titlebar/DevicePopover.tsx @@ -0,0 +1,8 @@ +import { useConfigStore } from '../../config' +import { DevicePopover as LinkPopover } from '../../gateway' + +/** The title-bar Device popover (gateway), with Settings' auto-connect it words a state by. */ +export function DevicePopover() { + const autoConnect = useConfigStore((s) => s.autoConnect) + return +} diff --git a/src/renderer/src/app/titlebar/RelayPill.tsx b/src/renderer/src/app/titlebar/RelayPill.tsx new file mode 100644 index 0000000..27728e8 --- /dev/null +++ b/src/renderer/src/app/titlebar/RelayPill.tsx @@ -0,0 +1,10 @@ +import { useConfigStore } from '../../config' +import { RelayPill as Pill } from '../../dcs' + +/** The title-bar Relay pill (dcs), with the settings its tooltip names: auto-connect, host, port. */ +export function RelayPill() { + const autoConnect = useConfigStore((s) => s.autoConnect) + const host = useConfigStore((s) => s.host) + const port = useConfigStore((s) => s.port) + return +} diff --git a/src/renderer/src/cockpit-adopt.ts b/src/renderer/src/cockpit-adopt.ts deleted file mode 100644 index 17418cd..0000000 --- a/src/renderer/src/cockpit-adopt.ts +++ /dev/null @@ -1,283 +0,0 @@ -// 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 { 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' -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 - * node is listed — still identifying, or a kind that can't be adopted — so you can see what each - * one is; only a PDU or a panel group can be adopted (adoptability()). - */ -export function adoptQueue(views: NodeView[]): NodeView[] { - const at = (v: NodeView) => v.entry.firstSeenAt ?? Number.POSITIVE_INFINITY - return views - .filter((v) => v.entry.source === 'new') - .sort((a, b) => at(a) - at(b) || a.entry.nodeId - b.entry.nodeId) -} - -/** - * The queue while the dialog is open (#93 re-review): what is waiting now, plus every row it has - * already shown that is gone from the model — kept, offline, so power-cycling a board to identify - * it (a registry board not added leaves the model when it drops) doesn't close the dialog or lose - * its form. A row leaves only once added (`done`) or when the dialog closes. - */ -export function keptQueue( - live: NodeView[], - shown: ReadonlyMap, - done: ReadonlySet -): NodeView[] { - const ids = new Set(live.map((v) => v.entry.nodeId)) - const gone = [...shown.values()] - .filter((v) => !ids.has(v.entry.nodeId) && !done.has(v.entry.nodeId)) - .map((v): NodeView => ({ ...v, entry: { ...v.entry, availability: 'offline' } })) - return adoptQueue([...live.filter((v) => !done.has(v.entry.nodeId)), ...gone]) -} - -/** - * The Adopt form's Powered by list (#93): the console's PDUs in your cockpit; the chosen one if it is - * in your cockpit on another console, marked "(other console)"; and — only when the chosen PDU is - * not in your cockpit at all — that PDU as `pending`, which the form marks "(not added)". - */ -export function adoptPduChoices( - views: NodeView[], - console: string, - pduId: number | null | '', - e: NodeModelEntry -): { zones: { value: number; label: string }[]; pending?: string } { - const chosen = typeof pduId === 'number' ? pduId : null - const zones = powerZoneOptions(views, console, chosen) - if (chosen === null || zones.some((z) => z.value === chosen)) return { zones } - const waiting = views.find((v) => v.entry.nodeId === chosen) - const name = - waiting?.entry.name ?? - ((e.registry?.pduId === chosen && e.registry.pduName) || `PDU #${chosen}`) - 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 - badge: 'PDU' | 'PANEL GROUP' | 'IDENTIFYING' | 'BRIDGE' | 'UNKNOWN TYPE' - /** Why it can't be adopted (yet), shown in place of the form. */ - note?: string -} - -/** - * Whether a new node can be adopted, by what it has identified as (entry.identified, the shared - * resolver). A whitelist: a PDU or a panel group can; still identifying, a bridge, or a kind this - * client doesn't know can't (it shows in Attention while it's on the bus, #93). - */ -export function adoptability(e: NodeModelEntry): Adoptability { - switch (e.identified) { - case 'pdu': - return { kind: 'pdu', badge: 'PDU' } - case 'panel': - return { kind: 'panel', badge: 'PANEL GROUP' } - case 'bridge': - return { - badge: 'BRIDGE', - note: '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.' - } - case 'other': - return { - badge: 'UNKNOWN TYPE', - note: `It reports a board type this client doesn’t know (${e.status?.nodeKind ?? '?'}), 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.` - } - default: - return { - badge: 'IDENTIFYING', - note: 'Identifying… waiting for the board to report its type. If it doesn’t within a few seconds, it shows in Attention as an issue.' - } - } -} - -export interface Evidence { - /** What it identified as, and from which signal; or that it hasn't yet. */ - detected: string - reporting: string - registry: string -} - -/** "Detected as": the kind and the signal it came from. */ -function detectedAs(e: NodeModelEntry): string { - const said = reportedIdentity(e.status?.nodeKind) - // Not reported (yet): only PDU pages tell — never the NODE_ID or its NODE_IDS.md entry (#93). - const how = said ? 'reported by the board' : 'PDU telemetry seen' - switch (e.identified) { - case 'pdu': - return `PDU (${how})` - case 'panel': - return 'panel group (reported by the board)' - case 'bridge': - return 'Gateway (reported by the board)' - case 'other': - return `unknown board type ${e.status?.nodeKind ?? ''} (reported by the board)`.trim() - default: - return 'not known yet' - } -} - -const rail = (label: string, p?: { vAvgMv: number; iAvgMa: number }) => - p - ? `${label} ${(p.vAvgMv / 1000).toFixed(2)} V · ${(displayCurrentMa(p.iAvgMa) / 1000).toFixed(2)} A` - : '' - -/** What the dialog can say about a new NODE_ID: only what it has reported. */ -export function adoptEvidence(v: NodeView): Evidence { - const e = v.entry - const st = e.status - const detected = detectedAs(e) - const registry = e.registry - ? `NODE_IDS.md: ${e.registry.name}, ${consoleLabel(e.registry.console)}` - : 'not in NODE_IDS.md' - if (e.availability !== 'online' || !st) { - return { detected, reporting: 'not reporting right now', registry } - } - const { rail12V, rail5V } = v.readings - if (e.kind === 'pdu' && (rail12V || rail5V)) { - const rails = [rail('12 V', rail12V), rail('5 V', rail5V)].filter(Boolean).join(', ') - return { detected, reporting: `telemetry: ${rails}`, registry } - } - const faults = [ - st.boff && 'bus-off', - st.degraded && nodeFaultTag(st), - st.overheat && 'overheat', - st.epvf && 'error-passive' - ].filter((f): f is string => !!f) - return { - detected, - reporting: `status only · up ${fmtUptime(st.uptimeSec)} · ${faults.length ? faults.join(', ') : 'healthy'}`, - registry - } -} - -export interface AdoptForm { - name: string - console: string - /** Panels only. '' = not chosen yet; null = None (no PDU feeds it). */ - pduId: number | null | '' -} - -/** - * Where the form starts (#93): a registry board pre-filled from NODE_IDS.md — name, console and its - * default PDU — so adding it is one click; a NODE_ID the registry does not know starts blank. - */ -export function prefill(e: NodeModelEntry, kind: NodeKind): AdoptForm { - const r = e.registry - if (!r) return { name: '', console: '', pduId: '' } - return { name: r.name, console: r.console, pduId: kind === 'pdu' ? null : r.pduId } -} - -/** - * "Add all N panel groups from NODE_IDS.md" (#93): the waiting registry panel groups. PDUs are added - * one by one. A board whose form you changed is added as its form says (None included — that is a - * choice); an untouched one with its defaults, keeping its default PDU only if that PDU is already - * in your cockpit (`views`), else None — the PDU choice stays explicit. An unfinished form skips it. - */ -export function registrySpecs( - queue: NodeView[], - views: NodeView[], - edits: ReadonlyMap = new Map() -): { nodeId: number; spec: AdoptSpec }[] { - const inCockpit = (id: number | null) => - id !== null && - views.some((v) => v.entry.nodeId === id && v.entry.kind === 'pdu' && v.entry.source !== 'new') - const out: { nodeId: number; spec: AdoptSpec }[] = [] - for (const v of queue) { - if (!v.entry.registry || adoptability(v.entry).kind !== 'panel') continue - const edited = edits.get(v.entry.nodeId) - const form = prefill(v.entry, 'panel') - const pduId = typeof form.pduId === 'number' && inCockpit(form.pduId) ? form.pduId : null - const spec = adoptSpec('panel', edited ?? { ...form, pduId }) - if (spec) out.push({ nodeId: v.entry.nodeId, spec }) - } - return out -} - -/** The adopt request, or null while the form is incomplete. A PDU has no powered-by. */ -export function adoptSpec(kind: NodeKind, f: AdoptForm): AdoptSpec | null { - const name = f.name.trim() - if (!name || !f.console) return null - if (kind === 'pdu') return { name, console: f.console, pduId: null, kind } - if (f.pduId === '') return null - return { name, console: f.console, pduId: f.pduId, kind } -} - -/** - * The identify row's dot while the user power-cycles a board: blue while it reports, red once it - * has dropped off the bus, green when it comes back after a drop. - */ -export function identState(online: boolean, droppedSinceOpen: boolean): 'new' | 'off' | 'on' { - if (!online) return 'off' - return droppedSinceOpen ? 'on' : 'new' -} - -/** - * Which NODE_ID to select next. `order` is the queue as it was shown; `queue` is what is left. - * After Adopt the current one leaves the queue: take the one after it, else the first - * left. Later keeps the current one queued and moves on; past the end there is nothing (close). - */ -export function nextSelection( - order: number[], - queue: number[], - current: number, - how: 'done' | 'later' -): number | null { - const left = new Set(queue) - const after = order.slice(order.indexOf(current) + 1).find((id) => left.has(id)) - if (how === 'later') return after ?? null - return after ?? queue.find((id) => id !== current) ?? null -} - -/** - * A normal board reports its kind ~1 s after it appears. Until this has passed, an unidentified - * new board is simply still identifying — not an issue — so it never flashes a warning. - */ -export const IDENTIFY_WAIT_MS = 5000 - -/** - * New boards on the bus that can't be adopted (#93): a kind that can't be adopted (a second - * SimGateway, a type this client doesn't know), or still unidentified after IDENTIFY_WAIT_MS. A - * board on the bus is active, so it can't be hidden — it shows in Attention as a warning instead. - * `nextAt` = when one still identifying will cross the line. - */ -export function strayNew(views: NodeView[], now: number): { strays: NodeView[]; nextAt?: number } { - const strays: NodeView[] = [] - let nextAt: number | undefined - for (const v of views) { - const e = v.entry - if (e.source !== 'new' || e.availability !== 'online' || adoptability(e).kind) continue - const due = (e.firstSeenAt ?? 0) + IDENTIFY_WAIT_MS - if (e.identified || due <= now) strays.push(v) - else nextAt = Math.min(nextAt ?? due, due) - } - return nextAt === undefined ? { strays } : { strays, nextAt } -} - -/** A stray's Attention line: what it is, and that it can't be adopted. */ -export function strayFlag(e: NodeModelEntry): string { - const what = - e.identified === 'bridge' - ? 'SECOND GATEWAY' - : e.identified - ? 'UNKNOWN BOARD TYPE' - : 'UNIDENTIFIED' - return `${what} · can’t be adopted` -} diff --git a/src/renderer/src/cockpit.ts b/src/renderer/src/cockpit.ts deleted file mode 100644 index aa55499..0000000 --- a/src/renderer/src/cockpit.ts +++ /dev/null @@ -1,601 +0,0 @@ -// 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 { nodeFaultTag } from '@shared/nodes' -import { PSU_PRESETS, type PsuSetting } 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' - -/** Never colour alone: every state but healthy / unknown carries a symbol. */ -export const STATE_SYMBOL: Record = { - on: '', - warn: '!', - off: '✕', - new: '+', - unknown: '' -} - -/** A PDU's pages that are fresh right now. A stale page is not a reading. */ -export interface PduReadings { - rail12V?: PduRailPage - rail5V?: PduRailPage - status?: PduStatusPage -} - -export function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings { - const r: PduReadings = {} - if (t?.rail12V && !t.rail12V.stale) r.rail12V = t.rail12V - if (t?.rail5V && !t.rail5V.stale) r.rail5V = t.rail5V - if (t?.status && !t.status.stale) r.status = t.status - return r -} - -const RAIL_FLAG_TEXT: [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'] -] -/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state, not alerts. */ -const STATUS_FLAG_TEXT: [number, string][] = [ - [PDU_FLAG.TEMP_CRIT, 'TEMP CRIT'], - [PDU_FLAG.TEMP_WARN, 'TEMP WARN'], - [PDU_FLAG.NTC_FAULT, 'NTC FAULT'] -] - -/** The alert flags a PDU raises on its fresh pages, e.g. `5V UNDER-V`. Empty = healthy. */ -export function pduAlertFlags(r: PduReadings): string[] { - const out: string[] = [] - const rail = (label: string, p?: PduRailPage) => { - for (const [bit, text] of RAIL_FLAG_TEXT) if (p && p.flags & bit) out.push(`${label} ${text}`) - } - rail('12V', r.rail12V) - rail('5V', r.rail5V) - for (const [bit, text] of STATUS_FLAG_TEXT) if (r.status && r.status.flags & bit) out.push(text) - return out -} - -export interface NodeHealth { - state: NodeState - /** Short reason shown on Attention and tile tooltips, e.g. `OFFLINE`, `I2C`, `5V UNDER-V`. */ - 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' } - if (e.availability === 'unknown') return { state: 'unknown' } - if (e.notSeen) return { state: 'off', flag: 'NOT SEEN THIS SESSION' } - if (e.availability === 'offline') return { state: 'off', flag: 'OFFLINE' } - const st = e.status - if (st?.boff) return { state: 'off', flag: 'BUS-OFF' } - const flags: string[] = [] - if (st?.degraded) flags.push(nodeFaultTag(st)) - if (st?.overheat) flags.push('OVERHEAT') - if (st?.epvf) flags.push('ERR-PASSIVE') - if (e.kind === 'pdu') flags.push(...pduAlertFlags(r)) - return flags.length ? { state: 'warn', flag: flags.join(' · ') } : { state: 'on' } -} - -/** Everything the tab needs about one node, computed once per push. */ -export interface NodeView { - entry: NodeModelEntry - health: NodeHealth - /** PDUs: fresh pages only. */ - readings: PduReadings - /** PDUs: online but no fresh telemetry. */ - noReadings: boolean -} - -export function nodeViews(model: NodeModel, telemetry: NodeTelemSnapshot[]): NodeView[] { - const telem = new Map(telemetry.map((t) => [t.nodeId, t])) - return model.nodes.map((entry) => { - // An offline PDU has no readings, even in the seconds before its last pages go stale. - const readings = - entry.kind === 'pdu' && entry.availability === 'online' - ? freshReadings(telem.get(entry.nodeId)) - : {} - return { - entry, - health: nodeHealth(entry, readings), - readings, - noReadings: entry.kind === 'pdu' && entry.readings === 'none' - } - }) -} - -// ── search + filter ───────────────────────────────────────────────────────── - -/** A tile is lit when it matches the search (name or NODE_ID) and, with Issues on, has one. */ -export function tileMatches(v: NodeView, query: string, issuesOnly: boolean): boolean { - if (issuesOnly && (v.health.state === 'on' || v.health.state === 'unknown')) return false - const q = query.trim().toLowerCase() - if (!q) return true - return v.entry.name.toLowerCase().includes(q) || String(v.entry.nodeId) === q.replace(/^#/, '') -} - -// ── plan layout ───────────────────────────────────────────────────────────── - -export type ConsoleKey = 'L' | 'C' | 'R' - -/** NODE_IDS.md consoles are `Left` / `Center` / `Right`; anything else lands on the panel. */ -export function consoleKey(console: string | null): ConsoleKey { - const c = (console ?? '').toLowerCase() - if (c.startsWith('left')) return 'L' - if (c.startsWith('right')) return 'R' - return 'C' -} - -/** Stage geometry, in the 1000-wide stage the plan scales to fit. */ -export const STAGE_W = 1000 -const MIN_STAGE_H = 600 -const CONSOLES: Record< - ConsoleKey, - { title: string; x: number; titleY: number; top: number; w: number; cols: number; wide: boolean } -> = { - L: { title: 'Left console', x: 40, titleY: 164, top: 186, w: 200, cols: 2, wide: false }, - C: { title: 'Instrument panel', x: 290, titleY: 100, top: 122, w: 420, cols: 4, wide: true }, - R: { title: 'Right console', x: 760, titleY: 164, top: 186, w: 200, cols: 2, wide: false } -} -const TILE_GAP = 8 -const ROW_H = 42 -/** Tile labels are mono 11 px: about 0.6 em a character. */ -const CHAR_W = 6.6 -/** - * Left inset and padding, gap and border around the label, plus room for the state symbol — always - * reserved, so a board changing state never changes its tile's width or re-wraps its group. - */ -const TILE_CHROME = 46 - -/** - * A tile fits its label (#93): never narrower than the console's column (`min`), at most `max` - * (~2 columns, or the whole group on a side console); past that the label ends in "…". Its width - * depends on the name only, never on the board's state. - */ -export function tileWidth(label: string, min: number, max: number): number { - const fit = Math.ceil(label.length * CHAR_W + TILE_CHROME) - return Math.min(max, Math.max(min, fit)) -} -const HINT_H = 16 -const GROUP_GAP = 10 - -export interface PlanTile { - view: NodeView - /** Name without its console prefix (`Left_Fuel` → `Fuel`). */ - short: string - x: number - y: number - w: number -} - -export interface PlanGroup { - key: string - /** The PDU heading this group; null = nodes with no power zone on this console. */ - pdu: NodeView | null - console: ConsoleKey - x: number - y: number - w: number - wide: boolean - tiles: PlanTile[] - outage?: PduOutage - /** Under the tiles of a PDU that went down with its nodes. */ - hint?: { text: string; y: number } -} - -export interface PlanWire { - d: string - /** The PDU feeding this line raises a rail flag. */ - alert: boolean -} - -export interface PlanLayout { - height: number - consoles: { key: ConsoleKey; title: string; sub: string; x: number; y: number }[] - /** Background shapes, sized to the content. */ - sideBox: { y: number; h: number } - centerBottom: number - psu: { x: number; y: number } - groups: PlanGroup[] - wires: PlanWire[] -} - -/** Name without its console prefix (`Left_Fuel` → `Fuel`). */ -/** The tile label: the console prefix goes, however it is joined (`Center_`, `Center `, `Center-`). */ -export const shortName = (name: string) => name.replace(/^(Left|Center|Right)[_\s-]+(?=\S)/i, '') - -export function outageHint(o: PduOutage): string { - const n = o.nodeIds.length - const who = - n === o.fedCount - ? n === 1 - ? 'The only node' - : `All ${n} nodes` - : `${n} of ${o.fedCount} nodes` - const how = n === 1 ? 'lost power with it' : 'lost power together' - return `${who} on this PDU ${how} — a blown 5 V fuse at the PDU looks like this.` -} - -/** - * One group per PDU on the PDU's console, then the console's nodes without a power zone. A panel - * sits under the PDU that feeds it, wherever its own console is; new nodes are not placed (they - * wait in "New on the bus"). Tiles fit their names and wrap within their group (#93). - */ -export function planLayout(views: NodeView[], outages: PduOutage[]): PlanLayout { - const pdus = views.filter((v) => v.entry.kind === 'pdu' && v.entry.source !== 'new') - const pduIds = new Set(pdus.map((p) => p.entry.nodeId)) - const panels = views.filter((v) => v.entry.kind === 'panel' && v.entry.source !== 'new') - const groups: PlanGroup[] = [] - const bottom: Record = { L: 0, C: 0, R: 0 } - - for (const key of ['L', 'C', 'R'] as const) { - const c = CONSOLES[key] - const tileW = Math.floor((c.w - TILE_GAP * (c.cols - 1)) / c.cols) - const maxW = Math.min(2 * tileW + TILE_GAP, c.w) - let y = c.top - const place = (pdu: NodeView | null, members: NodeView[]) => { - const outage = pdu ? outages.find((o) => o.pduId === pdu.entry.nodeId) : undefined - const g: PlanGroup = { - key: pdu ? `pdu-${pdu.entry.nodeId}` : `none-${key}`, - pdu, - console: key, - x: c.x, - y, - w: c.w, - wide: c.wide, - tiles: [], - outage - } - y += c.wide ? 30 : 44 - // Tiles flow left to right and wrap inside the group, each as wide as its label needs. - let x = c.x - let row = 0 - for (const view of members) { - const short = shortName(view.entry.name) - const w = tileWidth(short, tileW, maxW) - if (x > c.x && x + w > c.x + c.w) { - x = c.x - row++ - } - g.tiles.push({ view, short, x, y: y + row * ROW_H, w }) - x += w + TILE_GAP - } - y += (members.length ? row + 1 : 0) * ROW_H - if (outage) { - g.hint = { text: outageHint(outage), y: y - 4 } - y += HINT_H - } - y += GROUP_GAP - groups.push(g) - } - for (const pdu of pdus.filter((p) => consoleKey(p.entry.console) === key)) { - place( - pdu, - panels.filter((p) => p.entry.pduId === pdu.entry.nodeId) - ) - } - const loose = panels.filter( - (p) => - consoleKey(p.entry.console) === key && - (p.entry.pduId === null || !pduIds.has(p.entry.pduId)) - ) - if (loose.length) place(null, loose) - bottom[key] = y - } - - // Background boxes grow with the content; the PSU sits under the lowest of them. - const sideBottom = Math.max(530, bottom.L + 14, bottom.R + 14) - const centerBottom = Math.max(374, bottom.C + 14) - const psuY = Math.max(548, sideBottom + 18, centerBottom + 18) - const height = Math.max(MIN_STAGE_H, psuY + 52) - const psu = { x: 420, y: psuY } - - // One feed line per PDU, gold while it raises a rail flag. - const wires: PlanWire[] = [] - for (const key of ['L', 'C', 'R'] as const) { - const on = pdus.filter((p) => consoleKey(p.entry.console) === key) - on.forEach((p, i) => { - const off = i - (on.length - 1) / 2 - const alert = railAlert(p.readings) - if (key === 'C') { - const x = Math.round(500 + off * 70) - wires.push({ d: `M${x} ${psuY} V${centerBottom}`, alert }) - } else { - const x = Math.round((key === 'L' ? 140 : 860) + off * 24) - const from = key === 'L' ? psu.x : psu.x + 160 - wires.push({ d: `M${from} ${psuY + 18 - i * 8} H${x} V${sideBottom + 2}`, alert }) - } - }) - } - - const count = (key: ConsoleKey) => pdus.filter((p) => consoleKey(p.entry.console) === key).length - return { - height, - consoles: (['L', 'C', 'R'] as const).map((key) => ({ - key, - title: CONSOLES[key].title, - sub: count(key) > 1 ? `${count(key)} PDUs` : '', - x: CONSOLES[key].x, - y: CONSOLES[key].titleY - })), - sideBox: { y: 150, h: sideBottom - 150 }, - centerBottom, - psu, - groups, - wires - } -} - -function railAlert(r: PduReadings): boolean { - return !!(r.rail12V?.flags || r.rail5V?.flags) -} - -// ── Attention ─────────────────────────────────────────────────────────────── - -/** "1 node affected" / "3 nodes affected" — a PDU outage, wherever it is shown. */ -export function affected(n: number): string { - return `${n} node${n === 1 ? '' : 's'} affected` -} - -export interface AttentionItem { - key: string - nodeId: number - name: string - state: NodeState - flag: string - /** A PDU outage: one item for the PDU and every node that dropped with it. */ - outage?: PduOutage - /** A new board on the bus that can't be adopted (#93): its cog opens the Adopt dialog. */ - stray?: boolean -} - -/** A new board that can't be adopted, and what Attention says about it (cockpit-adopt strayNew). */ -export interface StrayItem { - nodeId: number - flag: string -} - -/** - * Most severe first: PDU outages, then offline (incl. bus-off), then warnings — each by NODE_ID. - * Nodes that dropped with their PDU are folded into its outage item. New boards that can be - * adopted have their own prompt; ones that can't (`strays`) are warnings, after the rest. A PDU - * without readings is not an issue (its panel explains). - */ -export function attentionItems( - views: NodeView[], - outages: PduOutage[], - strays: StrayItem[] = [] -): AttentionItem[] { - const folded = new Set(outages.flatMap((o) => o.nodeIds)) - const items: (AttentionItem & { rank: number })[] = [] - for (const v of views) { - const { state, flag } = v.health - if (state !== 'off' && state !== 'warn') continue - const outage = outages.find((o) => o.pduId === v.entry.nodeId) - if (!outage && folded.has(v.entry.nodeId)) continue - const e = v.entry - const seen = - e.notSeen && e.lastSeenAt !== undefined ? ` · last seen ${lastSeenLabel(e.lastSeenAt)}` : '' - items.push({ - key: `n-${e.nodeId}`, - nodeId: e.nodeId, - name: e.name, - state, - flag: outage ? `OFFLINE · ${affected(outage.nodeIds.length)}` : `${flag ?? ''}${seen}`, - outage, - rank: outage ? 0 : state === 'off' ? 1 : 2 - }) - } - for (const s of strays) { - items.push({ - key: `s-${s.nodeId}`, - nodeId: s.nodeId, - name: `NODE_ID ${s.nodeId}`, - state: 'warn', - flag: s.flag, - stray: true, - rank: 3 - }) - } - return items - .sort((a, b) => a.rank - b.rank || a.nodeId - b.nodeId) - .map(({ rank: _rank, ...i }) => i) -} - -/** How many Attention items show before "+N more". */ -export const ATTENTION_SHOWN = 3 - -// ── health band ───────────────────────────────────────────────────────────── - -export interface HealthBand { - online: number - /** - * Your cockpit's boards (registry + adopted, PDUs included), plus new boards on the bus that - * can't be adopted — those are active and a warning (#93). New boards waiting for Adopt are not - * counted. - */ - total: number - warn: number - off: number - /** No complete roster yet and not reported: no data, so neither online nor offline. */ - unknown: number - /** Summed over PDUs with fresh readings. null = no PDU has any. */ - amps12: number | null - amps5: number | null - watts12: number | null - watts: number | null - /** PDUs on the plan that have no fresh readings (offline or none). */ - pdusWithout: number - pdusWith: number - /** Lowest 5 V rail average and the PDU it is on. */ - low5?: { volts: number; nodeId: number; name: string } - /** Highest TEC / REC among online nodes. */ - worstCan?: { tec: number; rec: number; nodeId: number; name: string } - busOff: number - /** The first board that is bus-off (lowest NODE_ID): the CAN card names it before any count. */ - busOffBoard?: { nodeId: number; name: string } - hottest?: { tempC: number; nodeId: number; name: string; level: 'ok' | 'warn' | 'crit' } -} - -const railW = (p?: PduRailPage) => (p ? (p.vAvgMv / 1000) * (displayCurrentMa(p.iAvgMa) / 1000) : 0) - -/** - * Total W the client computes from the fresh rails (V × A per rail). null = no readings at all. - * Placed nodes only: a PDU still waiting in "New on the bus" reports readings but is not on the - * plan, and the Total card and its session peak must add up the same PDUs. - */ -export function totalWatts(views: NodeView[]): number | null { - let w: number | null = null - for (const v of views) { - if (v.entry.source === 'new') continue - const { rail12V, rail5V } = v.readings - if (rail12V || rail5V) w = (w ?? 0) + railW(rail12V) + railW(rail5V) - } - return w -} - -export function healthBand(views: NodeView[], strays = 0): HealthBand { - const known = views.filter((v) => v.entry.source !== 'new') - const pdus = known.filter((v) => v.entry.kind === 'pdu') - const b: HealthBand = { - online: - known.filter((v) => v.health.state === 'on' || v.health.state === 'warn').length + strays, - total: known.length + strays, - warn: known.filter((v) => v.health.state === 'warn').length + strays, - off: known.filter((v) => v.health.state === 'off').length, - unknown: known.filter((v) => v.health.state === 'unknown').length, - amps12: null, - amps5: null, - watts12: null, - watts: totalWatts(known), - pdusWithout: 0, - pdusWith: 0, - busOff: 0 - } - const add = (cur: number | null, ma: number) => (cur ?? 0) + displayCurrentMa(ma) / 1000 - for (const p of pdus) { - const { rail12V, rail5V, status } = p.readings - if (rail12V || rail5V || status) b.pdusWith++ - else b.pdusWithout++ - if (rail12V) { - b.amps12 = add(b.amps12, rail12V.iAvgMa) - b.watts12 = (b.watts12 ?? 0) + railW(rail12V) - } - if (rail5V) { - b.amps5 = add(b.amps5, rail5V.iAvgMa) - const volts = rail5V.vAvgMv / 1000 - if (!b.low5 || volts < b.low5.volts) { - b.low5 = { volts, nodeId: p.entry.nodeId, name: p.entry.name } - } - } - if (status && status.ntcTempC !== null && (!b.hottest || status.ntcTempC > b.hottest.tempC)) { - b.hottest = { - tempC: status.ntcTempC, - nodeId: p.entry.nodeId, - name: p.entry.name, - level: - status.flags & PDU_FLAG.TEMP_CRIT - ? 'crit' - : status.flags & PDU_FLAG.TEMP_WARN - ? 'warn' - : 'ok' - } - } - } - for (const v of known) { - const st = v.entry.status - if (v.entry.availability !== 'online' || !st) continue - if (st.boff) { - b.busOff++ - b.busOffBoard ??= { nodeId: v.entry.nodeId, name: v.entry.name } - } - const worst = b.worstCan ? Math.max(b.worstCan.tec, b.worstCan.rec) : -1 - if (Math.max(st.tec, st.rec) > worst) { - b.worstCan = { tec: st.tec, rec: st.rec, nodeId: v.entry.nodeId, name: v.entry.name } - } - } - return b -} - -/** - * The CAN errors card's sub-line. A bus-off board is the worst, whatever its counters say (a - * bus-off node can read TEC 0), so it is named first. Otherwise a board is named only when its - * counters are above 0; "no errors" only when nothing is bus-off and every count is 0. - */ -export function canErrorsSub(b: HealthBand): string { - const bus = `${b.busOff} bus-off` - if (b.busOffBoard) return `worst #${b.busOffBoard.nodeId} · ${bus}` - const c = b.worstCan - if (!c) return bus - return Math.max(c.tec, c.rec) > 0 ? `worst #${c.nodeId} · ${bus}` : `no errors · ${bus}` -} - -/** Who the CAN card's "worst" is, for its tooltip; undefined when it names no board. */ -export function canErrorsWorst(b: HealthBand): string | undefined { - if (b.busOffBoard) return b.busOffBoard.name - const c = b.worstCan - return c && Math.max(c.tec, c.rec) > 0 ? c.name : undefined -} - -// ── PSU headroom ──────────────────────────────────────────────────────────── - -/** A band value drawn against a PSU limit (Settings › Cockpit › Power supply). Display only. */ -export interface Headroom { - limit: number - /** Share of the limit in use, not capped (112 = over). null = no reading. */ - pct: number | null - /** Bar fill, 0–100. */ - bar: number -} - -/** null = the limit is unknown: the card shows its plain total. */ -export function headroom(value: number | null, limit: number | undefined): Headroom | null { - if (limit === undefined) return null - const pct = value === null ? null : (value / limit) * 100 - 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' -} - -/** - * "New on the bus · N": new NODE_IDs on the bus that have identified as a PDU or a panel group. - * One still identifying is not news yet (isReportableNew — the Overview and event log agree). - */ -export function newCount(model: NodeModel): number { - return model.nodes.filter(isReportableNew).length -} diff --git a/src/renderer/src/tabs/settings/ConfigurationSection.tsx b/src/renderer/src/config/ConfigurationSection.tsx similarity index 70% rename from src/renderer/src/tabs/settings/ConfigurationSection.tsx rename to src/renderer/src/config/ConfigurationSection.tsx index b2b7dea..a443d86 100644 --- a/src/renderer/src/tabs/settings/ConfigurationSection.tsx +++ b/src/renderer/src/config/ConfigurationSection.tsx @@ -1,28 +1,29 @@ import { useState } from 'react' -import type { ImportPreview } from '@shared/ipc' -import { configState, useStore } from '../../store' +import type { ImportPreview } from '@shared/config' +import { Row, SectionHead } from '../app/kit' import { ImportDialog } from './ImportDialog' -import { Row, SectionHead } from './Row' -import { importDoneText } from './settings-view' +import { importDoneText } from './import-view' +import { useConfigStore } from './store' type Note = { text: string; err?: boolean } -export function ConfigurationSection() { - const set = useStore((s) => s.set) +/** Settings › Configuration. `axisLabels` name the calibration rows of an import's review. */ +export function ConfigurationSection({ axisLabels }: { axisLabels: readonly string[] }) { const [exported, setExported] = useState() const [imported, setImported] = useState() const [preview, setPreview] = useState() const doExport = async () => { setExported(undefined) - const r = await window.skyhawk?.exportConfig() - if (r?.error) setExported({ text: r.error, err: true }) - else if (r?.path) setExported({ text: `Saved ${r.path.split(/[/\\]/).pop()}` }) + // What came of it is ConfigTransfer's to say (R3.4a); the wording is the row's. + const r = await useConfigStore.getState().exportConfig() + if (r?.outcome === 'failed') setExported({ text: r.error, err: true }) + else if (r?.outcome === 'saved') setExported({ text: `Saved ${r.path.split(/[/\\]/).pop()}` }) } const doImport = async () => { setImported(undefined) - const r = await window.skyhawk?.openConfigImport() + const r = await useConfigStore.getState().openConfigImport() if (r?.status === 'error') setImported({ text: r.error, err: true }) else if (r?.status === 'ready') setPreview(r.preview) } @@ -69,9 +70,10 @@ export function ConfigurationSection() { {preview && ( setPreview(undefined)} onApplied={(r) => { - set(configState(r.config)) + // The settings come back on `config:changed` (R3.3). setImported({ text: importDoneText(r.applied.length, r.backupPath) }) setPreview(undefined) }} diff --git a/src/renderer/src/tabs/settings/ImportDialog.tsx b/src/renderer/src/config/ImportDialog.tsx similarity index 83% rename from src/renderer/src/tabs/settings/ImportDialog.tsx rename to src/renderer/src/config/ImportDialog.tsx index 82f868f..3f0d3de 100644 --- a/src/renderer/src/tabs/settings/ImportDialog.tsx +++ b/src/renderer/src/config/ImportDialog.tsx @@ -1,24 +1,22 @@ import { useState } from 'react' -import type { ConfigImportApplyResult, ImportPreview } from '@shared/ipc' -import type { SectionId } from '@shared/config-file' -import { AXIS_LABELS } from '../../store' -import { Dialog } from './Dialog' -import { importRowKey, importSubtitle } from './settings-view' - -const CAL_NOTE = - 'Adds these values to the client’s calibration copy — nothing is written to the device here. If the Gateway has lost calibration, the usual Review & restore offer appears on the HID tab; axes the device still holds are never overwritten.' +import type { ConfigImportApplyResult, ImportPreview, SectionId } from '@shared/config' +import { Dialog } from '../app/kit' +import { importRowKey, importSubtitle } from './import-view' +import { useConfigStore } from './store' /** * Import configuration — review what the file changes, section by section, and apply the ones * ticked. Sections with no change are shown but cannot be picked; a file that changes nothing - * says so and offers no Apply. + * says so and offers no Apply. `axisLabels` name the calibration rows. */ export function ImportDialog({ preview, + axisLabels, onClose, onApplied }: { preview: ImportPreview + axisLabels: readonly string[] onClose: () => void onApplied: (r: Extract) => void }) { @@ -38,7 +36,7 @@ export function ImportDialog({ const apply = async () => { setBusy(true) setError(undefined) - const r = await window.skyhawk?.applyConfigImport(preview.token, [...picked], backup) + const r = await useConfigStore.getState().applyConfigImport(preview.token, [...picked], backup) setBusy(false) if (r?.ok) onApplied(r) else setError(r?.error ?? 'Import failed') @@ -120,13 +118,13 @@ export function ImportDialog({
{s.rows.map((r, i) => (
- {importRowKey(r, AXIS_LABELS, boards.size > 1)} + {importRowKey(r, axisLabels, boards.size > 1)} {r.from} → {r.to}
))} - {s.id === 'calibration' && !none &&
{CAL_NOTE}
} + {s.note &&
{s.note}
} ) diff --git a/src/renderer/src/config/import-view.ts b/src/renderer/src/config/import-view.ts new file mode 100644 index 0000000..10611c9 --- /dev/null +++ b/src/renderer/src/config/import-view.ts @@ -0,0 +1,33 @@ +// Settings › Configuration wording (#67; R3.4b: the import half of settings-view.ts), pure so it +// is unit-tested: what the Import review and its done line say. +import { day, hhmm, plural } from '@shared/format' + +/** Import review subtitle: "skyhawk-config.json · exported 2026-10-12 21:02 · client v0.2.0". */ +export function importSubtitle(p: { + fileName: string + exportedAt?: string + client?: string +}): string { + const parts = [p.fileName] + const t = p.exportedAt ? Date.parse(p.exportedAt) : NaN + if (!Number.isNaN(t)) parts.push(`exported ${day(t)} ${hhmm(t)}`) + if (p.client) parts.push(`client v${p.client}`) + return parts.join(' · ') +} + +/** A review row's key: calibration rows name the axis, and the board when there are several. */ +export function importRowKey( + r: { k: string; axis?: number; board?: string }, + axisLabels: readonly string[], + severalBoards: boolean +): string { + if (r.axis === undefined) return r.k + const name = axisLabels[r.axis] ?? r.k + return severalBoards && r.board ? `${name} · ${r.board}` : name +} + +/** "Applied 1 section" / "Applied 3 sections · backup saved as skyhawk-config-….json". */ +export function importDoneText(applied: number, backupPath?: string): string { + const done = `Applied ${plural(applied, 'section')}` + return backupPath ? `${done} · backup saved as ${backupPath.split(/[/\\]/).pop()}` : done +} diff --git a/src/renderer/src/config/index.ts b/src/renderer/src/config/index.ts new file mode 100644 index 0000000..dd8dacb --- /dev/null +++ b/src/renderer/src/config/index.ts @@ -0,0 +1,12 @@ +// config (R3.4b): the settings as the forms hold them, and the settings intents; Settings › +// Configuration (export / import). +export { + bindConfig, + configFromMain, + useConfigStore, + viewTransport, + type ConfigState, + type SourceMode, + type TransportId +} from './store' +export { ConfigurationSection } from './ConfigurationSection' diff --git a/src/renderer/src/config/store.ts b/src/renderer/src/config/store.ts new file mode 100644 index 0000000..9522207 --- /dev/null +++ b/src/renderer/src/config/store.ts @@ -0,0 +1,172 @@ +// The settings as the forms hold them, and the settings intents (R3.4b: the config slice of the +// old single store, cut verbatim, plus the export / import calls Settings › Configuration made +// straight to `window.skyhawk`, and the Diagnostics actions). The IPC adapter binds the intents and +// delivers the pushes. +import { create } from 'zustand' +import type { SkyhawkApi } from '@shared/app' +import { + DEFAULT_CONFIG, + PSU_VIEW_DEFAULT, + type AppConfig, + type ConfigChanged, + type ConfigEdit, + type ConfigView, + type DcsTransport, + type PsuView +} from '@shared/config' +import type { PsuLimits, PsuSetting } from '@shared/nodes' + +export type SourceMode = 'bridge' | 'monitor' | 'replay' +export type TransportId = 'tcp' | 'loop' | 'uni' + +/** A call to main as the view made it: undefined outside Electron (no `window.skyhawk`). */ +type Ask> = ( + ...args: Parameters +) => ReturnType | undefined + +export interface ConfigState { + sourceMode: SourceMode + transport: TransportId + host: string + port: string + autoConnect: boolean + + // replay + replayDriveSerial: boolean + + // Cockpit options (#67) + attentionBanner: boolean + psu: PsuSetting + /** What the PSU setting comes to — preset, limits, name — as main resolves it (R3.3). */ + psuView: PsuView + + // developer / debug + debugMode: boolean + debugDump?: { path: string; count: number } + + setConfigField: (patch: Partial) => void + /** Settings › Cockpit's PSU switch: main switches the mode over the setting it holds (R3.4a). */ + setPsuMode: (mode: PsuSetting['mode']) => void + /** Settings › Cockpit's PSU limit, as typed: main refuses, deletes or sets it (R3.4a). */ + setPsuLimit: (key: keyof PsuLimits, text: string) => void + dumpSerialPorts: () => void + revealDebugLog: () => void + exportConfig: Ask<'exportConfig'> + openConfigImport: Ask<'openConfigImport'> + applyConfigImport: Ask<'applyConfigImport'> +} + +/** Main, as the IPC adapter hands it over; nothing until it binds. */ +let api: () => SkyhawkApi | undefined = () => undefined + +/** The IPC adapter's binding: every intent reads `window.skyhawk` through it, at call time. */ +export function bindConfig(skyhawk: () => SkyhawkApi | undefined): void { + api = skyhawk +} + +// ── renderer <-> main transport mapping ────────────────────────────────────── +const TRANSPORT_TO_IPC: Record = { + tcp: 'tcp-to-host', + loop: 'loopback-multicast', + uni: 'unicast-listen' +} + +/** The contract's transport as the forms name it. */ +export function viewTransport(transport: DcsTransport): TransportId { + const t = (Object.keys(TRANSPORT_TO_IPC) as TransportId[]).find( + (k) => TRANSPORT_TO_IPC[k] === transport + ) + return t ?? 'tcp' +} + +/** The store fields an AppConfig sets — on load, and after a config import replaces it. */ +function configState(cfg: AppConfig): ConfigFields { + return { + sourceMode: cfg.sourceMode, + transport: viewTransport(cfg.transport), + host: cfg.host, + port: String(cfg.commandPort), + autoConnect: cfg.autoConnect, + replayDriveSerial: cfg.replayDriveSerial, + debugMode: cfg.debugMode, + attentionBanner: cfg.attentionBanner, + psu: cfg.psu + } +} + +/** The settings fields the forms edit, as the store holds them. */ +type ConfigFields = Pick< + ConfigState, + | 'sourceMode' + | 'transport' + | 'host' + | 'port' + | 'autoConnect' + | 'replayDriveSerial' + | 'debugMode' + | 'attentionBanner' + | 'psu' +> + +/** A form's change as main takes it (`config:edit`): the contract's transport, the port as typed. */ +function configEdit(patch: Partial): ConfigEdit { + const { transport, ...rest } = patch + return { ...rest, ...(transport !== undefined && { transport: TRANSPORT_TO_IPC[transport] }) } +} + +/** A form's change, to main (`config:edit`): main builds the settings and saves them (R3.3). */ +const editConfig = (edit: ConfigEdit): void => void api()?.editConfig(edit) + +export const useConfigStore = create((set) => ({ + ...configState(DEFAULT_CONFIG), + psuView: PSU_VIEW_DEFAULT, + + // What is typed stays as typed; main builds and saves the settings (`config:edit`, R3.3). + setConfigField: (patch) => { + set(patch as Partial) + editConfig(configEdit(patch)) + }, + + // The switched setting comes back on `config:changed` (R3.4a). + setPsuMode: (mode) => { + editConfig({ psuMode: mode }) + }, + + // The setting comes back on `config:changed` (R3.4a); the field keeps its own text. + setPsuLimit: (key, text) => { + editConfig({ psuLimit: { key, text } }) + }, + + dumpSerialPorts: () => { + void api() + ?.dumpSerialPorts() + .then((r) => set({ debugDump: r })) + }, + + revealDebugLog: () => { + void api()?.revealDebugLog() + }, + + exportConfig: () => api()?.exportConfig(), + openConfigImport: () => api()?.openConfigImport(), + applyConfigImport: (token, sections, backup) => api()?.applyConfigImport(token, sections, backup) +})) + +/** What main sends this slice; the IPC adapter calls each as its push or pull lands. */ +export const configFromMain = { + /** `config:get`. */ + config: (v: ConfigView): void => + useConfigStore.setState({ ...configState(v.config), psuView: v.psu }), + + /** + * `config:changed`. An import replaced the settings: the forms start over from them. A form's own + * change leaves what is typed alone (the port as typed, N5); the PSU setting is main's either way, + * since main switches its mode (R3.4a). + */ + changed: (c: ConfigChanged): void => + useConfigStore.setState({ + psuView: c.psu, + psu: c.config.psu, + ...(c.source === 'import' ? configState(c.config) : {}) + }) +} diff --git a/src/renderer/src/connection-summary.ts b/src/renderer/src/connection-summary.ts deleted file mode 100644 index c8aea17..0000000 --- a/src/renderer/src/connection-summary.ts +++ /dev/null @@ -1,105 +0,0 @@ -// 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 { - affected, - ATTENTION_SHOWN, - attentionItems, - healthBand, - lastSeenLabel, - type NodeState, - type NodeView -} from './cockpit' -import { fmtTime } from './store' - -export interface SummaryRow { - nodeId: number - name: string - state: NodeState - /** Chip text: `OFFLINE`, `BUS-OFF`, or the flags the board raised. */ - chip: string - tone: 'err' | 'warn' - /** - * `#13 · PDU_Left · offline since 21:11:18`, or for a board remembered from an earlier session - * that hasn't reported since the link opened, `… · not seen this session · last seen 14 Oct, - * 21:13` — "offline since" would be the roster's time, not when it went (#93). - */ - meta: string -} - -export interface NodesSummary { - /** Known nodes (registry + adopted, PDUs included); new nodes are not counted. */ - known: number - healthy: number - warn: number - off: number - /** No complete roster yet and not reported. */ - unknown: number - /** Known nodes that are PDUs, by their kind (never by NODE_ID). */ - pdus: number - /** Client-computed total over PDUs with fresh readings; null = none has any. */ - watts: number | null - /** The most severe nodes, in Cockpit Attention order. */ - rows: SummaryRow[] - /** Attention items not shown. */ - more: number - /** `+5 more · healthy nodes hidden`; empty when there is nothing to say. */ - 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') - const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry])) - const items = attentionItems(views, outages) - const healthy = band.online - band.warn - - const rows = items.slice(0, ATTENTION_SHOWN).map((i): SummaryRow => { - const e = byId.get(i.nodeId)! - const meta = [`#${i.nodeId}`] - if (i.outage) meta.push(affected(i.outage.nodeIds.length)) - else if (e.kind !== 'pdu') { - const feed = e.pduId === null ? undefined : byId.get(e.pduId) - meta.push(feed ? feed.name : 'no power zone') - } - if (e.notSeen) { - 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)}`) - } - return { - nodeId: i.nodeId, - name: i.name, - state: i.state, - chip: i.outage || e.notSeen ? 'OFFLINE' : i.flag, - tone: i.state === 'off' ? 'err' : 'warn', - meta: meta.join(' · ') - } - }) - - const more = Math.max(0, items.length - ATTENTION_SHOWN) - const footer = [ - more > 0 && `+${more} more`, - band.unknown > 0 && `${band.unknown} no status yet`, - healthy > 0 && 'healthy nodes hidden' - ] - .filter(Boolean) - .join(' · ') - - return { - known: known.length, - healthy, - warn: band.warn, - off: band.off, - unknown: band.unknown, - pdus: known.filter((v) => v.entry.kind === 'pdu').length, - watts: band.watts, - rows, - more, - footer - } -} diff --git a/src/renderer/src/dcs/RelayControls.tsx b/src/renderer/src/dcs/RelayControls.tsx new file mode 100644 index 0000000..c7f9b8d --- /dev/null +++ b/src/renderer/src/dcs/RelayControls.tsx @@ -0,0 +1,143 @@ +import { useDcsStore } from './store' +import { relayNote, relaySide, startBlockedReason } from './relay-view' + +// The Connection tab's relay controls (R3.4b: cut from Connection.tsx). The tab puts its settings +// cards (source mode, DCS link) between them, so each is its own component. + +const REC_DOT = ( + +) + +/** + * Load a capture, in Replay. Drive Gateway is a setting: the Connection tab hands in its value and + * its switch. + */ +export function CaptureCard({ + driveSerial, + onToggleDrive +}: { + driveSerial: boolean + onToggleDrive: () => void +}) { + const facts = useDcsStore((x) => x.relayFacts) + const openReplay = useDcsStore((x) => x.openReplay) + const replay = facts.replay + if (!facts.captureShown) return null + return ( +
+
+ Capture +
+ +
+ {replay ? ( + <> + {replay.file.split(/[/\\]/).pop()} + {` · ${replay.events} events · ${(replay.durationMs / 1000).toFixed(1)}s`} + {' — press Start to play.'} + + ) : ( + 'No capture loaded. Load a recorded session to replay it with no DCS running.' + )} +
+
+
+
Drive Gateway
+
+ Also write the replay to the serial device +
+
+ +
+
+ ) +} + +/** Record the live stream, outside Replay. */ +export function RecordCard() { + const facts = useDcsStore((x) => x.relayFacts) + const relaying = useDcsStore((x) => x.relaying) + const toggleCapture = useDcsStore((x) => x.toggleCapture) + // The relay controls as main decides them (relay:facts, R3.3), for the on/off as shown. + const side = relaySide(facts, relaying) + const recording = facts.recording + if (!facts.recordShown.connection) return null + return ( +
+
+ Record +
+ +
+ {recording.on + ? 'Recording live DCS-BIOS stream. Stop to save — replay the file on any machine with no DCS.' + : side.canRecord.connection + ? 'Capture a live session to replay later with no DCS running.' + : 'Start the relay first to enable recording.'} +
+
+ ) +} + +/** Start / Stop relay, and what a running relay is waiting for or why Start is blocked. */ +export function RelayStart() { + const facts = useDcsStore((x) => x.relayFacts) + const relaying = useDcsStore((x) => x.relaying) + const toggleRelay = useDcsStore((x) => x.toggleRelay) + // The relay controls as main decides them (relay:facts, R3.3), for the on/off as shown. + const side = relaySide(facts, relaying) + const blocked = side.startBlocked + const note = blocked + ? { text: startBlockedReason(blocked), info: true } + : relayNote(side.waiting, facts.stream) + return ( +
+ + {note &&
{note.text}
} +
+ ) +} diff --git a/src/renderer/src/RelayPill.tsx b/src/renderer/src/dcs/RelayPill.tsx similarity index 62% rename from src/renderer/src/RelayPill.tsx rename to src/renderer/src/dcs/RelayPill.tsx index 905c376..ca7e87c 100644 --- a/src/renderer/src/RelayPill.tsx +++ b/src/renderer/src/dcs/RelayPill.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from 'react' -import { useStore } from './store' +import { deviceView, useGatewayStore } from '../gateway' +import { useDcsStore } from './store' import { - deviceView, + endpointText, + relaySide, relayTip, - relayWaiting, - startBlocked, startBlockedCallout, startBlockedReason -} from './device-view' +} from './relay-view' /** How long the blocked-Start callout stays up on its own. */ const CALLOUT_MS = 6000 @@ -17,52 +17,42 @@ const CALLOUT_MS = 6000 * * Green while delivering end to end, gold while running but waiting, grey when stopped. When Start * would be refused — no board for a mode that needs one — it says so at a glance with a "!", and a - * click answers with a callout under the pill: why, and the Connect that fixes it. + * click answers with a callout under the pill: why, and the Connect that fixes it. What each of + * those is, main decides (`relay:facts`, R3.3); the pill words it. `autoConnect`, `host` and + * `port` are Settings' (the title bar hands them in). */ -export function RelayPill() { - const sourceMode = useStore((s) => s.sourceMode) - const relaying = useStore((s) => s.relaying) - const relayStream = useStore((s) => s.relayStream) - const deviceState = useStore((s) => s.deviceState) - const devicePort = useStore((s) => s.devicePort) - const deviceDetail = useStore((s) => s.deviceDetail) - const autoConnect = useStore((s) => s.autoConnect) - const replayDriveSerial = useStore((s) => s.replayDriveSerial) - const replayFile = useStore((s) => s.replayFile) - const transport = useStore((s) => s.transport) - const host = useStore((s) => s.host) - const port = useStore((s) => s.port) - const toggleRelay = useStore((s) => s.toggleRelay) - const connectDevice = useStore((s) => s.connectDevice) +export function RelayPill({ + autoConnect, + host, + port +}: { + autoConnect: boolean + host: string + port: string +}) { + const relaying = useDcsStore((s) => s.relaying) + const facts = useDcsStore((s) => s.relayFacts) + const toggleRelay = useDcsStore((s) => s.toggleRelay) + const connectDevice = useGatewayStore((s) => s.connectDevice) const [callout, setCallout] = useState(false) const btn = useRef(null) - const blocked = !relaying && startBlocked({ mode: sourceMode, replayDriveSerial, deviceState }) - const waiting = relayWaiting({ mode: sourceMode, relaying, stream: relayStream, deviceState }) - const endpoint = - transport === 'tcp' - ? `${host}:${port}` - : transport === 'loop' - ? '239.255.50.10:5010' - : 'unicast listen' + // The side of the on/off the pill shows: a click's pending Start or Stop reads at once. + const side = relaySide(facts, relaying) + const blocked = side.startBlocked + const waiting = side.waiting !== 'none' const title = blocked - ? startBlockedReason(sourceMode) + ? startBlockedReason(blocked) : relayTip({ - mode: sourceMode, - relaying, - stream: relayStream, - deviceState, - deviceLabel: deviceView(deviceState, { - port: devicePort, - detail: deviceDetail, - autoConnect - }).label, - endpoint, - replayFile + tip: side.tip, + stream: facts.stream, + deviceLabel: deviceView(facts.deviceState, { autoConnect }).label, + endpoint: endpointText(facts.endpoint.pill, host, port), + replayFile: facts.replay?.file }) // The board arriving answers the callout; so does it timing out. - const open = callout && blocked + const open = callout && !!blocked useEffect(() => { if (!open) return const timer = setTimeout(() => setCallout(false), CALLOUT_MS) @@ -85,7 +75,7 @@ export function RelayPill() { + +
+ {deviceRows.map((d) => ( +
+ {d.k} + {d.v} +
+ ))} + {dev.note && ( +
+ {dev.note} + {dev.noteSettingsLink && ( + <> + {' '} + + + )} +
+ )} +
+ + ) +} diff --git a/src/renderer/src/DevicePopover.tsx b/src/renderer/src/gateway/DevicePopover.tsx similarity index 68% rename from src/renderer/src/DevicePopover.tsx rename to src/renderer/src/gateway/DevicePopover.tsx index 0d077ad..da15f78 100644 --- a/src/renderer/src/DevicePopover.tsx +++ b/src/renderer/src/gateway/DevicePopover.tsx @@ -1,29 +1,36 @@ import { useEffect, useRef } from 'react' -import { isLinkUp } from '@shared/ipc' -import { useStore } from './store' -import { deviceView } from './device-view' +import { useNav } from '../app/kit' +import { useGatewayStore } from './store' +import { deviceView, linkButton } from './link-view' import { gatewayView } from './gateway-view' /** * The title-bar Device indicator (#69) and its popover: the link state at a glance on every tab, * with the same Connect / Disconnect / Retry as the Connection tab's device card. While connected * it also shows the Gateway's own health (#94): gold "!" when it reports a problem but still works, - * red "!" when panel input can't reach DCS. + * red "!" when panel input can't reach DCS. `autoConnect` is Settings' (the title bar hands it in). */ -export function DevicePopover() { - const deviceState = useStore((s) => s.deviceState) - const devicePort = useStore((s) => s.devicePort) - const deviceDetail = useStore((s) => s.deviceDetail) - const deviceGateway = useStore((s) => s.deviceGateway) - const autoConnect = useStore((s) => s.autoConnect) - const connectDevice = useStore((s) => s.connectDevice) - const disconnectDevice = useStore((s) => s.disconnectDevice) - const open = useStore((s) => s.devicePopOpen) - const set = useStore((s) => s.set) +export function DevicePopover({ autoConnect }: { autoConnect: boolean }) { + const deviceState = useGatewayStore((s) => s.deviceState) + const devicePort = useGatewayStore((s) => s.devicePort) + const deviceDetail = useGatewayStore((s) => s.deviceDetail) + const deviceGateway = useGatewayStore((s) => s.deviceGateway) + const deviceAction = useGatewayStore((s) => s.deviceAction) + const deviceBusy = useGatewayStore((s) => s.deviceBusy) + const connectDevice = useGatewayStore((s) => s.connectDevice) + const disconnectDevice = useGatewayStore((s) => s.disconnectDevice) + const open = useNav((s) => s.devicePopOpen) + const set = useNav((s) => s.set) const btn = useRef(null) - const dev = deviceView(deviceState, { port: devicePort, detail: deviceDetail, autoConnect }) - const gw = gatewayView(deviceGateway, isLinkUp(deviceState), devicePort) + const dev = deviceView(deviceState, { + port: devicePort, + detail: deviceDetail, + busy: deviceBusy, + autoConnect + }) + const button = linkButton(deviceAction) + const gw = gatewayView(deviceGateway, devicePort) const cls = gw.pill?.cls ?? dev.cls const meta = gw.meta ?? dev.popMeta @@ -85,15 +92,15 @@ export function DevicePopover() { )}
@@ -160,27 +164,27 @@ export function Hid() {
Axes - {s.cal ? `${present.length} of 8 slots · device-reported` : 'int16 · ±32768'} + {snap + ? `${s.hidPresent} of ${HID_REPORT_LAYOUT.axes.count} slots · device-reported` + : `int16 · ±${-HID_REPORT_LAYOUT.axes.min}`} - {s.cal && ( + {snap && ( {uncalibrated ? `${uncalibrated} ${uncalibrated === 1 ? 'axis' : 'axes'} uncalibrated` : 'all axes calibrated'} )} - {(present.length > 0 || !linkUp) && calib.controller && ( + {cal.calibrate.shown && ( . @@ -213,7 +217,7 @@ export function Hid() { raw={v} avail={axisLive(i)} cal={calState(i)} - onCalibrate={calState(i) ? () => void calib.start(i) : undefined} + onCalibrate={calState(i) ? () => onCalibrate(i) : undefined} /> ))}
@@ -222,7 +226,7 @@ export function Hid() {
Hats - 8-way POV + {HID_REPORT_LAYOUT.hats.directions}-way POV
{s.hats.map((d, i) => ( @@ -249,12 +253,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/renderer/src/CalibrationDialog.tsx b/src/renderer/src/hid/calibration/CalibrationDialog.tsx similarity index 60% rename from src/renderer/src/CalibrationDialog.tsx rename to src/renderer/src/hid/calibration/CalibrationDialog.tsx index 2a1e185..aba36c0 100644 --- a/src/renderer/src/CalibrationDialog.tsx +++ b/src/renderer/src/hid/calibration/CalibrationDialog.tsx @@ -1,146 +1,33 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { - DEFAULT_CAPTURE_CONFIG, - hasTravelled, - holdProgress, - holdRemainingMs, - sweepsRemaining -} from '@shared/axis-capture' -import type { CalSnapshot } from '@shared/ipc' -import { CalibrationController, type CalibrationState } from './calibration-controller' -import { AXIS_LABELS, useStore } from './store' - -/** - * 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 - 32768 -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) + '%' +import { useEffect, useRef, useState } from 'react' +import { CAL_PROTO, HID_REPORT_LAYOUT } from '@shared/firmware' +import type { CalibrationDialogState } from '@shared/hid/calibration' +import { AXIS_OFFSET, AXIS_SPAN, fmt, fmtSpan, pct, promptText } from './calibration-view' +import { AXIS_LABELS } from '../index' +import 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 / 65535 >= b.at)! - return { counts, pct: Math.round((counts / 65535) * 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. @@ -199,19 +86,19 @@ export function CalibrationDialog({ ) : ( connected )}{' '} - · signed ±32767 + {`· signed ±${HID_REPORT_LAYOUT.axes.max}`}
DCS link paused - - {(cap || draft) && ( + {d.strip && (
{steps.map((label, i) => (
@@ -226,8 +113,8 @@ export function CalibrationDialog({
))} - {inTravel && cap - ? `${sweepsRemaining(cap)} sweep${sweepsRemaining(cap) === 1 ? '' : 's'} remaining` + {d.sweepsRemaining !== undefined + ? `${d.sweepsRemaining} sweep${d.sweepsRemaining === 1 ? '' : 's'} remaining` : 'Captured, not written — nothing reaches the device until you write.'}
@@ -239,8 +126,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.
@@ -302,7 +189,7 @@ export function CalibrationDialog({ Captured range · sensor - {cap?.phase === 'capturing' && !cap.awaitingMovement && ( + {d.holdAtStops && ( Hold at each stop @@ -315,7 +202,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,9 +210,9 @@ export function CalibrationDialog({ {s.live && }
- −32768 + −{AXIS_OFFSET} 0 - 32767 + {HID_REPORT_LAYOUT.axes.max}
@@ -350,15 +237,15 @@ export function CalibrationDialog({
@@ -371,29 +258,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`}
@@ -412,12 +299,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 @@ -434,14 +321,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} @@ -498,7 +382,7 @@ export function CalibrationDialog({
)} - {s.restorable && s.restoreOpen && !s.write && !s.paused && ( + {d.restore === 'panel' && s.restorable && (
Restore calibration to the device @@ -530,13 +414,10 @@ export function CalibrationDialog({ {s.device?.serialNumber ?? 'unknown'}. Only axes the device has lost are written; anything it still holds is left alone. - - @@ -544,15 +425,15 @@ export function CalibrationDialog({
)} - {s.restorable && !s.restoreOpen && !s.write && !s.paused && ( - )} - {s.notice && !s.write && ( + {d.notice && s.notice && (
{s.notice.kind === 'written' ? '✓' : '⌫'} @@ -594,35 +475,35 @@ export function CalibrationDialog({ )}
- {invalid && !writing && ( + {d.showInvalid && invalid && ( {AXIS_LABELS[invalid.axis]}: {invalid.reason} )}
- {stored?.calibrated && !writing && ( + {d.remove.shown && ( )} - @@ -631,73 +512,3 @@ export function CalibrationDialog({
) } - -/** Owns the controller's lifetime and feeds it the RAW stream. */ -export function useCalibration(device: CalSnapshot | undefined, linkUp: boolean) { - const api = window.skyhawk - const ref = useRef(null) - if (!ref.current && api) ref.current = new CalibrationController(api) - const controller = ref.current - const [open, setOpen] = useState(false) - const [offer, setOffer] = useState<{ confirmedAt: string; axes: number[] }>() - const setCal = useStore((st) => st.set) - - useEffect(() => { - if (!controller || !api) return - const off = api.on('cal:raw', (batch) => controller.ingest(batch)) - return () => { - off() - controller.dispose() - } - }, [controller, api]) - - // Order matters: the link going down clears the store's snapshot in the same render, and the - // controller must already be paused when that empty snapshot arrives — otherwise it reads as a - // different board and the captured sweeps are thrown away. - useEffect(() => controller?.linkChanged(linkUp), [controller, linkUp]) - useEffect(() => controller?.deviceChanged(device), [controller, device]) - - // Checked whenever the device's report changes, and needs no session: GET_CAL already ran to - // drive the badges, and the cache is a local file. So the offer 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. - useEffect(() => { - let live = true - void api?.calCacheRead().then((r) => { - if (!live) return - setOffer( - r.ok && r.value.board && r.value.regressed.length - ? { confirmedAt: r.value.board.confirmedAt, axes: r.value.regressed } - : undefined - ) - }) - return () => { - live = false - } - }, [api, device]) - - return { - controller, - open, - offer, - async start(axis: number, showRestore = false) { - if (!controller) return - await controller.open(axis, device) - controller.setRestoreOpen(showRestore) - setOpen(true) - }, - async close() { - if (!controller) return - await controller.close() - setOpen(false) - const r = await window.skyhawk?.calRead() - if (r?.ok) setCal({ cal: r.value }) - const c = await api?.calCacheRead() - setOffer( - c?.ok && c.value.board && c.value.regressed.length - ? { confirmedAt: c.value.board.confirmedAt, axes: c.value.regressed } - : undefined - ) - } - } -} diff --git a/src/renderer/src/hid/calibration/calibration-view.ts b/src/renderer/src/hid/calibration/calibration-view.ts new file mode 100644 index 0000000..d73d70d --- /dev/null +++ b/src/renderer/src/hid/calibration/calibration-view.ts @@ -0,0 +1,66 @@ +// The calibration dialog's wording and geometry (R3.1). Main decides what is shown (the pushed +// CalibrationState); this only turns it into numbers on screen and the prompt's words. +import { HID_REPORT_LAYOUT } from '@shared/firmware' +import type { CapturePrompt } from '@shared/hid/calibration' + +/** 32768: the unsigned -> signed offset, from the HID report's axis range. */ +export const AXIS_OFFSET = -HID_REPORT_LAYOUT.axes.min +/** 65535: the full unsigned span, from the HID report's axis range. */ +export 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 +export const fmt = (n: number) => toDisplay(n).toLocaleString('en-US') +/** A span, not a position — no offset applies. */ +export const fmtSpan = (n: number) => n.toLocaleString('en-US') +export const pct = (v: number) => Math.max(0, Math.min(100, (v / AXIS_SPAN) * 100)).toFixed(2) + '%' + +/** + * The live prompt's words, from what main says it asks for. + * + * Two states per step: **travelling** (gold — keep moving, the bar is not counting yet) and + * **holding** (green — stop moving, the bar is the dwell timer). Before the axis has moved at all + * in the first step there is no direction, so the copy stays neutral. + */ +export function promptText(p: CapturePrompt) { + const { holding, side } = p + + if (p.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 + } + } + + 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' + : p.step === 'stopA' + ? `Push the axis to its ${side.toUpperCase()} stop` + : `Now ${side === 'lower' ? 'pull' : 'push'} it to its ${side.toUpperCase()} stop`, + note: p.awaitingMovement + ? 'Nothing has moved yet — take the axis all the way to the mechanical stop.' + : 'Keep moving steadily until you reach the stop.', + holding + } +} diff --git a/src/renderer/src/hid/calibration/index.ts b/src/renderer/src/hid/calibration/index.ts new file mode 100644 index 0000000..21c94f1 --- /dev/null +++ b/src/renderer/src/hid/calibration/index.ts @@ -0,0 +1,9 @@ +// hid/calibration (R3.4b): axis calibration as main runs it — the pushed workflow and the dialog. +export { + bindCalibration, + calibrationFromMain, + useCalibrationStore, + type CalibrationIntents, + type CalibrationStoreState +} from './store' +export { CalibrationDialog } from './CalibrationDialog' diff --git a/src/renderer/src/hid/calibration/store.ts b/src/renderer/src/hid/calibration/store.ts new file mode 100644 index 0000000..4c3f45c --- /dev/null +++ b/src/renderer/src/hid/calibration/store.ts @@ -0,0 +1,79 @@ +// Axis calibration as main pushes it, and the dialog's intents (R3.4b: the calibration slice of the +// old single store, cut verbatim). The IPC adapter binds the intents and delivers the pushes. +import { create } from 'zustand' +import type { SkyhawkApi } from '@shared/app' +import { CALIBRATION_IDLE, type CalibrationState } from '@shared/hid/calibration' + +export interface CalibrationStoreState { + /** + * Axis calibration as main pushes it (`cal:workflow`): the device's stored calibration and the + * HID tab's badges and offer, and the calibration dialog while it is open. Main runs the + * workflow; the HID tab and the dialog only render this and send `calibrate` intents. + */ + calibration: CalibrationState + /** The calibration dialog's actions, sent to main. */ + calibrate: CalibrationIntents +} + +/** The calibration dialog's actions (R3.1): main runs the workflow and pushes `cal:workflow`. */ +export interface CalibrationIntents { + /** Open the dialog on `axis`; `review` expands the restore offer. */ + open(axis: number, review?: boolean): void + close(): void + selectAxis(axis: number): void + startCapture(): void + retry(): void + setSelfCentring(selfCentring: boolean): void + save(): void + restore(): void + deleteAxis(idx: number): void + setRestoreOpen(open: boolean): void + /** The HID tab mounted (`true`) or unmounted (`false`). */ + attach(on: boolean): void +} + +/** Main, as the IPC adapter hands it over; nothing until it binds. */ +let skyhawk: () => SkyhawkApi | undefined = () => undefined + +/** The IPC adapter's binding: every intent reads `window.skyhawk` through it, at call time. */ +export function bindCalibration(ref: () => SkyhawkApi | undefined): void { + skyhawk = ref +} + +/** The calibration intents, each a call to main. */ +function calibrationIntents(): CalibrationIntents { + const api = () => skyhawk() + return { + open: (axis, review) => void api()?.calOpen(axis, review), + close: () => void api()?.calClose(), + selectAxis: (axis) => void api()?.calSelectAxis(axis), + startCapture: () => void api()?.calStartCapture(), + retry: () => void api()?.calRetry(), + setSelfCentring: (selfCentring) => void api()?.calSetSelfCentring(selfCentring), + save: () => void api()?.calSave(), + restore: () => void api()?.calRestore(), + deleteAxis: (idx) => void api()?.calDeleteAxis(idx), + setRestoreOpen: (open) => void api()?.calSetRestoreOpen(open), + attach: (on) => void api()?.calAttach(on) + } +} + +export const useCalibrationStore = create(() => ({ + calibration: CALIBRATION_IDLE, + calibrate: calibrationIntents() +})) + +/** What main sends this slice; the IPC adapter calls each as its push or pull lands. */ +export const calibrationFromMain = { + /** + * The rehydrate bundle's `cal`. The device's report is only pushed when the port opens. A + * renderer reload builds a fresh store while the main-process session keeps running, so without + * carrying the calibration here the badges would stay blank until the next reconnect — forever, + * on a stable link. + */ + status: (cal: CalibrationState | undefined): void => + useCalibrationStore.setState({ calibration: cal ?? CALIBRATION_IDLE }), + + /** `cal:workflow`. */ + workflow: (c: CalibrationState): void => useCalibrationStore.setState({ calibration: c }) +} diff --git a/src/renderer/src/freshness.test.ts b/src/renderer/src/hid/hid-view.test.ts similarity index 87% rename from src/renderer/src/freshness.test.ts rename to src/renderer/src/hid/hid-view.test.ts index 0d77773..18b3cc5 100644 --- a/src/renderer/src/freshness.test.ts +++ b/src/renderer/src/hid/hid-view.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { freshness, FRESH_MS, RECENT_MS } from './freshness' +import type { DeviceState } from '@shared/gateway' +import { isBoardPresent } from '../../../main/gateway/link-state' +import { FRESH_MS, freshnessBand, RECENT_MS } from '../../../main/hid/hid-facts' +import { freshnessView } from './hid-view' + +/** The Reports card as the user sees it: main's verdicts (R3.3), coloured here. */ +const freshness = (state: DeviceState, ageMs: number) => + freshnessView(isBoardPresent(state), freshnessBand(ageMs)) const MIN = 60 * 1000 diff --git a/src/renderer/src/hid/hid-view.ts b/src/renderer/src/hid/hid-view.ts new file mode 100644 index 0000000..a5d3428 --- /dev/null +++ b/src/renderer/src/hid/hid-view.ts @@ -0,0 +1,27 @@ +import { HID_CONTROLS_BY_ID, HID_ID, HID_REPORT_LAYOUT } from '@shared/firmware' +import type { HidFreshness } from '@shared/hid' + +/** 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' +) + +export const HAT_DIRS = ['CTR', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] + +/** + * How recently the device last said anything, as a colour: the band main pushes on `hid:report`, + * or "no link" while `device:status` says the board is absent (R3.3: both decided in main — + * main/hid/hid-facts.ts and main/gateway/link-state.ts). Red is reserved for that one case: the + * board is not there, so nothing could arrive. + */ +const BAND = { + live: { cls: 'rate--live', label: 'live' }, + idle: { cls: 'rate--idle', label: 'idle' }, + quiet: { cls: 'rate--stale', label: 'quiet' } +} as const + +export function freshnessView(boardPresent: boolean, band: HidFreshness) { + if (!boardPresent) return { cls: 'rate--down', label: 'no link' } + return BAND[band] +} diff --git a/src/renderer/src/hid/index.ts b/src/renderer/src/hid/index.ts new file mode 100644 index 0000000..8552edf --- /dev/null +++ b/src/renderer/src/hid/index.ts @@ -0,0 +1,4 @@ +// hid (R3.4b): the HID report as main pushes it, and the live HID panel. +export { hidFromMain, useHidStore, type HidState } from './store' +export { HidPanel } from './HidPanel' +export { AXIS_LABELS } from './hid-view' diff --git a/src/renderer/src/hid/store.ts b/src/renderer/src/hid/store.ts new file mode 100644 index 0000000..f7eb699 --- /dev/null +++ b/src/renderer/src/hid/store.ts @@ -0,0 +1,53 @@ +// The HID report and what the firmware catalogues, as main pushes them (R3.4b: the hid slice of +// the old single store, cut verbatim). No intents: the IPC adapter only delivers the pushes. +import { create } from 'zustand' +import { HID_REPORT_LAYOUT } from '@shared/firmware' +import type { HidAvailability, HidFreshness, HidReport } from '@shared/hid' + +export interface HidState { + // HID live state + which report indices the firmware catalogues (rest dim) + axes: number[] + buttons: number[] + hats: number[] + hidRate: number + /** The report's age as main bands it (`hid:report`, R3.3). */ + hidBand: HidFreshness + /** Axis slots this Gateway declares, as main counts them (`hid:report`). */ + hidPresent: number + /** Per axis slot: shown live (catalogued and declared), as main decides it (`hid:report`). */ + axisLive: boolean[] + availHats: number[] + availButtons: number[] +} + +export const useHidStore = create(() => ({ + axes: Array(HID_REPORT_LAYOUT.axes.count).fill(0), + buttons: [], + hats: Array(HID_REPORT_LAYOUT.hats.count).fill(0), + availHats: [], + availButtons: [], + hidRate: 0, + // A report 0 ms old reads live; no slot is live until main says so (as before the catalogue). + hidBand: 'live', + hidPresent: 0, + axisLive: [] +})) + +/** What main sends this slice; the IPC adapter calls each as its push or pull lands. */ +export const hidFromMain = { + /** `hid:availability`. */ + availability: (a: HidAvailability): void => + useHidStore.setState({ availHats: a.hats, availButtons: a.buttons }), + + /** `hid:report`. */ + report: (h: HidReport): void => + useHidStore.setState({ + axes: h.axes, + buttons: h.buttons.flatMap((b, i) => (b ? [i] : [])), + hats: h.hats, + hidRate: h.rateHz, + hidBand: h.band, + hidPresent: h.present, + axisLive: h.axisLive + }) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index fc6517a..b64ad95 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,7 +2,7 @@ import React from 'react' import { createRoot } from 'react-dom/client' import '@fontsource-variable/saira/index.css' import '@fontsource-variable/jetbrains-mono/index.css' -import { App } from './App' +import { App } from './app' import './styles.css' const root = document.getElementById('root') diff --git a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx b/src/renderer/src/nodes/AdoptDialog.tsx similarity index 80% rename from src/renderer/src/tabs/cockpit/AdoptDialog.tsx rename to src/renderer/src/nodes/AdoptDialog.tsx index 5de7c5b..97ea1be 100644 --- a/src/renderer/src/tabs/cockpit/AdoptDialog.tsx +++ b/src/renderer/src/nodes/AdoptDialog.tsx @@ -1,22 +1,23 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { NodeMappingResult } from '@shared/ipc' -import type { NodeView } from '../../cockpit' +import { seenAgo } from '@shared/format' +import { + CONSOLE_OPTIONS, + type AdoptBadge, + type AdoptForm as FormValues, + type AdoptRow, + type NodeMappingResult, + type ZoneChoices +} from '@shared/nodes' import { - adoptability, - adoptEvidence, adoptPduChoices, - adoptQueue, adoptSpec, identState, keptQueue, nextSelection, - prefill, - registrySpecs, - seenAgo, - type AdoptForm as FormValues -} from '../../cockpit-adopt' -import { CONSOLE_OPTIONS } from '../../cockpit-panels' -import { useModalFocus } from '../../useModalFocus' + registrySpecs +} from './adopt-view' +import { useModalFocus } from '../app/kit' +import { useNodesStore } from './store' import { Dot } from './Dot' /** @@ -25,29 +26,37 @@ import { Dot } from './Dot' * while it's on the bus (#93 — no Ignore). Esc, the scrim and Close shut it; focus returns to * what opened it. `initial` selects a NODE_ID (a stray's Attention cog). */ -export function AdoptDialog(props: { views: NodeView[]; initial?: number; onClose: () => void }) { +export function AdoptDialog(props: { + /** Main's queue: new NODE_IDs, oldest first seen first. */ + rows: AdoptRow[] + zones: ZoneChoices + initial?: number + onClose: () => void +}) { const ref = useRef(null) useModalFocus(ref, props.onClose) - const live = useMemo(() => adoptQueue(props.views), [props.views]) + const live = props.rows // Rows this dialog has shown, so one that drops (power-cycled to identify it) stays listed. - const shown = useRef(new Map()) - for (const v of live) shown.current.set(v.entry.nodeId, v) + const shown = useRef(new Map()) + for (const r of live) shown.current.set(r.view.entry.nodeId, r) const [done, setDone] = useState>(new Set()) const queue = useMemo(() => keptQueue(live, shown.current, done), [live, done]) - const ids = queue.map((v) => v.entry.nodeId) + const ids = queue.map((r) => r.view.entry.nodeId) // The queue as the dialog showed it, so "next" keeps its place as nodes leave it. const order = useRef(ids) for (const id of ids) if (!order.current.includes(id)) order.current.push(id) const [sel, setSel] = useState( props.initial !== undefined && ids.includes(props.initial) ? props.initial : (ids[0] ?? null) ) - const current = queue.find((v) => v.entry.nodeId === sel) ?? queue[0] + const current = queue.find((r) => r.view.entry.nodeId === sel) ?? queue[0] // NODE_IDs that dropped off the bus while the dialog was open (power-cycle to identify). const [dropped, setDropped] = useState>(new Set()) useEffect(() => { - const off = queue.filter((v) => v.entry.availability !== 'online').map((v) => v.entry.nodeId) + const off = queue + .filter((r) => r.view.entry.availability !== 'online') + .map((r) => r.view.entry.nodeId) if (off.some((id) => !dropped.has(id))) setDropped(new Set([...dropped, ...off])) }, [queue, dropped]) @@ -61,26 +70,21 @@ export function AdoptDialog(props: { views: NodeView[]; initial?: number; onClos // Each board's form, as you left it, while the dialog is open: switching rows keeps it, and // "Add all" takes it (None included) over the NODE_IDS.md defaults. const [edits, setEdits] = useState>(new Map()) - const bulk = registrySpecs(queue, props.views, edits) + const bulk = registrySpecs(queue, edits) const [bulkError, setBulkError] = useState('') + // Main adopts them one at a time and stops at the first it refuses (R3.2b); the ones it added + // leave the queue. const addAll = async () => { setBulkError('') - const added: number[] = [] - for (const b of bulk) { - const r = await window.skyhawk?.adoptNode(b.nodeId, b.spec) - if (r && !r.ok) { - setDone(new Set([...done, ...added])) - return setBulkError(`NODE_ID ${b.nodeId}: ${r.error}`) - } - added.push(b.nodeId) - } - setDone(new Set([...done, ...added])) + const r = await useNodesStore.getState().addAllNodes(bulk) + setDone(new Set([...done, ...(r ? r.done : bulk.map((b) => b.nodeId))])) + if (r && !r.ok) setBulkError(`NODE_ID ${r.nodeId}: ${r.error}`) } if (!current) return null const move = (how: 'done' | 'later') => { - if (how === 'done') setDone(new Set([...done, current.entry.nodeId])) - const next = nextSelection(order.current, ids, current.entry.nodeId, how) + if (how === 'done') setDone(new Set([...done, current.view.entry.nodeId])) + const next = nextSelection(order.current, ids, current.view.entry.nodeId, how) if (next === null && how === 'later') return props.onClose() setSel(next) } @@ -113,8 +117,9 @@ export function AdoptDialog(props: { views: NodeView[]; initial?: number; onClos
- {queue.map((v) => { - const on = v.entry.nodeId === current.entry.nodeId + {queue.map((r) => { + const v = r.view + const on = v.entry.nodeId === current.view.entry.nodeId return (
{v.entry.registry @@ -138,13 +143,13 @@ export function AdoptDialog(props: { views: NodeView[]; initial?: number; onClos })}
setEdits(new Map(edits).set(current.entry.nodeId, f))} + key={current.view.entry.nodeId} + row={current} + zones={props.zones} + dropped={dropped.has(current.view.entry.nodeId)} + position={`${ids.indexOf(current.view.entry.nodeId) + 1} of ${ids.length}`} + form={edits.get(current.view.entry.nodeId)} + onForm={(f) => setEdits(new Map(edits).set(current.view.entry.nodeId, f))} bulk={bulk.length > 1 ? bulk.length : 0} bulkError={bulkError} onAddAll={() => void addAll()} @@ -158,8 +163,9 @@ export function AdoptDialog(props: { views: NodeView[]; initial?: number; onClos } function AdoptForm(props: { - view: NodeView - views: NodeView[] + /** Main's row: what it is, what it starts as, what it is seen doing. */ + row: AdoptRow + zones: ZoneChoices dropped: boolean position: string /** Registry panel groups waiting (2 or more): offer "Add all N panel groups from NODE_IDS.md". */ @@ -172,23 +178,22 @@ function AdoptForm(props: { onDone: () => void onLater: () => void }) { - const e = props.view.entry + const { row } = props + const e = row.view.entry const id = e.nodeId // Only a node identified as a PDU or a panel group gets the form; anything else is shown as-is. - const able = adoptability(e) - const kind = able.kind - const form = - props.form ?? (kind ? prefill(e, kind) : prefill({ ...e, registry: undefined }, 'panel')) + const kind = row.kind + const form = props.form ?? row.prefill const { name, console: con, pduId } = form const setName = (v: string) => props.onForm({ ...form, name: v }) const setPduId = (v: number | null | '') => props.onForm({ ...form, pduId: v }) const [busy, setBusy] = useState(false) const [error, setError] = useState('') - const ev = adoptEvidence(props.view) + const ev = row.evidence // #93: the PDU is explicit — one in your cockpit on another console is "(other console)"; a // default PDU not in your cockpit yet stays a choice, "(not added)", and the board moves under it // once that PDU is added. - const { zones, pending } = adoptPduChoices(props.views, con, pduId, e) + const { zones, pending } = adoptPduChoices(props.zones, row, con, pduId) const spec = kind ? adoptSpec(kind, { name, console: con, pduId }) : null const ident = identState(e.availability === 'online', props.dropped) @@ -222,7 +227,7 @@ function AdoptForm(props: {
{!kind ? (
- {able.note} + {row.note}
) : ( <> @@ -336,7 +341,7 @@ function AdoptForm(props: { @@ -345,7 +350,7 @@ function AdoptForm(props: { ) } -const BADGE_CLASS: Record['badge'], string> = { +const BADGE_CLASS: Record = { PDU: 'kind--pdu', 'PANEL GROUP': 'kind--pg', IDENTIFYING: 'kind--wait', @@ -353,6 +358,6 @@ const BADGE_CLASS: Record['badge'], string> = { 'UNKNOWN TYPE': 'kind--no' } -function Badge({ badge }: { badge: ReturnType['badge'] }) { +function Badge({ badge }: { badge: AdoptBadge }) { return {badge} } diff --git a/src/renderer/src/tabs/Cockpit.tsx b/src/renderer/src/nodes/Cockpit.tsx similarity index 76% rename from src/renderer/src/tabs/Cockpit.tsx rename to src/renderer/src/nodes/Cockpit.tsx index a8aa301..5f30207 100644 --- a/src/renderer/src/tabs/Cockpit.tsx +++ b/src/renderer/src/nodes/Cockpit.tsx @@ -1,56 +1,27 @@ 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 { useStore } from '../store' -import { strayFlag, strayNew } from '../cockpit-adopt' -import { gatewayView, type GatewayView } from '../gateway-view' -import { - ATTENTION_SHOWN, - STAGE_W, - STATE_SYMBOL, - attentionItems, - canErrorsSub, - canErrorsWorst, - fmtPct, - headroom, - healthBand, - newCount, - nodeViews, - planLayout, - psuName, - tileMatches, - type AttentionItem, - type HealthBand, - type Headroom, - type NodeView, - type PlanGroup, - type PlanLayout -} from '../cockpit' -import { BoardEvents } from './cockpit/BoardEvents' -import { Dot } from './cockpit/Dot' -import { AdoptDialog } from './cockpit/AdoptDialog' -import { NodePanel, PduPanel } from './cockpit/SidePanels' +import type { PsuView } from '@shared/config' +import type { CockpitAttention, CockpitBand, NodeView, PsuLimits } from '@shared/nodes' +import { fmtPct, hhmm, voltsValue } from '@shared/format' +import { Cog, useNav } from '../app/kit' +import { BoardEvents } from '../events' +import { useGatewayStore } from '../gateway' +import { useNodesStore } from './store' +import { STATE_SYMBOL, tileMatches } from './node-view' +import { STAGE_W, planLayout, type PlanGroup, type PlanLayout } from './plan-layout' +import { headroom, type Headroom } from './band-view' +import { Dot } from './Dot' +import { AdoptDialog } from './AdoptDialog' +import { NodePanel, PduPanel } from './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) - const peakW = useStore((s) => s.nodesPeakW) - const live = useStore((s) => isLinkUp(s.deviceState)) - const deviceGateway = useStore((s) => s.deviceGateway) - /** The Gateway's own problem (#94): an Attention item, never a tile on the plan. */ - const gateway = gatewayView(deviceGateway, live).attention - const linkDownAt = useStore((s) => s.linkDownAt) - const psu = useStore((s) => s.psu) - const limits = useMemo(() => effectivePsuLimits(psu), [psu]) +/** The Cockpit tab. `psuView` is the PSU setting as main resolves it (the app hands it in). */ +export function Cockpit({ psuView }: { psuView: PsuView }) { + /** Everything the tab decides, as main built it (cockpit:snapshot, R3.2). */ + const snap = useNodesStore((s) => s.cockpit) + const live = snap.live + const linkDownAt = useGatewayStore((s) => s.linkDownAt) + const { limits, name: psuName } = psuView const [query, setQuery] = useState('') const [issuesOnly, setIssuesOnly] = useState(false) @@ -60,43 +31,23 @@ export function Cockpit() { const [open, setOpen] = useState(null) const [adopting, setAdopting] = useState(false) /** A node another tab asked to open here (Connection's "›"). */ - const pendingOpen = useStore((s) => s.cockpitOpen) - const set = useStore((s) => s.set) + const pendingOpen = useNav((s) => s.cockpitOpen) + const set = useNav((s) => s.set) useEffect(() => { if (pendingOpen === undefined) return setOpen(pendingOpen) set({ cockpitOpen: undefined }) }, [pendingOpen, set]) /** The Overview's new-devices notice asked for the Adopt dialog. */ - const pendingAdopt = useStore((s) => s.cockpitAdopt) + const pendingAdopt = useNav((s) => s.cockpitAdopt) useEffect(() => { if (!pendingAdopt) return setAdopting(true) set({ cockpitAdopt: undefined }) }, [pendingAdopt, set]) - const views = useMemo(() => (model ? nodeViews(model, telemetry) : []), [model, telemetry]) - const outages = useMemo(() => model?.outages ?? [], [model]) - const layout = useMemo(() => planLayout(views, outages), [views, outages]) - // New boards that can't be adopted are active on the bus, so they are warnings (#93) — once an - // unidentified one has had IDENTIFY_WAIT_MS to report its kind, so a normal board never flashes. - const [now, setNow] = useState(() => Date.now()) - const stray = useMemo(() => strayNew(views, now), [views, now]) - useEffect(() => { - if (stray.nextAt === undefined) return - const t = setTimeout(() => setNow(Date.now()), Math.max(0, stray.nextAt - Date.now()) + 50) - return () => clearTimeout(t) - }, [stray.nextAt]) - const strays = useMemo( - () => - live ? stray.strays.map((v) => ({ nodeId: v.entry.nodeId, flag: strayFlag(v.entry) })) : [], - [live, stray] - ) - const band = useMemo(() => healthBand(views, strays.length), [views, strays]) - const items = useMemo( - () => (live ? attentionItems(views, outages, strays) : []), - [live, views, outages, strays] - ) + const { views } = snap + const layout = useMemo(() => planLayout(snap.plan), [snap.plan]) /** The Adopt dialog opened on this NODE_ID (a stray's cog), else on the first in its queue. */ const [adoptAt, setAdoptAt] = useState() const openView = open === null ? undefined : views.find((v) => v.entry.nodeId === open) @@ -113,7 +64,7 @@ export function Cockpit() { return (
- +
@@ -122,7 +73,7 @@ export function Cockpit() {