From 24c4055be8c2095cb3ab2e598aa67e51b2841702 Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 21 Aug 2026 07:37:36 +0200 Subject: [PATCH] fix(sim): a raw-byte UART seam, because feedUart is UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESP32 and STM32 shims only offered `feedUart(uart, data: string)` and `serialWrite(text)`, both of which run the string through TextEncoder. That is UTF-8, so every byte >= 0x80 leaves as TWO bytes. Fine for the text protocols the seam was written for (NMEA out of the GPS module, AT chatter); silently fatal for anything framed. A part answering a binary protocol has no way to spot it. A ten-byte reply of AA 55 04 00 FF FD 01 FD 55 AA reaches the guest as fifteen bytes, C2 AA 55 04 00 C3 BF C3 BD 01 C3 BD 55 C2 AA and no parser recovers from that: the sync word is gone, the length field disagrees with the frame, the checksum covers bytes that were never sent. The symptom is a device that answers and a sketch that times out anyway, with nothing in the console — which is what makes it expensive to find. Protocols whose replies happen to be ASCII pass straight through, so the seam looks fine right up until a sync word is 0xAA. Both shims now also expose `sendSerialBytes(bytes: number[], uart = 0)`, forwarding to the bridge method of the same name without encoding. feedUart keeps its text contract and its callers; its doc comment now says so. The test pins the difference rather than the implementation: same frame down both paths, asserting the text one mangles it and the raw one does not, plus a round trip of all 256 byte values. --- .../__tests__/shim-raw-serial-bytes.test.ts | 103 ++++++++++++++++++ frontend/src/store/useSimulatorStore.ts | 28 ++++- 2 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/shim-raw-serial-bytes.test.ts diff --git a/frontend/src/__tests__/shim-raw-serial-bytes.test.ts b/frontend/src/__tests__/shim-raw-serial-bytes.test.ts new file mode 100644 index 00000000..e4ea7dd3 --- /dev/null +++ b/frontend/src/__tests__/shim-raw-serial-bytes.test.ts @@ -0,0 +1,103 @@ +/** + * The raw-byte UART seam on the board shims. + * + * `feedUart` and `serialWrite` take a string and encode it as UTF-8. That is + * correct for the text protocols they were written for (NMEA, AT commands), + * and silently destroys anything binary: every byte >= 0x80 becomes two. + * + * A part answering a framed protocol — sync words like 0xAA/0x55, a checksum, + * a length field — has to reach the guest byte for byte, so the shims expose + * `sendSerialBytes(bytes, uart)` alongside. This test pins that difference so + * the two paths do not quietly converge again. + */ + +import { describe, expect, it, vi } from 'vitest'; + +/** Minimal stand-in for the bridge: records what actually reaches the guest. */ +class FakeBridge { + sent: Array<{ bytes: number[]; uart: number | undefined }> = []; + connected = true; + sendSerialBytes(bytes: number[], uart?: number) { + this.sent.push({ bytes, uart }); + } + sendPinEvent() {} +} + +/** + * The shims are module-private, so exercise the two code paths directly + * against the same fake bridge. This mirrors `Esp32BridgeShim.serialWrite` / + * `.feedUart` (UTF-8) versus `.sendSerialBytes` (raw) exactly. + */ +const textPath = (bridge: FakeBridge, data: string, uart = 0) => + bridge.sendSerialBytes(Array.from(new TextEncoder().encode(data)), uart); +const rawPath = (bridge: FakeBridge, bytes: number[], uart = 0) => bridge.sendSerialBytes(bytes, uart); + +/** A Chain-protocol heartbeat reply: two sync words and a checksum. */ +const BINARY_FRAME = [0xaa, 0x55, 0x04, 0x00, 0xff, 0xfd, 0x01, 0xfd, 0x55, 0xaa]; + +describe('raw-byte UART seam', () => { + it('the text path mangles every byte >= 0x80', () => { + const b = new FakeBridge(); + textPath(b, String.fromCharCode(...BINARY_FRAME)); + const got = b.sent[0].bytes; + expect(got).not.toEqual(BINARY_FRAME); + expect(got.length).toBeGreaterThan(BINARY_FRAME.length); + // 0xAA arrives as the two-byte UTF-8 sequence C2 AA. + expect(got.slice(0, 2)).toEqual([0xc2, 0xaa]); + }); + + it('the raw path delivers the frame byte for byte', () => { + const b = new FakeBridge(); + rawPath(b, BINARY_FRAME); + expect(b.sent[0].bytes).toEqual(BINARY_FRAME); + expect(b.sent[0].uart).toBe(0); + }); + + it('the two paths agree on pure ASCII, which is why this went unnoticed', () => { + const ascii = 'HELLO'; + const a = new FakeBridge(); + const c = new FakeBridge(); + textPath(a, ascii); + rawPath(c, Array.from(ascii, (ch) => ch.charCodeAt(0))); + expect(a.sent[0].bytes).toEqual(c.sent[0].bytes); + }); + + it('routes to the requested UART', () => { + const b = new FakeBridge(); + rawPath(b, [0x01], 2); + expect(b.sent[0].uart).toBe(2); + }); + + it('every byte value survives a round trip through the raw path', () => { + const b = new FakeBridge(); + const all = Array.from({ length: 256 }, (_, i) => i); + rawPath(b, all); + expect(b.sent[0].bytes).toEqual(all); + // The text path would balloon past 256 for the same input. + const t = new FakeBridge(); + textPath(t, String.fromCharCode(...all)); + expect(t.sent[0].bytes.length).toBeGreaterThan(256); + }); +}); + +describe('the shims expose the raw seam', () => { + it('Esp32BridgeShim and the STM32 shim both declare sendSerialBytes', async () => { + // Source-level assertion: the shims are not exported, and importing the + // store pulls in the whole simulation tree. Reading the file keeps this + // test honest about WHAT it checks — that both seams exist and forward + // without encoding. + const fs = await import('node:fs'); + const path = await import('node:path'); + const url = await import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const src = fs.readFileSync(path.join(here, '../store/useSimulatorStore.ts'), 'utf8'); + const raw = src.match(/sendSerialBytes\(bytes: number\[\], uart = 0\): void \{\s*this\.bridge\.sendSerialBytes\(bytes, uart\);/g); + expect(raw, 'both shims must forward raw bytes unencoded').toHaveLength(2); + }); +}); + +// Guard against a stray global TextEncoder stub in the suite masking the above. +it('TextEncoder really is UTF-8 here', () => { + expect(vi.isMockFunction(TextEncoder)).toBe(false); + expect(Array.from(new TextEncoder().encode('ª'))).toEqual([0xc2, 0xaa]); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 3f50f582..05839ceb 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -217,11 +217,26 @@ class Esp32BridgeShim { this.bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text))); } /** - * Feed bytes into a hardware UART's RX from an external part (GPS module, + * Feed RAW BYTES into a hardware UART's RX. Use this, not `feedUart`, for + * anything that is not text. + * + * `feedUart`/`serialWrite` take a string and run it through TextEncoder, + * which is UTF-8: every byte >= 0x80 comes out as TWO bytes. That is + * invisible for NMEA or AT chatter and silently fatal for a binary frame — + * a reply of `AA 55 04 00 FF FD 01 FD 55 AA` reaches the guest as + * `C2 AA 55 04 00 C3 BF C3 BD 01 C3 BD 55 C2 AA` and no parser recovers. + * Byte-oriented device models (framed UART protocols with checksums and + * 0xAA/0x55 sync words) need the bytes to arrive as written. + */ + sendSerialBytes(bytes: number[], uart = 0): void { + this.bridge.sendSerialBytes(bytes, uart); + } + /** + * Feed TEXT into a hardware UART's RX from an external part (GPS module, * a wired peer board via Interconnect, …). Uniform seam across simulators * (`sim.feedUart(uart, data)`). The backend QEMU worker routes the bytes * into the requested UART (0 = Serial / GPIO3, 2 = Serial2 / GPIO16 on - * the classic ESP32 pinout). + * the classic ESP32 pinout). Text only — see `sendSerialBytes` above. */ feedUart(uart: number, data: string): boolean { this.bridge.sendSerialBytes(Array.from(new TextEncoder().encode(data)), uart); @@ -800,10 +815,17 @@ class Stm32BridgeShim { this.bridge.sendPinEvent(pin, state); } + /** Raw-byte counterpart of `feedUart` — see the note on the ESP32 shim: + * feedUart is UTF-8 and mangles any byte >= 0x80, so binary protocols + * must use this. */ + sendSerialBytes(bytes: number[], uart = 0): void { + this.bridge.sendSerialBytes(bytes, uart); + } /** - * Feed bytes into a hardware USART's RX from an external part (GPS module, + * Feed TEXT into a hardware USART's RX from an external part (GPS module, * a wired peer board via Interconnect, …). Uniform seam across simulators * (`sim.feedUart(uart, data)`). uart 0 = USART1 (PA10 RX), 1 = USART2 (PA3). + * Text only — see `sendSerialBytes` above. */ feedUart(uart: number, data: string): boolean { this.bridge.sendSerialBytes(Array.from(new TextEncoder().encode(data)), uart);