Merge pull request #15 from davidmonterocrespo24/copilot/add-oscilloscope-logic-analyzer

feat: Built-in oscilloscope / logic analyzer for pin waveform visualization
pull/19/head
David Montero Crespo 2026-03-11 12:22:49 -03:00 committed by GitHub
commit d0d792df4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 899 additions and 0 deletions

View File

@ -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%;
}

View File

@ -0,0 +1,460 @@
/**
* 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 <canvas>.
*
* 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<ChannelCanvasProps> = ({
channel,
samples,
windowEndMs,
windowMs,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(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);
// 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]);
return (
<div ref={wrapRef} className="osc-channel-canvas-wrap">
<canvas ref={canvasRef} className="osc-channel-canvas" />
</div>
);
};
// ── Ruler canvas ─────────────────────────────────────────────────────────────
interface RulerCanvasProps {
windowEndMs: number;
windowMs: number;
timeDivMs: number;
}
const RulerCanvas: React.FC<RulerCanvasProps> = ({ windowEndMs, windowMs, timeDivMs }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(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);
// Intentionally exclude stable canvasRef/wrapRef and module-level drawRuler.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowEndMs, windowMs, timeDivMs]);
return (
<div ref={wrapRef} className="osc-ruler">
<canvas ref={canvasRef} className="osc-ruler-canvas" />
</div>
);
};
// ── 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<HTMLDivElement>(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<number | null>(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 (
<div className="osc-container">
{/* ── Header ── */}
<div className="osc-header">
<span className="osc-title">Oscilloscope</span>
{/* Add Channel button + dropdown */}
<div className="osc-picker-wrap" ref={pickerRef}>
<button
className="osc-btn"
onClick={() => setShowPicker((v) => !v)}
title="Add a pin channel"
>
+ Add Channel
</button>
{showPicker && (
<div className="osc-picker-dropdown">
{AVAILABLE_PINS.map(({ pin, label }) => (
<button
key={pin}
className={`osc-pin-btn${activePins.has(pin) ? ' osc-pin-btn-active' : ''}`}
onClick={() => !activePins.has(pin) && handleAddChannel(pin)}
title={activePins.has(pin) ? 'Already added' : `Monitor ${label}`}
>
{label}
</button>
))}
</div>
)}
</div>
{/* Time / div */}
<span className="osc-label">Time/div:</span>
<select
className="osc-select"
value={timeDivMs}
onChange={(e) => setTimeDivMs(Number(e.target.value))}
>
{TIME_DIV_OPTIONS.map(({ label, ms }) => (
<option key={ms} value={ms}>{label}</option>
))}
</select>
{/* Run / Pause */}
<button
className={`osc-btn${capturing ? '' : ' osc-btn-active'}`}
onClick={() => setCapturing(!capturing)}
title={capturing ? 'Pause capture' : 'Resume capture'}
>
{capturing ? '⏸ Pause' : '▶ Run'}
</button>
{/* Clear */}
<button
className="osc-btn osc-btn-danger"
onClick={clearSamples}
title="Clear all captured samples"
>
Clear
</button>
</div>
{/* ── Waveforms ── */}
{channels.length === 0 ? (
<div className="osc-empty">
<span>No channels added.</span>
<span style={{ color: '#777' }}>Click &quot;+ Add Channel&quot; to monitor a pin.</span>
</div>
) : (
<>
<div className="osc-waveforms">
{channels.map((ch) => (
<div key={ch.id} className="osc-channel-row">
{/* Label + remove button */}
<div className="osc-channel-label">
<span className="osc-channel-name" style={{ color: ch.color }}>
{ch.label}
</span>
<button
className="osc-channel-remove"
onClick={() => removeChannel(ch.id)}
title={`Remove ${ch.label}`}
>
×
</button>
</div>
{/* Waveform canvas */}
<ChannelCanvas
channel={ch}
samples={samples[ch.id] ?? []}
windowEndMs={windowEndMs}
windowMs={windowMs}
/>
</div>
))}
</div>
{/* Time ruler */}
<RulerCanvas
windowEndMs={windowEndMs}
windowMs={windowMs}
timeDivMs={timeDivMs}
/>
</>
)}
</div>
);
};

View File

@ -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 = () => {
</svg>
Serial
</button>
{/* Oscilloscope toggle */}
<button
onClick={toggleOscilloscope}
className={`canvas-serial-btn${oscilloscopeOpen ? ' canvas-serial-btn-active' : ''}`}
title="Toggle Oscilloscope / Logic Analyzer"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2 14 6 8 10 14 14 6 18 14 22 10" />
</svg>
Scope
</button>
</div>
<div className="canvas-header-right">

View File

@ -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<HTMLDivElement>(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<CompilationLog[]>([]);
const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT);
@ -276,6 +279,18 @@ export const EditorPage: React.FC = () => {
</div>
</>
)}
{oscilloscopeOpen && (
<>
<div
onMouseDown={handleBottomPanelResizeMouseDown}
style={resizeHandleStyle}
title="Drag to resize"
/>
<div style={{ height: bottomPanelHeight, flexShrink: 0 }}>
<Oscilloscope />
</div>
</>
)}
</div>
</div>

View File

@ -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): PORTB8, PORTC14, PORTD0.
*/
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;
}
});

View File

@ -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<number, RP2040I2CDevice>, Map<number, RP2040I2CDevice>] = [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);
}

View File

@ -0,0 +1,120 @@
/**
* 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<string, OscSample[]>;
// ── 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<OscilloscopeState>((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;
// 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 } };
});
},
clearSamples: () => {
const { channels } = get();
const fresh: Record<string, OscSample[]> = {};
channels.forEach((c) => { fresh[c.id] = []; });
set({ samples: fresh });
},
}));

View File

@ -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<SimulatorState>((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<SimulatorState>((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<SimulatorState>((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}`);
},