From ad0656b1cce256d1deb4eca2ff590210db2d7570 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:09:24 +0000 Subject: [PATCH 2/3] feat: add built-in oscilloscope / logic analyzer panel - Add useOscilloscopeStore with ring-buffer sample storage and channel management - Add onPinChangeWithTime callback to AVRSimulator (fires on every bit transition with cycle-derived timestamp) - Add onPinChangeWithTime callback to RP2040Simulator (fires on GPIO state change) - Wire oscilloscope callbacks in useSimulatorStore (initSimulator + setBoardType) - Create Oscilloscope React component with canvas-based waveform rendering - Add oscilloscope panel to EditorPage (resizable bottom panel, same as SerialMonitor) - Add 'Scope' toggle button to SimulatorCanvas toolbar Co-authored-by: davidmonterocrespo24 <47928504+davidmonterocrespo24@users.noreply.github.com> --- .../src/components/simulator/Oscilloscope.css | 224 +++++++++ .../src/components/simulator/Oscilloscope.tsx | 457 ++++++++++++++++++ .../components/simulator/SimulatorCanvas.tsx | 17 + frontend/src/pages/EditorPage.tsx | 15 + frontend/src/simulation/AVRSimulator.ts | 34 ++ frontend/src/simulation/RP2040Simulator.ts | 14 + frontend/src/store/useOscilloscopeStore.ts | 123 +++++ frontend/src/store/useSimulatorStore.ts | 15 + 8 files changed, 899 insertions(+) create mode 100644 frontend/src/components/simulator/Oscilloscope.css create mode 100644 frontend/src/components/simulator/Oscilloscope.tsx create mode 100644 frontend/src/store/useOscilloscopeStore.ts diff --git a/frontend/src/components/simulator/Oscilloscope.css b/frontend/src/components/simulator/Oscilloscope.css new file mode 100644 index 0000000..940c8ed --- /dev/null +++ b/frontend/src/components/simulator/Oscilloscope.css @@ -0,0 +1,224 @@ +/* ── Oscilloscope panel ─────────────────────────────────────────────────── */ + +.osc-container { + display: flex; + flex-direction: column; + height: 100%; + background: #1e1e1e; + border-top: 1px solid #333; + font-family: 'Consolas', 'Menlo', monospace; + font-size: 12px; + color: #ccc; + overflow: hidden; +} + +/* ── Header ── */ +.osc-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background: #252526; + border-bottom: 1px solid #333; + flex-shrink: 0; + flex-wrap: wrap; + min-height: 32px; +} + +.osc-title { + color: #cccccc; + font-weight: 600; + font-size: 12px; + white-space: nowrap; + margin-right: 4px; +} + +.osc-btn { + background: transparent; + border: 1px solid #555; + color: #ccc; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + white-space: nowrap; + transition: border-color 0.15s, background 0.15s; +} + +.osc-btn:hover { + border-color: #007acc; + color: #fff; +} + +.osc-btn-active { + background: #0e3a5a; + border-color: #007acc; + color: #4fc3f7; +} + +.osc-btn-danger { + border-color: #7a0000; + color: #ff6b6b; +} + +.osc-btn-danger:hover { + background: #3a0000; + border-color: #ff6b6b; +} + +.osc-label { + color: #888; + font-size: 11px; + white-space: nowrap; +} + +.osc-select { + background: #1e1e1e; + border: 1px solid #444; + color: #ccc; + padding: 2px 4px; + border-radius: 3px; + font-size: 11px; + outline: none; + cursor: pointer; +} + +.osc-select:focus { + border-color: #007acc; +} + +/* ── Channel picker dropdown ── */ +.osc-picker-wrap { + position: relative; +} + +.osc-picker-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 200; + background: #252526; + border: 1px solid #555; + border-radius: 4px; + padding: 4px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px; + min-width: 200px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} + +.osc-pin-btn { + background: #2d2d2d; + border: 1px solid #444; + color: #ccc; + padding: 3px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + text-align: center; + transition: background 0.1s, border-color 0.1s; +} + +.osc-pin-btn:hover { + background: #0e3a5a; + border-color: #007acc; + color: #fff; +} + +.osc-pin-btn-active { + background: #1a3a1a; + border-color: #00ff41; + color: #00ff41; + cursor: default; +} + +/* ── Waveform area ── */ +.osc-waveforms { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: #0a0a0a; +} + +.osc-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #555; + font-size: 12px; + flex-direction: column; + gap: 8px; +} + +.osc-channel-row { + display: flex; + align-items: stretch; + border-bottom: 1px solid #1a1a1a; + height: 60px; +} + +.osc-channel-label { + width: 56px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4px 6px; + background: #141414; + border-right: 1px solid #222; + gap: 4px; +} + +.osc-channel-name { + font-size: 11px; + font-weight: 700; +} + +.osc-channel-remove { + background: transparent; + border: none; + color: #555; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; + transition: color 0.1s; +} + +.osc-channel-remove:hover { + color: #ff6b6b; +} + +.osc-channel-canvas-wrap { + flex: 1; + position: relative; + overflow: hidden; +} + +.osc-channel-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +/* ── Time ruler ── */ +.osc-ruler { + height: 20px; + flex-shrink: 0; + background: #141414; + border-top: 1px solid #222; + position: relative; +} + +.osc-ruler-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/frontend/src/components/simulator/Oscilloscope.tsx b/frontend/src/components/simulator/Oscilloscope.tsx new file mode 100644 index 0000000..4b90e44 --- /dev/null +++ b/frontend/src/components/simulator/Oscilloscope.tsx @@ -0,0 +1,457 @@ +/** + * Oscilloscope / Logic Analyzer panel. + * + * Captures digital pin HIGH/LOW transitions (with timestamps from the CPU + * cycle counter) and renders them as step waveforms on a . + * + * Usage: + * - Click "+ Add Channel" to pick pins to monitor. + * - Adjust Time/div to zoom in or out. + * - Click Run / Pause to freeze the display without stopping the simulation. + * - Click Clear to wipe all captured samples. + */ + +import React, { + useRef, + useEffect, + useState, + useCallback, + useLayoutEffect, +} from 'react'; +import { useOscilloscopeStore, type OscChannel, type OscSample } from '../../store/useOscilloscopeStore'; +import { useSimulatorStore } from '../../store/useSimulatorStore'; +import './Oscilloscope.css'; + +// Horizontal divisions shown at once +const NUM_DIVS = 10; + +/** Time/div options shown in the selector */ +const TIME_DIV_OPTIONS: { label: string; ms: number }[] = [ + { label: '0.1 ms', ms: 0.1 }, + { label: '0.5 ms', ms: 0.5 }, + { label: '1 ms', ms: 1 }, + { label: '5 ms', ms: 5 }, + { label: '10 ms', ms: 10 }, + { label: '50 ms', ms: 50 }, + { label: '100 ms', ms: 100 }, + { label: '500 ms', ms: 500 }, +]; + +/** Possible digital pins to monitor (Uno/Nano layout) */ +const AVAILABLE_PINS = [ + { pin: 0, label: 'D0' }, + { pin: 1, label: 'D1' }, + { pin: 2, label: 'D2' }, + { pin: 3, label: 'D3' }, + { pin: 4, label: 'D4' }, + { pin: 5, label: 'D5' }, + { pin: 6, label: 'D6' }, + { pin: 7, label: 'D7' }, + { pin: 8, label: 'D8' }, + { pin: 9, label: 'D9' }, + { pin: 10, label: 'D10' }, + { pin: 11, label: 'D11' }, + { pin: 12, label: 'D12' }, + { pin: 13, label: 'D13' }, + { pin: 14, label: 'A0' }, + { pin: 15, label: 'A1' }, + { pin: 16, label: 'A2' }, + { pin: 17, label: 'A3' }, + { pin: 18, label: 'A4' }, + { pin: 19, label: 'A5' }, +]; + +// ── Canvas rendering helpers ──────────────────────────────────────────────── + +/** + * Draw a single channel's waveform onto `canvas`. + * @param samples The sample ring-buffer for this channel. + * @param color Stroke color (CSS string). + * @param windowEndMs Right edge of the time window. + * @param windowMs Total time window width (NUM_DIVS * timeDivMs). + */ +function drawWaveform( + canvas: HTMLCanvasElement, + samples: OscSample[], + color: string, + windowEndMs: number, + windowMs: number, +): void { + const { width, height } = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + + // Background grid lines + ctx.strokeStyle = '#1e1e1e'; + ctx.lineWidth = 1; + for (let d = 0; d <= NUM_DIVS; d++) { + const x = Math.round((d / NUM_DIVS) * width); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + + // Horizontal center guide + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + + if (samples.length === 0) return; + + const windowStartMs = windowEndMs - windowMs; + + // Convert timeMs → canvas x pixel + const toX = (t: number) => ((t - windowStartMs) / windowMs) * width; + + const HIGH_Y = Math.round(height * 0.15); + const LOW_Y = Math.round(height * 0.85); + + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + // Find the last sample before the window to establish the initial state + let initState = false; + for (let i = samples.length - 1; i >= 0; i--) { + if (samples[i].timeMs <= windowStartMs) { + initState = samples[i].state; + break; + } + } + + let currentY = initState ? HIGH_Y : LOW_Y; + ctx.moveTo(0, currentY); + + for (const s of samples) { + if (s.timeMs < windowStartMs) continue; + if (s.timeMs > windowEndMs) break; + + const x = Math.max(0, Math.min(width, toX(s.timeMs))); + const nextY = s.state ? HIGH_Y : LOW_Y; + + // Vertical step + ctx.lineTo(x, currentY); + ctx.lineTo(x, nextY); + currentY = nextY; + } + + // Extend to the right edge + ctx.lineTo(width, currentY); + ctx.stroke(); + + // HIGH / LOW labels at the right margin + ctx.fillStyle = color; + ctx.font = '9px monospace'; + ctx.fillText('H', width - 12, HIGH_Y + 3); + ctx.fillText('L', width - 12, LOW_Y + 3); +} + +/** + * Draw the time-axis ruler below all the channels. + */ +function drawRuler( + canvas: HTMLCanvasElement, + windowEndMs: number, + windowMs: number, + timeDivMs: number, +): void { + const { width, height } = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + ctx.strokeStyle = '#444'; + ctx.fillStyle = '#888'; + ctx.font = '9px monospace'; + ctx.lineWidth = 1; + + const windowStartMs = windowEndMs - windowMs; + + for (let d = 0; d <= NUM_DIVS; d++) { + const timeAtDiv = windowStartMs + d * timeDivMs; + const x = Math.round((d / NUM_DIVS) * width); + + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, 5); + ctx.stroke(); + + // Format the label + const absMs = Math.abs(timeAtDiv); + const label = absMs >= 1000 + ? `${(timeAtDiv / 1000).toFixed(1)}s` + : `${timeAtDiv.toFixed(absMs < 1 ? 2 : 1)}ms`; + + if (d < NUM_DIVS) { + ctx.fillText(label, x + 2, height - 3); + } + } +} + +// ── Channel canvas hook ───────────────────────────────────────────────────── + +interface ChannelCanvasProps { + channel: OscChannel; + samples: OscSample[]; + windowEndMs: number; + windowMs: number; +} + +const ChannelCanvas: React.FC = ({ + channel, + samples, + windowEndMs, + windowMs, +}) => { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + + // Re-draw whenever data or sizing changes + useLayoutEffect(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + + const { width, height } = wrap.getBoundingClientRect(); + if (width === 0 || height === 0) return; + + canvas.width = Math.floor(width) * window.devicePixelRatio; + canvas.height = Math.floor(height) * window.devicePixelRatio; + const ctx = canvas.getContext('2d'); + if (ctx) ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + drawWaveform(canvas, samples, channel.color, windowEndMs, windowMs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [samples, channel.color, windowEndMs, windowMs]); + + return ( +
+ +
+ ); +}; + +// ── Ruler canvas ───────────────────────────────────────────────────────────── + +interface RulerCanvasProps { + windowEndMs: number; + windowMs: number; + timeDivMs: number; +} + +const RulerCanvas: React.FC = ({ windowEndMs, windowMs, timeDivMs }) => { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + + useLayoutEffect(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + + const { width, height } = wrap.getBoundingClientRect(); + if (width === 0 || height === 0) return; + + canvas.width = Math.floor(width) * window.devicePixelRatio; + canvas.height = Math.floor(height) * window.devicePixelRatio; + const ctx = canvas.getContext('2d'); + if (ctx) ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + drawRuler(canvas, windowEndMs, windowMs, timeDivMs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowEndMs, windowMs, timeDivMs]); + + return ( +
+ +
+ ); +}; + +// ── Main component ───────────────────────────────────────────────────────── + +export const Oscilloscope: React.FC = () => { + const { + running: capturing, + timeDivMs, + channels, + samples, + setCapturing, + setTimeDivMs, + addChannel, + removeChannel, + clearSamples, + } = useOscilloscopeStore(); + + const simRunning = useSimulatorStore((s) => s.running); + + const [showPicker, setShowPicker] = useState(false); + const pickerRef = useRef(null); + + // Close picker when clicking outside + useEffect(() => { + if (!showPicker) return; + const handler = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowPicker(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showPicker]); + + // ── Compute the display window ────────────────────────────────────────── + // + // We show the last (NUM_DIVS * timeDivMs) ms of captured data. + // windowEndMs = latest sample time across all channels (or 0 if none). + + const [, forceRedraw] = useState(0); + + // Poll at 60 fps while simulation + capture are running + const rafRef = useRef(null); + useEffect(() => { + if (simRunning && capturing) { + const tick = () => { + forceRedraw((n) => n + 1); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + } + }, [simRunning, capturing]); + + const windowMs = NUM_DIVS * timeDivMs; + + // Find the latest sample time to anchor the right edge of the window + let windowEndMs = 0; + for (const ch of channels) { + const buf = samples[ch.id] ?? []; + if (buf.length > 0) { + windowEndMs = Math.max(windowEndMs, buf[buf.length - 1].timeMs); + } + } + // Always advance at least one window ahead so the display isn't stale + windowEndMs = Math.max(windowEndMs, windowMs); + + const handleAddChannel = useCallback((pin: number) => { + addChannel(pin); + setShowPicker(false); + }, [addChannel]); + + const activePins = new Set(channels.map((c) => c.pin)); + + return ( +
+ {/* ── Header ── */} +
+ Oscilloscope + + {/* Add Channel button + dropdown */} +
+ + + {showPicker && ( +
+ {AVAILABLE_PINS.map(({ pin, label }) => ( + + ))} +
+ )} +
+ + {/* Time / div */} + Time/div: + + + {/* Run / Pause */} + + + {/* Clear */} + +
+ + {/* ── Waveforms ── */} + {channels.length === 0 ? ( +
+ No channels added. + Click "+ Add Channel" to monitor a pin. +
+ ) : ( + <> +
+ {channels.map((ch) => ( +
+ {/* Label + remove button */} +
+ + {ch.label} + + +
+ + {/* Waveform canvas */} + +
+ ))} +
+ + {/* Time ruler */} + + + )} +
+ ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 6f44861..c19ff20 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -15,6 +15,7 @@ import { PinOverlay } from './PinOverlay'; import { PartSimulationRegistry } from '../../simulation/parts'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import type { ComponentMetadata } from '../../types/component-metadata'; +import { useOscilloscopeStore } from '../../store/useOscilloscopeStore'; import './SimulatorCanvas.css'; export const SimulatorCanvas = () => { @@ -43,6 +44,10 @@ export const SimulatorCanvas = () => { const wireInProgress = useSimulatorStore((s) => s.wireInProgress); const recalculateAllWirePositions = useSimulatorStore((s) => s.recalculateAllWirePositions); + // Oscilloscope + const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); + const toggleOscilloscope = useOscilloscopeStore((s) => s.toggleOscilloscope); + // Component picker modal const [showComponentPicker, setShowComponentPicker] = useState(false); const [registry] = useState(() => ComponentRegistry.getInstance()); @@ -779,6 +784,18 @@ export const SimulatorCanvas = () => { Serial + + {/* Oscilloscope toggle */} +
diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 7e2b344..7b96a5c 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -10,10 +10,12 @@ import { FileExplorer } from '../components/editor/FileExplorer'; import { CompilationConsole } from '../components/editor/CompilationConsole'; import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas'; import { SerialMonitor } from '../components/simulator/SerialMonitor'; +import { Oscilloscope } from '../components/simulator/Oscilloscope'; import { AppHeader } from '../components/layout/AppHeader'; import { SaveProjectModal } from '../components/layout/SaveProjectModal'; import { LoginPromptModal } from '../components/layout/LoginPromptModal'; import { useSimulatorStore } from '../store/useSimulatorStore'; +import { useOscilloscopeStore } from '../store/useOscilloscopeStore'; import { useAuthStore } from '../store/useAuthStore'; import type { CompilationLog } from '../utils/compilationLogger'; import '../App.css'; @@ -42,6 +44,7 @@ export const EditorPage: React.FC = () => { const containerRef = useRef(null); const resizingRef = useRef(false); const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen); + const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); const [consoleOpen, setConsoleOpen] = useState(false); const [compileLogs, setCompileLogs] = useState([]); const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT); @@ -276,6 +279,18 @@ export const EditorPage: React.FC = () => {
)} + {oscilloscopeOpen && ( + <> +
+
+ +
+ + )}
diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index b55997f..743540d 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -135,6 +135,12 @@ export class AVRSimulator { public onSerialData: ((char: string) => void) | null = null; /** Fires whenever the sketch changes Serial baud rate (Serial.begin) */ public onBaudRateChange: ((baudRate: number) => void) | null = null; + /** + * Fires for every digital pin transition with a millisecond timestamp + * derived from the CPU cycle counter (cycles / CPU_HZ * 1000). + * Used by the oscilloscope / logic analyzer. + */ + public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null; private lastPortBValue = 0; private lastPortCValue = 0; private lastPortDValue = 0; @@ -231,6 +237,30 @@ export class AVRSimulator { return this.adc; } + /** + * Fire onPinChangeWithTime for every bit that differs between newVal and oldVal. + * @param pinMap Optional explicit per-bit Arduino pin numbers (Mega). + * @param offset Legacy pin offset (Uno/Nano): PORTB→8, PORTC→14, PORTD→0. + */ + private firePinChangeWithTime( + newVal: number, + oldVal: number, + pinMap: number[] | null, + offset = 0, + ): void { + if (!this.onPinChangeWithTime || !this.cpu) return; + const timeMs = this.cpu.cycles / 16_000; + const changed = newVal ^ oldVal; + for (let bit = 0; bit < 8; bit++) { + if (changed & (1 << bit)) { + const pin = pinMap ? pinMap[bit] : offset + bit; + if (pin < 0) continue; + const state = (newVal & (1 << bit)) !== 0; + this.onPinChangeWithTime(pin, state, timeMs); + } + } + } + /** * Monitor pin changes and update component states */ @@ -247,6 +277,7 @@ export class AVRSimulator { const old = this.megaPortValues.get(portName) ?? 0; if (value !== old) { this.pinManager.updatePort(portName, value, old, pinMap); + this.firePinChangeWithTime(value, old, pinMap); this.megaPortValues.set(portName, value); } }); @@ -256,18 +287,21 @@ export class AVRSimulator { this.portB!.addListener((value) => { if (value !== this.lastPortBValue) { this.pinManager.updatePort('PORTB', value, this.lastPortBValue); + this.firePinChangeWithTime(value, this.lastPortBValue, null, 8); this.lastPortBValue = value; } }); this.portC!.addListener((value) => { if (value !== this.lastPortCValue) { this.pinManager.updatePort('PORTC', value, this.lastPortCValue); + this.firePinChangeWithTime(value, this.lastPortCValue, null, 14); this.lastPortCValue = value; } }); this.portD!.addListener((value) => { if (value !== this.lastPortDValue) { this.pinManager.updatePort('PORTD', value, this.lastPortDValue); + this.firePinChangeWithTime(value, this.lastPortDValue, null, 0); this.lastPortDValue = value; } }); diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index 2e9b5bd..a7b2a98 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -53,6 +53,13 @@ export class RP2040Simulator { /** Serial output callback — fires for each byte the Pico sends on UART0 */ public onSerialData: ((char: string) => void) | null = null; + /** + * Fires for every GPIO pin transition with a millisecond timestamp. + * Used by the oscilloscope / logic analyzer. + * timeMs is derived from the RP2040 cycle counter (cycles / F_CPU * 1000). + */ + public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null; + /** I2C virtual devices on each bus */ private i2cDevices: [Map, Map] = [new Map(), new Map()]; private activeI2CDevice: [RP2040I2CDevice | null, RP2040I2CDevice | null] = [null, null]; @@ -222,6 +229,13 @@ export class RP2040Simulator { const unsub = gpio.addListener((state: GPIOPinState) => { const isHigh = state === GPIOPinState.High; this.pinManager.triggerPinChange(pin, isHigh); + if (this.onPinChangeWithTime && this.rp2040) { + // Use clock cycles if available, otherwise 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const clk = (this.rp2040 as any).clock; + const timeMs = clk ? clk.timeUs / 1000 : 0; + this.onPinChangeWithTime(pin, isHigh, timeMs); + } }); this.gpioUnsubscribers.push(unsub); } diff --git a/frontend/src/store/useOscilloscopeStore.ts b/frontend/src/store/useOscilloscopeStore.ts new file mode 100644 index 0000000..98a6d4b --- /dev/null +++ b/frontend/src/store/useOscilloscopeStore.ts @@ -0,0 +1,123 @@ +/** + * Oscilloscope / Logic Analyzer store. + * + * Captures pin HIGH/LOW transitions with microsecond-level timestamps + * derived from the AVR CPU cycle counter and renders them as waveforms. + */ + +import { create } from 'zustand'; + +export const MAX_SAMPLES = 10_000; + +/** Distinct colors cycled through when adding new channels */ +export const CHANNEL_COLORS = [ + '#00ff41', + '#ff6b6b', + '#4fc3f7', + '#ffd54f', + '#ce93d8', + '#80cbc4', + '#ffb74d', + '#f06292', +]; + +export interface OscChannel { + id: string; + pin: number; + label: string; + color: string; +} + +export interface OscSample { + /** Time in milliseconds from simulation start */ + timeMs: number; + state: boolean; +} + +interface OscilloscopeState { + /** Whether the panel is visible */ + open: boolean; + /** Whether capture is active (pause/resume independently of simulation) */ + running: boolean; + /** Milliseconds per horizontal division (10 divisions shown) */ + timeDivMs: number; + /** Channels currently monitored */ + channels: OscChannel[]; + /** Circular sample buffers keyed by channel id */ + samples: Record; + + // ── Actions ──────────────────────────────────────────────────────────────── + + toggleOscilloscope: () => void; + setCapturing: (running: boolean) => void; + setTimeDivMs: (ms: number) => void; + addChannel: (pin: number) => void; + removeChannel: (id: string) => void; + /** Push one sample; drops the oldest if the buffer is full */ + pushSample: (channelId: string, timeMs: number, state: boolean) => void; + clearSamples: () => void; +} + +export const useOscilloscopeStore = create((set, get) => ({ + open: false, + running: true, + timeDivMs: 1, + channels: [], + samples: {}, + + toggleOscilloscope: () => set((s) => ({ open: !s.open })), + + setCapturing: (running) => set({ running }), + + setTimeDivMs: (ms) => set({ timeDivMs: ms }), + + addChannel: (pin: number) => { + const { channels } = get(); + if (channels.some((c) => c.pin === pin)) return; // already added + + const isAnalog = pin >= 14 && pin <= 19; + const pinLabel = isAnalog ? `A${pin - 14}` : `D${pin}`; + + const id = `osc-ch-${pin}`; + const color = CHANNEL_COLORS[channels.length % CHANNEL_COLORS.length]; + + set((s) => ({ + channels: [...s.channels, { id, pin, label: pinLabel, color }], + samples: { ...s.samples, [id]: [] }, + })); + }, + + removeChannel: (id) => { + set((s) => { + const { [id]: _removed, ...rest } = s.samples; + return { + channels: s.channels.filter((c) => c.id !== id), + samples: rest, + }; + }); + }, + + pushSample: (channelId, timeMs, state) => { + if (!get().running) return; + set((s) => { + const buf = s.samples[channelId]; + if (!buf) return s; + + let next: OscSample[]; + if (buf.length >= MAX_SAMPLES) { + // Drop the oldest entry (shift) + next = [...buf.slice(1), { timeMs, state }]; + } else { + next = [...buf, { timeMs, state }]; + } + return { samples: { ...s.samples, [channelId]: next } }; + }); + }, + + clearSamples: () => { + const { channels } = get(); + const fresh: Record = {}; + channels.forEach((c) => { fresh[c.id] = []; }); + set({ samples: fresh }); + }, +})); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 481b2ad..ebbdf14 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -6,6 +6,7 @@ import { VirtualDS1307, VirtualTempSensor, I2CMemoryDevice } from '../simulation import type { RP2040I2CDevice } from '../simulation/RP2040Simulator'; import type { Wire, WireInProgress, WireEndpoint } from '../types/wire'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; +import { useOscilloscopeStore } from './useOscilloscopeStore'; export type BoardType = 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico'; @@ -114,6 +115,18 @@ export const useSimulatorStore = create((set, get) => { // Create PinManager instance const pinManager = new PinManager(); + /** Attach the oscilloscope pin-change-with-time callback to a simulator */ + function wireOscilloscopeCallback(sim: AVRSimulator | RP2040Simulator): void { + sim.onPinChangeWithTime = (pin: number, state: boolean, timeMs: number) => { + const { channels, pushSample } = useOscilloscopeStore.getState(); + for (const ch of channels) { + if (ch.pin === pin) { + pushSample(ch.id, timeMs, state); + } + } + }; + } + return { boardType: 'arduino-uno' as BoardType, boardPosition: { ...DEFAULT_BOARD_POSITION }, @@ -203,6 +216,7 @@ export const useSimulatorStore = create((set, get) => { if (simulator instanceof AVRSimulator) { simulator.onBaudRateChange = (baudRate: number) => set({ serialBaudRate: baudRate }); } + wireOscilloscopeCallback(simulator); set({ boardType: type, simulator, compiledHex: null, serialOutput: '', serialBaudRate: 0 }); console.log(`Board switched to: ${type}`); }, @@ -219,6 +233,7 @@ export const useSimulatorStore = create((set, get) => { if (simulator instanceof AVRSimulator) { simulator.onBaudRateChange = (baudRate: number) => set({ serialBaudRate: baudRate }); } + wireOscilloscopeCallback(simulator); set({ simulator, serialOutput: '', serialBaudRate: 0 }); console.log(`Simulator initialized: ${boardType}`); }, From a06ea17f9921863842916eb0d41a97422dbdf40b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:13:17 +0000 Subject: [PATCH 3/3] refactor: address code review feedback on oscilloscope - Improve ring buffer efficiency: one array copy instead of two (slice+shift+push vs slice+spread) - Fix extra whitespace in canvas dimension assignments - Add explanatory comments for eslint-disable-next-line react-hooks/exhaustive-deps Co-authored-by: davidmonterocrespo24 <47928504+davidmonterocrespo24@users.noreply.github.com> --- frontend/src/components/simulator/Oscilloscope.tsx | 13 ++++++++----- frontend/src/store/useOscilloscopeStore.ts | 11 ++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/simulator/Oscilloscope.tsx b/frontend/src/components/simulator/Oscilloscope.tsx index 4b90e44..9c6bbdd 100644 --- a/frontend/src/components/simulator/Oscilloscope.tsx +++ b/frontend/src/components/simulator/Oscilloscope.tsx @@ -215,17 +215,19 @@ const ChannelCanvas: React.FC = ({ // Re-draw whenever data or sizing changes useLayoutEffect(() => { const canvas = canvasRef.current; - const wrap = wrapRef.current; + const wrap = wrapRef.current; if (!canvas || !wrap) return; const { width, height } = wrap.getBoundingClientRect(); if (width === 0 || height === 0) return; - canvas.width = Math.floor(width) * window.devicePixelRatio; + canvas.width = Math.floor(width) * window.devicePixelRatio; canvas.height = Math.floor(height) * window.devicePixelRatio; const ctx = canvas.getContext('2d'); if (ctx) ctx.scale(window.devicePixelRatio, window.devicePixelRatio); drawWaveform(canvas, samples, channel.color, windowEndMs, windowMs); + // Intentionally exclude canvasRef/wrapRef (stable refs) and the + // module-level drawWaveform function from the dependency array. // eslint-disable-next-line react-hooks/exhaustive-deps }, [samples, channel.color, windowEndMs, windowMs]); @@ -246,21 +248,22 @@ interface RulerCanvasProps { const RulerCanvas: React.FC = ({ windowEndMs, windowMs, timeDivMs }) => { const canvasRef = useRef(null); - const wrapRef = useRef(null); + const wrapRef = useRef(null); useLayoutEffect(() => { const canvas = canvasRef.current; - const wrap = wrapRef.current; + const wrap = wrapRef.current; if (!canvas || !wrap) return; const { width, height } = wrap.getBoundingClientRect(); if (width === 0 || height === 0) return; - canvas.width = Math.floor(width) * window.devicePixelRatio; + canvas.width = Math.floor(width) * window.devicePixelRatio; canvas.height = Math.floor(height) * window.devicePixelRatio; const ctx = canvas.getContext('2d'); if (ctx) ctx.scale(window.devicePixelRatio, window.devicePixelRatio); drawRuler(canvas, windowEndMs, windowMs, timeDivMs); + // Intentionally exclude stable canvasRef/wrapRef and module-level drawRuler. // eslint-disable-next-line react-hooks/exhaustive-deps }, [windowEndMs, windowMs, timeDivMs]); diff --git a/frontend/src/store/useOscilloscopeStore.ts b/frontend/src/store/useOscilloscopeStore.ts index 98a6d4b..9f46d83 100644 --- a/frontend/src/store/useOscilloscopeStore.ts +++ b/frontend/src/store/useOscilloscopeStore.ts @@ -103,13 +103,10 @@ export const useOscilloscopeStore = create((set, get) => ({ const buf = s.samples[channelId]; if (!buf) return s; - let next: OscSample[]; - if (buf.length >= MAX_SAMPLES) { - // Drop the oldest entry (shift) - next = [...buf.slice(1), { timeMs, state }]; - } else { - next = [...buf, { timeMs, state }]; - } + // Efficient copy: one allocation instead of two (avoids spread + slice). + const next = buf.slice(); + if (next.length >= MAX_SAMPLES) next.shift(); // drop oldest + next.push({ timeMs, state }); return { samples: { ...s.samples, [channelId]: next } }; }); },