Single owner of all physical hardware on the lab rig. Exposes a REST + WebSocket API consumed by View (and optionally Develop, when Control needs to pull build artifacts).
- One process per rig. Serial devices have exclusive open semantics.
- All hardware calls are serialized per device through a
DeviceWorker(single-thread executor). No exception. - No pulse fires unless
ArmGate.require_armed()succeeds. This includes every code path in every router, the orchestrator, and any future feature. - All pulse-emitting paths acquire a
RateLimitertoken. Default 10/sec. - The JSONL logbook is canonical. The SQLite index is a derivable mirror, never the source of truth.
- State mutations broadcast. Every change to
AppStateis followed by aBroadcaster.broadcast()call.
ArmGate (hold-to-arm + auto-disarm timer), RateLimiter (sliding window), StopFlag (cooperative stop). Raise Disarmed / RateLimited. Carried forward from old-em-setup/glitchweb/backend/app/safety.py.
DeviceWorker wraps a ThreadPoolExecutor(max_workers=1) per device. WorkerRegistry holds one per device name. Carried forward from old-em-setup/glitchweb/backend/app/workers.py. Future split: separate read-queue from write-queue (status polls compete with writes today).
AppState is the single in-memory snapshot: device statuses, position, counters, scan progress, arm state. Broadcaster distributes WS events with drop-oldest backpressure. Carry-forward from old-em-setup/glitchweb/backend/app/state.py.
JSONL append-only writer carried from old code. New: a SQLite mirror updated on every append for the columns required by heatmap and replay queries.
perform_attempt() + run_scan() from old code. Extended with sweep dimensions (delay_us × pulse_width_ns × voltage_v) on top of the XYZ grid. Adds replay(run_id) that rehydrates an AttemptResult and re-executes the exact attempt.
VID/PID + manufacturer/serial-prefix matching. Carry-forward from old-em-setup/glitchweb/backend/app/ports.py.
JSON config under ~/.config/emfi-control/config.json with hot reload. Schema mirrors old-em-setup/glitchweb/backend/app/config.py shape; auto-created on first start.
base.py— common interface (connect,disconnect,status)chipshover.py— wrapschipshoverpip package; XY logical↔machine coordinate transformchipshouter.py— wrapschipshouterpip package; idempotent arm/disarm; capturesfaults_latched(decoded names + raw bitmask) intolast_faultbefore clearing;set_pulse_width(ns)sets the EMFI HV pulse width (the parameter the campaign sweep'spulse_width_nscontrols) and reads it backscaffold.py— wrapsdonjon-scaffold; D0/D1/D2/D3 pin map, trigger-mode selector. In hardware trigger modes,pgen0provides only the A0 trigger pulse: its width is a fixed ~200 ns constant (set once at campaign start, not swept) and its delay is the per-attempt glitch delayxds110.py— subprocess wrappers:flash(elf_path)— invokes TIdsliteoruniflashCLI; fast for the "build → flash → campaign" loopattach_debugger(elf_path)— spawns OpenOCD on configured gdb/telnet ports for SW+HW combined attack analysis
GET /devices list discovered serial devices
GET /devices/chipshouter/faults last latched + current ChipSHOUTER faults
POST /devices/{name}/connect open serial port
POST /devices/{name}/disconnect
GET /config axes + ports + safety config (read-only)
POST /motion/move_abs { x, y, z } logical
POST /motion/move_rel { axis, distance }
POST /motion/home
POST /motion/set_origin
POST /motion/set_top_right { x, y }
POST /shouter/arm
POST /shouter/disarm
POST /shouter/pulse { voltage, pulse_width_ns, repeat }
POST /shouter/config { voltage, pulse_width_ns, mute, ... }
POST /target/flash { build_sha, elf_url }
POST /target/reset
POST /target/debug_attach { build_sha, elf_url, gdb_port }
POST /campaigns start a campaign (Campaign model)
GET /campaigns list campaigns
GET /campaigns/{id} status + summary
POST /campaigns/{id}/stop
GET /runs?campaign=...&since=...&outcome=...&limit=...
GET /runs/{id}
GET /heatmap?campaign=...&z=...&outcome=... per-cell outcome counts
POST /replay/{run_id} re-execute that exact attempt
POST /arm
POST /disarm
GET /arm_state
All responses use Pydantic models from emfi_protocol. Error responses use FastAPI's default { "detail": "..." }.
GET /heatmap groups attempts by (x, y) and returns per-outcome counts so View can color-code each cell without re-querying:
[ { "x": 1.0, "y": 2.0,
"counts": { "glitch": 2, "hang": 5, "crash": 0, "nothing": 8 } }, ... ]
outcome filters to a single bucket only when explicitly set; omit it (the default) to include every outcome — so the heatmap is non-empty before the first glitch lands. z narrows to one Z plane.
Motion coordinates have two frames: user logical (what the API and the
position WS topic report, and what the +X/+Y/+Z jog buttons mean) and
machine (raw gantry G-code coordinates). set_origin records the
current machine position as logical (0,0,0); logical coordinates are
offsets from it. On top of that offset, the axes section of
~/.config/emfi-control/config.json corrects for a gantry mounted
differently from the conventional rig:
| Flag | Effect | Set it when… |
|---|---|---|
invert_x |
User +X → machine −X | Pressing +X jogs the stage the wrong way along X |
invert_y |
User +Y → machine −Y | Pressing +Y jogs the stage the wrong way along Y |
invert_z |
User +Z → machine −Z | +Z moves toward the bed instead of away |
swap_xy |
User X drives machine Y and vice versa | The stage is mounted rotated 90° (X/Y transposed) |
Conventional setup (all flags false): origin = bottom-left of the
viewable DUT, +X = right, +Y = up, +Z = away from the bed. Rigs that don't
match should set the flags above.
Transform order (user → machine): invert the user axes first, then map to
machine axes via the optional swap; reads apply the exact inverse. Because
the inversion is applied before position is broadcast, the user never sees
negative coordinates unless they intentionally jog into the negative region
— e.g. a gantry whose physical top-right is machine (−10, −10) reports
logical (+10, +10), so the calibration wizard's top_right stays positive
and the campaign grid math (top_right − origin) doesn't collapse.
Changes take effect on Control restart (the config is read once at adapter
connect). GET /config returns the live axes / ports / safety
sections for display; there is no edit endpoint yet — edit the JSON file.
Server → client envelope: emfi_protocol.WsEvent.
Topics:
position—{ x, y, z, machine_x, machine_y, machine_z }arm—{ armed, seconds_until_auto_disarm }counter— Counters snapshotattempt—AttemptResult(one per attempt completed). Hardware-trigger campaigns also setshouter_pulse_width_ns_actual(the HV pulse width the ChipSHOUTER acknowledged, vs the commandedshouter_pulse_width_ns).device_status— per-deviceDeviceStatus. Forchipshouter, includesfault_names: list[str] | null(decoded names of the most recently latched faults).campaign_progress—{ campaign_id, phase, completed_attempts, total_attempts, current_xyz, current_sweep }.phaseis one ofstarted | running | completed | stopped | failed(with an optionalreasononfailed). Field names mirroremfi_protocol.CampaignStatus.current_xyzis[x, y, z]while running,nullotherwise.state— fullAppState.snapshot()on connecterror—{ detail, campaign_id? }
Client → server actions (kept minimal; prefer REST for commands):
{ id, action: "subscribe", topics: [...] }— optional topic filter{ id, action: "ping" }
~/.local/share/emfi-control/
sessions/
logbook-YYYYMMDD.jsonl
index.sqlite # mirror; schema below
~/.config/emfi-control/
config.json
CREATE TABLE runs (
id TEXT PRIMARY KEY,
session TEXT NOT NULL,
ts TEXT NOT NULL,
campaign_id TEXT,
x REAL, y REAL, z REAL,
delay_us REAL, pulse_width_ns REAL, voltage_v INTEGER,
outcome TEXT NOT NULL,
build_sha TEXT,
target_pc INTEGER
);
CREATE INDEX idx_runs_campaign ON runs(campaign_id);
CREATE INDEX idx_runs_outcome ON runs(outcome);
CREATE INDEX idx_runs_xy ON runs(x, y);Mirror is updated on every logbook.append(). Full rebuild from JSONL is supported via python -m control.tools.reindex.
Documented in old-em-setup/HANDOFF.md. Highest priority during build-out:
Scaffold.bus.ser.close()pokes internal attribute → use scaffold's public close.chipshouter.wait_for_arm()infinite block on HV failure → wrap with timeout + abort.- Status polls share the worker queue with writes → split reader/writer queues per device.
sys.pathinjection for adapters → replaced by this package'sadapters/module.
Pure-Python modules have unit tests with no hardware required:
tests/test_safety.py— ArmGate states, auto-disarm timing, RateLimiter windowtests/test_workers.py— single-threaded serialization, future propagationtests/test_state.py— counter math, snapshot stabilitytests/test_logbook.py— JSONL roundtrip, since/outcome filtering, SQLite index paritytests/test_orchestrator.py— fake adapters; exercise classification + grid sweep math
Hardware-touching tests are marked @pytest.mark.hw and skipped by default.