Files
esp-emu-peris/peris/runtime.js
T
darceye a9d26722d7 M0+M1: feasibility probe and peripherals framework
M0 (docs/FEASIBILITY.md, verified against esp-emulator wasm v0.51.0):
- GPIO outputs observable via gpio_state(); gpio_set_input() drives pads
  and raises guest interrupts (button-class peripherals work)
- LEDC PWM and RMT waveforms are NOT visible in the pad registers; duty
  is readable from LEDC registers and WS2812 frames decode from RMT
  channel RAM (per-chip MMIO map in peris/mmio/)
- Node probe + ESP-IDF test firmware (gpio/ledc/rmt three-phase loop)

M1 peripherals framework:
- esp-emu-peris.html entry (stock layout + Peripherals/Debug tabs),
  peris/worker.js (stock worker clone + per-batch pin sampling hooks)
- device plugin API v1 (docs/DEVICE-API.md) with manifest registry:
  add a peripheral = one module + one manifest line
- devices: led, button, touch-button, toggle-switch; wiring config
  import/export + localStorage autosave; pin-conflict reporting
- serve.py merged dev server, sync_www.py publisher (www only gains
  files, stock ones untouched), xterm vendored locally (no CDN)
- tests/m1_smoke.mjs integration smoke (config/LED state/button ISR/
  toggle/conflict/unknown-type) - all green
2026-10-29 19:22:45 +08:00

154 lines
5.8 KiB
JavaScript

/*
* Peripherals runtime (worker side): owns device instances, wires them to
* the emulator (sigbus + MMIO reads + input drive), and pushes throttled
* state updates to the page.
*/
import { Sigbus } from './sigbus.js';
import { loadRegistry } from './registry.js';
import { PERIS_READY, PERIS_STATE, PERIS_LOG } from './protocol.js';
import { gpioCount, GPIO_ABSENT } from './chips.js';
const STATE_FLUSH_MS = 33; // ~30 fps per device
export class PerisRuntime {
constructor() {
this.registry = null;
this.sigbus = new Sigbus(null);
this.emu = null;
this.chip = 'esp32c3';
this.devices = new Map(); // id -> { instance, meta, cfg }
this.dirty = new Set();
this.lastFlush = 0;
this.post = (msg) => postMessage(msg); // overridable for tests
}
async init() {
this.registry = await loadRegistry(import.meta.url);
}
attachEmu(emu, chip) {
this.emu = emu;
this.chip = chip || this.chip;
this.sigbus.attach(emu);
for (const { instance } of this.devices.values()) instance.onEmuReady?.();
}
/** Validate + instantiate a full config. Always answers PERIS_READY. */
async applyConfig(config) {
// A config can arrive before initWasm finished loading the device
// registry (page reload with a saved config) — wait for it once.
if (!this.registry) {
try { await this.init(); } catch (e) { /* reported below */ }
}
const errors = [];
const usedPins = new Map(); // pin -> deviceId (conflict detection)
for (const { instance } of this.devices.values()) {
try { instance.destroy?.(); } catch (e) { /* ignore */ }
}
this.devices.clear();
this.sigbus = new Sigbus(this.emu);
const devices = Array.isArray(config?.devices) ? config.devices : [];
for (const cfg of devices) {
const id = cfg.id;
if (!id || !cfg.type) { errors.push('device entry missing id/type'); continue; }
if (!this.registry || !this.registry.has(cfg.type)) {
errors.push(`${id}: unknown type "${cfg.type}"`);
continue;
}
const meta = this.registry.meta(cfg.type);
try {
// holder lets ctx.subscribe() find the instance even when
// called from inside the device constructor.
const holder = {};
const instance = new (this.registry.Device(cfg.type))(this.ctxFor(id, holder), cfg);
holder.dev = instance;
// Pin conflict check (v1: one device per pin, any direction).
const pins = meta.pins ? meta.pins(cfg) : [];
for (const p of pins) {
if (typeof p !== 'number' || p < 0 || p >= gpioCount(this.chip) ||
(GPIO_ABSENT[this.chip] || new Set()).has(p)) {
errors.push(`${id}: pin ${p} is not a valid ${this.chip} GPIO`);
continue;
}
if (usedPins.has(p)) {
errors.push(`${id}: pin ${p} already used by ${usedPins.get(p)}`);
continue;
}
usedPins.set(p, id);
}
this.devices.set(id, { instance, meta, cfg });
instance.onEmuReady?.();
} catch (e) {
errors.push(`${id}: ${e.message}`);
}
}
this.post({
type: PERIS_READY,
ok: errors.length === 0,
errors,
devices: [...this.devices.values()].map(({ meta, cfg }) => ({
id: cfg.id, type: cfg.type, title: meta.title,
})),
});
// Push each device's initial state so views render before the
// first pin change.
for (const id of this.devices.keys()) this.dirty.add(id);
this.flushStates(true);
}
command(id, cmd, params) {
this.devices.get(id)?.instance.onCommand?.(cmd, params);
}
/** Called by the worker after every run_batch. */
onBatchEnd() {
this.sigbus.sample();
this.flushStates(false);
}
flushStates(force) {
const now = Date.now();
if (!force && (this.dirty.size === 0 || now - this.lastFlush < STATE_FLUSH_MS)) return;
this.lastFlush = now;
for (const id of this.dirty) {
const d = this.devices.get(id);
if (!d) continue;
let state;
try { state = d.instance.getState ? d.instance.getState() : {}; }
catch (e) { state = { error: e.message }; }
this.post({ type: PERIS_STATE, id, state });
}
this.dirty.clear();
}
log(text, level = 'info') {
this.post({ type: PERIS_LOG, level, text });
}
ctxFor(id, holder) {
const rt = this;
const self = () => holder.dev || rt.devices.get(id)?.instance;
const subFn = () => self(); // sigbus resolves the instance lazily
return {
id,
get chip() { return rt.chip; },
get gpioCount() { return gpioCount(rt.chip); },
setPinInput(pin, high) { rt.sigbus.setInput(pin, high); },
readMem(addr, len) { return rt.emu ? rt.emu.read_memory(addr >>> 0, len) : new Uint8Array(0); },
readU32(addr) {
if (!rt.emu) return 0;
const b = rt.emu.read_memory(addr >>> 0, 4);
return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0;
},
cycles() { return rt.emu ? rt.emu.cycles() : 0; },
subscribe(pins) { rt.sigbus.subscribe(subFn, pins); },
unsubscribe() { rt.sigbus.unsubscribe(subFn); },
markDirty() { rt.dirty.add(id); },
log: (text, level) => rt.log(text, level),
};
}
}