**English** | [简体中文](DEVICE-API.zh-cn.md) # 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 ```js 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 `