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
This commit is contained in:
darceye committed 2026-10-29 19:22:45 +08:00
1 parent 9c4be26243
commit a9d26722d7
39 files changed
+8102 -1

No files matched your search

+2
View File
@@ -147,3 +147,5 @@ vite.config.ts.timestamp-*
# Turbo Repo
.turbo
.zcode/
tests/firmware/peris_probe/build/
+97 -1
View File
@@ -1,2 +1,98 @@
# esp-emu-peris
The esp-emulator peripherals
为乐鑫 [esp-emulator](https://github.com/espressif/esp-emulator) 浏览器形态(wasm)做的**模拟外设面板**:LED、按键、触摸按键、拨动开关……以插件方式不断添加,不需要改动官方 `www/` 里的任何基础文件。
```
esp-emu-peris.html 入口页面(UART0 控制台 + 右侧 Peripherals/Debug 双标签)
peris/ 全部前端代码(自有 worker + 设备插件框架)
worker.js 官方 worker 的复刻 + 每批引脚采样钩子(改动均标 peris:)
app.js 官方页面逻辑复刻 + Peripherals 面板
device-api.js 外设接口契约 v1(见 docs/DEVICE-API.md)
registry.js 插件注册表(manifest 驱动)
runtime.js worker 侧设备宿主
sigbus.js 引脚信号总线(变化分发)
protocol.js / chips.js / mmio/
devices/ 外设插件:led / button / touch-button / toggle-switch
examples/ 接线配置示例(JSON,可直接 Import)
tests/ M0 探针(Node)+ 测试固件(ESP-IDF)
docs/ FEASIBILITY(M0 验证报告)/ DEVICE-API / UPGRADE / ROADMAP
```
## 快速开始
```bash
# 1) 开发服务器(合并服务本仓库与 _tools/esp-emu-browser/www 的 pkg/)
python serve.py # http://127.0.0.1:8080/esp-emu-peris.html
# 2) 浏览器打开上面的地址,选择芯片、拖入 merged flash image(.bin)
# 注意:必须是 idf.py merge-bin 生成的完整镜像,裸 app bin 无法启动
# 3) 右侧 Peripherals 标签:Add 添加 LED/按键等,选好 GPIO;
# Export/Import 导出/导入接线配置(JSON,自动存 localStorage)
```
把外设面板**发布**进官方 www 目录(只增不改):
```bash
python sync_www.py # 或 sync_www.bat;之后 www/esp-emu-peris.html 即可用
```
## 能力与限制(实测结论,详见 docs/FEASIBILITY.md)
| 外设 | 状态 | 途径 |
|---|---|---|
| 各色 LED | ✅ M1 | 轮询 GPIO 输出寄存器 |
| 物理按键 / 触摸按键(GPIO 语义)/ 拨动开关 | ✅ M1 | 驱动引脚输入,固件可读到并触发中断 |
| PWM 调光 LED / RGB | 🔜 M2 | 读 LEDC duty 寄存器(引脚级 PWM 不可见) |
| WS2812 幻彩灯 | 🔜 M3 | 读 RMT 通道 RAM 解码(引脚级波形不可见) |
| 位绑定 I2C/SPI 设备、IR | 🔜 M4 | 高速引脚采样(需固件位绑定) |
| 硬件 I2C/SPI 设备、显示屏、ADC、真触摸、I2S、SD | ⏸ M5 | 等 esp-emulator 上游暴露 API |
注:触摸**外设**未被模拟——触摸按键以 GPIO 输入呈现,固件按 GPIO 读取即可
(`tests/firmware/peris_probe` 有示例:GPIO3 输入 + 下降沿 ISR)。
## 添加一个新外设
1. 在 `peris/devices/` 放一个模块,导出 `Device` 与 `View`(接口见
`peris/device-api.js` 顶部注释或 docs/DEVICE-API.md);
2. 在 `peris/devices/manifest.json` 加一行。完成——页面和 worker 会自动加载。
```js
// peris/devices/my-dev.js
export class Device {
static typeId = 'my-dev'; static title = 'My Device'; static category = 'misc';
static defaultConfig = { name: 'My', pin: 6 };
static configSchema = [{ key: 'pin', label: 'GPIO pin', type: 'pin' }];
static pins(cfg) { return [cfg.pin]; }
constructor(ctx, cfg) { this.ctx = ctx; ctx.subscribe([cfg.pin]); }
onPinState(pin, s) { this.on = s.oe && s.out; this.ctx.markDirty(); }
getState() { return { on: this.on }; }
destroy() { this.ctx.unsubscribe(); }
}
export class View {
constructor(el, sendCmd) { el.textContent = 'hello'; }
render(state) { }
}
```
## 测试固件与 M0 探针
```powershell
# 构建(用 C:\Users\A\Desktop\quick\idfm.bat 的 IDF 环境)
powershell -ExecutionPolicy Bypass -NoProfile -File tests\firmware\build.ps1
# 生成 merged image(加载用):
# tests/firmware/peris_probe/build/merged-binary.bin
# Node 里直接验证模拟器能力(GPIO 可见性 / MMIO / WS2812 解码)
node tests/m0_probe.mjs
# M1 框架集成冒烟(真实 runtime+设备模块+wasm:配置应用/LED 状态/
# 按键 ISR 计数/开关命令/引脚冲突/未知类型拒绝)
node tests/m1_smoke.mjs
```
固件三阶段循环:GPIO8 闪烁 / GPIO9 LEDC 呼吸 / GPIO4 RMT-WS2812 发帧,
并配合 GPIO3 输入中断计数,用于端到端验证。
注:页面自身不依赖任何 CDN——xterm/xterm-addon-fit 已本地化到
`peris/vendor/`(取自 npm 包的非压缩构建),离线可用。
+82
View File
@@ -0,0 +1,82 @@
# esp-emu-peris 外设接口规范(v1)
一个外设 = `peris/devices/` 下的一个 ES 模块 + `manifest.json` 里的一行。除此之外不需要改动项目任何其它文件。
## 模块导出
```js
export class Device { // 运行在 worker,与模拟器交互
static typeId = 'led'; // 唯一、稳定、kebab-case
static title = 'LED'; // UI 显示名
static category = 'basic'; // Add 菜单分组
static description = '...'; // 一行说明(tooltip)
static defaultConfig = {...}; // 新建实例的默认配置
static configSchema = [ // 通用配置编辑器字段
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'color', label: 'Colour', type: 'color' },
{ key: 'name', label: 'Label', type: 'text' },
// type 还支持: number(min/max/step)、select(options:[{value,label}])、checkbox
];
static pins(cfg) { return [cfg.pin]; } // 声明占用的 GPIO(用于冲突检测)
constructor(ctx, cfg) {} // cfg = { id, type, ...设备配置 }
onPinState(pin, s) {} // 引脚输出变化回调 s={out, oe, cycles}
onCommand(cmd, params) {} // 页面 View 发来的用户操作
onEmuReady() {} // 模拟器(重)加载后:重新驱动输入电平
getState() { return {...}; } // 可序列化状态,推给页面 View(~30fps)
destroy() {} // 释放/恢复引脚
}
export class View { // 运行在页面,渲染设备卡片主体
constructor(el, sendCmd) {} // el=卡片主体容器; sendCmd(cmd, params)
render(state) {} // state 来自 Device.getState()
setConfig(cfg) {} // 可选:配置编辑后回调(如换 LED 颜色)
}
```
## ctx(worker 侧设备上下文)
| 成员 | 说明 |
|---|---|
| `ctx.id` / `ctx.chip` / `ctx.gpioCount` | 设备 id、芯片型号、GPIO 数 |
| `ctx.setPinInput(pin, high)` | 驱动引脚输入电平(固件可读到、可触发 GPIO 中断) |
| `ctx.subscribe(pins)` / `ctx.unsubscribe()` | 订阅引脚输出变化(onPinState) |
| `ctx.readMem(addr, len)` / `ctx.readU32(addr)` | 总线/MMIO 读取(LEDC duty、RMT RAM 等) |
| `ctx.cycles()` | 模拟器周期计数 |
| `ctx.markDirty()` | 请求尽快把 getState() 推给页面 |
| `ctx.log(text, level?)` | 在 UART 控制台打一条 `[Peris]` 日志 |
## 消息协议(页面 ↔ worker)
在原版 worker 协议之外增加(见 `peris/protocol.js`):
- 页面→worker:`peris_config {config}`(全量应用配置)、`peris_command {id, cmd, params}`
- worker→页面:`peris_ready {ok, errors, devices}`、`peris_state {id, state}`、`peris_log {level, text}`
## 配置文件
```json
{
"perisVersion": 1,
"chip": "esp32c3",
"devices": [ { "id": "led-1", "type": "led", "pin": 8, "color": "#ff4d4d", "name": "Red" } ]
}
```
- 自动保存于 localStorage(`peris.config.v1`),面板 Export/Import 按钮可导出/导入 `.json`。
- v1 规则:每个 GPIO 同时只能被一个设备占用(任意方向);冲突会在面板顶部黄条提示。
## 设备开发注意事项(源自 M0 实测,见 FEASIBILITY.md)
1. **引脚输出**:固件主动 GPIO 输出在 `onPinState` 可见(out+oe);**LEDC/RMT 走 GPIO matrix 的信号不出现在引脚寄存器**,必须读 MMIO(LEDC duty 寄存器、RMT 通道 RAM),地址表在 `peris/mmio/`。
2. **引脚输入**:`ctx.setPinInput` 会真实进入固件(读值+中断),按键类设备在 `onEmuReady` 里要重新驱动电平(模拟器重载后电平复位)。
3. **时间**:`vTaskDelay` 期间模拟器时间快进,不要试图在 CPU 睡眠窗口做引脚级波形捕获;CPU 活跃的位绑定波形可在 run_batch 批间采样,但请节制(细批约 99 MIPS,粗批 5000+ MIPS)。
4. 状态推送节流 ~30fps;`getState()` 返回的对象会 postMessage,保持小而可序列化。
5. View 中所有用户交互统一通过 `sendCmd(cmd, params)` 发给 Device,不要在页面侧直接操作模拟器。
## 示例
- `peris/devices/led.js` — 输出型设备(sigbus 订阅)
- `peris/devices/button.js` — 输入型设备(setPinInput + onEmuReady)
- `peris/devices/touch-button.js` — 继承 button 的 Device,换 View
- `peris/devices/toggle-switch.js` — 保持型输入设备
+65
View File
@@ -0,0 +1,65 @@
# M0 可行性验证报告(esp-emu-peris)
日期:2026-10-29 · 环境:esp-emulator browser wasm v0.51.0(`_tools/esp-emu-browser/www/pkg`)
固件:ESP-IDF master(`C:\esp\master\esp-idf`,经 `C:\Users\A\Desktop\quick\idfm.bat` 环境)
复现:`node tests/m0_probe.mjs`(探针)+ `tests/firmware/peris_probe`(测试固件,`idf.py build && idf.py merge-bin` 后加载 `build/merged-binary.bin`)
## 结论总表
| # | 问题 | 结论 | 证据 |
|---|---|---|---|
| Q1a | 固件 GPIO 输出能否被 `gpio_state()` 轮询观察到 | **PASS** | 8 次翻转全部可见(采样粒度 5 万指令/批) |
| Q1b | `gpio_set_input()` 驱动引脚能否触发固件 GPIO 中断 | **PASS** | 探针驱动 6 个下降沿,固件 ISR 计数 6/6 |
| Q2a | LEDC PWM 波形是否体现在 GPIO 输出寄存器(pad 级轮询测占空比) | **FAIL** | 引脚 oe=1 但 out 恒 0;duty=50%/100% 期间估计值恒 0 |
| Q2b | LEDC duty 寄存器能否用 `read_memory()` 读到且跟踪设定值 | **PASS** | 53/53 样本跟踪;`half(4096)→DUTY_R=0x10000=4096×16`,`max(8191)→0x1fff0` |
| Q3a | RMT/WS2812 波形能否用小批量 `run_batch(40)` 在引脚级采样解码 | **FAIL** | 2472 批采样中 out4 恒 0,无任何电平变化 |
| Q3b | RMT 通道 RAM 能否用 `read_memory()` 读出并解码 WS2812 帧 | **PASS** | 93 次快照解出 46 帧,匹配固件日志颜色 0x111111/0x222222/0x333333 |
## 对外设方案的决定性影响
- **LED/物理按键/触摸按键(GPIO 语义)**:直接可行——Q1a+Q1b 全通过。
- **PWM 调光 LED**:走 **MMIO 寄存器路径**(Q2b),不走引脚采样(Q2a 已证不可行)。读
`LEDC_CHn_DUTY_R` 即得精确占空比,无混叠、无开销。
- **WS2812**:走 **RMT 通道 RAM 路径**(Q3b),不走引脚采样(Q3a 已证不可行)。符号在
发送后仍留存于 RAM,可事后解码。
- **位绑定(bit-bang)I2C/SPI/IR**:仍可行——Q1a 证明 CPU 主动翻转 GPIO_OUT 可见;
CPU 在翻转时不睡眠,不存在下面的"时间快进"问题。高速采样仅在 CPU 活跃窗口有效。
## 关键技术细节
### ESP32-C3 寄存器地址(来自 IDF soc 头文件)
| 寄存器 | 地址 | 备注 |
|---|---|---|
| GPIO_OUT / GPIO_ENABLE | 0x60004004 / 0x60004020 | `gpio_state()` 即这两个字的镜像 |
| GPIO_FUNCn_OUT_SEL_CFG | 0x60004000+0x554+4n | 低 9 位 = GPIO matrix 输出信号选择 |
| IO_MUX_GPIO0..11_REG | 0x60009000+0x04+4n | bit10..12 MCU_SEL 等pad配置 |
| LEDC_CHn_(CONF0/HPOINT/DUTY/CONF1/DUTY_R) | 0x60019000+0x14n(+0/4/8/C/10) | C3 用 LSCHn 命名 |
| RMT_CHnCONF0 / 通道 RAM(RMTMEM) | 0x60016010+8n / 0x60016400+0xC0n | 每通道 48 字(192 字节) |
### 实测行为要点
1. **LEDC duty 布局**:`DUTY_CH0` 字段在 bits[18:4],寄存器值 = duty × 16(与真机一致)。
2. **GPIO matrix 路由可读**:固件把 GPIO9 配给 LEDC0 后 `FUNC9_OUT_SEL=0x2d`(=45,
LEDC_LS_CH0_SIG),GPIO4 配给 RMT 后 `FUNC4_OUT_SEL=0x33`(=51,RMT_SIG_0)。
**外设接线的自动发现**可据此实现:轮询所有引脚的 OUT_SEL + IO_MUX 即可知道每个
pad 当前由哪个外设信号驱动。
3. **RMT RAM 留存**:发送完成后符号不清除;48 字/通道;>192 符号的帧走 ping-pong
(TX_THR_EVENT 重填+wrap),快照中可能同时存在相邻帧的片段,帧间以 reset 符号
(低电平 ≥30µs,即 d0≥300 且 l0=0)分界。
4. **位网格重同步**:本测试中 RAM 内位流相对 LED 字节网格缺头部 2 位(ping-pong 写入
顺序所致),前置补 0 搜索(0..7)即可对齐;真实设备解码器需实现该重同步。
5. **时间快进**:`vTaskDelay` 期间模拟器不消耗完整 CPU 周期(一批 `run_batch(40)` 中
`cycles()` 可跳数千),因此 **CPU 睡眠期间的外设波形在引脚级不可捕获**——这是 Q3a
失败的根本原因,与采样率无关。
6. **性能**(Node 24,直接 wasm 调用):粗粒度(50k 指令/批)≈ **5000+ MIPS**;细粒度
(40 指令/批 + 每批 `gpio_state()`)≈ **99 MIPS**(约 53 倍开销)。浏览器 worker 内
大致同量级——细粒度采样只应作为 CPU 活跃窗口的临时手段,常驻采样用粗粒度+MMIO。
7. **加载固件必须是合并镜像**(bootloader@0x0 + 分区表@0x8000 + app@0x10000),
用 `idf.py merge-bin` 生成;裸 app bin 不能启动。
## 已排除 / 仍受上游限制的能力
- 硬件 I2C/SPI 外接设备模型、真触摸外设、ADC、I2S、SPI/RGB565 屏、SD 卡、UART1:
wasm 无对应 API,需等 esp-emulator 上游(见 docs/ROADMAP.md)。
- 引脚级 PWM/RMT 波形观察:结构性不可行(Q2a/Q3a),不依赖采样率。
+44
View File
@@ -0,0 +1,44 @@
# 路线图
## M2 · PWM 调光 LED(下一期)
- `pwm-led.js` + `rgb-led.js`:读 LEDC 各通道 `DUTY_R` 寄存器(地址表 `peris/mmio/`),
精确占空比,无混叠(路线已在 M0 验证:Q2b PASS)。
- 通道→引脚映射:读 `GPIO_FUNCn_OUT_SEL_CFG`(M0 实测 LEDC0=0x2d、RMT0=0x33),
实现"自动发现接线"辅助;也允许手动指定通道/引脚。
- 呼吸灯平滑渲染(canvas 亮度插值)。
## M3 · WS2812 幻彩灯
- `ws2812.js`:轮询 RMT 通道 RAM(M0 验证 Q3b PASS),符号→位流→GRB 帧;
ping-pong 分组 + reset 符号重同步 + 前置补零对齐(细节见 FEASIBILITY.md)。
- 灯条/灯环/矩阵三种 View;GRB/RGB 顺序、亮度限制、Gamma 可配。
- 性能策略:JS 解码先行;若实测不够,把"采样+解码"热路径用 C/C++ 编成独立 wasm
辅助模块在 worker 内调用(不动模拟器的闭源 wasm,不影响设备 JS 接口)。
## M4 · 位绑定协议设备 + IR
- `bitbang-i2c-slave.js`(SCL/SDA 上模拟 24Cxx/BME280 等)、`ir-nec.js`(捕获固件 RMT/位绑定发射)。
- 前提:固件用 GPIO 位绑定(硬件 I2C/SPI 总线无法从外部接入,M0 已确认)。
需要配套测试固件与文档说明此约束。
- 高速采样调度(sigbus 扩展:临时小批量窗口,CPU 活跃期有效)。
## M5 · 上游能力跟进(不可控)
以下能力当前 wasm 无 API,只能跟进 esp-emulator 新版本(升级流程见 UPGRADE.md):
| 能力 | 依赖的上游接口 | 现状 |
|---|---|---|
| 硬件 I2C 设备模型 | i2c slave/设备桥 API(仅 CLI `--i2c-dev`,且只有哑寄存器) | ❌ |
| 硬件 SPI 外设(SD 卡等) | spi 设备桥 | ❌ |
| SPI/RGB565 显示屏 | spi/lcd_cam(外设本身未模拟) | ❌ |
| 真触摸外设 | touch 外设模拟 | ❌(触摸按键以 GPIO 语义代替) |
| ADC(电位器等) | adc 模拟 | ❌ |
| I2S 音频 | i2s 模拟 | ❌ |
| UART1 外设 | wasm 暴露 uart1(CLI 有 `--uart1-tcp`) | ❌ |
| 状态快照保存/恢复 | save/load API | ❌ |
## 维护项
- `update_esp_emu_browser.bat`(在 `_tools`,当前为空):按 UPGRADE.md 流程脚本化。
- `peris/mmio/`:补 C5/C6/H2/P4/S3 地址表(从各自 IDF soc 头文件提取,方法同 M0)。
+29
View File
@@ -0,0 +1,29 @@
# 跟进上游 esp-emulator / esp-emu-browser 升级
## 锁定版本
当前基线:**esp-emulator v0.51.0**(`_tools/esp-emu-browser/www/pkg`,wasm-bindgen `--target web`)。
## 升级步骤
1. 下载新版 wasm 包(如 `esp-emu-<ver>-wasm.tar.gz`)解压出新的 `www/`
(`_tools` 下已有 `update_esp_emu_browser.bat`,目前为空,可按此逻辑补全)。
2. **先跑 M0 探针**:`node tests/m0_probe.mjs <新固件路径不动,直接对 pkg>`。
探针直接 import 新 `pkg/esp_emu.js`,六项结论(Q1a/Q1b/Q2a/Q2b/Q3a/Q3b)若有变化,
先更新 `docs/FEASIBILITY.md` 与 `peris/mmio/` 地址表,再动其它代码。
3. **diff 官方 worker.js**:
`diff <new_www>/worker.js peris/worker.js`
我们对原文件的改动全部带 `peris:` 注释,逐块把上游新逻辑移植进 `peris/worker.js`:
- 新增/变更的消息类型 → 同步到 peris/worker.js 与 peris/app.js 的 switch
- 运行循环(run_batch/throttle/restart)变化 → 保持 `peris.onBatchEnd()` 钩子在每批之后
4. **diff 官方 app.js / index.html**:页面功能(终端/图表/寄存器/内存面板)改动按需
移植进 `peris/app.js` 与 `esp-emu-peris.html`(我们的文件是它们的超集复刻)。
5. 若 `WasmEmulator` API 变化(新增 GPIO/PWM/RMT 接口!),优先改用官方 API 简化设备实现——
那正是 ROADMAP.md 里等的东西。
6. GUI 冒测(加载固件、LED/按键、配置导入导出),提交。
## 需要盯的上游能力(详见 ROADMAP.md)
- GPIO 之外的外设 API(i2c/spi 设备模型、rmt 回调、touch、adc)
- 事件回调机制(目前只能轮询)
- 浏览器包的 UART1 / 快照保存
+648
View File
@@ -0,0 +1,648 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP-EMU PERIS — ESP32 emulator with peripherals</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='4' fill='%23ff7a1a'/%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5' fill='none' stroke='%23141414' stroke-width='1.6'/%3E%3Cpath d='M6 1.5v2M10 1.5v2M6 12.5v2M10 12.5v2M1.5 6h2M1.5 10h2M12.5 6h2M12.5 10h2' stroke='%23141414' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E">
<!-- xterm vendored locally (from npm xterm@5.3.0 / xterm-addon-fit@0.8.0,
unminified build) so the page works without CDN access. -->
<link rel="stylesheet" href="peris/vendor/css/xterm.css">
<style>
:root {
--bg: #0e0e10;
--surface: #151518;
--surface-2: #1c1c21;
--surface-3: #232329;
--border: #26262d;
--border-strong: #34343d;
--text: #ececea;
--muted: #8e8e98;
--faint: #5c5c66;
--accent: #ff7a1a;
--accent-hover: #ff9247;
--accent-soft: rgba(255, 122, 26, 0.14);
--accent-text: #ffb27a;
--ok: #49d49a;
--warn: #f5c04a;
--err: #ff5c5c;
--mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
--radius: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: var(--sans);
font-size: 13px;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
button, select, input { font: inherit; color: inherit; }
/* ---- top bar ---- */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 16px;
height: 50px;
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
.brand { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.brand .mark {
width: 26px; height: 26px;
border-radius: 7px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4e1a 100%);
display: grid; place-items: center;
box-shadow: 0 0 0 1px rgba(255, 122, 26, 0.35), 0 4px 14px rgba(255, 122, 26, 0.25);
}
.brand .mark svg { width: 16px; height: 16px; }
header h1 { font-size: 16px; font-weight: 650; letter-spacing: 0.2px; }
header h1 span {
color: var(--muted);
font-weight: 500;
font-size: 12px;
font-family: var(--mono);
margin-left: 6px;
}
.transport { display: flex; align-items: center; gap: 6px; margin-left: 8px; }
.btn {
background: var(--surface-2);
border: 1px solid var(--border-strong);
color: var(--text);
padding: 5px 12px;
border-radius: var(--radius);
cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
line-height: 1.2;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover:not(:disabled) { background: var(--surface-3); border-color: #44444f; }
.btn:disabled { opacity: 0.38; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #141414; font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.small { padding: 3px 9px; font-size: 12px; }
.btn svg { width: 11px; height: 11px; fill: currentColor; }
.status { margin-left: auto; display: flex; align-items: center; gap: 14px; white-space: nowrap; }
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 10px 4px 8px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
font-size: 12px;
color: var(--muted);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
.pill[data-state="ready"] .dot { background: var(--warn); }
.pill[data-state="running"] .dot { background: var(--ok); box-shadow: 0 0 0 3px rgba(73, 212, 154, 0.2); }
.pill[data-state="paused"] .dot { background: var(--accent); }
.pill[data-state="running"], .pill[data-state="ready"], .pill[data-state="paused"] { color: var(--text); }
.status .mips {
color: var(--accent-text);
font-weight: 600;
font-family: var(--mono);
min-width: 84px; text-align: right;
}
.status #cycle-display { font-family: var(--mono); color: var(--muted); min-width: 110px; text-align: right; font-size: 12px; }
/* ---- layout ---- */
.main-area { display: flex; flex: 1; overflow: hidden; }
.left-panel, .side-panel {
background: var(--surface);
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.left-panel { width: 236px; border-right: 1px solid var(--border); }
.side-panel { width: 360px; border-left: 1px solid var(--border); }
.panel-section { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.panel-section h3 {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--muted);
margin-bottom: 10px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-weight: 600;
}
.panel-section h3 .tag {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0;
text-transform: none;
color: var(--accent-text);
background: var(--accent-soft);
padding: 1px 6px;
border-radius: 4px;
}
/* ---- right panel tabs (peris) ---- */
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
position: sticky; top: 0;
background: var(--surface);
z-index: 2;
}
.ptab {
flex: 1;
background: none;
border: 0;
border-bottom: 2px solid transparent;
color: var(--muted);
padding: 10px 8px 8px;
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
cursor: pointer;
}
.ptab:hover { color: var(--text); }
.ptab.active { color: var(--accent-text); border-bottom-color: var(--accent); }
.pane.hidden { display: none; }
/* ---- peripherals panel (peris) ---- */
#pane-peris { flex: 1; display: flex; flex-direction: column; }
#pane-peris.over { outline: 2px dashed var(--accent); outline-offset: -6px; }
.peris-toolbar {
display: flex; gap: 6px; padding: 10px 14px;
border-bottom: 1px solid var(--border);
position: sticky; top: 41px; background: var(--surface); z-index: 1;
}
.peris-toolbar select {
flex: 1; min-width: 0;
background: var(--bg); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 4px 8px; font-size: 12px;
}
#peris-errors {
display: none;
color: var(--warn); font-size: 11.5px;
padding: 6px 14px; border-bottom: 1px solid var(--border);
line-height: 1.5;
}
#peris-cards { flex: 1; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
#peris-empty { padding: 22px 10px; text-align: center; color: var(--faint); font-size: 12px; line-height: 1.6; }
#peris-empty strong { color: var(--muted); }
.peris-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
}
.peris-card-head {
display: flex; align-items: center; gap: 8px;
margin-bottom: 8px;
}
.dev-title { font-weight: 600; font-size: 12.5px; }
.dev-type { color: var(--faint); font-size: 10.5px; font-family: var(--mono); }
.dev-remove { margin-left: auto; padding: 1px 7px; font-size: 13px; color: var(--muted); }
.dev-remove:hover { color: var(--err); border-color: var(--err); }
.dev-editor { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; margin-top: 10px; }
.dev-field { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
.dev-field span { flex: 1; white-space: nowrap; }
.dev-field input[type="text"], .dev-field input[type="number"], .dev-field select {
width: 96px; flex-shrink: 0;
background: var(--bg); border: 1px solid var(--border-strong);
border-radius: 4px; padding: 3px 6px; font-size: 11.5px; outline: none;
}
.dev-field input[type="color"] { width: 34px; height: 22px; padding: 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--bg); }
.dev-field input[type="checkbox"] { accent-color: var(--accent); }
.dev-field select { max-width: 96px; }
/* device view bodies */
.dev-body { min-height: 74px; display: flex; align-items: center; justify-content: center; }
.led-wrap { display: flex; align-items: center; justify-content: center; padding: 6px 0; }
.led-bulb {
--led-color: #ff4d4d;
width: 44px; height: 44px; border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--led-color) 45%, #2a2a30);
background: radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--led-color) 16%, #1a1a1e), #101014 72%);
position: relative;
transition: box-shadow 0.15s, border-color 0.15s;
}
.led-bulb.lit {
background: radial-gradient(circle at 38% 32%, #ffffff33 0%, var(--led-color) 46%, color-mix(in srgb, var(--led-color) 55%, #20060a) 100%);
box-shadow: 0 0 18px 4px color-mix(in srgb, var(--led-color) 55%, transparent), 0 0 44px 10px color-mix(in srgb, var(--led-color) 25%, transparent);
border-color: var(--led-color);
}
.led-bulb.floating { border-style: dashed; opacity: 0.6; }
.led-cap { text-align: center; font-size: 10.5px; color: var(--faint); font-family: var(--mono); margin-top: 6px; }
.hw-button {
min-width: 128px;
padding: 16px 22px;
border-radius: 10px;
border: 1px solid var(--border-strong);
background: linear-gradient(180deg, var(--surface-3), var(--surface-2));
color: var(--muted);
font-weight: 700; font-size: 12px; letter-spacing: 1.2px;
cursor: pointer;
box-shadow: 0 3px 0 #0a0a0c, inset 0 1px 0 rgba(255,255,255,0.05);
transition: transform 0.05s, box-shadow 0.05s, color 0.1s;
user-select: none;
}
.hw-button.down, .hw-button:active {
transform: translateY(3px);
box-shadow: 0 0 0 #0a0a0c, inset 0 1px 3px rgba(0,0,0,0.5);
color: var(--accent-text);
border-color: var(--accent);
}
.touch-pad {
min-width: 116px; min-height: 74px;
border-radius: 12px;
border: 1px solid var(--border-strong);
background: linear-gradient(180deg, #17171b, #101013);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
color: var(--faint); cursor: pointer; user-select: none;
transition: color 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.touch-pad svg { width: 26px; height: 26px; }
.touch-pad .touch-label { font-size: 9.5px; letter-spacing: 1.6px; font-weight: 700; }
.touch-pad:hover { color: var(--muted); }
.touch-pad.active {
color: var(--accent-text);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft), inset 0 0 24px rgba(255,122,26,0.12);
}
.hw-switch { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.hw-switch input { display: none; }
.sw-track {
width: 46px; height: 24px; border-radius: 999px;
background: var(--bg); border: 1px solid var(--border-strong);
position: relative; transition: background 0.15s, border-color 0.15s;
}
.sw-knob {
position: absolute; top: 2px; left: 2px;
width: 18px; height: 18px; border-radius: 50%;
background: var(--surface-3); border: 1px solid var(--border-strong);
transition: left 0.15s, background 0.15s;
}
.hw-switch input:checked + .sw-track { background: var(--accent-soft); border-color: var(--accent); }
.hw-switch input:checked + .sw-track .sw-knob { left: 24px; background: var(--accent); border-color: var(--accent); }
.sw-text { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* ---- form fields ---- */
.field { margin-bottom: 10px; }
.field:last-child { margin-bottom: 0; }
.field label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.field select, .field input[type="text"], .field .btn { width: 100%; }
.field select, .field input[type="text"] {
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 6px 8px;
font-size: 12.5px;
outline: none;
}
.field select:focus, .field input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.field select { appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e8e98'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 9px center; padding-right: 24px; }
.file-upload { position: relative; overflow: hidden; display: block; }
.file-upload .btn { text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.file-upload input[type="file"] { position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
.dropzone {
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
padding: 14px 10px;
text-align: center;
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
position: relative;
}
.dropzone:hover, .dropzone.over { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
.dropzone strong { color: var(--accent-text); font-weight: 600; }
.dropzone .fname { display: block; margin-top: 4px; font-family: var(--mono); font-size: 11px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropzone input[type="file"] { position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
.note { font-size: 11px; color: var(--faint); line-height: 1.45; margin-top: 8px; }
.note.warn { color: var(--warn); }
/* ---- terminal ---- */
.terminal-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
.term-toolbar {
display: flex; align-items: center; gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-size: 11.5px; color: var(--muted);
}
.term-toolbar h2 { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.9px; font-weight: 600; color: var(--muted); }
.term-toolbar .hint { color: var(--faint); }
.term-toolbar .spacer { flex: 1; }
.term-toolbar .count { font-family: var(--mono); }
#terminal-container { flex: 1; padding: 8px 4px 6px 10px; overflow: hidden; }
#terminal-container.over { outline: 2px dashed var(--accent); outline-offset: -6px; }
.xterm .xterm-viewport { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
/* ---- stat tiles & chart ---- */
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
.tile { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; }
.tile .k { font-size: 10.5px; color: var(--muted); margin-bottom: 3px; }
.tile .v { font-family: var(--mono); font-size: 15px; font-weight: 600; color: var(--text); }
.tile .v small { font-size: 10.5px; color: var(--muted); font-weight: 500; margin-left: 3px; }
.tile.accent .v { color: var(--accent-text); }
.chart-wrap { position: relative; }
#mips-chart { display: block; width: 100%; height: 64px; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--border); }
.chart-caption { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--faint); margin-top: 4px; }
.chart-tip {
position: absolute; top: 6px; pointer-events: none;
background: var(--surface-3); border: 1px solid var(--border-strong);
padding: 2px 6px; border-radius: 4px;
font-family: var(--mono); font-size: 10.5px; color: var(--text);
display: none; white-space: nowrap;
}
/* ---- cores ---- */
.cores { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.core { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 10px; }
.core .k { font-size: 10.5px; color: var(--muted); display: flex; justify-content: space-between; }
.core .v { font-family: var(--mono); font-size: 13px; color: var(--accent-text); margin-top: 2px; }
.core.idle { opacity: 0.45; }
/* ---- GPIO ---- */
.gpio-legend { display: flex; gap: 12px; font-size: 10.5px; color: var(--muted); margin-bottom: 8px; flex-wrap: wrap; }
.gpio-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: -1px; margin-right: 4px; border: 1px solid var(--border-strong); }
.gpio-legend i.out-high { background: var(--accent); border-color: var(--accent); }
.gpio-legend i.out-low { background: transparent; border-color: var(--accent); }
.gpio-legend i.in-high { background: var(--surface-3); border-color: var(--muted); }
.gpio-legend i.in-low { background: var(--bg); }
.gpio-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; }
.gpio-pin {
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-strong);
background: var(--bg);
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: 10.5px;
color: var(--muted);
cursor: pointer;
user-select: none;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.gpio-pin.in.high { background: var(--surface-3); border-color: var(--muted); color: var(--text); }
.gpio-pin.in:hover { border-color: var(--text); }
.gpio-pin.out { cursor: default; }
.gpio-pin.out.low { border-color: var(--accent); color: var(--accent-text); }
.gpio-pin.out.high { background: var(--accent); border-color: var(--accent); color: #141414; font-weight: 600; }
.gpio-pin.absent { visibility: hidden; }
/* ---- registers ---- */
.reg-table { font-family: var(--mono); font-size: 11px; width: 100%; border-collapse: collapse; }
.reg-table td { padding: 2px 4px; white-space: nowrap; }
.reg-table .name { color: var(--muted); width: 1%; }
.reg-table .val { color: var(--text); border-radius: 3px; transition: background 0.5s, color 0.5s; }
.reg-table .val.changed { background: var(--accent-soft); color: var(--accent-text); transition: none; }
.reg-table tr.sep td { padding-top: 6px; }
/* ---- memory ---- */
.mem-controls { display: flex; gap: 6px; margin-bottom: 6px; align-items: center; }
.mem-controls input[type="text"] {
flex: 1; min-width: 0;
background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius);
padding: 4px 8px; font-family: var(--mono); font-size: 11.5px; outline: none;
}
.mem-controls input[type="text"]:focus { border-color: var(--accent); }
.mem-controls select {
background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius);
padding: 4px 6px; font-family: var(--mono); font-size: 11px;
}
.mem-follow { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); cursor: pointer; white-space: nowrap; }
.mem-follow input { accent-color: var(--accent); }
#mem-display {
font-family: var(--mono); font-size: 10.5px; line-height: 1.5;
color: var(--text);
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 8px;
max-height: 230px; overflow: auto;
white-space: pre;
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
#mem-display .addr { color: var(--accent-text); }
#mem-display .ascii { color: var(--muted); }
#mem-display .zero { color: var(--faint); }
#mem-display .empty { color: var(--faint); }
footer {
background: var(--surface);
padding: 5px 16px;
font-size: 11px;
color: var(--faint);
border-top: 1px solid var(--border);
flex-shrink: 0;
display: flex; justify-content: space-between; gap: 12px;
}
footer a { color: var(--muted); text-decoration: none; }
footer a:hover { color: var(--accent-text); }
@media (max-width: 1100px) {
.side-panel { width: 300px; }
.gpio-grid { grid-template-columns: repeat(7, 1fr); }
}
@media (max-width: 860px) {
body { height: auto; overflow: auto; }
header { height: auto; flex-wrap: wrap; padding: 10px 16px; gap: 10px; }
.status { margin-left: 0; width: 100%; }
.main-area { flex-direction: column; overflow: visible; }
.left-panel, .side-panel { width: 100%; border: 0; border-bottom: 1px solid var(--border); }
.terminal-panel { min-height: 50vh; }
footer { flex-direction: column; gap: 2px; }
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="mark" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" stroke="#141414" stroke-width="1.6" stroke-linecap="round">
<rect x="4" y="4" width="8" height="8" rx="1.5"/>
<path d="M6 1.5v2M10 1.5v2M6 12.5v2M10 12.5v2M1.5 6h2M1.5 10h2M12.5 6h2M12.5 10h2"/>
</svg>
</div>
<h1>ESP-EMU <span>v0.51.0 + peris</span></h1>
</div>
<div class="transport">
<button id="run-btn" class="btn primary" disabled><svg viewBox="0 0 10 10"><path d="M1 0l8 5-8 5z"/></svg>Run</button>
<button id="pause-btn" class="btn" disabled><svg viewBox="0 0 10 10"><path d="M1 0h3v10H1zM6 0h3v10H6z"/></svg>Pause</button>
<button id="step-btn" class="btn" disabled><svg viewBox="0 0 10 10"><path d="M0 0l6 5-6 5zM7 0h2v10H7z"/></svg>Step</button>
<button id="reset-btn" class="btn" disabled><svg viewBox="0 0 10 10"><path d="M5 1a4 4 0 1 0 4 4h-1.5A2.5 2.5 0 1 1 5 2.5V4l3-2.5L5 0z"/></svg>Reset</button>
</div>
<div class="status">
<span class="pill" id="status-pill" data-state="idle"><i class="dot"></i><span id="status-text">No firmware loaded</span></span>
<span class="mips" id="mips-display">0.0 MIPS</span>
<span id="cycle-display">0 cycles</span>
</div>
</header>
<div class="main-area">
<aside class="left-panel">
<div class="panel-section">
<h3>Target</h3>
<div class="field">
<label for="chip-select">Chip</label>
<select id="chip-select">
<option value="esp32c3">ESP32-C3 · RV32IMAC</option>
<option value="esp32c5">ESP32-C5 · RV32IMAC</option>
<option value="esp32c6">ESP32-C6 · RV32IMAC</option>
<option value="esp32h2">ESP32-H2 · RV32IMAC</option>
<option value="esp32p4">ESP32-P4 · RV32IMAFC ×2</option>
<option value="esp32s3">ESP32-S3 · Xtensa LX7 ×2</option>
<option value="esp32s31">ESP32-S31 · RV32IMAFC ×2</option>
</select>
</div>
<div class="field">
<label>Firmware (merged flash image)</label>
<div class="dropzone" id="fw-drop">
<span id="fw-drop-text"><strong>Choose a .bin</strong> or drop it here</span>
<span class="fname" id="fw-name"></span>
<input type="file" id="firmware-file" accept=".bin">
</div>
</div>
<div class="field">
<label>ROM ELF</label>
<div class="file-upload">
<button id="rom-btn" class="btn small">Override ROM...</button>
<input type="file" id="rom-file" accept=".elf">
</div>
<div class="note">The embedded ROM is used unless you pick another revision.</div>
</div>
</div>
<div class="panel-section">
<h3>WiFi soft AP</h3>
<div class="field">
<label for="wifi-ssid">SSID</label>
<input type="text" id="wifi-ssid" value="myssid" placeholder="WiFi SSID">
</div>
<div class="field">
<label for="wifi-password">Password</label>
<input type="text" id="wifi-password" value="mypassword" placeholder="Empty for open network">
</div>
<div class="note">Firmware that joins this network gets an association and a DHCP lease. Taking packets further needs the proxy below.</div>
</div>
<div class="panel-section" id="net-section">
<h3>Network proxy</h3>
<div class="field">
<label for="net-url">WebSocket URL</label>
<input type="text" id="net-url" value="ws://localhost:8765" placeholder="ws://host:port">
</div>
<div class="field">
<button id="net-btn" class="btn small">Connect</button>
</div>
<div class="note" id="net-note">Bridges WiFi frames to a TAP interface on your machine via <code>tools/ws-net-proxy.py</code>.</div>
</div>
</aside>
<section class="terminal-panel">
<div class="term-toolbar">
<h2>UART0 console</h2>
<span class="hint">Typing here goes to the firmware</span>
<span class="spacer"></span>
<span class="count" id="uart-count">0 B</span>
<button id="clear-btn" class="btn small">Clear</button>
</div>
<div id="terminal-container"></div>
</section>
<aside class="side-panel">
<div class="panel-tabs">
<button class="ptab active" data-pane="peris">Peripherals</button>
<button class="ptab" data-pane="debug">Debug</button>
</div>
<div class="pane" id="pane-peris">
<div class="peris-toolbar">
<select id="peris-add" title="Add a peripheral"></select>
<button id="peris-add-btn" class="btn small primary">Add</button>
<button id="peris-export-btn" class="btn small" title="Download the wiring config as JSON">Export</button>
<button id="peris-import-btn" class="btn small" title="Load a wiring config from a JSON file">Import</button>
<input type="file" id="peris-import-file" accept=".json" hidden>
</div>
<div id="peris-errors"></div>
<div id="peris-cards"></div>
<div id="peris-empty">
<strong>No peripherals wired yet.</strong><br>
Pick one above and press <em>Add</em>, or drop a<br>
peris <em>.json</em> config here / press Import.
</div>
</div>
<div class="pane hidden" id="pane-debug">
<div class="panel-section">
<h3>Performance</h3>
<div class="tiles">
<div class="tile accent"><div class="k">Speed</div><div class="v" id="tile-mips">0.0<small>MIPS</small></div></div>
<div class="tile"><div class="k">vs. real time (live)</div><div class="v" id="tile-ratio">–</div></div>
<div class="tile"><div class="k">Emulated time</div><div class="v" id="tile-time">0.000<small>s</small></div></div>
<div class="tile"><div class="k">Cycles</div><div class="v" id="tile-cycles">0</div></div>
</div>
<div class="chart-wrap">
<canvas id="mips-chart" height="64"></canvas>
<div class="chart-tip" id="chart-tip"></div>
</div>
<div class="chart-caption"><span>MIPS, last 30 s</span><span id="chart-max"></span></div>
</div>
<div class="panel-section">
<h3>Cores <span class="tag" id="isa-tag">–</span></h3>
<div class="cores" id="cores"></div>
</div>
<div class="panel-section">
<h3>GPIO <span class="tag" id="gpio-count"></span></h3>
<div class="gpio-legend">
<span><i class="out-high"></i>output high</span>
<span><i class="out-low"></i>output low</span>
<span><i class="in-high"></i>input high</span>
<span><i class="in-low"></i>input low</span>
</div>
<div class="gpio-grid" id="gpio-grid"></div>
<div class="note">Click an input pin to drive it. Pins the firmware configured as outputs show what it drives.</div>
</div>
<div class="panel-section">
<h3>Registers <span class="tag" id="reg-tag">hart 0</span></h3>
<table class="reg-table" id="reg-table"></table>
</div>
<div class="panel-section">
<h3>Memory</h3>
<div class="mem-controls">
<input type="text" id="mem-addr" placeholder="0x3FC80000" spellcheck="false">
<select id="mem-len">
<option value="64">64 B</option>
<option value="256" selected>256 B</option>
<option value="1024">1 KiB</option>
</select>
<button id="mem-read-btn" class="btn small">Read</button>
</div>
<div class="mem-controls">
<label class="mem-follow"><input type="checkbox" id="mem-follow"> Refresh while running</label>
</div>
<div id="mem-display"><span class="empty">Enter an address and press Read.</span></div>
</div>
</div>
</aside>
</div>
<footer>
<span>ESP-EMU v0.51.0 + esp-emu-peris | Emulates ESP32-C3/C5/C6/H2, ESP32-P4/S31, ESP32-S3</span>
<span>Runs entirely in this tab · <a href="https://github.com/espressif/esp-emulator" target="_blank" rel="noopener">esp-emulator</a> · <a href="https://github.com/darceye/esp-emu-peris" target="_blank" rel="noopener">esp-emu-peris</a></span>
</footer>
<script src="peris/vendor/lib/xterm.js"></script>
<script src="peris/vendor/lib/xterm-addon-fit.js"></script>
<script type="module" src="peris/app.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"perisVersion": 1,
"chip": "esp32c3",
"devices": [
{ "id": "led-red", "type": "led", "name": "Red", "pin": 8, "color": "#ff4d4d" },
{ "id": "led-green", "type": "led", "name": "Green", "pin": 9, "color": "#49d49a" },
{ "id": "led-blue", "type": "led", "name": "Blue", "pin": 10, "color": "#6ea8fe" },
{ "id": "key-1", "type": "button", "name": "Boot key", "pin": 3, "activeLow": true },
{ "id": "touch-1", "type": "touch-button", "name": "Touch pad", "pin": 4, "activeLow": true },
{ "id": "sw-1", "type": "toggle-switch", "name": "Mode", "pin": 5, "activeLow": false, "initial": false }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"perisVersion": 1,
"chip": "esp32c3",
"devices": [
{ "id": "led-1", "type": "led", "name": "Blink LED (GPIO8)", "pin": 8, "color": "#ff4d4d", "invert": false },
{ "id": "btn-1", "type": "button", "name": "Probe button (GPIO3, ISR)", "pin": 3, "activeLow": true }
]
}
+1088
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
/*
* Per-chip GPIO facts, shared by the page and the worker.
* Mirrors app.js of the stock esp-emulator-browser UI.
*/
export const GPIO_COUNT = {
esp32c3: 22, esp32c5: 29, esp32c6: 31, esp32h2: 28,
esp32p4: 55, esp32s3: 49, esp32s31: 32,
};
export const GPIO_ABSENT = { esp32s3: new Set([22, 23, 24, 25]) };
export function gpioCount(chip) {
return GPIO_COUNT[chip] || 32;
}
export function validPins(chip) {
const n = gpioCount(chip);
const absent = GPIO_ABSENT[chip] || new Set();
const out = [];
for (let i = 0; i < n; i++) if (!absent.has(i)) out.push(i);
return out;
}
+55
View File
@@ -0,0 +1,55 @@
/*
* esp-emu-peris device API — version 1 contract (see docs/DEVICE-API.md).
*
* A peripheral is one ES module in peris/devices/ exporting:
*
* export class Device {
* static typeId = 'led'; // unique, stable, kebab-case
* static title = 'LED'; // shown in the UI
* static category = 'basic'; // grouping in the Add menu
* static description = '...'; // one line for tooltips
* static defaultConfig = {...}; // fresh instance defaults
* static configSchema = [ // generic editor fields
* { key:'pin', label:'GPIO pin', type:'pin' },
* { key:'color', label:'Colour', type:'color' },
* { key:'name', label:'Label', type:'text' },
* { key:'x', label:'...', type:'number'|'select'|'checkbox',
* min?, max?, step?, options?: [{value,label}] },
* ];
* static pins(cfg) { return [cfg.pin]; } // GPIO pins claimed (conflicts)
*
* constructor(ctx, cfg) {} // cfg = {id, type, ...device config}
* onPinState(pin, s) {} // sigbus: s = {out, oe, cycles} on change
* onCommand(cmd, params) {} // user interaction from the View
* onEmuReady() {} // (re)drive inputs after emulator (re)load
* getState() { return {...}; } // serialisable state for the View
* destroy() {}
* }
*
* export class View {
* constructor(el, sendCmd) {} // el = card body; sendCmd(cmd, params)
* render(state) {} // state from Device.getState()
* setConfig(cfg) {} // optional: react to config edits
* }
*
* ctx (worker side):
* ctx.id, ctx.chip, ctx.gpioCount
* ctx.setPinInput(pin, high) // drive a pad input (reaches interrupts)
* ctx.subscribe(pins), ctx.unsubscribe()
* ctx.readMem(addr, len), ctx.readU32(addr) // MMIO/bus reads
* ctx.cycles()
* ctx.markDirty() // ask for a state flush (~30 fps)
* ctx.log(text, level?)
*
* Register the module in peris/devices/manifest.json — that is the only
* other file a new peripheral touches.
*/
export const DEVICE_API_VERSION = 1;
/** Sanitise an incoming config against a device's defaultConfig/schema. */
export function normaliseConfig(meta, raw) {
const cfg = { ...(meta.defaultConfig || {}), ...(raw || {}) };
const known = new Set(['id', 'type', ...(meta.configSchema || []).map((f) => f.key)]);
for (const k of Object.keys(cfg)) if (!known.has(k)) delete cfg[k];
return cfg;
}
+78
View File
@@ -0,0 +1,78 @@
/*
* Push button (momentary) — drives a GPIO *input* level while held.
* Default wiring is active-low (pressed = 0 V) with a pull-up, the usual
* ESP32 board style. Verified in M0: gpio_set_input() changes reach the
* firmware and raise its GPIO interrupts.
*/
export class Device {
static typeId = 'button';
static title = 'Push button';
static category = 'basic';
static description = 'Momentary button driving a GPIO input (active-low by default).';
static defaultConfig = { name: 'Button', pin: 9, activeLow: true };
static pins(cfg) { return [cfg.pin]; }
static configSchema = [
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'activeLow', label: 'Active low (pull-up wiring)', type: 'checkbox' },
{ key: 'name', label: 'Label', type: 'text' },
];
constructor(ctx, cfg) {
this.ctx = ctx;
this.cfg = cfg;
this.pressed = false;
this._drive();
}
/* Active-low wiring (default): idle = pull-up HIGH, pressed = LOW. */
idleLevel() { return !!this.cfg.activeLow; }
pressedLevel() { return !this.cfg.activeLow; }
_drive() {
this.ctx.setPinInput(this.cfg.pin, this.pressed ? this.pressedLevel() : this.idleLevel());
}
onEmuReady() { this._drive(); } // re-drive after a new emulator instance
onCommand(cmd) {
if (cmd === 'press' && !this.pressed) {
this.pressed = true;
} else if (cmd === 'release' && this.pressed) {
this.pressed = false;
} else if (cmd !== 'press' && cmd !== 'release') {
return;
}
this._drive();
this.ctx.markDirty();
}
getState() { return { pressed: this.pressed }; }
destroy() {
this.ctx.setPinInput(this.cfg.pin, this.idleLevel());
this.ctx.unsubscribe();
}
}
export class View {
constructor(el, sendCmd) {
this.send = sendCmd;
el.className = 'dev-body button-body';
const btn = document.createElement('button');
btn.className = 'hw-button';
btn.textContent = 'PRESS';
const down = (e) => { e.preventDefault(); sendCmd('press'); btn.classList.add('down'); };
const up = (e) => { if (e) e.preventDefault(); sendCmd('release'); btn.classList.remove('down'); };
btn.addEventListener('pointerdown', down);
btn.addEventListener('pointerup', up);
btn.addEventListener('pointerleave', up);
btn.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') down(e); });
btn.addEventListener('keyup', up);
this.btn = btn;
el.appendChild(btn);
}
render(state) {
this.btn.classList.toggle('down', !!state.pressed);
this.btn.textContent = state.pressed ? 'PRESSED' : 'PRESS';
}
}
+73
View File
@@ -0,0 +1,73 @@
/*
* LED — single colour LED on a GPIO output.
* Reads the pad via the sigbus (out + output-enable), so it lights exactly
* when the firmware drives the pin (and shows "floating" when it does not).
*/
export class Device {
static typeId = 'led';
static title = 'LED';
static category = 'basic';
static description = 'Single-colour LED wired to a GPIO output.';
static defaultConfig = { name: 'LED', pin: 8, color: '#ff4d4d', invert: false };
static pins(cfg) { return [cfg.pin]; }
static configSchema = [
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'color', label: 'Colour', type: 'color' },
{ key: 'invert', label: 'Active low', type: 'checkbox' },
{ key: 'name', label: 'Label', type: 'text' },
];
constructor(ctx, cfg) {
this.ctx = ctx;
this.cfg = cfg;
this.on = false;
this.driven = false;
ctx.subscribe([cfg.pin]);
}
onPinState(pin, s) {
const on = s.oe && (s.out !== !!this.cfg.invert);
if (on !== this.on || s.oe !== this.driven) {
this.on = on;
this.driven = s.oe;
this.ctx.markDirty();
}
}
getState() {
return { on: this.on, driven: this.driven };
}
destroy() {
this.ctx.unsubscribe();
}
}
export class View {
constructor(el, sendCmd) {
this.el = el;
el.className = 'dev-body led-body';
const bulb = document.createElement('div');
bulb.className = 'led-bulb';
this.halo = document.createElement('div');
this.halo.className = 'led-halo';
bulb.appendChild(this.halo);
const cap = document.createElement('div');
cap.className = 'led-cap';
this.cap = cap;
const wrap = document.createElement('div');
wrap.className = 'led-wrap';
wrap.appendChild(bulb);
el.appendChild(wrap);
el.appendChild(cap);
}
render(state) {
const bulb = this.halo.parentElement;
bulb.classList.toggle('lit', !!state.on);
bulb.classList.toggle('floating', state.driven === false);
this.cap.textContent = state.driven === false ? 'not driven by firmware' : (state.on ? 'ON' : 'OFF');
}
setConfig(cfg) {
this.halo.parentElement.style.setProperty('--led-color', cfg.color || '#ff4d4d');
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"devices": [
{ "module": "led.js" },
{ "module": "button.js" },
{ "module": "touch-button.js" },
{ "module": "toggle-switch.js" }
]
}
+67
View File
@@ -0,0 +1,67 @@
/*
* Toggle switch (slide) — drives a GPIO input level, keeping the level
* while the emulator runs. Click to flip.
*/
export class Device {
static typeId = 'toggle-switch';
static title = 'Toggle switch';
static category = 'basic';
static description = 'Latching switch driving a GPIO input level.';
static defaultConfig = { name: 'Switch', pin: 11, activeLow: false, initial: false };
static pins(cfg) { return [cfg.pin]; }
static configSchema = [
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'activeLow', label: 'Active low', type: 'checkbox' },
{ key: 'initial', label: 'Initial ON state', type: 'checkbox' },
{ key: 'name', label: 'Label', type: 'text' },
];
constructor(ctx, cfg) {
this.ctx = ctx;
this.cfg = cfg;
this.on = !!cfg.initial;
this._drive();
}
level() { return this.on !== !!this.cfg.activeLow; }
_drive() {
this.ctx.setPinInput(this.cfg.pin, this.level());
}
onEmuReady() { this._drive(); }
onCommand(cmd, params) {
if (cmd === 'set') this.on = !!params?.on;
else if (cmd === 'toggle') this.on = !this.on;
else return;
this._drive();
this.ctx.markDirty();
}
getState() { return { on: this.on }; }
destroy() {
this.ctx.setPinInput(this.cfg.pin, !this.cfg.activeLow); // idle level
this.ctx.unsubscribe();
}
}
export class View {
constructor(el, sendCmd) {
this.send = sendCmd;
el.className = 'dev-body switch-body';
const wrap = document.createElement('label');
wrap.className = 'hw-switch';
wrap.innerHTML = `<input type="checkbox"><span class="sw-track"><span class="sw-knob"></span></span><span class="sw-text">OFF</span>`;
const input = wrap.querySelector('input');
input.addEventListener('change', () => sendCmd('set', { on: input.checked }));
this.input = input;
this.text = wrap.querySelector('.sw-text');
el.appendChild(wrap);
}
render(state) {
this.input.checked = !!state.on;
this.text.textContent = state.on ? 'ON' : 'OFF';
}
}
+46
View File
@@ -0,0 +1,46 @@
/*
* Touch button — a capacitive-touch styled input pad. Electrically it is
* the same as the push button: it drives a GPIO input level.
*
* NOTE: the esp-emulator does not emulate the touch-pad peripheral, so
* firmware written against the touch driver will not see this. Use GPIO
* input handling in firmware (see tests/firmware for an example).
*/
import { Device as ButtonDevice } from './button.js';
export class Device extends ButtonDevice {
static typeId = 'touch-button';
static title = 'Touch button';
static category = 'basic';
static description = 'Touch-style pad driving a GPIO input (touch peripheral itself is not emulated — firmware must read it as GPIO).';
static defaultConfig = { name: 'Touch', pin: 10, activeLow: true };
static pins(cfg) { return [cfg.pin]; }
static configSchema = ButtonDevice.configSchema;
// idleLevel/pressedLevel inherited from button.js (verified levels).
}
export class View {
constructor(el, sendCmd) {
this.send = sendCmd;
el.className = 'dev-body touch-body';
const pad = document.createElement('div');
pad.className = 'touch-pad';
pad.setAttribute('role', 'button');
pad.tabIndex = 0;
pad.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M9 11V5.5a1.5 1.5 0 0 1 3 0V11m0-2.5a1.5 1.5 0 0 1 3 0V11m0-1a1.5 1.5 0 0 1 3 0v4c0 3.9-2.6 6.5-6 6.5s-5.2-1.4-6.6-3.8l-1.8-3.1a1.6 1.6 0 0 1 2.7-1.7L8 13V6.5"/>
</svg><span class="touch-label">TOUCH</span>`;
const down = (e) => { e.preventDefault(); sendCmd('press'); pad.classList.add('active'); };
const up = (e) => { if (e) e.preventDefault(); sendCmd('release'); pad.classList.remove('active'); };
pad.addEventListener('pointerdown', down);
pad.addEventListener('pointerup', up);
pad.addEventListener('pointerleave', up);
pad.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') down(e); });
pad.addEventListener('keyup', up);
this.pad = pad;
el.appendChild(pad);
}
render(state) {
this.pad.classList.toggle('active', !!state.pressed);
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* MMIO register maps per chip (little-endian, byte addresses). Verified
* against ESP-IDF soc headers and the running emulator in M0 — see
* docs/FEASIBILITY.md. Used by register-level devices (PWM LED, WS2812).
*/
import esp32c3 from './mmio/esp32c3.json' with { type: 'json' };
const MAPS = { esp32c3 };
export function mmioFor(chip) {
return MAPS[chip] || null;
}
/** Resolve a dotted path like 'ledc.ch0.dutyR' to an address. */
export function regAddr(chip, path) {
const map = mmioFor(chip);
if (!map) return null;
let node = map;
for (const part of path.split('.')) {
node = node?.[part];
if (node == null) return null;
}
return typeof node === 'number' ? node : null;
}
+30
View File
@@ -0,0 +1,30 @@
{
"gpio": {
"base": 3700795392,
"out": 3700795396,
"outW1ts": 3700795400,
"outW1tc": 3700795404,
"enable": 3700795424,
"funcOutSel": "0x60004000 + 0x554 + 4*pin",
"funcOutSelBase": 3700795924
},
"ioMux": {
"base": 3700805632,
"note": "GPIO0..11 at base + 0x04 + 4*pin; LEDC0 signal = 0x2d, RMT ch0 signal = 0x33 in FUNCn_OUT_SEL (verified M0)"
},
"ledc": {
"base": 3774877696,
"note": "LSCHn layout, stride 0x14 per channel",
"chStride": 20,
"ch0": { "conf0": 3774877696, "hpoint": 3774877700, "duty": 3774877704, "conf1": 3774877708, "dutyR": 3774877712 },
"dutyShift": 4
},
"rmt": {
"base": 3774779392,
"ch0conf0": 3774779408,
"ch1conf0": 3774779412,
"memBase": 3774780416,
"memWordsPerChannel": 48,
"chStride": 192
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
* Message protocol between the page and the peris worker (v1).
* These ride on the same postMessage channel as the stock worker protocol.
*/
export const PERIS_API_VERSION = 1;
// page -> worker
export const PERIS_CONFIG = 'peris_config'; // { config } apply a full config
export const PERIS_COMMAND = 'peris_command'; // { id, cmd, params } view -> device
// worker -> page
export const PERIS_READY = 'peris_ready'; // { ok, errors, devices }
export const PERIS_STATE = 'peris_state'; // { id, state } device state update
export const PERIS_LOG = 'peris_log'; // { level, text }
// Config file shape (also the localStorage shape):
// {
// perisVersion: 1,
// chip: 'esp32c3',
// devices: [ { id, type, ...deviceConfig } ]
// }
+41
View File
@@ -0,0 +1,41 @@
/*
* Device plugin registry. Both the page (for Views) and the worker (for
* Devices) import device modules through the manifest, so adding a
* peripheral is: drop a module into peris/devices/ and add one line to
* peris/devices/manifest.json. Nothing else in the tree changes.
*/
export async function loadRegistry(baseUrl) {
const url = (p) => new URL(p, baseUrl).href;
// Manifest module paths are relative to peris/devices/.
const deviceUrl = (m) => (/^([a-z]+:|\/)/i.test(m) ? m : 'devices/' + m.replace(/^\.\//, ''));
const manifest = await (await fetch(url('devices/manifest.json'))).json();
const modules = new Map();
const errors = [];
for (const entry of manifest.devices) {
const modPath = entry.module?.module || entry.module;
try {
const mod = await import(url(deviceUrl(modPath)));
const type = mod.Device?.typeId;
if (!type) throw new Error('module does not export Device with typeId');
if (modules.has(type)) throw new Error(`duplicate typeId "${type}"`);
modules.set(type, { mod, meta: mod.Device });
} catch (e) {
errors.push(`${entry.module || entry}: ${e.message}`);
}
}
return {
errors,
types() {
return [...modules.values()].map((m) => ({
typeId: m.meta.typeId,
title: m.meta.title || m.meta.typeId,
category: m.meta.category || 'misc',
description: m.meta.description || '',
}));
},
has: (type) => modules.has(type),
meta: (type) => modules.get(type)?.meta,
Device: (type) => modules.get(type)?.mod.Device,
View: (type) => modules.get(type)?.mod.View,
};
}
+153
View File
@@ -0,0 +1,153 @@
/*
* 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),
};
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
* Pin signal bus (worker side). After every run_batch the worker calls
* sample(); changed pins are dispatched to the devices watching them.
* Devices subscribe through a *getter* because they typically call
* ctx.subscribe() from inside their constructor, before the runtime has
* the instance registered — the getter is resolved lazily at dispatch.
* Cost per batch with no changes: one gpio_state() call and four XORs.
*/
export class Sigbus {
constructor(emu) {
this.emu = emu; // WasmEmulator or null before load
this.subs = new Map(); // pin -> Set<() => device>
this.lastOut0 = 0; this.lastOut1 = 0;
this.lastOe0 = 0; this.lastOe1 = 0;
}
attach(emu) {
this.emu = emu;
this.lastOut0 = this.lastOut1 = this.lastOe0 = this.lastOe1 = 0;
}
subscribe(getDev, pins) {
for (const p of pins) {
if (!this.subs.has(p)) this.subs.set(p, new Set());
this.subs.get(p).add(getDev);
}
}
unsubscribe(getDev) {
for (const set of this.subs.values()) set.delete(getDev);
}
setInput(pin, high) {
if (this.emu) this.emu.gpio_set_input(pin, !!high);
}
/** Read gpio_state() and notify subscribers of changed pins. */
sample() {
if (this.subs.size === 0 || !this.emu) return;
const s = this.emu.gpio_state();
const out0 = s[0] >>> 0, out1 = s[1] >>> 0, oe0 = s[2] >>> 0, oe1 = s[3] >>> 0;
const chOut0 = out0 ^ this.lastOut0, chOut1 = out1 ^ this.lastOut1;
const chOe0 = oe0 ^ this.lastOe0, chOe1 = oe1 ^ this.lastOe1;
this.lastOut0 = out0; this.lastOut1 = out1;
this.lastOe0 = oe0; this.lastOe1 = oe1;
if ((chOut0 | chOut1 | chOe0 | chOe1) === 0) return;
const cycles = this.emu.cycles();
const dispatch = (changed, base, outWord, oeWord) => {
let bits = changed;
while (bits) {
const low = bits & (bits - 1); // clear lowest set bit
const bitIdx = 31 - Math.clz32(bits ^ low); // index of that bit
const pin = base + bitIdx;
const set = this.subs.get(pin);
if (set) {
const s2 = {
out: ((outWord >>> bitIdx) & 1) !== 0,
oe: ((oeWord >>> bitIdx) & 1) !== 0,
cycles,
};
for (const getDev of set) {
const dev = getDev();
if (dev) dev.onPinState?.(pin, s2);
}
}
bits = low;
}
};
dispatch(chOut0, 0, out0, oe0);
dispatch(chOut1, 32, out1, oe1);
dispatch(chOe0, 0, out0, oe0); // oe changes re-report current out
dispatch(chOe1, 32, out1, oe1);
}
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rLine truncated
//# sourceMappingURL=xterm.js.map
+383
View File
@@ -0,0 +1,383 @@
/*
* Web Worker running the emulation loop with esp-emu-peris hooks.
*
* This is the stock esp-emulator-browser worker.js (v0.51.0) with three
* additions, marked "peris:":
* 1. A PerisRuntime instance owning the peripheral devices.
* 2. runtime.onBatchEnd() after every run_batch (pin sampling + state
* flush to the page).
* 3. peris_config / peris_command message handlers.
* Everything else — load flow, throttle, snapshots, networking, stepping —
* behaves exactly like the stock worker. See docs/UPGRADE.md for how to
* re-sync when the upstream worker changes.
*/
import { PerisRuntime } from './runtime.js';
import { PERIS_CONFIG, PERIS_COMMAND } from './protocol.js';
let wasm = null;
let emulator = null;
let running = false;
let batchSize = 50000;
let pendingLoad = null;
let ws = null;
// peris: peripheral device runtime
const peris = new PerisRuntime();
// Live-view state: how often a snapshot (registers, GPIO, watched memory)
// goes to the page while running, and which memory window it follows.
const SNAPSHOT_INTERVAL_MS = 150;
let lastSnapshot = 0;
let memWatch = null; // { addr, len } or null
// Global error handler — catches WASM panics and unhandled exceptions
self.onerror = function(msg, src, line, col, err) {
postMessage({ type: 'error', message: `Worker error: ${msg}` });
};
self.onunhandledrejection = function(e) {
postMessage({ type: 'error', message: `Worker promise rejected: ${e.reason}` });
};
// Import wasm module
async function initWasm(wasmUrl) {
try {
const { default: init, WasmEmulator } = await import(wasmUrl);
await init();
wasm = { WasmEmulator };
postMessage({ type: 'ready' });
// peris: load the device registry (manifest + modules); failures
// are reported on the console, not fatal.
try {
await peris.init();
} catch (e) {
console.warn('peris registry failed:', e);
}
// Process any load request that arrived while WASM was initializing
if (pendingLoad) {
const msg = pendingLoad;
pendingLoad = null;
handleLoad(msg);
}
} catch (e) {
postMessage({ type: 'error', message: `Failed to init WASM: ${e.message}` });
}
}
// What the page needs to know about the machine it is looking at.
function machineInfo() {
return {
pc: emulator.pc(),
isXtensa: emulator.is_xtensa(),
numHarts: emulator.num_harts(),
cyclesPerUs: emulator.cycles_per_us(),
};
}
function handleLoad(msg) {
const chip = msg.chip || 'esp32c3';
try {
emulator = new wasm.WasmEmulator(chip);
if (msg.ssid) {
emulator.set_wifi_config(msg.ssid, msg.password || '');
}
if (msg.rom) {
emulator.load_rom_elf(new Uint8Array(msg.rom));
} else if (emulator.has_default_rom()) {
emulator.load_default_rom();
} else {
throw new Error(`No ROM provided and no embedded default for chip ${chip}`);
}
if (msg.efuse) {
emulator.load_efuse(new Uint8Array(msg.efuse));
}
emulator.set_boot_from_rom(!msg.skipRom);
const data = new Uint8Array(msg.firmware);
emulator.load_firmware(data);
peris.attachEmu(emulator, chip); // peris: new emulator instance
postMessage({ type: 'loaded', chip, ...machineInfo() });
sendSnapshot(true);
} catch (err) {
postMessage({ type: 'error', message: `Load failed: ${err}` });
}
}
// --- WebSocket networking ---
function connectNetwork(url) {
if (ws) {
ws.close();
ws = null;
}
try {
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
postMessage({ type: 'net_status', connected: true });
};
ws.onclose = function() {
postMessage({ type: 'net_status', connected: false });
ws = null;
};
ws.onerror = function() {
postMessage({ type: 'error', message: `WebSocket connection failed: ${url}` });
ws = null;
};
ws.onmessage = function(e) {
// Receive Ethernet frame from TAP proxy → push into emulator WiFi RX
if (emulator && e.data instanceof ArrayBuffer && e.data.byteLength >= 14) {
emulator.wifi_rx_push(new Uint8Array(e.data));
}
};
} catch (e) {
postMessage({ type: 'error', message: `WebSocket error: ${e.message}` });
}
}
function disconnectNetwork() {
if (ws) {
ws.close();
ws = null;
}
}
function drainTxToNetwork() {
if (!ws || ws.readyState !== WebSocket.OPEN || !emulator) return;
// wifi_tx_drain returns length-prefixed frames: [u32 len][frame bytes]...
const buf = emulator.wifi_tx_drain();
if (buf.length === 0) return;
let offset = 0;
while (offset + 4 <= buf.length) {
const len = buf[offset] | (buf[offset + 1] << 8) | (buf[offset + 2] << 16) | (buf[offset + 3] << 24);
offset += 4;
if (offset + len > buf.length) break;
// Copy into a new ArrayBuffer to avoid sending the entire backing buffer
const frame = new Uint8Array(buf.buffer.slice(buf.byteOffset + offset, buf.byteOffset + offset + len));
ws.send(frame.buffer);
offset += len;
}
}
// Handle messages from main thread
onmessage = function(e) {
const msg = e.data;
switch (msg.type) {
case 'init':
initWasm(msg.wasmUrl);
break;
case 'load':
if (!wasm) {
pendingLoad = msg;
postMessage({ type: 'error', message: 'WASM still loading, please wait...' });
} else {
handleLoad(msg);
}
break;
case 'start':
running = true;
runLoop();
break;
case 'stop':
running = false;
sendSnapshot(true);
peris.flushStates(true); // peris: final state on pause
break;
case 'step': {
if (!emulator) break;
let output = emulator.run_batch(1);
peris.onBatchEnd(); // peris
if (emulator.needs_restart()) {
if (output) postMessage({ type: 'uart_output', data: output });
output = '';
try {
emulator.restart();
postMessage({ type: 'restarted' });
} catch (err) {
postMessage({ type: 'error', message: `Restart failed: ${err}` });
}
}
postMessage({
type: 'step',
output: output,
pc: emulator.pc(),
cycles: emulator.cycles(),
});
sendSnapshot(true);
peris.flushStates(true); // peris
break;
}
case 'reset': {
running = false;
if (emulator) {
try {
emulator.restart();
postMessage({ type: 'reset', reloaded: true, ...machineInfo() });
sendSnapshot(true);
} catch (err) {
postMessage({ type: 'error', message: `Reset failed: ${err}` });
postMessage({ type: 'reset', reloaded: false });
}
} else {
postMessage({ type: 'reset', reloaded: false });
}
break;
}
case 'uart_input':
if (emulator) {
emulator.uart_input(new Uint8Array(msg.data));
}
break;
case 'gpio_input':
if (emulator) {
emulator.gpio_set_input(msg.pin, !!msg.high);
sendSnapshot(true);
}
break;
case 'read_mem':
if (emulator) {
postMessage({
type: 'mem',
addr: msg.addr >>> 0,
bytes: emulator.read_memory(msg.addr >>> 0, msg.len),
});
}
break;
case 'mem_watch':
memWatch = msg.addr == null ? null : { addr: msg.addr >>> 0, len: msg.len };
if (memWatch && emulator) sendSnapshot(true);
break;
case 'net_connect':
connectNetwork(msg.url);
break;
case 'net_disconnect':
disconnectNetwork();
break;
case 'set_batch_size':
batchSize = msg.size;
break;
case PERIS_CONFIG: // peris
peris.applyConfig(msg.config);
break;
case PERIS_COMMAND: // peris
peris.command(msg.id, msg.cmd, msg.params);
break;
}
};
function runLoop() {
if (!running || !emulator) return;
const startTime = performance.now();
let totalCycles = 0;
let allOutput = '';
// Wait before the next batch for the drift the emulator reports; the
// worker cannot block, so setTimeout sleeps. Capped with the network up
// so queued frames are not left waiting.
let nextDelay = 0;
// Run multiple batches per animation frame for throughput
while (running) {
const output = emulator.run_batch(batchSize);
allOutput += output;
totalCycles += batchSize;
peris.onBatchEnd(); // peris: pin sample + throttled state flush
// Handle software restart (esp_restart / OTA reboot)
if (emulator.needs_restart()) {
// Flush output before restart
if (allOutput.length > 0) {
postMessage({ type: 'uart_output', data: allOutput });
allOutput = '';
}
try {
emulator.restart();
postMessage({ type: 'uart_output', data: '\r\n' });
postMessage({ type: 'restarted', ...machineInfo() });
} catch (err) {
postMessage({ type: 'error', message: `Restart failed: ${err}` });
running = false;
}
break;
}
// Drain TX frames every batch to minimize network latency
drainTxToNetwork();
const drift = emulator.throttle_delay_ms(performance.now());
if (drift > 0) {
nextDelay = ws ? Math.min(drift, 5) : drift;
break;
}
// Yield every ~16ms for responsiveness
if (performance.now() - startTime > 12) break;
}
if (allOutput.length > 0) {
postMessage({ type: 'uart_output', data: allOutput });
}
// Send status update. A batch that ran for less than a millisecond has
// no meaningful rate; report it as 0 rather than a huge or infinite one.
const elapsedMs = performance.now() - startTime;
postMessage({
type: 'status',
pc: emulator.pc(),
cycles: emulator.cycles(),
mips: elapsedMs >= 1 ? (totalCycles / elapsedMs / 1000).toFixed(1) : '0.0',
now: performance.now(),
});
sendSnapshot(false);
if (running) {
setTimeout(runLoop, nextDelay);
}
}
// Registers, per-hart PCs, GPIO pads and the watched memory window, rate
// limited while running; `force` sends one regardless (step, pause, load).
function sendSnapshot(force) {
if (!emulator) return;
const now = performance.now();
if (!force && now - lastSnapshot < SNAPSHOT_INTERVAL_MS) return;
lastSnapshot = now;
const harts = emulator.num_harts();
const pcs = [];
for (let h = 0; h < harts; h++) pcs.push(emulator.hart_pc(h));
const snap = {
type: 'snapshot',
regs: Array.from(emulator.registers()),
pcs,
gpio: Array.from(emulator.gpio_state()),
cycles: emulator.cycles(),
};
if (memWatch) {
snap.mem = { addr: memWatch.addr, bytes: emulator.read_memory(memWatch.addr, memWatch.len) };
}
postMessage(snap);
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
esp-emu-peris dev server.
Serves this repository (the peris UI) merged with the stock esp-emulator
browser bundle, so the page can load /pkg/esp_emu.js + wasm from the
tools checkout without copying anything into it.
Routes:
/pkg/* -> <www>/pkg/*
everything else -> this repository
Usage: python serve.py [port] [--www PATH]
default port 8080, default www ../_tools/esp-emu-browser/www
"""
import argparse
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
REPO = os.path.dirname(os.path.abspath(__file__))
MIME = {
'.wasm': 'application/wasm',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
}
class Handler(SimpleHTTPRequestHandler):
www_dir = REPO # class attribute set in main()
def end_headers(self):
self.send_header('Cache-Control', 'no-store')
super().end_headers()
def guess_type(self, path):
ext = os.path.splitext(path)[1].lower()
return MIME.get(ext, super().guess_type(path))
def translate_path(self, path):
path = unquote(urlparse(path).path)
if path.startswith('/pkg/'):
rel = os.path.relpath(os.path.join(self.www_dir, path.lstrip('/')), self.www_dir)
if not rel.startswith('..'):
return os.path.join(self.www_dir, rel)
# serve repository files (and only those) for everything else
rel = os.path.relpath(os.path.join(REPO, path.lstrip('/')), REPO)
if rel.startswith('..'):
self.send_error(403, 'outside root')
return os.devnull
return os.path.join(REPO, rel)
def main():
default_www = os.path.normpath(os.path.join(REPO, '..', '_tools', 'esp-emu-browser', 'www'))
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('port', nargs='?', type=int, default=8080)
ap.add_argument('--www', default=default_www, help='esp-emu-browser www directory')
args = ap.parse_args()
if not os.path.isdir(os.path.join(args.www, 'pkg')):
print(f'error: {args.www}/pkg not found — pass --www /path/to/esp-emu-browser/www', file=sys.stderr)
sys.exit(1)
Handler.www_dir = args.www
url = f'http://127.0.0.1:{args.port}/esp-emu-peris.html'
print(f'serving repo : {REPO}')
print(f'serving /pkg : {args.www}/pkg')
print(f'open : {url}')
ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever()
if __name__ == '__main__':
main()
+3
View File
@@ -0,0 +1,3 @@
@echo off
rem Publish esp-emu-peris into the esp-emu-browser www directory.
python "%~dp0sync_www.py" %*
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Publish esp-emu-peris into an esp-emu-browser www directory: copies the
entry HTML, the peris/ tree and examples/. Existing stock files
(index.html, app.js, worker.js, pkg/) are never touched.
Usage: python sync_www.py [--www PATH] [--clean]
default www ../_tools/esp-emu-browser/www
--clean removes a previously synced peris/ from www first
"""
import argparse
import os
import shutil
REPO = os.path.dirname(os.path.abspath(__file__))
ITEMS = ['esp-emu-peris.html', 'peris', 'examples']
def sync(www, clean=False):
ok = True
for name in ITEMS:
src = os.path.join(REPO, name)
dst = os.path.join(www, name)
if not os.path.exists(src):
print(f'skip (missing in repo): {name}')
continue
if clean and os.path.isdir(dst):
shutil.rmtree(dst)
try:
if os.path.isdir(src):
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
shutil.copy2(src, dst)
print(f'copied: {name}')
except OSError as e:
print(f'FAILED: {name}: {e}')
ok = False
return ok
def main():
default_www = os.path.normpath(os.path.join(REPO, '..', '_tools', 'esp-emu-browser', 'www'))
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--www', default=default_www, help='esp-emu-browser www directory')
ap.add_argument('--clean', action='store_true', help='remove old peris/ in www before copying')
args = ap.parse_args()
if not os.path.isdir(args.www):
raise SystemExit(f'error: {args.www} is not a directory')
ok = sync(args.www, args.clean)
print('done' if ok else 'done with errors')
raise SystemExit(0 if ok else 1)
if __name__ == '__main__':
main()
+20
View File
@@ -0,0 +1,20 @@
# Build helper for esp-emu-peris test firmware.
# Usage: powershell -ExecutionPolicy Bypass -NoProfile -File build.ps1 [app-dir]
# Loads the same Espressif environment as C:\Users\A\Desktop\quick\idfm.bat
# (C:\Espressif\tools\Microsoft.master.PowerShell_profile.ps1), then runs
# idf.py set-target esp32c3 + idf.py build in the app directory.
param(
[string]$AppDir = "$PSScriptRoot\peris_probe",
[string]$Target = "esp32c3"
)
. 'C:\Espressif\tools\Microsoft.master.PowerShell_profile.ps1' | Out-Null
$env:MSYSTEM = $null
Set-Location $AppDir
if (-not (Test-Path "sdkconfig") -or -not (Test-Path "build")) {
idf.py set-target $Target
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
idf.py build
exit $LASTEXITCODE
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(peris_probe)
@@ -0,0 +1,3 @@
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES esp_driver_gpio esp_driver_rmt esp_driver_ledc)
+193
View File
@@ -0,0 +1,193 @@
/*
* peris_probe — M0 feasibility firmware for esp-emu-peris.
*
* Runs three phases in an endless loop, each announced on UART0 with
* distinctive "###" markers so an external probe can sync on them:
*
* P1 plain GPIO blink on GPIO 8 (2 Hz) — proves gpio_state() polling.
* While blinking, GPIO 3 is an input with a falling-edge ISR whose
* count is printed every half period — proves gpio_set_input() can
* drive pins *and* raise interrupts in the guest.
* P2 LEDC breathing on GPIO 9 (5 kHz / 13-bit duty ramp) — lets the
* probe check whether PWM is observable at the pad register and
* whether the LEDC duty registers track the requested duty.
* P2b one-shot extremes (duty 0 / 50% / max for 300 ms each) so duty
* estimates have flat, unambiguous windows to be measured against.
* P3 RMT WS2812 bursts on GPIO 4 (8 LEDs, colour walk, frame logged
* *before* it is sent) — lets the probe switch to high-rate pin
* sampling in time to catch the waveform, and separately dump the
* RMT channel RAM to decode the frame from symbols.
*
* The driver paths used here mirror esp-emulator's own smoke test app
* (gpio_config, legacy ledc_* driver, rmt_new_tx_channel + copy encoder),
* which the emulator is known to support.
*/
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "driver/rmt_tx.h"
#define PIN_GPIO 8 /* P1: plain blink */
#define PIN_BTN 3 /* P1: driven input + ISR */
#define PIN_LEDC 9 /* P2: LEDC PWM */
#define PIN_RMT 4 /* P3: RMT WS2812 */
#define LED_COUNT 8
#define RMT_RES_HZ (10 * 1000 * 1000) /* 0.1 us per RMT tick */
static rmt_channel_handle_t s_chan;
static rmt_encoder_handle_t s_enc;
static rmt_symbol_word_t s_syms[LED_COUNT * 24 + 1];
static volatile int s_btn_count;
static void IRAM_ATTR btn_isr(void *arg)
{
s_btn_count++;
}
/* GRB order, MSB first. T1H/T1L = 0.8/0.5 us, T0H/T0L = 0.4/0.8 us at
* 0.1 us per tick, trailing >60 us low reset. */
static void ws2812_build(uint32_t rgb)
{
int n = 0;
for (int led = 0; led < LED_COUNT; led++) {
uint8_t grb[3] = { (rgb >> 8) & 0xFF, (rgb >> 16) & 0xFF, rgb & 0xFF };
for (int b = 0; b < 24; b++, n++) {
int one = (grb[b / 8] >> (7 - (b % 8))) & 1;
s_syms[n].duration0 = one ? 8 : 4;
s_syms[n].level0 = 1;
s_syms[n].duration1 = one ? 5 : 8;
s_syms[n].level1 = 0;
}
}
s_syms[n].duration0 = 600;
s_syms[n].level0 = 0;
s_syms[n].duration1 = 1;
s_syms[n].level1 = 0;
}
static void ws2812_send(void)
{
rmt_transmit_config_t tx = { .loop_count = 0 };
esp_err_t err = rmt_transmit(s_chan, s_enc, s_syms, sizeof(s_syms), &tx);
if (err != ESP_OK) {
printf("###ERR rmt_transmit %d\n", err);
return;
}
err = rmt_tx_wait_all_done(s_chan, 100);
if (err != ESP_OK) {
printf("###ERR rmt_wait %d\n", err);
}
}
void app_main(void)
{
gpio_config_t io = {
.pin_bit_mask = (1ULL << PIN_GPIO) | (1ULL << PIN_BTN),
.mode = GPIO_MODE_OUTPUT_OD, /* patched below, see pins */
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
/* Split config: PIN_GPIO is a push-pull output, PIN_BTN a pulled-up
* input with a falling-edge ISR. */
io.mode = GPIO_MODE_OUTPUT;
io.pin_bit_mask = 1ULL << PIN_GPIO;
ESP_ERROR_CHECK(gpio_config(&io));
io.mode = GPIO_MODE_INPUT;
io.pin_bit_mask = 1ULL << PIN_BTN;
io.pull_up_en = GPIO_PULLUP_ENABLE;
io.intr_type = GPIO_INTR_NEGEDGE;
ESP_ERROR_CHECK(gpio_config(&io));
ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_isr_handler_add(PIN_BTN, btn_isr, NULL));
ledc_timer_config_t t = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = LEDC_TIMER_13_BIT,
.timer_num = LEDC_TIMER_0,
.freq_hz = 5000,
.clk_cfg = LEDC_AUTO_CLK,
};
ESP_ERROR_CHECK(ledc_timer_config(&t));
ledc_channel_config_t c = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL_0,
.timer_sel = LEDC_TIMER_0,
.intr_type = LEDC_INTR_DISABLE,
.gpio_num = PIN_LEDC,
.duty = 0,
.hpoint = 0,
};
ESP_ERROR_CHECK(ledc_channel_config(&c));
rmt_tx_channel_config_t rc = {
.clk_src = RMT_CLK_SRC_DEFAULT,
.gpio_num = PIN_RMT,
.mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL,
.resolution_hz = RMT_RES_HZ,
.trans_queue_depth = 4,
};
ESP_ERROR_CHECK(rmt_new_tx_channel(&rc, &s_chan));
rmt_copy_encoder_config_t ec = {};
ESP_ERROR_CHECK(rmt_new_copy_encoder(&ec, &s_enc));
ESP_ERROR_CHECK(rmt_enable(s_chan));
printf("###READY\n");
int round = 0;
while (1) {
printf("###ROUND %d\n", round++);
/* --- P1: blink + count probe-driven falling edges on PIN_BTN --- */
s_btn_count = 0;
printf("###P1 start\n");
for (int i = 0; i < 8; i++) {
gpio_set_level(PIN_GPIO, i & 1);
printf("###P1 i=%d btn=%d\n", i, s_btn_count);
vTaskDelay(pdMS_TO_TICKS(250));
}
gpio_set_level(PIN_GPIO, 0);
printf("###P1 end btn=%d\n", s_btn_count);
/* --- P2: duty ramp, then flat extremes for clean measurement --- */
printf("###P2 start\n");
for (int step = 0; step <= 32; step++) {
uint32_t duty = step <= 16 ? (uint32_t)step * 512 : (uint32_t)(32 - step) * 512;
if (duty > 8191) {
duty = 8191;
}
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
printf("###P2 d=%lu\n", (unsigned long)duty);
vTaskDelay(pdMS_TO_TICKS(100));
}
static const struct { uint32_t duty; const char *tag; } flats[] = {
{ 0, "zero" },
{ 4096, "half" },
{ 8191, "max" },
};
for (int k = 0; k < 3; k++) {
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, flats[k].duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
printf("###P2 flat=%s d=%lu\n", flats[k].tag, (unsigned long)flats[k].duty);
vTaskDelay(pdMS_TO_TICKS(300));
}
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
printf("###P2 end\n");
/* --- P3: WS2812 frames; log before sending so a probe that
* switches to fine sampling on the marker catches the waveform --- */
printf("###P3 start\n");
for (int f = 0; f < 8; f++) {
uint32_t rgb = 0x111111u * (uint32_t)(f + 1); /* 0x111111..0x888888 */
printf("###P3 f=%d c=0x%06lx\n", f, (unsigned long)rgb);
ws2812_build(rgb);
ws2812_send();
vTaskDelay(pdMS_TO_TICKS(400));
}
printf("###P3 end\n");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
# Small, quiet build: no BT, size-optimised, single large app partition.
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/*
* Focused M0 debug: dump raw LEDC duty registers during P2 flats and raw
* RMT channel-0 RAM symbols during P3, plus cycles() behaviour across a
* small-batch window. Run: node tests/m0_debug.mjs
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL, fileURLToPath } from 'node:url';
import path from 'node:path';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WWW = 'D:/d3/_git/esp_proj/_tools/esp-emu-browser/www';
const FW = path.join(REPO, 'tests/firmware/peris_probe/build/merged-binary.bin');
const { initSync, WasmEmulator } = await import(
pathToFileURL(path.join(WWW, 'pkg/esp_emu.js')).href
);
initSync(readFileSync(path.join(WWW, 'pkg/esp_emu_bg.wasm')));
const emu = new WasmEmulator('esp32c3');
if (emu.has_default_rom()) emu.load_default_rom();
emu.set_boot_from_rom(true);
emu.load_firmware(new Uint8Array(readFileSync(FW)));
const readU32 = (a) => { const b = emu.read_memory(a, 4); return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0; };
const LEDC_DUTY = 0x60019008, LEDC_DUTY_R = 0x60019010;
const RMTMEM = 0x60016400;
let uart = '', scan = 0, phase = 'boot', done = false, batch = 0;
let dutyCtx = null, dutyLogs = [], memDumps = [], cyclesLog = [], dbgFine = 0;
function handle(line) {
let m;
if (line === '###P2 start') phase = 'P2';
else if ((m = line.match(/^###P2 flat=(\w+) d=(\d+)/))) dutyCtx = `${m[1]}(${m[2]})`;
else if (line === '###P2 end') phase = 'idle';
else if (line === '###P3 start') phase = 'P3';
else if ((m = line.match(/^###P3 f=(\d+) c=0x([0-9a-fA-F]{6})/))) {
if (dbgFine < 2) { dbgFine++; debugFineWindow(); }
}
else if (line === '###P3 end') done = true;
}
function processUart() {
for (;;) {
const nl = uart.indexOf('\n', scan);
if (nl < 0) break;
const line = uart.slice(scan, nl).trim();
scan = nl + 1;
if (line.startsWith('###')) handle(line);
if (done) return;
}
}
function debugFineWindow() {
// Log cycles behaviour and RMT RAM across consecutive tiny batches.
const start = emu.cycles();
for (let i = 0; i < 4000 && emu.cycles() - start < 4_000_000; i++) {
const out = emu.run_batch(40);
if (out) { uart += out; processUart(); if (done) break; }
if (i < 40 || i % 400 === 0) {
const s = emu.gpio_state();
cyclesLog.push({
i, cycles: emu.cycles(), dc: emu.cycles() - start,
out4: (s[0] >> 4) & 1, oe4: (s[2] >> 4) & 1,
});
}
}
// RAM dump right after the window.
const bytes = emu.read_memory(RMTMEM, 48 * 4);
const words = [];
for (let w = 0; w < 48; w++) words.push((bytes[w * 4] | (bytes[w * 4 + 1] << 8) | (bytes[w * 4 + 2] << 16) | (bytes[w * 4 + 3] << 24)) >>> 0);
memDumps.push({ at: emu.cycles(), words });
}
while (!done) {
const out = emu.run_batch(50000);
if (out) { uart += out; processUart(); }
if (done) break;
if (phase === 'P2' && dutyCtx && batch % 64 === 0 && dutyLogs.length < 40) {
dutyLogs.push({ ctx: dutyCtx, duty: readU32(LEDC_DUTY).toString(16), dutyR: readU32(LEDC_DUTY_R).toString(16) });
}
if (phase === 'P3' && batch % 4 === 0 && memDumps.length < 4) {
const bytes = emu.read_memory(RMTMEM, 48 * 4);
const words = [];
for (let w = 0; w < 48; w++) words.push((bytes[w * 4] | (bytes[w * 4 + 1] << 8) | (bytes[w * 4 + 2] << 16) | (bytes[w * 4 + 3] << 24)) >>> 0);
if (words.filter((w) => w !== 0).length >= 10) memDumps.push({ at: emu.cycles(), words });
}
if (++batch > 400_000) throw new Error('timeout');
}
console.log('--- LEDC duty regs during flats (ctx, DUTY, DUTY_R hex) ---');
for (const d of dutyLogs.slice(0, 12)) console.log(` ${d.ctx} duty=0x${d.duty} duty_r=0x${d.dutyR}`);
console.log('\n--- cycles()/pin behaviour across run_batch(40) window ---');
for (const c of cyclesLog) console.log(` i=${c.i} cycles=${c.cycles} delta=${c.dc} out4=${c.out4} oe4=${c.oe4}`);
console.log('\n--- RMTMEM ch0 dumps ---');
memDumps.forEach((d, k) => {
console.log(` dump#${k} at cycles=${d.at}:`);
for (let w = 0; w < 16; w++) console.log(` [${w.toString(2).padStart(2, '0')}] 0x${d.words[w].toString(16).padStart(8, '0')} d0=${d.words[w] & 0x7fff} l0=${(d.words[w] >> 15) & 1} d1=${(d.words[w] >> 16) & 0x7fff} l1=${(d.words[w] >> 31) & 1}`);
});
+397
View File
@@ -0,0 +1,397 @@
#!/usr/bin/env node
/*
* M0 feasibility probe for esp-emu-peris.
*
* Loads the esp-emulator browser wasm bundle in Node (via initSync, no
* fetch needed) and runs the peris_probe firmware against it, answering:
*
* Q1a Are guest GPIO outputs observable through gpio_state()? (P1 blink)
* Q1b Can gpio_set_input() drive a pad *and* raise guest interrupts?
* (P1: probe toggles GPIO3, firmware counts falling-edge ISRs)
* Q2a Is LEDC PWM observable at the pad register, and does a statistical
* duty estimate at coarse batch size track the real duty?
* Q2b Are LEDC duty registers readable through read_memory()?
* Q3a Is the RMT/WS2812 waveform observable at the pin level using small
* run_batch() sizes ("fine sampling")?
* Q3b Is the RMT channel RAM readable and decodable via read_memory()?
*
* Usage: node tests/m0_probe.mjs [firmware.bin] [--json]
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL, fileURLToPath } from 'node:url';
import path from 'node:path';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WWW = 'D:/d3/_git/esp_proj/_tools/esp-emu-browser/www';
const FW = process.argv[2] ?? path.join(REPO, 'tests/firmware/peris_probe/build/merged-binary.bin');
// --- ESP32-C3 MMIO map (from IDF soc headers, see docs/FEASIBILITY.md) ---
const GPIO_BASE = 0x60004000;
const MMIO = {
GPIO_FUNC_OUT_SEL: (pin) => GPIO_BASE + 0x554 + 4 * pin,
IO_MUX: (pin) => 0x60009000 + 0x04 + 4 * pin, // valid for GPIO0..11
LEDC_CH0_DUTY: 0x60019008,
LEDC_CH0_DUTY_R: 0x60019010,
RMT_CH0CONF0: 0x60016010,
RMTMEM_CH0: 0x60016400,
RMTMEM_CH0_WORDS: 48,
};
const PIN_BLINK = 8, PIN_BTN = 3, PIN_LEDC = 9, PIN_RMT = 4;
// ---------------------------------------------------------------- wasm boot
const { initSync, WasmEmulator } = await import(
pathToFileURL(path.join(WWW, 'pkg/esp_emu.js')).href
);
initSync(readFileSync(path.join(WWW, 'pkg/esp_emu_bg.wasm')));
const emu = new WasmEmulator('esp32c3');
if (emu.has_default_rom()) emu.load_default_rom();
emu.set_boot_from_rom(true);
emu.load_firmware(new Uint8Array(readFileSync(FW)));
const CYCLES_PER_US = emu.cycles_per_us();
function readU32(addr) {
const b = emu.read_memory(addr, 4);
return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0;
}
function gpioBits() {
const s = emu.gpio_state(); // [out_lo, out_hi, enable_lo, enable_hi]
return { out: s[0] >>> 0, oe: s[2] >>> 0 };
}
// ------------------------------------------------------------- WS2812 decode
/* Decode a level-run sequence [{level,us}] into WS2812 frames. High run
* >0.6us is '1', else '0'; a low run >40us latches the frame. */
function decodeWs2812Runs(runs) {
const frames = [];
let bits = [];
const flush = () => {
if (bits.length >= 24) {
const n = Math.floor(bits.length / 24) * 24;
const colors = [];
for (let led = 0; led < n / 24; led++) {
let v = 0;
for (let b = 0; b < 24; b++) v = (v << 1) | bits[led * 24 + b];
const rgb = (((v >> 8) & 0xff) << 16) | (((v >> 16) & 0xff) << 8) | (v & 0xff);
colors.push(rgb);
}
frames.push(colors);
}
bits = [];
};
for (const r of runs) {
if (r.level === 1) bits.push(r.us > 0.6 ? 1 : 0);
else if (r.us > 40) flush();
}
flush();
return frames;
}
/* A decoded frame matches when at least 2 of its LEDs equal the colour the
* firmware logged for that burst. Ping-pong RAM reuse splits a frame into
* multiple reset-delimited groups of 1-2 LEDs, so the threshold must be 2. */
function frameMatches(colors, expected) {
return Array.isArray(colors) && colors.filter((c) => c === expected).length >= 2;
}
// --------------------------------------------------------------- state & bag
const res = {
cyclesPerUs: CYCLES_PER_US,
q1a: { blinkTransitions: 0, blinkSamplesTotal: 0, oeSeen: false },
q1b: { drivenFallingEdges: 0, firmwareCount: null },
q2a: { pinOeEver: false, pinOutToggled: false, ramp: [], flats: [] },
q2b: { dutyRegSamples: 0, dutyRTracking: 0, funcOutSel9: null, ioMux9: null },
q3: {
pinOeEver: false, pinEdgesTotal: 0, pinFrames: [], memSnapshots: 0, memFrames: [],
expectedColors: [], pinMatched: [], memMatched: [], rmtConf0: null, funcOutSel4: null,
},
perf: { coarseMips: null, fineMips: null, coarseBatches: 0, fineBatches: 0 },
};
let uartBuf = '';
let uartScan = 0;
let phase = 'boot';
let roundNo = null;
let sawReady = false;
let done = false;
let currentDutyCtx = null; // {duty, tag?} for the current P2 step
let dutyStep = null; // {duty, tag, high, oe, total}
let lastBlink = -1;
let lastLedcLevel = -1;
let inFine = false;
let batch = 0;
let fineBatches = 0, fineCycles = 0;
const tCoarse0 = Date.now();
const cCoarse0 = emu.cycles();
const tFine0 = Date.now();
function pushDutyStep() {
if (!dutyStep || dutyStep.total === 0) return;
const est = dutyStep.oe ? +(dutyStep.high / dutyStep.oe).toFixed(3) : null;
const row = { duty: dutyStep.duty, samples: dutyStep.total, est };
if (dutyStep.tag) res.q2a.flats.push({ ...row, tag: dutyStep.tag });
else res.q2a.ramp.push(row);
dutyStep = null;
}
function handleMarker(line) {
let m;
if (line === '###READY') sawReady = true;
else if ((m = line.match(/^###ROUND (\d+)/))) roundNo = +m[1];
else if (line === '###P1 start') {
phase = 'P1';
emu.gpio_set_input(PIN_BTN, true); // pull-up idle level
res.q1a.blinkTransitions = 0;
lastBlink = -1;
} else if ((m = line.match(/^###P1 end btn=(\d+)/))) {
res.q1b.firmwareCount = +m[1];
phase = 'idle';
} else if (line === '###P2 start') {
phase = 'P2';
res.q2b.funcOutSel9 = readU32(MMIO.GPIO_FUNC_OUT_SEL(PIN_LEDC));
res.q2b.ioMux9 = readU32(MMIO.IO_MUX(PIN_LEDC));
} else if ((m = line.match(/^###P2 d=(\d+)/))) {
pushDutyStep();
dutyStep = { duty: +m[1], tag: null, high: 0, oe: 0, total: 0 };
} else if ((m = line.match(/^###P2 flat=(\w+) d=(\d+)/))) {
pushDutyStep();
dutyStep = { duty: +m[2], tag: m[1], high: 0, oe: 0, total: 0 };
} else if (line === '###P2 end') {
pushDutyStep();
phase = 'idle';
} else if (line === '###P3 start') {
phase = 'P3';
res.q3.rmtConf0 = readU32(MMIO.RMT_CH0CONF0);
res.q3.funcOutSel4 = readU32(MMIO.GPIO_FUNC_OUT_SEL(PIN_RMT));
} else if ((m = line.match(/^###P3 f=(\d+) c=0x([0-9a-fA-F]{6})/))) {
const expected = parseInt(m[2], 16);
res.q3.expectedColors.push(expected);
if (!inFine) fineSampleWindow(8_000_000, expected);
} else if (line === '###P3 end') {
phase = 'idle';
if (roundNo === 0) done = true; // one round is enough
}
}
function processUart() {
for (;;) {
const nl = uartBuf.indexOf('\n', uartScan);
if (nl < 0) break;
const line = uartBuf.slice(uartScan, nl).trim();
uartScan = nl + 1;
if (!line.startsWith('###')) continue;
handleMarker(line);
if (done) return;
}
}
// ------------------------------------------------------- fine (pin) sampling
/* Run small run_batch(40) steps for up to maxCycles emulated cycles,
* recording level changes on PIN_RMT, then decode WS2812 from the runs. */
function fineSampleWindow(maxCycles, expected) {
inFine = true;
const startCycles = emu.cycles();
const changes = []; // [cycles, level]; level -1 = pad not driven
let last = null;
while (emu.cycles() - startCycles < maxCycles) {
const out = emu.run_batch(40);
if (out) { uartBuf += out; processUart(); if (done) break; }
const g = gpioBits();
const oe = (g.oe >>> PIN_RMT) & 1;
const lvl = oe ? (g.out >>> PIN_RMT) & 1 : -1;
if (oe) res.q3.pinOeEver = true;
if (lvl !== last) { changes.push([emu.cycles(), lvl]); last = lvl; }
fineBatches++; fineCycles += 40;
}
if (changes.length > 1) res.q3.pinEdgesTotal += changes.length - 1;
const runs = [];
for (let i = 0; i < changes.length - 1; i++) {
const [t0, l0] = changes[i];
const t1 = changes[i + 1][0];
if (l0 === -1) continue; // undriven gap
runs.push({ level: l0, us: (t1 - t0) / CYCLES_PER_US });
}
const frames = decodeWs2812Runs(runs);
for (const f of frames) {
if (!res.q3.pinFrames.some((x) => JSON.stringify(x) === JSON.stringify(f))) res.q3.pinFrames.push(f);
if (frameMatches(f, expected)) res.q3.pinMatched.push(expected);
}
inFine = false;
}
// RMT symbol words -> level runs (us); stop at an all-zero word (TX end).
function rmtWordsToRuns(words) {
const runs = [];
for (const w of words) {
const d0 = w & 0x7fff, l0 = (w >> 15) & 1;
const d1 = (w >> 16) & 0x7fff, l1 = (w >> 31) & 1;
if (d0 === 0 && d1 === 0) break;
if (d0) runs.push({ level: l0, us: d0 * 0.1 });
if (d1) runs.push({ level: l1, us: d1 * 0.1 });
}
return runs;
}
/* Decode RMT channel RAM into WS2812 frames. Symbols persist in RAM after
* TX (verified in M0); resync on the low "reset" symbol (>=30 us low),
* skip zero padding words, bits follow as high-run durations. bitOffset
* drops the first k bits of each resync'd group (the ping-pong write order
* can shift the stream a few bits relative to the LED boundary). */
function decodeRmtMemFrames(words, bitOffset = 0) {
const frames = [];
let bits = [];
const flush = () => {
let b = bits;
if (b.length >= 24) {
// The ping-pong write order can leave the first few bits of a
// frame outside the snapshot; prepending k zeros restores the
// LED byte grid. Try all k in the caller.
if (bitOffset > 0) b = Array(bitOffset).fill(0).concat(b);
const n = Math.floor(b.length / 24) * 24;
const colors = [];
for (let led = 0; led < n / 24; led++) {
let v = 0;
for (let i = 0; i < 24; i++) v = (v << 1) | b[led * 24 + i];
const rgb = (((v >> 8) & 0xff) << 16) | (((v >> 16) & 0xff) << 8) | (v & 0xff);
colors.push(rgb);
}
frames.push(colors);
}
bits = [];
};
for (const w of words) {
const d0 = w & 0x7fff, l0 = (w >> 15) & 1;
const d1 = (w >> 16) & 0x7fff, l1 = (w >> 31) & 1;
if (d0 === 0 && d1 === 0) continue; // padding between ping-pong blocks
if (l0 === 0 && d0 >= 300) { // reset symbol: frame boundary
flush();
continue;
}
if (l0 === 1) bits.push(d0 * 0.1 > 0.6 ? 1 : 0);
if (l1 === 1) bits.push(d1 * 0.1 > 0.6 ? 1 : 0);
}
flush();
return frames;
}
// --------------------------------------------------------------- coarse loop
while (!done) {
const out = emu.run_batch(50000);
if (out) { uartBuf += out; processUart(); }
if (done) break;
if (!sawReady && batch > 6000) throw new Error('firmware never printed ###READY — boot failed?');
if (batch > 600_000) throw new Error('probe ran too long without finishing a round');
const g = gpioBits();
if (phase === 'P1') {
const oe8 = (g.oe >>> PIN_BLINK) & 1;
const b8 = (g.out >>> PIN_BLINK) & 1;
if (oe8) res.q1a.oeSeen = true;
if (b8 !== lastBlink) { res.q1a.blinkTransitions++; lastBlink = b8; }
res.q1a.blinkSamplesTotal++;
// Drive GPIO3: falling edge every 64 batches, released high again 32
// batches later. Counted by the firmware ISR (vTaskDelay durations
// are cycle-compressed in the emulator, so use a dense cadence).
if (batch % 64 === 0) { emu.gpio_set_input(PIN_BTN, false); res.q1b.drivenFallingEdges++; }
else if (batch % 64 === 32) emu.gpio_set_input(PIN_BTN, true);
}
if (phase === 'P2') {
const oe9 = (g.oe >>> PIN_LEDC) & 1;
const b9 = (g.out >>> PIN_LEDC) & 1;
if (oe9) res.q2a.pinOeEver = true;
if (b9 !== lastLedcLevel) { lastLedcLevel = b9; res.q2a.pinOutToggled = true; }
if (dutyStep) {
dutyStep.total++;
if (oe9) { dutyStep.oe++; if (b9) dutyStep.high++; }
}
if (batch % 16 === 0) {
res.q2b.dutyRegSamples++;
const dutyR = readU32(MMIO.LEDC_CH0_DUTY_R);
// C3 DUTY_CH0 field is duty << 4 (bits [18:4]) — verified in M0.
if (dutyStep && Math.abs(dutyR - dutyStep.duty * 16) <= 64) res.q2b.dutyRTracking++;
}
}
if (phase === 'P3' && batch % 6 === 0) {
const bytes = emu.read_memory(MMIO.RMTMEM_CH0, MMIO.RMTMEM_CH0_WORDS * 4);
const words = new Uint32Array(48);
for (let w = 0; w < 48; w++) {
words[w] = (bytes[w * 4] | (bytes[w * 4 + 1] << 8) | (bytes[w * 4 + 2] << 16) | (bytes[w * 4 + 3] << 24)) >>> 0;
}
if (words.filter((w) => w !== 0).length >= 10) {
res.q3.memSnapshots++;
for (let off = 0; off < 8; off++) {
for (const f of decodeRmtMemFrames(words, off)) {
if (!res.q3.memFrames.some((x) => JSON.stringify(x) === JSON.stringify(f))) res.q3.memFrames.push(f);
for (const exp of res.q3.expectedColors) {
if (frameMatches(f, exp) && !res.q3.memMatched.includes(exp)) res.q3.memMatched.push(exp);
}
}
}
}
}
batch++;
}
pushDutyStep();
res.perf.coarseBatches = batch;
const coarseMs = Date.now() - tCoarse0;
const coarseCycles = emu.cycles() - cCoarse0;
res.perf.coarseMips = +((coarseCycles / coarseMs / 1000).toFixed(1));
const fineMs = Math.max(Date.now() - tFine0 - coarseMs, 1);
res.perf.fineBatches = fineBatches;
res.perf.fineMips = +((fineCycles / fineMs / 1000).toFixed(1));
// ------------------------------------------------------------------ verdicts
const out = [];
const p = (s = '') => { out.push(s); console.log(s); };
p('================ esp-emu-peris M0 probe ================');
p(`firmware: ${FW}`);
p(`cycles_per_us=${CYCLES_PER_US} coarse=${batch} batches(${res.perf.coarseMips} MIPS) fine=${fineBatches}x40(${res.perf.fineMips} MIPS)`);
const q1aOk = res.q1a.blinkTransitions >= 6 && res.q1a.oeSeen;
p(`\nQ1a GPIO output via gpio_state(): ${q1aOk ? 'PASS' : 'FAIL'}`);
p(` blink transitions=${res.q1a.blinkTransitions} oe=${res.q1a.oeSeen} samples=${res.q1a.blinkSamplesTotal}`);
const q1bOk = res.q1b.firmwareCount !== null && res.q1b.firmwareCount > 0 &&
res.q1b.firmwareCount >= res.q1b.drivenFallingEdges - 2;
p(`Q1b gpio_set_input() -> guest ISR: ${q1bOk ? 'PASS' : 'FAIL'}`);
p(` probe drove ${res.q1b.drivenFallingEdges} falling edges, firmware counted ${res.q1b.firmwareCount}`);
const rampWithEst = res.q2a.ramp.filter((s) => s.est !== null);
let corr = null;
if (rampWithEst.length >= 5) {
const xs = rampWithEst.map((s) => s.duty / 8192), ys = rampWithEst.map((s) => s.est);
const mx = xs.reduce((a, b) => a + b) / xs.length, my = ys.reduce((a, b) => a + b) / ys.length;
let num = 0, dx = 0, dy = 0;
for (let i = 0; i < xs.length; i++) { num += (xs[i] - mx) * (ys[i] - my); dx += (xs[i] - mx) ** 2; dy += (ys[i] - my) ** 2; }
corr = +(num / Math.sqrt(dx * dy)).toFixed(3);
}
const q2aOk = res.q2a.pinOeEver && res.q2a.pinOutToggled && (corr === null || corr > 0.8);
p(`\nQ2a LEDC PWM observable at pad register: ${q2aOk ? 'PASS' : 'FAIL'}`);
p(` pin9 oe ever=${res.q2a.pinOeEver} out toggled=${res.q2a.pinOutToggled} ramp r=${corr} over ${rampWithEst.length} steps`);
for (const f of res.q2a.flats) p(` flat ${f.tag}: duty=${(f.duty / 8192).toFixed(3)} est=${f.est} (n=${f.samples})`);
const q2bOk = res.q2b.dutyRegSamples > 10 && res.q2b.dutyRTracking > 5;
p(`Q2b LEDC duty regs via read_memory(): ${q2bOk ? 'PASS' : 'FAIL'}`);
p(` samples=${res.q2b.dutyRegSamples} tracking=${res.q2b.dutyRTracking} FUNC9_OUT_SEL=0x${(res.q2b.funcOutSel9 ?? 0).toString(16)} IO_MUX9=0x${(res.q2b.ioMux9 ?? 0).toString(16)}`);
const q3aOk = res.q3.pinMatched.length > 0;
p(`\nQ3a WS2812 via pin fine-sampling(40): ${q3aOk ? 'PASS' : 'FAIL'}`);
p(` pin4 oe ever=${res.q3.pinOeEver} changes=${res.q3.pinEdgesTotal} frames=${res.q3.pinFrames.length} matched=${res.q3.pinMatched.map((c) => '0x' + c.toString(16)).join(',') || '-'}`);
if (res.q3.pinFrames.length) p(` e.g. ${res.q3.pinFrames[0].slice(0, 8).map((c) => '0x' + c.toString(16)).join(' ')}`);
const q3bOk = res.q3.memMatched.length > 0;
p(`Q3b WS2812 via RMT channel RAM: ${q3bOk ? 'PASS' : 'FAIL'}`);
p(` snapshots=${res.q3.memSnapshots} frames=${res.q3.memFrames.length} matched=${res.q3.memMatched.map((c) => '0x' + c.toString(16)).join(',') || '-'}`);
for (let i = 0; i < Math.min(res.q3.memFrames.length, 12); i++) {
p(` frame[${i}] len=${res.q3.memFrames[i].length}: ${res.q3.memFrames[i].map((c) => '0x' + c.toString(16)).join(' ')}`);
}
p(`\nRMT_CH0CONF0=0x${(res.q3.rmtConf0 ?? 0).toString(16)} FUNC4_OUT_SEL=0x${(res.q3.funcOutSel4 ?? 0).toString(16)}`);
p(`fine-vs-coarse slowdown: ${(res.perf.coarseMips / Math.max(res.perf.fineMips, 0.1)).toFixed(1)}x`);
if (process.argv.includes('--json')) console.log(JSON.stringify(res, null, 2));
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/*
* M1 integration smoke test (Node): runs the real PerisRuntime + real device
* modules against the real emulator wasm, covering what the GUI session
* could not reach (and serving as a regression test):
*
* S1 config apply -> peris_ready ok, initial states pushed
* S2 LED follows firmware GPIO8 blink (peris_state on/off flips)
* S3 button press/release (peris_command) raises firmware ISR counts
* S4 toggle-switch drives its pin level
* S5 pin conflict rejected with a peris_ready error
* S6 unknown device type rejected
*
* Usage: node tests/m1_smoke.mjs [merged-firmware.bin]
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL, fileURLToPath } from 'node:url';
import path from 'node:path';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WWW = 'D:/d3/_git/esp_proj/_tools/esp-emu-browser/www';
const FW = process.argv[2] ?? path.join(REPO, 'tests/firmware/peris_probe/build/merged-binary.bin');
const { initSync, WasmEmulator } = await import(
pathToFileURL(path.join(WWW, 'pkg/esp_emu.js')).href
);
initSync(readFileSync(path.join(WWW, 'pkg/esp_emu_bg.wasm')));
const { PerisRuntime } = await import(pathToFileURL(path.join(REPO, 'peris/runtime.js')).href);
const devices = {};
for (const m of ['led', 'button', 'touch-button', 'toggle-switch']) {
devices[m] = await import(pathToFileURL(path.join(REPO, `peris/devices/${m}.js`)).href);
}
const registry = {
has: (t) => t in devices,
meta: (t) => devices[t].Device,
Device: (t) => devices[t].Device,
View: () => null,
types: () => Object.keys(devices).map((t) => ({ typeId: t, title: devices[t].Device.title })),
errors: [],
};
// --- boot emulator ---
const emu = new WasmEmulator('esp32c3');
if (emu.has_default_rom()) emu.load_default_rom();
emu.set_boot_from_rom(true);
emu.load_firmware(new Uint8Array(readFileSync(FW)));
const msgs = [];
const states = () => msgs.filter((m) => m.type === 'peris_state');
const rt = new PerisRuntime();
rt.post = (m) => msgs.push(m);
rt.registry = registry;
rt.attachEmu(emu, 'esp32c3');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' (' + detail + ')' : ''}`);
};
let uart = '';
let uartScan = 0; // cumulative marker scan position
let inP1 = false;
function trackMarkers() {
for (;;) {
const nl = uart.indexOf('\n', uartScan);
if (nl < 0) break;
const line = uart.slice(uartScan, nl).trim();
uartScan = nl + 1;
if (line === '###P1 start') inP1 = true;
else if (line === '###P1 end') inP1 = false;
}
}
function runBatches(n, size = 50000) {
for (let i = 0; i < n; i++) {
uart += emu.run_batch(size);
trackMarkers();
rt.onBatchEnd();
}
}
// S1: apply config, expect ready + initial states
await rt.applyConfig({
perisVersion: 1,
devices: [
{ id: 'led-1', type: 'led', pin: 8, color: '#ff4d4d', name: 'L' },
{ id: 'btn-1', type: 'button', pin: 3, activeLow: true },
{ id: 'sw-1', type: 'toggle-switch', pin: 5, initial: false },
],
});
const ready1 = msgs.find((m) => m.type === 'peris_ready');
check('S1 config applied', ready1?.ok === true && ready1.devices.length === 3);
check('S1 initial states pushed',
['led-1', 'btn-1', 'sw-1'].every((id) => states().some((s) => s.id === id)));
// S4: toggle switch drives pin level (GPIO5 input)
rt.command('sw-1', 'set', { on: true });
rt.flushStates(true); // commands flush on the next batch; force for the check
const swState = states().filter((s) => s.id === 'sw-1').pop();
check('S4 toggle command updates state', swState?.state?.on === true);
// S2/S3: run firmware; LED flips; button pulses counted by guest ISR
msgs.length = 0;
rt.flushStates(true);
runBatches(400); // boot + into the first P1 blink window
const ledStates = states().filter((s) => s.id === 'led-1');
const sawOn = ledStates.some((s) => s.state.on === true);
const sawOff = ledStates.some((s) => s.state.on === false);
check('S2 LED observed on and off', sawOn && sawOff, `${ledStates.length} state msgs`);
// Press/release while the firmware is inside a P1 phase, holding the press
// across batches (instant press+release inside one batch gap loses the edge).
let pulsesLeft = 3, hold = 0, made = 0;
for (let i = 0; i < 5000 && pulsesLeft >= 0; i++) {
uart += emu.run_batch(50000);
trackMarkers();
if (inP1 && pulsesLeft > 0 && i % 40 === 0) {
rt.command('btn-1', 'press');
hold = 3; // batches held down
pulsesLeft--; made++;
} else if (hold > 0 && --hold === 0) {
rt.command('btn-1', 'release');
if (pulsesLeft === 0) pulsesLeft = -1; // done after last release settles
}
rt.onBatchEnd();
}
// let the firmware print the remaining ###P1 i=N btn=M lines
runBatches(400);
// The firmware prints "###P1 i=N btn=M" steps and a final "###P1 end btn=M";
// it zeroes the counter at each P1 start, so count any of these lines.
const btnCounts = [...uart.matchAll(/btn=(\d+)/g)].map((x) => +x[1]);
const lastBtn = btnCounts.length ? btnCounts[btnCounts.length - 1] : 0;
const anyCount = btnCounts.some((c) => c > 0);
if (process.env.VERBOSE) console.log('DEBUG btnCounts:', btnCounts.join(','), 'made:', made, 'inP1:', inP1, 'uartLen:', uart.length);
check('S3 button pulses reach guest ISR', anyCount && lastBtn >= 2,
`${made} pulses, last btn=${lastBtn}`);
// S5: conflict rejected
msgs.length = 0;
await rt.applyConfig({
perisVersion: 1,
devices: [
{ id: 'a', type: 'led', pin: 8 },
{ id: 'b', type: 'led', pin: 8 },
],
});
const ready2 = msgs.find((m) => m.type === 'peris_ready');
check('S5 pin conflict rejected',
ready2?.ok === false && ready2.errors.some((e) => e.includes('already used')),
ready2?.errors.join('; '));
// S6: unknown type rejected
msgs.length = 0;
await rt.applyConfig({ perisVersion: 1, devices: [{ id: 'x', type: 'nope', pin: 1 }] });
const ready3 = msgs.find((m) => m.type === 'peris_ready');
check('S6 unknown type rejected', ready3?.ok === false && ready3.errors.some((e) => e.includes('unknown type')));
const failed = results.filter((r) => !r.ok);
console.log(failed.length ? `\n${failed.length} FAILED` : '\nALL PASS');
process.exit(failed.length ? 1 : 0);