diff --git a/frontend/src/__tests__/avr-uart-tx-waveform.test.ts b/frontend/src/__tests__/avr-uart-tx-waveform.test.ts new file mode 100644 index 00000000..1b678407 --- /dev/null +++ b/frontend/src/__tests__/avr-uart-tx-waveform.test.ts @@ -0,0 +1,148 @@ +/** + * AVR UART TX pin waveform synthesis + * + * avr8js's USART peripheral only intercepts the transmitted byte at the + * UDR0 data register — it never toggles PD1 (Uno/Nano) / PE1 (Mega). The + * oscilloscope and any other GPIO consumer therefore see a flat line on + * the TX pin during Serial.print, which doesn't match real hardware. + * + * AVRSimulator.emitUartTxFrame() is the shim that closes that gap: when + * onByteTransmit fires it derives the 10-bit UART frame from the byte and + * the current USART config, then emits each bit transition through + * onPinChangeWithTime so the scope sees the same waveform a real ATmega328P + * would put on PD1. + * + * These tests assert that: + * - The TX pin is seeded HIGH (idle) when TXEN flips on. + * - Each byte produces a properly-timed start/data(LSB-first)/stop sequence + * on pin 1 at the configured baud rate. + * - Bytes that need no internal transitions (e.g. 0xFF) still emit the + * start-bit drop and the stop-bit rise. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AVRSimulator } from '../simulation/AVRSimulator'; +import { PinManager } from '../simulation/PinManager'; + +// ATmega328P USART0 register addresses +const UCSRA = 0xc0; +const UCSRB = 0xc1; +const UCSRC = 0xc2; +const UBRRL = 0xc4; +const UBRRH = 0xc5; + +const UCSRB_RXEN = 0x10; +const UCSRB_TXEN = 0x08; +const UCSRC_UCSZ1 = 0x04; +const UCSRC_UCSZ0 = 0x02; + +const EMPTY_HEX = ':00000001FF\n'; + +type PinEvent = { pin: number; state: boolean; timeMs: number }; + +function configureUsartFor115200(sim: AVRSimulator): void { + const cpu = (sim as unknown as { cpu: { data: Uint8Array } }).cpu; + cpu.data[UBRRH] = 0; + cpu.data[UBRRL] = 8; // 16M / (16*9) = 111111 baud (Arduino's actual 115200 setting) + cpu.data[UCSRC] = UCSRC_UCSZ1 | UCSRC_UCSZ0; // 8 data bits, no parity, 1 stop bit + cpu.data[UCSRA] = 0; // U2X=0 → multiplier 16 + // Trigger the configuration-change hook by simulating a UCSRB write + cpu.data[UCSRB] = UCSRB_RXEN | UCSRB_TXEN; + // avr8js's writeHook for UCSRB updates internal state; the cleanest way to + // trigger it without running the firmware is to call onConfigurationChange + // directly (it's the callback we registered, so it's safe to invoke). + sim.usart!.onConfigurationChange?.(); +} + +beforeEach(() => { + let counter = 0; + let depth = 0; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + if (depth === 0) { + depth++; + cb(0); + depth--; + } + return ++counter; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('AVR USART → TX pin waveform synthesis', () => { + let pm: PinManager; + let sim: AVRSimulator; + let events: PinEvent[]; + + beforeEach(() => { + pm = new PinManager(); + sim = new AVRSimulator(pm); + events = []; + sim.onPinChangeWithTime = (pin, state, timeMs) => { + events.push({ pin, state, timeMs }); + }; + sim.loadHex(EMPTY_HEX); + }); + afterEach(() => sim.stop()); + + it('seeds the TX pin HIGH (idle) when TXEN flips 0 → 1', () => { + configureUsartFor115200(sim); + + // The first thing the scope should see on PD1 is an idle-HIGH sample. + const txEvents = events.filter((e) => e.pin === 1); + expect(txEvents.length).toBeGreaterThanOrEqual(1); + expect(txEvents[0].state).toBe(true); + }); + + it('emits a complete 10-bit UART frame for a byte with internal transitions', () => { + configureUsartFor115200(sim); + events = []; // discard the idle-seed event so we only inspect the frame + + // 'a' = 0x61 = 0b01100001 → LSB-first bits: 1, 0, 0, 0, 0, 1, 1, 0 + // start bit0 bit1 bit2 bit3 bit4 bit5 bit6 bit7 stop + // LOW HIGH LOW LOW LOW LOW HIGH HIGH LOW HIGH + // Transitions vs. prev (starting from idle HIGH): + // t0 LOW (start), t1 HIGH (b0), t2 LOW (b1), t6 HIGH (b5), + // t8 LOW (b7), t9 HIGH (stop) + sim.usart!.onByteTransmit!(0x61); + + const txEvents = events.filter((e) => e.pin === 1); + const states = txEvents.map((e) => e.state); + expect(states).toEqual([false, true, false, true, false, true]); + }); + + it('handles 0xFF (all ones) — only start bit drop, then stop-bit rise', () => { + configureUsartFor115200(sim); + events = []; + + // 0xFF: start LOW, then 8x HIGH (no internal transitions), then stop HIGH. + // Only 1 LOW (start) and 1 HIGH (first data bit, which is also the rest). + sim.usart!.onByteTransmit!(0xff); + + const txEvents = events.filter((e) => e.pin === 1); + expect(txEvents.map((e) => e.state)).toEqual([false, true]); + }); + + it('does not emit anything when TXEN is disabled', () => { + // Don't configure UCSRB — TXEN remains 0. + events = []; + sim.usart!.onByteTransmit!(0x61); + + const txEvents = events.filter((e) => e.pin === 1); + expect(txEvents).toHaveLength(0); + }); + + it('uses the configured baud rate for bit timing (1 bit ≈ 1/baud seconds)', () => { + configureUsartFor115200(sim); + events = []; + + // 0x00 produces transitions at: t0 LOW (start) and t9 HIGH (stop only). + sim.usart!.onByteTransmit!(0x00); + + const txEvents = events.filter((e) => e.pin === 1); + expect(txEvents).toHaveLength(2); + const dtMs = txEvents[1].timeMs - txEvents[0].timeMs; + // 9 bit periods between start LOW and stop HIGH at 16M/(16*9) = 111111 baud: + // bitMs = 1000 / 111111 ≈ 0.009 ms, 9 * 0.009 ≈ 0.081 ms + expect(dtMs).toBeCloseTo((9 * 1000) / 111111, 3); + }); +}); diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 6d3d9098..02f4de23 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -305,6 +305,13 @@ export class AVRSimulator { private lastPortCValue = 0; private lastPortDValue = 0; private lastOcrValues: number[] = []; + /** + * Last known TXEN bit value, used to detect 0→1 transitions and seed the + * TX pin baseline at idle HIGH the moment the firmware enables the USART. + * Without this seed the oscilloscope shows a floating/LOW baseline until + * the first byte transmits, which doesn't match real hardware. + */ + private lastTxEnable = false; constructor(pinManager: PinManager, boardVariant: 'uno' | 'mega' | 'tiny85' = 'uno') { this.pinManager = pinManager; @@ -423,9 +430,14 @@ export class AVRSimulator { this.usart = new AVRUSART(this.cpu, activeUsart0Config, 16000000); this.usart.onByteTransmit = (value: number) => { if (this.onSerialData) this.onSerialData(String.fromCharCode(value)); + // Synthesize the UART frame on PD1 so the oscilloscope sees a real + // waveform during Serial.print. See emitUartTxFrame() for details. + this.emitUartTxFrame(value); }; this.usart.onConfigurationChange = () => { if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate); + // Seed idle HIGH on the TX pin the first time TXEN flips on. + this.handleUartConfigChange(); }; this.twi = new AVRTWI(this.cpu, activeTwiConfig, 16000000); @@ -509,6 +521,88 @@ export class AVRSimulator { this.scheduledPinChanges.splice(i, 0, { cycle: atCycle, pin, state }); } + /** + * Synthesize a real bit-level UART frame on the TX pin so an oscilloscope + * sees a waveform during Serial.print, matching real ATmega328P / ATmega2560 + * behavior. avr8js's USART only intercepts the byte at the UDR0 register + * level — it never toggles PD1 (Uno/Nano) / PE1 (Mega), so without this + * shim the TX pin is flat in the scope while real hardware would show the + * UART frame at the configured baud rate. + * + * Frame layout (8N1, the Arduino default): + * [start LOW] [data LSB ... data MSB] [parity?] [stop1] [stop2?] + * + * We honour avr8js's USART configuration getters (bitsPerChar, parityEnabled, + * parityOdd, stopBits, baudRate) so unusual configurations stay accurate. + * + * Each transition is emitted via onPinChangeWithTime so the oscilloscope + * stamps it with simulator time (cpu.cycles / 16_000 ms), giving bit-level + * timing that holds at any sweep speed. + */ + private emitUartTxFrame(byte: number): void { + const usart = this.usart; + if (!usart || !this.cpu || !this.onPinChangeWithTime) return; + if (!usart.txEnable) return; + + const baud = usart.baudRate; + if (!baud || baud <= 0) return; + + // ATmega328P (Uno/Nano) UART0: TX = PD1 → Arduino pin 1 + // ATmega2560 (Mega) UART0: TX = PE1 → Arduino pin 1 (Mega TX0) + // ATtiny85 has no hardware USART so this method is never called. + const txPin = 1; + + const freqHz = 16_000_000; + const cyclesPerBit = freqHz / baud; + const startCycle = this.cpu.cycles; + + // Build the frame bit-by-bit. UART idles HIGH; start = LOW; data LSB first; + // optional parity; stop bit(s) HIGH. Idle->start gives the first transition. + const dataBits = usart.bitsPerChar; // typically 8 + const bits: boolean[] = [false]; // start bit + let onesCount = 0; + for (let i = 0; i < dataBits; i++) { + const b = (byte >> i) & 1; + bits.push(b !== 0); + onesCount += b; + } + if (usart.parityEnabled) { + // Even parity = bit that makes total ones even; odd = total ones odd. + const parity = usart.parityOdd ? (onesCount % 2 === 0) : (onesCount % 2 !== 0); + bits.push(parity); + } + for (let i = 0; i < usart.stopBits; i++) bits.push(true); + + // Emit only the bits that change state to keep buffer churn minimal. + // The "previous" state at startCycle is idle HIGH. + let prevState = true; + for (let i = 0; i < bits.length; i++) { + if (bits[i] !== prevState) { + const timeMs = (startCycle + i * cyclesPerBit) / 16_000; + this.onPinChangeWithTime(txPin, bits[i], timeMs); + prevState = bits[i]; + } + } + // After the stop bit(s) the line is already HIGH (idle) so no trailing + // transition is needed — the next byte will start from HIGH automatically. + } + + /** + * Seed the TX pin at idle HIGH when the firmware sets TXEN for the first + * time (typically inside Serial.begin). Without this seed the scope's + * "initial state before the first byte" defaults to LOW, hiding the start + * bit transition of the very first byte sent. + */ + private handleUartConfigChange(): void { + if (!this.usart || !this.cpu) return; + const tx = this.usart.txEnable; + if (tx && !this.lastTxEnable && this.onPinChangeWithTime) { + const timeMs = this.cpu.cycles / 16_000; + this.onPinChangeWithTime(1, true, timeMs); + } + this.lastTxEnable = tx; + } + /** Flush all scheduled pin changes whose target cycle has been reached. */ private flushScheduledPinChanges(): void { if (this.scheduledPinChanges.length === 0 || !this.cpu) return; @@ -749,9 +843,11 @@ export class AVRSimulator { this.usart = new AVRUSART(this.cpu, usart0Config, 16000000); this.usart.onByteTransmit = (value: number) => { if (this.onSerialData) this.onSerialData(String.fromCharCode(value)); + this.emitUartTxFrame(value); }; this.usart.onConfigurationChange = () => { if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate); + this.handleUartConfigChange(); }; this.twi = new AVRTWI(this.cpu, twiConfig, 16000000);