feat(scope/trigger): add Auto / Normal / Single-shot trigger modes
Real digital storage scopes have a trigger that pins the visible window
around a detected edge — without it, sparse activity (UART bytes once
per loop, an interrupt firing every few seconds) scrolls off the screen
faster than the eye can catch. Velxio's scope was free-running only,
which made the recent UART TX waveform work effectively invisible at
fine time/div settings: the byte burst was 87 µs but the window only
showed the most recent 1 ms.
Three trigger modes, matching what you'd find on a Rigol / Tektronix:
* Auto — current free-running behaviour, window's right edge
tracks the most recent sample. Default.
* Normal — window pins around each triggering edge so the event
lands at `triggerPosition * windowMs` from the left
(default centred at 0.5). Keeps re-pinning on every
new triggering edge.
* Single — arms once, freezes the trace on the first triggering
edge by flipping `running = false`. User clicks
"Re-arm" to capture again.
Three knobs configurable per mode:
- source: which channel produces the trigger event
- edge: rising (↑) / falling (↓) / either (⇅)
- position: trigger lands at this fraction of the window
(UI hard-codes centre 0.5 for now; the store field
accepts any value if we want a draggable handle later)
UI additions in the scope header (only shown when mode != auto):
- source / edge dropdowns
- status badge (Armed / Triggered / Captured) with pulse animation
on Armed so the user knows the scope is waiting for an event
- Re-arm button in Single mode after capture
Canvas changes:
- Dashed orange "T" marker drawn at the trigger position when an
edge is latched and within the visible window.
Store changes:
- pushSample peeks at the trigger channel's previous state, detects
a matching edge, sets triggeredAtMs (and stops `running` for
Single mode). matchesTriggerEdge() exported for unit testing.
- clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge
all re-arm the trigger; rearmTrigger() explicitly resets and resumes
capture (used by the Re-arm button after a single-shot).
Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the
existing 1892 tests still pass.
Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat
line" UX trap reported on the Discord follow-up — at 0.1 ms/div the
window is 1 ms but bytes fire every 2 s, so without a trigger the
chance of catching the burst is < 0.05 %. With Normal trigger on
rising D1 the burst pins in the middle of the window and the user can
zoom down to bit level (8.68 µs each) without losing it.
This commit is contained in:
parent
737ec5c6eb
commit
aab380e80b
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Oscilloscope trigger logic
|
||||
*
|
||||
* The trigger turns the otherwise free-running scope into something that
|
||||
* actually behaves like a digital storage scope — windows pin around
|
||||
* detected edges instead of scrolling away from sparse activity. These
|
||||
* tests pin down the three modes (auto / normal / single), the three
|
||||
* edge selectors (rising / falling / either), and the re-arm flow.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
useOscilloscopeStore,
|
||||
matchesTriggerEdge,
|
||||
} from '../store/useOscilloscopeStore';
|
||||
|
||||
const initialState = useOscilloscopeStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useOscilloscopeStore.setState({
|
||||
...initialState,
|
||||
channels: [],
|
||||
samples: {},
|
||||
triggerMode: 'auto',
|
||||
triggerChannelId: null,
|
||||
triggerEdge: 'rising',
|
||||
triggerPosition: 0.5,
|
||||
triggeredAtMs: null,
|
||||
triggerStatus: 'idle',
|
||||
running: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesTriggerEdge', () => {
|
||||
it('detects rising edges only when prev=LOW → new=HIGH', () => {
|
||||
expect(matchesTriggerEdge(false, true, 'rising')).toBe(true);
|
||||
expect(matchesTriggerEdge(true, false, 'rising')).toBe(false);
|
||||
expect(matchesTriggerEdge(false, false, 'rising')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects falling edges only when prev=HIGH → new=LOW', () => {
|
||||
expect(matchesTriggerEdge(true, false, 'falling')).toBe(true);
|
||||
expect(matchesTriggerEdge(false, true, 'falling')).toBe(false);
|
||||
expect(matchesTriggerEdge(true, true, 'falling')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects both directions when edge is either', () => {
|
||||
expect(matchesTriggerEdge(false, true, 'either')).toBe(true);
|
||||
expect(matchesTriggerEdge(true, false, 'either')).toBe(true);
|
||||
expect(matchesTriggerEdge(true, true, 'either')).toBe(false);
|
||||
expect(matchesTriggerEdge(false, false, 'either')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushSample under each trigger mode', () => {
|
||||
it('auto mode never sets triggeredAtMs', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
const chId = useOscilloscopeStore.getState().channels[0].id;
|
||||
|
||||
s.pushSample(chId, 0, false);
|
||||
s.pushSample(chId, 1, true); // rising edge — auto ignores
|
||||
s.pushSample(chId, 2, false); // falling edge — auto ignores
|
||||
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
|
||||
expect(useOscilloscopeStore.getState().samples[chId]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('normal mode latches triggeredAtMs on the configured rising edge', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
const chId = useOscilloscopeStore.getState().channels[0].id;
|
||||
s.setTriggerMode('normal');
|
||||
|
||||
s.pushSample(chId, 0, false); // first sample — no prior state, no trigger
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
|
||||
s.pushSample(chId, 1, true); // ↑ rising — trigger fires
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
expect(useOscilloscopeStore.getState().triggerStatus).toBe('triggered');
|
||||
s.pushSample(chId, 2, false); // ↓ falling — not "rising", no re-trigger
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
s.pushSample(chId, 3, true); // ↑ rising — re-trigger to new time
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(3);
|
||||
});
|
||||
|
||||
it('single-shot mode stops capture after the first triggering edge', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
const chId = useOscilloscopeStore.getState().channels[0].id;
|
||||
s.setTriggerMode('single');
|
||||
|
||||
s.pushSample(chId, 0, false);
|
||||
s.pushSample(chId, 1, true); // ↑ trigger → capture, freeze
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
expect(useOscilloscopeStore.getState().running).toBe(false);
|
||||
expect(useOscilloscopeStore.getState().triggerStatus).toBe('captured');
|
||||
|
||||
// Further pushes are ignored (`running === false` early-exit + capture lock)
|
||||
s.pushSample(chId, 2, false);
|
||||
s.pushSample(chId, 3, true);
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
expect(useOscilloscopeStore.getState().samples[chId]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rearmTrigger resumes single-shot capture', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
const chId = useOscilloscopeStore.getState().channels[0].id;
|
||||
s.setTriggerMode('single');
|
||||
|
||||
s.pushSample(chId, 0, false);
|
||||
s.pushSample(chId, 1, true); // capture
|
||||
expect(useOscilloscopeStore.getState().running).toBe(false);
|
||||
|
||||
s.rearmTrigger();
|
||||
expect(useOscilloscopeStore.getState().running).toBe(true);
|
||||
expect(useOscilloscopeStore.getState().triggerStatus).toBe('armed');
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
|
||||
|
||||
s.pushSample(chId, 2, false);
|
||||
s.pushSample(chId, 3, true); // re-capture
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(3);
|
||||
});
|
||||
|
||||
it('falling-edge selector ignores rising edges', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
const chId = useOscilloscopeStore.getState().channels[0].id;
|
||||
s.setTriggerMode('normal');
|
||||
s.setTriggerEdge('falling');
|
||||
|
||||
s.pushSample(chId, 0, false);
|
||||
s.pushSample(chId, 1, true); // rising — ignored
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
|
||||
s.pushSample(chId, 2, false); // falling — fires
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(2);
|
||||
});
|
||||
|
||||
it('only the configured trigger channel fires the trigger', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
s.addChannel('uno-1', 13, 'D13');
|
||||
const [chD1, chD13] = useOscilloscopeStore.getState().channels.map((c) => c.id);
|
||||
s.setTriggerMode('normal');
|
||||
s.setTriggerChannel(chD13);
|
||||
|
||||
s.pushSample(chD1, 0, false);
|
||||
s.pushSample(chD1, 1, true); // edge on D1 — should NOT trigger (channel mismatch)
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
|
||||
s.pushSample(chD13, 0, false);
|
||||
s.pushSample(chD13, 1, true); // edge on D13 — triggers
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
});
|
||||
|
||||
it('removing the trigger channel falls back to the first remaining channel', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
s.addChannel('uno-1', 13, 'D13');
|
||||
const [chD1, chD13] = useOscilloscopeStore.getState().channels.map((c) => c.id);
|
||||
s.setTriggerMode('normal');
|
||||
s.setTriggerChannel(chD13);
|
||||
|
||||
s.removeChannel(chD13);
|
||||
expect(useOscilloscopeStore.getState().triggerChannelId).toBeNull();
|
||||
|
||||
// With triggerChannelId === null, the resolver falls back to the first
|
||||
// remaining channel (chD1) so rising edges on D1 now trigger.
|
||||
s.pushSample(chD1, 0, false);
|
||||
s.pushSample(chD1, 1, true);
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
});
|
||||
|
||||
it('clearSamples re-arms the trigger', () => {
|
||||
const s = useOscilloscopeStore.getState();
|
||||
s.addChannel('uno-1', 1, 'D1');
|
||||
const chId = useOscilloscopeStore.getState().channels[0].id;
|
||||
s.setTriggerMode('normal');
|
||||
|
||||
s.pushSample(chId, 0, false);
|
||||
s.pushSample(chId, 1, true);
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
|
||||
|
||||
s.clearSamples();
|
||||
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
|
||||
expect(useOscilloscopeStore.getState().triggerStatus).toBe('armed');
|
||||
expect(useOscilloscopeStore.getState().samples[chId]).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -291,3 +291,41 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Trigger status badge ───────────────────────────────────────────────── */
|
||||
.osc-trigger-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.osc-trigger-status-idle {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.osc-trigger-status-armed {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
animation: osc-pulse-armed 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.osc-trigger-status-triggered {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.osc-trigger-status-captured {
|
||||
color: #ff9800;
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
@keyframes osc-pulse-armed {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
useOscilloscopeStore,
|
||||
type OscChannel,
|
||||
type OscSample,
|
||||
type TriggerMode,
|
||||
type TriggerEdge,
|
||||
} from '../../store/useOscilloscopeStore';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import { BOARD_KIND_LABELS } from '../../types/board';
|
||||
|
|
@ -84,6 +86,12 @@ function drawWaveform(
|
|||
color: string,
|
||||
windowEndMs: number,
|
||||
windowMs: number,
|
||||
/**
|
||||
* Trigger marker — when not null, render a vertical orange line at this
|
||||
* X fraction (0..1) of the canvas to show where the trigger event
|
||||
* landed. Mirrors the orange "T" cursor on a real Tektronix / Rigol.
|
||||
*/
|
||||
triggerXFrac: number | null = null,
|
||||
): void {
|
||||
const { width, height } = canvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
|
@ -110,6 +118,22 @@ function drawWaveform(
|
|||
ctx.lineTo(width, height / 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Trigger marker — drawn under the trace so the waveform sits on top.
|
||||
if (triggerXFrac !== null) {
|
||||
const x = Math.round(triggerXFrac * width);
|
||||
ctx.strokeStyle = '#ff9800';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#ff9800';
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.fillText('T', x + 2, 10);
|
||||
}
|
||||
|
||||
if (samples.length === 0) return;
|
||||
|
||||
const windowStartMs = windowEndMs - windowMs;
|
||||
|
|
@ -198,6 +222,9 @@ interface ChannelCanvasProps {
|
|||
samples: OscSample[];
|
||||
windowEndMs: number;
|
||||
windowMs: number;
|
||||
/** X fraction (0..1) of the trigger marker, or null when no marker should
|
||||
* be drawn (e.g. auto mode or the trigger event is outside the window). */
|
||||
triggerXFrac: number | null;
|
||||
}
|
||||
|
||||
const ChannelCanvas: React.FC<ChannelCanvasProps> = ({
|
||||
|
|
@ -205,6 +232,7 @@ const ChannelCanvas: React.FC<ChannelCanvasProps> = ({
|
|||
samples,
|
||||
windowEndMs,
|
||||
windowMs,
|
||||
triggerXFrac,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -221,8 +249,8 @@ const ChannelCanvas: React.FC<ChannelCanvasProps> = ({
|
|||
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);
|
||||
}, [samples, channel.color, windowEndMs, windowMs]);
|
||||
drawWaveform(canvas, samples, channel.color, windowEndMs, windowMs, triggerXFrac);
|
||||
}, [samples, channel.color, windowEndMs, windowMs, triggerXFrac]);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="osc-channel-canvas-wrap">
|
||||
|
|
@ -364,6 +392,16 @@ export const Oscilloscope: React.FC = () => {
|
|||
addChannel,
|
||||
removeChannel,
|
||||
clearSamples,
|
||||
triggerMode,
|
||||
triggerChannelId,
|
||||
triggerEdge,
|
||||
triggerPosition,
|
||||
triggeredAtMs,
|
||||
triggerStatus,
|
||||
setTriggerMode,
|
||||
setTriggerChannel,
|
||||
setTriggerEdge,
|
||||
rearmTrigger,
|
||||
} = useOscilloscopeStore();
|
||||
|
||||
// Any board running → oscilloscope can capture
|
||||
|
|
@ -420,15 +458,36 @@ export const Oscilloscope: React.FC = () => {
|
|||
|
||||
const windowMs = NUM_DIVS * timeDivMs;
|
||||
|
||||
// ── Window positioning ─────────────────────────────────────────────────
|
||||
// Auto mode: window's right edge tracks the most recent sample across
|
||||
// all channels (free-running). Normal / single mode with a latched
|
||||
// trigger: pin the window around the trigger event so it lands at
|
||||
// `triggerPosition * windowMs` from the left. Normal / single mode
|
||||
// with NO trigger yet: fall back to free-running so the user can still
|
||||
// see what's happening while waiting for the first edge.
|
||||
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);
|
||||
if (triggerMode !== 'auto' && triggeredAtMs !== null) {
|
||||
windowEndMs = triggeredAtMs + (1 - triggerPosition) * windowMs;
|
||||
} else {
|
||||
for (const ch of channels) {
|
||||
const buf = samples[ch.id] ?? [];
|
||||
if (buf.length > 0) {
|
||||
windowEndMs = Math.max(windowEndMs, buf[buf.length - 1].timeMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
windowEndMs = Math.max(windowEndMs, windowMs);
|
||||
|
||||
// X fraction (0..1) of the trigger marker within the visible window.
|
||||
// null = no marker (auto mode or trigger event outside the window).
|
||||
let triggerXFrac: number | null = null;
|
||||
if (triggerMode !== 'auto' && triggeredAtMs !== null) {
|
||||
const windowStartMs = windowEndMs - windowMs;
|
||||
if (triggeredAtMs >= windowStartMs && triggeredAtMs <= windowEndMs) {
|
||||
triggerXFrac = (triggeredAtMs - windowStartMs) / windowMs;
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddChannel = useCallback(
|
||||
(boardId: string, pin: number, pinLabel: string) => {
|
||||
addChannel(boardId, pin, pinLabel);
|
||||
|
|
@ -500,6 +559,72 @@ export const Oscilloscope: React.FC = () => {
|
|||
{capturing ? `⏸ ${t('editor.oscilloscope.pause')}` : `▶ ${t('editor.oscilloscope.run')}`}
|
||||
</button>
|
||||
|
||||
{/* ── Trigger ─────────────────────────────────────────────────────
|
||||
Mode + source + edge are the three knobs a real DSO exposes.
|
||||
Auto = free-running (current default). Normal = window pins
|
||||
on every triggering edge. Single = arm once, freeze on first
|
||||
edge — click again to re-arm. */}
|
||||
<span className="osc-label" title="Trigger configuration">Trigger</span>
|
||||
<select
|
||||
className="osc-select"
|
||||
value={triggerMode}
|
||||
onChange={(e) => setTriggerMode(e.target.value as TriggerMode)}
|
||||
title="Trigger mode"
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="single">Single</option>
|
||||
</select>
|
||||
|
||||
{triggerMode !== 'auto' && (
|
||||
<>
|
||||
<select
|
||||
className="osc-select"
|
||||
value={triggerChannelId ?? channels[0]?.id ?? ''}
|
||||
onChange={(e) => setTriggerChannel(e.target.value || null)}
|
||||
title="Trigger source"
|
||||
disabled={channels.length <= 1}
|
||||
>
|
||||
{channels.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{boardShortName(c.boardId)}:{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="osc-select"
|
||||
value={triggerEdge}
|
||||
onChange={(e) => setTriggerEdge(e.target.value as TriggerEdge)}
|
||||
title="Trigger edge"
|
||||
>
|
||||
<option value="rising">↑ Rising</option>
|
||||
<option value="falling">↓ Falling</option>
|
||||
<option value="either">⇅ Either</option>
|
||||
</select>
|
||||
|
||||
<span
|
||||
className={`osc-trigger-status osc-trigger-status-${triggerStatus}`}
|
||||
title={`Trigger status: ${triggerStatus}`}
|
||||
>
|
||||
{triggerStatus === 'armed' && 'Armed'}
|
||||
{triggerStatus === 'triggered' && 'Triggered'}
|
||||
{triggerStatus === 'captured' && 'Captured'}
|
||||
{triggerStatus === 'idle' && 'Idle'}
|
||||
</span>
|
||||
|
||||
{triggerMode === 'single' && triggerStatus === 'captured' && (
|
||||
<button
|
||||
className="osc-btn osc-btn-active"
|
||||
onClick={rearmTrigger}
|
||||
title="Re-arm and capture the next triggering edge"
|
||||
>
|
||||
Re-arm
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clear */}
|
||||
<button
|
||||
className="osc-btn osc-btn-danger"
|
||||
|
|
@ -542,6 +667,7 @@ export const Oscilloscope: React.FC = () => {
|
|||
samples={samples[ch.id] ?? []}
|
||||
windowEndMs={windowEndMs}
|
||||
windowMs={windowMs}
|
||||
triggerXFrac={triggerXFrac}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,27 @@
|
|||
*
|
||||
* Channels are keyed by (boardId, pin) so multiple boards with the same
|
||||
* logical pin number can be monitored independently.
|
||||
*
|
||||
* Trigger model — matches a real digital storage scope:
|
||||
*
|
||||
* - `auto` free-running display; the window's right edge tracks the
|
||||
* latest sample. This is the default and the behaviour
|
||||
* existing test suites depend on.
|
||||
*
|
||||
* - `normal` window pins around each triggering edge: the trigger
|
||||
* event lands at `triggerPosition * windowMs` from the
|
||||
* left, with the rest of the window showing post-trigger
|
||||
* samples. The window holds steady until the next edge.
|
||||
*
|
||||
* - `single` arms once: on the first triggering edge after arming the
|
||||
* scope captures, then sets `running = false` so the trace
|
||||
* freezes for inspection. User must click "Single" again
|
||||
* to re-arm.
|
||||
*
|
||||
* Edge detection looks at the configured trigger channel only. The
|
||||
* trigger fires when the newly-pushed sample's state differs from the
|
||||
* previous one on that channel AND the transition matches the configured
|
||||
* `triggerEdge` (rising / falling / either).
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
|
@ -39,6 +60,10 @@ export interface OscSample {
|
|||
state: boolean;
|
||||
}
|
||||
|
||||
export type TriggerMode = 'auto' | 'normal' | 'single';
|
||||
export type TriggerEdge = 'rising' | 'falling' | 'either';
|
||||
export type TriggerStatus = 'idle' | 'armed' | 'triggered' | 'captured';
|
||||
|
||||
interface OscilloscopeState {
|
||||
/** Whether the panel is visible */
|
||||
open: boolean;
|
||||
|
|
@ -51,6 +76,24 @@ interface OscilloscopeState {
|
|||
/** Circular sample buffers keyed by channel id */
|
||||
samples: Record<string, OscSample[]>;
|
||||
|
||||
// ── Trigger ───────────────────────────────────────────────────────────────
|
||||
triggerMode: TriggerMode;
|
||||
/** Channel that triggers the scope. `null` = first channel; reset on remove. */
|
||||
triggerChannelId: string | null;
|
||||
triggerEdge: TriggerEdge;
|
||||
/**
|
||||
* Fraction (0..1) of the visible window where the trigger event lands.
|
||||
* 0 = trigger at the left edge (all post-trigger samples)
|
||||
* 0.5 = trigger at the centre (default, equal pre and post)
|
||||
* 1 = trigger at the right edge (all pre-trigger samples)
|
||||
*/
|
||||
triggerPosition: number;
|
||||
/** Simulator time of the most-recently latched trigger, or `null` when
|
||||
* the scope is armed and waiting for an edge. */
|
||||
triggeredAtMs: number | null;
|
||||
/** Status surface for the UI badge. */
|
||||
triggerStatus: TriggerStatus;
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
toggleOscilloscope: () => void;
|
||||
|
|
@ -61,6 +104,41 @@ interface OscilloscopeState {
|
|||
/** Push one sample; drops the oldest if the buffer is full */
|
||||
pushSample: (channelId: string, timeMs: number, state: boolean) => void;
|
||||
clearSamples: () => void;
|
||||
|
||||
setTriggerMode: (mode: TriggerMode) => void;
|
||||
setTriggerChannel: (channelId: string | null) => void;
|
||||
setTriggerEdge: (edge: TriggerEdge) => void;
|
||||
setTriggerPosition: (pos: number) => void;
|
||||
/** Reset the trigger (re-arm for single-shot, clear "triggered" status). */
|
||||
rearmTrigger: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a new sample's state vs. the previous state constitutes a
|
||||
* triggering edge under the configured edge mode. Exported so the
|
||||
* trigger logic can be unit-tested in isolation.
|
||||
*/
|
||||
export function matchesTriggerEdge(prevState: boolean, newState: boolean, edge: TriggerEdge): boolean {
|
||||
if (prevState === newState) return false;
|
||||
if (edge === 'either') return true;
|
||||
if (edge === 'rising' && !prevState && newState) return true;
|
||||
if (edge === 'falling' && prevState && !newState) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the channel id the trigger should listen on. If the user
|
||||
* hasn't explicitly picked one (or picked one that's since been removed),
|
||||
* fall back to the first channel — the most common single-channel case.
|
||||
*/
|
||||
function resolveTriggerChannelId(
|
||||
triggerChannelId: string | null,
|
||||
channels: OscChannel[],
|
||||
): string | null {
|
||||
if (triggerChannelId && channels.some((c) => c.id === triggerChannelId)) {
|
||||
return triggerChannelId;
|
||||
}
|
||||
return channels[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export const useOscilloscopeStore = create<OscilloscopeState>((set, get) => ({
|
||||
|
|
@ -70,6 +148,13 @@ export const useOscilloscopeStore = create<OscilloscopeState>((set, get) => ({
|
|||
channels: [],
|
||||
samples: {},
|
||||
|
||||
triggerMode: 'auto',
|
||||
triggerChannelId: null,
|
||||
triggerEdge: 'rising',
|
||||
triggerPosition: 0.5,
|
||||
triggeredAtMs: null,
|
||||
triggerStatus: 'idle',
|
||||
|
||||
toggleOscilloscope: () => set((s) => ({ open: !s.open })),
|
||||
|
||||
setCapturing: (running) => set({ running }),
|
||||
|
|
@ -93,23 +178,69 @@ export const useOscilloscopeStore = create<OscilloscopeState>((set, get) => ({
|
|||
removeChannel: (id) => {
|
||||
set((s) => {
|
||||
const { [id]: _removed, ...rest } = s.samples;
|
||||
// If the removed channel was the trigger source, fall back to the
|
||||
// first remaining channel via the resolver — keeps the trigger
|
||||
// working without forcing the user to re-pick.
|
||||
const remainingChannels = s.channels.filter((c) => c.id !== id);
|
||||
const nextTriggerCh =
|
||||
s.triggerChannelId === id ? null : s.triggerChannelId;
|
||||
return {
|
||||
channels: s.channels.filter((c) => c.id !== id),
|
||||
channels: remainingChannels,
|
||||
samples: rest,
|
||||
triggerChannelId: nextTriggerCh,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
pushSample: (channelId, timeMs, state) => {
|
||||
if (!get().running) return;
|
||||
set((s) => {
|
||||
const buf = s.samples[channelId];
|
||||
if (!buf) return s;
|
||||
const s = get();
|
||||
if (!s.running) return;
|
||||
|
||||
const next = buf.slice();
|
||||
const buf = s.samples[channelId];
|
||||
if (!buf) return;
|
||||
|
||||
// Trigger detection happens BEFORE we mutate the buffer so we can
|
||||
// peek at the previous state on the trigger channel. Auto mode
|
||||
// skips this entirely — the scope free-runs.
|
||||
let nextTriggeredAtMs = s.triggeredAtMs;
|
||||
let nextRunning = s.running;
|
||||
let nextStatus = s.triggerStatus;
|
||||
|
||||
if (s.triggerMode !== 'auto') {
|
||||
const triggerChId = resolveTriggerChannelId(s.triggerChannelId, s.channels);
|
||||
if (triggerChId === channelId) {
|
||||
const triggerBuf = s.samples[triggerChId];
|
||||
if (triggerBuf && triggerBuf.length > 0) {
|
||||
const prevState = triggerBuf[triggerBuf.length - 1].state;
|
||||
const single = s.triggerMode === 'single';
|
||||
// Single-shot: once we've captured (status === 'captured'),
|
||||
// ignore further edges until the user explicitly re-arms.
|
||||
const captureLocked = single && s.triggerStatus === 'captured';
|
||||
if (!captureLocked && matchesTriggerEdge(prevState, state, s.triggerEdge)) {
|
||||
nextTriggeredAtMs = timeMs;
|
||||
if (single) {
|
||||
nextRunning = false;
|
||||
nextStatus = 'captured';
|
||||
} else {
|
||||
nextStatus = 'triggered';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set((cur) => {
|
||||
const curBuf = cur.samples[channelId];
|
||||
if (!curBuf) return cur;
|
||||
const next = curBuf.slice();
|
||||
if (next.length >= MAX_SAMPLES) next.shift();
|
||||
next.push({ timeMs, state });
|
||||
return { samples: { ...s.samples, [channelId]: next } };
|
||||
return {
|
||||
samples: { ...cur.samples, [channelId]: next },
|
||||
...(nextTriggeredAtMs !== cur.triggeredAtMs ? { triggeredAtMs: nextTriggeredAtMs } : {}),
|
||||
...(nextRunning !== cur.running ? { running: nextRunning } : {}),
|
||||
...(nextStatus !== cur.triggerStatus ? { triggerStatus: nextStatus } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -119,6 +250,53 @@ export const useOscilloscopeStore = create<OscilloscopeState>((set, get) => ({
|
|||
channels.forEach((c) => {
|
||||
fresh[c.id] = [];
|
||||
});
|
||||
set({ samples: fresh });
|
||||
set({
|
||||
samples: fresh,
|
||||
triggeredAtMs: null,
|
||||
// Clearing samples re-arms whatever mode we're in.
|
||||
triggerStatus: get().triggerMode === 'auto' ? 'idle' : 'armed',
|
||||
});
|
||||
},
|
||||
|
||||
setTriggerMode: (mode) => {
|
||||
set((s) => ({
|
||||
triggerMode: mode,
|
||||
// Switching modes is implicitly a re-arm — drop any latched trigger
|
||||
// and set the status appropriate to the new mode.
|
||||
triggeredAtMs: null,
|
||||
triggerStatus: mode === 'auto' ? 'idle' : 'armed',
|
||||
// If switching to a capture mode while paused, resume capture so
|
||||
// the next edge can land. The user can pause manually after if
|
||||
// they want.
|
||||
running: mode === 'auto' ? s.running : true,
|
||||
}));
|
||||
},
|
||||
|
||||
setTriggerChannel: (channelId) => {
|
||||
set({
|
||||
triggerChannelId: channelId,
|
||||
triggeredAtMs: null,
|
||||
triggerStatus: get().triggerMode === 'auto' ? 'idle' : 'armed',
|
||||
});
|
||||
},
|
||||
|
||||
setTriggerEdge: (edge) => {
|
||||
set({
|
||||
triggerEdge: edge,
|
||||
triggeredAtMs: null,
|
||||
triggerStatus: get().triggerMode === 'auto' ? 'idle' : 'armed',
|
||||
});
|
||||
},
|
||||
|
||||
setTriggerPosition: (pos) => {
|
||||
set({ triggerPosition: Math.max(0, Math.min(1, pos)) });
|
||||
},
|
||||
|
||||
rearmTrigger: () => {
|
||||
set((s) => ({
|
||||
triggeredAtMs: null,
|
||||
triggerStatus: s.triggerMode === 'auto' ? 'idle' : 'armed',
|
||||
running: true,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue