Repository navigation
refactor: structure rewrite — one owner per rule, topic namespaces, presentation-only renderer - #129
Merged
Merged
Conversation
R0.1 of the structure rewrite (#101). Tooling only: no production code, UI or behaviour changes. ## What it adds - **`.nvmrc`** = `24`. Consistent with `engines.node >=24` and `node-version: 24` in CI. - **`dependency-cruiser@18.5.0`** (exact), started from `depcruise --init oneshot`. The default rule set is replaced by the boundary rules; the scaffold's options are kept. - **`.dependency-cruiser.cjs`**: the arch-phase0-v2 §5 rules, written for the **target** layout (`src/main/<topic>/`, `src/renderer/src/<topic>/`, `src/shared/<topic>/{index,ipc}.ts`, leaves `src/shared/firmware/`, `src/shared/format/`, `src/renderer/src/app/kit/`): - process: `P1-renderer-not-main`, `P2-main-not-renderer`, `P3-shared-is-a-leaf`, `P4-renderer-imports-contracts-only`, `P6-electron-only-in-app` - contracts and leaves: `C1-contracts-import-contracts`, `L-firmware-leaf`, `L-format-leaf`, `L-kit-leaf` - topic directions, one rule per topic and per process (`topic-main-<t>`, `topic-renderer-<t>`): app→all; nodes→gateway, events; dcs→gateway, events; hid→gateway; hid/calibration→hid, gateway; gateway→events; config→nodes, hid/calibration; events→nothing. Every topic may also import firmware, format, kit and the shared contracts. - deep imports: `T2-main-no-deep-topic-import`, `T2-renderer-no-deep-topic-import` (across topics, only `<topic>/index.ts`) - cycles: `C2-no-circular`, type-only imports included (`tsPreCompilationDeps: true`) - tests, `src/test-fixtures/` and `src/renderer/test/` are excluded. The generated `src/main/reference/*.generated.ts` files stay in: they import nothing, so they can't originate a violation, and excluding them would hide edges *into* them. - aliases resolve through `tsconfig.json`, which carries both `@shared/*` and `@renderer/*`. Main and preload use only `@shared`, which resolves the same way. - **`.dependency-cruiser.severity.json`**: the only place a rule family flips from `warn` to `error`. Every key is `warn` in this PR. - **ESLint P5** in `eslint.config.mjs`: `no-restricted-syntax` on `window.skyhawk` member access in `src/renderer/**`, ignoring `src/renderer/src/app/ipc-adapter.ts` and `src/renderer/test/**`, severity `warn`. `npm run lint` has no `--max-warnings`, so warnings don't fail CI. - **`npm run lint:deps`** (`depcruise src --config .dependency-cruiser.cjs --output-type err-long`), run as a new step in the existing **Lint · Typecheck · Test** job (job name unchanged). Warn-severity findings exit 0. ## Severity keys and flip plan | Key | Rules | Flips to `error` in | |---|---|---| | `firmwareLeaf` | L-firmware-leaf | R1.1 | | `process` | P3 | R1.3 | | `contracts` | C1 | R1.3 | | `cycles` | C2 | R1.3 | | `formatLeaf` | L-format-leaf | R1.3 | | `crossProcess` | P1, P2 | R1.4 | | `electron` | P6 | R1.4 | | `topicsMain` | topic-main-* | R2.3 | | `deepMain` | T2-main | R2.3 | | `rendererShared` | P4 | R3.4 | | `kitLeaf` | L-kit-leaf | R3.4 | | `topicsRenderer` | topic-renderer-* | R3.4 | | `deepRenderer` | T2-renderer | R3.4 | | ESLint P5 | `window.skyhawk` | R3.4 (in `eslint.config.mjs`) | ## Today's counts (warn-only) The rules target folders that don't exist yet, so today's counts are not the §5.4 planning estimate (that estimate mapped flat files to their future topics). `npm run lint:deps`: **65 warnings, 0 errors**, 97 modules, 298 dependencies. | Rule | Today | Notes | |---|---|---| | P1, P2, P3, C1 | 0 | P3/C1 match on `src/shared/<topic>/` paths, which don't exist yet | | P4-renderer-imports-contracts-only | 53 edges from 28 files | 36 to model files (node-rules 11, psu 4, node-telem 4, nodes 3, node-mapping 3, events 3, node-kind 2, gateway 2, axis-capture 2, config-file 1, cal-cache 1), plus 17 to the flat `shared/ipc.ts`, which isn't at a contract path yet | | P6-electron-only-in-app | 5 files | main/index.ts, debug.ts, settings.ts, node-mapping.ts, cal-cache.ts | | C2-no-circular | 6 | node-kind ↔ node-mapping; events ↔ ipc; config-file ↔ ipc; cal-cache ↔ ipc; config-file → cal-cache → ipc → config-file; gateway → events → ipc → gateway | | T2-renderer-no-deep-topic-import | 1 | tabs/Overview.tsx → assets/logo.png (today's `tabs/` and `assets/` folders read as topics) | | topic-*, T2-main, L-* | 0 | no topic or leaf folders yet | | ESLint P5 | 32 in 10 files | store.ts 16, SidePanels 4, CalibrationDialog 2, AdoptDialog 2, ConfigurationSection 2, ResetDialog 2, AboutSection 1, CockpitSection 1, ImportDialog 1, useNodeModel 1 | ## Proof each rule family fires Throwaway files in the target layout (deleted before commit) produced: ``` warn P1-renderer-not-main: src/renderer/src/zzP1.ts → src/main/debug.ts warn P2-main-not-renderer: src/main/zzP2.ts → src/renderer/src/freshness.ts warn P3-shared-is-a-leaf: src/shared/zz/ipc.ts → src/main/debug.ts warn C1-contracts-import-contracts: src/shared/zz/ipc.ts → src/main/debug.ts warn C1-contracts-import-contracts: src/shared/zz/index.ts → src/shared/psu.ts warn L-firmware-leaf: src/shared/firmware/zz.ts → src/shared/format/zz.ts warn L-format-leaf: src/shared/format/zz.ts → src/shared/events.ts warn L-kit-leaf: src/renderer/src/app/kit/zz.ts → src/renderer/src/freshness.ts warn topic-main-events: src/main/events/zz.ts → src/main/nodes/zz.ts warn topic-main-hid: src/main/hid/zz.ts → src/main/hid/calibration/index.ts warn topic-renderer-gateway: src/renderer/src/gateway/zz.ts → src/renderer/src/app/zz.ts warn T2-main-no-deep-topic-import: src/main/nodes/zz.ts → src/main/gateway/deep.ts warn T2-renderer-no-deep-topic-import: src/renderer/src/app/zz.ts → src/renderer/src/nodes/deep.ts warn C2-no-circular: src/main/zzcycleA.ts → src/main/zzcycleB.ts → src/main/zzcycleA.ts ``` P4 and P6 fire on today's tree (above). Checks that should stay silent did: nodes → `gateway/index.ts` (allowed direction, entry import) and hid/calibration → hid. The `src/renderer/src/app/zz.ts` import was written as `@renderer/nodes/deep`, and it resolved to `src/renderer/src/nodes/deep.ts`. - **Aliases resolve:** on today's tree, every `@shared/...` import resolves to `src/shared/...` (main 37 edges, preload 4, renderer 54), with 0 unresolvable modules. - **Severity wiring:** with `firmwareLeaf` set to `error`, the L-firmware-leaf finding was reported as `error` and `lint:deps` exited 1. The change was reverted before commit. ## Checks `npm run lint` (0 errors, 32 P5 warnings), `npm run typecheck`, `npx vitest run` (40 files, 698 tests) and `npm run lint:deps` all exit 0. Also in the lockfile: adding dependency-cruiser bumped three hoisted dev-only transitive packages (acorn 8.17→8.18, is-core-module 2.16.2→2.17.0, semver 7.8.4→7.8.5). Refs #97
Phase 0, R0.4 of the structure rewrite (#101). Tests only: no behaviour or UI change. The one production edit removes the test-only export `DEFAULT_APP_SETTINGS` from `shared/config-file.ts`. Its only importer was `config-file.test.ts`, and it now lives in the fixtures. ## Seam tests (new files) | Row | Seam | File | What it pins | |---|---|---|---| | B8 | HUB | `src/main/node-hub.seam.test.ts` | Forget removes the whole entry (name, console, power zone, hidden flag, last seen). If the board reports again, it is `new`: a registry board comes back pre-filled from NODE_IDS.md, an adopted one comes back blank. It is never written. | | B10 (HUB half) | HUB | `src/main/node-hub.seam.test.ts` | Hiding a PDU keeps its boards' links, both the explicit `pduId` in the file and the default link in the model. When the PDU reports, its hidden flag clears and the group is whole again. | | B33 | HUB | `src/main/node-hub.seam.test.ts` | A board identified only by PDU telemetry gets no "new on the bus" row when the pages arrive. It gets one row, stamped with that time, at its next status row, and only once. | | B13 | SESSION | `src/main/session.seam.test.ts` | Disconnect, unplug (port close) and app quit each write `lastSeen` = the time of the close to node-mapping.json. | | B29 | SESSION | `src/main/session.seam.test.ts` | The v2-Gateway warning is raised once per link, and raised again after a reopen. **See the discrepancy below.** | | B17 | FILE | `src/main/node-mapping.seam.test.ts` | v2 / v1 / unversioned files are upgraded on read. The ignore list is dropped. Seen, placed, renamed and hidden boards become `added`. Adopted entries are kept but not marked `added`. The save writes v3, and from then on the file is read as written. | | B94 | XFER | `src/main/config-transfer.seam.test.ts` | Non-object and unknown sections are skipped and listed. A file with no readable section is refused. Each known section goes through its own store's sanitizer: bad fields are dropped and good ones kept. | **Brittle test (T16):** the `session.test.ts:439–454` test spied on `session.link.roster.reset` / `session.link.nodes.reset`. It is replaced by B31 in `session.seam.test.ts`, which goes through the harness and checks only outcomes: - After relay Start / Stop / Start, the stray node is still in the `nodes:model` push and in `nodes:get`, and so is its telemetry. - After a port reopen, the next push and the snapshot have dropped them. No test spies on `session.link.*` any more. ## Services harness: `src/main/test-harness/services.ts` `makeServices({ serial, transport, clock, paths })` launches main the way `index.ts` does: settings from `config.json`, then `session.open()`. It runs against fakes for the serial port, the DCS transport, HID and the clock (vitest fake timers), with a temporary `<userData>`. `node-mapping`, `settings` and `cal-cache` are real, so they write files there. Options: - `serial: { portPath, serialNumber }` - `transport: { connectOnStart }` - `clock: { start }` - `paths: { files }`: seeds `<userData>` before launch. Tests only see: - `pushes` / `pushesOf(ch)` / `lastPush(ch)` - `invoke(CTRL.x, ...args)`: the same handlers `registerIpc()` wires, typed per channel - `serial.open() / close() / receive() / notFound() / fail() / written()` - `transport.export() / connected() / fail() / sent()` - `clock.advance() / now()` - `files.json() / text() / list()` - `closeWindow()`, `quit()`, `dispose()`. Cleanup is automatic via `onTestFinished`. Only the harness touches `Session` / `session.link`. When Session becomes `AppServices` in R2.3, only this file changes. ## Fixtures: `src/test-fixtures/` - `nodes.ts` holds the shared builders: - `nodeStatus()`, `modelNode()` and `nodeModel()` (T11) - `statusRow()` / `statusLine()` for v3 `_NODE_STATUS` rows - These builders replace the copies in ten test files: connection-summary, overview, cockpit, cockpit-panels, cockpit-adopt, gateway-view, nodes, events, node-rules and gateway. `session.test.ts` now uses `statusLine` from the fixtures too. Where a file's defaults differed, it passes those fields explicitly, so every test gets exactly the inputs it had before. No assertion changed. - `config.ts` holds `DEFAULT_APP_SETTINGS`. - `src/test-fixtures` is added to `include` in both `tsconfig.json` and `tsconfig.node.json`. Imports are relative, so no new path alias. ## Contract vs code: discrepancies (pinned as the code behaves today) - **B29.** The contract says the v2 warning repeats once after a port reopen (2 rows in total). Today, `DeviceLink` does reset `legacyNodesWarned` on open and raises the warning again: the stream-stats error count goes 1 → 2. But `Session.logError` dedupes per tag (`lastErr`), and nothing ever clears `'NODES'`, so the identical second message never reaches the event log. **The event log keeps 1 row.** The test pins both halves and carries a `// contract B29 says …; today the code does …` comment. - **B17 (not pinned).** The legacy `added` upgrade marks *any* non-adopted entry with an override, not only registry entries (M9 → F28, already pinned by `node-mapping.test.ts:109`). It's a FIX-track item, so B17 deliberately sticks to registry ids and adopted entries. ## Fail-check Each seam test was seen to go red against a deliberately broken production line, then restored: | Test | What was broken | |---|---| | B8 | `forgetNode` made a no-op | | B10 | `clearReportedHidden` made a no-op; separately, `setHidden` made to unlink the PDU's boards | | B33 | telemetry made to emit the status events itself | | B13 | `linkClosed()` removed from `disconnect`, `onClose` and `shutdown` (one at a time) | | B29 | the `legacyNodesWarned` reset removed from `onOpen` | | B31 | `roster` / `nodes` reset added to relay Start; separately, removed from `onOpen` | | B17 | the legacy `added` derivation removed; separately, v3 treated as legacy | | B94 | the non-object skip removed; separately, the `nodeMapping` sanitizer bypassed | ## Checks `npm run lint`, `npm run typecheck` and `npx vitest run` all pass: **44 files, 713 tests**. The baseline was 40 / 698: one brittle test removed, 16 tests added. Refs #97
R0.2 of the structure rewrite: a jsdom component harness, plus the first 8 tier-1 component tests and the 15 phase-0 behaviour probes. **Test tooling and tests only.** No production code changes, so no UI or behaviour changes. ## Tooling - devDependencies, pinned exactly: `jsdom@29.1.1` (not 30.x, which needs Node ^24.15), `@testing-library/react@16.3.3`, `@testing-library/dom@10.4.2`, `@testing-library/user-event@14.6.7`, `@testing-library/jest-dom@7.0.1`. - `vitest.config.mts` now uses `test.projects`: - **node**: `src/**/*.test.ts`, in the node environment with the `@shared` alias. Every test that existed before runs here, unchanged. - **dom**: `src/renderer/**/*.test.tsx`, in jsdom with `@vitejs/plugin-react`, the `@shared` and `@renderer` aliases, and the setup file. `__APP_VERSION__` is defined the way `electron.vite.config.ts` defines it, from `package.json`'s version. - `.dependency-cruiser.cjs`: added `'^src/main/test-harness/'` to `options.exclude.path`. The test-only services harness from #103 is no longer counted as a main topic. No rule or severity changed. ## Harness API (`src/renderer/test/`) | File | What it gives a test | |---|---| | `setup.ts` | jest-dom matchers. Before each test, a fresh store (`resetStore()`) and a fresh `window.skyhawk` mock. After each, `cleanup()` and real timers. It also polyfills a no-op `ResizeObserver`, and shims `globalThis.jest.advanceTimersByTime` so that Testing Library's async wrapper advances Vitest fake timers (without it, `user-event` hangs under `vi.useFakeTimers()`). | | `skyhawk-mock.ts` | `installSkyhawkMock()` builds a typed `{ [K in keyof SkyhawkApi]: Mock<…> }`, so an IPC change breaks it at typecheck. `skyhawk()` returns the current mock. Intents are `vi.fn`s with harmless defaults, so a test can assert them (`sky.forgetNode`) or override them (`mockResolvedValueOnce`). The mock also has `sky.scenario` and `sky.emitScenario(s)`. | | `scenarios.ts` | Scenario builders: `link(state)`, `connected(gateway?)`, `gateway(row)` / `gatewayNotResponding()` (these call main's own `gatewayStatus()`), `panel`, `pdu`, `newBoard`, `offline`, `notSeen`, `noStatusYet`, `outage`, `model`. They are built on `src/test-fixtures/nodes.ts`. | | `render.ts` | `startBridge(scenario)` subscribes the store the way `App` does and unsubscribes when the test ends, which removes the "one `initBridge` per file" snag. `renderScenario(ui, scenario)` returns `{ sky, user, …render }`. Under fake timers, `user` runs its delays on the fake clock. | | `store-reset.ts` | `resetStore()` restores the store's initial state. | ### Mock design rule (for R3) Tests never set store fields and never push channels by hand. A test describes main's state as a `Scenario` (device link, relay, node model, telemetry, config, events, cal, HID, aircraft, registry) and feeds it through one path, `emitScenario()`: - it keeps the scenario as main's current state, so the pulls (`getStatus`, `getNodes`, `getConfig`, `getEvents`, `getNodeRegistry`) answer from it, and - it pushes each part on today's channel (`device:status`, `relay:state`, `nodes:model`, `nodes:telemetry`, `events:batch`, `cal:data`, `hid:report`, `aircraft:changed`). When R3 moves a decision into main and adds a push (`cockpit:snapshot`, `cal:workflow`), `emitScenario` derives that push by calling main's pure builder on the same inputs. The change stays inside the mock, and the T1–T35 scenarios and assertions don't change. The file's header comment says the same. ## Tests T1–T8 | # | File | What it pins | Rows | |---|---|---|---| | T1 | `t1-cockpit-attention-strays` | With fake timers: an unidentified new board is nothing at t0, then "!NODE_ID 9 · UNIDENTIFIED · can’t be adopted · #9" after 5 s, and the band reads "1 warning". SECOND GATEWAY and UNKNOWN BOARD TYPE show at once. With the link down there are no strays and the band shows "/ 0". A stray's cog opens Adopt with that id selected. The grace counts from `firstSeenAt`. The Overview ignores the stray. | B66, B65, X1, F26 | | T2 | `t2-cockpit-attention-order` | Gateway first ("✕ Gateway / Bus-off"), then the outage "OFFLINE · 1 node affected", then offline and warnings by NODE_ID, with "+N more". The "The only node on this PDU…" hint. A Gateway warning shows "!", a healthy Gateway shows no item. The Gateway cog opens the Device popover. The outage cog opens the PDU panel, a board cog opens the board panel. "Show all on map" turns Issues on. The empty states: "Nothing needs attention." and "No live node status — connect the Gateway." | B65, B42, B37 | | T3 | `t3-overview-tile-banners` | Tile "1 / 5" with ✕ and "nodes up · — W · 3 PDUs". The banner "4 offline (PDU_Right + 1 node, Center_Armament, PDU_Center)". `attentionBanner: false` hides the banner. A Gateway bus-off is a red alert placed before the banner, and its Details opens the popover. A Gateway warning gives no alert. A stray isn't counted. The counts match the Cockpit band. | B58, B59, B42, X1 | | T4 | `t4-overview-new-devices` | Registry board: "…from NODE_IDS.md, not added to your cockpit yet." Unregistered board: "…not in NODE_IDS.md." A mix gives "— 1 from NODE_IDS.md". Unidentified and non-adoptable boards aren't mentioned. Hidden while the link is down. "Open Cockpit →" opens the Cockpit tab with Adopt on that board. | B60 | | T5 | `t5-connection-nodes-card` | Counts match the Cockpit band. Rows come in Attention order, with symbol, chip and meta ("#4 · 1 node affected · offline since 21:11:18", "not seen this session · last seen 14 Oct, 21:14", "no power zone"). Footer "+1 more · 1 no status yet · healthy nodes hidden". Refresh calls `refreshNodes`, and is disabled with the "Device link released…" hint while the link is down. "›" opens the Cockpit on that board. | B63, B14, B37, X2 | | T6 | `t6-device-popover` | Each of the 6 `DeviceState`s: label, title, line and button (Connect / Retry / Disconnect call the mock). The port only in the tooltip, plus the "busy" line. Health "OK · 42 °C". A warning shows "!" and its note. "Not responding" replaces the title and line, with no Health row. Nothing about the Gateway while the link is down. Esc and the scrim close it, and focus returns to the indicator. "Connection tab →". | B44, B42, B40 | | T7 | `t7-board-footer-panel` | `offline`: Forget and Hide are enabled, with the "waits in New on the bus…" note. `online` and `unknown` disable them, with their reasons. Reset is greyed at the defaults and calls `resetNode` when the board differs. An adopted board has no Reset and the "shows as new" note. Forget and Hide call main, the panel closes on success, and a refusal shows the error. | B73, X4 | | T8 | `t8-board-footer-pdu` | "Its 2 boards move to No power zone… Hide keeps the links". The fed count covers visible boards only, not the hidden one. The note when the PDU feeds nothing. Online is disabled. Hide calls `hideNode(4, true)` and Forget calls `forgetNode(4)`. Reset covers "name and console" only. | B73, B9, B10, X8 | Each describe title lists its rows. ## Probes The 15 probes from the contract's scratch copy pass on this branch. Their assertions are unchanged; only imports moved. They are split by process, so main never imports renderer code: | File | Project | Probes | |---|---|---| | `src/main/phase0-probes.test.ts` | node | 11 that use main's `NodeHub` or `@shared/*` only: C1, M1/C3, M3, C4, M7, M7 contrast, G1, D7, D3, #95 bug 1, #95 bug 3 | | `src/renderer/src/phase0-probes.test.ts` | node (a `.ts` helper test, like the existing `src/renderer/src/*.test.ts`) | 4 that call the renderer helpers: D1 conflict, S6, C6, #95 bug 2 | `DEFAULT_APP_SETTINGS` now comes from `src/test-fixtures/config` (it moved there in #103). Neither tsconfig changes. ## Discrepancies: contract wording vs today (pinned today, commented in the test) - **T6 port line.** The contract says the popover shows a port line while connected and drops it once the Gateway reports health. Today the popover has no port line while connected, with or without health. The port is in the indicator's tooltip (`Gateway connected · <port>`) and on the Connection tab. Pinned as is. - **T6 gold vs red "!".** The colour is CSS-only, so it isn't asserted (the brief rules out CSS checks). The tests assert the "!" and the tooltip. - **T2 "Gateway · Bus-off".** On screen the item reads "Gateway" over "Bus-off" and has no " · #id". Pinned as rendered. - Deliberate pins of today's conflicts, each with a `// contract says …; today …` comment: X1 (strays only on the Cockpit), F26 (the grace counts from `firstSeenAt`), X2 (Connection meta names a PDU on the bus that isn't added), X4 (the footer needs `offline`, while main accepts `unknown`), X8 (the fed count is visible boards only). ## Fail-checks For 22 mutations, one behaviour was broken in production code locally, the matching test file was run, then the change was reverted with `git checkout`. Every mutation turned at least one test red. Examples: | T | Broken locally | Red | |---|---|---| | T1 | `IDENTIFY_WAIT_MS` 5000→0 · "SECOND GATEWAY" text · strays not gated on the link | t0 / bridge / link-down tests | | T2 | rank order swapped · `ATTENTION_SHOWN` 3→4 · Gateway cog no-op | order (2), cap (3), cog (1) | | T3 | `BANNER_NAMES` 3→2 · banner ignores the setting · "no nodes known" text | 1 each | | T4 | notice shown while the link is down · registry detail text | 1 each | | T5 | "offline since" text · Refresh always enabled · footer separator | 1 each | | T6 | Gateway shown while the link is down · Retry label · Esc doesn't refocus | 1 / 2 / 1 | | T7 | `unknown` treated as offline · Hide sends `false` · adopted note | 1 each | | T8 | fed count +1 · PDU Hide tooltip | 3 / 1 | The first run of the T1 link-down mutation stayed green, because Attention also hides items while the link is down. I added a band-count assertion (`/ 0`), and it now goes red. ## Checks - `npx vitest run`: **54 files / 791 tests**. That's 44 files / 713 tests as before, plus 2 probe files / 15 probes, plus 8 dom files / 63 tests. - `npm run lint`: 0 errors (32 warnings, unchanged). `npm run lint:deps`: 0 errors (65 warnings, unchanged). `npm run typecheck`: clean. `npm run build`: OK locally. Refs #97
Second half of the tier-1 component tests from the phase-0 plan: T9–T16. Tests only, no production change. Uses the R0.2 harness as is (`renderScenario` / `startBridge`, scenarios through `emitScenario`, intents checked on the mock). **Total: 849 tests in 62 files** (was 791 in 54; +58). Lint 0 errors (32 warnings, unchanged), lint:deps 0 errors (65 warnings, unchanged), typecheck clean. ## What each test pins | # | File | What it pins | Rows | |---|---|---|---| | T9 | `t9-cockpit-new-on-bus` | "New on the bus · N" + "not linked yet" + Adopt… for identified new boards (PDUs counted); none for identifying/bridge; **still shown after the link drops (R5 today)**; Adopt… opens the dialog on the first in its queue | B68, F12, X7 | | T10 | `t10-adopt-dialog-queue-form` | Queue order (first seen, then NODE_ID) and badges PANEL GROUP / PDU / IDENTIFYING / BRIDGE / UNKNOWN TYPE; registry pre-fill + "Add to cockpit" → `adoptNode` spec; unlisted panel: Adopt disabled until name + console + Powered by; PDU form has no Powered by; non-adoptable rows show their note, no form; Later moves on / closes past the end; after an add the selection moves to the next; a refused adopt shows main's error | B74, B21, B22 | | T11 | `t11-adopt-dialog-add-all` | "Add all 2 panel groups from NODE_IDS.md" never adds a PDU; an untouched form keeps its default PDU only if it's in your cockpit, else None; an edited form is taken as written (None stays None); **an edited form keeps a "(not added)" PDU (D7 today)**; Powered by lists "(other console)" and "(not added)" + its hint; **a hidden PDU reads "(not added)" and Add all sends None (#95 bug 2 / X3 today)**; a row that drops while open stays listed, ✕, form kept, and turns green when back | B74, F15, F24, X3 | | T12 | `t12-settings-hidden-boards` | Rows "#id · name · console · returns when it reports again" from `model.hiddenNodes`; Un-hide → `hideNode(id,false)`; Un-hide all un-hides each; a refusal shows main's error; empty state + Un-hide all disabled | B88 | | T13 | `t13-settings-import-preview` | Subtitle; rows "key from → to"; "no change" sections can't be picked; "This file matches your current setup" + no Apply; **`"nodeMapping": {}` previews as a normal, pre-picked section offering to drop every adopted board (F1 today)**; unknown section → "Skipped — written by a newer client…"; Apply disabled with nothing picked; `applyConfigImport(token, sections, backup)`; done line with the backup name; refused apply / refused file | B90, B94, F1 | | T14 | `t14-app-shell` | Wordmark, `v${package.json version}`, Device indicator, Relay pill; 7 tabs with `aria-selected`, click switches tab and page; "SOURCE <mode>"; `App` itself subscribes on mount (rendered without `startBridge`), takes main's pulled state, and a `device:status` push changes the pill | B43 | | T15 | `t15-relay-pill` | Stopped / Relaying; Start saves settings then calls `startRelay`, optimistic Relaying; Stop calls `stopRelay`; the waiting tooltips (DCS-BIOS connecting; Bridge waiting for the Gateway); Bridge without a board → "!", `aria-disabled`, alertdialog "Can't start the relay", Connect only connects; the board arriving lifts the block; Esc returns focus; Monitor starts with no board; a Start main refuses goes back to Stopped | B45, B47 | | T16 | `t16-board-panel-settings` | Display name + "Registry: …, Right console (NODE_IDS.md)."; Power zone = that console's placed PDUs + None (not a new PDU); "(other console)"; a console change picks its first PDU; Save sends only what changed (trimmed name; console + pduId); no-change Save just closes; empty name → main's error inline, panel stays open; a PDU has no power zone and Save sends `{ console }` only | B72, X2 | `scenarios.ts` gains `setup()`, `configFileText()` and `importPreview()`: `openConfigImport`'s answer built the way main's `ConfigTransfer.preview` builds it (shared `parseConfigFile` + `diffImport`), so T13 — F1 especially — pins what main would actually preview, not a hand-written preview. ## Discrepancies (pinned as today, marked in the tests) 1. **T9 / R5, F12** — the CTA stays from the last model after the link drops (as the plan expected). 2. **T11 / D7, F24** — an edited Adopt form keeps a "(not added)" PDU and Add all sends it; the form offers "(not added)" at all. 3. **T11 / X3, F15** — a hidden PDU in your cockpit reads "(not added)", and Add all sends `pduId: null` for its boards. 4. **T13 / F1** — `"nodeMapping": {}` previews as a normal section, pre-picked, offering to take every adopted board out of the cockpit. 5. **T16 / B72** — "an empty name gives an inline error": today the panel has no check of its own. It sends `setNodeMapping(id, { name: '' })` and shows main's refusal ("Name cannot be empty") inline. Same user-visible result, but the gate is main's. 6. **T16 / B72 wording** — the Settings tab's field is labelled "Power zone"; "Powered by" is the Status tab section and the Adopt form label. 7. **T15** — when main refuses Start, the pill goes back to Stopped but shows no reason (main's error text is dropped). ## Fail-check (each break applied locally, test file run, restored with `git checkout`; production tree clean after) | T | Break | Result | |---|---|---| | T9 | CTA gated on `live` (the R5 fix) | 1 failed | | T9 | `newCount` counts unidentified new boards | 1 failed | | T10 | queue tie-break by NODE_ID reversed | 3 failed | | T10 | panel spec complete without Powered by | 1 failed | | T11 | Add all includes PDUs | 1 failed | | T11 | untouched default PDU kept even if not in cockpit | 2 failed | | T11 | rows that drop are not kept | 1 failed | | T11 | identify dot never turns red | 1 failed | | T12 | Un-hide sends `hideNode(id, true)` | 2 failed | | T13 | empty section skipped in `parseConfigFile` (the F1 fix) | 1 failed | | T13 | Apply enabled with nothing picked | 1 failed | | T14 | `App` doesn't call `initBridge` on mount | 1 failed | | T14 | `aria-selected` stuck on Overview | 1 failed | | T15 | refused Start not taken back | 1 failed | | T15 | blocked pill starts anyway | 3 failed | | T16 | console change keeps the old PDU | 2 failed | | T16 | Save always sends the name | 3 failed | Refs #97
…eplace hand-typed copies (#106) R1.1 of the structure rewrite (#101, phase #98). This PR is mechanical: behaviour, UI and wording stay the same. ## What changed - **The generated reference moves to `src/shared/firmware/`.** `scripts/sync-a4ec.ts` now writes there (`OUT_DIR`). The six `*.generated.ts` files were moved with `git mv`. `npm run sync` at the same pin (d29433ab) regenerates them byte-for-byte: `git diff --exit-code -- src/shared/firmware` is clean after the sync. A hand-written barrel, `src/shared/firmware/index.ts`, is now the only import path. CI's freshness gate (`ci.yml`) now diffs the new folder. README and PRD path mentions are updated to match. - `src/main/reference/dcsbios-metadata.ts` stays where it is. It is hand-curated DCS-BIOS data and moves in R1.4. - **All 35 hand-typed copies (§3.3 A1–A35) are now imports.** Callers still use the same names, so call sites barely change. For example, `CAL_TYPE` / `CAL_NACK` are built from the generated tables (`byName(CAL_TYPES)`), and `RAIL_FLAG` / `PDU_FLAG` / `TELEM_PAGE` / `NODE_KIND` are aliases of the generated objects. The cockpit / cockpit-panels / events re-key sites are unchanged because they already key by `RAIL_FLAG.X` / `PDU_FLAG.X`. - **`L-firmware-leaf` is now `error`.** `lint:deps` exits 0 with 0 errors and 65 warnings, the same baseline as before. I checked that the rule can fail: a deliberate `export * from '../hid'` in the barrel produced `error L-firmware-leaf`, and I reverted it. - **`reference.test.ts`** moves next to the generated files and becomes a version and shape guard (see below). ## A1–A35 How each value was checked: - **(R)** the existing `reference.test` copy-equality assertions. The full suite ran against the new imports with those assertions still in place: 849/849 at commit ffe0a2d. - **(P)** a one-off proof test, not committed. It holds the old literals copied verbatim from 2598104 and compares them with the replacements. 8/8 passed. - **(S)** the existing suite (849 at ffe0a2d), whose assertions are unchanged. - **(H)** a one-off render proof, also not committed. In jsdom it rendered `<Hid />` twice (default store, then a store with calibration, axis/hat/button values and availability) and `<CalibrationDialog>` once (its controller fed a device snapshot). The `innerHTML` and `textContent` were dumped once with the base (2598104) `Hid.tsx`, `CalibrationDialog.tsx` and `store.ts` checked out, and once with this branch. The two 28 KB dumps are **identical** (same SHA-1). | # | Was | Now | Verified | |---|---|---|---| | A1 | `CAL_PROTO_VERSION = 1` | `CAL_PROTO.version` | R, P | | A2 | `CAL_MAGIC` AA 53 4B 43 | `Uint8Array.from(CAL_PROTO.magic)` (still a `Uint8Array`) | R, P (incl. instanceof) | | A3 | `CAL_ENVELOPE_BYTES = 10` | `CAL_PROTO.envelopeBytes` | R, P | | A4 | `CAL_MAX_PAYLOAD = 82` | `CAL_PROTO.maxPayload` (`CAL_MAX_FRAME` still 92) | R, P | | A5 | `AXIS_CAL_SLOTS = 8` | `CAL_PROTO.axisSlots` | R, P | | A6 | `CAL_AXIS_NONE = 0xff` | `CAL_PROTO.axisNone` | R, P | | A7 | `CAL_TYPE` (14) | `byName(CAL_TYPES)` | R (both directions), P (entries + key order) | | A8 | `CAL_NACK` (8) | `byName(CAL_NACK_REASONS)` | R (both directions), P (entries + key order) | | A9 | `CAL_PAYLOAD_LEN` (14) | generated `CAL_PAYLOAD_LEN`, re-exported from the codec | R (both directions), P (entries + key order) | | A10 | callink `NACK_NAME` (5) | `nackRefusalName(reason)` in shared/calibration.ts (X11) | P: about 610 inputs (−100…499, fractions, ±0, NaN, ±Infinity, 1e21) against the old expression | | A11 | device-link reverse lookup over `CAL_NACK` | `nackReasonName(reason)` in shared/calibration.ts (X11) | P: same inputs against the old `Object.entries(…).find` | | A12 | `NODE_MSG` | `NODE_STATUS.msgName` | R, P | | A13 | `NODE_END_MSG` | `NODE_STATUS.endMsgName` | R, P | | A14 | `NODE_REQ_ADDR = 0x86fe` | `NODE_STATUS.reqAddress` | R, P | | A15 | `HFLAG_OVERHEAT` | `NODE_HEALTH_FLAGS.OVERHEAT` | R, P | | A16 | `HFLAG_DEGRADED` | `NODE_HEALTH_FLAGS.DEGRADED` | R, P | | A17 | `NODE_KIND` | `= NODE_KINDS` | R, P (entries + key order) | | A18 | `NODE_TELEM_MSG` | `NODE_TELEM.msgName` | R, P | | A19 | `TELEM_PAGE` | `= TELEM_PAGES` | R, P | | A20 | `RAIL_FLAG` | `= PDU_RAIL_FLAGS` | R, P | | A21 | `PDU_FLAG` | `= PDU_STATUS_FLAGS` | R, P | | A22 | `HID_REPORT_SIZE = 34` | `HID_REPORT_LAYOUT.size` | P, S (shared/hid.test) | | A23 | `HID_AXIS_COUNT = 8` | `.axes.count` | P, S | | A24 | `HID_BUTTON_COUNT = 128` | `.buttons.count` | P, S | | A25 | `HID_HAT_COUNT = 4` | `.hats.count` | P, S | | A26 | offsets 0/16/18, `subarray(…, +16)` | `.buttons/.hats/.axes.offset`, `.buttons.bytes` | P, S | | A27 | `nibble <= 7` | `nibble <= hats.centeredAtOrAbove - 1` (written as `<= … - 1`, not `< …`, so non-integer input also behaves the same) | P: about 610 inputs against the old expression | | A28 | `hidAvailability` `< 8`, `< 4`, `< 128` | `axes/hats/buttons.count` (stays in main/index.ts; the move is a later PR) | P | | A29 | `AXIS_LABELS` Roll…Zoom, Spare | `HID_CONTROLS_BY_ID.get(HID_ID.AXIS_MIN + i)?.label ?? 'Spare'` over `axes.count` | P (jsdom: imported the real `AXIS_LABELS` and `toEqual`'d the old 8 strings), H. **All 8 labels identical** | | A30 | `Array(8).fill(0)`, `[0,0,0,0]` | `Array(axes.count).fill(0)`, `Array(hats.count).fill(0)` | P (`toStrictEqual` on the real store initial state), H | | A31 | Hid.tsx "of 8 slots", "8-way POV", "128 × 1-bit", `length: 128` | `axes.count`, `hats.centeredAtOrAbove`, `buttons.count` | P, H | | A32 | Hid.tsx `/ 32768`, "int16 · ±32768" | `-HID_REPORT_LAYOUT.axes.min` | P, H | | A33 | CalibrationDialog `− 32768`, `/ 65535` (21, 25, 39-40, 318, 326) | `AXIS_OFFSET = -axes.min`, `AXIS_SPAN = axes.max - axes.min` | P (numeric identity on sample values), H | | A34 | CalibrationDialog "of 8 slots" | `of {CAL_PROTO.axisSlots} slots` | P, H | | A35 | `FAULT_NOTE` 0x05/0x09, `FAULT_TEXT` 0x09 | keyed by `NODE_FAULT_CODES` name (`HOST_LINK_LOST`, `DUPLICATE_NODE_ID`); wording unchanged | P: `gatewayStatus(…).note` and `withFaultInfo(…)` (`toStrictEqual`) for faultId 0–299 against the old tables | No value differed, so every one was swapped. ## X11 (A10/A11) The two disagreeing NACK namers are now **separate functions side by side in `shared/calibration.ts`**. Both are built on `CAL_NACK_REASONS`, and they are deliberately not unified; unifying them is the fix track. - `nackRefusalName(reason)`: CalLink's `CalNackError` message. Names only the 5 reachable reasons (`BAD_INDEX`, `BAD_ORDER`, `NO_SESSION`, `NO_STORAGE`, `BAD_DEADZONE`). Anything else becomes `reason 0x…`. - `nackReasonName(reason)`: device-link's `CalFailure.reasonName`. Names all 8, else `0x…`. ## reference.test: deleted assertions These assertions compared a hand-typed copy with its generated value. Each copy is now the generated value, so the comparisons were deleted. Five whole `it` blocks went with them, so the suite goes from 849 to 844 tests. - `node-status.generated › matches the shared decoder contract`: `NODE_STATUS.reqAddress` = `NODE_REQ_ADDR`, `.msgName` = `NODE_MSG`, `.endMsgName` = `NODE_END_MSG`. The `protoVersion` = `SUPPORTED_NODE_PROTO` guard is kept. - `node-status.generated › health-flag masks match the decoder` (whole `it`): `OVERHEAT` = `HFLAG_OVERHEAT`, `DEGRADED` = `HFLAG_DEGRADED`. - `node-telem contract › matches the shared decoder`: `NODE_TELEM.msgName` = `NODE_TELEM_MSG`. The `protoVersion` = `SUPPORTED_TELEM_PROTO` guard is kept. - `node-telem contract › page ids and flag bits match the decoder exactly` (whole `it`): `TELEM_PAGES`/`TELEM_PAGE`, `PDU_RAIL_FLAGS`/`RAIL_FLAG`, `PDU_STATUS_FLAGS`/`PDU_FLAG`, `NODE_KINDS`/`NODE_KIND`. - `axis-cal.generated › scalar contract matches the codec`: `axisSlots`, `axisNone`, `envelopeBytes`, `maxPayload` and `magic` against the codec copies. - `axis-cal.generated › every message type agrees, in both directions` (whole `it`). - `axis-cal.generated › every payload length agrees, for every type` (whole `it`). - `axis-cal.generated › every NACK reason agrees, in both directions` (whole `it`). **The calibration version gate became a pin.** `expect(CAL_PROTO.version).toBe(cal.CAL_PROTO_VERSION)` would now compare a value with itself, so it is `expect(CAL_PROTO.version).toBe(1)`, in an `it` renamed "is the protocol version the codec is written for". A firmware protocol bump still fails loudly. This is the only assertion whose content changed. Kept unchanged: the a4ec / hid-controls / hid-report-layout / node-names / dcsbios-metadata shape checks, the fault-code pins and unique-badge check, "marks device-originated types by the high bit", and "COMMIT carries exactly one axis". ## Grep proof Each pattern was run with `git grep -nE <pattern> -- src ':!*.generated.ts' ':!*.test.ts' ':!*.test.tsx'`, with comment lines filtered out. At the base (2598104) **every pattern hits**. At this head every pattern has 0 hits, except the one noted below. ``` CAL_PROTO_VERSION = [0-9] CAL_ENVELOPE_BYTES = [0-9] 0xaa, 0x53, 0x4b, 0x43 CAL_MAX_PAYLOAD = [0-9] AXIS_CAL_SLOTS = [0-9] CAL_AXIS_NONE = 0x (HELLO|SESSION_OPEN|STREAM_SELECT|HELLO_ACK|RAW): 0x (BAD_CRC|BAD_INDEX|BAD_DEADZONE|NO_SESSION): 0x \[CAL_TYPE\.[A-Z_]+\]: [0-9] NACK_NAME|\[CAL_NACK\.|Object\.entries\(CAL_NACK\) '_NODE_STATUS'|'_NODE_STATUS_END'|'_NODE_TELEM' 0x86fe HFLAG_(OVERHEAT|DEGRADED) = 0x (PANEL_GROUP|BRIDGE): 0x (PDU_RAIL_12V|PDU_STATUS): 0x (UNDER_V|OVER_CURRENT|FUSE_OPEN|SENSOR_FAULT): 0x (UNCAL_VDDA|TEMP_CRIT|NTC_FAULT): 0x HID_(REPORT_SIZE|AXIS_COUNT|BUTTON_COUNT|HAT_COUNT) = [0-9] (BUTTONS|HATS|AXES)_OFFSET = [0-9] BUTTONS_OFFSET \+ 16 nibble <= 7 AXIS_MIN < 8|HAT_MIN < 4|BUTTON_MIN < 128 'Brake L'|'Throttle'|'Spare', Array\(8\)|\[0, 0, 0, 0\] of 8 slots|8-way|128 ×|length: 128 0x0[59]: ['{] / 32768|±32768'|- 32768|/ 65535|>−32768< ``` The one exception is `/ 65535` in `main/decode.ts:143-145`. That code decodes the A-4E-C altimeter drums: it is DCS module data, not firmware, and it is out of scope (§3.3 "Not firmware"). ## Not done here, on purpose - `CalibrationDialog.tsx:202` "signed ±32767" and `:328` `<span>32767</span>` are still literals. They equal `axes.max`, but A33's line list (21, 25, 39-40, 318, 326) does not include them. - The generated banner in `axis-cal.generated.ts` still says "The codec in shared/calibration.ts restates these…", and that is now stale. The text comes from `sync-a4ec.ts`, and changing it would break the byte-identical regeneration this PR has to prove. It belongs with R1.2's sync changes. - §3.3 group B is untouched. ## Checks `npm run typecheck` is clean. `npm run lint` has 0 errors (32 warnings). `npm run lint:deps` has 0 errors (65 warnings). `npx vitest run` passes 62 files: 849 tests with the old assertions at ffe0a2d, 844 after the guard rewrite. `npm run build` succeeds. Refs #98
R1.2 of the structure rewrite (#101, phase #98). This PR extends the firmware sync and swaps the matching client copies for imports. Behaviour, UI and wording stay the same. The sync pin is unchanged at d29433ab. ## What changed - **`scripts/sync-a4ec.ts`** now also reads `PanelBridge/PanelBridge.cpp` and `PowerMonitor/TelemSchedule.h` (two new `SRC` entries, same sparse fetch). Two new generated files are exported from the `shared/firmware` barrel: - `node-bus.generated.ts`: `MAX_NODE_ID`, `TELEM_PERIOD_MS` - `usb-identity.generated.ts`: `USB_IDENTITY` (`vid`, `pid`, `manufacturer`, `product`, `cdcInterface`, `baud`) - **`constexprNum(src, name, where)`** now names its source file. It also evaluates a sum of literals and other constants from the same file, which `CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD` needs. Any other expression fails the sync. - **`CAL_PROTO`** gains `maxFrame` (from `AxisCal.h`) and `sessionTimeoutMs` (from `SimGateway.cpp` `CAL_SESSION_TIMEOUT_MS`). - **`HID_REPORT_LAYOUT.hats.directions`** comes from the descriptor's hat Logical Minimum/Maximum bytes (`0x15 00` / `0x25 07`), giving 8. `centeredAtOrAbove` is now derived from the same range (logical max + 1 = 8, the same value as before). The sync fails if the logical minimum is ever not 0. The direction names (`HAT_DIRS`, CTR N … NW) stay client display labels. - **B10, keys only.** `PDU_RAIL_FLAG_INFO` and `PDU_STATUS_FLAG_INFO` map each bit to `{ name, description }`, with the description taken verbatim from the `parseEnum` comment capture, the same way `NODE_FAULT_CODES` does it. The client's flag wording (`cockpit-panels.ts`, `events.ts`) is **not** replaced. It differs from the firmware comments, so replacing it would be a behaviour change. - **The `axis-cal.generated.ts` banner** no longer claims the codec "restates these". It now says the codec imports them through the barrel and that `reference.test.ts` pins the values. ## Values → generated name → client site | # | Value | Firmware source (d29433ab) | Generated | Client site replaced | Verified | |---|---|---|---|---|---| | B1 | 63 | `PanelBridge.cpp` `static constexpr uint8_t MAX_NODE_ID = 63` | `MAX_NODE_ID` | `shared/node-mapping.ts` `isNodeId` `v <= 63`; `shared/nodes.ts` `nodeId > 63` (and the field comment); `shared/node-telem.ts` `nodeId > 63` | P (both decoders' gates for every id 0–255), R | | B12 | 0x2E8A / 0x4134 | `SimGateway.cpp` `TinyUSBDevice.setID(0x2E8A, 0x4134)` | `USB_IDENTITY.vid/.pid` | `main/serial.ts` `SIMGATEWAY_VID/PID` (also feeds `main/hid.ts`); `Connection.tsx` "0x2E8A / 0x4134" (both rows) | P, R, H | | B13 | "A-4E Skyhawk", "OpenSkyhawk", "A-4E Skyhawk DCS-BIOS" | `SimGateway.cpp` `setProductDescriptor` / `setManufacturerDescriptor` / `Serial.setStringDescriptor` | `USB_IDENTITY.product/.manufacturer/.cdcInterface` | `Connection.tsx` Identity row and "A-4E Skyhawk · Gateway" | P, R, H | | B14 | 250000 | `SimGateway.cpp` `Serial.begin(250000)` (the CDC one, not `_uart->begin`) | `USB_IDENTITY.baud` | `main/serial.ts` `BAUD` | P, R | | B15 | 92 | `AxisCal.h` `CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD` | `CAL_PROTO.maxFrame` | `shared/calibration.ts` `CAL_MAX_FRAME` (was the same sum, client-side) | P (= envelope + payload = 92), R | | B16 | 30000 | `SimGateway.cpp` `constexpr uint32_t CAL_SESSION_TIMEOUT_MS = 30000` | `CAL_PROTO.sessionTimeoutMs` | `main/callink.ts` `deviceTimeoutMs` default (`30_000`). `KEEPALIVE_MS = 10_000` stays client policy; only its comment cites the synced timeout. | P (read off a fresh `CalLink`), R | | B20 | 1000 | `TelemSchedule.h` `static constexpr uint32_t TELEM_PERIOD_MS = 1000` | `TELEM_PERIOD_MS` | None in code. `TELEM_STALE_MS = 10_000` stays client policy, and its comment now cites the period. | R | | B11 | 8 | `SimGateway.cpp` HID descriptor, hat Logical Min/Max | `HID_REPORT_LAYOUT.hats.directions` | `tabs/Hid.tsx` "8-way POV" (was built from `hats.centeredAtOrAbove`) | P, R, H | | R1.1 (b) | 32767 | `SimGateway.cpp` axis Logical Maximum (already synced) | `HID_REPORT_LAYOUT.axes.max` | `CalibrationDialog.tsx` "· signed ±32767" and the scale's `<span>32767</span>` | P, R, H | How each value was checked: - **(R)** A new `reference.test.ts` assertion pins the value equal to the old literal, so a firmware change fails loudly. The pinned values are VID `0x2e8a`, PID `0x4134`, the three strings, baud 250000, `MAX_NODE_ID` 63, `TELEM_PERIOD_MS` 1000, `hats.directions` 8 (plus `centeredAtOrAbove` 8 and `axes.max` 32767), `maxFrame` 92 (= envelope + payload) and `sessionTimeoutMs` 30000. - **(P)** A one-off proof test, not committed. It compared every replacement with the old literal: the exported `SIMGATEWAY_VID/PID`, the built `"0x2E8A / 0x4134"` string, `CAL_MAX_FRAME`, a fresh `CalLink`'s `deviceTimeoutMs`, `parseNodeStatus`/`parseNodeTelem` acceptance for node ids 0–255, and `CAL_TYPE`/`CAL_NACK` entries in key order. 5/5 passed. - **(H)** A one-off render proof, also not committed. In jsdom it rendered `<Connection />` twice (connected, and no-device), `<Hid />` once (connected), and `<CalibrationDialog>` twice (link up, and link down/paused), with its controller fed an 8-axis snapshot. It dumped `innerHTML` and `textContent` once at the base (f833939) and once at this head. The two 27.8 KB dumps are **identical** (SHA-1 `07bd9daca558bfadb75841b4beabd061ee5baecf`). The dumps contain every swapped string: "0x2E8A / 0x4134", "0x2E8A / 0x4134 · target", "A-4E Skyhawk", "A-4E Skyhawk · Gateway", "8-way POV", "· signed ±32767" and ">32767<". Each swapped JSX text is still a single text node (a template literal), so the markup does not split. **No value differed, so every one was swapped.** ## Typed names (R1.1 carry-over) The generator now emits literal name unions next to the code-keyed tables: `CalTypeName`, `CalNackName` and `NodeFaultName`. `PduRailFlagName` / `PduStatusFlagName` are `keyof typeof` the flag objects. The info interfaces now type `name` with these unions. The tables themselves keep `Partial<Record<number, …>>`, so indexing by a wire byte is still allowed and still yields `| undefined`. - `byName<N>()` in `shared/calibration.ts` now returns `Readonly<Record<N, number>>`. `CAL_TYPE.HELLO` type-checks and `CAL_TYPE.FOO` does not compile. - `REACHABLE_NACK` is a `ReadonlySet<CalNackName>`. - `FAULT_NOTE` (`shared/gateway.ts`) and `FAULT_TEXT` (`main/node-hub.ts`) are keyed by `NodeFaultName`, not `string`. - `reference.test.ts` proves this with `// @ts-expect-error` on `CAL_TYPE.FOO`, `CAL_NACK.BAD_FOO` and a misspelt `NodeFaultName`. I checked that the directives carry weight: with the `CAL_TYPE.FOO` one removed, `tsc` fails with `TS2339: Property 'FOO' does not exist on type 'Readonly<Record<CalTypeName, number>>'`. ## Grep proof Each pattern was run with `git grep -nE <pattern> -- src ':!*.generated.ts' ':!*.test.ts' ':!*.test.tsx'`, with comment lines filtered out. The counts are base (f833939) → head: ``` 3 -> 0 (<=|>) 63([^0-9]|$) 5 -> 2 0x2e8a|0x2E8A|0x4134 1 -> 0 250000 2 -> 0 'A-4E Skyhawk'|>A-4E Skyhawk 1 -> 0 CAL_ENVELOPE_BYTES \+ CAL_MAX_PAYLOAD 1 -> 0 deviceTimeoutMs = 30_000 2 -> 0 signed ±32767|<span>32767</span> 1 -> 0 centeredAtOrAbove\}-way 4 -> 0 Partial<Record<string, (string|\{ label)|Readonly<Record<string, number>>|ReadonlySet<string> ``` The two remaining VID/PID hits are in `src/main/test-harness/services.ts:126-127`, the `vi.doMock('../serial', …)` double. The same double is in `session.test.ts:80-81`. They are test fakes of the module's exports, not production copies, and I left them alone. ## Not done here, on purpose - Out of scope until R1.5 / the pin bump: B2–B9, B17, B18 and B21. B19 is untouched, since it is already derived. - B13 `manufacturer` and `cdcInterface` are synced and pinned, but no client code reads them today, so no site changed. - No client wording was replaced with firmware comments (B10). ## Checks - `npm run sync` at d29433ab regenerates every file. Running it twice gives no diff, and it was run again after the commit hooks: `git diff --exit-code -- src/shared/firmware` is clean, which is the CI freshness gate. - `npm run typecheck` is clean. - `npm run lint` has 0 errors (32 warnings, the same as the baseline). - `npm run lint:deps` has 0 errors (65 warnings, the same as the baseline). - `npx vitest run` passes 62 files: 852 tests, which is 844 plus 8 new `reference.test` cases. No existing assertion changed. - `npm run build` succeeds. Refs #98
R1.5 of the structure rewrite. Firmware PR OpenSkyHawk/OpenSkyhawk#347 gave names to nine protocol values that used to exist only as comments or bare literals. This PR syncs those names and swaps the client's hand-typed copies for them (plan §3.3 rows B2–B9 and B17). Nothing changes in behaviour: no UI, wording or output differs. ## 1. Pin bump: d29433ab → a78dd902 `PIN.commit` now points at `a78dd90276fffb820b64ba74b82f354ab167348a`, the #347 merge. It is the only firmware commit between the two pins. The pin was bumped and `npm run sync` run in a commit of its own (c150ca8), before any generator change. That commit touches exactly one line in each of the 8 generated files: the `// Source: … @ <commit>` banner. `node-names.generated.ts` also has `NODE_REGISTRY_SOURCE.commit`. Every value is identical, and the registry `date` stays 2026-10-16. ``` scripts/sync-a4ec.ts | 2 +- src/shared/firmware/*.generated.ts (8 files) | 1+/1- each (node-names 2+/2-) ``` ## 2. What the sync now generates These are additions to the existing generators. No new script. `constexprNum` accepts a leading `-` on its first term, which the two signed sentinels need. A new `parseOffsets` reads an offset enum and checks two things: the offsets are strictly ascending, and each lies inside the row or payload it indexes. A relayout the decoders can't read field by field fails the sync instead of mis-decoding. **`node-status.generated.ts`** (from `NodeStatus.h`): - `GATEWAY_NODE_ID` = 0 - `NODE_STATUS_ROW_BYTES` = 14 and `NODE_STATUS_FIELD` {ID 0, PRESENT 1, HB_FLAGS 2, UPTIME 3, RX_COUNT 5, ESR 7, DIE_TEMP_C 9, HEALTH_FLAGS 10, FAULT_MASK 11, FAULT_ID 12, NODE_KIND 13} - `HEARTBEAT_FLAGS` {BOFF 0x1, EPVF 0x2} - `HB_ESR_TEC_SHIFT` = 0 and `HB_ESR_REC_SHIFT` = 8 - `DIE_TEMP_UNAVAILABLE` = -128 - `NODE_TELEM_ROW_BYTES` = 9 and `NODE_TELEM_FIELD` {ID 0, FRAME 1} - `PDU_RAIL_FIELD` {FLAGS 1, V_AVG_MV 2, V_MIN_MV 4, I_AVG_MA 6} - `PDU_STATUS_FIELD` {FLAGS 1, NTC_TEMP_DC 2, I12_PEAK_MA 4, I5_PEAK_MA 6} - `NTC_TEMP_UNAVAILABLE` = -32768 **`axis-cal.generated.ts`** (from `AxisCal.h`): - `CAL_PROTO.dataRecBytes` = 10, from `CAL_DATA_REC_BYTES`. - `CAL_HELLO_ACK_FIELD`, `CAL_DATA_FIELD`, `CAL_DATA_REC_FIELD`, `CAL_SESSION_OPEN_FIELD`, `CAL_SESSION_ACK_FIELD`, `CAL_STREAM_SELECT_FIELD`, `CAL_COMMIT_FIELD`, `CAL_RESET_FIELD`, `CAL_ACK_FIELD`, `CAL_NACK_FIELD`, `CAL_RAW_FIELD`. The type prefix is stripped from each entry, so `CAL_COMMIT_MIN` becomes `CAL_COMMIT_FIELD.MIN`. The sync fails if an entry lacks the expected prefix. Each table is bounded by its type's synced payload length; the per-axis record table is bounded by `CAL_DATA_REC_BYTES`. Every table is an `as const` name-keyed object, so a misspelt field name doesn't compile. Each entry keeps the firmware's trailing comment (`// u16: AxisCal::min`). `reference.test.ts` pins every new value at today's literal, so a future firmware relayout fails there. ## 3. Client sites replaced | Name | Client site replaced | How equality was proven | |---|---|---| | `GATEWAY_NODE_ID` (B2) | `shared/nodes.ts` `BRIDGE_NODE_ID = 0` (export kept, now `= GATEWAY_NODE_ID`); `tabs/settings/settings-view.ts` `nodeId === 0` | 0 === 0. `registryOnBus` base vs head over node IDs 0–70 × 4 models × link up/down. Settings › About › registry render identical | | `NODE_STATUS_ROW_BYTES` + `NODE_STATUS_FIELD` (B3) | `nodes.ts` `HEX_LEN = 28`, the `{28}` regex, and every `f(i, n)` hex slice | `parseNodeStatus` base vs head, `toStrictEqual`, over **89,949 rows** (below) | | `HEARTBEAT_FLAGS` (B4) | `nodes.ts` `flags & 0x01` / `& 0x02` | same corpus, all 256 HB_FLAGS bytes | | `HB_ESR_TEC_SHIFT` / `HB_ESR_REC_SHIFT` (B5) | `nodes.ts` `esr & 0xff` / `(esr >> 8) & 0xff` | same corpus, all 65,536 ESR values | | `DIE_TEMP_UNAVAILABLE` (B6) | `nodes.ts` `traw === 0x80` | all 256 die-temp bytes, base vs head (sign convention below) | | `NODE_TELEM_ROW_BYTES` + `NODE_TELEM_FIELD` (B7) | `node-telem.ts` `HEX_LEN = 18`, the `{18}` regex, `slice(0, 2)`, the frame-byte slice `2 + i*2`, `slice(4)` for the unknown-page body | `parseNodeTelem` base vs head, `toStrictEqual`, over **87,133 rows** (below) | | `PDU_RAIL_FIELD` / `PDU_STATUS_FIELD` (B8) | `node-telem.ts` `b(1)`, `u16(2/4/6)`, `i16(2/6)` | same corpus, both rail pages at the u16/i16 extremes | | `NTC_TEMP_UNAVAILABLE` (B9) | `node-telem.ts` `NTC_UNAVAILABLE = -0x8000` | -0x8000 === -32768. All 65,536 NTC words on the status page, base vs head | | `CAL_HELLO_ACK_FIELD` (B17) | `calibration.ts` `decodeHelloAck` `p[0..5]` | every decoder, base vs head, payload lengths 0–90 (short, exact and long) × 7 payloads each (all-0, all-0xff, all-0x80, all-0x7f, random), plus 2,000 random 82-byte payloads | | `CAL_DATA_FIELD` + `CAL_DATA_REC_FIELD` + `CAL_PROTO.dataRecBytes` (B17) | `decodeCalData` `p[0]`, `p[1]`, `2 + i * 10`, `o + 2/4/6/8` | same | | `CAL_SESSION_ACK_FIELD` (B17) | `decodeSessionAck` `u16(p, 0)`, `u16(p, 2)`, `p[4]` | same | | `CAL_ACK_FIELD` / `CAL_NACK_FIELD` / `CAL_RAW_FIELD` (B17) | `decodeAck` `p[0]`; `decodeNack` `p[0..2]`; `decodeRaw` `p[0]`, `u16(p, 1/3)` | same | | `CAL_COMMIT_FIELD` (B17) | `encodeCommit` `p[0]`, `put(1/3/5/7)`, and `new Uint8Array(9)` → `CAL_PAYLOAD_LEN[COMMIT]` (already synced, already pinned at 9) | base vs head bytes: 6 seqs × 5 idx × 12 values (incl. 0x10000, -1, 1.5, NaN), plus 2,000 random axes per seq. Demux round-trip identical | | `CAL_SESSION_OPEN_FIELD` / `CAL_STREAM_SELECT_FIELD` / `CAL_RESET_FIELD` (B17) | `one(type, seq, byte)` with its implicit offset 0 (`Uint8Array.from([byte])`) | base vs head bytes: 6 seqs × 20 byte values (incl. -1, 256, 1.5, NaN, ±Infinity, 1e9). `one` now sizes the payload `at + 1`, so if the field ever moved off byte 0, buildFrame's length check would throw rather than send a zero | **Status-row corpus (89,949 rows):** - every hex literal in the repo's test files and fixtures - `statusRow()` for node IDs 0–255 × present 0/1 × kinds 0–3, 4 and 0xff - each field at its min/max, with the rest all-min and all-max - every die-temp byte, every ESR value, and every byte of flags, present and hFlags - 20,000 random mixed-case rows - malformed rows: lengths 26/27/29/30, whitespace, non-hex, `0x`, `+`/`-` prefix, Arabic-Indic digit `isLegacyNodeStatus` was compared over the same rows. **Telemetry corpus (87,133 rows):** - every fixture literal - node IDs 0–255 × pages 0–4 - every page byte - every NTC int16 - rail extremes - 20,000 random rows - malformed rows The proof tests were mutation-checked. With `esr >> 7` and `p[F.DETAIL]` swapped in for the reason byte, they fail. They are throwaway files and are not committed. **Render compare:** each view is fed rows decoded through the decoders: a gateway row, panels with bus-off/error-passive, the die-temp sentinel, -40 °C and overheat+fault, a PDU, a removed node, rail/status/unknown TELEM pages including the NTC sentinel and an INT16_MIN current, and decoded CAL_DATA / SESSION_ACK / RAW. I dumped the HTML of 8 views: Cockpit, the Cockpit panel sheet, the Cockpit PDU sheet, Connection, HID, the Calibration dialog, Settings › About, and the Settings registry dialog. Head was dumped twice to confirm the output is deterministic. Then the four changed source files were swapped back to base and the views dumped again. `diff -r` base vs head: **identical**. ## 4. Sign conventions - **B6, die temperature.** The firmware value is signed int8 -128. The row carries its two's-complement byte, 0x80. The decoder already converted the byte to signed (`traw < 128 ? traw : traw - 256`). It now converts first and compares the signed value with `DIE_TEMP_UNAVAILABLE`. That is the same test as the old `traw === 0x80`, because the signed value is -128 exactly when the byte is 0x80. Proven for all 256 byte values. `reference.test` also pins `DIE_TEMP_UNAVAILABLE & 0xff === 0x80`. - **B9, NTC.** `i16()` already produced the signed value, and the old constant was `-0x8000`, so `NTC_TEMP_UNAVAILABLE` (-32768) drops straight in. Proven for all 65,536 words. `reference.test` pins `& 0xffff === 0x8000`. The `i16` sign conversion (`v >= 0x8000 ? v - 0x10000 : v`) is generic int16 handling, not the sentinel, so it stays. ## 5. What stays hand-typed, and why - `V2_HEX_LEN = 26` in `nodes.ts`. The legacy v2 row is detected client-side, only to say "this bridge is too old". The firmware no longer describes it, so there is nothing to sync from. A comment now says so. - Hat direction labels (CTR N NE … NW): client wording. - The TELEM page byte, read as `b(0)`. The firmware names no field for it; `NodeTelemField` says "TelemPage at FRAME + 0". - The calibration envelope offsets in `buildFrame` / `classify` (type @4, seq @5, len @6–7, payload @8). The firmware has no envelope-field enum, and they were outside B17. - Out of scope per the brief: B18 (CRC parameters; the vector test is kept) and B21 (hysteresis text). Cockpit-adopt's Gateway wording is untouched. ## 6. Grep proof Each pattern was grepped over `src/shared/nodes.ts`, `node-telem.ts`, `calibration.ts` and `renderer/src/tabs/settings/settings-view.ts`. Every count is 0: ``` 0 BRIDGE_NODE_ID = 0 0 nodeId === 0 0 HEX_LEN = (28|18) 0 \{28\}|\{18\} 0 f\([0-9]+, [0-9]\) 0 flags & 0x0[12] 0 esr >> 8|esr & 0xff 0 traw === 0x80 0 NTC_UNAVAILABLE|-0x8000 0 hex\.slice\(4\)|slice\(2 \+ i 0 b\([1-7]\)|u16\([2-6]\)|i16\([2-6]\) 0 p\[[0-9]\] 0 u16\(p, [0-9]|o \+ [0-9]|2 \+ i \* 10 0 put\([0-9] 0 new Uint8Array\(9\) 0 Uint8Array\.from\(\[byte\]\) ``` A repo-wide search for `0x80`, `0x8000`, `INT16_MIN`, `=== 0` on node IDs and `>> 8) & 0xff` outside tests and fixtures found only generic byte handling: LE writes, CRC, int16 sign conversion, and dcsbios export frames. ## Verification - `npm run sync` is idempotent at the new pin: the diff is byte-identical before and after a re-run. That is the CI freshness gate. - `npm run typecheck`: clean. - `npm run lint`: 0 errors. No warnings in the changed files. - `npm run lint:deps`: 0 errors. - `npx vitest run`: 62 files, 856 tests. That is 852 plus 4 new reference pins. Existing assertions are unchanged. - `npm run build`: passes. Refs #98 Refs OpenSkyHawk/OpenSkyhawk#346
…109) R1.3 of the structure rewrite (#101, phase #98). Mechanical only: UI, wording and behaviour are unchanged. IPC channel strings and payload shapes are byte-identical. The shared format helpers are not in this PR; they come in R1.3b. ## Topic contracts `src/shared/ipc.ts` is gone. Each topic now has its own `src/shared/<topic>/ipc.ts` and an `index.ts` that re-exports it. | Topic | What its contract holds | |---|---| | **app** | `SkyhawkApi` (verbatim). `PushChannels`, the union of the topics' push maps. `PushChannel`. `IPC` and `CTRL`, spread from the topics' channel objects. `RelayStatus`, the `relay:status` rehydrate bundle. `DebugDumpResult`. `APP_CTRL` (`relay:status`, `debug:dump-ports`, `debug:reveal`) | | **gateway** | `DeviceState`, `isLinkUp`, `DeviceStatus`, `GatewayKind`, `GatewayStatus`, `SerialFrame`. Pushes `device:status` and `serial:traffic`. Intents `device:connect`, `device:disconnect`, `serial:monitor` | | **dcs** | `EXPORT_MULTICAST_ADDR`, `EXPORT_PORT`, `COMMAND_PORT`. `StreamState`, `RelayState`, `AircraftStatus`, `StatsSnapshot`, `LogRow`, `TelemetryReadout`, `RelayResult`, `ExportResult`, `CaptureState`, `ReplayLoad`, `CaptureEvent`, `CaptureMeta`. Pushes `relay:state`, `aircraft:changed`, `stats:tick`, `log:batch`, `telemetry:tick`. Intents `relay:start`, `relay:stop`, `log:export`, `capture:toggle`, `replay:open` | | **nodes** | `NodeStatus`, `NodeSeen`, `NodeIdentity`, `NodeKind`. The mapping shapes. The telemetry page shapes and `NodeTelemSnapshot`. The model shapes. The PSU shapes and `DEFAULT_PSU`. `NodeRegistryInfo`, `NodeSnapshot`, `NodeMappingResult`. Pushes `nodes:telemetry` and `nodes:model`. Intents `nodes:*` and `node-mapping:*` | | **hid** | `HidSnapshot`, `HidAvailability`. Push `hid:report`. Intent `hid:availability` | | **hid/calibration** | `CalRawSample`, `CalSnapshot`, `CalFailure`, `CalResult`, `CalHello`, `CalCommitAxis`, `CalCacheEntry`. `CachedAxis`, `CachedBoard`, `CalCache`. The capture shapes (`CaptureConfig`, `CaptureStep`, `CapturePhase`, `CapturedSweep`, `CaptureResult`, `CaptureState`). Pushes `cal:data` and `cal:raw`. Intents: the ten `cal:*` | | **events** | `EventSource`, `EventSeverity`, `LogEventInput`, `LogEvent`, `MAX_EVENTS`. Push `events:batch`. Intents `events:get`, `events:clear`, `events:export` | | **config** | `SourceMode`, `DcsTransport`, `SOURCE_MODES`, `TRANSPORTS`, `AppConfig`, `DEFAULT_CONFIG`, **`SettingsStore`** (new, type-only). `CONFIG_FORMAT`, `AppSettings`, `ConfigSnapshot`, `SectionId`, `ConfigFile`, `DiffRow`, `SectionDiff`. `ConfigExportResult`, `ImportPreview`, `ConfigImportOpen`, `ConfigImportApplyResult`. Intents `config:*` | Each topic also exports a `<TOPIC>_PUSH` / `<TOPIC>_CTRL` channel object and a `<Topic>PushChannels` map. `IPC` and `CTRL` are spreads of those objects, so their key order differs from before. Nothing iterates either object; the guard below checks their exact types. `DEFAULT_CONFIG` now reads `commandPort: COMMAND_PORT` and `listenPort: EXPORT_PORT` from the dcs contract. The values are the same: 7778 and 5010, checked at runtime. `SettingsStore` is a read-only store over `AppConfig`: `getState()` plus both `subscribe` overloads, shaped like zustand/vanilla's `StoreApi` under `subscribeWithSelector`. Nothing uses it yet; R2.3 wires it. The guard file checks that a real zustand `Mutate<StoreApi<AppConfig>, [['zustand/subscribeWithSelector', never]]>` is assignable to it. ## Pushed shapes moved out of the models (the 52 "MOVE (pushed shape)" rows) - `nodes.ts`: `NodeStatus`, `NodeSeen` - `node-telem.ts`: `PduRailPage`, `PduStatusPage`, `UnknownTelemPage`, `TelemPageData`, `NodeTelem`, `TelemPageView`, `NodeTelemSnapshot`. The page literal types read `typeof TELEM_PAGES.X` from `shared/firmware`; `TELEM_PAGE` was an alias of it, so the types are identical. - `node-kind.ts`: `NodeIdentity` - `node-mapping.ts`: `NodeKind`, `NodeOverride`, `NodeMappingFile`, `NodeDefaults`, `MappedNode`, `MappingPatch`, `AdoptSpec`, `ResetOptions`, `ResetRow`, `AdoptedRow`, `ResetPreview` - `node-rules.ts`: `NodeAvailability`, `NodeModelEntry`, `PduOutage`, `HiddenNode`, `NodeModel` - `psu.ts`: `PsuLimits`, `PsuPresetId`, `PsuPreset`, `PsuSetting` - `events.ts`: `EventSource`, `EventSeverity`, `LogEventInput`, `LogEvent` - `gateway.ts`: `GatewayKind`, `GatewayStatus` - `config-file.ts`: `AppSettings`, `ConfigSnapshot`, `SectionId`, `ConfigFile`, `DiffRow`, `SectionDiff` - `cal-cache.ts`: `CachedAxis`, `CachedBoard`, `CalCache` - `axis-capture.ts`: `CaptureStep`, `CapturePhase`, `CapturedSweep`, `CaptureResult`, `CaptureState` - `capture.ts`: `CaptureEvent`, `CaptureMeta` These also had to move, because a contract value or shape is defined by them. Without them, C1 can't reach 0: - `DEFAULT_PSU` → nodes. `DEFAULT_CONFIG.psu` is `DEFAULT_PSU`, and it's still the same object. - `CONFIG_FORMAT` → config (`ConfigFile.format` is `typeof CONFIG_FORMAT`). - `MAX_EVENTS` → events (plan row 236). - `CaptureConfig` → hid/calibration (`CaptureState.config`). - `EXPORT_MULTICAST_ADDR` / `EXPORT_PORT` / `COMMAND_PORT` → dcs (plan rows 1–3). The model files import their types from the contracts. No names changed: `LogRow` → `StreamRow` and `CaptureState` → `RecordState` wait for R3.4. All importers moved to the topic path: 49 files imported `@shared/ipc`, 99 existing files changed in total (imports, plus the moved declarations in the 13 model files). Tests changed import paths only. The one dynamic import in `session.test.ts` now reads `@shared/config/ipc`. ## Cycles broken (C2) | Cycle | How | |---|---| | config-file ↔ ipc (value) | `config-file` imports the config contract. `sanitizeConfig` calls a model (`sanitizePsu`), so it can't stay in a contract. It moves to `shared/settings-schema.ts`, the plan's file name, still in shared until R1.4 moves it to `main/config/` | | config-file → cal-cache → ipc → config-file | `cal-cache` takes `CalSnapshot` from the hid/calibration contract, and the contract no longer imports `config-file` | | events ↔ ipc (type) | `LogEvent` and friends are in the events contract. `events.ts` reads `DeviceStatus`, `RelayState` and `SourceMode` from the gateway, dcs and config contracts | | gateway → events → ipc → gateway | `GatewayStatus` is in the gateway contract | | cal-cache ↔ ipc (type) | The cache shapes are in the hid/calibration contract | | node-kind ↔ node-mapping (model ↔ model) | `NodeKind` and `NodeIdentity` both move into the nodes contract. `node-kind` no longer imports `node-mapping`; `node-mapping` → `node-kind` (`isAdoptableKind`) is the only edge left. No code moved between the models | ## Lint counts | | Before (552b050) | After | |---|---|---| | `lint:deps` | 0 errors, 65 warnings | **0 errors, 23 warnings** | | C2 cycles | 6 | **0** (→ error) | | P3 shared → process | 0* | **0** (→ error) | | C1 contract → non-contract | 0* | **0** (→ error) | | P4 renderer → non-contract shared | 53 | 17 (the rest are value imports that leave in R3.x) | | P6 electron outside app | 5 | 5 (R1.4) | | T2 renderer deep import | 1 | 1 (`logo.png`, R3.4) | | `npm run lint` | 0 errors, 32 warnings | 0 errors, 32 warnings | | `vitest` | 62 files, 856 tests | 62 files, 856 tests | \* Before this PR, no file matched the `shared/<topic>/(index\|ipc).ts` contract pattern, so P3 and C1 had nothing to report. §5.4 counted the same 8 edges by hand-mapping; those are gone now. I checked that the rules fire: a probe import from `events/ipc.ts` into `events.ts` errors on both C1 and C2. `.dependency-cruiser.severity.json` flips `process`, `contracts`, `cycles` and `formatLeaf` to `"error"`. `formatLeaf` has nothing to match until R1.3b. ## API-equality guard `src/shared/app/ipc.compat.test-d.ts` is type-only; vitest and the build don't pick it up. `npm run typecheck` checks it under both tsconfigs. It holds a frozen copy of the pre-split `SkyhawkApi`, `PushChannels`, `IPC` and `CTRL`, and asserts exact `Equal<Old, New>` for all four. The first commit (8e3830f) added it against the old `@shared/ipc` and typechecked green. It's still green after the split. **Fail-proof.** I made each change temporarily, ran typecheck, then reverted: - `exportLog(text: string, mode?: number)` gives `ipc.compat.test-d.ts(187,10): error TS2344: Type 'false' does not satisfy the constraint 'true'` (SkyhawkApi). - `serialTraffic: 'serial:traffik'` in `GATEWAY_PUSH` gives the same error at (189,10) (IPC). - `SettingsStore.getState(): AppConfig & { extra: 1 }` gives the same error at (199,3) (zustand fit). **Preload runtime.** The preload still keeps its explicit method table. It now imports `CTRL` and the types from `@shared/app/ipc` and the topic contracts. Its exposed API was diffed at runtime, before and after: the preload ran with `electron` stubbed, and every method was called with sentinel args while recording what reached `ipcRenderer`. The result is identical: key `skyhawk` and 41 methods (`on`, `getConfig`, `setConfig`, `exportConfig`, `openConfigImport`, `applyConfigImport`, `startRelay`, `stopRelay`, `getStatus`, `connectDevice`, `disconnectDevice`, `exportLog`, `getEvents`, `clearEvents`, `exportEvents`, `toggleCapture`, `openReplay`, `getHidAvailability`, `refreshNodes`, `getNodes`, `setNodeMapping`, `adoptNode`, `forgetNode`, `resetNode`, `resetNodeMapping`, `previewNodeReset`, `getNodeRegistry`, `hideNode`, `setSerialMonitor`, `dumpSerialPorts`, `revealDebugLog`, `calHello`, `calRead`, `calSessionOpen`, `calStreamSelect`, `calCommit`, `calReset`, `calCacheRead`, `calCacheStore`, `calCacheDrop`, `calSessionClose`). Each one invokes the same channel with the same args. `IPC` and `CTRL` hold the same key→channel pairs as before (also checked at runtime). ## Shim status There is **no shim**. `src/shared/ipc.ts` is deleted, and nothing in `src/` imports `@shared/ipc` or a relative `./ipc` that points at it. Importers use `@shared/<topic>/ipc`, not the bare `@shared/<topic>`. Today `@shared/gateway`, `@shared/events`, `@shared/hid` and `@shared/nodes` resolve to the model files `gateway.ts`, `events.ts`, `hid.ts` and `nodes.ts`, because a file wins over a directory index. The `index.ts` files are in place; once R1.4 moves those models to main, the imports can drop `/ipc`. ## Verification `npm run typecheck`, `npm run lint` (0 errors), `npm run lint:deps` (0 errors), `npx vitest run` (856/856) and `npm run build` all pass. The T14 app-shell component test passes unchanged. Refs #98
R1.3b of the structure rewrite (#101). Adds the `src/shared/format` leaf and points main, shared and the renderer at it. Every merged copy has one home now. Nothing changes on screen, in wording or in behaviour. - `src/shared/format/{text,time,units,events,psu}.ts`, with `index.ts` as the only import path. Main and the renderer import `@shared/format`. Files inside `src/shared` use `./format`, the same convention as `./firmware`. - The folder imports nothing else in `src`, type-only imports included. `L-format-leaf` stays at error and is clean. - Each module has its own test file (27 tests). The expected values come from the callers' existing tests and from today's rendered output. They pass in UTC, America/Los_Angeles and Asia/Kolkata. - The existing tests' assertions are unchanged. Only their import paths moved (11 files). ## Merged helpers | Helper (`@shared/format`) | Copies merged (base `ef57688`) | How equality was proven | |---|---|---| | `plural` | `renderer/overview.ts`, `renderer/tabs/settings/settings-view.ts`, `shared/config-file.ts` | sweep A | | `onOff` | `shared/config-file.ts` (moved) | sweep A | | `title` | `shared/gateway.ts` (moved) | sweep A (incl. `''`, which throws in both) | | `DASH` | `renderer/cockpit-panels.ts` (+ the `'—'` in `SidePanels.tsx` `pv`) | value identity | | `fileName` | `renderer/log-view.ts` (moved) | sweep A | | `errorText` | `main/node-mapping.ts` `message`, plus the inline copy in `main/config-transfer.ts` | sweep A over Error, its subclasses, strings, numbers, null, undefined, objects, Symbol, BigInt and null-prototype objects | | `pad`, `pad3` | `renderer/store.ts`, `renderer/tabs/Serial.tsx`, `renderer/tabs/settings/settings-view.ts`; `shared/events.ts` `pad(n, w = 2)`: `pad(n)` → `pad`, `pad(n, 3)` → `pad3` (its only callers) | sweep N | | `fmtTime` | `renderer/store.ts`, `renderer/tabs/Serial.tsx` | sweep T | | `hhmmss` | `renderer/connection-summary.ts` `clock`, plus the inline `fmtTime(e.t).slice(0, 8)` in `tabs/cockpit/BoardEvents.tsx` | sweep T | | `hhmm` | `renderer/tabs/Cockpit.tsx` `hhmm`, `renderer/tabs/settings/settings-view.ts` `clock` | sweep T | | `day` | `renderer/tabs/settings/settings-view.ts` (moved) | sweep T | | `fmtDateTime` | `shared/events.ts` (moved) | sweep T | | `lastSeenLabel` | `renderer/cockpit.ts` (moved) | sweep T | | `seenAgo` | `renderer/cockpit-adopt.ts` (moved) | 40k (firstSeenAt, now) pairs incl. `undefined`, NaN and ±Infinity | | `fmtUptime` | `renderer/store.ts` (moved) | sweep N | | `stamp` | `main/config-transfer.ts`, plus the inline `toISOString().replace(…)` in `main/node-mapping.ts` | 5k dates incl. an invalid date (throws `RangeError` in both) | | `volts` (`12.00 V`) | `shared/events.ts` (moved) | sweep N | | `voltsValue` (`12.00`) | `renderer/tabs/Cockpit.tsx` `volts`; the number inside `SidePanels.tsx` `pv` | sweep N | | `amps` | the inline `${(… / 1000).toFixed(2)} A` in `shared/events.ts` `railReading` and in `renderer/cockpit-adopt.ts` `rail` | sweep N through `displayCurrentMa` | | `fmtBytes` | `renderer/store.ts` (moved) | sweep N | | `fmtPct` | `renderer/cockpit.ts` (moved) | sweep N | | `hex2`, `hex4` | `renderer/store.ts` (moved) | sweep N | | `eventText` | `shared/events.ts` (moved). The parameter is now the structural `{ event; detail? }`, because the leaf can't import `LogEvent`. Every caller passes a `LogEvent` | every event × detail combination incl. `''` and `undefined` | | `PSU_LIMITS` (`psu.ts`) | `shared/config-file.ts` `LIMITS` `{key,label,unit}`, `renderer/tabs/settings/CockpitSection.tsx` `LIMITS` `{k,label,unit,custom}` | deep-equal against both base tables | **Equality proof.** This is a throwaway vitest file and is not committed. A script copied every old copy verbatim out of `git show ef57688:<file>` and checked that each snippet appears byte-for-byte in base. The test then compared each old copy with its new home. It checked the return value, or the thrown error's class and message, using `Object.is`, so `NaN` and `-0` count. - **Sweep N** (numbers): NaN, ±Infinity, ±0, ±0.5, the rounding edges `0.005`, `9.95`, `1023.5` and `1024`, the 16-bit edges `65535`, `65536` and `0x1ffff`, negatives, `±1e21`, the safe-integer limits, every integer in −3000…3000 (plus /7 and ×1.0001), and 20k seeded random values spanning 12 orders of magnitude. - **Sweep T** (times): NaN, ±Infinity, the ±8.64e15 Date limits, 40k seeded timestamps within ±13 years of 2026-10-16, and Jan 1 / Dec 31 23:59:59.999 of each year from 1969 to 2040. - **Sweep A** (text): plurals over 8k counts × 9 words × an omitted, irregular or empty `many`; 14 strings for `title`, incl. `''`, `ß` and an emoji; 10 path shapes for `fileName`. The sweeps ran **1,724,217 checks, all equal**. The proof can fail: changing `pad3`'s fill or `DASH` turned it red, and it went green again after reverting. **Render compare.** A throwaway vitest file, not committed, rendered 23 views through the R0.2 harness. The views are: - Overview, at two stats ticks; - Cockpit (live, and link-down); - four node sheets, two PDU sheets, and the Adopt dialog with two selections; - Connection, in two scenarios; - Serial; - Log › Stream and Log › Events; - all five Settings sections, plus the Import preview. They ran on base and on head, after prettier. `diff -r` shows them identical, and the joined HTML has sha1 `e59c2ce8…` on both sides. ## Copies kept separate (they differ) - **`volts` vs `voltsValue`.** `events.ts` prints `12.00 V`. `Cockpit.tsx`'s `volts` and `SidePanels.tsx`'s `pv` print `12.00`, with no unit. - **`hhmm` vs `hhmmss`.** `hhmm` is `21:14` (`Cockpit.tsx` `hhmm`, `settings-view` `clock`). `hhmmss` is `21:14:07` (`connection-summary` `clock`, BoardEvents). - `hhmmss` stays defined as `fmtTime(ms).slice(0, 8)`. Its NaN output is `NaN:NaN:`, so rebuilding it from `pad` would change it. ## Left in place (and why) - **`SEVERITY_GLYPH`, `isProblem`** (`shared/events.ts`) and **`SEVERITY_NAME`** (`tabs/Log.tsx`). They are keyed by the events contract's `EventSeverity`. The leaf can't import it, because depcruise counts type-only imports. - Restating the severity union in the leaf would put a second owner on a contract rule. - §3.6 row 908 also schedules `SEVERITY_NAME` for R3.4. `format/events.ts` holds only `eventText`. - **`rail` text** (`cockpit-adopt.ts`). It needs `displayCurrentMa`, a model rule from `shared/node-telem`, so it stays local. It is now built from the shared `volts` and `amps`, with identical output. - **`pv`** (`SidePanels.tsx`). Its live/undefined gating is view logic, so it stays local. It is now `voltsValue` + `DASH`. - **`main/index.ts:107, :126`**: the inline `${err instanceof Error ? err.message : err}`. This is not merged into `errorText` because it differs on a `Symbol`: a template literal throws a `TypeError`, while `String()` returns `Symbol(x)`. Further copies found and left untouched (different output, or inline in a file this PR doesn't otherwise change): - `Connection.tsx:11` `usbId` is like `hex4` but has no 16-bit mask. - `Connection.tsx:259` has an inline `m[0]!.toUpperCase() + m.slice(1)`. - `Cockpit.tsx:257` has `const dash = '—'`, and there are other inline `'—'` literals. - `cockpit-panels.ts:313-317` has inline `(x / 1000).toFixed(2)`. `reportAge`'s `ago` is there too. - `Cockpit.tsx` has `${a.toFixed(2)} A`, where the input is already in A. - `config-transfer.ts:82` has `toISOString().slice(0, 10)`, which is the UTC date and not the local `day`. ## Grep proof ``` $ git grep -nE "(const|function) (plural|onOff|title|DASH|fileName|message|errorText|pad|pad3|fmtTime|hhmm|hhmmss|clock|day|fmtDateTime|lastSeenLabel|seenAgo|fmtUptime|stamp|volts|amps|fmtBytes|fmtPct|hex2|hex4|eventText|LIMITS)\b" -- src ':!src/shared/format' | grep -v '\.test\.' (no output) $ git grep -nE "err instanceof Error \? err\.message : String\(err\)|toISOString\(\)\.replace\(/\[:\.\]/g|fmtTime\([^)]*\)\.slice\(0, 8\)|padStart\(3, '0'\)" -- src ':!src/shared/format' | grep -v '\.test\.' (no output) ``` ## Checks (local) - `npm run typecheck` is clean. - `npm run lint` has 0 errors. It has 32 warnings, and each changed file has the same count as on base. - `npm run lint:deps` has 0 errors and 23 warnings, the same list as base (P4 17, P6 5, T2 1). - `npx vitest run`: 67 files, 883 tests. That is 62 / 856 plus the 5 new files and 27 new tests. - `npm run build` passes. - `prettier --check src` is clean. Refs #98
…d log (#111) R1.4a of the structure rewrite (#101, phase #98). This PR only moves files. Nothing changes for the user: the IPC channels, the files written under `<userData>`, and the startup and shutdown order are all the same. ## What changes - Main's own files move into `src/main/<topic>/` folders. Each topic gets an `index.ts` that exports only what other topics use. - `src/main/index.ts` is now a one-line `import './app'`. The old entry moved whole to `app/index.ts`. - `biosconfig.ts` and its test are deleted (UD-E). Nothing imported them. - **P6:** only `main/app` imports electron now. Paths and the debug log are handed in as ports. - `.dependency-cruiser.severity.json`: `crossProcess` (P1/P2) and `electron` (P6) are now `error`. DeviceLink and Session stay whole until R2.2 and R2.3. The `src/shared` models are untouched (R1.4b). No exported symbol was renamed. ## Old path → new path | Old | New | |---|---| | `index.ts` | `app/index.ts` (the entry `index.ts` is now `import './app'`) | | `session.ts` (+ `.test`, `.seam.test`) | `app/session.ts` | | `debug.ts` | `app/debug-log.ts` | | `user-data.ts` (+ test) | `app/user-data.ts` | | `serial.ts` (+ test) | `gateway/serial-bridge.ts` | | `device-link.ts` | `gateway/device-link.ts` | | `net.ts` | `dcs/transports.ts` | | `decode.ts` (+ test) | `dcs/sim-telemetry-decoder.ts` | | `replay.ts` (+ test) | `dcs/replay-source.ts` | | `stats.ts` | `dcs/stream-stats.ts` | | `reference/dcsbios-metadata.ts` | `dcs/dcsbios-metadata.ts` | | `node-hub.ts` (+ test, seam test) | `nodes/node-hub.ts` | | `phase0-probes.test.ts` | `nodes/phase0-probes.test.ts` (it probes NodeHub) | | `node-mapping.ts` (+ test, seam test) | `nodes/mapping-store.ts` | | `hid.ts` (+ test) | `hid/hid-reader.ts` | | `callink.ts` (+ test) | `hid/calibration/cal-link.ts` | | `cal-cache.ts` | `hid/calibration/cache-store.ts` | | `settings.ts` | `config/settings-service.ts` | | `config-transfer.ts` (+ test, seam test) | `config/config-transfer.ts` | | `biosconfig.ts` (+ test) | deleted | All of these are `git mv`. Over the whole diff, git detects every one as a rename except `app/index.ts`. The entry file stays at `src/main/index.ts`, so `app/index.ts` shows as a copy of it, and `src/main/index.ts` as rewritten. The first commit (c836b36) is renames only. ## Topic `index.ts` exports | Topic | Exports | |---|---| | `gateway` | `DeviceLink`, `DeviceLinkDeps`, `listSerialPorts` | | `dcs` | `createTransport`, `Transport`, `Decoder`, `Recorder`, `ReplaySource`, `ReplayInfo`, `Stats` | | `nodes` | `NodeHub`, `withFaultInfo` | | `hid` | `HidReader` | | `hid/calibration` | `CalLink`, `CalNackError`, `CalTimeoutError`, `loadCalCache`, `saveCalCache` | | `config` | `loadConfig`, `saveConfig`, `ConfigTransfer` | | `app` | the entry itself (its side effects, unchanged) | ## Ports added (P6) Each port is declared by the module that uses it. `app` provides the implementation. | Port | Shape | Declared in | Provided by | |---|---|---|---| | `MappingPaths` | `{ nodeMappingFile(): string }` | `nodes/mapping-store.ts` | `app/user-data.ts` `storePaths()`, via Session → DeviceLink → NodeHub | | `CachePaths` | `{ calibrationCacheFile(): string }` | `hid/calibration/cache-store.ts` | `storePaths()`, via Session → DeviceLink, and `app/index.ts` for ConfigTransfer | | `SettingsPaths` | `{ configFile(): string }` | `config/settings-service.ts` | `storePaths()` in `app/index.ts` | | `Log` | `(tag: string, data?: unknown) => void` | `gateway/serial-bridge.ts`, `hid/calibration/cal-link.ts`, `nodes/node-hub.ts` (one each: one shared declaration would create an import cycle or a cross-topic import) | `app/debug-log.ts` `debugLog`, via Session → DeviceLink | | `DeviceLinkDeps` | `{ paths: { nodeMappingFile(); calibrationCacheFile() }, log: Log }` | `gateway/device-link.ts` | `app/session.ts` | | `NodeHubDeps` | `{ paths?: MappingPaths, log?: Log }` | `nodes/node-hub.ts` | DeviceLink (passes its own deps) | - **`storePaths(userData: () => string)`** returns getters. `app/index.ts` calls it after the userData override, as `storePaths(() => app.getPath('userData'))`. Each path is still worked out at every use, the way `join(app.getPath('userData'), name)` was. So the timing is the same as before. - **`app/debug-log.ts` is unchanged.** It is in `app`, so it may keep using electron for `debug.log`. - **Optional parameters.** The new constructor arguments on `SerialBridge`, `CalLink` and `NodeHub` are optional and trailing: `log` defaults to a no-op, and NodeHub's `paths` defaults to one that throws on first use. Existing unit tests build these classes with their old arguments. - DeviceLink's deps are required, and production always passes everything. - `Session(emit, paths?)` defaults to the same `storePaths(() => app.getPath('userData'))`, for tests that build a Session on its own. Session is in `app`, so it may import electron. ## lint:deps, per rule | Rule | Before (ca57ba6) | After | |---|---|---| | P4-renderer-imports-contracts-only | 17 warn | 17 warn | | P6-electron-only-in-app | 5 warn | **0** (now `error`) | | P1 / P2 | 0 | 0 (now `error`) | | T2-renderer-no-deep-topic-import | 1 warn | 1 warn | | topic-main-gateway | n/a (no folders) | 3 warn | | T2-main-no-deep-topic-import | n/a | **0** | | **Total** | 23 warn, 0 error | 21 warn, 0 error | The only new main warnings are the expected ones, all due to DeviceLink spanning topics until R2.2: - `gateway/device-link.ts → nodes/index.ts` (NodeHub, withFaultInfo) - `gateway/device-link.ts → hid/index.ts` (HidReader) - `gateway/device-link.ts → hid/calibration/index.ts` (CalLink, cache) The old `→ app` edges (`debugLog` from gateway, nodes and hid/calibration) are gone: Log is injected now. `hid/hid-reader.ts` used to import `SIMGATEWAY_VID/PID` from `serial`. Those two constants are just `USB_IDENTITY.vid/pid`, so it now reads `USB_IDENTITY` from `@shared/firmware` directly. The values are identical, and hid has no import from gateway at all. `serial-bridge` still exports `SIMGATEWAY_VID/PID` as before. ## Proof the paths are unchanged 1. **B13** (`app/session.seam.test.ts`, through `makeServices()`) passes unchanged. The harness now builds its paths with `storePaths(() => world.userData)` and passes them exactly as `app/index.ts` does. B13 writes `node-mapping.json` and `config.json` through those paths and reads them back by name. 2. **B17** (`nodes/mapping-store.seam.test.ts`) and `mapping-store.test.ts` hand the store `storePaths(() => userData.dir)`. They then check the file at `join(userData.dir, 'node-mapping.json')`, and all their assertions are unchanged. 3. **New `storePaths` unit tests** (`app/user-data.test.ts`, +2) check: - the three names: `config.json`, `calibration-cache.json`, `node-mapping.json`; - that `<userData>` is worked out at each use. 4. **Built-bundle boot probe.** `out/main/index.js` was built from ca57ba6 and from this branch, and each was loaded in node with electron, serialport and node-hid stubbed. Each run went through `whenReady`, `config:set`, `debug:dump-ports`, `node-mapping:set` and `will-quit`. The two outputs are **byte-identical**, with both `SKYHAWK_USER_DATA` set and the dev default (`<appPath>/.userdata`). They match on: - the `setPath('userData', …)` call; - the order of the `app.on` registrations; - the preload and renderer paths; - all 40 IPC channels; - which files appear in `<userData>`, and what they contain. ## Tests - 881 tests in 66 files. The old count was 883 in 67 files; the difference is the 4 `biosconfig` tests removed and the 2 `storePaths` tests added. - The tests changed only import paths and `vi.mock` paths, with these exceptions: - **`mapping-store.test.ts` and `mapping-store.seam.test.ts`.** The store no longer reads electron, so their `vi.mock('electron', …getPath…)` line became a `storePaths(() => userData.dir)` handed to the store, through a two-line local `loadNodeMapping`/`saveNodeMapping` wrapper. Every call and assertion line is unchanged. - **The `makeServices()` harness** passes `paths` to `Session`, `loadConfig` and `saveConfig`, the way `app/index.ts` does. - **Two comments** in tests that named old paths were updated. - The other `vi.mock('electron')` lines are left as they were; in modules that no longer import electron they now do nothing. ## Checks - `npm run typecheck`: passes. - `npm run lint`: 0 errors, 32 warnings, the same P5 renderer warnings as on base. - `npm run lint:deps`: 0 errors. - `npx vitest run`: 881 passed. - `npm run build`: passes. Refs #98
R1.4b of the structure rewrite (#101, phase #98). This PR moves code and splits files. Nothing changes for the user, and no exported symbol was renamed. ## What changes - Every model in `src/shared` that isn't a contract, `firmware` or `format` moves into its `src/main/<topic>/` file (arch-phase0-v2 §1.2, §3.6). - The rules the renderer still calls move into `src/shared/<topic>/legacy-rules.ts`. These files are pure and marked `@deprecated`, and main imports them too, so each rule has one copy. - Imports: - contracts come from the bare `@shared/<topic>`, since `shared/gateway.ts`, `events.ts`, `hid.ts` and `nodes.ts` no longer shadow the folders; - other main topics are reached through their `index.ts`, with five exceptions listed below; - inside a topic, imports are relative. - depcruise: - P4 accepts `legacy-rules.ts`; - a new rule `P4-legacy-rules` (severity key `legacyRules: "warn"`) counts the renderer's calls into those files; - `rendererShared` (P4) is now `error`. - `src/shared` now holds only `<topic>/{index,ipc}.ts`, `<topic>/legacy-rules.ts`, `firmware/`, `format/` and their tests. DeviceLink and Session are not split; that is R2.2 and R2.3. ## Commits 1. `68ac0fb` is renames only. Each `git mv` has 100% similarity: a file that moved whole went to its new home, and a file that split went to the home of its largest part. The build is broken at this commit. 2. `24d4d3c` splits the files and fixes the imports. 3. `79a3cfe` changes the depcruise rule and severity. ## Old → new | `src/shared/…` (old) | New home | |---|---| | `dcsbios.ts` (+ test) | `main/dcs/bios-protocol.ts`, plus `main/gateway/link-framing.ts` (split) | | `main/dcs/dcsbios-metadata.ts` | merged into `main/dcs/bios-protocol.ts`, then deleted | | `capture.ts` (+ test) | `main/dcs/capture-file.ts` | | `calibration.ts` (+ test) | `main/hid/calibration/codec.ts` (CalDemux included) | | `cal-cache.ts` (+ test) | `main/hid/calibration/cache-rules.ts` (+ `mergeCalibration`, from config-file) | | `config-file.ts` (+ test) | `main/config/config-file.ts` | | `settings-schema.ts`, `config.test.ts` | `main/config/settings-schema.ts` (+ `.test`) | | `gateway.ts` (+ test) | `main/gateway/gateway-health.ts`. `isLinkUp` stays in the gateway contract | | `events.ts` | split, see below. The test moves to `main/events/events.test.ts` | | `nodes.ts` (+ test) | `main/nodes/node-status-wire.ts` (minus `nodeFaultTag` → legacy) | | `node-telem.ts` (+ test) | `main/nodes/node-telem.ts` (minus `displayCurrentMa`, `RAIL_FLAG`, `PDU_FLAG` → legacy) | | `node-kind.ts` (+ test) | `main/nodes/node-kind.ts` (minus `reportedIdentity`, `isAdoptableKind` → legacy) | | `node-mapping.ts` (+ test) | `main/nodes/mapping-rules.ts`, `registry.ts`, `membership.ts` (split) | | `node-rules.ts` (+ test) | `main/nodes/node-model.ts`, `power-zone.ts` (split; `isReportableNew` → legacy) | | `psu.ts` (+ test) | `main/nodes/psu-limits.ts` (minus the renderer's four → legacy) | | `hid.ts` (+ test) | `main/hid/hid-report.ts` | | `axis-capture.ts` (+ test) | `shared/hid/calibration/legacy-rules.ts` (+ `.test`), whole | Each test file moved whole and its cases were not split. A test that covers symbols that now live in several files imports each one from its new home. ## The split files Code is cut and pasted verbatim. A script compared every non-comment, non-import line of the 17 old files with the new files, as multisets, with the `export` keyword ignored. There is no difference in either direction. What changed is new header comments, the `@deprecated` tags, and `export` added where a private helper is now used across files. | From | Symbols | To | |---|---|---| | `events.ts` | `NodeLookup`, `NodeEventTracker` | `main/nodes/node-events.ts` | | | `RAIL_FLAGS`, `PDU_STATUS_FLAGS`, `TEMP_FLAGS`, `railReading` (private → exported) | `main/nodes/faults.ts` | | | `StatusFlag`, `STATUS_FLAGS`, `faultLabel` | `main/events/status-flags.ts` (OQ4) | | | `clientErrorEvent`, `EventLog`, `cell`, `EVENTS_TSV_HEADER`, `eventsToTsv` | `main/events/event-log.ts` | | | `deviceEvent` | `main/gateway/link-events.ts` | | | `RelayContext`, `MODE_LABEL`, `relayEvents` | `main/dcs/relay-events.ts` | | | `SEVERITY_GLYPH`, `isProblem` | `shared/events/legacy-rules.ts` | | `dcsbios.ts` | `encodeExportFrame`, `parseCommand`, `LineAssembler` | `main/gateway/link-framing.ts` (OQ2) | | | the rest (`formatCommand` included, for R3.4) + `ACFT_NAME`, `A4EC_ADDRESS_RANGE` | `main/dcs/bios-protocol.ts` | | `node-mapping.ts` | `isPduName`, `registryDefaults` | `main/nodes/registry.ts` | | | `adopted`, `inCockpit`, `forgettable` (private → exported) | `main/nodes/membership.ts` | | | the rest (`checkPowerZone` included; `isNodeId` now exported for registry) | `main/nodes/mapping-rules.ts` | | `node-rules.ts` | `PDU_GROUP_WINDOW_MS`, `PDU_GROUP_POLL_CATCH_MS`, `droppedTogether`, `pduOutages` | `main/nodes/power-zone.ts` | | | `NodeModelInput`, `buildNodeModel`, `cameOnline` | `main/nodes/node-model.ts` | | | `isReportableNew` | `shared/nodes/legacy-rules.ts` | | `config-file.ts` | `mergeCalibration` | `main/hid/calibration/cache-rules.ts` | ## legacy-rules contents Each file opens with: *"Renderer still calls these; they move to main in R3.x and this file disappears."* Each export is `@deprecated`. | File | Exports | Renderer callers | |---|---|---| | `shared/nodes/legacy-rules.ts` | `RAIL_FLAG`, `PDU_FLAG`, `displayCurrentMa`, `nodeFaultTag`, `reportedIdentity`, `isAdoptableKind`, `isReportableNew`, `PSU_PRESETS`, `PSU_LIMIT_MAX`, `isLimit`, `effectivePsuLimits`, `parsePsuLimit` | `cockpit.ts`, `cockpit-panels.ts`, `cockpit-adopt.ts`, `overview.ts`, `tabs/Cockpit.tsx`, `tabs/settings/CockpitSection.tsx` | | `shared/events/legacy-rules.ts` | `SEVERITY_GLYPH`, `isProblem` | `log-view.ts`, `tabs/Log.tsx` | | `shared/hid/calibration/legacy-rules.ts` | the whole of `axis-capture` | `CalibrationDialog.tsx`, `calibration-controller.ts` | - `DEFAULT_PSU` was already in the nodes contract and stays there. - `PSU_LIMIT_MAX` and `isLimit` aren't called by the renderer. They are here because `parsePsuLimit` needs them, and shared can't import main. - `reportedIdentity` reads `NODE_KINDS` from `../firmware`, aliased as `NODE_KIND` so its body is unchanged. - The renderer's production code now imports from `src/shared` only contracts, `firmware`, `format` and these three files. ## lint:deps, per rule | Rule | Before (111e907) | After | |---|---|---| | P4-renderer-imports-contracts-only | 17 warn | **0** (now `error`) | | P4-legacy-rules (new) | n/a | 10 warn | | topic-main-gateway | 3 warn | 3 warn (unchanged: DeviceLink until R2.2) | | T2-main-no-deep-topic-import | 0 | 5 warn | | T2-renderer-no-deep-topic-import | 1 warn | 1 warn | | every other rule | 0 | 0 | | **Total** | 21 warn, 0 error | 19 warn, 0 error | No topic-direction edge is new. The five T2-main deep imports are deliberate: - **`nodes/node-status-wire.ts → gateway/link-framing.ts`** (`encodeExportFrame`). Going through `gateway/index.ts` would close an import cycle (C2, error): gateway index → `device-link` → nodes index → `node-hub` → `node-status-wire` → gateway index. The cycle goes away when R2.2 splits DeviceLink. - **`config/config-file.ts → nodes/mapping-rules.ts`, `nodes/psu-limits.ts`, `hid/calibration/cache-rules.ts`**, and **`config/settings-schema.ts → nodes/psu-limits.ts`**. The renderer's test helpers (`renderer/test/scenarios.ts`, the t13 test and `test-fixtures/config.ts`) import `config-file`, so the renderer `tsconfig.json`, which has no Node types, typechecks its closure. The nodes and hid/calibration indexes also export NodeHub and CalLink, which pull in `node:fs` and `Buffer`, so going through the indexes fails `npm run typecheck`. Each import carries a comment that says why. ## Proof nothing changed 1. **Tests:** 881 passed in 66 files, the same count as base. Test files changed only import paths, plus four comments that named old paths: three notes in `reference.test.ts` and one JSDoc in `scenarios.ts`. A diff filter over every test, fixture and harness file shows no other line changed. 2. **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`). `out/main/index.js` from base and from head, each with electron, serialport and node-hid stubbed, through `whenReady`, `config:set`, `debug:dump-ports`, `node-mapping:set` and `will-quit`. The outputs are **byte-identical**, both with `SKYHAWK_USER_DATA` set and with the dev default (`<appPath>/.userdata`). That covers: - the `setPath` call; - the `app.on` order; - the preload and renderer paths; - all 40 channels; - the files in `<userData>` and what they contain. The preload bundle is byte-identical too. 3. **Render compare.** A one-off jsdom test (not committed) dumps the innerHTML and text of each view below, base vs head. The dumps are byte-identical (sha1 `4ea8c9e3…`): - Cockpit; - Cockpit with the PDU board panel open, the degraded board panel open, and the Adopt dialog open; - Overview; - Connection; - HID; - the Calibration dialog, link up and link down; - Settings › Cockpit, PSU preset and custom; - Log, in the Stream view, the Events view and Problems only. The scenario was built to exercise each moved rule: a degraded node (`nodeFaultTag`), PDU pages with 12 V UNDER-V and TEMP WARN raised, a negative 5 V current (`displayCurrentMa`, `RAIL_FLAG`, `PDU_FLAG`), two reportable new boards (`isReportableNew`, `isAdoptableKind`, `reportedIdentity`), the NX650 preset and custom limits (`PSU_PRESETS`, `effectivePsuLimits`), and events of all four severities (`SEVERITY_GLYPH`, `isProblem`). ## Left as is, on purpose - `checkPowerZone` stays in `mapping-rules.ts`. §3.6 puts it in `power-zone.ts`, but it calls `effectiveMapping` and `MappingError`, and mapping-rules calls it back, so the move would create an import cycle (C2, error). Its X4 co-location is R3.2 work anyway. - `MODE_LABEL` is in `dcs/relay-events.ts`, next to its only user, as §1.2 lists it. §3.6 names `relay-rules.ts`, a file that doesn't exist until R3.3. - `isObject` in config-file is not merged into mapping-rules' `isPlainObject` (§3.6 MERGE). That is a dedupe, not a move. - `nodes/faults.ts` holds only the rail and PDU flag text from `events.ts`. `FAULT_TEXT` and `withFaultInfo` live in `node-hub.ts`, which is main's own file, and `nodeFaultTag` is in legacy-rules. - The generated firmware files (`node-status.generated.ts`, `axis-cal.generated.ts`) and the matching `scripts/sync-a4ec.ts` doc strings still say `shared/nodes.ts`, `shared/node-telem.ts` and `shared/calibration.ts`. Changing them means regenerating under the freshness gate, so they wait for the next sync. ## Checks - `npm run typecheck`: passes. - `npm run lint`: 0 errors, 32 warnings, the same P5 renderer warnings as on base. - `npm run lint:deps`: 0 errors, 19 warnings. - `npx vitest run`: 881 passed in 66 files. - `npm run build`: passes. Refs #98
R2.1 of the structure rewrite. Main's three JSON files under
`<userData>` now share one set of read, parse and write mechanics. Each
file keeps its own **policy object**, so it behaves exactly as it does
today. The node mapping's state and persistence move out of `NodeHub`
into a `MappingStore` service.
There is no UI change and no functional change. Each file keeps today's
read, write, error and recovery behaviour (X13). Unifying them (fsync,
`.bak`) is fix track, not this PR.
## Commits
1. `test(config): pin config.json load and save behaviour (B97)`. This
tier-2 FILE-seam test was written first, against today's code. It was
proved able to fail by four mutations, each turning at least one case
red:
- rethrowing write errors;
- writing compact JSON;
- returning the shared `DEFAULT_CONFIG` object;
- skipping `sanitizeConfig`.
2. `refactor(nodes): the MappingStore service and the JSON store`.
## The JSON store: `config/json-store.ts`
`jsonFile(path, policy)` returns `{ read(), write(value) }`.
- **`read()`** resolves the path; a throw there propagates. It then
reports what it found:
- `missing`;
- `read` (with the parsed data);
- `unreadable` (with the error);
- for a file that doesn't parse: `damaged` (left in place), or
`movedAside` / `stuck` (moved aside, or couldn't be).
- **`write()`** always writes `JSON.stringify(value, null, 2)`.
The store knows nothing about what a file means. The owner decides that
through its port: defaults, an empty cache, an empty or read-only
mapping.
| Policy field | What it reproduces (base 64310ec) | config.json |
calibration-cache.json | node-mapping.json |
|---|---|---|---|---|
| `atomic` | Write to `<file>.tmp`, rename it over the file, and
`rmSync(tmp, {force})` on failure (`nodes/mapping-store.ts:67-80`). When
false, `writeFileSync` writes in place (`settings-service.ts:23-27`,
`cache-store.ts:27-32`). | false | false | **true** |
| `reportWriteErrors` | When true, a failed write is handed back as `{
error }` and `saveNodeMapping` throws `MappingSaveError("Could not save
node mapping: …")`. The path is resolved *outside* that guard, so a
throwing path throws raw, as at `mapping-store.ts:68`. When false,
everything is swallowed, the path included, because it was inside `try`
(`settings-service.ts:23`, `cache-store.ts:28`). | false | false |
**true** |
| `moveAsideDamaged` | When true, an unparseable file is renamed to
`<file>.bad-<stamp>`. If the rename fails, the result is `stuck`, which
loads read-only (`mapping-store.ts:38-53`). When false, the file is left
where it is and the defaults load. | false | false | **true** |
**Read-only.** Read-only behaviour (newer version, unreadable file,
stuck file) isn't a store mechanic. It is the mapping format's meaning,
so it lives with the owner in `nodes/mapping-file.ts`, with today's
exact messages (`mapping-store.ts:34-59`). The plan's `readOnlyIfNewer`
field would have made json-store know the mapping's version, so it isn't
a field. The plan's `throwOnError` became `reportWriteErrors`; the
reason is under *Didn't match the brief* below.
## What moved from NodeHub into MappingStore
`nodes/mapping-store.ts` is the `MappingStore` service. These moved into
it from `node-hub.ts`:
- `loaded`;
- `store()` (lazy load);
- `mapping()`;
- `save()` (no-op for the same file, refused while read-only, written
before it is committed);
- `commit()`;
- `persist()` (logs `nodes.mapping` and commits unsaved);
- the `NO_PATHS` fail-loud default, now `NO_FILE` with the same message.
NodeHub builds one MappingStore and delegates to it:
- `mapping()` → `store.mapping()`;
- `persist` → `store.persist`;
- `update` → `store.save`.
`commit` marks the model dirty through an `onCommit` callback. The
mapping rules stay in `mapping-rules.ts`.
Today's `loadNodeMapping` / `saveNodeMapping` / `MappingSaveError` /
`LoadedMapping` are now the file format in **`nodes/mapping-file.ts`**,
over the port. They are a separate module from the service because
NodeHub's tests mock the file layer below the store
(`vi.mock('./mapping-file')`). That way the real
`save`/`persist`/read-only logic still runs under B11, B12, B18 and B19.
## Ports
- **`MappingFileIO`** is declared in `nodes/mapping-file.ts`,
re-exported by `mapping-store.ts` and `nodes/index.ts`. It has `read()`
(missing / read / unreadable / movedAside / stuck) and `write(file) → {
error } | undefined`. Because `JsonRead` is typed by the policy, only a
JsonFile under a move-aside policy can satisfy it; TypeScript checks
this at the wiring.
- **`CacheFileIO`** is declared in `hid/calibration/cache-store.ts`. It
has `read()` (read / missing / unreadable / damaged) and a best-effort
`write(cache)`. `loadCalCache` / `saveCalCache` take it in place of
`CachePaths`.
- **Wiring.** `app/user-data.ts` `storeFiles(paths)` builds both ports
from `storePaths` and `JSON_POLICY`:
- `Session` passes them to `DeviceLink`, whose deps are now `{
mappingFile, cacheFile, log }`, and `DeviceLink` passes them on to
`NodeHub`;
- `app/index.ts` uses `cacheFile` for ConfigTransfer's calibration
section.
- **Settings.** `config/settings-service.ts` is in `config`, so it
builds its own `jsonFile` under `JSON_POLICY.config`. It stays a pair of
functions, with the same `loadConfig(paths)` / `saveConfig(config,
paths)` signatures.
- **Imports.** No new import from `nodes` or `hid/calibration` into
`config`.
## Proofs
- **Contract rows.** B11, B12, B16, B17, B18, B19, B97 and B104 pass
with their assertions unchanged. The `makeServices()` seam tests
(`session.seam.test.ts`) pass, and the harness is unchanged.
- **Test changes.** Only `vi.mock` paths changed, `./mapping-store` →
`./mapping-file`, in `node-hub.test.ts`, `node-hub.seam.test.ts`,
`phase0-probes.test.ts` and `session.test.ts`. In
`mapping-store.test.ts` and `mapping-store.seam.test.ts`, the import
path changed and the two-line local wrapper now hands the store
`storeFiles(storePaths(...)).mappingFile` in place of `paths`. Every
call and assert line is unchanged.
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`).
`out/main/index.js` was built at base and at head. The outputs are
**byte-identical** with `SKYHAWK_USER_DATA` set and with the dev default
(`<appPath>/.userdata`). The `config.json` and `node-mapping.json`
written to `<userData>` have identical sha1s.
- **One-off per-file proof** (not committed). The same script was run at
base and at head, and the outputs are **byte-identical** (71 cases, 223
lines). For config.json, calibration-cache.json and node-mapping.json,
it records the return value or the thrown error class and message, plus
the full `<userData>` listing with modes and contents, for:
- missing file;
- round trip;
- damaged JSON;
- damaged JSON in a read-only folder (move-aside fails);
- unreadable file (EACCES, and a folder);
- newer version;
- older version;
- write error (read-only folder, and read-only file);
- a stale `.tmp`, on a successful and on a failed save;
- a path port that throws on load and on save.
It also drives NodeHub through the real `Session → DeviceLink` wiring
(`importMapping`, `setMapping`, `mappingFile`) with the mapping missing,
good, damaged, damaged-and-stuck, unreadable, newer, and failing to
write. It also calls `DeviceLink.calCacheRead`.
## Didn't match the brief
- **`throwOnError` became `reportWriteErrors`.** For node-mapping.json,
today's save resolves the path *outside* its `try`
(`mapping-store.ts:68`). A path that throws there propagates raw, not as
`MappingSaveError`, and NodeHub's `persist` rethrows it rather than
logging and committing. A store that simply threw would leave the owner
unable to tell a write failure from a path failure without importing a
`config` error class. So the store hands the write failure back as `{
error }`, `saveNodeMapping` wraps only that, and the proof's "path port
throws on save" case is byte-identical.
- **A new file, `nodes/mapping-file.ts`.** It holds the file-format half
(the old `loadNodeMapping`/`saveNodeMapping`), so the tests that mock
the file layer keep running the real MappingStore logic. The brief puts
both halves in `mapping-store.ts`, but that would force those tests to
mock MappingStore itself.
- **No `readOnlyIfNewer` policy field.** Read-only stays in the mapping
format, as described under *Read-only* above.
## Checks
| Check | Result |
|---|---|
| `npx vitest run` | 67 files, 889 tests: 881 plus the 8 B97 tests |
| `npm run typecheck` | clean |
| `npm run lint` | 0 errors, 32 warnings. None of them is in a changed
file. |
| `npm run lint:deps` | 0 errors, 19 warnings, the same count as base |
| `npm run build` | passes |
Refs #99
…eviceLink (#114) R2.2a of the structure rewrite (#101, phase #99): the first half of the DeviceLink split. HID, calibration and event reporting move into their own services. The connection, the line taps, the serial monitor and the node poll stay in DeviceLink for R2.2b. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port, the files written and the order of all of them are the same. The trace proof below checks this. ## What moved where | From DeviceLink | To | Notes | |---|---|---| | `startHid`, `.hid` (the reader), `pushHid`, `.hidPushedAt`, `.hidPushTimer`, `HID_PUSH_MS`, `HID_RATE_MS` + the rate timer, `hid.reopen()` on port open, the HID part of `flushLink`, the HID part of `shutdown` | `hid/hid-service.ts` **HidService** | Its I/O is the HID reader (`hid/hid-reader.ts`) | | `.cal` (a CalLink per `connect()`), `readCalibrationState`, `gatewaySerialNumber`, `calRun`, `calHello` / `calRead` / `calSessionOpen` / `calStreamSelect` / `calCommit` / `calReset` / `calSessionClose`, `calCacheRead` / `calCacheStore` / `calCacheDrop` (through `CacheFileIO`), `.calRawBuf` + `flushCalRaw` + its timer, `.lastCal` + `calSnapshot`, `.calSerialNumber`, `exportGated`, and the calibration parts of `teardown` and `onClose` | `hid/calibration/hid-calibration-service.ts` **HidCalibrationService** | Same `CalResult`s. It is also the link's calibration frame filter (see below) | | `report()`, `.eventCbs`, `onNodeEvents` | `events/event-journal.ts` **EventJournal** | `report(events)` + `onEvents(cb)`, the same fan-out as before. Session still hosts the EventLog (R2.3) | Callers change their receiver only: `app/index.ts` IPC handlers call `session.calibration.cal*`, and Session uses `calibration.exportGated` and `calibration.calSnapshot()`. No method was renamed. **The frame filter.** CalDemux stays where it was, inside CalLink in the codec. DeviceLink still runs it first on every incoming chunk, at the same call site. It now calls it through a `CalFrameFilter` port (`ingest(chunk)`, `exportGated`), which the calibration service implements with the same code as before: `this.cal ? this.cal.ingest(chunk) : chunk`. That is how DeviceLink drops its `hid/calibration` import. ## Open/close reset order, and how it is kept `onOpen`/`onClose` and the other moments become a `LinkRider` subscription. Session adds the riders in a fixed order: `ride(calibration)`, then `ride(hid)`. At each moment, DeviceLink calls them in that order, at the exact line where their code used to be: | Moment | DeviceLink order (unchanged) | |---|---| | `start()` | link flush timer (200) → serial flush timer (50) → **riders' timers**: cal:raw (50), HID rate (1000) → roster poll timer (5000) → **riders' `start`**: HID reader → connect | | `connect()` | teardown → new SerialBridge → **`attach`**: CalLink → port callbacks → `scanning` → port start | | `teardown()` | **`release`**: CalLink close + cal state cleared → port stop → gateway reset | | port opens | roster/nodes/gateway resets, assembler → `connected` → roster request → **`opened`**: calibration read-on-open, then HID reopen | | port closes | `nodes.linkClosed()` → **`closed`**: CalLink close, held bytes passed back down the stream, snapshot cleared → disconnect or `reconnecting` | | 200 ms tick | **`flush`**: HID snapshot → node flush, gateway tick, health push (link up only) | | `shutdown()` | `nodes.linkClosed()` → clear all link timers (riders' included, same order) → teardown → **`stop`**: HID pending push, reader → `started = false` | The riders' timers go on the link's own timer list through `every(ms, fn)`. That keeps both the creation order and the clearing order the same. This matters because same-period timers fire in the order they were created. The CalLink writes through the injected `write`, which is `DeviceLink.write`, where before it wrote through the SerialBridge handle directly. These are the same port: a CalLink only writes synchronously inside a call, while it is the current one, or from its keepalive, which `close()` stops before the port changes. ## Proof - **Side-effect trace (not committed).** A one-off harness drives `Session` with fakes for the serial port, the DCS transport, the HID reader, the debug log and `node:fs`, under fake timers. It records, in order: every emit (channel + payload), the serial port's lifecycle and every byte written, DCS sends, the HID reader's lifecycle and `sampleRate` calls, every timer created or cleared (with its period), the debug-log calls, the fs writes and renames under `<userData>`, the values returned to callers, and the final `<userData>` files. The outputs at base (afadc63) and head are **byte-identical**: 9 scenarios, 1,362 recorded events (595 emits, 127 serial, 211 timer, 130 HID, 59 returned values, 38 debug-log, 13 fs, 19 DCS) and the same sha1. The scenarios: - HID at different rates (a burst inside 16 ms, spaced reports, a 1 ms flood, idle across the rate window and the roster poll, a trailing push pending at quit); - calibration frames split across chunks and interleaved with DCS-BIOS lines while the relay runs, with export gated and ungated; - every `cal*` command, including NACK, timeout, a bad protocol version, the keepalive, the assumed expiry after a failed close, and an unmatched reply; - the offline paths (no port, `scanning`, after disconnect); - cache read/store/drop, before and after the serial is known; - node status and telemetry lines, the gateway row, a v2 row, a mapping write, the roster poll and silence; - the serial monitor, including overflow; - port close mid-exchange with held bytes, requests while reconnecting, reopen, close with auto-connect off, error and Retry; - shutdown with an exchange, a held candidate and a HID push all in flight. As a sanity check, swapping the two `ride()` calls, or moving the HID reader start ahead of the roster timer, changes the trace (102 and 36 diff lines). - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`): base and head outputs are byte-identical, both with `SKYHAWK_USER_DATA` set and with the dev default. `config.json` and `node-mapping.json` have the same sha1s. - **Tests.** 67 files, 889 tests, all passing. One test edit: `session.test.ts` reached `calHello` through `session.link`, so those 2 lines now call `session.calibration.calHello()`. Their assertions are unchanged. The `makeServices()` seam tests and the component tests are untouched. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before; `src/main` has none. - `npm run lint:deps`: 0 errors, **17 warnings** (was 19). `topic-main-gateway` goes from 3 to 1. The one left is `device-link → nodes`, which is for R2.2b. - `npm run build`: OK. Refs #99
R2.2b of the structure rewrite (#101, phase #99): the second half of the DeviceLink split. What was left in DeviceLink after R2.2a goes to its owners from the decision table: the USB port to **GatewayConnection**, the line taps to **DeviceLineTap**, the Serial tab's raw view to **SerialTrafficMonitor**, and the roster poll, node lines and Gateway health to **NodeHub** (UD-C). `gateway/device-link.ts` is deleted. No test needed an alias for it. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port, the files written and the order of all of them are the same. The trace proof below checks this. ## What moved where | From DeviceLink | To | Notes | |---|---|---| | `.serial`, `.status`, `.autoConnect`, `.started`, `.timers`, `.riders` + `ride()`, `start`, `shutdown`, `setAutoConnect`, `connect`, `disconnect`, `teardown`, `onOpen`, `onClose`, `write`, `deviceStatus`, `onSerialData` (the `CalFrameFilter` call site), `fromDevice` (its byte half), `flushLink` (the 200 ms timer and its `isLinkUp` guard), `setStatus`, `raise`, `onBytes` / `onStatus` / `onError`, `LINK_FLUSH_MS`, `CalFrameFilter` / `LinkRider` / `LinkTimer` | `gateway/gateway-connection.ts` **GatewayConnection** | New: `onTraffic` (the serial monitor's tap), `attached` (was `!!this.serial` inside `requestNodes`), `exportGated` (was `deps.frames.exportGated` inside `flushLink`), `pushGatewayHealth` (the health half of `flushLink`) | | `gatewaySerialNumber` (moved into HidCalibrationService by R2.2a) | **GatewayConnection** `usbSerialNumber()` | Same body. Calibration gets it injected as `serialNumber` and still awaits it first thing in its read-on-open | | `.assembler`, the line loop of `fromDevice`, `onLine`, `DeviceLine` | `gateway/device-line-tap.ts` **DeviceLineTap** | Read-only. Node lines go to NodeHub through a gateway-declared `NodeLineSink` port, so gateway never imports nodes | | `.serialMonitor`, `.serialBuf`, `setSerialMonitor`, `onSerialTraffic`, `flushSerial`, `SERIAL_FLUSH_MS`, `MAX_SERIAL_BUF` | `gateway/serial-traffic-monitor.ts` **SerialTrafficMonitor** | Its 50 ms timer goes on the connection's timer list as a rider, in the same slot | | `.roster`, `.gateway` (GatewayWatch), `.legacyNodesWarned`, `requestNodes`, `NODES_REFRESH_MS` + the 5 s poll, `gatewayLine`, the v2-legacy warning, the node branches of `fromDevice`, the node and Gateway-health part of `flushLink`, the node resets in `onOpen`, `gateway.reset()` in `teardown`, `nodes.linkClosed()` in `shutdown` / `disconnect` / `onClose` | `nodes/node-hub.ts` **NodeHub** | It writes the roster request through the connection's `write`, through the `NodeHubLink` port. Its link moments are `NodeHub.rider`. NodeHub's own `flush` must never be called as a rider's `flush`, so the rider is a separate object and not the hub itself. The v2 warning goes out on `NodeHub.onError`, which Session wires to the same handler as the connection's errors. That keeps the `NODES` row's once-per-run dedupe in `logError` | | `isBoardPresent`, `isBusy` (`link-state.ts`) | not created | Neither exists in main today: they are renderer logic (R3.3). `isLinkUp` stays in `shared/gateway` for the renderer | Callers change their receiver only. `app/index.ts` and `test-harness/services.ts` call `session.connection.connect()` / `.disconnect()`, `session.nodes.*` (with `requestNodes`) and `session.serialMonitor.setSerialMonitor()`. `session.test.ts` changes 9 `session.link.connect()` / `.disconnect()` lines to `session.connection.*`. Their arguments and assertions are unchanged. No method was renamed. The `nodes` index now exports only what other topics use: `NodeHub`, `NodeRoster` and `MappingFileIO`. Its node-wire re-exports were DeviceLink's. ## Wiring and the order it keeps Session constructs, in this order: calibration, HID, GatewayConnection, NodeHub, SerialTrafficMonitor, DeviceLineTap. None of these constructors has a side effect; NodeHub's MappingStore still loads lazily. Two of the dependencies are circular, and both are lazy arrows, the same way calibration's `write` already was: the connection's `health` calls `nodes.gatewayHealth()`, and NodeHub's `link` is the connection. Riders are added in this order: `serialMonitor`, `calibration`, `hid`, `nodes.rider`, `lines`. At each moment, the connection calls them in that order, at the line where DeviceLink's own code used to sit: | Moment | Order (unchanged from DeviceLink) | |---|---| | `start()` | link tick (200, the connection's own) → **`timers`**: serial flush (50), cal:raw (50), HID rate (1000), roster poll (5000) → **`start`**: HID reader → connect | | `connect()` | teardown → new SerialBridge → **`attach`**: CalLink → port callbacks → `scanning` → port start | | `teardown()` | **`release`**: calibration → port stop → **`released`**: the Gateway's health reset | | port opens | **`opening`**: roster, node data, Gateway health, the v2 flag, then the line assembler → `connected` status (carrying `health()`) → **`connected`**: health `takeChanged`, roster request → **`opened`**: calibration read-on-open, HID reopen | | port closes | **`closing`**: nodes' last seen → **`closed`**: calibration, held bytes back down the stream → disconnect or `reconnecting` | | 200 ms tick | **`flush`**: HID snapshot → (link up only) **`tick`**: node flush, Gateway tick, health push | | `disconnect()` | **`closing`** → teardown → `disconnected` | | `shutdown()` | **`closing`** → all link timers cleared (same order) → teardown → **`stop`**: HID → `started = false` | Subscriptions, in order: - `connection.onTraffic` → serial monitor; - `connection.onBytes` → the relay to DCS first, then `lines.push`. That is the order DeviceLink handed bytes on in: relay first, then the line loop; - `lines.onLine` → command rows; - `connection.onError` and `nodes.onError` → the same handler; - `journal.onEvents`; - `connection.onStatus`. The rider table and this list are also written as a comment in `Session`'s constructor. ## Proof - **Side-effect trace (not committed).** This is the R2.2a harness, extended. It drives `Session` with fakes for the serial port, the DCS transport, the HID reader, the debug log and `node:fs`, under fake timers. It records, in order: every emit (channel + payload), the serial port's lifecycle and every byte written, DCS sends, the HID reader's lifecycle, every timer created or cleared (with its period), the debug-log calls, fs writes, returned values and the final `<userData>` files. - **Changes to the harness:** - the fake port now feeds the monitor tap as `SerialBridge` does (`tx` on write, `rx` before data), so the serial monitor sees real traffic; - the intents resolve through accessors that work at both base and head. - **New scenarios:** - **roster-poll-disconnect**: the poll while scanning, steady polls, Disconnect (the polls stop writing), connect/not-found/open, a drop with a reply cut mid-line, a poll while reconnecting, a reopen (the cut line does not prefix the next), and an error with Retry mid-poll; - **gateway-health**: the Gateway's own row going ok, bus-off, error-passive and cleared; unanswered polls going "not responding"; answering again; a calibration exchange pausing the clock; a drop while not responding, then a reopen; - **monitor-roster-bursts**: the monitor switched on mid-burst, the roster request tapped as tx, off before a flush, on twice, telemetry + commands + RAW, a flood across flushes, a disconnect while on; - **relay-node-lines**: node rows, a v2 row, telemetry and commands while the Bridge relay runs. This one makes the relay-before-line-tap order observable. - **Result:** base (3b9d5da) and head outputs are **byte-identical**, with the same sha1 (`a5ed9ba0…`): 13 scenarios and 2,304 recorded events (1,332 emits, 197 serial, 281 timer, 267 HID, 71 returned values, 93 debug-log, 37 fs, 26 DCS). Base is deterministic over two runs. - **It can fail.** One deliberate reorder at a time: - swapping the serial monitor and calibration riders: 4 diff lines; - subscribing the line tap before the relay: 6; - riding NodeHub before HID: 52. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with `SKYHAWK_USER_DATA` set and with the dev default: - base and head probe outputs are byte-identical; - `config.json` and `node-mapping.json` have the same sha1s; - `debug.log` is identical apart from its timestamp. - **Tests.** 67 files, 889 tests, all passing. The `makeServices()` seam tests and the component tests are untouched. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before. - `npm run lint:deps`: 0 errors, **15 warnings (was 17)**: - `topic-main-gateway` goes from 1 to **0**, because gateway no longer imports nodes; - `T2-main` goes from 5 to **4**: `nodes/node-status-wire` now imports `encodeExportFrame` through `gateway/index.ts`, with no cycle. The 4 left are the known `config → nodes` / `config → hid/calibration` deep imports, for R2.3. - `npm run build`: OK. Refs #99
R2.3a of the structure rewrite (#101, phase #99). This is the DCS half of R2.3. Session's relay, stream and capture code moves, verbatim, to the owners in the decision table (DCS side, out of Session). Settings, the event log, AppServices and the per-topic IPC are left for R2.3b. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port and to DCS, the files written, the timers and the order of all of them are the same. The trace proof below checks this. ## Tests written first Each one is its own commit, before the refactor. Each was shown to fail, then committed. - **B107: DCS link transports** (`dcs/transports.seam.test.ts`, 10 tests). - **TCP to host** runs on real sockets on 127.0.0.1. It covers connect, export in and commands out on one socket, a command queued while connecting, the 2 s reconnect after DCS drops the socket, and a refused connect (an error, then a close, retried every 2 s until Stop). It also covers a DCS that comes up after Start, and the close after Stop being reported asynchronously. - **Unicast listen** runs on real sockets on 127.0.0.1. It covers bind, then connected, export in, and commands to host:commandPort. It also covers Stop reporting the close synchronously, sharing the port with another reuseAddr listener, and a port held exclusively (`EADDRINUSE`, never connected). - **Loopback multicast** runs on a stand-in socket: whether a CI runner can join a group is not ours to pin. It covers the export port bound with reuseAddr (not listenPort), the group join, then connected. A failed join is an error and still reports connected. Socket errors pass through, and commands go to host:commandPort. - **It can fail.** One mutation at a time: - TCP reconnect delay 2000 → 1000: 1 test fails; - TCP retrying after Stop: 1; - multicast binding listenPort: 1; - no `connected` after the join: 2; - UDP Stop not reporting the close: 3; - socket without reuseAddr: 2. - **B108: the stats tick** (`dcs/stream-stats.seam.test.ts`, 7 tests). This runs through `makeServices()`, so it survives the move. - It covers: - one `stats:tick` a second, only while relaying; - bytes in and decoded frames per second; - command rate, total and last command, and bytes out to DCS in Bridge; - commands counted in Monitor with nothing sent; - errors and reconnects from both DCS and the Gateway link; - uptime; - a fresh start on every Start, and on a settings change that rebuilds the relay. - **It can fail.** One mutation at a time: - `reset` keeping errors: 2 tests fail; - total not counted: 2; - `STATS_MS` 1001: 6; - link `reconnecting` not counted: 1; - bytes out not counted: 1. ## What moved where | From Session | To | Notes | |---|---|---| | `start`, `run`, `stop`, `setRelay`, `.running`, `.relay`, `.timers`, `.replay`, `.parser`, `.decoder`, `onExport`, `fromDevice`, `onWrite`, `TELEMETRY_MS`, the telemetry and stats ticks | `dcs/dcs-relay.ts` **DcsRelay** | Bodies verbatim. `this.config` is a getter over the `RelayOptions` port; `this.events` is a `RelayEventSink` port (see below) | | the `running && relayConfigChanged → stop(); run()` block of `setConfig` | **DcsRelay** `reconfigure(prev, next)` | `run` stays private. Session's `setConfig` calls it at the same point | | `loadReplay` | **DcsRelay** `loadReplay` | See *Not as briefed* | | `.transport`, its creation and subscriptions in `run`, the stale guards, its stop in `stop`, `send` in `fromDevice` | `dcs/dcs-connection.ts` **DcsConnection** `open` / `start` / `stop` / `send` | `open` builds and subscribes; `start` binds or dials. In between, the relay sets `running` and `connecting`, as before | | `exportToDevice`, `deviceToDcs`, `RELAY_KEYS`, `relayConfigChanged`, `ForwardConfig` | `dcs/relay-rules.ts` | Adds `RelayOptions` = `Pick<AppConfig, RELAY_KEYS>`, which is exactly what the relay reads. `relayConfigChanged` is generic over it, so `AppConfig` still passes | | `relayContext` | `dcs/relay-events.ts` | Next to `relayEvents` (X9) | | `.logBuf`, `.logTimer`, `flushLog`, `LOG_FLUSH_MS`, `MAX_BATCH`, the `lines.onLine` command-row body | `dcs/stream-log.ts` **StreamLog** `open` / `close` / `onLine` / `rows` / `flushLog` | `flushLog` still emits `events:batch` (drained from the EventLog it is handed) before `log:batch`, until R2.3b splits it. `open()` returns false when already open, so `Session.open` keeps its early return | | `.recorder`, `.recordPath`, `startRecording`, `stopRecording`, `isRecording`; `Recorder` (from `replay-source.ts`) | `dcs/session-recorder.ts` **SessionRecorder** | `record(dir, chunk)` is `recorder?.record` | | `flushTelemetry` | `dcs/sim-telemetry-decoder.ts` `flushTelemetry(decoder, emit)` | Next to the decoder. The relay's 200 ms tick reads `this.decoder` at call time, as before | | `STATS_MS` | `dcs/stream-stats.ts` | | | `config`, `getConfig`, `setConfig`, `events`, `lastErr`, `logError`, `lastDevice`, `status()`, `open`, `shutdown`, the wiring | stay in **Session** | `status()` reads `{ running, relay }` from `relay.status()` | `createTransport` and the transports now take `TransportOptions`, the four settings they read. This is a type-only change. **Ports.** DcsRelay writes to the board through `RelayDevice` (`deviceStatus`, `write`, `exportGated`), which Session fills with the GatewayConnection itself. `connection.exportGated` is the same `calibration.exportGated` getter that Session read, one hop further, so `dcs` does not import `hid`. Session fills `RelayEventSink` with `events.add`, `logError` and `lastErr.delete`. **Callers change their receiver only.** - `app/index.ts` and `test-harness/services.ts` use `session.relay.start()`, `.stop()` and `.loadReplay()`, and `session.recorder.*`. - `session.test.ts` changes 24 `session.start()` / `.stop()` lines to `session.relay.*`, and imports the three rules from `../dcs/relay-rules`. - Arguments and assertions are unchanged. The `makeServices()` seam tests and the component tests are untouched. The `dcs` index now exports only what `app` uses: `DcsConnection`, `DcsRelay`, `SessionRecorder`, `Stats`, `StreamLog`. ## Wiring and the order it keeps Session constructs, in this order: calibration, HID, GatewayConnection, NodeHub, SerialTrafficMonitor, DeviceLineTap, then **SessionRecorder, StreamLog, DcsConnection and DcsRelay**. None of the new constructors has a side effect. The parser and the first decoder are built inside DcsRelay, as they were inside Session. The subscriptions are unchanged, in the same order: - `onTraffic`; - `onBytes`: `relay.fromDevice`, then `lines.push`; - `lines.onLine`: `streamLog.onLine`; - `onError` ×2; - `journal.onEvents`; - `onStatus`. The DCS side, now written in the constructor comment too: | Moment | Order (unchanged from Session) | |---|---| | `open()` | stream-log flush (33) first, then `connection.start` | | relay Start | Bridge / driven Replay refused unless the link is up → a running relay stopped → decoder, parser, stats reset → DCS error forgotten → Replay: `connected`, playback; otherwise the transport built and subscribed → `connecting` → transport start; then the telemetry (200) and stats (1000) ticks | | relay Stop | ticks cleared → replay stopped → transport forgotten, then stopped → stream log flushed → `stopped` | | export chunk | stats in → parser (named rows: stats frames, then the log) → recorder → device write (Bridge / driven Replay, not while calibration gates it) | | `setConfig` | auto-connect → the relay rebuilt if a setting it is built from changed | | `shutdown()` | relay Stop → `connection.shutdown` → stream-log flush cleared | ## Proof - **Side-effect trace (not committed).** This is the R2.2b harness, extended. It drives Session with every edge faked under fake timers. It records, in order: emits, the serial lifecycle and writes, DCS transport lifecycle and sends, the HID lifecycle, timer create/clear, debug-log calls, fs writes, returned values and the final `<userData>` files. - **Changes to the harness:** - relay and recorder intents resolve through accessors that work at both base and head; - the fake transport can be told to throw on construction. - **New scenarios:** - **relay-bridge**: start, both directions, DCS drop and back, the same error twice, stop, stop again, restart; - **relay-monitor**: no board, then a board, still read-only; - **start-refused**: scanning, no-device, reconnecting, a drop while running, a relay setting changed during the drop (rebuilt, not refused), a released link, driven Replay with no board, undriven Replay with no capture; - **replay-undriven**: no capture, load, play to the end, restart, reload mid-play, a bad capture; - **replay-driven**: refused, then plays into the board, with a calibration exchange gating the forward; - **record-during-relay**: record across start, export and commands; across a stop; restarted; - **dcs-reconnect**: refused before ever up, up and dropping, the error being news again, stale transport callbacks after a rebuild and after Stop; - **config-while-running**: non-relay settings, transport, listen and command port, to Bridge with no board, to Replay with nothing loaded (the relay ends), stopped, a transport that throws on Start and on a rebuild, into a loaded Replay; - **commands-stopped-running**: commands while stopped and running in Bridge, a burst over one flush, Monitor. - **Result:** base (f667979) and head outputs are **byte-identical**, with the same sha1 (`1e67f88f…`): 22 scenarios (13 from R2.2b + 9 new) and 4,639 recorded lines (1,699 emits, 583 timer, 389 DCS, 314 HID, 282 serial, 147 returned values, 115 debug-log, 40 fs). Base is deterministic over two runs. - **It can fail.** One deliberate reorder at a time: - Stop flushing the log after `stopped` instead of before: 82 diff lines; - the stats tick created before the telemetry tick: 116; - the transport stopped before it is forgotten: 76; - the log flush not draining the event log: 84. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with `SKYHAWK_USER_DATA` set and with the dev default: - base and head probe outputs are byte-identical; - `config.json` and `node-mapping.json` have the same sha1s; - `debug.log` is identical apart from its timestamp. - **Tests.** 69 files, 906 tests: the 889 from before plus B107 (10) and B108 (7). The existing assertions are unchanged. ## Not as briefed - `loadReplay` is on **DcsRelay**, not on ReplaySource. It swaps the capture in the slot that `run()` plays and `stop()` stops, so it lives with that slot. ReplaySource keeps `load` / `info` / `play` / `stop` unchanged. - `flushLog` still drains the event log (`events:batch`), because Session keeps the event log in R2.3a. StreamLog is handed the EventLog's `take`. R2.3b splits it. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before. - `npm run lint:deps`: 0 errors, 15 warnings. The list is identical to base (diffed). There are no new warnings: `dcs` imports only `gateway` and `events` (types: `DeviceLine`, `EventLog`) and the shared contracts. - `npm run build`: OK. Refs #99
R2.3b of the structure rewrite (#101, phase #99). This is the settings and event-log half of R2.3. Session's config and event log move to their owners. Session keeps only its wiring, `status()`, and `open()` / `shutdown()`. AppServices and the per-topic IPC are left for R2.3c. Nothing changes for the user. The IPC channels, the pushes, the bytes on the serial port and to DCS, the files written, the timers and the order of all of them are the same. The trace proof below checks this. ## What moved where | From | To | Notes | |---|---|---| | `Session.config`, `getConfig`, `setConfig` | `config/settings-service.ts` **SettingsService** `get` / `update` | The live `AppConfig` is now a zustand/vanilla store under `subscribeWithSelector` (see below). `update` is `setConfig`: it merges, the store's subscribers react, and nothing is saved | | `app/index.ts` `applyConfig` | **SettingsService** `apply` | `update` → debug-log switch → `saveConfig`, in that order. `config:set` and ConfigTransfer's `setConfig` both call it | | `app/index.ts` whenReady seed (`setConfig(loadConfig(paths))` + `setDebugEnabled`) | **SettingsService** `load` | It seeds and switches the debug log. It does not write `config.json`, as before | | `Session.events` (EventLog), `logError` + `lastErr`, the `lastErr.delete` calls | `events/event-journal.ts` **EventJournal** `add` / `error` / `forget` | The per-tag dedupe is verbatim. `NODES` is still never forgotten, so the v2 warning stays once per run (B29) | | `Session.lastDevice` and the onStatus row logic | **EventJournal** `linkState(state, row)` | `deviceEvent` stays in `gateway/link-events.ts`. Session hands the journal a closure that makes the row from the last state the journal holds. The make-row → add → remember-unless-scanning order is unchanged | | the `events:batch` half of `StreamLog.flushLog` | **EventJournal** `flush` | StreamLog now calls `events.flush()` where it used to `take()` and emit, so it still runs first, ahead of `log:batch` | | `events:get` / `clear` / `export` (`session.events.*`, `eventsToTsv`) | **EventJournal** `snapshot` / `clear` / `exportTsv` | See *Not as briefed* | | the `journal.onEvents → events.add` hop | gone | `report` now adds to the log it hosts. It had one subscriber | **Ports.** - DcsRelay's `options: () => RelayOptions` is replaced by `settings: RelaySettings` (`{ getState(): RelayOptions }`), which the SettingsStore fits. - StreamLog's `Pick<EventLog, 'take'>` is replaced by `StreamLogEvents` (`{ flush(): void }`). `dcs` no longer imports `events` for StreamLog. - DcsRelay's `RelayEventSink` (`add` / `error` / `forget`) is the journal itself. - NodeHub still reports through its `onEvents` callback, now pointed at `journal.report`. - The journal is handed `emit` and `debugLog`. `events` imports only `@shared/events` and `@shared/gateway` (types). `events/index.ts` now exports only `EventJournal` and the status-flag vocabulary. `config/index.ts` exports `SettingsService` in place of `loadConfig` / `saveConfig`, which have no callers left outside `config`. ## The settings store, and who gets it - `SettingsService` builds one store per instance with `createSettingsStore(initial = DEFAULT_CONFIG)`. It is not a module singleton, and a test can build its own. - The service holds the writable store. It hands out `store: SettingsStore`, the read-only view from `@shared/config`, which the compile guard already checks a real zustand store fits. - **The relay** gets `settings.store` and reads `getState()` wherever it used to call `options()`. - **The connection** gets its slice. `open()` passes `settings.get().autoConnect` to `start`, and a change reaches `setAutoConnect` through the subscriber below. GatewayConnection's own `autoConnect` field and `start` / `setAutoConnect` are unchanged. - **The debug log** (app's `setDebugEnabled`) is injected into SettingsService as `debugMode(on)`, because `config` cannot import `app`. **How the reaction order is kept.** zustand runs subscribers synchronously inside `setState`, after the state is replaced. That is the same moment `setConfig` ran its side effects, after `this.config` was reassigned. Session registers **one** subscriber, and it is `setConfig`'s body verbatim: auto-connect if it changed, then `relay.reconfigure(prev, next)`. The order is in that one listener, not in registration order. `apply` then runs the debug-log switch and the save, as `applyConfig` did. `update` calls `setState({ ...prev, ...patch }, true)`, replacing rather than merging. zustand's own merge treats a non-object `partial` as the whole new state. A `config:set` with no patch would therefore have set the settings to `undefined`, where `setConfig` kept a copy. The trace covers this: dropping the `replace` gives a 1,019-line diff. ## Wiring Session's constructor comment has a new block for R2.3b: the store's one subscriber, what `config:set` / import / launch seed do after it, and the flush order. Construction order: SettingsService and EventJournal are built first, then the rest as before. Neither constructor has a side effect. Session is now 173 lines (was 193); `app/index.ts` is 289 (was 299). **Callers change their receiver only.** - `app/index.ts` and `test-harness/services.ts` use `session.settings.get` / `apply` / `load` and `session.journal.*`. The harness's own copy of `applyConfig` is gone. - `session.test.ts` changes eight `session.setConfig(` to `session.settings.update(`, three `session.events.snapshot()` to `session.journal.snapshot()`, and one `Parameters<…>` type. - Arguments and assertions are unchanged. The `makeServices()` seam tests and the component tests are untouched. ## Proof - **Side-effect trace (not committed).** This is the R2.3a harness, extended. It records the same edges, plus every `setDebugEnabled` call. - **Changes to the harness:** settings, events and export go through accessors that work at both base and head. Seeding can go through the launch path (`load`). - **New scenarios:** - **config-set-autoconnect**: off and on with the link looking, after a Disconnect, while open (left open, then the drop is not reopened), back on after the drop, and on while up (nothing); - **config-set-autoconnect-off-launch**: off from launch, then on, then off while looking; - **config-set-relay**: - a relay setting while running (rebuilt and saved); - debug on together with a relay setting (the switch lands after the rebuild and before the save); - non-relay settings (`attentionBanner`, `psu`, `debugMode`), an empty patch, an undefined patch, and a whole config as an import sends it; - a mode switch while running, then while stopped; - auto-connect and a relay setting in one change, with the link dropped and the relay running; - **config-load-seed**: a `config.json` with bad fields is sanitized, the debug log is switched, nothing is written back, and auto-connect off holds until a `config:set`; - **config-load-damaged**: defaults, and the file is left as it is until a `config:set`; - **error-dedupe**: - the same SERIAL error twice, then a DCS error twice, then a new SERIAL message; - Retry forgetting SERIAL; - DCS up forgetting DCS; - the v2 warning once, and not again after a reopen; - **events-get-clear-export**: empty, then rows with tabs and newlines in a field, a clear with a row pending (never pushed), ids continuing after a clear, and a double clear; - **device-rows-reconnect**: no board at launch (no row), plug/unplug/replug, Disconnect twice, Connect with nothing there, then a fault and a Retry. - **Result:** base (6228947) and head outputs are **byte-identical**, sha1 `38a4e2b5…`. That is 30 scenarios (22 from R2.3a + 8 new) and 6,028 recorded lines: 1,933 emits (105 of them `events:batch`), 747 timer, 416 DCS, 384 serial, 367 HID, 213 returned values, 156 debug-log, 69 fs writes and 27 debug-log switches. Base is deterministic over two runs. - **It can fail.** One deliberate reorder at a time: - the relay rebuilt before auto-connect, inside the subscriber: 6 diff lines; - `config.json` saved before the debug log is switched: 50; - the debug log switched before the change (and so before the rebuild): 75; - `events:batch` pushed after `log:batch`: 40; - no error dedupe: 30; - `lastDevice` remembered before the row is made: 153; - zustand's merge instead of a replace: 1,019. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with `SKYHAWK_USER_DATA` set and with the dev default: - base and head probe outputs are byte-identical; - `config.json` after `config:set` and `node-mapping.json` have the same sha1s; - `debug.log` is identical apart from its timestamp. - The probe copies the bundle out of the repo, so it now runs with `NODE_PATH` set to the repo's `node_modules`, at both base and head. Main now `require`s `zustand/vanilla` and `zustand/middleware`, which are externalized like `serialport`. `zustand` is already a runtime dependency, so the packaged app ships it. - **Tests.** 69 files, 906 tests, as before. The existing assertions are unchanged. ## Not as briefed - **The settings reaction lives in Session's wiring, not inside SettingsService.** It calls `connection.setAutoConnect` and `relay.reconfigure`, and `config` may not import `gateway` or `dcs`. So SettingsService owns the store and the `update` / `apply` / `load` paths, and Session (AppServices in R2.3c) registers the one ordered subscriber. - **GatewayConnection gets a slice, not the store.** Its `autoConnect` is pushed to it at `start` and through `setAutoConnect`, and its `was` check compares against its own field. Reading the store instead would change `start`'s signature and that check, so the connection keeps both. It is handed the value from the store. - **`events:export` builds the TSV before the save dialog, not after.** It used to take the snapshot before the dialog and format it after. `exportTsv()` does both at the snapshot's moment. Formatting is pure, so the file is the same. The only difference is that a cancelled dialog has still formatted the text. ## Lint - `npm run typecheck`: clean. - `npm run lint`: 0 errors. The 32 warnings are the same renderer P5 warnings as before. - `npm run lint:deps`: 0 errors, 15 warnings. The list is identical to base (diffed). There are no new warnings: `events` imports no topic, and `config` imports only `zustand` beyond its own files and the contracts. - `npm run build`: OK. Refs #99
R2.3c of the structure rewrite (#101, phase #99), the last part of R2.3. `Session` becomes **`AppServices`** (UD-F), a composition object that only builds the services and wires them. `app/index.ts` is split by job, and each main topic gets its own `ipc.ts`. The renderer tests get their own tsconfig, so the last four T2-main deep imports can go through the topic indexes. The main topic rules and T2-main are now **error**. Nothing changes for the user. The IPC channels and their registration order, the `app.on` order, the pushes, the bytes on the serial port and to DCS, the files written and the timers are all the same. The proof is below. ## Old → new | Old | New | Notes | |---|---|---| | `app/session.ts` `Session` | `app/app-services.ts` **`AppServices`** | `git mv`, then the class is renamed. The wiring, the settings subscriber, `open()`, `shutdown()` and `status()` are verbatim. It imports `Emit` from `@shared/app`. | | `app/session.test.ts`, `app/session.seam.test.ts` | `app/app-services.test.ts`, `app/app-services.seam.test.ts` | `git mv`. Only the import, `new AppServices(`, `typeof AppServices` and one `describe` title change. Assertions are unchanged. | | `app/index.ts`: the userData override, the store paths, the construction of Session and ConfigTransfer, `whenReady`, `window-all-closed`, `will-quit` | `app/bootstrap.ts` `start()` | Moved verbatim, in the same order. ConfigTransfer is still built here, as before (see *Not as briefed*). | | `app/index.ts`: `mainWindow`, `emit`, `createWindow` | `app/window.ts` | | | 9 local `type Emit` copies: AppServices, GatewayConnection, SerialTrafficMonitor, NodeHub, HidService, HidCalibrationService, DcsRelay, StreamLog, and EventJournal's events-only variant | **one `Emit` in `@shared/app/ipc.ts`**, next to `PushChannels` | A type only. `Emit<Channels = PushChannel>`. Every service imports it from the contract. EventJournal takes `Emit<keyof EventsPushChannels>`, which is derived from it rather than redefined. The full `emit` is still assignable to it. | | `app/index.ts`: `CONFIG_FILTER`, `CONFIG_MAX_BYTES`, `CAPTURE_FILTER` and the six `dialog.show*Dialog` calls | `app/dialogs.ts` `dialogs: Dialogs` | `Dialogs = ConfigDialogs & DcsDialogs & EventsDialogs`. Each topic declares the dialogs it needs as a port. | | `app/index.ts` `registerIpc()` | `app/ipc.ts` `registerIpc(ipcMain, { services, transfer, dialogs })` | Calls each topic's registrar. Owns `relay:status`, `debug:dump-ports` and `debug:reveal`. | | `config:get/set/export/import-open/import-apply` | `config/ipc.ts` `registerConfigIpc` | | | `relay:start/stop`, `log:export`, `capture:toggle`, `replay:open` | `dcs/ipc.ts` `registerDcsIpc` | | | `device:connect/disconnect`, `serial:monitor` | `gateway/ipc.ts` `registerGatewayIpc` | | | `hid:availability`, and `hidAvailability()` | `hid/ipc.ts` `registerHidIpc`, and `hid/hid-report.ts` | The function is moved verbatim to the HID model (§1.2). | | `nodes:*`, `node-mapping:*` (10) | `nodes/ipc.ts` `registerNodesIpc` | | | `cal:*` (10) | `hid/calibration/ipc.ts` `registerCalibrationIpc` | | | `events:get/clear/export` | `events/ipc.ts` `registerEventsIpc` | | | `app/index.ts` (289 lines) | `app/index.ts`: `export { start } from './bootstrap'` | | | `main/index.ts`: `import './app'` | `import { start } from './app'; start()` | | | `test-harness/services.ts` | the same file | `makeServices()` now builds `AppServices`, as planned. No test that uses the harness changes. | **The handlers move verbatim. Only the receiver changes**, from `session.x` to the `x` passed in `deps`. Channel names come from the topic contracts (`CONFIG_CTRL`, `DCS_CTRL`, …). The one change inside a handler is the dialog call. `await dialog.showSaveDialog({…})` followed by `if (res.canceled || !res.filePath)` becomes `await dialogs.saveX()` followed by `if (!filePath)`. The cancel check itself now lives in `dialogs.ts`, unchanged. Arguments are still evaluated where they were: `transfer.defaultFileName()` and `Date.now()` run before the dialog opens, and `journal.exportTsv()` still runs before the events dialog. **What the topic registrars take.** A topic can't import electron (P6) or app (the topic rules), so `@shared/app` gains three types: `CtrlChannel` (any CTRL value), `IpcListener` (ipcMain's listener shape) and `IpcRegistrar` (`{ handle(channel, listener) }`). The real `ipcMain` fits `IpcRegistrar` structurally. `app/*` line counts: app-services 175, bootstrap 61, ipc 116, dialogs 81, window 45, debug-log 38, user-data 50, index 2. ## IPC registration order Today's `ipcMain.handle` order interleaves topics: config, dcs, **app**, gateway, hid, nodes, **gateway**, calibration, **dcs**, events, **app**, **dcs**. Registering one topic after another would reorder it. So: - The topics' registrars and app's own three handlers register into a **collector**, a `Map` behind an `IpcRegistrar`. - `app/ipc.ts` keeps **`IPC_ORDER`**, the 40 channels in today's order. It then calls `ipcMain.handle` once per channel, in that order. - **A channel missing from `IPC_ORDER` fails the typecheck** and names the channel. `EveryChannel<typeof IPC_ORDER>` checks this. I tried it by dropping `debug:reveal`: `TS2741 … '{ unordered: "debug:reveal"; }'`. - `satisfies readonly CtrlChannel[]` rejects a misspelled channel. - At startup, a second handler for the same channel throws, as `ipcMain` itself does. So does a listed channel that has no handler. Neither can happen in a correct build. **Proof.** The boot probe now also prints the registration order (`order …`), the handler count and any duplicate `handle`. Base and head print the same 40-channel order: ``` order config:get,config:set,config:export,config:import-open,config:import-apply,relay:start,relay:stop,relay:status,device:connect,device:disconnect,hid:availability,nodes:refresh,nodes:get,node-mapping:set,node-mapping:adopt,node-mapping:forget,node-mapping:reset-node,node-mapping:reset,node-mapping:reset-preview,nodes:registry,node-mapping:hide,serial:monitor,cal:hello,cal:read,cal:session-open,cal:stream-select,cal:commit,cal:reset,cal:cache-read,cal:cache-store,cal:cache-drop,cal:session-close,log:export,events:get,events:clear,events:export,debug:dump-ports,debug:reveal,capture:toggle,replay:open count 40 ``` The probe can fail. Swapping `relay:start` and `relay:stop` in `IPC_ORDER` gives a one-line diff on `order`. ## The tsconfig change **The premise was off.** `tsconfig.json` set no `types`, so TypeScript loaded every `@types/*` package, `@types/node` included (66 files). The renderer's app code could already use `Buffer` or `node:fs` and typecheck. I checked this at base with a probe file in `src/renderer/src`. So moving the deep imports to `../nodes` typechecked even without the split. This PR makes the boundary real: - **`tsconfig.json`** adds `"types": []`. It leaves out `src/renderer/test`, `src/renderer/**/*.test.ts(x)`, `src/shared/**/*.test.ts` and `src/test-fixtures`. - With these settings, renderer app code that uses `Buffer` fails with TS2591. - The shared tests are still checked by `tsconfig.node.json`, as before. - `ipc.compat.test-d.ts` stays in `tsconfig.json`. - **`tsconfig.test.json`** (new) extends `tsconfig.json`. It sets `types: ["node"]`, so the files it checks get DOM and Node types. It covers `src/renderer`, `src/shared` and `src/test-fixtures`, tests included. - **`npm run typecheck`** runs all three projects. vitest is unaffected. ## Deep imports removed | From | Was | Now | |---|---|---| | `config/config-file.ts` | `../nodes/mapping-rules` (`effectiveMapping`, `importMapping`, `sanitizeNodeMapping`) | `../nodes` | | `config/config-file.ts` | `../nodes/psu-limits` (`sanitizePsu`) | `../nodes` | | `config/config-file.ts` | `../hid/calibration/cache-rules` (`sanitizeCache`) | `../hid/calibration` | | `config/settings-schema.ts` | `../nodes/psu-limits` (`sanitizePsu`) | `../nodes` | `nodes/index.ts` now exports those four rules, and `hid/calibration/index.ts` exports `sanitizeCache`. There are no cycles: C2 stays at 0, at error. `.dependency-cruiser.severity.json` sets `topicsMain` and `deepMain` to **error**. ## Lint counts | | Before (f3cc40a) | After | |---|---|---| | `npm run lint` | 0 errors, 32 warnings (all P5, renderer) | 0 errors, 32 warnings (the same) | | `npm run lint:deps` | 0 errors, 15 warnings: P4-legacy-rules 10, T2-main 4, T2-renderer 1 | **0 errors, 11 warnings**: P4-legacy-rules 10, T2-renderer 1 | | `npm run typecheck` | 2 projects, clean | 3 projects, clean | | `npx vitest run` | 69 files, 906 tests | 69 files, 906 tests, assertions unchanged | | `npm run build` | ok | ok | ## Trace and boot proof - **Side-effect trace (R2.3b harness, not committed).** It drives the services with every edge faked: 30 scenarios. - The head copy changes one line, the import: `const { AppServices: Session } = await import('./app/app-services')`. - Base (f3cc40a) and head output are **byte-identical**: sha1 `38a4e2b5…`, the same as R2.3b's head. - The output is 6,028 recorded lines, 1,933 of them emits. - **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`, plus the registration-order dump). It ran with `SKYHAWK_USER_DATA` set and with the dev default. - Base and head output are **byte-identical**: `setPath`, the `app.on` order (`window-all-closed`, `will-quit`, then `activate` inside `whenReady`), the window's preload and renderer paths, the 40 channels in registration order, `config:get`/`config:set`, `debug:dump-ports`, `node-mapping:set` and the `will-quit` save. - `config.json` and `node-mapping.json` are byte-identical. `debug.log` differs only in its wall-clock timestamp. ## Not as briefed The review moved `Emit` to the contract: one type, no topic copies (commit `788ffc6`). - **`IpcRegistrar`, `IpcListener` and `CtrlChannel` in `@shared/app`.** The brief asked for an "ipcMain-like" parameter. This puts it in one place rather than seven: a topic can't import electron or app. `IpcListener` uses `any[]` for the arguments with an eslint-disable, because electron's own listener type does. - **The `Dialogs` port** resolves to a path or `undefined` rather than electron's result object. `CONFIG_MAX_BYTES` lives in `dialogs.ts`, as planned. The import's size check in `config/ipc.ts` reads it as `dialogs.configMaxBytes`, because config can't import app. - **ConfigTransfer is still built in `start()`**, as it was in `index.ts`, and not in AppServices. The brief didn't move it. It is a candidate for AppServices once it pushes (R3.3). Refs #99
Tier-2 component tests, part 1 of 2: T17–T26 from the Phase 0
component-test plan. **Tests only.** Nothing under `src/renderer/src`,
`src/main` or `src/shared` changes. Every scenario goes in through
`emitScenario` at today's push and invoke level, so R3 can swap in its
new pushes inside the mock and leave these tests as they are.
**Totals:** 69 → 79 test files, 906 → 967 tests (+61). The 906 existing
tests are unchanged and pass. Lint and lint:deps show 0 errors (the
warnings are the same as before). Typecheck passes on all 3 projects.
## Harness additions (`src/renderer/test/`)
- **`skyhawk-mock.ts`**
- New `Scenario` fields:
- `stream` → `log:batch`
- `serial` → `serial:traffic`
- `calRaw` → `cal:raw`
- `hidAvailability` → answers the `getHidAvailability` pull
- **`calDevice`**: a scripted Gateway calibration channel that answers
the `cal*` invokes the way main's `HidCalibrationService` does:
- `offline` ("Gateway is not connected") while the port is closed.
- GET_CAL answers with what the device stores, and also pushes
`cal:data`.
- COMMIT and RESET change what it stores.
- Optional `refuse` makes SESSION_OPEN, STREAM_SELECT, COMMIT or RESET
return a NACK or a timeout instead of an ack.
- The restore cache runs through main's own `cache-rules` (`boardFor`,
`regressedAxes`, `storeAxes`, `dropAxis`), keyed by the board serial and
compared against the last GET_CAL.
- When a port opens on a scripted device, the mock pushes `cal:data`, as
main does in `readCalibrationState`.
- Without a `calDevice`, every existing default is unchanged.
- **`scenarios.ts`**
- `reported(id, over)`: a status row with main's `withFaultInfo`
applied.
- `pduTelemetry(id, pages)`: a PDU's pages, built by main's
`NodeTelemStore`.
- Row builders: `logEvent`, `streamRow`, `serialFrame`.
- Calibration builders: `calSnapshot`, `calDevice`, `calCache`, and
`sweeps(...)` (RAW batches for N full sweeps, optionally with a rest
position).
## What each test pins
| # | File | Pins | Rows |
|---|---|---|---|
| T17 | `t17-cockpit-plan` | One group per placed PDU on its own
console, then that console's No power zone. A panel sits under its PDU
even when the PDU is on another console. "N PDUs" appears under a
console title. A hidden PDU's boards are drawn under No power zone and
come back when it reports. New boards are never placed, and a zone on a
PDU not added yet reads No power zone. A state change never reorders
tiles. A tile's label drops the console prefix, and its tooltip has the
full name. | B64, B10, X2 |
| T18 | `t18-node-panel-status` | Fault boxes: healthy (none), offline
(one box, whatever was raised last), not seen this session plus the
last-seen time, no status yet, degraded with the fault-table label,
unknown code "Fault N". Several faults stack in the order bus-off →
fault → overheat → error-passive. The status rows for each case. "Not in
registry" (C6). "updated N s ago" ticks on fake timers and changes to
"last report" once offline. With the link down the panel reads "last
known". | B70, B14, F11 |
| T19 | `t19-pdu-panel` | An offline PDU gets the fuse sentence only
when its boards dropped with it. "No readings received", with the
firmware hint only if it never had readings. Raised flags appear as one
box with their meanings. UNCAL appears only as a calibration row. Rail
rows show avg / min / A / peak A, a negative offset reads 0.00 A, and
the W hint is shown. NTC rows cover a value, a fault and unavailable.
"Feeds · N" lists the boards, and opening one switches panels. | B71 |
| T20 | `t20-cockpit-band-search` | Band counts: known boards plus
strays, unknown counted separately, new boards left out. 12 V and 5 V
amps, W, lowest 5 V rail, worst CAN with the bus-off board named first,
hottest PDU. Peak W holds when the draw falls. With the link down every
value is a dash. Search by name or #id is case-insensitive. Issues shows
only boards with an issue and combines with search. | B67, B69 |
| T21 | `t21-board-events` | Empty state. Only node rows, newest first,
one line each, with the count. "View all in Log →" opens Log › Events
filtered to Nodes. | B75 |
| T22 | `t22-log-events` | Source filter All / Nodes / Client. "Problems
only". Search over the source label and the event text. Raw changes the
column to "Message (raw)". Pause shows PAUSED and holds rows while the
tab count rises; Resume shows them. Clear calls `clearEvents`. The
Export footer, and no export when there are no events. | B78, B55 |
| T23 | `t23-log-stream` | Direction filter. Search by name or address.
Raw shows the address and bytes. Pause. Clear stays on the renderer
side. Export sends oldest-first TSV to `exportLog`, shows the footer,
and does nothing when the stream is empty. | B77 |
| T24 | `t24-serial-tab` | The off text. The toggle calls
`setSerialMonitor(true/false)`. Rows from `serial:traffic`, newest
first, as hex and ASCII, with `…+N` past 48 bytes. Stopping keeps the
rows; Clear empties them. | B79 |
| T25 | `t25-hid-tab` | Axes, hats and buttons from `hid:report`.
Uncatalogued or undeclared slots show a dash or n/a. Freshness reads
live / idle / quiet / no link, and a released port still reads live
(#74). CAL/RAW badges and the uncalibrated count. Calibrate is disabled
while the link is down, with the reason. The Review & restore banner,
which is absent when nothing is lost or the link is down. | B80 |
| T26 | `t26-calibration-dialog` | Opening runs SESSION_OPEN on the
first uncalibrated axis, and Write is disabled. STREAM_SELECT switches
axes. Cancel closes the session and re-reads. While capturing, the
capture and rest-position buttons are disabled. Three RAW sweeps produce
a draft, and Write sends COMMIT. Write, Cancel, ✕, the axis rail and
Re-capture stay disabled through the writing and verifying phases. After
that come the done state, the badge from the read-back, the cache store
and the notice. An invalid draft names the axis and keeps Write
disabled. When the link drops, the dialog pauses: Write and Delete are
disabled, axes switch locally with no STREAM_SELECT, and drafts are
kept. When the same board reports, the session reopens on the axis on
screen. An interrupted sweep restarts and the sweeps already accepted
are kept. The restore panel: Not now, the re-offer, and Restore writing
the cached values, after which the offer is gone. | B83, B82 |
## Where today's behaviour differs from the contract (pinned as today,
with a `// contract says …` comment)
1. **T17 / B64:** a long tile name "…" is CSS `text-overflow` only. The
DOM label is the whole short name, so the test pins that text.
2. **T18 / C6:** "Not in registry" is pinned, as C6 describes. No route
in the UI leads to a new panel group's own panel: it has no tile, no
Attention row and no Connection row. The box can only be reached on a
new PDU, through a placed board's Powered by (X2 R3).
3. **T20 / B69:** search and Issues dim tiles only through the CSS class
`tile2--dim`; no text or ARIA state changes. The test asserts that class
because it is the only thing to observe. This is the one class assertion
in the PR.
4. **T23 / B77:** Pause in Log › Stream drops `log:batch` rows that
arrive while paused (`store.ts` returns early). Log › Events holds its
rows instead. The test pins the drop.
5. **B77, not tested:** Autoscroll only toggles its own button state;
nothing in the Log view reads it.
6. **T22 / B78:** the `skyhawk-events-<n>.tsv` name is suggested in
main's save dialog and isn't visible to the renderer. The test pins the
footer built from the path main returns.
7. **T26 / B76:** the CalibrationDialog has no `role="dialog"` or
accessible name. The tests find it by its title text.
## Fail-check (break the behaviour, see red, `git checkout` the file,
tree clean)
| T | Behaviour broken | Result |
|---|---|---|
| T17 | `planLayout`: only `pduId === null` boards go to No power zone |
red (2) |
| T18 | `nodeFaultBoxes`: degraded box dropped | red (2) |
| T19 | `pduFaultBoxes`: fuse sentence on every offline PDU | red (1) |
| T20 | `healthBand`: strays left out of total | red (1) |
| T20 | `tileMatches`: `#id` not stripped | red (1) |
| T21 | BoardEvents lists client rows | red (1) |
| T22 | `filterEvents` ignores Problems only | red (1) |
| T23 | `exportLog` newest first | red (1) |
| T24 | `setSerialMonitor` never reaches main | red (1) |
| T25 | `freshness`: released port reads "no link" | red (1) |
| T26 | Write not disabled while paused | red (1) |
| T26 | Cancel not disabled while writing | red (1) |
Refs #99
Tier-2 component tests, part 2 of 2: T27–T35 from the Phase 0
component-test plan, plus contract row **B2** at the HUB seam. **Tests
only.** Nothing under `src/renderer/src`, `src/main` (outside
`*.test.ts`) or `src/shared` changes. Every renderer scenario goes in
through `emitScenario` at today's push and invoke level.
**Totals:** 79 → 88 test files, 967 → 1056 tests (+89). The 967 existing
tests are unchanged and pass. Lint and lint:deps show 0 errors (the
warnings are the same production-file warnings as before). Typecheck
passes on all 3 projects.
## Harness additions (`src/renderer/test/`)
- **`skyhawk-mock.ts`**: two new `Scenario` fields for the relay's
ticks: `gauges` → `telemetry:tick` and `stats` → `stats:tick`.
- **`scenarios.ts`**, each answering at main's level:
- `simTelemetry(fill)`: the five readouts from main's own DCS-BIOS
`Decoder`, after DCS exported the given fraction of each output's range.
- `streamStats(over)`: one stats tick, idle by default.
- `bundledRegistry()`: what main answers `getNodeRegistry` with
(`NodeHub.registry()`).
- `resetPreview(file)`: what main answers `previewNodeReset` with for a
given node-mapping.json (`NodeHub.previewReset()`).
## What each test pins
| # | File | Pins | Rows |
|---|---|---|---|
| T27 | `t27-overview-hero-gauges` | Link tile: the value, sub-line and
colour for each device state × relay (relaying / stream stopped / "relay
waiting — resumes when it connects"). The Link and Cockpit tiles open
their tabs. Aircraft tile: NONE, "_ACFT_NAME · metadata" vs "inferred ·
address range", gold plus the A-4E-C-only banner when the aircraft is
unsupported. DCS Link tile: TCP / Multicast / Unicast with their
sub-lines. Gauges: five idle "—" readouts, then the tick's values. Fuel
is gold, and red under 25 %; the other ring colours are pinned too.
Stream Health from `stats:tick`; Errors red once > 0; the CMD line. |
B56, B57 |
| T28 | `t28-connection-relay-controls` | Device card: only the VID/PID
**target** before a board; identity, VID/PID, port and CDC + HID when
connected; Disconnect / Connect / Retry call main; the busy note; the
auto-connect-off note's Settings › Connection link; greyed out while
scanning. Relay controls: mode buttons save the mode and show their
hints; Replay's Load capture… and the loaded-file line; the Drive
Gateway toggle saves and blocks Start without a board; host, port and
transport saved as typed or picked; Record disabled unless relaying, its
hints and the running count; Start disabled when blocked, with the
waitnote; the waiting / ended waitnotes. | B61, B62 |
| T29 | `t29-settings-power-supply` | NX650 reads 54 A / 20 A / 650 W,
read-only, and typing into it changes nothing. Custom shows the saved
limits, editable, and saves a typed number. Custom keeps its values
across a switch to the preset and back. An emptied field saves no limit.
A non-number ("abc", "-5", "0", "20000", "1e3") is marked invalid, kept
on screen and never saved. | B87 |
| T30 | `t30-settings-reset` | Reset… is disabled when nothing differs
and no adopted board shows, and when the only adopted board is hidden
(X3). It is enabled when a board differs or an adopted board shows. The
row description. The review lists main's preview rows old → new, with
the adopted board under "Also forget adopted nodes". Reset sends
`resetNodeMapping({ forgetAdopted: false })`; ticking the option sends
`{ forgetAdopted: true }`, and the button label follows. With only an
adopted board, nothing can be reset until the option is ticked. A
refusal shows main's error and keeps the review open. Cancel calls
nothing. | B89, B24 |
| T31 | `t31-settings-connection-diagnostics` | Connection opens first
and shows the saved mode, host and port. Host and port are saved as
typed. The auto-connect toggle saves the setting both ways; the renderer
doesn't connect itself, main does. Record session calls `toggleCapture`
and shows the count. Diagnostics: the debug toggle saves `debugMode`;
Dump calls `dumpSerialPorts` and shows where the ports went; Reveal
calls `revealDebugLog`. | B86, B91 |
| T32 | `t32-settings-about` | Version `v<package version>`, app id,
licence. "NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway" with
the commit. The registry dialog lists every NODE_ID with the bridge as
the Gateway, and On the bus reads serial link / yes / offline / hidden /
—. It follows the next push. Unplugged, the Gateway reads "no device"
but CAN nodes still read "yes" (R5). | B92, X7 |
| T33 | `t33-dialog-focus` | One test file over a Settings review Dialog
and a Cockpit side-panel Sheet. Focus goes to the first control on open.
Tab goes through the controls and wraps, and Shift+Tab wraps back. Esc,
Close and a scrim click each close it, and focus returns to the opener
(Reset… / the tile). | B76 |
| T34 | `t34-overview-cockpit-agreement` | Integration in `App`: one
`nodes:model` push, then Overview, Cockpit and Connection. Each tab
shows the same offline and warning counts. The exception is a stray,
which only the Cockpit band counts (X1). Without the stray all three
agree exactly. The next push moves all three together. | X1, B58, B63,
B67 |
| T35 | `t35-settings-hidden-after-push` | Settings follows pushes
through its own `useNodeModel` copy. A hidden board that reports again
drops off, leaving the empty state and Un-hide all disabled. A board
hidden elsewhere appears. The last push wins. Only the deterministic
path is pinned; the C5 / F10 race is not. | B88, F10 |
| B2 | `src/main/nodes/node-hub.seam.test.ts` | HUB seam: adopt 41 on
today's bundle, then a "later client" whose NODE_IDS.md lists 41 starts
on the same node-mapping.json. 41 is still in your cockpit with your
name, console and PDU, online. It isn't "new on the bus". The adoption
is kept on disk, plus `lastSeen`. Off the bus it stays, offline. The
later bundle is a `vi.mock` of `@shared/firmware` whose `NODE_NAMES` is
today's plus 41; the other tests in the file see today's bundle. | B2 |
B2 had no seam test before: only the (C) `mapping-rules.test.ts`
"adopted, then in the registry" case.
## Where today's behaviour differs from the contract (pinned as today,
with a `// contract says …` comment)
1. **B2:** the contract says the model keeps `source: 'adopted'`. Today
main reports the board as `source: 'registry'`. It stays in your cockpit
with your settings. Because your name differs from the registry's, it is
`differs: true` and counts in `differingCount`. So Reset… now offers to
rename it to the registry name, which the C test also shows (`resetNode`
→ `Left_Oxy`).
2. **T28 / B61:** the contract says the "busy" note carries a Settings
link. Today the busy note has none. Only the
released-with-auto-connect-off note links to Settings › Connection.
3. **T31 vs B62:** Settings › Connection's **Record session** row is
enabled while the relay is stopped. The Connection tab's Record card is
disabled unless relaying. The two surfaces disagree.
4. **T27 / B56 (not a contract conflict, noted):** for Unicast, the DCS
Link tile's sub-line reads the multicast endpoint (`239.255.50.10:5010 ·
local`).
5. **T27 / B57 (noted):** the idle gauge placeholders carry different
units from the live readouts. Before the first tick IAS and Press Alt
read `% FS`; after it they read `kt` and `ft`.
6. **Known conflicts, pinned as planned:** X1 (T34; a stray isn't
counted on Overview or Connection), X3 (T30; Reset… disabled when the
only adopted board is hidden), R5 / X7 (T32; CAN rows read "yes" while
unplugged).
7. **T33:** the CalibrationDialog shares the focus handling but has no
`role="dialog"` or accessible name (known), so T33 covers the Dialog and
the Sheet only.
## Fail-check (break the behaviour, see red, `git checkout` the file,
tree clean)
| T | Behaviour broken | Result |
|---|---|---|
| T27 | `Overview`: fuel red under 20 % instead of 25 % | red (1) |
| T27 | `Overview`: Errors red only above 5 | red (1) |
| T28 | `startBlocked` always false | red (2) |
| T28 | Connection Record never disabled | red (1) |
| T29 | PSU preset fields not read-only | red (2) |
| T29 | a refused PSU value is saved anyway | red (1) |
| T30 | Reset… ignores adopted boards | red (2) |
| T30 | ResetDialog always sends `forgetAdopted: false` | red (2) |
| T31 | Dump button calls Reveal | red (1) |
| T32 | `registryOnBus`: no "hidden" | red (1) |
| T32 | `registryOnBus`: CAN "yes" only while the link is up (fixes R5)
| red (2) |
| T33 | `useModalFocus`: focus not returned to the opener | red (6) |
| T33 | `useModalFocus`: Tab doesn't wrap | red (2) |
| T34 | `nodesSummary`: healthy = online (warnings counted twice) | red
(2) |
| T34 | Overview counts new boards as strays (fixes X1) | red (2) |
| T35 | `useNodeModel` stops following `nodes:model` | red (3) |
| B2 | `inCockpit`: only `added` counts (an adopted entry stops counting
once it is in the registry) | red (1) |
After each run, `git status` was clean.
Refs #99
…121) R3.1 of the structure rewrite (#101). The calibration workflow runs in main. The dialog and the HID tab draw what main pushes, and send the user's actions. ## What moved | Before | After | |---|---| | `src/renderer/src/calibration-controller.ts` (`CalibrationController`, `CalibrationApi`) | `src/main/hid/calibration/calibration-workflow.ts` (`CalibrationWorkflow`, `CalibrationCommands`). Moved verbatim: same methods and logic. Timers are injected (`WorkflowTimers`, default `SYSTEM_TIMERS`). Its state types (`DraftAxis`, `WritePhase`, `WriteProgress`, `WorkflowState`, which was the controller's `CalibrationState`) moved to the contract. | | `src/renderer/src/calibration-controller.test.ts` | `src/main/hid/calibration/calibration-workflow.test.ts`. Ported 1:1: the import paths changed, `CalibrationController` became `CalibrationWorkflow`, and `CalibrationApi` became `CalibrationCommands`. | | `src/shared/hid/calibration/legacy-rules.ts` (+ test) | `src/main/hid/calibration/axis-capture.ts` (+ test). Moved verbatim, minus the `@deprecated` tags. The legacy file is deleted. | | `CalibrationDialog.tsx`: `TRAVEL_BANDS`, `travelQuality`, the prompt's step meaning and direction inference, and every gate | `src/main/hid/calibration/calibration-view-model.ts` (`dialogState`). Each derivation is verbatim. | | `tabs/Hid.tsx`: badges, uncalibrated count, offer banner gate, offer axis, Calibrate shown/enabled, default axis | `calibration-view-model.ts` (`tabState`) | | `useCalibration`: the controller's lifetime (built on HID-tab mount, disposed on unmount), `cal:raw` → ingest, linkChanged before deviceChanged, offer re-read per device report, the start sequence (open → restore panel → shown) and the close sequence (session close → hidden → re-read → offer); `store.ts`: the snapshot dropped whenever the link isn't up; the dialog's `startRaw` anchor | `src/main/hid/calibration/workflow-host.ts` (`CalibrationWorkflowHost`), hosted by `HidCalibrationService` (`.workflow`). It keeps one view per mounted HID tab (`cal:attach`). AppServices feeds it the link state from `onStatus`, after `device:status` has gone out. | | — | `src/renderer/src/calibration-view.ts`: `toDisplay`/`fmt`/`fmtSpan`/`pct` and `promptText` (the prompt's words) | The renderer's calibration code is wording only. The two UI-local states stay in the dialog: the two-click Delete arming and the 1.2 s "Captured" beat. ## The workflow's lifetime: `cal:attach` (the lifecycle intent) Before R3.1, the HID tab owned the controller: `useCalibration` built it on mount and disposed it on unmount. `cal:attach(on)` replaces that ownership. `Hid.tsx` sends it from a `useEffect` at the same two moments. - **`attach(true)`** builds a fresh `CalibrationWorkflow`. It then runs what the mount's effects ran, in their order: `linkChanged`, `deviceChanged`, and the restore-offer read. The offer starts empty until that read lands, as the hook's state did. - **`attach(false)`** calls `dispose()`. The ticker and the hold/notice timers stop, and RAW is dropped while nothing is attached. As with the renderer's controller, work already under way keeps running. - **A renderer reload** never runs the unmount, and the old page's controller died with the page. - `relay:status` (a renderer's first pull) and a second `attach(true)` without a detach both mark the old view gone: it is disposed, and its pending commands never answer. - So nothing the old page started reaches the device again, and the rehydrate carries no dialog. Without this, a dialog, its live reading and its pending timers would have outlived a tab switch or a reload. That would be a behaviour change. ## `CalibrationState` (`cal:workflow`) ```ts interface CalibrationState { tab: { snapshot?: CalSnapshot // the device's report while the link is up (was store.cal) badges: Partial<Record<number, 'cal' | 'raw'>> uncalibrated: number offer?: { confirmedAt: string; axes: number[] } showOffer: boolean // offer && link up && an axis present offerAxis?: number // Review & restore opens here calibrate: { shown: boolean; enabled: boolean; axis?: number } } dialog?: { // present while the dialog is shown workflow: WorkflowState // the workflow's own state, verbatim present: CalAxis[]; draft?: DraftAxis; stored?: CalAxis writing: boolean; invalid: { axis; reason } | null; showInvalid: boolean shown?: CaptureResult; travel: TravelQuality | null; selfCentring: boolean prompt: { step; holding; side: 'upper' | 'lower' | null; awaitingMovement } | null dirty: number[]; strip: boolean; stepIndex: 0 | 1; sweepsRemaining?: number holdAtStops: boolean; hold?: { remainingMs; progress }; sweeps: SweepCount convergence: { min; max; short }[]; restore: 'panel' | 'reoffer' | null; notice: boolean capture: { action: 'redo' | 'start'; disabled; recapture } disabled: { close; axisSwitch; rest; write }; remove: { shown; disabled } } } ``` `CALIBRATION_IDLE` (contract) is the launch state the renderer starts from. A main test pins the host's first state equal to it. ## Intents ↔ old methods | Intent | Was | |---|---| | `cal:open`(axis, review?) | `useCalibration.start` → `controller.open` + `setRestoreOpen` + shown | | `cal:close` | `useCalibration.close` → `controller.close` + hidden + `calRead` + `calCacheRead` | | `cal:select-axis`(axis) | `selectAxis` | | `cal:start-capture` | `startCapture` | | `cal:retry` | `retry` | | `cal:set-self-centring`(on) | `setSelfCentring` | | `cal:save` | `save` | | `cal:restore` | `restore` | | `cal:delete-axis`(idx) | `deleteAxis` | | `cal:set-restore-open`(open) | `setRestoreOpen` | | `cal:attach`(on) | lifecycle: `useCalibration`'s controller construction on mount, `dispose()` on unmount | The other public members are main-internal now: - `ingest` takes RAW batches from the service's 50 ms flush; - `linkChanged` and `deviceChanged` are driven by the host; - `subscribe` and `snapshot` feed the push; - `selfCentring`, `dirtyAxes` and `invalidAxis` are folded into the state; - `dispose` is called by `cal:attach(false)`. The renderer calls the intents through `useStore().calibrate`. ## API guard diff (`ipc.compat.test-d.ts`, updated on purpose) - **SkyhawkApi:** - removed `calHello`, `calRead`, `calSessionOpen`, `calStreamSelect`, `calCommit`, `calReset`, `calSessionClose`, `calCacheRead`, `calCacheStore`, `calCacheDrop`; - added `calOpen`, `calClose`, `calSelectAxis`, `calStartCapture`, `calRetry`, `calSetSelfCentring`, `calSave`, `calRestore`, `calDeleteAxis`, `calSetRestoreOpen`, `calAttach` (all `Promise<void>`). - **CTRL:** the ten `cal:*` protocol channels are replaced by the ten workflow channels and `cal:attach`. - **PushChannels / IPC:** `cal:raw` is removed (main-internal) and `cal:workflow` → `CalibrationState` is added. `cal:data` is unchanged (still pushed; the renderer no longer listens). - **RelayStatus** (now frozen in the guard too): `cal?: CalSnapshot` → `cal?: CalibrationState`. - **IPC_ORDER:** the eleven new channels are appended after `replay:open`. Every pre-existing handler keeps its relative order. ## Mock `skyhawk-mock.ts` hosts a real `CalibrationWorkflowHost`, on the test's clock and timers. - Its commands are the scripted-`CalDevice` `cal*` spies. They are unchanged, and are now spies on the mock rather than on `SkyhawkApi`. - The intents answer through the host, including `calAttach`, and `getStatus` runs main's `relay:status` reload rule. - `emitScenario` hands the link state to the host after `device:status` and the report after `cal:data`, and RAW batches go to the host. `getStatus` returns the host's state. No other test file changed. ## Proofs - **Component tests:** T1–T35 are green, with no test-file diff. The only change under `src/renderer/test/` is `skyhawk-mock.ts`. - **Ported controller test:** - 57 cases, same as before. The case-name diff is the describe prefix only (`CalibrationController …` → `CalibrationWorkflow …`). - Same expected values. The diff is imports, the class/interface names and nothing else. - **workflow-host.test.ts:** besides the 7 ported store cases, 5 lifetime cases: - nothing to act on and RAW dropped before mount; - a remount starts fresh; - an unmount stops the timers; - a second mount without an unmount leaves the old view behind; - after a renderer load, the old view's pending re-read never reaches the device. - **store.test.ts calibration block:** the store keeps 5 tests, now on `calibration.tab.snapshot` with the same expectations. The 7 that pinned the drop-on-link-down rule moved with the rule to `workflow-host.test.ts`, with the same cases and expectations. - **Main trace** (r23c AppServices harness + 7 new dialog flows, 37 scenarios), base vs head: - **Non-calibration and protocol scenarios (30):** byte-identical once the `emit cal:workflow` lines (new) and `emit cal:raw` lines (now main-internal) are removed. - **Dialog flows**, driven at base by the old controller + useCalibration emulation over the old `cal:*` handlers, and at head through the intent handlers: - the flows: capture → save → verify, restore, delete axis, port close mid-write + resume + Cancel inside the held confirmation, link drop mid-capture + resume, HID-tab unmount mid-capture and mid-write + remount, renderer reload mid-write + new page; - result: device traffic (serial writes, device replies, cache file writes, final files) is byte-identical, and so is every other line (timers included) apart from those pushes. - **Render compare:** identical `innerHTML` base vs head in all 56 states: - **HID tab:** link down/up, offer. - **Restore:** panel, re-offer, writing, verifying, done, notice. - **Switch and delete:** axis switch, delete armed, erased. - **Capture:** prompts incl. holding, awaiting confirmation and centre; rejected + redo; draft; rest toggle. - **Link and write:** paused/resumed; the write phases; invalid draft. - **Refusals:** commit, session and stream. - **Lifetime:** a tab switch mid-dialog and back; a reload mid-dialog. - **Boot probe (R2.3c):** the only diffs are channels, and `debug.log` differs by timestamps only. - Removed (10): `cal:hello`, `cal:read`, `cal:session-open`, `cal:stream-select`, `cal:commit`, `cal:reset`, `cal:cache-read`, `cal:cache-store`, `cal:cache-drop`, `cal:session-close`. - Added at the end (11): `cal:open`, `cal:close`, `cal:select-axis`, `cal:start-capture`, `cal:retry`, `cal:set-self-centring`, `cal:save`, `cal:restore`, `cal:delete-axis`, `cal:set-restore-open`, `cal:attach`. - Every other channel keeps its relative order; the count goes from 40 to 41. ## Checks - `npm run typecheck` (3 projects): clean. - `npm run lint`: 0 errors. The warnings fall from 32 to 31 (P5: −2 CalibrationDialog, +1 the store's one calibration accessor). - `npm run lint:deps`: 0 errors, 9 warnings (down from 11). P4-legacy-rules goes from 10 to 8 (CalibrationDialog and calibration-controller are gone); T2-renderer stays at 1. - `npx vitest run`: 89 files, 1064 tests. - `npm run build`: OK. ## Left for R3.4 - `cal:data` is still pushed and still in the contract. The renderer no longer listens: the report reaches it inside `cal:workflow`. Remove it with the dead-code pass. Refs #100
R3.2a of the structure rewrite (#101): the first half of R3.2. The **Cockpit tab** reads a snapshot that main builds and pushes. The tab keeps only presentation: the plan's geometry, wording, the search box and its own view state. Overview, Connection, Settings, Add all, Un-hide all and the `useNodeModel` delete are R3.2b. ## What moved | Before (renderer) | After | |---|---| | `cockpit.ts` `freshReadings`, `nodeHealth`, `nodeViews` | `main/nodes/cockpit-snapshot/views.ts` | | `cockpit.ts` rail / status flag text, `pduAlertFlags`; `cockpit-panels.ts` flag meanings, `pduFlagMeanings` | `main/nodes/faults.ts` | | `cockpit.ts` `attentionItems`, `affected`, `ATTENTION_SHOWN`, `newCount`; `cockpit-adopt.ts` `IDENTIFY_WAIT_MS`, `strayNew`, `strayFlag`; `Cockpit.tsx` strays and items only while live, the Gateway item taking a shown slot, count / shown / more; `gateway-view.ts` the Attention part | `cockpit-snapshot/attention.ts` (`cockpitAttention`, `gatewayAttention`) | | `cockpit.ts` `healthBand`, `totalWatts`, `canErrorsSub`, `canErrorsWorst`; `Cockpit.tsx` REC-over-TEC; `store.ts` `setNodes` peak W (`nodesPeakW`) | `cockpit-snapshot/band.ts` (`cockpitBand`), `index.ts` (`sessionPeakW`) | | `cockpit.ts` `consoleKey` and the grouping half of `planLayout` (who is drawn, which zone, which console, outage per group, gold feeds, PDUs per console); `Cockpit.tsx` the PDU group header's rules (OFFLINE override, flag split, rail value and alert gates, "no readings") | `cockpit-snapshot/plan-zones.ts` (`planZones`, `groupHead`) | | `cockpit-panels.ts` `boardActions`, fault boxes, status / rail / board rows, `powerZoneOptions`, `firstPduOn`; `SidePanels.tsx` the feeding PDU, the other PDUs on the console, Feeds, the fed count passed to Forget, `feedState`, the outage flag for the PDU box | `cockpit-snapshot/panels.ts` (`boardPanels`, `zoneChoices`) | | `cockpit-adopt.ts` `adoptQueue`, `adoptPduChoices`, `adoptability`, `detectedAs`, `adoptEvidence`, `prefill`; `registrySpecs`'s default-PDU rule | `cockpit-snapshot/adopt.ts` (`adoptRows`, `addAllPduId`) | | `Cockpit.tsx` the views → strays → band → attention pipeline and its stray `setTimeout` | `cockpit-snapshot/index.ts` (`buildCockpitSnapshot`) + `main/nodes/cockpit-host.ts` (`CockpitSnapshotHost`, hosted by NodeHub) | | `cockpit-panels.ts` `CONSOLE_OPTIONS`, `consoleLabel` | `shared/nodes/ipc.ts`: main's evidence wording and the renderer both read it | Each function moved verbatim. Two things are new composition: `buildCockpitSnapshot` and the per-section wrappers (`cockpitBand`, `cockpitAttention`, `boardPanels`, `zoneChoices`, `adoptRows`, `groupHead`, `planZones`). Each wrapper runs the same expressions the components ran. The renderer keeps these view-model files: - `node-view.ts`: `STATE_SYMBOL` and `tileMatches` (the search). - `plan-layout.ts`: `planLayout` geometry over the pushed zones, plus `tileWidth`, `shortName` and `outageHint`. - `band-view.ts`: `headroom`, and `psuName` (R3.3). - `panel-view.ts`: `reportAge` (the clock), `mappingPatch` (the unsaved form), and the zone-table lookups. - `adopt-view.ts`: - `adoptSpec` (the unsaved form); - `identState` and `nextSelection`; - `keptQueue` (the rows the open dialog keeps); - `registrySpecs` (Add all, which R3.2b moves); - the `adoptPduChoices` lookup over the pushed zones. `cockpit.ts` keeps the copy that Overview and Connection still call (`nodeViews`, `healthBand`, `attentionItems`) until R3.2b. `cockpit-panels.ts` and `cockpit-adopt.ts` are deleted. ## `CockpitSnapshot` (`cockpit:snapshot`, new push) ```ts interface CockpitSnapshot { rev: number // increases with every build live: boolean // isLinkUp when built views: NodeView[] // {entry, health {state, flag}, readings, noReadings} outages: PduOutage[] plan: { key: 'L' | 'C' | 'R'; zones: PlanZone[]; feeds: boolean[] }[] // PlanZone {key, pdu, head?, members, outage?} // head {off, flags[], noReadings, rail12V?, rail5V?} band: { band: HealthBand; canValue?: {counter, value}; canSub: string; canWorst?: string; peakW: number } attention: { gateway?: {flag, state}; items; shown; count; more; newCount } panels: Record<nodeId, BoardPanel> // BoardPanel {placed, faults, actions, status?, poweredBy?, // rails?, board?, feeds?, sharedWith?} zones: { options: Record<console, Record<zone | '', {value, label}[]>>; first: Record<console, number | null> } adopt: AdoptRow[] // AdoptRow {view, kind?, badge, note?, evidence, goneEvidence, // prefill, pending?, addAllPduId?} } ``` Notes on the shape: - `COCKPIT_IDLE` is the contract's launch value, and the renderer store starts from it. A main test pins it equal to the builder's output for no model. - `buildCockpitSnapshot(input)` is pure, with `now` and `rev` injected. It returns `{ snapshot, nextAt? }`, where `nextAt` is when the next board still identifying becomes a stray. - **API guard:** `PushChannels` / `IPC` gain `cockpit:snapshot` → `CockpitSnapshot`. No intent or method changed, so IPC_ORDER and the boot probe's channels are unchanged. ## When it is pushed (`CockpitSnapshotHost`) The host mirrors what the renderer's store held and re-rendered on: | Event | Push | |---|---| | `nodes:model` | just before it (`node-hub.test` pins `nodes:model` as the last push of a mapping change) | | `nodes:telemetry` | just before it | | `device:status`, link state | after it (a new `onStatus` subscriber, after calibration's) | | `device:status`, Gateway health | after it (`rider.tick`) | | a renderer load (`relay:status`) | built from main's current model and telemetry, with the peak W starting over as the old store's did | | the stray timer | when it fires | The store state it mirrors: the last pushed model and telemetry, the peak W raised on each push (and on a renderer's first pull, which restarted it), and the stray timer. `nodes:get` is not used for the rehydrate. Settings' `useNodeModel` also pulls it, and a peak reset there would be a behaviour change. ## Stray timing The renderer ran: ```ts setTimeout(() => setNow(Date.now()), Math.max(0, nextAt - Date.now()) + 50) ``` The effect depended on `nextAt`. Its `now` moved only when that timer fired, so a push in between was judged at the old time. The host keeps the same three things: - one timer per `nextAt`, cleared when `nextAt` changes, at `nextAt - now + 50` (`STRAY_RECHECK_MS`); - a strays clock that moves at the first build, at a renderer load and at each timer fire; - `nextAt` from the builder. ## X1–X8: the Cockpit's variant, co-located Each variant is a separately named function, listed in `cockpit-snapshot/index.ts`. Other surfaces keep their own copies until R3.2b. | X | Cockpit's variant (main) | The other surface's | |---|---|---| | X1 hidden in cockpit | PDU choices, Add all and the plan see the plan's boards only: `addAllPduId`, `powerZoneOptions` / `firstPduOn`, `planZones` | Settings `anyAdopted` (R3.2b) | | X2 on the bus while unplugged | strays and items only while live: `cockpitAttention` | Connection / registry rows (R3.2b) | | X3 strays | counted online + warning: `cockpitBand`; New on the bus = `isReportableNew` (`newCount`) | Overview `healthBand(views)` with no strays (renderer copy, R3.2b) | | X4 zone choices | `powerZoneOptions` / `firstPduOn` pushed as `zones`; `adoptPduChoices` "(not added)" as `pending`; `addAllPduId` (D7); `boardPanels` `poweredBy` from any source; `planZones` No power zone | AdoptDialog clears `pduId` on a console change (form, unchanged) vs NodePanel picks the first PDU | | X5 Forget / Hide | `boardActions`: offline only | `NodeHub.onlineRefusal`: present in the roster | | X6 fault order | `nodeHealth` (views), `nodeFaultBoxes` / `pduFaultBoxes` (panels), `adoptEvidence` (adopt) | | | X7 fed boards | `boardPanels` feeds / fed count | `pduOutages` fedCount | | X8 registry wording | NOT IN REGISTRY (`nodeHealth`), "Not in registry" (`nodeFaultBoxes`) | Settings registry rows (R3.2b) | ## Mock `skyhawk-mock.ts` hosts a real `CockpitSnapshotHost` over the scenario, on the test's clock and timers: - `emitScenario` hands it each `nodes:model`, `nodes:telemetry` and `device:status` push, in the same step as main; - `getStatus` (main's relay:status) calls `rendererLoaded`, in a microtask, so the page has subscribed first, as asynchronous IPC guarantees; - today's pushes are unchanged. No other file under `src/renderer/test/` changed. ## Proofs - **T1–T35:** green, with no test-file diff. The only change under `src/renderer/test/` is `skyhawk-mock.ts`. - **Ported helper tests:** every case kept its title and expected values. Count before → after: | Before | Count | After | |---|---|---| | `cockpit.test.ts` | 46 | main `cockpit-snapshot/cockpit.test.ts` 23 + `node-view.test.ts` 3 + `plan-layout.test.ts` 14 + `band-view.test.ts` 6 | | `cockpit-panels.test.ts` | 38 | main `panels.test.ts` 35 + `panel-view.test.ts` 3 | | `cockpit-adopt.test.ts` | 28 | main `adopt.test.ts` 17 + `adopt-view.test.ts` 11 | | `store.test.ts` peak W | 2 | `cockpit-host.test.ts` | Every non-verbatim line is one of four things: - an import; - `planLayout(views, outages)` → `layout(views, outages)`, which runs main's `planZones` first; - the keptQueue row accessors (`r.view.entry`); - `registrySpecs(queue, views, edits)` → `specs(...)` over main's `adoptRows`. The renderer `cockpit.test.ts` keeps 20 of its cases for the Overview / Connection copy. The `phase0-probes.test.ts` Cockpit lines now call main's functions, with the same matchers and values. `cockpit-host.test.ts` adds 6 cases: renderer-load peak, idle = builder, push points, the stray timer, one timer per `nextAt`, the strays' clock. - **Render compare, base vs head:** every distinct `document.body.innerHTML` recorded by a MutationObserver through all 35 component-test files plus 7 extra Cockpit flows. - 277 tests and 1068 states (612 distinct), with identical sequences. 112 tests and 524 states touch the Cockpit. - Two T9/T20 states show the wall-clock `hh:mm` of a real-timer `linkDownAt`; these are normalised before comparing. - The extra flows cover: - link down with nothing known; - a full cockpit: every panel kind and tab, every fault box (offline, notSeen, unknown, bus-off, degraded + overheat + error-passive, rail / NTC / TEMP / UNCAL flags, no readings with and without history), outage + hint, Feeds / Powered by, zone lists incl. "(other console)", search / Issues / focus, Gateway warn / err / not responding, link down and back; - the stray before, at and after the 5 s grace; - peak W; - no complete roster; - Adopt: every badge, edits, "(not added)", a hidden PDU, a power-cycle (kept offline, then back), Later, a refused Add all. - **Main trace (r31 harness, 37 scenarios):** every scenario is byte-identical once the 229 `emit cockpit:snapshot` lines are removed. The timer lines are unchanged, 1039 both sides. - **Stray timing with fake timers:** renderer Attention transitions stepped 1 ms at a time, base (the renderer's own timer) vs head (main's host via the mock). Identical: - a board unidentified at mount turns stray at +5050; - boards pushed later turn stray at +11050 and +11550; - a push 20 ms after a board is due changes nothing until +16550. NodeHub on its own, fed real `_NODE_STATUS` lines and stepped 1 ms at a time, pushes the stray's snapshot at +5050. - **Boot probe:** identical channels, order and count (41). `debug.log` differs only in timestamps. ## Checks - `npm run typecheck` (3 projects): clean. - `npm run lint`: 0 errors, 31 warnings (base 31). - `npm run lint:deps`: 0 errors, 8 warnings (base 9). `cockpit-panels` and `cockpit-adopt` are gone (−2); `band-view.ts` → legacy-rules adds 1 (`psuName`, R3.3). - `npx vitest run`: 96 files, 1090 tests. - `npm run build`: OK. ## Left in the renderer, on purpose - The registry defaults behind "Registry: … (NODE_IDS.md)" (`useRegistryEntry`). Moving them removes the "…" frame shown while the `getNodeRegistry` pull is in flight, and the render compare showed it. - The Adopt dialog's session state, `keptQueue` and `dropped`: the rows kept while it is open, and the boards that dropped since. The plan puts these in NodeHub per dialog session, which needs intents. A kept row that left the model shows its last pushed `pending` name and `addAllPduId`. - Add all's set and its sequence (R3.2b); PSU name and limits and `linkDownAt` (R3.3). - `gatewayView().attention` is no longer read: the Cockpit was its only reader. Dead code for R3.4. Refs #100
…gs (#123) R3.2b of the structure rewrite (#101) is the second half of R3.2. The **Overview**, **Connection** and **Settings** now read the snapshot main builds and pushes (`cockpit:snapshot`), as the Cockpit tab has since R3.2a. They keep only presentation: wording, layout and their own view state. **Add all** and **Un-hide all** become NodeHub intents that run today's loops. Nothing changes for the user. The text, counts, order, enablement and timing are all the same. ## What moved | Before (renderer) | After | |---|---| | `cockpit.ts`: the Overview's and Connection's copy of `nodeViews`, `nodeHealth`, `freshReadings`, `pduAlertFlags`, `attentionItems`, `affected`, `ATTENTION_SHOWN`, `healthBand`, `totalWatts` | **deleted.** It was identical to main's `cockpit-snapshot/views.ts`, `band.ts`, `attention.ts` and `faults.ts`; the check was text identity, function by function. | | `overview.ts` `cockpitTile`, `BANNER_NAMES`, `attentionSummary` (with `itemName` and `names`), `reportedKind`, `newDevices`; `Overview.tsx` the views → band → items pipeline, the banner only while connected, and the notice's live gate | `main/nodes/cockpit-snapshot/summaries.ts` (`overviewSummary`) | | `connection-summary.ts` `nodesSummary`; `Connection.tsx` its pipeline | `summaries.ts` (`nodesSummary`); `connection-summary.ts` is deleted | | `settings-view.ts` `registryName`, `registrySummary`, `registryOnBus`; `CockpitSection.tsx` `differing`, `anyAdopted` and Reset…'s gate, the hidden list; `AboutSection.tsx` the link and the model behind On the bus | `main/nodes/cockpit-snapshot/registry-rows.ts` (`settingsNodes`) | | `useNodeModel` (its own `nodes:get` pull and `nodes:model` subscription, mounted 3×) | **deleted**; the subscription goes. The pull stays as the loading gate (`useNodesPulled`, below). | | `AdoptDialog.tsx` `addAll`: one `adoptNode` invoke per board | `node-mapping:add-all` → `NodeHub.addAll` (`main/nodes/mapping-sequence.ts` `addAllInTurn`) | | `CockpitSection.tsx` `unhide(ids)`: one `hideNode(id, false)` invoke per board (Un-hide and Un-hide all) | `node-mapping:unhide` → `NodeHub.unhide` (`unhideInTurn`) | Every function moved verbatim; the check was text identity, declaration by declaration. The new composition is: - `overviewSummary`; - `settingsNodes`; - the two loops. Each runs the same statements as the component it came from. The renderer keeps: - `overview.ts` `newDevicesText`: the notice's wording; - `settings-view.ts`: the reset, import and hidden-row wording; - `adopt-view.ts` `registrySpecs`: the set Add all sends, because it reads the unsaved forms. ## New snapshot sections ```ts interface CockpitSnapshot { // … R3.2a's sections … overview: { tile: CockpitTile // {value, sub, symbol?, tone} banner: AttentionSummary | null // {head, detail}; the renderer applies Settings' toggle newDevices: NewDevices | null // {count, panels, pdus, registry} } nodesCard: NodesSummary // {known, healthy, warn, off, unknown, pdus, watts, // rows: SummaryRow[], more, footer} settings: { differing: number canReset: boolean hidden: HiddenNode[] registry: { summary: string; rows: RegistryRow[] } // RegistryRow {nodeId, name, console, // onBus, unloaded}: OnBus = {text, dot} } } ``` - `CockpitSnapshotInput` gains `registry?`, and `CockpitSource` gains `registry?()`. NodeHub answers it with `this.registry()`, the same source as `nodes:registry`; the mock answers with the scenario's registry. - `COCKPIT_IDLE` gains the sections for "no model, link down". The host test that pins the idle snapshot against the builder still passes, unchanged. - `unloaded` is the On the bus value the registry dialog shows before its own pull lands: `registryOnBus(id, undefined, live)`. ## The variants, co-located, not unified (X1–X8 option a) | X | This surface's variant (main) | The Cockpit's | |---|---|---| | X1 in cockpit | Settings `anyAdopted` = an adopted board on the plan. A hidden adopted board is missed, so Reset… stays greyed (`settingsNodes`). | The plan's boards (R3.2a) | | X2 on the bus | The Overview's banner and notice only while the link is up. Connected nodes reads the last model whatever the link. The registry's CAN rows ignore the link; only the Gateway row follows it (`registryOnBus`). | Items and strays only while live (`cockpitAttention`) | | X3 strays | The Overview and Connection count none: `healthBand(views)` and `attentionItems(views, outages)` with no strays. The notice is `isReportableNew` (`newDevices`). | Counted as online + warning (`cockpitBand`) | | X4 zone | A Connected nodes row names its zone's PDU wherever that PDU is, else "no power zone" (`nodesSummary`). | `poweredBy`, `zoneChoices` (R3.2a) | | X8 wording | The registry's bridge row reads "Gateway" (`registryName`). | NOT IN REGISTRY / "Not in registry" | The full list is in `cockpit-snapshot/index.ts`, `summaries.ts` and `registry-rows.ts`. ## Add all and Un-hide all `node-mapping:add-all`(bulk: `AdoptRequest[]`) and `node-mapping:unhide`(ids) both return `NodeBatchResult`: ```ts type NodeBatchResult = | { ok: true; done: number[] } | { ok: false; done: number[]; nodeId: number; error: string } ``` The loop is the renderer's, statement for statement: ```ts for (const b of bulk) { const r = await adopt(b.nodeId, b.spec) // NodeHub.adopt: saves, pushes cockpit:snapshot + nodes:model 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** is the same loop over `hide(id, false)`. - **Order, stops and results:** the boards go in the given order, each awaited before the next. The loop stops at the first refusal, and the boards after it are not tried. - **Progress:** each board pushes the model as it lands, the same steps the per-board invokes made, so the dialog draws the same intermediate rows. - **Renderer side:** - Add all: `setDone(done ∪ result.done)`, then on a refusal `NODE_ID n: error`. It keeps the stale-closure `done`, as before. - Un-hide: `setError(undefined)`, then the refusal's error. - Single Un-hide goes through the same intent with one id, as it went through the same loop before. - **Known defects:** the Add-all race and Un-hide all stopping at the first failure are kept (fix track). - **API guard:** `CTRL`, `SkyhawkApi` (`addAllNodes`, `unhideNodes`) and the frozen copy in `ipc.compat.test-d.ts` are updated on purpose. Both channels are appended to `IPC_ORDER`. ## Pulls kept for a visible reason - **Settings `nodes:get`.** Each section (Cockpit, Hidden boards, the registry dialog) pulled the model on mount and showed nothing until it landed. During that frame: - Reset… is disabled and reads "Every node matches"; - Hidden boards shows "No hidden boards"; - the registry rows read "—". The render compare shows these frames, so `useNodesPulled` keeps them. It sends the same pull per mount, and the section's data shows once the reply lands, or once a newer `nodes:model` push reaches the store, as the old subscription did. What is shown is the snapshot's section. - **About `nodes:registry`.** It is kept as before: it gates the row and gives the dialog's file, commit and date. - **ResetDialog** still re-previews on each `nodes:model` push. Its trigger is now the store's `nodeModel`, which changes exactly on those pushes once Settings is open. ## Proofs - **T1–T35:** green, with no test-file diff. The only change under `src/renderer/test/` is `skyhawk-mock.ts`: - its CockpitSnapshotHost source answers the scenario's registry; - `addAllNodes` and `unhideNodes` run main's `addAllInTurn` / `unhideInTurn` over the mock's own `adoptNode` / `hideNode` spies, so T11 and T12 still see and refuse each board's call. - **Ported helper tests:** each case keeps its title, body and expected values. Only the imports changed. | Before | Count | After | |---|---|---| | `overview.test.ts` | 22 | main `cockpit-snapshot/overview-summary.test.ts` 20 + renderer `overview.test.ts` 2 (`newDevicesText`) | | `connection-summary.test.ts` | 10 | main `cockpit-snapshot/connection-summary.test.ts` 9 + renderer `node-view.test.ts` 1 (the case that also reads `STATE_SYMBOL`) | | `settings-view.test.ts` | 8 | main `cockpit-snapshot/registry-rows.test.ts` 3 + renderer `settings-view.test.ts` 5 | | `cockpit.test.ts` (renderer copy) | 20 | deleted: each case was textually identical, header included, to one already in main's `cockpit-snapshot/cockpit.test.ts` (R3.2a) | - The renderer `phase0-probes.test.ts` D1 probe now calls main's functions, with the same matchers and values. - `plan-layout.test.ts` takes `RAIL_FLAG` from `@shared/firmware` (`PDU_RAIL_FLAGS`, the object legacy-rules re-exported). - **New tests (13):** - NodeHub Add all / Un-hide all, with per-board pushes and the stop (2); - `mapping-sequence` order, stop and no-answer (4); - `overviewSummary` (2); - `settingsNodes` (4); - the host's registry source (1). - **Render compare, base vs head:** a MutationObserver records every distinct `document.body.innerHTML` through all 35 component-test files, R3.2a's 7 extra flows and 9 new ones. - **Overall:** 286 tests and 1158 states (657 distinct). Every sequence is identical. - **The surfaces this PR touches:** 181 tests and 648 states render the Overview, Connection, Settings, the App or the Adopt dialog. - **The new flows cover:** - Overview: the tile, banner and notice in each state (scanning, no roster, unknown, healthy, warnings only, offline + outage + bus-off + not seen, PDU flags, Gateway err / warn, new devices registry / unregistered / mix / gone, link down and back, the toggle off); - Connection: the nodes card through the same states, Refresh, error and no-device; - Settings › About: the registry dialog through pushes and the link, re-mounted, and the bundled registry opened while unplugged; - Hidden boards: Un-hide all with a push after the first board and a refusal at the second, single Un-hide, Un-hide all again; - Reset…: through pushes, the review re-previewing on pushes, a refused apply, then apply; - Add all with a push after the first board and a refusal at the second (the dialog draws the added board as "not reporting right now" between them), then the rest with pushes; - link down and back across Overview, Connection and Settings in the App. - **Main trace (the r31 harness, 40 scenarios = 37 + 3 new):** every scenario is identical once the new snapshot sections are removed from the `cockpit:snapshot` payloads. That covers every emit, file write, serial write, timer, debug-log line and return value. The snapshots are at the same positions (249). The three new scenarios are: - `add-all`: a refusal mid-way, the rest, an empty set, then link down; - `unhide-all`: a refusal mid-way, one alone, then the rest; - `sequences-read-only`. Base drives them through the old per-board `node-mapping:adopt` / `node-mapping:hide` invokes in the renderer's loop; head drives them through one intent. Their device and file traffic is identical. - **Stray timing (R3.2a's harness):** unchanged: - the renderer side: +5050, +11050, +11550 and +16550; - NodeHub on its own: the stray's snapshot at +5050. - **Boot probe:** the only change is the 2 new channels, `node-mapping:add-all` and `node-mapping:unhide`. They are appended at the end of the order (41 → 43). `config.json` and `node-mapping.json` are identical; `debug.log` differs only in timestamps. ## Checks - `npm run typecheck` (3 projects): clean. - `npm run lint`: 0 errors, 31 warnings (base 31). - `npm run lint:deps`: 0 errors, 6 warnings (base 8). The `overview.ts` → legacy-rules and `cockpit.ts` → legacy-rules warnings are gone. - `npx vitest run`: 98 files, 1083 tests. Base was 96 / 1090: −20 duplicate cases and +13 new. - `npm run build`: OK. ## legacy-rules importers left in the renderer `shared/nodes/legacy-rules`: only the PSU ones, which are R3.3: - `band-view.ts` (`PSU_PRESETS`); - `Cockpit.tsx` (`effectivePsuLimits`); - `CockpitSection.tsx` (`parsePsuLimit`, `PSU_PRESETS`). `shared/events/legacy-rules` (out of scope): `log-view.ts` (`isProblem`) and `Log.tsx` (`SEVERITY_GLYPH`). ## Notes - **C5 race:** a Settings pull reply that lands after a newer push no longer overwrites it, because the rows come from the pushed snapshot. The mock answers pulls at once, so no flow can reach this race and none was changed by it; T35 pins only the deterministic path. - **Registry source:** the registry rows come from main's registry, not the dialog's pull. Main's registry is bundled and never changes at runtime, so the two agree. A test that swaps the scenario's registry mid-session would see the summary follow at the next push. - **Leftover comment:** T35's header still names `useNodeModel`. Test files are untouched by rule. - **Store fields:** the store keeps `nodeModel` (the review's re-preview trigger and the pulled-gate) and `nodeTelemetry` (now read only by `store.test.ts`) for R3.4's store split. Refs #100
…ain (#124) R3.3 of the structure rewrite (#101). The rules the renderer still worked out for the link, the relay, the HID tab and the settings move to their main owners, which push the result. The renderer keeps the wording, the layout, what is typed, and one piece of pending view state: the Relaying it shows at once when Start or Stop is clicked. Nothing changes for the user. The text, enablement, colours and order are all the same, and so are the gaps listed below. ## New push fields, per channel | Channel | Payload | What's new | |---|---|---| | `device:status` | `DeviceStatusPush` = `DeviceStatus` + `DeviceLinkFacts` | `linkUp`, `boardPresent`, `busy`, `linkDownAt?`, `serialMonitor` (additive). Also carried by the rehydrate bundle's `device`. | | `hid:report` | `HidReport` = `HidSnapshot` + `HidReportFacts` | `band` (`live` / `idle` / `quiet`), `present` (axis slots declared), `axisLive[]` (additive) | | `telemetry:tick` | `TelemetryReadout[]` | each readout gains `tone` (`fuel-low` / `fuel` / `secondary` / `primary`) | | **NEW** `relay:facts` | `RelayFacts` | `running` and `stopped` sides, each with {`waiting`, `tip`, `startBlocked?` (`bridge` / `replay`), `overviewLink`, `canRecord` {connection, settings}}; `stream?`, `deviceState`, `endpoint` {pill, overview}, `recording` {on, events?}, `replay?` {file, events, durationMs}. The rehydrate bundle carries it as `relayFacts`. | | **NEW** `config:changed` | `ConfigChanged` = {`config`, `psu`: `PsuView` {name, limits, preset}, `source`: `edit` / `import`} | pushed after every settings change | New intents: `config:edit` (`editConfig(ConfigEdit)`: a form's change, with the port as typed) and `relay:toggle` (`toggleRelay(relaying)`). `config:get` now answers `ConfigView` {config, psu}. Both channels go at the end of `IPC_ORDER`. `SectionDiff` gains `note?`. The API guard (`ipc.compat.test-d.ts`) is updated on purpose, with a dated note for each change. New contract idle values, each pinned to its main builder by a test: `DEVICE_IDLE`, `RELAY_FACTS_IDLE`, `TELEMETRY_IDLE`, `AIRCRAFT_NONE`, `PSU_VIEW_DEFAULT`. `isLinkUp` leaves the gateway contract, and main imports it from the gateway topic. ## Old → new Every rule moved verbatim, its comment with it. Where one renderer function both decided and worded (`relayTip`, `relayNote`, `startBlockedReason`, `gaugeColor`, `freshness`), main keeps the branches and pushes which one applies; the renderer keeps the strings and colours. | Before (renderer) | After (main) | |---|---| | store.ts `device:status` handler: `isLinkUp`, the `linkDownAt` stamp | `gateway/link-state.ts` (`isLinkUp`, `LinkDownClock`, `linkFacts`); GatewayConnection pushes the result | | device-view.ts `isBusy`; freshness.ts `BOARD_PRESENT` | `gateway/link-state.ts` (`isBusy`, `isBoardPresent`) | | store.ts optimistic `serialMonitor` | SerialTrafficMonitor's page flag; setting it re-pushes `device:status` | | freshness.ts `FRESH_MS` / `RECENT_MS` / bands; Hid.tsx present axes and `axisLive` | `hid/hid-facts.ts`; HidService pushes the result | | device-view.ts `waitingForDevice` / `waitingForDcs`, `startBlocked`, `relayWaiting`, `relayTip` / `relayNote` branches, `startBlockedReason` / `Callout` mode choice | `dcs/relay-rules.ts`; DcsRelay pushes `relay:facts` | | RelayPill.tsx / Overview.tsx endpoint labels; Overview.tsx Link sub-line; Connection.tsx / ConnectionSection.tsx Record gating | `dcs/relay-rules.ts` (`pillEndpoint`, `overviewEndpoint`, `overviewLink`, `canRecord`) | | store.ts `toggleRelay` chain (Stop, or save then Start) | `DcsRelay.toggle` (`relay:toggle`). The renderer keeps the pending flag and its rollback. | | store.ts recording / replay file from the intent replies | `DcsRelay.showCapture` / `showReplay`, from the same replies (`captureView`, `replayView`) | | store.ts `IDLE_TELEMETRY`; Overview.tsx `gaugeColor` | `dcs/sim-telemetry-decoder.ts` (`idleTelemetry`, `gaugeTone`) | | store.ts `buildConfig` (port fallback, read-modify-write) | `config/settings-form.ts`; `SettingsService.edit` (`config:edit`) | | ConfigurationSection.tsx rebuilding the store from `r.config` | `config:changed` with `source: 'import'` (`SettingsService.imported`, called by ConfigTransfer) | | band-view.ts `psuName`; legacy `PSU_PRESETS`, `effectivePsuLimits` | `nodes/psu-limits.ts` (`resolvePsu`), carried by `config:changed` / `config:get` | | ImportDialog.tsx `CAL_NOTE` and when it shows | `config/config-file.ts` `withSectionNotes`, applied in `ConfigTransfer.preview` | | store.ts initial settings, device, aircraft and telemetry literals | `configState(DEFAULT_CONFIG)`, `DEVICE_IDLE`, `AIRCRAFT_NONE`, `TELEMETRY_IDLE` | ## The co-located variants (X9, X10, X12) | X | Variants, side by side in their main owner | |---|---| | X9 endpoint label | `pillEndpoint`: TCP → host:port as typed, loopback → `239.255.50.10:5010`, unicast → `unicast listen`. `overviewEndpoint`: TCP → host:port as typed + ` · remote`, otherwise `239.255.50.10:5010 · local`, for Unicast too. | | X10 telemetry spec | `IDLE_TELEMETRY` (the old store's placeholders: IAS and Press Alt in `% FS`, Press Alt keyed `D_ALT_NEEDLE`) next to the live `TELEMETRY` (`kt`, `ALT_FT` / `ft`). `gaugeTone` keys on the id, so the idle Press Alt keeps its plain ring and the live one is a secondary ring, as before. | | X12 Record | `canRecord(relaying)`: Connection card `connection: relaying`; Settings › Connection `settings: true` | ## How each gap is kept - **N1, reload loses page state.** Main now holds what the page held, and resets it when a renderer loads (`relay:status` → `AppServices.rendererLoaded`): - `linkDownAt` (`LinkDownClock.pageLoaded`); - the serial monitor's flag, while the monitor itself keeps running and its traffic keeps coming; - the recording and the loaded capture. Aircraft and telemetry still start from the idle constants and are not in the bundle. Peak W was already handled in R3.2. The render proof reloads while relaying, recording, monitoring and with the link down, and again in Replay with a capture loaded. Both runs render the same states. - **N2, Monitor says "relay waiting".** `overviewLink(relaying, linkUp)` keeps the mode-blind rule. - **N3, Bridge-only device wait.** `waitingForDevice` is moved verbatim. Driven Replay stays blocked but never "waiting for the Gateway". - **N5, per keystroke, junk → 7778.** Each keystroke is still saved, through `config:edit`. `buildConfig` keeps `Number(port) || 7778`. The fields keep showing what was typed: an edit's own `config:changed` (`source: 'edit'`) doesn't touch them. Only an import resets them, as its answer did, even an import that changed no settings. - **N7, HID availability fetched once.** The pull stays, for hats and buttons. Axes use main's catalogue, which is the same bundled table the pull answered from, fixed per build. - **N8, PSU fields stale after an import.** The typed text is still the form's own state, seeded once per mount. - **Silent revert on a refused Start.** The renderer still takes back its pending Relaying when the answer is `!ok`, and shows no reason. ## legacy-rules - **`shared/events/legacy-rules.ts`: deleted.** - `SEVERITY_GLYPH` is a glyph table. - `isProblem` is the filter behind the Events view's "Problems only" toggle. It chooses which rows the view lists from the severity main gave them, and decides nothing about the events. Both are presentation, so both move to the renderer's `log-view.ts`. The `isProblem` case moves from main's events test to `log-view.test.ts`. - **`shared/nodes/legacy-rules.ts`: the PSU entries go** (`PSU_PRESETS` and `effectivePsuLimits` to main; `psuName` came from band-view.ts). Two things stay, which the brief did not expect: - `parsePsuLimit` (with `isLimit` and `PSU_LIMIT_MAX`) stays, and so does its one renderer caller, CockpitSection.tsx. The PSU form's invalid outline is judged on every keystroke against the text typed since that form mounted. A verdict pushed from main would land one IPC hop later. Main also can't know about the mount, which re-seeds the text (a saved 1e-7 re-seeds as `1e-7`, which reads invalid). - The file is not empty. `RAIL_FLAG`, `PDU_FLAG`, `displayCurrentMa`, `nodeFaultTag`, `reportedIdentity`, `isAdoptableKind` and `isReportableNew` have no renderer caller left; only main uses them. Moving them is R3.4's "legacy-rules move to `main/<topic>/`". ## Where this differs from the brief 1. **`relay:facts` is a new channel, not new fields on `relay:state`.** There were two reasons: - Today any `relay:state` push resets the pending Relaying (store.ts). The facts must be re-pushed when the link, the settings, a recording or a capture changes. On `relay:state`, that re-push would take back a Start that main hasn't answered yet. - `app-services.test.ts` 'relay state (#69)' asserts `relay:state` payloads and `status().relay` with `toEqual`. New fields would change those assertions. `relay:state` is byte-identical. Each fact comes for both sides of the on/off, so a pending Start or Stop reads the side it shows. 2. **`config:get` answers `ConfigView`, not `AppConfig`.** The power supply view is then there in the same reply as the settings, as `effectivePsuLimits(psu)` was. `config:changed` carries `source`, so an edit's echo leaves what is typed alone (N5). 3. **The endpoint label is a template.** Main picks the label; for TCP it says "the host and port as typed", and the renderer fills them in from its fields. The pill has always shown the port as typed (`127.0.0.1:77a`), while the saved port is 7778. 4. **HidService re-pushes `hid:report` in two cases.** It does so when calibration's `cal:workflow` carries a new device snapshot, and when a renderer loads. The live axes follow the snapshot, as the tab re-rendered on that push. 5. **Timing in the app, not in tests.** Facts that depend on a form's own change (the mode, Drive Gateway, the transport, the PSU), and the serial monitor's flag, now come back one IPC round trip after the click instead of in the same render. The mock pushes them synchronously, as main does inside the handler, so the render sequences are identical. Start's rollback lands one round trip sooner: the save and the Start run in main back to back. ## Proofs - **T1–T35: no test-file diff.** The only changes under `src/renderer/test/` are: - `skyhawk-mock.ts`, which builds each push with main's builders (`linkFacts` / `LinkDownClock`, `hidFacts`, `relayFacts`, `editedConfig`, `resolvePsu`) and hosts their page state. `toggleRelay` runs DcsRelay's sequence over the `setConfig` / `startRelay` / `stopRelay` spies. The capture, replay and import answers feed main's views through a wrapper that keeps them drivable spies. - `scenarios.ts`, whose `importPreview` applies `withSectionNotes`, as `ConfigTransfer.preview` does. - **Ported helper tests.** Each case keeps its title, body and values. Adapters in each file's header compose main's rule with the renderer's wording. | File | Before | After | |---|---|---| | `device-view.test.ts` | 18 | 18 | | `freshness.test.ts` | 5 | 5 | | `store.test.ts` | 16 | 16 (the fake main builds pushes with main's builders) | | `gateway-view.test.ts` | 5 | 5 (untouched) | | `band-view.test.ts` | 6 | 5 (`psuName` → `psu-limits.test.ts`, 4 → 6 with `resolvePsu`) | | `events.test.ts` | 27 | 26 (`isProblem` → `log-view.test.ts`, 11 → 12) | - **New tests (30):** - `link-state` (5) - `hid-facts` (3) - `relay-rules` (9) - decoder tones and the X10 idle pin (2) - `settings-edit` (5) - `resolvePsu` (1) - `pushed-rules.seam` (5) - **Render compare, base vs head.** - **Overall:** 300 tests and 1735 states (931 distinct) across all 35 component-test files, R3.2a's and R3.2b's flows, and 14 new flows. Every sequence is identical. - **Surfaces this PR touches:** 211 tests and 1376 states render the title bar, Overview, Connection, HID, Serial, Settings or the Cockpit band. - **The new flows cover:** - the device link in every `DeviceState` (busy / other errors, Gateway health) across the pill, popover, Connection card and Link tile; - auto-connect off and its Settings link; - the relay in each mode × link state, through every stream state, blocked callouts and a refused Start; - a pending Start while the link, a recording and the transport change; - Record on both surfaces; - gauges idle → live → fuel alert → kept after Stop; - HID freshness across fake time, the board coming and going, and calibration arriving with no report yet; - PSU preset / custom with the Cockpit band, including invalid and re-seeded text; - host and port as typed (junk too) with the pill and the Overview following; - an import with and without settings, and a failed one; - the serial monitor; - two renderer reloads (N1). - **Main trace.** The r31/r32b harness ran 40 scenarios plus 5 new ones (`r33-link-facts`, `-relay-toggle`, `-config-edit`, `-import`, `-capture-replay`). Base drives the renderer's old sequences; head drives the new intents. - **Filtered:** with the new fields stripped and the new channels dropped (330 `relay:facts`, 46 `config:changed`), every scenario is identical: the same emits, file writes, serial writes, timers, debug-log lines and return values. - **Inserted:** the only insertions are re-pushes, 10 `device:status` (one per serial-monitor toggle) and 69 `hid:report` (calibration snapshot changes and renderer loads). - **Files:** config.json and node-mapping.json are written byte for byte the same. - **Boot probe.** - **Intents:** `config:edit` and `relay:toggle` are appended (43 → 45). - **Pushes:** `config:changed` and `relay:facts` are new. - **Files:** `config.json` and `node-mapping.json` are identical; `debug.log` differs only in timestamps. ## Checks - `npm run typecheck` (3 projects): clean. - `npm run lint`: 0 errors, 29 warnings, all P5 direct `window.skyhawk` (base 31: two fewer references in store.ts). - `npm run lint:deps`: 0 errors, 2 warnings (base 6). Gone: both events legacy warnings, and the nodes legacy warnings from Cockpit.tsx and band-view.ts. Left: `CockpitSection.tsx → shared/nodes/legacy-rules.ts` (`parsePsuLimit`, above) and `Overview.tsx → assets/logo.png`. - `npx vitest run`: 103 files, 1113 tests (base 98 / 1083: +30 new, ported cases net 0). - `npm run build`: OK. ## Left for R3.4 (outside this brief's list) - `deviceView`'s state → button / action table (§4.3, §4.7). - `gatewayView`'s link gate and the not-responding title (§4.7). - Connection's Refresh gate (`canRefresh`, §4.3) and the Record card hidden in Replay (§4.3); both still read pushed facts or the mode in the renderer. - The renderer's transport aliases (§4.9). - PSU `{...psu, mode}` and `parsePsuLimit` (§4.10, above). - ConfigurationSection's export / import answer handling and ImportDialog's "Import failed" fallback (§4.10). - The remaining `shared/nodes/legacy-rules.ts` entries. - The store fields `relayStream` and `hidAgeMs`, now read only by `store.test.ts`. Refs #100
R3.4a of the structure rewrite (#101): the decisions R3.3 left in the renderer move to their main owners, which push the result. The renderer keeps the wording. `shared/nodes/legacy-rules.ts` is deleted. Nothing changes for the user. Text, enablement and order are the same, and the known gaps (N1–N9, the parked list, the Relay pill's silent revert) are kept as they are. ## Old → new Each rule moved verbatim, with its comment. | Before (renderer) | After (main) | Pushed as | |---|---|---| | device-view.ts `deviceView`: the state → button / action table, disabled while scanning | `gateway/link-state.ts` `linkAction` (GatewayConnection) | `device:status` `action` | | gateway-view.ts: the Gateway hidden while the link is down | `gateway/gateway-health.ts` `shownHealth` / `shownStatus`, applied by GatewayConnection | `device:status` `gateway`, left out while down | | gateway-view.ts: `kind === 'not-responding'` → popover title, not a Health row | `gatewayStatus` marks it | `GatewayStatus.aboutLink` | | Connection.tsx Refresh `disabled={!linkUp}` | NodeHub's own roster guard, moved verbatim to `nodes/node-status-wire.ts` `canRequestRoster(attached, state)`; `NodeHub.canRefresh()`; `requestNodes` uses it | `device:status` `canRefresh` (GatewayConnection's `canRefresh` dep, like `health`) | | Connection.tsx Record card `sourceMode !== 'replay'` | `dcs/relay-rules.ts` `recordShown(mode)`, beside `canRecord` | `relay:facts` `recordShown` {connection, settings} | | Connection.tsx Capture card `sourceMode === 'replay'` (its mirror) | `dcs/relay-rules.ts` `captureShown(mode)`, beside `recordShown` | `relay:facts` `captureShown`, in the same push | | ConfigurationSection.tsx: reading the export reply from which field is set | `ConfigTransfer.exportTo` (the config:export handler's body, moved) | reply `ConfigExportResult.outcome` | | CockpitSection.tsx PSU switch `{ ...psu, mode }` | `config/settings-form.ts` `editedConfig` (SettingsService) | intent `config:edit` `{ psuMode }`; the setting comes back on `config:changed` | | CockpitSection.tsx `onType`: refuse / delete / set one limit, `{ ...psu, custom }` | `config/settings-form.ts` `withPsuLimit` + `savedEdit`; `SettingsService.edit` saves and pushes nothing for a refused limit | intent `config:edit` `{ psuLimit: { key, text } }` | The renderer keeps "Connect / Disconnect / Retry" (`linkButton`), "Saved {file}", the cards themselves, the popover's title and Health row, and the PSU field's typed text. The field's per-keystroke invalid outline still uses the contract's `parsePsuLimit`: that validates unsaved input. ### Record (X12-style variants) Settings › Connection's Record row always shows. It differs from the Connection card, so it gets its own variant: `recordShown` = `{ connection: mode !== 'replay', settings: true }`. ConnectionSection reads `settings`. The value follows the mode, not the on/off, so it sits at the top of `RelayFacts`, not in each side. ### Items with nothing to move - **ConfigurationSection's import-open handling.** The canceled / error / ready verdict already comes from main (`ConfigImportOpen.status`). The section only switches on it. - **ImportDialog's `'Import failed'`.** `ConfigImportApplyResult`'s failure always has an `error: string`: by type, and on all five `ConfigTransfer.apply` returns. So `r?.error ?? 'Import failed'` only fires when there is no answer (`window.skyhawk` absent). Main can't decide that, so the fallback stays as wording. Both cases have render flows. - **Transport aliases** (`TRANSPORT_TO_IPC`, Connection's `TRANSPORTS`, Overview's TCP / Multicast / Unicast). They are view-model names only, a 1:1 rename, so they stay. `configState`'s `?? 'tcp'` can't be reached, because main's `sanitizeConfig` only lets the three `DcsTransport` values through. It is left as it is. ## New push fields | Channel / reply | Field | |---|---| | `device:status` (and `relay:status`'s `device`) | `action: LinkAction` {`offer`: connect / disconnect / retry, `intent`: connect / disconnect, `disabled?`}, `canRefresh: boolean`; `gateway` absent while the link is down | | `GatewayStatus` (on `device:status`) | `aboutLink?: true` (not responding) | | `relay:facts` (and `relay:status`'s `relayFacts`) | `recordShown: { connection, settings }`, `captureShown: boolean` | | `config:export` reply | `ConfigExportResult` = `{ outcome: 'saved', path }` / `{ outcome: 'canceled' }` / `{ outcome: 'failed', error }` | | `config:edit` intent | `ConfigEdit.psuMode?`, `ConfigEdit.psuLimit?: { key, text }` | No channel, intent or method was added, removed or renamed (45 intents). The API guard (`ipc.compat.test-d.ts`) gets a dated R3.4a note. `DEVICE_IDLE` and `RELAY_FACTS_IDLE` gain the new fields and are still pinned to their builders. ## Where legacy-rules went The file is deleted. The `P4-legacy-rules` depcruise rule, its `LEGACY` path and the `legacyRules` severity key go with it. | Entry | Now | |---|---| | `RAIL_FLAG`, `PDU_FLAG` | Pure aliases, so callers import `PDU_RAIL_FLAGS as RAIL_FLAG` / `PDU_STATUS_FLAGS as PDU_FLAG` from `@shared/firmware` (as plan-layout.test already did) | | `displayCurrentMa` | `main/nodes/node-telem.ts` | | `nodeFaultTag` | `main/nodes/faults.ts` | | `reportedIdentity`, `isAdoptableKind` | `main/nodes/node-kind.ts` | | `isReportableNew` | `main/nodes/node-model.ts` | | `parsePsuLimit`, `isLimit`, `PSU_LIMIT_MAX` | the nodes contract `shared/nodes/ipc.ts`, next to the PSU types. A comment says why: main sanitizes with them, and the renderer marks a field invalid per keystroke from the typed text | ## Timing in the app (not in tests) As in R3.3, a fact that follows a form's own change arrives one IPC round trip after the click: - **The Capture and Record cards.** Both now come from the same `relay:facts` push, so they flip together. During the round trip the previous mode's cards stay on screen; they are never both visible. - **The PSU segment and read-only fields after a mode click.** The store's `psu` comes from every `config:changed`, edits included, since main switches the mode and sets the limits. - **The PSU limit field never moves.** Its text is the form's own state. The sync mock pushes inside the handler, so those render sequences are identical. An async-hop proof (below) holds main's answer back to show the in-between state. ## Proofs - **T1–T35: no test-file diff.** The only file changed under `src/renderer/test/` is `skyhawk-mock.ts`. It builds `device:status` with main's `shownStatus`, `linkFacts` (now `action`) and `canRequestRoster`. A form's edit goes through `savedEdit`, so a refused limit saves and pushes nothing. `exportConfig`'s default answer is `{ outcome: 'canceled' }`. - **Ported helper tests.** Same cases, same values. The header adapters compose main's rule with the renderer's wording. | File | Before | After | |---|---|---| | `device-view.test.ts` | 18 | 18 (`button` = `linkButton(linkAction(state))`) | | `gateway-view.test.ts` | 5 | 5 (`gatewayView(g, linkUp, port)` = `viewOf(shownHealth(g, linkUp), port)`) | | node-kind, node-model, node-telem, node-status-wire, psu-limits, panels, cockpit, events | unchanged | only their imports changed | - **New tests (14).** - link-state: the action table, plus `canRefresh` (2) - gateway-health: `aboutLink`, plus `shownHealth` / `shownStatus` (2) - `canRequestRoster` (1) - `recordShown`, plus `captureShown` as its mirror (2) - PSU mode switch, plus `withPsuLimit` / `savedEdit` (2) - a refused limit saves and pushes nothing (1) - `ConfigTransfer.exportTo`: saved / cancelled / failed (1) - `pushed-rules.seam`: action and Refresh through the link states; Record and Capture across modes; the PSU switch and typed limits over config:edit (3) - **Render compare, base vs head.** 307 tests and 1965 states (1067 distinct): all 35 component-test files, the R3.2a / R3.2b / R3.3 flows, and 7 new flows (230 states). Every sequence is identical. After each click the flows write what reached main into the page (connect / disconnect / refresh / export / import / apply calls, and every settings save), so the clicks are compared too. The new flows cover: - every `DeviceState`, with the card's and the popover's button clicked; - Gateway not responding, and health pushed while the link is down (reconnecting, error, disconnected); - Refresh in every state; - Record across modes, stopped and relaying, and Settings › Connection; - export saved / cancelled / failed; - import open refused, apply refused twice, applied, cancelled; - PSU preset ↔ custom, with typing, invalid text, and an import. - **Async-hop proof (base and head).** Two flows hold every `config:edit` answer back until an explicit hop, so the state between a click and main's push is on screen. A MutationObserver checks every DOM state. - **Source mode** (Replay / Monitor / Replay / Bridge / Replay / Monitor): the Capture and Record cards are never both shown, at base or at head. Before this fix, head (8c41811) failed here with both cards showing during the hop. At head, the 6 held states show the previous mode's cards, and each flips when main answers. - **PSU limit** (type a valid value, then an invalid one, delete it, type two keys with both answers held): the field shows exactly what was typed in every state. Base and head reach the same 8 DOM states in the same order, with the same saves. - **Main trace.** The R3.3 harness ran 45 scenarios plus 3 new ones: - `r34a-psu-mode`: mode switches, and limits typed valid, invalid, empty and junk; - `r34a-refresh-action`; - `r34a-export`, through the real config:export handler. With the new fields stripped, all 48 scenarios (11,794 lines) are identical: the same emits, file writes, serial writes, timers, debug-log lines and return values. Nothing is inserted or dropped; a refused limit writes and pushes nothing on either side. Fields stripped: `device:status.action` and `.canRefresh` (230 each), `gateway.aboutLink` (5), `relay:facts.recordShown` and `.captureShown` (387 each), `config:export.outcome` (4). - **Boot probe.** No difference in either run, the dev userData or `SKYHAWK_USER_DATA`. The channel list is the same 45 intents, in the same order, with the same pushes. `config.json` and `node-mapping.json` are byte-identical. ## Checks - `npm run typecheck` (3 projects): clean. - `npm run lint`: 0 errors, 29 warnings, all P5 (unchanged; store.ts's two `editConfig` callers share one `window.skyhawk` reference). - `npm run lint:deps`: 0 errors, 1 warning, the expected one: `Overview.tsx → assets/logo.png` (base: 2; the `CockpitSection.tsx → legacy-rules` warning is gone). - `npx vitest run`: 103 files, 1127 tests (base 1113; +14 new). - `npm run build`: OK. ## Left for R3.4c - Main has a second copy of the link gate: `cockpit-snapshot/attention.ts` `gatewayAttention` keeps its own `!linkUp || !g`. It could consume `shownHealth`. Refs #100
…#126) R3.4b of the structure rewrite (#101). The renderer gets its topic folders, one zustand store per topic, and a single IPC adapter. The renderer boundary rules go to error. Nothing changes for the user. Every component test and every render flow reaches the same DOM states in the same order, and main is untouched. Three commits: 1. `git mv` only. 2. The edits. 3. The lint flips. ## Old → new Moved files keep their content. The edits are imports, store hooks and the cuts listed in the table. | Before (`src/renderer/src/`) | After | |---|---| | `App.tsx` | `app/App.tsx` (+ `app/index.ts`; `main.tsx` imports `./app`) | | `store.ts` | split: see the next section. `initBridge` → `app/ipc-adapter.ts` | | `assets/logo.png` | `app/assets/logo.png` (clears the T2 warning) | | `useModalFocus.ts` | `app/kit/use-modal-focus.ts` | | `tabs/settings/{Dialog,Row}.tsx` | `app/kit/{Dialog,Row}.tsx` | | `Sheet` (in SidePanels), `Chevron` (SidePanels), `CogIcon` (Cockpit) | `app/kit/Sheet.tsx`, `app/kit/icons.tsx` (`Chevron`, `Cog`), cut verbatim | | `tabs/Overview.tsx`, `overview.ts` | `app/overview/Overview.tsx`, `app/overview/overview-view.ts` | | `tabs/Connection.tsx` | `app/connection/Connection.tsx`. It composes `gateway/DeviceCard.tsx`, `nodes/NodesCard.tsx` and `dcs/RelayControls.tsx` (`CaptureCard`, `RecordCard`, `RelayStart`), each cut verbatim | | `tabs/Log.tsx` | `app/log/Log.tsx`. It keeps the toolbar, the head row and the footer, and renders `events/EventsView.tsx` / `dcs/StreamView.tsx` (the row lists, cut verbatim) inside the same `.log__rows` element | | `tabs/Hid.tsx` | `hid/HidPanel.tsx` + `app/hid/HidTab.tsx`. HidTab holds the calibration store, the `attach` effect and the dialog, and hands them to the panel | | `tabs/Settings.tsx`, `tabs/settings/{About,Cockpit,Connection,Diagnostics}Section.tsx`, `ResetDialog.tsx`, `useNodesPulled.ts` | `app/settings/…` | | `tabs/settings/settings-view.ts` | `app/settings/settings-view.ts` (reset and hidden rows) + `config/import-view.ts` (`importSubtitle`, `importRowKey`, `importDoneText`) | | `tabs/settings/{ConfigurationSection,ImportDialog}.tsx` | `config/…` | | `DevicePopover.tsx`, `gateway-view.ts`, `tabs/Serial.tsx` | `gateway/…`. Serial's hex/ASCII `render` → `gateway/serial-view.ts` | | `device-view.ts` | `gateway/link-view.ts` (`deviceView`, `linkButton`) + `dcs/relay-view.ts` (`relaySide`, `endpointText`, `relayTip`, `relayNote`, `startBlocked*`) | | `RelayPill.tsx` | `dcs/RelayPill.tsx` | | `rawHex`, `LogRow`, `DirFilter` (store.ts) | `dcs/stream-view.ts` | | `tabs/Cockpit.tsx`, `tabs/cockpit/{AdoptDialog,SidePanels,Dot}.tsx`, `node-view`, `plan-layout`, `panel-view`, `adopt-view`, `band-view` | `nodes/…` | | `freshness.ts`, `AXIS_LABELS` + `HAT_DIRS` (store.ts) | `hid/hid-view.ts` | | `CalibrationDialog.tsx`, `calibration-view.ts` | `hid/calibration/…` | | `tabs/cockpit/BoardEvents.tsx`, `log-view.ts` | `events/BoardEvents.tsx`, `events/events-view.ts`. `LogView` and `EventSourceFilter` → the nav store | | (new) | `app/titlebar/{DevicePopover,RelayPill}.tsx`, `app/cockpit/Cockpit.tsx`: app wrappers that hand a topic view its settings (below) | Test files moved with their modules: `store.test.ts` → `app/ipc-adapter.test.ts`, and each `*-view.test.ts` sits next to its module. ## Store slices → topic stores Each store is cut verbatim from `store.ts`. Its fields, initial values, intent bodies and comments are unchanged. Each one exports `use<Topic>Store`, `bind<Topic>(api)` and `<topic>FromMain`, the handlers the adapter calls. | Store | State | Intents | |---|---|---| | `app/kit/nav-store.ts` `useNav` | `tab`, `logView`, `eventSource`, `cockpitOpen`, `cockpitAdopt`, `devicePopOpen` | `set`, `showEvents`, `openInCockpit`, `openAdopt` | | `gateway/store.ts` | the `device:status` fields (`linkState`), `serialMonitor`, `serialLog` | `connectDevice`, `disconnectDevice`, `setSerialMonitor`, `clearSerial` | | `dcs/store.ts` | `relaying`, `relayStream`, `relayFacts`, `aircraft`, telemetry, stats, `log`, `autoscroll`, `rawMode`, `dirFilter`, `search` | `set`, `toggle` (autoscroll / rawMode), `toggleRelay`, `toggleCapture`, `openReplay`, `clearLog`, `exportLog` | | `nodes/store.ts` | `nodeModel`, `nodeTelemetry`, `cockpit` | `refreshNodes`, plus the views' former direct calls: `pullNodes`, `getNodeRegistry`, `setNodeMapping`, `adoptNode`, `addAllNodes`, `resetNode`, `forgetNode`, `hideNode`, `unhideNodes`, `previewNodeReset`, `resetNodeMapping` | | `hid/store.ts` | axes, buttons, hats, rate, age, band, present, `axisLive`, `availHats`, `availButtons` | none (no `bindHid`) | | `hid/calibration/store.ts` | `calibration` | `calibrate` (`CalibrationIntents`) | | `events/store.ts` | `events`, `eventsPauseMark`, `logPaused`, `problemsOnly` | `set`, `togglePause` (the `logPaused` branch of the old `toggle`), `clearEvents`, `exportEvents` | | `config/store.ts` | the form fields (`configState`), `psu`, `psuView`, `debugMode`, `debugDump` | `setConfigField`, `setPsuMode`, `setPsuLimit`, `dumpSerialPorts`, `revealDebugLog`, `exportConfig`, `openConfigImport`, `applyConfigImport` | **Every multi-field update stays one update.** - The Log tab's pause lives with the events pause mark, because `toggle('logPaused')` set both in one update and `clearEvents` reads it. - `showEvents`, `openInCockpit` and `openAdopt` set nav fields only. ## The adapter `app/ipc-adapter.ts` is the only file that reads `window.skyhawk`. - At load it binds every store through one accessor, `() => window.skyhawk`, which is read at each call. That matches the old `window.skyhawk?.x()` at call time, including outside Electron. - `initBridge` is the old one, with each pull and push handed to its slice, in the same order: - **Pulls:** `getHidAvailability` (hid) → `getNodes` (nodes) → `getConfig` (config) → `getEvents` (events) → `getStatus` - **Subscriptions:** `hid:report` → `device:status` → `relay:state` → `relay:facts` → `cal:workflow` → `aircraft:changed` → `nodes:model` → `nodes:telemetry` → `cockpit:snapshot` → `config:changed` → `serial:traffic` → `telemetry:tick` → `stats:tick` → `events:batch` → `log:batch` **Cross-slice cases.** There are two, and both keep the old timing: 1. **The rehydrate bundle (`getStatus`).** It used to be one update. It is now three, one after another in the same callback: dcs (`relaying`, `relayStream`, `relayFacts`), then gateway (`linkState(st.device)`), then calibration (`st.cal ?? CALIBRATION_IDLE`). Nothing runs between them, so React renders them in one batch. The reload flows below show identical states. 2. **`log:batch` (dcs).** It reads `useEventsStore.getState().logPaused` at the moment the batch lands (dcs → events is an allowed direction). It used to read `get().logPaused`. ## App composition Topic views stay in their topic, and the app hands each one the other topics' values as props. | App component | Composes | |---|---| | `App` | nav `tab`, config `sourceMode`; title bar; tabs | | `titlebar/DevicePopover` | gateway `DevicePopover` + config `autoConnect` | | `titlebar/RelayPill` | dcs `RelayPill` + config `autoConnect`, `host`, `port` | | `overview/Overview` | config, dcs, gateway (`gatewayView`), nodes (`Dot`, snapshot), nav | | `connection/Connection` | gateway `DeviceCard` (+ `autoConnect`), nodes `NodesCard` (+ `autoConnect`), config source-mode and DCS-link cards, dcs `CaptureCard` (+ Drive Gateway value and switch), `RecordCard`, `RelayStart` | | `cockpit/Cockpit` | nodes `CockpitTab` + config `psuView` | | `log/Log` | nav (sub-tab, source), dcs `StreamView` + stream filters, events `EventsView` + `filterEvents` + pause | | `hid/HidTab` | hid `HidPanel` + hid/calibration (state, `attach` effect, `CalibrationDialog`, `open`) | | `settings/*` | config, dcs (Record row), nodes (hidden, reset, registry); `ConfigurationSection` (config) + hid `AXIS_LABELS` | ## Lint, before → after | Check | Before (4d13321) | After | |---|---|---| | `npm run lint` | 0 errors, 29 warnings (all P5) | **0 errors, 0 warnings**; P5 is `error` | | `npm run lint:deps` | 0 errors, 1 warning (T2 `Overview.tsx → assets/logo.png`); 181 modules, 561 deps | **0 errors, 0 warnings**; 213 modules, 667 deps | | `.dependency-cruiser.severity.json` | `kitLeaf`, `topicsRenderer`, `deepRenderer` = warn | **all 13 keys `error`** | The rules were checked to fail. Temporary probe files (deleted again) each produced an error: - gateway → dcs: `topic-renderer-gateway` - hid → hid/calibration: `topic-renderer-hid` - nodes → app: `topic-renderer-nodes` - app → `gateway/store.ts`: `T2-renderer-no-deep-topic-import` - kit → gateway: `L-kit-leaf` - `window.skyhawk` in nodes: P5 ESLint error The CI step "Boundary lint (warn-only …)" is now "Boundary lint", and the depcruise header comment is updated. ## Proofs - **T1–T35 and the renderer unit tests: green, with unchanged assertions.** - The 35 component tests: import paths only. - The view-model tests: import paths only. - The harness: - `store-reset.ts` resets every topic store and imports the adapter, so the intents are bound; - `render.ts` calls the adapter's `initBridge`. - `app/ipc-adapter.test.ts` (was `store.test.ts`): only its `freshStore()` helper changed. It now imports the adapter and the stores, and returns a merged `getState()` view with `toggle` routed (`logPaused` → events `togglePause`). Its cases and assertions are unchanged. - **Render compare, base (4d13321) vs head.** 313 tests and 2050 DOM states (1119 distinct). Every sequence is identical. - Coverage: - all 35 component-test files; - the R3.2a / R3.2b / R3.3 / R3.4a render flows; - the R3.4a async-hop proof, whose per-step log is also byte-identical; - one new R3.4b flow (4 tests, 63 states): Log Stream ↔ Events with rows in both, Pause across views, Clear while paused, Raw, search; cross-tab targets; reloads whose rehydrate spans relay, link and calibration; HID reports streaming under an open calibration dialog. - One normalization: the logo `src` in a test run is the source path, which moved. In the build, the asset is the same file, `logo-Cq53l2FN.png`. - **Main trace.** The R3.4a harness ran 48 scenarios (11,842 lines), byte-identical. `git diff 4d13321 -- src/main src/shared src/preload src/test-fixtures` is empty. - **Build.** `out/main` and `out/preload` are byte-identical to base. The renderer CSS and logo asset are unchanged; only the JS bundle differs. - **Boot probe.** Both runs, dev userData and `SKYHAWK_USER_DATA`, are identical: - 45 intents, in the same order; - the same pushes; - the same files written. - **Checks:** - `npm run typecheck` (3 projects): clean; - lint and lint:deps as above; - `npx vitest run`: 103 files, 1127 tests; - `npm run build`: OK. ## Not as in the plan (§1.4 / §2.2) - `dcs` exports three relay-control components (`CaptureCard`, `RecordCard`, `RelayStart`), not one `RelayControls`. The Connection column puts config cards between them. - `nodes` also exports `Dot`, which the Overview uses. - `hid` has no `bindHid`: it has no intents. - No `nodes-card-view.ts`. The nodes card has no wording left to move (main pushes it). - Three small app wrappers (`app/titlebar/*`, `app/cockpit/Cockpit.tsx`) hand settings to gateway, dcs and nodes views, which may not read config. Component tests import them, so the render lines stay the same. Refs #100
R3.4c of the structure rewrite (#101). Before the rewrite the renderer worked out its decisions synchronously from one push. Main now decides, and real IPC is asynchronous, so the renderer could draw frames that the original app never showed. This PR makes those frames consistent again. Settled states do not change. Two commits: 1. `refactor(dcs)`: the source mode and transport are shown from `relay:facts` (F1, F2). 2. `refactor(app)`: each main turn's pushes go out as one `push:batch` message (F3, F4, F5). ## The audit An async-hop audit replayed 94 user actions. It held every renderer→main invoke for one hop, then delivered main's pushes one IPC message at a time. It compared the DOM after each step with BASE (2598104, before the rewrite) and with HEAD (4d13321). It found two kinds of mixed frame, where part of the screen has updated and a related part has not. - **F1, source mode switch.** The segment, the hint and the title bar's SOURCE come from the form's optimistic `sourceMode` and flip at once. The Capture/Record cards, Start with its waitnote, and the pill's blocked "!" and tooltip come from `relay:facts` and lag one hop. With no device, Monitor→Bridge briefly showed Start enabled, and a click there sent a Start that main refused. - **F2, transport radio while relaying.** The radio moved at once, but the pill tooltip still named the old endpoint. - **F3, link change.** `AppServices`' onStatus fan-out sends `device:status`, `cal:workflow`, `cockpit:snapshot` and `relay:facts` as four messages. Frames between them showed, for example, the link down while the Cockpit still read live. - **F4.** `cal:workflow` arrived, then `hid:report`. One frame in between showed CAL/RAW badges with "0 of 8 slots". - **F5, Restore.** Two `cal:workflow` pushes in one tick. A restored *draft* with Write enabled was visible before "Writing…". ## F1 / F2: shown from the facts main judged `RelayFacts` gains `mode` and `transport`, the settings `relayFacts()` was judged against. These now read them instead of the form's local value: - the Source Mode segment and its hint; - the title bar's `SOURCE <b>`; - the Transport radios; - the Overview's DCS Link name (TCP / Multicast / Unicast). Each of these flips in the same push as the cards, Start and the pill. A click shows its result once main has taken it. A Start can no longer go out for the old mode. `SourceMode` and `DcsTransport` are now declared in the dcs contract, and config re-exports them, so the facts can name them without a contract cycle. `RELAY_FACTS_IDLE` carries the defaults (`monitor`, `tcp-to-host`). Settings › Connection's read-only "Source mode" row still reads the form value. It cannot change while it is on screen. ## F3–F5: one message per main turn - **Flush point.** `batchPushes(send)` (`main/app/push-batch.ts`) is the `Emit` that `window.ts` hands to AppServices. The first push of a turn starts a batch and queues a microtask. Every push made during the rest of that synchronous turn joins the batch. The microtask sends it with `webContents.send('push:batch', batch)`. - The flush adds no timer-visible delay: the batch is out before any timer or I/O callback. - It stays ahead of the reply the turn's handler returns. `ipcMain.handle` replies from a continuation that is queued only after the handler returns. - `setImmediate` was rejected: it would let replies overtake pushes. - **Ordering.** A `PushBatch` is an ordered list of `{ channel, payload }` (`PushItem`). Main's order and payloads are kept as given, and nothing is merged or dropped. A channel pushed twice appears twice, and the later push wins exactly as before. - **Preload.** The preload registers one `ipcRenderer.on('push:batch')` listener. `SkyhawkApi.on(channel, cb)` is unchanged: it is still the per-channel subscription, now served from that one listener. - `deliverPushes` (shared/app) hands each item to its channel's subscribers in order, all within the one IPC event. - A subscriber that throws costs only its own push. The rest of the batch is still delivered, and the error is rethrown at the end. - **Adapter.** `initBridge`'s `api.on(...)` calls stay as the routes to each `<topic>FromMain`. Because a whole batch reaches those routes in one task, React 18 commits the turn once. Only the adapter's comment changed. - **F5.** Batching alone covers it. The two `cal:workflow` pushes still both go out, in one batch, and the second wins in one commit. The audit's in-hop (d) frame is gone, so `workflow.restore()` was left outside the host's `batch()`. - **Contract and guard.** These are new: `PUSH_BATCH`, `PushItem`, `PushBatch` and `deliverPushes`. `ipc.compat.test-d.ts` now pins `'push:batch'` and the batch's shape. `SkyhawkApi`, `PushChannels`, `IPC`, `CTRL` and `RelayStatus` still equal the frozen copy. - **Mock.** `skyhawk-mock.ts` collects pushes the same way and delivers them through `deliverPushes`. - A mock "turn" (`emitScenario`, an intent's answer, `calRead`, a host's timer firing) delivers its batch as it ends, inside its `act`. - Any other push is delivered in a microtask, as main's flush is. - `getStatus`'s pushes join one batch ahead of its answer, as main's `relay:status` handler sends them. - The calibration and cockpit hosts get timers whose callbacks are turns. ## Audit, before → after (94 cases) The audit-hop harness was ported to the R3.4b paths, and its HEAD capture now records one message per `push:batch`. BASE was re-run in both modes. Every recorded frame is byte-identical to the audit's run, so the "already at BASE" classifications still hold. **The frame between the click and the hop (HEAD):** | Class | Audit (4d13321) | This PR | |---|---|---| | (a) before state | 42 (11 new latency) | 53 (22 new latency: the 11 accepted + the 11 F1/F2 cases below) | | (b) after state | 34 | 30 | | (c) mix | 8 (**7 new**) | 1: `pill-callout-connect`, already at BASE | | (d) never at BASE | 10 (0 new) | 10 (0 new; all identical at BASE-with-hop) | The 11 F1/F2 cases that now wait one hop as a benign (a): - the 6 source-mode switches and Transport Loopback while relaying, which were (c); - Bridge→Monitor and Transport TCP / Loopback / Unicast while stopped, which were (b). **Between pushes (in-hop frames):** | | Audit | This PR | |---|---|---| | (c)/(d) frames | 29 | 9 | | new (not at BASE) | **14, in 8 cases** (F3, F4, F5) | **0** | The 9 that remain all appear identically at BASE-with-hop: the busy state of a mapping call (Adopt, Save, Reset to default), the per-board loop of Add all and Un-hide all, Reset apply, Select axis and the refused Start. The click-window MutationObserver (c) frames of the 7 F1/F2 cases are gone. HEAD's before and after states equal BASE's in 92/94 cases, the same two harness artifacts as in the audit. ## Proofs - **Render compare** (`r34b` render scripts: every component test plus the R3.2a–R3.4b flows, fbcd181 vs this head). Of 313 tests, 312 have identical state sequences. - The one that differs is the R3.4a async flow "source mode: the Capture and Record cards are never both shown". It loses 6 intermediate states: exactly the F1 mixes, with the segment moved and the cards not. - Its remaining sequence is the base sequence with those states removed, in order, and its final state is equal. - In total, 2,050 states become 2,044. No settled state changed. - **Main trace** (r34a harness, 48 scenarios, with `push:batch` recorded as sent and expanded into its items). - Expanded batches give 4,702 emits, identical in order and payload to the same head with an unbatched emit. Every non-emit side effect (serial, DCS, timers, files, logs) is identical too. - Against fbcd181, the differences are `relay:facts`' new `mode` / `transport` fields, plus one extra `relay:facts` push. That push is in `commands-stopped-running`: Bridge→Monitor while stopped with the link up. No other fact changed there, so it was not pushed before. The mode now has to reach the segment. - There were 3,435 batches, 350 of them carrying more than one push (the largest carried 59). - With replies modelled as `ipcMain.handle` sends them (one continuation later): no reply is delayed behind a push it used to precede. 25 replies now come before pushes made after their handler returned. Each is a value the harness returns synchronously, followed by a separate scripted action in the same run; under Electron those would be separate messages. - **Boot probe** (built bundle, dev userData and `SKYHAWK_USER_DATA`). The intents are identical (45, same order) and the files are identical. - The wire is the one difference: per-channel sends become `push:batch`. Its items carry the same channels as base: dev `cockpit:snapshot, config:changed, device:status, hid:report, relay:facts, relay:state`; env adds `nodes:model`. - The probe waits one `setImmediate` before reading what was sent, because `will-quit`'s `relay:state` now leaves in the final microtask. - **T1–T35** pass with unchanged assertions. Only the mock changed under `src/renderer/test/`. - **Checks:** - typecheck (3 projects) clean; - lint 0/0; - lint:deps: no violations; - vitest: 104 files, 1,135 tests, which is the baseline 1,127 plus `push-batch.test.ts` (7) and one relay-rules case; - build ok. Known gaps are unchanged: N1–N9, the parked items, and the Relay pill's local pending state with its silent revert. Refs #100
R3.4d, the last PR of the structure rewrite. It deletes dead code, merges two leftover duplicates, applies the plan's RENAME rows, rewrites the pixel-geometry plan-layout tests, fixes stale comments and adds `ARCHITECTURE.md`. Behaviour is unchanged: no UI change and no wording change. The one wire change is that two dead pushes are removed: `cal:data` and `nodes:telemetry`. Refs #100 ## 1. Dead code Each item was checked with grep before it was deleted. | Removed | Evidence | |---|---| | The `cal:data` push: its contract entry (`CalibrationPushChannels`, `CALIBRATION_PUSH.calData`), main's `emit('cal:data')` in `HidCalibrationService.calRead`, the mock's push, and the API guard's entry. | The renderer subscribes only `cal:workflow` (`ipc-adapter.ts`); no file under `src/renderer/src` names `cal:data`, and the preload has no per-channel code. `calRead` still hands the report to the workflow. In the mock, `cal` stays a scenario part with no push (`PUSHES.cal: null`), and `host.reported` runs at the same point. | | The `nodes:telemetry` push: the contract entry (`NodesPushChannels`, `NODES_PUSH.nodesTelemetry`), NodeHub's `emit('nodes:telemetry')`, the mock's push, and the API guard's entry. | After the route above went, no renderer file subscribes it. NodeHub still takes the PDU pages on the same 1 s throttle, hands them to the Cockpit snapshot host (`telemetryPushed`, unchanged) and pushes the model after them; `nodes:get` still answers them (`NodeTelemSnapshot` stays: `NodeSnapshot.telemetry` uses it). In the mock, `telemetry` stays a scenario part with no push (`PUSHES.telemetry: null`) and still feeds the real cockpit host at the same point. | | `gatewayView().attention` | No reader: `Overview.tsx` reads `.alert` and `DevicePopover.tsx` reads pill/title/meta/health. The Cockpit's item comes from `cockpit:snapshot`. | | Store fields `nodeTelemetry`, `relayStream`, `hidAgeMs`, with their writers | A per-field grep of every `*State` interface: these three are the only fields that nothing in `src/renderer/src` reads outside the store (`private_*` seq counters excluded). Their only readers were `ipc-adapter.test.ts` cases. `nodeModel` is kept: ResetDialog and `useNodesPulled` use it. With `nodeTelemetry` gone, the adapter's `nodes:telemetry` route did nothing, so it was removed; the push itself goes in the next row. | | `formatCommand`, `cameOnline`, `decodeAxis`, `Recorder.reset`, `isDebugEnabled` (plan §3.5) | No production caller; each name appears only at its definition and in its own test. `Recorder.reset` and `isDebugEnabled` had no tests. The trace harness mocks `isDebugEnabled` in a `vi.mock` factory, which doesn't need the export. | | Other unused exports | Found with **knip 6.41.0** via `npx` (no dependency added; knip 5 can't load TypeScript 6). Run with all source files as the project and tests as entries. **Deleted** (no reference anywhere, tests included): the codec's `encodeGetCal`, `encodeSessionClose`, `encodeKeepalive`, `encodeStreamSelect` (`CalLink` builds frames with `request(CAL_TYPE.X, …)`) and `CAL_MAX_PAYLOAD`; the `ImportDialog` and `AdoptDialog` re-exports from their topic doors. **Un-exported** (used only in their own file): 32 symbols, plus one test re-export (`scenarios.ts` `nodeModel`), across `config-file`, `settings-service`, `relay-rules`, `stream-log`, `cache-rules`, `calibration-view-model`, `calibration-workflow`, `codec`, `cockpit-snapshot/*`, `node-status-wire`, `node-telem`, `power-zone`, `registry`, `dcs-relay`, `serial-bridge`, `plan-layout` and the test harnesses. | Left on purpose, and listed by knip after this PR: - shared contract exports (`PSU_LIMIT_MAX`, the calibration and telemetry sub-types); - generated `CalTypeName`; - the topic doors' type re-exports (`<Topic>State`, `JsonFile`/`JsonPolicy`/`JsonRead`, `MappingFileIO`, `CacheFileIO`, `GatewayConnectionDeps`, `SerialRow`, `StreamLine`). They are types only, and every door follows the same pattern. - `installSkyhawkMock` is a false positive: the vitest `setupFiles` entry uses it. Production-dead but used by tests, so **not deleted** (outside the plan's five): - `ACFT_NAME` and `SUPPORTED_NODE_PROTO` / `SUPPORTED_TELEM_PROTO`: pins in `reference.test.ts`; - `idleTelemetry`: pinned equal to the contract's `TELEMETRY_IDLE`; - `encodeHello` / `encodeSessionOpen`: codec wire vectors; - `relayWaiting`: `link-view.test.ts`. ## 2. Duplicates - **`gatewayAttention` uses `shownHealth`.** `shownHealth(g, up)` is `up ? g : undefined`, so `!shownHealth(g, up)` equals `!up || !g` for every input. A scratch test, not committed, ran the old gate (verbatim) against the new one through `cockpitAttention`. It covered 13 statuses (undefined, plus ok, warn and err from `gatewayStatus` over healthy, bus-off, overheat, error-passive, fault and kind-0 rows, each with and without not-responding) × link up/down, so 26 cases, all equal. `gateway/index.ts` now exports `shownHealth`; nodes → gateway is an allowed direction. - **`isObject` → `isPlainObject`.** The bodies are character-identical (`typeof v === 'object' && v !== null && !Array.isArray(v)`). `config-file.ts` imports it from `nodes` (the plan's MERGE row into `mapping-rules.ts`); config → nodes is allowed. ## 3. Renames (types only, no channel string changed) From the plan's RENAME rows: | Old | New | Where | |---|---|---| | `LogRow` | `StreamRow` | contract `shared/dcs/ipc.ts` (`log:batch`) | | `CaptureState` (DCS) | `RecordState` | contract `shared/dcs/ipc.ts` (`capture:toggle`); calibration's own `CaptureState` is untouched | | `Transport` | `TransportId` | `renderer/src/config/store.ts` | | `LogRow` | `StreamLine` | `renderer/src/dcs/stream-view.ts` | | `SectionId` | `SettingsSectionId` | `renderer/src/app/settings/Settings.tsx`; config's contract `SectionId` is untouched | **API guard, removed channels.** The diff, on purpose: - `OldPushChannels`: `'cal:data': CalSnapshot` and `'nodes:telemetry': NodeTelemSnapshot[]` removed; - `OldIPC`: `readonly calData: 'cal:data'` and `readonly nodesTelemetry: 'nodes:telemetry'` removed; - the now-unused `CalSnapshot` and `NodeTelemSnapshot` imports removed; - an R3.4d paragraph in the header says why. `OldSkyhawkApi.on` and `OldPushBatch` follow `OldPushChannels`, so neither channel can be subscribed or batched. `OldCTRL` and the intents are unchanged. **API guard, renames.** The frozen copy now pins the two contract shapes under their old names: `OldLogRow` and `OldCaptureState`, used in `log:batch` and `toggleCapture`. A new `RenamedShapesUnchanged` asserts `Equal<OldLogRow, StreamRow>` and `Equal<OldCaptureState, RecordState>`. To check the guard can fail, a field was added to `RecordState` as a scratch mutation: typecheck failed on both assertions. ## 4. Brittle tests **`plan-layout.test.ts`.** The cases that asserted pixel constants now assert the same facts without them: - **Tile width.** Was 99 / 172 / 206. Now: a short name gets the column width; a longer one is wider than a column and narrower than the cap, and grows with its name; a very long one is capped. - **Flow and wrap.** Was x/y/w tuples. Now: rows by membership and order (`Engine` | `ARC51_UHF_Radio` | `Fuel, Trim` | `Gear`); the flow order is the members' order; each row starts at the group's left edge; tiles run left to right, a gap apart; column tiles share one width and the long name is wider; every tile stays inside the group. - **Stage growth.** Was `> 600` and `psu.y + 52`. Now: the stage is taller than a one-board stage, the PSU sits under the side box, and the stage ends the same distance under the PSU. Scratch mutations of `plan-layout.ts` showed the new assertions still catch: - no wrap; - a fixed width; - overlapping tiles; - no gap. They no longer catch a change to the PSU box's 52 px. **Kept with its geometry:** "a board changing state never moves a tile". There the geometry is the behaviour, and it compares two layouts with each other, not with constants. **Whole-sentence banner tests:** left as they are. **Duplicate matrices: none deleted.** The rule is that a duplicate goes only when an identical case stays at the owner. None qualified: - **Identify matrix.** `node-kind.test` is the PROTO owner and `node-hub.test` the HUB owner. The other copies each pin a fact the owners don't: - `node-model.test` "identifies by …": the model wiring, including the telemetry-only PDU; - `overview-summary.test` `reportedKind`: it reads `identified`, not the display kind; - `cockpit.test` `newCount`: a non-new board isn't counted; - `adopt.test` `adoptability`: badges and notes. - **Input validation ×2.** `mapping-rules.test` refuses `[]` and `[{…}]` (the `Array.isArray` branch), five on/off values and four reset shapes. `node-hub.test` checks one value each and the `{ok:false}` conversion. - **Import cluster ×3.** `node-hub.test` ("keeps … added and last seen"), `mapping-rules.test` `importMapping` and `config-file.test` `diffImport` are the PART 1D "pin wrong or soon-to-change" rows (F19), so they are untouched. The "never hides a board on the bus" copies test different functions: the hub's disk result, `importMapping`'s output and the preview rows. ## 5. Comments and docs - Comments that described current code through `Session`, `DeviceLink`, `useNodeModel`, old renderer files, or old `shared/*` model paths now name the current owner, or just the phase. That covers `cal-link`, `node-hub`, `app-services`, `stream-log`, the harness, `relay-rules`, `summaries`, the `cockpit-snapshot` headers, `link-state`, `hid-facts`, `shared/gateway/ipc.ts` and others. - Comment-only test edits: - T35's header (it now names `useNodesPulled` and the nodes store); - port-provenance headers; - `relay-rules.test` (it pointed to `device-view.test.ts`, which is gone). - `scripts/sync-a4ec.ts` doc strings now point to `main/nodes/node-status-wire.ts`, `main/nodes/node-telem.ts` and `main/hid/calibration/codec.ts`. `npm run sync` (pinned a78dd90276) rewrites `node-status.generated.ts` and `axis-cal.generated.ts`, and only their comments change. With comments stripped, all eight generated files are identical to before, and a second sync is a no-op. - `PRD.md` §9's repo layout now shows the topic folders, the boundary-lint files and the real workflows. ## 6. `ARCHITECTURE.md` A new contributor guide at the repo root, linked from `README.md`. It covers: - the process wall and the IPC contract, with the API guard; - the topics, with `index.ts` as the only door, and the allowed-directions table; - the roles (model, service, view-model, view), with one example each; - the owner per topic; - how a screen gets its data; - `push:batch`; - firmware values from `npm run sync`; - the settings store and the injected ports; - where each piece of a new feature goes; - the boundary lint and its severity file; - the test layers. ## Proofs - **Tests.** T1–T35 pass with their assertions unchanged; the only diff under `src/renderer/test/t*` is T35's header comment. vitest goes from 104 files and 1,135 tests to 104 files and 1,131 tests. Deleted cases: 1. `bios-protocol.test` "formatCommand › builds an identifier + arg line": dead code. 2. `node-model.test` "cameOnline › lists only nodes that were not present…": dead code. 3. `hid-report.test` "decodeAxis sign-extends a 16-bit field": dead code. 4. `ipc-adapter.test` "carries the report age, not just the rate": dead field `hidAgeMs`. The rate and the age's band are pinned by T25. Assertion lines removed from cases that stay: - `gateway-view.test`: three `v.attention` lines; - `ipc-adapter.test`: three `relayStream` lines and one `nodeTelemetry` line. Four titles were reworded to drop the removed fields. Re-expressed, not deleted (titles unchanged): the three `app-services.test` cases that counted `nodes:telemetry` pushes. - "feeds _NODE_TELEM to the node layer…": the pages are what `nodes:get` answers (node 3, PDU, live). The throttle is the number of `cockpit:snapshot` pushes per take of the pages: none for a second frame inside the second, then exactly one more take after it. - "keeps node pushes flowing after the relay stops": the pages are taken and a Cockpit snapshot goes out. - "stops node pushes while unplugged": no Cockpit snapshot after the close for a late chunk. All three also pass unchanged on base 8462a6c. A scratch mutation (throttle 1000 → 0 ms) fails the first one. - **Render compare** (the r34c scripts, 8462a6c vs head: every component test plus the R3.2a–R3.4b flows): **313 tests, all 313 sequences identical**, 2,044 states on both sides. The async log is identical. - **Main trace** (the r34a harness, 48 scenarios, batches expanded). Compared with base: - **Pushes:** 51 `cal:data` and 43 `nodes:telemetry` emits fewer. The other 4,608 emits are identical in order and payload. - **Other side effects:** all 7,142 lines (serial, DCS, timers, files, logs, replies) are identical. - **Batches:** 3,435 on both sides, with the same pushes in each. The 51 batches that carried `cal:data` and the 43 that carried `nodes:telemetry` are one item shorter. No batch became empty: each `nodes:telemetry` shared its turn with the `cockpit:snapshot` before it and the `nodes:model` after it. - **Batch timing:** `nodes:telemetry` moved no batch. Two batches (dialog-close-mid-write and dialog-link-drop-mid-capture, one each) now flush after the fake device's SESSION_OPEN ack instead of before it. `cal:data` was the first push of that turn, so it queued the flush microtask before the session-open write. The first push is now `cal:workflow`, which comes after the write. The harness's fake device answers in a microtask; a real port answers on an I/O event, so the order can't change in the app. - **Boot probe** (built bundles, dev userData and `SKYHAWK_USER_DATA`): identical. The boot sequence has no device, so it never pushed `cal:data` or `nodes:telemetry`. - **Checks:** - typecheck (3 projects) clean; - lint 0/0; - lint:deps: no violations (214 modules); - vitest: 1,131 passed; - `npm run build` ok; - `npm run sync` gives the same generated values. - **Other push channels with no renderer listener:** none. All 14 remaining channels in the contracts' `*PushChannels` are subscribed in `ipc-adapter.ts`, and every store field they set has a production reader.
mottihoresh
marked this pull request as ready for review
October 17, 2026 11:39
…check on it (#130) Addresses the four code-quality "missing await" notes on #129 (`workflow-host.ts` selectAxis / save / restore / deleteAxis). They were false positives: `this.view?.workflow.x() ?? Promise.resolve()` applies `??` to the optional-chain result (`undefined` when no view), not to the promise. This branches on the view instead, so the scan reads it correctly. The returned promise is the same object as before when a view exists, and a resolved one when it doesn't, so there's no behaviour or timing change. Checks: typecheck (3 projects), lint, and 1131/1131 tests pass locally. Refs #100
This was referenced Oct 17, 2026
mottihoresh
added a commit
that referenced
this pull request
Oct 27, 2026
Bring #169's main side in line with ARCHITECTURE.md (the #129 rewrite): - One owner per rule: the link-health model (reset reason, first fault, self-recovery) moves to main/gateway/gateway-diag.ts. hid/calibration keeps only the wire (decodeDiag, GET_DIAG) and hands each read to GatewayConnection, as NodeHub feeds the Gateway's health. - Main pushes finished state: DeviceStatus gains linkHealth, carried on device:status (and so in the reload bundle) while the link is up, dropped when it goes. - Intents act, pushes answer: device:diag (which returned the record) becomes device:diag-refresh (refreshGatewayDiag → void), registered in gateway/ipc.ts with the reader injected by app-services. - A real seam test through makeServices() replaces the service-level one. Refs #169
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The structure rewrite (#101), integrated on
refactor/structurethrough 27 squash-merged PRs (#102–#128). Structure only. No new functionality and no intended behaviour change. Every behaviour fix waits on the fix track in #101 and lands onmainafter this merge.Merge with a merge commit (not squash), so each step stays on
mainand can be bisected.What changed
src/maindecides,src/rendereronly presents,src/sharedholds the per-topic IPC contracts, the generated firmware reference and the formatting helpers, andsrc/preloadholds the bridge. Lint enforces the wall.app,gateway,dcs,nodes,hid,hid/calibration,events,config. Each has anindex.tsas its only door, and the allowed import directions are enforced.GatewayConnection,DeviceLineTap,SerialTrafficMonitorNodeHub(roster poll + Gateway health),MappingStoreHidService,HidCalibrationService(it hosts the calibration workflow)EventJournalDcsConnection,DcsRelay,StreamLog,SessionRecorderSettingsService(one zustand store, handed to services)AppServicesdoes wiring only. DeviceLink and Session are gone.app/ipc-adapter.tsis the onlywindow.skyhawkreader.npm run sync. That includes the 9 values refactor(firmware): name the comment-only protocol values so the client sync can read them (#346) OpenSkyhawk#347 named.push:batch: each main turn reaches the renderer as one message and one commit.ARCHITECTURE.mdis the contributor guide.How "no behaviour change" was checked, PR by PR
Lint, boundary lint, typecheck (3 projects) and build are clean, with every rule at error and 0 warnings.
Before merging
After merging