Merge pull request #15 from davidmonterocrespo24/copilot/add-oscilloscope-logic-analyzer
feat: Built-in oscilloscope / logic analyzer for pin waveform visualizationpull/19/head
commit
d0d792df4a
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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 "+ Add Channel" 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue