Files
darceye 7f5f1aaa88 Logic analyzer probe freedoms: passive pads, any-GPIO trigger, 1 h window (0.11.0)
- passive probes (new `static passive = true` device metadata): a pure
  listener never drives an input and claims no pad, so it may clamp onto
  ANY GPIO - including pads other devices own (an LED, an I2C pair, a
  MAX7219 bus) and other analyzers. The runtime's pin-conflict check and
  the wired-AND arbitration skip passive pads; the panel's usedPins
  ignores them, so other devices' pickers never show them as "in use";
  the analyzer's channel grid never disables a GPIO (pads owned elsewhere
  get a dashed "shared" hint). Replaces the analyzer's sharedBus flag.
- trigger on any GPIO: trigCh (channel index) becomes trigPin (GPIO
  number) - the trigger pad gets its own subscription and need not be an
  analysed channel (off-channel triggers land in no lane, channel levels
  still snapshot at capture start). The window dropdown lists every valid
  GPIO. Tooltip documents the gpio_state visibility caveat: a trigger
  fires only when the FIRMWARE drives the pad - pure-input pads (driven
  by our button devices) never toggle the output registers.
- window cap lifted 1 s -> 1 h of guest time (was a conservative guard;
  dense signals still freeze earlier at the 20 000-edge buffer with the
  buffer-full note - the natural limit, since Saleae streams into host
  RAM while we capture into a fixed buffer).
- tests: runtime-level passive sharing (probe onto an LED pad + I2C pair
  + second probe, zero conflicts), off-channel trigger, 1 h clamp,
  trigger cases migrated to trigPin; device bumped to 1.1.0; GUI checked
  (shared chip clickable, 23 trigger options, 5000 ms window accepted).
- docs (both languages): CHANGELOG 0.11.0 + device version table,
  DEVICE-API passive metadata + instrument section, README capability
  row, ROADMAP probe-freedoms bullet, AGENT rule 3.11; version synced to
  0.11.0.
2026-11-02 10:58:17 +08:00

18 KiB
Raw Permalink Blame History

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/PinState etc.) and can be referenced from JSDoc for editor hints. The registry validates metadata at load time (a missing typeId/title/pins()/ defaultConfig errors immediately); the unit tests in tests/unit/devices.test.mjs are 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 passive = true;            // optional (0.11): pure listener — never
                                      //   drives an input, claims no pad; may
                                      //   overlap with ANY device, and other
                                      //   devices' pin pickers ignore its pads
    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 via host.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 calls rowIcon before setConfig, 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.js setColour()), 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 marked chip:'pull' — when checked it shows V (pull-up to VCC) for pullWhenChecked:'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 every run_batch, after the pin sample): read registers via ctx.readU32 + ctx.mmio addresses, and ctx.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; sharedBus lets several slaves share the pair, and the runtime wired-ANDs shared pads' input drives). Engine, register models and bit-exact Bosch compensation live in peris/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 leaves APB_MEM_WADDR at 0 so mid-TX stitching is impossible). Ratio- based decode; source: auto prefers RMT routing. Envelope + decode in peris/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 in dropped) instead of garbling the display. Register model + Code B font + painters in peris/devices/max7219-lib.js; daisy chains share the pads (sharedBus, chain/pos pick each part's word). Lease sizing is at the scheduler floor (fine 20 / arm 60): measured against delay-free back-to-back gpio_set_level pulses (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: ANY GPIO + rising/falling/any edge or Immediate, guest-time window up to 1 h, auto re-arm). Readout math, the waveform painter and the floating window live in peris/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 — the resolution config 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 declares passive and legally taps pads ANY device owns (the trigger pad gets its own subscription and need not be a channel; it fires only when the firmware drives the pad — gpio_state never shows pure-input pads). 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 on document.body (outside the device row), its View must implement destroy() to remove it when the row goes away — the panel calls destroy() on row removal and on list rebuilds. Multi-pin pickers inside a View grey out occupied GPIOs through host.pinInfo() (the same candidate-aware set the generic editor uses).
  • Saleae-style plot model (also in la-lib): a zoomable view {t0, spanUs} with viewXform/clampView/zoomAt (wheel zoom anchored at the cursor, drag pan, fit on double-click), hover measurement (pulseAround for the pulse under the cursor, pwmOf for period/frequency/duty from consecutive rising edges), A/B cursors with Δt + 1/Δt and edge jumping (levelAt, fmtHz). The device bumps capId on 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, including chip, 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 in peris-panel.ts exportFileName().
  • firmware is 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-standard File.path — the browser security model provides no full path). Import ignores it and never auto-loads firmware; chip selects 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)

  1. 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) on onBatch(); the address tables arrive via ctx.mmio (sources: src/core/mmio/).
  2. Pin inputs: ctx.setPinInput genuinely reaches the firmware (reads + interrupts); button-type devices must re-drive their level in onEmuReady (levels reset when the emulator reloads).
  3. 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).
  4. State pushes are throttled to ~30fps; the object returned by getState() is postMessaged — keep it small and serialisable.
  5. Device actions from the View always go through sendCmd(cmd, params); config changes (e.g. picking a colour from the row icon) go through host.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 View
  • peris/devices/toggle-switch.js — latching input device
  • peris/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)