0.9.0 - MAX7219 Display (max7219.js v1.0.0, engine in max7219-lib.js): - 8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus (hardware SPI stays invisible, M0). Level-driven shift engine samples DIN on the CLK rising edge, latches MSB-first 16-bit words on the LOAD rising edge; misaligned frames are dropped and counted in `dropped`, never garbled. Datasheet register model: Code B decode (independent DP), intensity PWM, scan limit, shutdown/display test, power-on defaults. 8x8 matrix and 7-segment Views auto-selected from the decode-mode register; daisy chains share the three pads (sharedBus) and latch their chain position. Lease sizing measured against the tightest real firmware (gun 0.1.0, delay-free pulses ~20 instructions apart): fine at the scheduler floor 20, arm 60. Smoke S12 boots the gun 0.1.0 release (machine-local, skips when absent) with zero drops; examples/esp32s3-gun.json wires it. 0.10.0 - Logic analyzer (logic-analyzer.js v1.0.0, logic in la-lib.js): - Multi-channel GPIO waveform capture: up to 8 pads, channel+edge or immediate trigger, 1-1000 ms guest-time window, auto re-arm, Z shown when the pad is released; rides the M4 scheduler (arm 1000, fine lease per resolution preset 20/100/300, renewed per edge); 20 000-edge buffer freezes with an overflow flag. sharedBus makes it a pure tap on other bus devices' pads (smoke S13 arms it on the P4 soft-I2C bus: SCL 1950 / SDA 352 alternating edges in a 40 ms window, frozen afterwards). - Floating analyzer window: draggable, resizable, non-modal popup (position fixed, no backdrop - the page underneath keeps every pointer event; geometry clamped + persisted). One drawWaveform painter feeds the popup, the details mini-preview and the row icon (AGENT 3.8); channels picked from a GPIO grid, occupied pins greyed. - Saleae-style plot interactions: wheel zoom anchored at the cursor (1 ns floor), left/middle-drag pan, double-click/F fit; hover measurement shades the pulse and reads level + width + period + frequency + PWM duty (rise-to-rise cycle); A/B cursors with delta-t and 1/delta-t; lane selection + </> buttons and arrow keys jump view-centre onto the previous/next edge; lane gutter shows the right-edge level; a fresh capture re-fits the view and drops cursors (capId). - Device API v1 additions (backwards compatible): ctx.pinLevel(pin) snapshot read of the driven level; ViewHost.pinInfo() for multi-pin pickers; View.destroy() teardown on row removal / list rebuild so Views owning DOM outside their row (floating windows) clean it up. - Tests: la-lib units (parsePins, levelAt, axis ticks, fit span, pixel transform, zoom/pan view model, pulseAround, pwmOf, fmtHz/levelChar), the capture state machine via fake ctx (immediate/triggered starts, direction filtering incl. Z-as-falling, window end, stop/clear/auto, buffer overflow, onEmuReady reset, capId), sigbus.pinLevel against a fake emulator; smoke S13 + S1 grows to 12 devices; examples/esp32c3-probe.json wires the analyzer on the I2C pair. - Docs (both languages): CHANGELOG 0.9.0 + 0.10.0 + device version table, ROADMAP shipped sections, DEVICE-API instrument section + new ctx/host/ View members, ARCHITECTURE GUI checklist + floating-window note, README capability table + tree, AGENT rule 3.11 + GUI-smoke bug-table rows; version synced to 0.10.0 in all three places.
18 KiB
English | 简体中文
esp-emu-peris peripheral interface spec (v1)
A peripheral = one ES module under peris/devices/ + one line in
manifest.json. Nothing else in the project needs to change.
Language: plugins are plain JavaScript (drop-in, no build). The framework is compiled TypeScript; the full contract types live in
peris/core/types.d.ts(DeviceMeta/DeviceLike/DeviceCtx/PinStateetc.) and can be referenced from JSDoc for editor hints. The registry validates metadata at load time (a missingtypeId/title/pins()/defaultConfigerrors immediately); the unit tests intests/unit/devices.test.mjsare the reference for electrical semantics.
Module exports
export class Device { // runs in the worker, talks to the emulator
static typeId = 'led'; // unique, stable, kebab-case
static version = '1.0.0'; // plugin's own version (row header; logged in docs/CHANGELOG.md)
static title = 'LED'; // UI display name
static category = 'basic'; // Add-menu grouping
static description = '...'; // one-line explanation (tooltip)
static defaultConfig = {...}; // defaults for new instances
static sharedBus = true; // optional (M4): pins may overlap with
// OTHER sharedBus devices (bus wiring)
static configSchema = [ // generic config editor fields (in the
// expanded details, one per line)
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'color', label: 'Colour', type: 'color' },
{ key: 'name', label: 'Label', type: 'text' },
// also supported: number(min/max/step), select(options:[{value,label}]), checkbox
// a checkbox may add chip:'pull' + pullWhenChecked:'high'|'low'
// → the device row shows a V/G idle-pull badge (click to flip),
// see "Row UI conventions" below
];
static pins(cfg) { return [cfg.pin]; } // claimed GPIOs (conflict detection)
constructor(ctx, cfg) {} // cfg = { id, type, ...device settings }
onPinState(pin, s) {} // pin-output change callback s={out, oe, cycles}
onBatch() {} // tick after every run_batch (MMIO polling,
// register-level devices — see below)
onCommand(cmd, params) {} // user action sent from the page View
onEmuReady() {} // emulator (re)loaded: re-drive input levels /
// reset cached register state
getState() { return {...}; } // serialisable state pushed to the View (~30fps)
destroy() {} // release / restore pins
}
export class View { // runs on the page, renders the device view
constructor(el, sendCmd, host) {} // el = details body container;
// sendCmd(cmd, params);
// host.updateConfig(patch) requests a config change;
// host.pinInfo?() → { valid, used } pins for multi-pin pickers
rowIcon(el) {} // optional: compact interactive row icon (~24px), see below
render(state) {} // state comes from Device.getState(); drives icon and body together
setConfig(cfg) {} // optional: called after config edits (e.g. LED colour)
destroy() {} // optional: row removed / list rebuilt — Views owning DOM
// outside their row (floating windows) must clean it up
}
Row UI conventions (0.3.0+)
The peripherals pane is its own column in the three-pane layout (console | peripherals | debug); each device occupies one row:
[icon] [name] [GPIO#] [V/G] [▸ expand] [× remove] [⠿ drag]
- Interactive icon (
View.rowIcon(el)): a clickable mini widget whose semantics the plugin defines — LED opens a colour picker on click (writes back viahost.updateConfig({color})), buttons send press/release while held, the toggle switch flips on click.render(state)refreshes the icon state (lit / pressed / ON) as well. Without rowIcon a placeholder letter chip is shown. Call order: the panel always callsrowIconbeforesetConfig, so config-driven styling covers the icon; the icon and the details body are two visuals of the same state — plugins must share one setter/render (see led.jssetColour()), never a separate styling path for the icon. - Name / GPIO box / V·G badge: rendered generically by the panel, edited
in place on click (name and GPIO via inline editors, the V/G badge flips
directly). The GPIO box maps to the schema's first
type:'pin'field; the V/G badge maps to the checkbox markedchip:'pull'— when checked it shows V (pull-up to VCC) forpullWhenChecked:'high'(the default), otherwise G; unchecked is the opposite. - Expanded details: the device widget body (
el, the constructor's first argument) + the full config editor (one property per line). Advanced features that do not fit in the row live here. - Drag sorting ⠿: dragging is armed only while the grip is held; on release the devices order is written back (no worker rebuild).
ctx (worker-side device context)
| member | description |
|---|---|
ctx.id / ctx.chip / ctx.gpioCount |
device id, chip model, GPIO count |
ctx.mmio |
register address tables for ctx.chip (LEDC/RMT/GPIO matrix), or null when the chip has no table yet — register-level devices must idle then |
ctx.setPinInput(pin, high) |
drive a pin's input level (firmware reads it; raises GPIO interrupts) |
ctx.subscribe(pins) / ctx.unsubscribe() |
subscribe to pin-output changes (onPinState) |
ctx.readMem(addr, len) / ctx.readU32(addr) |
bus/MMIO reads (LEDC duty, RMT RAM, …) |
ctx.pinLevel(pin) |
current driven level straight from gpio_state() — true/false while the guest drives the pad (oe), null when released. One wasm read: snapshot channels sparingly (the logic analyzer samples at capture start), never per batch |
ctx.cycles() |
emulator cycle counter |
ctx.cyclesPerUs |
emulator clock in cycles per µs (edge timing → time units) |
ctx.armSampling(batch) |
M4 scheduler: standing run_batch size while this device watches an idle bus |
ctx.fineSampling(batch, forCycles) |
M4 scheduler: fine batches for the next forCycles cycles (renew while a transfer is in flight); auto-expires on CPU-sleep fast-forward |
ctx.releaseSampling() |
drop this device's scheduler leases (destroy) |
ctx.markDirty() |
request getState() to be pushed to the page soon |
ctx.log(text, level?) |
print a [Peris] line on the UART console |
Register-level devices (M2+)
Signals routed through the GPIO matrix (LEDC, RMT) never appear in
onPinState (M0 Q2a/Q3a) — a device for such a peripheral polls MMIO
instead:
- implement
onBatch()(called after everyrun_batch, after the pin sample): read registers viactx.readU32+ctx.mmioaddresses, andctx.markDirty()only when the derived state actually changed (duties are quantised to 1/1000 in ledc-lib so sub-visible changes stay quiet); - reset any cached register/wiring state in
onEmuReady()— a fresh emulator instance reads all zeros; static pins(cfg)returns[]when the wiring is firmware-side routing (LEDC devices claim no GPIO; two devices on one channel merely mirror).
The M2 plugins keep every shared semantic in peris/devices/ledc-lib.js
(channel parsing, GPIO_FUNCn_OUT_SEL_CFG wiring scan, DUTY_R/timer-DUTY_RES
duty decode, canvas bulb painter + level animator): helper modules like it
live in peris/devices/ outside the manifest (only manifest-listed
modules load as devices) and are imported relatively by the plugins and the
unit tests — never fork a second duty decode or wiring scan.
Two pitfalls the helpers already handle: the generic editor's <select>
hands values back as strings (parseChannel accepts '3' and
Number(null) === 0 is guarded), and idf.py merge-bin's -o is relative
to the build dir (tests/firmware/merge.ps1).
The M3 WS2812 plugin follows the same pattern with
peris/devices/ws2812-lib.js (RMT channel-RAM read, CHnSTATUS TX-busy
gate, wiring scan, frame decode with reset resync + leading-zero
realignment, LED-grid geometry/painters; channel parsing and the animator
are imported from ledc-lib — same semantics, one implementation). Its
measured constraint, surfaced in the UI: a 48-word channel RAM retains only
the last 46 bits of a frame, so long strips decode only their tail LEDs
(see FEASIBILITY.md §"Key technical details" 3–4).
Bit-bang protocol devices (M4)
CPU-driven (bit-banged) waveforms ARE visible in onPinState — but
gpio_state() is sampled once per run_batch, so every edge only survives
when the batch is small compared with the signal's half period. Devices
lease batch sizes through the scheduler (src/core/scheduler.ts):
ctx.armSampling(~1000)once — a standing watch batch while the bus is idle (cheap, resolves a START on buses down to ~20 kHz);ctx.fineSampling(~300, forCycles)on every edge — renews a fine window for the rest of the transfer; it expires on its cycle deadline or when the emulator fast-forwards through a CPU sleep (nothing sampleable there).
Timings inside the guest are NOT wall-accurate (esp_rom_delay_us runs at
~50% of nominal — FEASIBILITY.md §"M4 measurements"), so decoders must be
level- or ratio-based, never absolute-µs. The M4 plugins follow both rules:
peris/devices/bitbang-i2c-slave.js— 24C02 EEPROM / BME280 models on two bit-banged GPIOs (SCL/SDA;sharedBuslets several slaves share the pair, and the runtime wired-ANDs shared pads' input drives). Engine, register models and bit-exact Bosch compensation live inperis/devices/i2c-lib.js. The slave answers via the SDA input path — visible to the master exactly while it releases the pad (open-drain idiom); push-pull masters that never release cannot be answered, and clock stretching is not modelled.peris/devices/ir-nec.js— NEC capture from a bit-banged GPIO (edges → burst envelope in fine windows, flush after ~25 ms quiet) or from an RMT TX channel (34-word frames fit the post-TX channel RAM; the emulator leavesAPB_MEM_WADDRat 0 so mid-TX stitching is impossible). Ratio- based decode;source: autoprefers RMT routing. Envelope + decode inperis/devices/nec-lib.js.peris/devices/max7219.js— MAX7219 display driver on a bit-banged DIN/CLK/LOAD bus (write-only: no input path needed). The shift engine samples DIN on CLK rising edges, latches 16-bit words on the LOAD rising edge, and drops misaligned frames (counted indropped) instead of garbling the display. Register model + Code B font + painters inperis/devices/max7219-lib.js; daisy chains share the pads (sharedBus,chain/pospick each part's word). Lease sizing is at the scheduler floor (fine 20 / arm 60): measured against delay-free back-to-backgpio_set_levelpulses (edges ~20 instructions apart), the tightest real firmware found — delay-paced bit-bang is covered with much wider margins.
Instrument devices (0.10.0)
peris/devices/logic-analyzer.js— multi-channel GPIO waveform capture (up to 8 pads, one trigger: channel + rising/falling/any edge or Immediate, guest-time window, auto re-arm). Readout math, the waveform painter and the floating window live inperis/devices/la-lib.js; the painter is one implementation shared by the popup, the details mini-preview and the row icon. Sampling rides the M4 scheduler like the bit-bang devices (arm lease while waiting for the trigger, fine lease renewed per edge while recording — theresolutionconfig picks the fine batch: 20/100/300 instructions). Levels are 1/0 driven and -1 (Z) when the guest releases the pad; the analyzer never drives anything, so it declaressharedBusand legally taps pads other bus devices own. Capture data is pushed in full only when the buffer freezes (done); run pushes carry progress + live per-channel edge counts.- Floating windows (
la-lib.makeFloatingWindow): big UIs open a draggable, resizable, non-modal popup —position: fixed, no backdrop, so the page underneath keeps every pointer event. Geometry is clamped to the viewport and persisted to localStorage. Because such a window lives ondocument.body(outside the device row), its View must implementdestroy()to remove it when the row goes away — the panel callsdestroy()on row removal and on list rebuilds. Multi-pin pickers inside a View grey out occupied GPIOs throughhost.pinInfo()(the same candidate-aware set the generic editor uses). - Saleae-style plot model (also in
la-lib): a zoomable view{t0, spanUs}withviewXform/clampView/zoomAt(wheel zoom anchored at the cursor, drag pan, fit on double-click), hover measurement (pulseAroundfor the pulse under the cursor,pwmOffor period/frequency/duty from consecutive rising edges), A/B cursors with Δt + 1/Δt and edge jumping (levelAt,fmtHz). The device bumpscapIdon every capture start so Views reset zoom/cursors when fresh data arrives.
Message protocol (page ↔ worker)
Added on top of the stock worker protocol (see peris/protocol.js):
- page→worker:
peris_config {config}(apply a config in full),peris_command {id, cmd, params} - worker→page:
peris_ready {ok, errors, devices},peris_state {id, state},peris_log {level, text}
Config files
{
"perisVersion": 1,
"chip": "esp32c3",
"firmware": { "name": "merged-binary.bin", "size": 250048 },
"devices": [ { "id": "led-1", "type": "led", "pin": 8, "color": "#ff4d4d", "name": "Red" } ]
}
- Autosaved to localStorage (
peris.config.v1, includingchip, which is auto-selected when the page loads); the save/open icons in the panel head export/import.json. - Export file name: set in the panel-head input (it doubles as the project
name; empty = auto name
peris-{chip}-{date}.json), remembered in localStorage; naming logic inperis-panel.ts exportFileName(). firmwareis an informational field: records the name/size(/disk path) of the most recently loaded firmware at export time (the path exists only when the host exposes the non-standardFile.path— the browser security model provides no full path). Import ignores it and never auto-loads firmware;chipselects the matching chip when the localStorage config is saved/restored, but in an imported JSON it is informational only (it does not change the current selection).- v1 rule: each GPIO can be claimed by at most one device (either direction); conflicts surface in the yellow bar at the top of the pane.
Device development notes (from M0 measurements; see FEASIBILITY.md)
- Pin outputs: firmware GPIO writes are visible in
onPinState(out+oe); signals routed through the GPIO matrix (LEDC/RMT) never appear in the pin registers — you must read MMIO (LEDC duty registers, RMT channel RAM) ononBatch(); the address tables arrive viactx.mmio(sources:src/core/mmio/). - Pin inputs:
ctx.setPinInputgenuinely reaches the firmware (reads + interrupts); button-type devices must re-drive their level inonEmuReady(levels reset when the emulator reloads). - Time: emulator time fast-forwards during
vTaskDelay; do not attempt pin-level waveform capture inside CPU sleep windows. Bit-banged waveforms while the CPU is active can be sampled between run_batch calls, but keep it modest (~99 MIPS with fine batches, 5000+ MIPS with coarse ones). - State pushes are throttled to ~30fps; the object returned by
getState()is postMessaged — keep it small and serialisable. - Device actions from the View always go through
sendCmd(cmd, params); config changes (e.g. picking a colour from the row icon) go throughhost.updateConfig(patch)— the single source of truth for config is the page's PerisPanel; the worker side must never cache/mutate it. Never touch the emulator from the page side.
Examples
peris/devices/led.js— output device (sigbus subscription)peris/devices/button.js— input device (setPinInput + onEmuReady)peris/devices/touch-button.js— inherits button's Device, different Viewperis/devices/toggle-switch.js— latching input deviceperis/devices/pwm-led.js/rgb-led.js— register-level devices (onBatch + ctx.mmio + ledc-lib wiring scan/duty decode, canvas interpolation View)peris/devices/ws2812.js— register-level device on the RMT channel RAM (onBatch + ctx.mmio + ws2812-lib frame decode, strip/ring/matrix Views)peris/devices/ledc-lib.js— shared helper module (not a device)peris/devices/ws2812-lib.js— shared helper module (not a device)peris/devices/bitbang-i2c-slave.js— bit-bang I2C slave (i2c-lib.js engine)peris/devices/ir-nec.js— NEC receiver, gpio + RMT capture (nec-lib.js)peris/devices/max7219.js— MAX7219 display on a bit-banged 3-wire bus (max7219-lib.js engine, Code B font, matrix/7-seg painters)peris/devices/logic-analyzer.js— multi-GPIO waveform capture with a floating analyzer window (la-lib.js readout math + painter + window)peris/devices/i2c-lib.js/peris/devices/nec-lib.js/peris/devices/max7219-lib.js/peris/devices/la-lib.js— shared helper modules (not devices)