diff --git a/frontend/src/__tests__/oscilloscope-trigger.test.ts b/frontend/src/__tests__/oscilloscope-trigger.test.ts new file mode 100644 index 00000000..4af3b4dc --- /dev/null +++ b/frontend/src/__tests__/oscilloscope-trigger.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/simulator/Oscilloscope.css b/frontend/src/components/simulator/Oscilloscope.css index d4b98a75..27b8a02a 100644 --- a/frontend/src/components/simulator/Oscilloscope.css +++ b/frontend/src/components/simulator/Oscilloscope.css @@ -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; } +} diff --git a/frontend/src/components/simulator/Oscilloscope.tsx b/frontend/src/components/simulator/Oscilloscope.tsx index 44b72ee9..4a3a73cf 100644 --- a/frontend/src/components/simulator/Oscilloscope.tsx +++ b/frontend/src/components/simulator/Oscilloscope.tsx @@ -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 = ({ @@ -205,6 +232,7 @@ const ChannelCanvas: React.FC = ({ samples, windowEndMs, windowMs, + triggerXFrac, }) => { const canvasRef = useRef(null); const wrapRef = useRef(null); @@ -221,8 +249,8 @@ const ChannelCanvas: React.FC = ({ 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 (
@@ -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')}`} + {/* ── 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. */} + Trigger + + + {triggerMode !== 'auto' && ( + <> + + + + + + {triggerStatus === 'armed' && 'Armed'} + {triggerStatus === 'triggered' && 'Triggered'} + {triggerStatus === 'captured' && 'Captured'} + {triggerStatus === 'idle' && 'Idle'} + + + {triggerMode === 'single' && triggerStatus === 'captured' && ( + + )} + + )} + {/* Clear */}
))} diff --git a/frontend/src/store/useOscilloscopeStore.ts b/frontend/src/store/useOscilloscopeStore.ts index 0bff68d6..c4b60879 100644 --- a/frontend/src/store/useOscilloscopeStore.ts +++ b/frontend/src/store/useOscilloscopeStore.ts @@ -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; + // ── 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((set, get) => ({ @@ -70,6 +148,13 @@ export const useOscilloscopeStore = create((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((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((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, + })); }, }));