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:
1 parent
9c4be26243
commit
a9d26722d7
39 files changed
+8102
-1
No files matched your search
@@ -147,3 +147,5 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Turbo Repo
|
||||
.turbo
|
||||
.zcode/
|
||||
tests/firmware/peris_probe/build/
|
||||
@@ -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 包的非压缩构建),离线可用。
|
||||
@@ -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` — 保持型输入设备
|
||||
@@ -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),不依赖采样率。
|
||||
@@ -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)。
|
||||
@@ -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 / 快照保存
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"devices": [
|
||||
{ "module": "led.js" },
|
||||
{ "module": "button.js" },
|
||||
{ "module": "touch-button.js" },
|
||||
{ "module": "toggle-switch.js" }
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 } ]
|
||||
// }
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+209
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -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
|
||||
Vendored
+2
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
rem Publish esp-emu-peris into the esp-emu-browser www directory.
|
||||
python "%~dp0sync_www.py" %*
|
||||
+56
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user