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:
1 parent
e29f2e81e6
commit
293d76acf7
18 files changed
+876
-181
No files matched your search
@@ -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. 测试与验证约定
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user