Row-based peripherals UI (0.3.0) + same-logic reuse fixes (0.3.1)

- Three-pane layout: console | peripherals | debug, all visible at once
- One compact row per device: interactive icon (View.rowIcon), inline
  name/GPIO/V-G pull chip editing, expand/remove/drag-sort; details area
  holds the device view + config editor (one property per line)
- View API: optional rowIcon(el) + host.updateConfig(patch); schema
  chip:'pull'/pullWhenChecked declare the V/G badge
- Reuse fixes: single LED setColour for bulb+row dot, panel calls
  rowIcon before setConfig; fillPinOptions() shared by editor and row
  chip, editors rebuilt on chip change (refreshForChip)
- AGENT.md: new rule 8 (reuse same-semantic code) + two bug records;
  npm test passes explicit file list (Git Bash dir-resolution quirk)
- Devices: led/button/touch-button/toggle-switch 1.1.0 (led 1.1.1)
This commit is contained in:
darceye committed 2026-10-30 19:37:34 +08:00
1 parent e29f2e81e6
commit 293d76acf7
18 files changed
+876 -181

No files matched your search

+9
View File
@@ -56,6 +56,13 @@ esp-emulator wasm 能力边界见 `docs/EMU-API.md` 与 `docs/FEASIBILITY.md`(M0
6. 状态推送有 33ms 节流 + **尾随定时器**(保证最后一次 dirty 必达)——别删掉尾随逻辑,
那是修过的真实缺陷(状态静默丢失)。
7. sigbus 按批做 gpio_state 差分,**联合掩码**(out|oe)每引脚每批至多通知一次。
8. **同语义代码必须复用,禁止复制一份再改**:同一逻辑只允许一份实现,所有
入口(行内 chip / 详情编辑器 / 行图标 / 新插件)调用同一函数——
GPIO 可选项= `config-editor.ts fillPinOptions()`;V·G 徽章推导=`rowChips()`;
插件多视觉元素(如 LED 灯泡+行图标)共用同一 setter/render,不许给图标
单写一份样式逻辑;面板对 View 固定 **先 `rowIcon` 后 `setConfig`**,
保证配置式样能覆盖到图标。改行为前先找有没有既有函数,有就复用;
review 发现两处雷同代码时,先抽公共函数再改。
## 4. 曾踩过的 bug(防止回潮)
@@ -69,6 +76,8 @@ esp-emulator wasm 能力边界见 `docs/EMU-API.md` 与 `docs/FEASIBILITY.md`(M0
| 按键 activeLow 电平写反 | idle=高、pressed=低(active-low 上拉接法);单测固定语义 |
| Worker URL 相对**页面**解析,不是相对 import 的模块 | worker-client 里写页面相对路径 `build/core/worker.js` |
| 节流窗口丢最后一次状态 | 尾随 flush(见 §3.7) |
| LED 行图标颜色与详情灯泡不一致(0.3.0) | 面板曾先 setConfig 后 rowIcon,且插件两处各自设色;统一 setColour + 固定调用顺序(§3.8) |
| 换芯片加载固件后,详情编辑器 GPIO 选项仍是旧芯片(0.3.0) | 编辑器把 validPins 快照在构建时;芯片变化必须重建编辑器;选项构建统一走 fillPinOptions(§3.8) |
## 5. 测试与验证约定
+5 -3
View File
@@ -3,7 +3,7 @@
为乐鑫 [esp-emulator](https://github.com/espressif/esp-emulator) 浏览器形态(wasm)做的**模拟外设面板**:LED、按键、触摸按键、拨动开关……以插件方式不断添加,不需要改动官方 `www/` 里的任何基础文件。
```
esp-emu-peris.html 入口页面(UART0 控制台 + 右侧 Peripherals/Debug 双标签)
esp-emu-peris.html 入口页面(UART0 控制台 | 外设行式面板 | Debug,三栏同屏)
src/ TypeScript 源码(npm run build → build/)
core/ worker 侧框架:version/types/protocol/chips/mmio/sigbus/registry/runtime/worker
ui/ 页面侧模块:app/worker-client/terminal/chart/debug-panels/peris-panel/config-editor
@@ -37,8 +37,10 @@ npm run serve # http://127.0.0.1:8080/esp-emu-peris.html
# 浏览器打开上面的地址,选择芯片、拖入 merged flash image(.bin)
# 注意:必须是 idf.py merge-bin 生成的完整镜像,裸 app bin 无法启动
# 右侧 Peripherals 标签:Add 添加 LED/按键等,选好 GPIO;
# Export/Import 导出/导入接线配置(JSON,自动存 localStorage)
# 外设栏(控制台右侧):Add 添加 LED/按键等;每外设一行——行内图标可交互
# (LED 点击换色/按键按压),名称、GPIO 方框、V/G 电平徽章点击即改,
# ▸ 展开详情与高级配置,⠿ 拖动排序;Export/Import 导出/导入接线配置
# (JSON,自动存 localStorage)
```
把外设面板**发布**进官方 www 目录(只增不改;先 build):
+8 -4
View File
@@ -5,11 +5,13 @@
```
┌───────────────────────────────── 页面(page)─────────────────────────────────┐
│ esp-emu-peris.html 上游样式原块(便于升级 diff)+ <link peris/peris.css> │
│ 三栏同屏:UART0 控制台 │ 外设面板 │ Debug(0.3.0 起不再标签切换) │
│ build/ui/* (TypeScript 编译产物) │
│ app.ts 编排器:机器状态、Run/Pause/固件/网络控件、消息路由 │
│ worker-client Worker 唯一入口(其它模块一律经由它收发消息) │
│ terminal / chart / debug-panels UART 控制台 / 性能图 / Debug 只读面板 │
│ peris-panel + config-editor Peripherals 面板与通用配置编辑器 │
│ peris-panel + config-editor 行式外设列表(图标/名称/GPIO/V·G 徽章 │
│ + 展开/删除/拖动)与详情配置编辑器 │
└───────────────▲───────────────────────────────────────────────▲────────────┘
postMessage(协议见 core/protocol.ts) │ DOM
┌───────────────┴───────────────────────────────────────────────▼────────────┐
@@ -80,9 +82,11 @@ npm run serve # python serve.py(合并服务仓库与 www/pkg)
npm run sync # 发布进官方 www(先 build)
```
测试分层:**单元**(纯逻辑:位运算/电平语义/校验——快、定位准)→ **集成**(真实
wasm+固件全链路)→ **GUI**(手动清单:页面加载、加设备、载固件、Run 后 LED 翻转、
配置导入导出往返;浏览器自动化待网络环境允许时引入 Playwright,
测试分层:**单元**(纯逻辑:位运算/电平语义/校验/行式徽章推导——快、定位准)→
**集成**(真实 wasm+固件全链路)→ **GUI**(手动清单:页面加载、三栏同屏可见、加设备
后行式渲染、行内改名/改 GPIO/翻 V·G、行内图标交互(点灯换色/按压/开关)、展开详情
与配置编辑、拖动排序、载固件、Run 后 LED 翻转、配置导入导出往返;浏览器自动化待
网络环境允许时引入 Playwright,
`PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright`)。
## 6. 升级流程(摘要,详见 UPGRADE.md)
+41 -4
View File
@@ -3,6 +3,41 @@
版本三处同步:`src/core/version.ts`、`package.json`、本文件。
规则:主版本=协议/架构破坏性变更;次版本=新能力;修订=修复与小改。
## peris 0.3.1
- **修复**:LED 行内图标颜色不随配置(与详情灯泡不一致)。根因有二:面板先
`setConfig` 后 `rowIcon`(图标元素尚不存在,颜色没应用到它),插件里两处各自
设色。现 led 插件统一 `setColour()` 同时应用到灯泡与行图标,面板固定
**先 rowIcon 后 setConfig**。
- **修复**:加载固件换芯片后,展开详情的 GPIO 选项仍停留在旧芯片(行内 chip
一直正确)。引脚选项构建抽取为 `fillPinOptions()` 单一实现(详情编辑器与
行内 chip 共用);`PerisPanel.refreshForChip()` 在固件加载/复位重载后检测
芯片变化并重建详情编辑器。
- **AGENT.md 新增铁律 §3.8**:"同语义代码必须复用"——以上两个 bug 皆为复制
代码只改一处所致;同时登记两条 bug 记录(§4)。
- `npm test`/`npm run test:unit` 改为显式传 .test.mjs 文件列表(AGENT.md §1
记录的 Windows/Git Bash 目录解析坑,目录形式在本机直接 MODULE_NOT_FOUND)。
## peris 0.3.0
- **三栏同屏布局**:外设面板独立成栏,置于 UART 控制台与 Debug 之间,与 Debug
同宽(360px,窄屏自适应收窄/堆叠);移除 Peripherals/Debug 标签切换,三栏同时可见。
外设面板改为内部滚动(工具栏常驻),Debug 面板加标题条。
- **行式外设列表**(类 Photoshop 图层):每外设一行——左侧依次为交互图标、名称
(点击原地改名)、GPIO 号方框(点击弹出选单)、V/G 闲置电平徽章(点击翻转,
V=上拉到 VCC、G=下拉到 GND);右侧靠齐展开按钮、删除按钮、拖动排序柄。
展开后的详情区(类 Blender 属性分组)显示设备控件主体 + 全量配置编辑器
(一条属性一行);行内放不下的高级功能都收进详情。
- **View API 扩展**(可选项,向后兼容):`View.rowIcon(el)` 定制行内交互图标;
View 构造函数新增第三参 `host.updateConfig(patch)`,供 View 请求改配置
(如 LED 行内图标点击换色);configSchema 新增 `chip:'pull'` /
`pullWhenChecked` 字段声明 V/G 徽章。类型契约见 `build/core/types.d.ts`。
- **拖动排序**:拖柄按下才允许拖拽(避免误触);松手后按 DOM 序回写配置并持久化
(设备集合不变,不重建 worker 实例)。文件拖放导入与行排序互不干扰(按
dataTransfer 类型区分)。
- 新增单测:内置插件 rowIcon 存在性、`rowChips()` 引脚/V·G 徽章推导
(含 GPIO0、`pullWhenChecked:'low'` 反例)。
## peris 0.2.0
- **架构重构**:框架层 TypeScript 化(`src/core` + `src/ui`,tsc 逐文件编译到
@@ -28,7 +63,9 @@
| typeId | 版本 | 变更 |
|---|---|---|
| led | 1.0.0 | 初版:输出轮询、颜色、active-low、floating 提示 |
| button | 1.0.0 | 初版:瞬时按压驱动输入(含 0.2.0 前的电平语义修复) |
| touch-button | 1.0.0 | 初版:继承 button,触摸板视图(触摸外设未模拟,GPIO 语义) |
| toggle-switch | 1.0.0 | 初版:保持型开关、初始状态、onEmuReady 重驱动 |
| led | 1.1.1 | 修复:行图标颜色与配置不一致——统一 setColour 应用到灯泡+行图标 |
| button | 1.1.0 | 行内按压图标(press/release);activeLow 声明为 V/G 徽章 |
| touch-button | 1.1.0 | 行内触摸图标(继承 button 的徽章声明) |
| toggle-switch | 1.1.0 | 行内迷你拨动开关(点击 toggle);activeLow 声明为 V/G 徽章 |
历史:led/button/touch-button/toggle-switch 1.0.0 初版(见 peris 0.1.0/0.2.0 条目)。
+34 -5
View File
@@ -18,11 +18,13 @@ export class Device { // 运行在 worker,与模拟器交互
static category = 'basic'; // Add 菜单分组
static description = '...'; // 一行说明(tooltip)
static defaultConfig = {...}; // 新建实例的默认配置
static configSchema = [ // 通用配置编辑器字段
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
// checkbox 可加 chip:'pull' + pullWhenChecked:'high'|'low'
// → 设备行上显示 V/G 闲置电平徽章(点击翻转),详见下文"行式 UI"
];
static pins(cfg) { return [cfg.pin]; } // 声明占用的 GPIO(用于冲突检测)
@@ -34,13 +36,38 @@ export class Device { // 运行在 worker,与模拟器交互
destroy() {} // 释放/恢复引脚
}
export class View { // 运行在页面,渲染设备卡片主体
constructor(el, sendCmd) {} // el=卡片主体容器; sendCmd(cmd, params)
render(state) {} // state 来自 Device.getState()
export class View { // 运行在页面,渲染设备视图
constructor(el, sendCmd, host) {} // el=详情区主体容器; sendCmd(cmd, params);
// host.updateConfig(patch) 请求改本设备配置
rowIcon(el) {} // 可选:行内交互图标(el≈24px 容器),见下文
render(state) {} // state 来自 Device.getState();行图标与详情主体一起驱动
setConfig(cfg) {} // 可选:配置编辑后回调(如换 LED 颜色)
}
```
## 行式 UI 约定(0.3.0+)
外设面板是三栏布局中的独立一栏(控制台 | 外设 | Debug),每种外设占一行:
```
[交互图标] [名称] [GPIO号] [V/G] [▸展开] [×删除] [⠿拖动]
```
- **交互图标**(`View.rowIcon(el)`):可点击的迷你控件,操作语义由插件定义——
LED 点击换色(取色器,经 `host.updateConfig({color})` 写回配置)、按键按下/
松开(连发 press/release)、拨动开关点击 toggle。`render(state)` 里同步刷新
图标状态(亮灭/按下/ON)。不实现 rowIcon 时显示首字母占位块。
**调用顺序**:面板固定先 `rowIcon` 后 `setConfig`,配置式样可覆盖到图标;
图标与详情主体是同一状态的两个视觉,插件必须共用同一 setter/render
(参考 led.js 的 `setColour()`),禁止为图标单写一份样式逻辑。
- **名称 / GPIO 方框 / V·G 徽章**:由面板通用渲染,点击原地修改
(名称与 GPIO 走内联编辑,V/G 徽章直接翻转)。GPIO 方框取 schema 第一个
`type:'pin'` 字段;V/G 徽章取标记 `chip:'pull'` 的 checkbox——勾选时若
`pullWhenChecked:'high'`(默认)显示 V(上拉到 VCC),否则 G;未勾选相反。
- **展开详情**:设备控件主体(`el`,即构造函数第一个参数)+ 全量配置编辑器
(一条属性一行)。行内放不下的高级功能一律放详情。
- **拖动排序** ⠿:按下拖柄才启用拖拽,松手回写 devices 顺序(不重建 worker)。
## ctx(worker 侧设备上下文)
| 成员 | 说明 |
@@ -79,7 +106,9 @@ export class View { // 运行在页面,渲染设备卡片主体
2. **引脚输入**:`ctx.setPinInput` 会真实进入固件(读值+中断),按键类设备在 `onEmuReady` 里要重新驱动电平(模拟器重载后电平复位)。
3. **时间**:`vTaskDelay` 期间模拟器时间快进,不要试图在 CPU 睡眠窗口做引脚级波形捕获;CPU 活跃的位绑定波形可在 run_batch 批间采样,但请节制(细批约 99 MIPS,粗批 5000+ MIPS)。
4. 状态推送节流 ~30fps;`getState()` 返回的对象会 postMessage,保持小而可序列化。
5. View 中所有用户交互统一通过 `sendCmd(cmd, params)` 发给 Device,不要在页面侧直接操作模拟器。
5. View 中触发**设备动作**统一走 `sendCmd(cmd, params)`;**修改配置**(如行内换色)
走 `host.updateConfig(patch)`——配置的唯一事实源在页面 PerisPanel,worker 侧
不得缓存/改配置。不要在页面侧直接操作模拟器。
## 示例
+14 -15
View File
@@ -415,21 +415,17 @@
<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>
<aside class="peris-panel" id="pane-peris" aria-label="Peripherals">
<div class="panel-head">Peripherals</div>
<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 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-errors"></div>
<div class="peris-scroll">
<div id="peris-cards"></div>
<div id="peris-empty">
<strong>No peripherals wired yet.</strong><br>
@@ -437,8 +433,11 @@
peris <em>.json</em> config here / press Import.
</div>
</div>
</aside>
<div class="pane hidden" id="pane-debug">
<aside class="side-panel">
<div class="panel-head">Debug</div>
<div class="pane" id="pane-debug">
<div class="panel-section">
<h3>Performance</h3>
<div class="tiles">
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "esp-emu-peris",
"version": "0.2.0",
"version": "0.3.1",
"private": true,
"type": "module",
"description": "Simulated peripherals for the esp-emulator browser (wasm) build",
@@ -9,8 +9,8 @@
"check": "tsc -p tsconfig.json --noEmit",
"serve": "python serve.py",
"sync": "python sync_www.py",
"test": "node --test tests/unit/ && node tests/m1_smoke.mjs",
"test:unit": "node --test tests/unit/",
"test": "node --test tests/unit/core.test.mjs tests/unit/devices.test.mjs && node tests/m1_smoke.mjs",
"test:unit": "node --test tests/unit/core.test.mjs tests/unit/devices.test.mjs",
"test:smoke": "node tests/m1_smoke.mjs",
"test:m0": "node tests/m0_probe.mjs"
},
+23 -2
View File
@@ -6,7 +6,7 @@
*/
export class Device {
static typeId = 'button';
static version = '1.0.0';
static version = '1.1.0';
static title = 'Push button';
static category = 'basic';
static description = 'Momentary button driving a GPIO input (active-low by default).';
@@ -14,7 +14,8 @@ export class Device {
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' },
// chip:'pull' → the row shows V (pull-up) when activeLow, G otherwise
{ key: 'activeLow', label: 'Active low (pull-up wiring)', type: 'checkbox', chip: 'pull', pullWhenChecked: 'high' },
{ key: 'name', label: 'Label', type: 'text' },
];
@@ -72,8 +73,28 @@ export class View {
this.btn = btn;
el.appendChild(btn);
}
/* Compact row icon: press-and-hold, same press/release commands. */
rowIcon(el) {
el.title = 'Push — press and hold';
const b = document.createElement('button');
b.type = 'button';
b.className = 'icon-press';
b.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12"><circle cx="8" cy="8" r="5" fill="currentColor"/></svg>';
const down = (e) => { e.preventDefault(); this.send('press'); b.classList.add('down'); };
const up = (e) => { if (e) e.preventDefault(); this.send('release'); b.classList.remove('down'); };
b.addEventListener('pointerdown', down);
b.addEventListener('pointerup', up);
b.addEventListener('pointerleave', up);
b.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') down(e); });
b.addEventListener('keyup', up);
el.appendChild(b);
this.iconBtn = b;
}
render(state) {
this.btn.classList.toggle('down', !!state.pressed);
this.btn.textContent = state.pressed ? 'PRESSED' : 'PRESS';
if (this.iconBtn) this.iconBtn.classList.toggle('down', !!state.pressed);
}
}
+40 -3
View File
@@ -5,7 +5,7 @@
*/
export class Device {
static typeId = 'led';
static version = '1.0.0';
static version = '1.1.1';
static title = 'LED';
static category = 'basic';
static description = 'Single-colour LED wired to a GPIO output.';
@@ -45,8 +45,12 @@ export class Device {
}
export class View {
constructor(el, sendCmd) {
constructor(el, sendCmd, host) {
this.el = el;
this.send = sendCmd;
this.host = host; // host.updateConfig({color}) powers the row icon picker
this.colour = '#ff4d4d';
this.dot = null;
el.className = 'dev-body led-body';
const bulb = document.createElement('div');
bulb.className = 'led-bulb';
@@ -62,13 +66,46 @@ export class View {
el.appendChild(wrap);
el.appendChild(cap);
}
/* Compact row icon: a mini bulb that lights with the state; clicking it
* opens the colour picker and writes the choice back into the config. */
rowIcon(el) {
el.title = 'LED — click to change colour';
const dot = document.createElement('span');
dot.className = 'led-dot';
const picker = document.createElement('input');
picker.type = 'color';
picker.className = 'led-dot-picker';
picker.addEventListener('change', () => {
if (this.host) this.host.updateConfig({ color: picker.value });
else this.setColour(picker.value);
});
dot.appendChild(picker);
dot.addEventListener('click', () => { picker.value = this.colour; picker.click(); });
el.appendChild(dot);
this.dot = dot;
this.setColour(this.colour); // keep icon and bulb in lock-step
}
/* The ONE colour setter — applies to every LED visual (details bulb +
* row dot). Never style one of them anywhere else. */
setColour(color) {
this.colour = color || '#ff4d4d';
const bulb = this.halo ? this.halo.parentElement : null;
for (const t of [bulb, this.dot]) {
if (t) t.style.setProperty('--led-color', this.colour);
}
}
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');
if (this.dot) this.dot.classList.toggle('lit', !!state.on);
}
setConfig(cfg) {
this.halo.parentElement.style.setProperty('--led-color', cfg.color || '#ff4d4d');
this.setColour(cfg.color);
}
}
+17 -2
View File
@@ -4,7 +4,7 @@
*/
export class Device {
static typeId = 'toggle-switch';
static version = '1.0.0';
static version = '1.1.0';
static title = 'Toggle switch';
static category = 'basic';
static description = 'Latching switch driving a GPIO input level.';
@@ -12,7 +12,8 @@ export class Device {
static pins(cfg) { return [cfg.pin]; }
static configSchema = [
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'activeLow', label: 'Active low', type: 'checkbox' },
// chip:'pull' → the row shows V (pull-up) when activeLow, G otherwise
{ key: 'activeLow', label: 'Active low', type: 'checkbox', chip: 'pull', pullWhenChecked: 'high' },
{ key: 'initial', label: 'Initial ON state', type: 'checkbox' },
{ key: 'name', label: 'Label', type: 'text' },
];
@@ -61,8 +62,22 @@ export class View {
this.text = wrap.querySelector('.sw-text');
el.appendChild(wrap);
}
/* Compact row icon: mini slide switch, click to toggle. */
rowIcon(el) {
el.title = 'Switch — click to toggle';
const b = document.createElement('button');
b.type = 'button';
b.className = 'icon-toggle';
b.innerHTML = '<span class="mini-sw"><i></i></span>';
b.addEventListener('click', () => this.send('toggle'));
el.appendChild(b);
this.iconBtn = b;
}
render(state) {
this.input.checked = !!state.on;
this.text.textContent = state.on ? 'ON' : 'OFF';
if (this.iconBtn) this.iconBtn.classList.toggle('on', !!state.on);
}
}
+27 -5
View File
@@ -10,16 +10,20 @@ import { Device as ButtonDevice } from './button.js';
export class Device extends ButtonDevice {
static typeId = 'touch-button';
static version = '1.0.0';
static version = '1.1.0';
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;
static configSchema = ButtonDevice.configSchema; // includes the V/G pull chip
// idleLevel/pressedLevel inherited from button.js (verified levels).
}
const TOUCH_SVG = `<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>`;
export class View {
constructor(el, sendCmd) {
this.send = sendCmd;
@@ -28,9 +32,7 @@ export class View {
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>`;
pad.innerHTML = `${TOUCH_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);
@@ -41,7 +43,27 @@ export class View {
this.pad = pad;
el.appendChild(pad);
}
/* Compact row icon: press-and-hold fingertip. */
rowIcon(el) {
el.title = 'Touch — press and hold';
const b = document.createElement('button');
b.type = 'button';
b.className = 'icon-touch';
b.innerHTML = TOUCH_SVG;
const down = (e) => { e.preventDefault(); this.send('press'); b.classList.add('down'); };
const up = (e) => { if (e) e.preventDefault(); this.send('release'); b.classList.remove('down'); };
b.addEventListener('pointerdown', down);
b.addEventListener('pointerup', up);
b.addEventListener('pointerleave', up);
b.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') down(e); });
b.addEventListener('keyup', up);
el.appendChild(b);
this.iconBtn = b;
}
render(state) {
this.pad.classList.toggle('active', !!state.pressed);
if (this.iconBtn) this.iconBtn.classList.toggle('down', !!state.pressed);
}
}
+187 -39
View File
@@ -2,40 +2,56 @@
* esp-emu-peris panel styles. The stock page CSS stays inline in
* esp-emu-peris.html (kept verbatim from upstream for easy re-diff);
* everything peris-specific lives here.
*
* Layout: console | peripherals (one compact row per device, expandable
* details) | debug — all three visible at once. The peripherals panel has
* its own scrolling area so the toolbar stays in view.
*/
/* ---- right panel tabs ---- */
.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;
/* ---- right-side panel heads (peripherals + debug) ---- */
.panel-head {
padding: 8px 14px 7px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.8px;
cursor: pointer;
letter-spacing: 0.9px;
color: var(--muted);
font-weight: 600;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
user-select: none;
}
.main-area > .peris-panel {
width: 360px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
/* Higher specificity than the stock `.side-panel` width rules so both
right-hand panels always share the same width. */
@media (max-width: 1400px) {
.main-area > .peris-panel, .main-area > .side-panel { width: 300px; }
}
@media (max-width: 1150px) {
.main-area > .peris-panel, .main-area > .side-panel { width: 260px; }
}
@media (max-width: 860px) {
.main-area > .peris-panel, .main-area > .side-panel {
width: 100%;
border-left: 0;
border-bottom: 1px solid var(--border);
}
}
.ptab:hover { color: var(--text); }
.ptab.active { color: var(--accent-text); border-bottom-color: var(--accent); }
.pane.hidden { display: none; }
/* ---- peripherals panel ---- */
#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;
display: flex; gap: 6px; padding: 10px 12px;
border-bottom: 1px solid var(--border);
position: sticky; top: 41px; background: var(--surface); z-index: 1;
flex-shrink: 0;
}
.peris-toolbar select {
flex: 1; min-width: 0;
@@ -48,38 +64,170 @@
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-scroll {
flex: 1; min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
#peris-cards { padding: 10px 12px 14px; display: flex; flex-direction: column; gap: 6px; }
#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 {
/* ---- device rows (photoshop-layers style) ---- */
.peris-row {
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;
.peris-row.dragging { opacity: 0.5; border-style: dashed; }
.peris-row-main {
display: flex; align-items: center; gap: 6px;
padding: 4px 4px 4px 8px;
min-height: 32px;
}
.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; }
/* interactive device icon (built by View.rowIcon; fallback = letter chip) */
.dev-icon { width: 24px; height: 24px; flex-shrink: 0; display: grid; place-items: center; }
.dev-icon-plain {
width: 18px; height: 18px; border-radius: 5px;
background: var(--surface-3); border: 1px solid var(--border-strong);
color: var(--muted); font-size: 10px; font-weight: 700;
}
/* name: click to rename in place */
.dev-name-slot { min-width: 0; display: flex; }
.dev-name {
background: none; border: 0; padding: 2px 5px; border-radius: 4px;
color: var(--text); font-size: 12px; font-weight: 600; text-align: left;
max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: text;
}
.dev-name:hover { background: var(--surface-3); outline: 1px dashed var(--border-strong); }
.dev-name-input {
background: var(--bg); border: 1px solid var(--accent); border-radius: 4px;
color: var(--text); font-size: 12px; font-weight: 600; padding: 1px 4px;
width: 110px; outline: none;
}
/* pin config chips: GPIO number box + V/G pull badge */
.dev-chip-slot { display: flex; }
.dev-chip {
font-family: var(--mono); font-size: 11px; font-weight: 600; line-height: 1;
min-width: 22px; height: 20px; padding: 0 4px; text-align: center;
background: var(--bg); color: var(--accent-text);
border: 1px solid var(--border-strong); border-radius: 4px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: border-color 0.1s, color 0.1s;
}
.dev-chip:hover { border-color: var(--accent); }
.dev-chip-pull.v { color: var(--ok); }
.dev-chip-pull.g { color: var(--faint); }
.dev-chip-select {
font-family: var(--mono); font-size: 11px; height: 20px; padding: 0 2px;
background: var(--bg); color: var(--text);
border: 1px solid var(--accent); border-radius: 4px; outline: none;
max-width: 130px;
}
/* right-aligned row controls: expand / remove / drag handle */
.row-spacer { flex: 1; }
.row-btn {
width: 22px; height: 22px; flex-shrink: 0;
background: none; border: 0; border-radius: 4px; color: var(--faint);
display: grid; place-items: center; cursor: pointer;
font-size: 13px; line-height: 1;
transition: background 0.1s, color 0.1s;
}
.row-btn:hover { background: var(--surface-3); color: var(--text); }
.row-remove:hover { color: var(--err); }
.row-expand svg { transition: transform 0.12s; }
.peris-row.open .row-expand svg { transform: rotate(90deg); }
.row-grip {
width: 18px; height: 22px; flex-shrink: 0;
display: grid; place-items: center;
color: var(--faint); cursor: grab; font-size: 11px;
border-radius: 4px; user-select: none;
}
.row-grip:hover { color: var(--muted); background: var(--surface-3); }
/* expandable details (blender-style, one property per line) */
.peris-details { display: none; border-top: 1px dashed var(--border); padding: 8px 10px 10px; }
.peris-row.open .peris-details { display: block; }
.dev-body-wrap { display: flex; justify-content: center; padding: 2px 0 6px; }
.dev-editor {
display: grid; grid-template-columns: 1fr; gap: 5px;
margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border);
}
.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;
width: 118px; 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; }
.dev-field select { max-width: 118px; }
/* device view bodies */
/* ---- row icon widgets (built by each plugin's View.rowIcon) ---- */
.led-dot {
--led-color: #ff4d4d;
display: block; width: 14px; height: 14px; 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; cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
}
.led-dot.lit {
background: radial-gradient(circle at 38% 32%, #ffffff33 0%, var(--led-color) 55%, color-mix(in srgb, var(--led-color) 55%, #20060a) 100%);
box-shadow: 0 0 10px 2px color-mix(in srgb, var(--led-color) 55%, transparent);
border-color: var(--led-color);
}
/* the native colour picker is opened straight from a transparent overlay */
.led-dot-picker { position: absolute; inset: -4px; opacity: 0; width: calc(100% + 8px); height: calc(100% + 8px); cursor: pointer; border: 0; padding: 0; }
.icon-press {
width: 22px; height: 22px; padding: 0; border-radius: 50%;
border: 1px solid var(--border-strong); background: var(--surface-3);
color: var(--muted); cursor: pointer; display: grid; place-items: center;
box-shadow: 0 2px 0 #0a0a0c;
transition: transform 0.05s, box-shadow 0.05s, color 0.1s, border-color 0.1s;
}
.icon-press.down, .icon-press:active {
transform: translateY(2px); box-shadow: 0 0 0 #0a0a0c;
color: var(--accent-text); border-color: var(--accent);
}
.icon-touch {
width: 22px; height: 22px; padding: 0; border-radius: 50%;
border: 1px solid var(--border-strong); background: var(--surface-3);
color: var(--muted); cursor: pointer; display: grid; place-items: center;
transition: color 0.1s, border-color 0.1s, box-shadow 0.1s;
}
.icon-touch svg { width: 13px; height: 13px; }
.icon-touch.down, .icon-touch:active {
color: var(--accent-text); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.icon-toggle { background: none; border: 0; padding: 2px; cursor: pointer; display: block; }
.mini-sw {
width: 26px; height: 14px; border-radius: 999px;
background: var(--bg); border: 1px solid var(--border-strong);
position: relative; display: block;
transition: background 0.15s, border-color 0.15s;
}
.mini-sw i {
position: absolute; top: 1.5px; left: 1.5px;
width: 9px; height: 9px; border-radius: 50%;
background: var(--surface-3); border: 1px solid var(--border-strong);
transition: left 0.15s, background 0.15s;
}
.icon-toggle.on .mini-sw { background: var(--accent-soft); border-color: var(--accent); }
.icon-toggle.on .mini-sw i { left: 14px; background: var(--accent); border-color: var(--accent); }
/* ---- device view bodies (inside expanded details) ---- */
.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 {
+25 -2
View File
@@ -34,7 +34,11 @@ export interface DeviceCtx {
log(text: string, level?: 'info' | 'warn' | 'error'): void;
}
/** One field of the generic config editor. */
/**
* One field of the generic config editor. A `pin` field is additionally
* shown as the compact GPIO-number chip in the device row; a checkbox field
* marked `chip: 'pull'` is shown as the V/G idle-pull badge (click to flip).
*/
export interface ConfigField {
key: string;
label: string;
@@ -43,6 +47,10 @@ export interface ConfigField {
max?: number;
step?: number;
options?: Array<{ value: string | number; label: string }>;
/** Render this checkbox as the row's V/G pull badge. */
chip?: 'pull';
/** Idle pull level while the checkbox is checked ('high' → V, 'low' → G). Defaults to 'high'. */
pullWhenChecked?: 'high' | 'low';
}
/** A device entry in a wiring config: { id, type, ...settings }. */
@@ -83,13 +91,28 @@ export interface DeviceLike {
destroy?(): void;
}
/**
* Page-side handle handed to a View so it can request config changes
* (e.g. the LED row icon's colour picker) without touching the panel.
*/
export interface ViewHost {
/** Merge a patch into this device's config, re-apply and persist. */
updateConfig(patch: Record<string, unknown>): void;
}
/** Page-side view of a device plugin. */
export interface ViewLike {
render(state: Record<string, unknown>): void;
setConfig?(cfg: DeviceConfig): void;
/**
* Optional: build the compact interactive icon shown in the device row
* (click actions: LED → colour picker, button → press, switch → toggle).
* The same render(state) calls drive it; `el` is a ~24px container.
*/
rowIcon?(el: HTMLElement): void;
}
export interface ViewClass {
new (el: HTMLElement, sendCmd: (cmd: string, params?: Record<string, unknown>) => void): ViewLike;
new (el: HTMLElement, sendCmd: (cmd: string, params?: Record<string, unknown>) => void, host?: ViewHost): ViewLike;
}
export interface DeviceModule {
+1 -1
View File
@@ -2,4 +2,4 @@
* Project version (keep in sync with package.json and docs/CHANGELOG.md).
* Device plugins carry their own per-device version in Device.version.
*/
export const PERIS_VERSION = '0.2.0';
export const PERIS_VERSION = '0.3.1';
+5 -4
View File
@@ -168,10 +168,11 @@ class App {
this.applyMachineInfo({ ...this.machine, ...msg });
this.term.writeln('\x1b[33m[System] Emulator reset\x1b[0m');
this.term.writeln(`\x1b[32m[System] Firmware reloaded, PC=0x${msg.pc.toString(16)}\x1b[0m`);
this.firmwareLoaded = true;
this.debug.renderCores([msg.pc], this.machine.chip);
this.perf.updateTiles(0);
this.setStatus('ready', 'Ready');
this.firmwareLoaded = true;
this.debug.renderCores([msg.pc], this.machine.chip);
this.perf.updateTiles(0);
this.peris.refreshForChip(); // pin options follow the (possibly new) chip
this.setStatus('ready', 'Ready');
} else {
this.term.writeln('\x1b[33m[System] Emulator reset (no firmware)\x1b[0m');
this.firmwareLoaded = false;
+58 -10
View File
@@ -1,7 +1,9 @@
/*
* Generic device config editor, driven by the device's configSchema.
* Rendered inside each peripheral card; edits update the config object
* through onChange and are then applied to the worker by the panel.
* Rendered in each peripheral row's expanded details; edits update the
* config object through onChange and are then applied to the worker by
* the panel. Also exports rowChips() and fillPinOptions() — the shared
* helpers behind the compact GPIO / V·G chips shown inline in the row.
*/
import type { ConfigField, DeviceConfig, DeviceMeta } from '../core/types.js';
@@ -13,6 +15,23 @@ export interface EditorDeps {
onChange(): void;
}
/**
* Fill a <select> with the chip's valid GPIO options. This is the single
* source for every pin dropdown (details editor + row chip) — a second
* copy once drifted and served stale options, so reuse it, do not fork it.
*/
export function fillPinOptions(sel: HTMLSelectElement, valid: number[], used: Set<number>, current: unknown): void {
sel.innerHTML = '';
for (const p of valid) {
const opt = document.createElement('option');
opt.value = String(p);
opt.textContent = `GPIO ${p}` + (used.has(p) ? ' (in use)' : '');
sel.appendChild(opt);
}
sel.value = String(current);
if (sel.selectedIndex < 0 && sel.options.length) sel.value = sel.options[0].value;
}
export function buildConfigEditor(devCfg: DeviceConfig, meta: DeviceMeta, deps: EditorDeps): HTMLElement {
const editor = document.createElement('div');
editor.className = 'dev-editor';
@@ -26,14 +45,7 @@ export function buildConfigEditor(devCfg: DeviceConfig, meta: DeviceMeta, deps:
let input: HTMLSelectElement | HTMLInputElement;
if (field.type === 'pin') {
const sel = document.createElement('select');
for (const p of deps.validPins) {
const opt = document.createElement('option');
opt.value = String(p);
opt.textContent = `GPIO ${p}` + (used.has(p) ? ' (in use)' : '');
sel.appendChild(opt);
}
sel.value = String(devCfg[field.key]);
if (sel.selectedIndex < 0 && sel.options.length) sel.value = sel.options[0].value;
fillPinOptions(sel, deps.validPins, used, devCfg[field.key]);
input = sel;
} else if (field.type === 'checkbox') {
const cb = document.createElement('input');
@@ -77,3 +89,39 @@ export function buildConfigEditor(devCfg: DeviceConfig, meta: DeviceMeta, deps:
}
return editor;
}
/** Compact row summary derived from the schema (pure, unit-tested). */
export interface RowChips {
/** Schema key of the GPIO pin field, if the device has one. */
pinKey: string | null;
/** Current pin number (null if absent/invalid). */
pin: number | null;
/** Schema key of the checkbox shown as the V/G pull badge, if any. */
pullKey: string | null;
/** 'V' = idle pulled up to VCC, 'G' = idle pulled down to GND. */
pullChar: 'V' | 'G' | null;
}
/**
* Which config values a device row shows inline: the first `pin` field and
* the checkbox marked `chip:'pull'` (V when checked pulls high, unless the
* schema says `pullWhenChecked:'low'`).
*/
export function rowChips(meta: DeviceMeta, cfg: DeviceConfig): RowChips {
const fields: ConfigField[] = meta.configSchema || [];
const pinField = fields.find((f) => f.type === 'pin') || null;
const pullField = fields.find((f) => f.chip === 'pull' && f.type === 'checkbox') || null;
const pinKey = pinField?.key ?? null;
let pin: number | null = null;
if (pinKey != null) {
const n = Number(cfg[pinKey]);
if (Number.isFinite(n)) pin = n;
}
let pullChar: 'V' | 'G' | null = null;
if (pullField) {
const highWhenChecked = pullField.pullWhenChecked !== 'low';
const checked = !!cfg[pullField.key];
pullChar = checked === highWhenChecked ? 'V' : 'G';
}
return { pinKey, pin, pullKey: pullField?.key ?? null, pullChar };
}
+329 -77
View File
@@ -1,16 +1,19 @@
/*
* Peripherals panel: device cards with config editors, add/remove,
* wiring-config import/export + localStorage autosave, tab switching and
* the peris_* message handlers (peris_ready / peris_state / peris_log).
* Peripherals panel: one compact row per device (interactive icon · name ·
* GPIO chip · V/G pull badge, right-aligned expand/remove/drag) with an
* expandable details area holding the device View body and the generic
* config editor. Also owns add/remove, drag-sort, wiring-config
* import/export + localStorage autosave and the peris_* message handlers
* (peris_ready / peris_state / peris_log).
*/
import { loadRegistry } from '../core/registry.js';
import type { Registry } from '../core/registry.js';
import { normaliseConfig } from '../core/types.js';
import type { DeviceConfig, PerisConfig, ViewClass, ViewLike } from '../core/types.js';
import type { DeviceConfig, PerisConfig, ViewClass, ViewHost, ViewLike } from '../core/types.js';
import { PERIS_API_VERSION, PERIS_CONFIG, PERIS_COMMAND, PERIS_READY, PERIS_STATE, PERIS_LOG } from '../core/protocol.js';
import type { PerisEvent } from '../core/protocol.js';
import { validPins } from '../core/chips.js';
import { buildConfigEditor } from './config-editor.js';
import { buildConfigEditor, fillPinOptions, rowChips } from './config-editor.js';
import type { TerminalView } from './terminal.js';
const STORAGE_KEY = 'peris.config.v1';
@@ -21,19 +24,25 @@ export interface PerisPanelDeps {
terminal: TerminalView;
}
interface RowEntry { row: HTMLElement; view: ViewLike | null; }
export class PerisPanel {
private registry: Registry | null = null;
private config: PerisConfig = { perisVersion: PERIS_API_VERSION, devices: [] };
private views = new Map<string, { card: HTMLElement; view: ViewLike | null }>();
private views = new Map<string, RowEntry>();
private expanded = new Set<string>();
private dragRow: HTMLElement | null = null;
private idSeq = 0;
/** Chip the detail editors were built for (refreshForChip rebuilds on change). */
private editorChip = '';
constructor(private deps: PerisPanelDeps) {
this.wireToolbar();
this.wireTabs();
this.wireDrag();
this.restore();
}
/** Load the device registry, render toolbar + cards, apply to worker. */
/** Load the device registry, render toolbar + rows, apply to worker. */
async init() {
const reg = await loadRegistry();
this.registry = reg;
@@ -43,7 +52,7 @@ export class PerisPanel {
this.renderToolbar();
this.renderAll();
this.deps.terminal.writeln(
`\x1b[32m[Peris] ${reg.types().length} device type(s) ready — add peripherals in the Peripherals tab.\x1b[0m`);
`\x1b[32m[Peris] ${reg.types().length} device type(s) ready — add peripherals in the Peripherals panel.\x1b[0m`);
}
// ---- worker message handling ----
@@ -76,6 +85,7 @@ export class PerisPanel {
/** Re-apply the config after the worker loaded a fresh emulator. */
reapplyOnLoad() {
this.refreshForChip();
this.apply();
}
@@ -101,7 +111,7 @@ export class PerisPanel {
} catch { /* ignore */ }
}
// ---- cards ----
// ---- rows ----
private usedPins(exceptId: string): Set<number> {
const used = new Set<number>();
@@ -143,66 +153,229 @@ export class PerisPanel {
}
}
private buildDeviceCard(devCfg: DeviceConfig): { card: HTMLElement; view: ViewLike | null } | null {
private buildDeviceRow(devCfg: DeviceConfig): RowEntry | null {
const meta = this.registry?.meta(devCfg.type);
if (!meta) return null;
const card = document.createElement('div');
card.className = 'peris-card';
card.dataset.id = devCfg.id;
const row = document.createElement('div');
row.className = 'peris-row' + (this.expanded.has(devCfg.id) ? ' open' : '');
row.dataset.id = devCfg.id;
// header: title, type tag (with plugin version), remove
const ver = meta.version ? ` v${meta.version}` : '';
const head = document.createElement('div');
head.className = 'peris-card-head';
head.innerHTML = `<span class="dev-title"></span><span class="dev-type">${meta.title} · ${devCfg.type}${ver}</span>`;
const remove = document.createElement('button');
remove.className = 'btn small dev-remove';
remove.title = 'Remove device';
remove.textContent = '×';
head.appendChild(remove);
card.appendChild(head);
// ---- the one-line main row ----
const main = document.createElement('div');
main.className = 'peris-row-main';
// body: the device's own View
const body = document.createElement('div');
card.appendChild(body);
const ViewClass: ViewClass | undefined = this.registry?.View(devCfg.type);
let view: ViewLike | null = null;
if (ViewClass) {
view = new ViewClass(body, (cmd, params) =>
this.deps.send({ type: PERIS_COMMAND, id: devCfg.id, cmd, params }));
view.setConfig?.(devCfg);
const icon = document.createElement('span');
icon.className = 'dev-icon';
main.appendChild(icon);
const nameSlot = document.createElement('span');
nameSlot.className = 'dev-name-slot';
main.appendChild(nameSlot);
const chips = rowChips(meta, devCfg);
if (chips.pinKey != null) {
const pinSlot = document.createElement('span');
pinSlot.className = 'dev-chip-slot';
main.appendChild(pinSlot);
}
if (chips.pullKey != null) {
const pullBtn = document.createElement('button');
pullBtn.type = 'button';
pullBtn.className = 'dev-chip dev-chip-pull';
pullBtn.title = 'Idle pull level — click to flip (V = pull-up to VCC, G = pull-down to GND)';
pullBtn.addEventListener('click', () => {
devCfg[chips.pullKey!] = !devCfg[chips.pullKey!];
this.apply();
this.views.get(devCfg.id)?.view?.setConfig?.(devCfg);
this.renderRows();
});
main.appendChild(pullBtn);
}
// config editor
const spacer = document.createElement('span');
spacer.className = 'row-spacer';
main.appendChild(spacer);
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'row-btn row-expand';
expand.title = 'Show / hide details';
expand.innerHTML = `<svg viewBox="0 0 10 10" width="10" height="10"><path d="M3 1l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
expand.addEventListener('click', () => {
const open = row.classList.toggle('open');
if (open) this.expanded.add(devCfg.id);
else this.expanded.delete(devCfg.id);
});
main.appendChild(expand);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'row-btn row-remove';
remove.title = 'Remove device';
remove.textContent = '×';
remove.addEventListener('click', () => {
this.config.devices = this.config.devices.filter((d) => d.id !== devCfg.id);
this.views.delete(devCfg.id);
this.expanded.delete(devCfg.id);
row.remove();
this.apply();
this.renderEmpty();
});
main.appendChild(remove);
const grip = document.createElement('span');
grip.className = 'row-grip';
grip.title = 'Drag to reorder';
grip.textContent = '⠿';
// Dragging is armed only while the grip is held, so clicks on the
// other controls never start a drag.
grip.addEventListener('pointerdown', () => { row.draggable = true; });
row.addEventListener('pointerup', () => { row.draggable = false; });
row.addEventListener('dragstart', (e) => {
this.dragRow = row;
row.classList.add('dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', devCfg.id);
}
});
row.addEventListener('dragend', () => {
row.draggable = false;
row.classList.remove('dragging');
this.dragRow = null;
this.commitOrder();
});
main.appendChild(grip);
row.appendChild(main);
// ---- expandable details: the device's own View + config editor ----
const details = document.createElement('div');
details.className = 'peris-details';
const bodyWrap = document.createElement('div');
bodyWrap.className = 'dev-body-wrap';
const body = document.createElement('div');
bodyWrap.appendChild(body);
details.appendChild(bodyWrap);
let view: ViewLike | null = null;
const host: ViewHost = {
updateConfig: (patch) => {
Object.assign(devCfg, patch);
this.apply();
view?.setConfig?.(devCfg);
this.renderRows();
},
};
const ViewClass: ViewClass | undefined = this.registry?.View(devCfg.type);
if (ViewClass) {
view = new ViewClass(body, (cmd, params) =>
this.deps.send({ type: PERIS_COMMAND, id: devCfg.id, cmd, params }), host);
// rowIcon first, then setConfig: the icon exists when config
// styling is applied, so both visuals stay in lock-step.
view.rowIcon?.(icon);
view.setConfig?.(devCfg);
} else {
icon.classList.add('dev-icon-plain');
icon.title = meta.title;
icon.textContent = meta.title.charAt(0).toUpperCase();
}
row.appendChild(details);
this.attachEditor(row, devCfg, view); // after details is attached
document.getElementById('peris-cards')!.appendChild(row);
const entry: RowEntry = { row, view };
this.views.set(devCfg.id, entry);
this.updateRow(row, devCfg);
return entry;
}
/**
* (Re)build the details config editor for a row. Pin options snapshot
* the chip at build time, so this is also how a chip change reaches
* the editors — see refreshForChip().
*/
private attachEditor(row: HTMLElement, devCfg: DeviceConfig, view: ViewLike | null) {
const meta = this.registry?.meta(devCfg.type);
const details = row.querySelector('.peris-details');
if (!meta || !details) return;
details.querySelector('.dev-editor')?.remove();
const editor = buildConfigEditor(devCfg, meta, {
validPins: validPins(this.deps.getChip()),
usedPins: (id) => this.usedPins(id),
onChange: () => {
this.apply();
view?.setConfig?.(devCfg);
this.renderTitles();
this.renderRows();
},
});
card.appendChild(editor);
remove.addEventListener('click', () => {
this.config.devices = this.config.devices.filter((d) => d.id !== devCfg.id);
this.views.delete(devCfg.id);
card.remove();
this.apply();
this.renderEmpty();
});
document.getElementById('peris-cards')!.appendChild(card);
const entry = { card, view };
this.views.set(devCfg.id, entry);
return entry;
details.appendChild(editor);
}
private renderTitles() {
/**
* Rebuild the detail editors when the active chip changed (loading
* firmware for another chip after devices were added). The row's inline
* pin chip already asks the chip live; the editors must be rebuilt.
*/
refreshForChip() {
const chip = this.deps.getChip();
if (chip === this.editorChip) return;
this.editorChip = chip;
for (const d of this.config.devices) {
const entry = this.views.get(d.id);
if (entry) this.attachEditor(entry.row, d, entry.view);
}
}
/** Refresh one row's inline name/pin/pull chips. Slots currently holding
* an inline editor (input/select) are skipped; a slot emptied by a
* finished editor gets its button rebuilt here.
*/
private updateRow(row: HTMLElement, devCfg: DeviceConfig) {
const meta = this.registry?.meta(devCfg.type);
if (!meta) return;
const chips = rowChips(meta, devCfg);
const nameSlot = row.querySelector<HTMLElement>('.dev-name-slot');
if (nameSlot && !nameSlot.querySelector('input')) {
let btn = nameSlot.querySelector<HTMLButtonElement>('.dev-name');
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'dev-name';
btn.title = 'Rename';
btn.addEventListener('click', () => this.editName(nameSlot, devCfg));
nameSlot.replaceChildren(btn);
}
btn.textContent = String(devCfg.name ?? devCfg.type);
}
const pinSlot = row.querySelector<HTMLElement>('.dev-chip-slot');
if (pinSlot && !pinSlot.querySelector('select')) {
let btn = pinSlot.querySelector<HTMLButtonElement>('.dev-chip-pin');
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'dev-chip dev-chip-pin';
btn.title = 'GPIO pin — click to change';
btn.addEventListener('click', () => this.editPin(pinSlot, devCfg, chips.pinKey!));
pinSlot.replaceChildren(btn);
}
btn.textContent = chips.pin != null ? String(chips.pin) : '–';
}
const pull = row.querySelector('.dev-chip-pull');
if (pull) {
pull.textContent = chips.pullChar ?? '–';
pull.classList.toggle('v', chips.pullChar === 'V');
pull.classList.toggle('g', chips.pullChar === 'G');
}
}
private renderRows() {
for (const d of this.config.devices) {
const v = this.views.get(d.id);
if (v) v.card.querySelector('.dev-title')!.textContent = String(d.name ?? d.type);
if (v) this.updateRow(v.row, d);
}
}
@@ -219,10 +392,11 @@ export class PerisPanel {
const meta = this.registry?.meta(devCfg.type);
if (!meta) continue;
Object.assign(devCfg, normaliseConfig(meta, devCfg)); // drop unknown keys
this.buildDeviceCard(devCfg);
this.buildDeviceRow(devCfg);
}
this.renderTitles();
this.renderRows();
this.renderEmpty();
this.editorChip = this.deps.getChip();
this.apply();
}
@@ -236,11 +410,100 @@ export class PerisPanel {
const pinField = (meta.configSchema || []).find((f) => f.type === 'pin');
if (pinField) (cfg as Record<string, unknown>)[pinField.key] = this.freePin(cfg[pinField.key] as number | undefined);
this.config.devices.push(cfg);
this.buildDeviceCard(cfg);
this.renderTitles();
this.expanded.add(id);
this.buildDeviceRow(cfg);
this.renderRows();
this.renderEmpty();
this.apply();
this.views.get(id)?.card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
this.views.get(id)?.row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ---- inline row editors ----
private editName(slot: HTMLElement, devCfg: DeviceConfig) {
if (slot.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'dev-name-input';
input.value = String(devCfg.name ?? devCfg.type);
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') input.blur();
else if (e.key === 'Escape') {
input.value = String(devCfg.name ?? devCfg.type);
input.blur();
}
});
input.addEventListener('blur', () => {
const v = input.value.trim();
slot.replaceChildren(); // close the editor; updateRow rebuilds the button
if (v && v !== devCfg.name) {
devCfg.name = v;
this.apply();
this.views.get(devCfg.id)?.view?.setConfig?.(devCfg);
}
this.renderRows();
});
slot.replaceChildren(input);
input.focus();
input.select();
}
private editPin(slot: HTMLElement, devCfg: DeviceConfig, key: string) {
if (slot.querySelector('select')) return;
const sel = document.createElement('select');
sel.className = 'dev-chip-select';
// same option source as the details editor — never fork this
fillPinOptions(sel, validPins(this.deps.getChip()), this.usedPins(devCfg.id), devCfg[key]);
sel.addEventListener('change', () => {
devCfg[key] = Number(sel.value);
slot.replaceChildren(); // commit closes the editor
this.apply();
this.views.get(devCfg.id)?.view?.setConfig?.(devCfg);
this.renderRows();
});
sel.addEventListener('blur', () => {
if (!slot.contains(sel)) return; // already committed
slot.replaceChildren(); // cancel closes the editor
this.renderRows();
});
sel.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
slot.replaceChildren();
this.renderRows();
}
});
slot.replaceChildren(sel);
sel.focus();
}
// ---- drag sorting ----
private wireDrag() {
const cards = document.getElementById('peris-cards')!;
cards.addEventListener('dragover', (e) => {
if (!this.dragRow) return;
e.preventDefault();
const rows = Array.from(cards.querySelectorAll('.peris-row'))
.filter((r) => r !== this.dragRow) as HTMLElement[];
const after = rows.find((r) => {
const b = r.getBoundingClientRect();
return e.clientY < b.top + b.height / 2;
});
if (after) cards.insertBefore(this.dragRow, after);
else cards.appendChild(this.dragRow);
});
}
/** Reorder the config from the DOM order (device set unchanged → no worker rebuild). */
private commitOrder() {
const cards = document.getElementById('peris-cards')!;
const ids = Array.from(cards.querySelectorAll('.peris-row'))
.map((el) => (el as HTMLElement).dataset.id!);
this.config.devices.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
this.persist();
}
// ---- import / export ----
@@ -282,7 +545,7 @@ export class PerisPanel {
reader.readAsText(file);
}
// ---- toolbar / tabs wiring ----
// ---- toolbar wiring ----
private wireToolbar() {
document.getElementById('peris-add-btn')!.addEventListener('click', () => {
@@ -297,9 +560,15 @@ export class PerisPanel {
if (f) this.importCfg(f);
(e.target as HTMLInputElement).value = '';
});
// Config files are also drop targets.
// Config files are also drop targets (ignore internal row sorting,
// which carries text/plain rather than files).
const pane = document.getElementById('pane-peris')!;
pane.addEventListener('dragover', (e) => { e.preventDefault(); pane.classList.add('over'); });
const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes('Files');
pane.addEventListener('dragover', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
pane.classList.add('over');
});
pane.addEventListener('dragleave', () => pane.classList.remove('over'));
pane.addEventListener('drop', (e) => {
e.preventDefault();
@@ -308,21 +577,4 @@ export class PerisPanel {
if (f && f.name.endsWith('.json')) this.importCfg(f);
});
}
private wireTabs() {
const tabs = document.querySelectorAll('.ptab');
const panes: Record<string, HTMLElement> = {
peris: document.getElementById('pane-peris')!,
debug: document.getElementById('pane-debug')!,
};
for (const tab of Array.from(tabs)) {
(tab as HTMLElement).addEventListener('click', () => {
for (const t of Array.from(tabs)) t.classList.toggle('active', t === tab);
const name = (tab as HTMLElement).dataset.pane!;
for (const [key, pane] of Object.entries(panes)) {
pane.classList.toggle('hidden', key !== name);
}
});
}
}
}
+50 -2
View File
@@ -1,7 +1,8 @@
/*
* Unit tests for the built-in device plugins (worker side, no emulator):
* electrical semantics — button idle/pressed levels (an inverted activeLow
* was a real M1 bug), LED onPinState mapping, toggle switch driving.
* was a real M1 bug), LED onPinState mapping, toggle switch driving —
* plus the row-UI contract (rowIcon, V/G pull chips).
* Run: node --test tests/unit/
*/
import { test } from 'node:test';
@@ -9,7 +10,9 @@ import assert from 'node:assert/strict';
import * as led from '../../peris/devices/led.js';
import * as button from '../../peris/devices/button.js';
import * as toggle from '../../peris/devices/toggle-switch.js';
import * as touch from '../../peris/devices/touch-button.js';
import { validateDeviceModule } from '../../build/core/types.js';
import { rowChips } from '../../build/ui/config-editor.js';
/** Recording stub for the runtime ctx handed to device constructors. */
function makeCtx() {
@@ -31,11 +34,56 @@ function makeCtx() {
}
test('all device modules pass meta validation', () => {
for (const mod of [led, button, toggle]) {
for (const mod of [led, button, toggle, touch]) {
assert.equal(validateDeviceModule(mod), null, mod.Device.typeId);
}
});
test('all built-in Views implement the row icon API', () => {
for (const mod of [led, button, toggle, touch]) {
assert.equal(typeof mod.View.prototype.rowIcon, 'function', mod.Device.typeId);
}
});
test('rowChips: GPIO number + V/G pull char from schema chip flags', () => {
// input devices: activeLow checked → idle pulled up to VCC ('V')
const c1 = rowChips(button.Device, { id: 'b', type: 'button', pin: 3, activeLow: true });
assert.equal(c1.pinKey, 'pin');
assert.equal(c1.pin, 3);
assert.equal(c1.pullKey, 'activeLow');
assert.equal(c1.pullChar, 'V');
const c2 = rowChips(button.Device, { id: 'b', type: 'button', pin: 4, activeLow: false });
assert.equal(c2.pullChar, 'G');
assert.equal(c2.pin, 4);
// touch-button inherits button's schema, so it carries the chip too
assert.equal(rowChips(touch.Device, { pin: 10, activeLow: true }).pullChar, 'V');
// output device: pin chip only, no pull badge
const c3 = rowChips(led.Device, { id: 'l', type: 'led', pin: 8 });
assert.equal(c3.pin, 8);
assert.equal(c3.pullKey, null);
assert.equal(c3.pullChar, null);
// GPIO 0 is a valid pin value (must not be falsy-dropped)
assert.equal(rowChips(led.Device, { pin: 0 }).pin, 0);
// pullWhenChecked:'low' inverts the badge mapping
const inv = {
...toggle.Device,
configSchema: [
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
{ key: 'activeHigh', label: 'Active high', type: 'checkbox', chip: 'pull', pullWhenChecked: 'low' },
],
};
assert.equal(rowChips(inv, { pin: 2, activeHigh: true }).pullChar, 'G');
assert.equal(rowChips(inv, { pin: 2, activeHigh: false }).pullChar, 'V');
// schema without a pin field → no pin chip
const noPin = { ...led.Device, configSchema: [{ key: 'name', label: 'Label', type: 'text' }] };
assert.equal(rowChips(noPin, { name: 'x' }).pinKey, null);
});
test('button active-low: idle HIGH, pressed LOW (pull-up wiring)', () => {
const { ctx, inputs } = makeCtx();
const b = new button.Device(ctx, { id: 'b', type: 'button', pin: 3, activeLow: true });