MAX7219 display + logic analyzer with Saleae-style floating window (0.9.0 + 0.10.0)

0.9.0 - MAX7219 Display (max7219.js v1.0.0, engine in max7219-lib.js):
- 8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus (hardware
  SPI stays invisible, M0). Level-driven shift engine samples DIN on the
  CLK rising edge, latches MSB-first 16-bit words on the LOAD rising edge;
  misaligned frames are dropped and counted in `dropped`, never garbled.
  Datasheet register model: Code B decode (independent DP), intensity PWM,
  scan limit, shutdown/display test, power-on defaults. 8x8 matrix and
  7-segment Views auto-selected from the decode-mode register; daisy
  chains share the three pads (sharedBus) and latch their chain position.
  Lease sizing measured against the tightest real firmware (gun 0.1.0,
  delay-free pulses ~20 instructions apart): fine at the scheduler floor
  20, arm 60. Smoke S12 boots the gun 0.1.0 release (machine-local, skips
  when absent) with zero drops; examples/esp32s3-gun.json wires it.

0.10.0 - Logic analyzer (logic-analyzer.js v1.0.0, logic in la-lib.js):
- Multi-channel GPIO waveform capture: up to 8 pads, channel+edge or
  immediate trigger, 1-1000 ms guest-time window, auto re-arm, Z shown
  when the pad is released; rides the M4 scheduler (arm 1000, fine lease
  per resolution preset 20/100/300, renewed per edge); 20 000-edge buffer
  freezes with an overflow flag. sharedBus makes it a pure tap on other
  bus devices' pads (smoke S13 arms it on the P4 soft-I2C bus: SCL 1950 /
  SDA 352 alternating edges in a 40 ms window, frozen afterwards).
- Floating analyzer window: draggable, resizable, non-modal popup
  (position fixed, no backdrop - the page underneath keeps every pointer
  event; geometry clamped + persisted). One drawWaveform painter feeds
  the popup, the details mini-preview and the row icon (AGENT 3.8);
  channels picked from a GPIO grid, occupied pins greyed.
- Saleae-style plot interactions: wheel zoom anchored at the cursor (1 ns
  floor), left/middle-drag pan, double-click/F fit; hover measurement
  shades the pulse and reads level + width + period + frequency + PWM
  duty (rise-to-rise cycle); A/B cursors with delta-t and 1/delta-t;
  lane selection + </> buttons and arrow keys jump view-centre onto the
  previous/next edge; lane gutter shows the right-edge level; a fresh
  capture re-fits the view and drops cursors (capId).
- Device API v1 additions (backwards compatible): ctx.pinLevel(pin)
  snapshot read of the driven level; ViewHost.pinInfo() for multi-pin
  pickers; View.destroy() teardown on row removal / list rebuild so Views
  owning DOM outside their row (floating windows) clean it up.
- Tests: la-lib units (parsePins, levelAt, axis ticks, fit span, pixel
  transform, zoom/pan view model, pulseAround, pwmOf, fmtHz/levelChar),
  the capture state machine via fake ctx (immediate/triggered starts,
  direction filtering incl. Z-as-falling, window end, stop/clear/auto,
  buffer overflow, onEmuReady reset, capId), sigbus.pinLevel against a
  fake emulator; smoke S13 + S1 grows to 12 devices;
  examples/esp32c3-probe.json wires the analyzer on the I2C pair.
- Docs (both languages): CHANGELOG 0.9.0 + 0.10.0 + device version table,
  ROADMAP shipped sections, DEVICE-API instrument section + new ctx/host/
  View members, ARCHITECTURE GUI checklist + floating-window note,
  README capability table + tree, AGENT rule 3.11 + GUI-smoke bug-table
  rows; version synced to 0.10.0 in all three places.
This commit is contained in:
darceye committed 2026-11-02 00:05:48 +08:00
1 parent 1da996e657
commit 9cd5863d36
29 files changed
+4114 -647

No files matched your search

+17 -1
View File
@@ -137,6 +137,13 @@ and `docs/FEASIBILITY.md` (M0 measurements).
the runtime wired-ANDs shared pads' input drives (gpio_set_input is a
single-winner level write - two slave engines otherwise clobber each
other's ACK pulls, a real M4 bug).
11. **Views may own DOM outside their row** (the logic-analyzer floating
window is `position: fixed` on `document.body`, non-modal by design —
never add a backdrop). Such Views MUST implement `destroy()` (the panel
calls it on row removal and list rebuilds) and build multi-pin pickers
from `host.pinInfo()` — the same candidate-aware pin set the generic
editor uses, never a forked copy. `ctx.pinLevel(pin)` reads the current
driven level for snapshots only; the change stream stays `onPinState`.
## 4. Past bugs (prevent regressions)
@@ -156,6 +163,7 @@ and `docs/FEASIBILITY.md` (M0 measurements).
| two I2C slaves on one bus NACKed every transaction (M4) | `gpio_set_input` is a single-winner level write; shared-bus pads need the runtime's wired-AND arbitration (`sharedBus` metadata) |
| read-address bytes were never ACKed by the engine (M4, unit-caught) | the ACK decision belongs to the *completed byte* (`ackThisByte`), not to the (already switched) phase |
| C compensation diverged from the JS model only in pressure (M4) | `dig_P1` is uint16 in the Bosch layout; 36477 wraps negative in int16 - keep the C and JS implementations cross-checked bit-exactly by the smoke test |
| the logic-analyzer popup opened empty (0.10.0 GUI smoke): a `this.`-less `trigSel` threw mid-construction and the half-built window stayed on screen | unit tests never run View DOM code — for popup-building plugins, do a GUI pass with a `window.addEventListener('error')` trap; also wrap `setPointerCapture` in try/catch (synthetic/automated pointer events carry no active pointer id and throw NotFoundError, killing the drag) |
## 5. Testing & verification conventions
@@ -174,7 +182,15 @@ and `docs/FEASIBILITY.md` (M0 measurements).
devices (S1–S6 GPIO devices — S4b drives the `###P1b` digital-in sampling:
switch/touch/button levels read back by the guest plus the LED mirror,
S7/S8 LEDC register-level devices driven by the `###P2` markers, S9 ws2812
by `###P3`, S10/S11 the M4 I2C+IR devices by `###P4`). **Timing lessons**:
by `###P3`, S10/S11 the M4 I2C+IR devices by `###P4`, S13 the logic
analyzer armed on the P4 soft-I2C bus (SCL/SDA legally shared with the
i2c slaves); S12 is
**machine-local**: it boots the gun 0.1.0 release — a real-world
ESP32-S3 firmware with a delay-free bit-banged MAX7219 — in a second
emulator instance and skips with a notice when
`D:/d3/_git/gun_flag_monster/gun/release/0.1.0/gun-0.1.0-merged.bin`
is absent; the gun project is read-only reference material, never modify
it, and its later releases use hardware SPI (invisible, M0)). **Timing lessons**:
- **drive by UART markers, never batch counts** — with M4 leases standing
a sleep batch fast-forwards ~680k cycles, and a batch-count window
overshoots whole rounds (the firmware prints `###P1/P1b..P4 start/end` and
+13 -1
View File
@@ -104,6 +104,12 @@ esp-emulator wasm 能力边界见 `docs/EMU-API.zh-cn.md` 与 `docs/FEASIBILITY.
与测试共用)。总线设备声明 `static sharedBus = true`;runtime 对共享焊盘
的输入驱动做线与(gpio_set_input 是单赢家电平写——两个从机引擎否则会
互相覆盖 ACK 拉低,真实 M4 缺陷)。
11. **View 可以持有行以外的 DOM**(逻辑分析仪的浮动窗口挂在
`document.body` 上、`position: fixed`,刻意非模态——绝不加遮罩)。此类
View 必须实现 `destroy()`(面板在行移除与列表重建时调用),多引脚选择
器必须基于 `host.pinInfo()` 构建——与通用编辑器同一份、按候选设备感知
的引脚集合,禁止另抄一份。`ctx.pinLevel(pin)` 只用于快照式读取当前驱动
电平;变化流仍然走 `onPinState`。
## 4. 曾踩过的 bug(防止回潮)
@@ -123,6 +129,7 @@ esp-emulator wasm 能力边界见 `docs/EMU-API.zh-cn.md` 与 `docs/FEASIBILITY.
| 同总线两个 I2C 从机每笔事务都 NACK(M4) | gpio_set_input 是单赢家电平写;共享总线焊盘需要 runtime 的线与仲裁(`sharedBus` 元数据) |
| 引擎从不应答读方向地址字节(M4,单测抓出) | ACK 决定属于**刚完成的字节**(`ackThisByte`),不能按(已切换的)phase 判断 |
| C 补偿与 JS 模型只在气压上分歧(M4) | dig_P1 在 Bosch 布局里是 uint16;36477 在 int16 回绕成负——靠冒烟测试保持 C/JS 逐位一致 |
| 逻辑分析仪弹窗打开后是空的(0.10.0 GUI 冒烟):构造中途一个漏写 `this.` 的 `trigSel` 抛错,半成品窗口留在屏上 | 单元测试从不执行 View 的 DOM 代码——构建弹窗的插件要用带 `window.addEventListener('error')` 陷阱的 GUI 冒测;`setPointerCapture` 还要 try/catch(合成/自动化的指针事件没有活跃指针 id,会抛 NotFoundError 直接弄死拖动) |
## 5. 测试与验证约定
@@ -137,7 +144,12 @@ esp-emulator wasm 能力边界见 `docs/EMU-API.zh-cn.md` 与 `docs/FEASIBILITY.
(S1–S6 GPIO 设备——其中 S4b 驱动 `###P1b` 数字输入采样:开关/触摸/
按键电平被客户机读回并镜像到 LED,S7/S8 LEDC 寄存器级设备按 `###P2`
标记驱动,S9 ws2812 按 `###P3`,S10/S11 为 M4 的 I2C+IR 设备按
`###P4`)。**时序经验**:
`###P4`,S13 逻辑分析仪在 P4 软 I2C 总线上武装捕获(SCL/SDA 与 i2c
从机合法共享);S12 为**本机可选用例**:在第二个模拟器实例里启动 gun 0.1.0
发布件——真实 ESP32-S3 固件,无延时位绑定驱动 MAX7219——
`D:/d3/_git/gun_flag_monster/gun/release/0.1.0/gun-0.1.0-merged.bin`
缺失时打印提示跳过;gun 项目是只读参考,绝不可改动,且其后续版本改用
硬件 SPI(按 M0 不可见))。**时序经验**:
- **按 UART 标记驱动,永不按批数**——M4 租约常设时一个睡眠批次快进
~68 万周期,按批数的窗口会跑过整轮(固件打印 `###P1/P1b..P4 start/end`
与 `###ROUND`,见 `tests/firmware/peris_probe/main/main.c`);
+20 -6
View File
@@ -18,11 +18,15 @@ peris/
led/button/touch-button/toggle-switch + pwm-led/rgb-led (register-level,
shared helpers in ledc-lib.js) + ws2812 (RMT channel RAM, ws2812-lib.js)
+ bitbang-i2c-slave (24C02/BME280, i2c-lib.js) + ir-nec (NEC receiver,
nec-lib.js; both ride the M4 fine-batch sampling scheduler)
nec-lib.js) + max7219 (8-digit LED display, max7219-lib.js)
+ logic-analyzer (multi-GPIO waveform capture in a floating
window, la-lib.js; the bit-bang/protocol/instrument devices
ride the M4 fine-batch sampling scheduler)
vendor/ peris.css vendored xterm + panel styles
examples/ wiring-config examples (JSON, directly importable;
esp32c3-probe.json wires all 11 devices to the probe
firmware pins — import it to test every peripheral)
esp32c3-probe.json wires all 12 probe-firmware devices;
esp32s3-gun.json wires the MAX7219 for the gun 0.1.0
firmware)
tests/unit/ unit tests (node:test)
tests/m1_smoke.mjs integration smoke test (real wasm + firmware, full chain)
tests/m0_probe.mjs emulator capability probe (run first on upstream upgrades)
@@ -76,7 +80,9 @@ npm run sync # copies the entry html + peris/ (incl. build outpu
| WS2812 addressable LEDs | ✅ M3 | decodes from RMT channel RAM (pin-level waveform invisible); strip/ring/matrix Views. Long strips recover only their tail — the 48-word channel RAM keeps the last 46 bits of a frame (same as real C3 hardware) |
| Bit-bang I2C slave (24C02 EEPROM, BME280) | ✅ M4 | level-driven protocol engine on two bit-banged GPIOs (SCL/SDA); answers via the SDA input path (open-drain idiom); several slaves may share one bus (wired-AND arbitration); BME280 compensation mirrored bit-exactly |
| IR NEC receiver | ✅ M4 | captures firmware codes from a bit-banged GPIO (fine-batch edge sampling) or an RMT TX channel (channel RAM); ratio-based decode (the guest clock is not wall-accurate); repeat frames included |
| Hardware I2C/SPI devices, displays, ADC, real touch, I2S, SD | ⏸ M5 | waiting for esp-emulator upstream APIs |
| MAX7219 display (8×8 matrix / 7-segment) | ✅ 0.9.0 | level-driven shift engine on a firmware bit-banged DIN/CLK/LOAD bus (hardware SPI is invisible, M0); datasheet register model incl. Code B decode, intensity, scan limit, shutdown/test; daisy chains share the bus; misaligned frames dropped and counted, never garbled |
| Logic analyzer (multi-GPIO waveforms) | ✅ 0.10.0 | up to 8 pads, channel+edge or immediate trigger, guest-time window, auto re-arm; Z shown when the pad is released; rides the M4 sampling scheduler (resolution presets); shown in a draggable/resizable non-modal floating window (the page underneath keeps working); Saleae-style plot: wheel zoom at the cursor, drag pan, hover pulse/period/frequency/duty measurement, A/B Δt cursors, edge jumping; taps other bus devices' pads legally (`sharedBus`) |
| Hardware I2C/SPI buses (bus-attached displays, SD, etc.), ADC, real touch, I2S | ⏸ M5 | waiting for esp-emulator upstream APIs |
Note: the touch **peripheral** itself is not emulated — the touch button
appears as a GPIO input that firmware reads as plain GPIO
@@ -130,8 +136,16 @@ half duty) / GPIO4 RMT-WS2812 frames / a ~20 kHz soft-I2C master on
SCL=GPIO1+SDA=GPIO2 (24C02 + BME280 transactions, BME compensation
cross-checked bit-exactly) plus NEC IR codes bit-banged on GPIO5 and sent via
RMT ch1 on GPIO10 — plus GPIO3 input-interrupt counting, for end-to-end
verification. Import `examples/esp32c3-probe.json` (wires all 11 devices to
those pins) after loading the merged image to exercise every peripheral.
verification. Import `examples/esp32c3-probe.json` (wires all 12 devices to
those pins — the logic analyzer taps the I2C pair) after loading the merged
image to exercise every peripheral.
A second, real-world integration example: `examples/esp32s3-gun.json` wires
the MAX7219 display for the gun 0.1.0 release (ESP32-S3,
DIN=GPIO21/CLK=GPIO13/LOAD=GPIO14, bit-banged transport) — load
`gun-0.1.0-merged.bin` and the boot register-init sequence and the value
pushes decode live. The smoke test runs the same firmware as S12 when the
release binary is on this machine.
Note: the page itself has no CDN dependency — xterm/xterm-addon-fit are
vendored into `peris/vendor/` (unminified npm builds), so it works offline.
+16 -6
View File
@@ -15,10 +15,13 @@ peris/
toggle-switch + pwm-led/rgb-led(寄存器级,共享辅助在 ledc-lib.js)
+ ws2812(RMT 通道 RAM,ws2812-lib.js)
+ bitbang-i2c-slave(24C02/BME280,i2c-lib.js)+ ir-nec(NEC 接收,
nec-lib.js;两者都依托 M4 细批采样调度器)
nec-lib.js)+ max7219(8 位 LED 显示,max7219-lib.js)
+ logic-analyzer(浮动窗口中的多 GPIO 波形捕获,la-lib.js;
位绑定/协议/仪器类设备都依托 M4 细批采样调度器)
vendor/ peris.css xterm 本地化 + 面板样式
examples/ 接线配置示例(JSON,可直接 Import;esp32c3-probe.json
按探针固件引脚接线全部 11 个设备——导入即可测遍外设)
examples/ 接线配置示例(JSON,可直接 Import):esp32c3-probe.json
按探针固件引脚接线全部 12 个设备;esp32s3-gun.json
为 gun 0.1.0 固件接线 MAX7219 显示
tests/unit/ 单元测试(node:test)
tests/m1_smoke.mjs 集成冒烟(真实 wasm+固件全链路)
tests/m0_probe.mjs 模拟器能力探针(上游升级时先跑)
@@ -67,7 +70,9 @@ npm run sync # 复制入口 html + peris/(含编译产物)+ exam
| WS2812 幻彩灯 | ✅ M3 | 读 RMT 通道 RAM 解码(引脚级波形不可见);灯带/环形/矩阵 View。长灯带只能恢复尾部——48 字通道 RAM 只保留帧的最后 46 位(与真实 C3 硬件一致) |
| 位绑定 I2C 从机(24C02 EEPROM、BME280) | ✅ M4 | 两个位绑定 GPIO(SCL/SDA)上的电平驱动协议引擎;经 SDA 输入路径应答(开漏惯用法);多个从机可共享一条总线(线与仲裁);BME280 补偿逐位精确镜像 |
| IR NEC 接收器 | ✅ M4 | 双路捕获固件码:位绑定 GPIO(细批边沿采样)或 RMT TX 通道(通道 RAM);按比值解码(客户时钟不按墙钟走);支持重复帧 |
| 硬件 I2C/SPI 设备、显示屏、ADC、真触摸、I2S、SD | ⏸ M5 | 等 esp-emulator 上游暴露 API |
| MAX7219 显示(8×8 矩阵/七段数码管) | ✅ 0.9.0 | 固件位绑定 DIN/CLK/LOAD 总线上的电平驱动移位引擎(硬件 SPI 不可见,M0);寄存器模型遵循数据表(Code B 译码、亮度、扫描界、停机/测试);菊花链共享总线;失准帧丢弃并计数,绝不出花屏 |
| 逻辑分析仪(多 GPIO 波形) | ✅ 0.10.0 | 最多 8 个引脚,通道+边沿或立即触发,客机时间窗口,自动重新武装;引脚释放显示 Z;依托 M4 采样调度器(分辨率预设);界面在可拖动/可调大小的非模态浮动窗口中(下层页面照常交互);Saleae 风格绘图:滚轮以光标为中心缩放、拖拽平移、悬停测脉宽/周期/频率/占空比、A/B 游标测 Δt、边沿跳转;`sharedBus` 使其可合法搭接其他总线设备的引脚 |
| 硬件 I2C/SPI 总线(总线挂载的显示屏、SD 等)、ADC、真触摸、I2S | ⏸ M5 | 等 esp-emulator 上游暴露 API |
注:触摸**外设**未被模拟——触摸按键以 GPIO 输入呈现,固件按 GPIO 读取即可
(`tests/firmware/peris_probe` 有示例:GPIO3 输入 + 下降沿 ISR)。
@@ -117,8 +122,13 @@ GPIO9+GPIO6+GPIO7 上 LEDC 呼吸(ch0 渐变、ch1 反向、ch2 恒半占空比)
GPIO4 RMT-WS2812 发帧 / SCL=GPIO1+SDA=GPIO2 上 ~20 kHz soft-I2C 主机
(24C02 + BME280 事务,BME 补偿逐位交叉验证)加 GPIO5 位绑定与 GPIO10
RMT ch1 两路 NEC 红外码——并配合 GPIO3 输入中断计数,用于端到端验证。
加载合并镜像后导入 `examples/esp32c3-probe.json`(按上述引脚接线全部 11
个设备),每个阶段都会驱动到对应外设。
加载合并镜像后导入 `examples/esp32c3-probe.json`(按上述引脚接线全部 12
个设备,逻辑分析仪搭接 I2C 双线),每个阶段都会驱动到对应外设。
另一个真实固件集成示例:`examples/esp32s3-gun.json` 为 gun 0.1.0 发布件
(ESP32-S3,DIN=GPIO21/CLK=GPIO13/LOAD=GPIO14,位绑定传输)接线 MAX7219
显示——加载 `gun-0.1.0-merged.bin`,开机的寄存器初始化序列与数值推送都会
实时解码。发布件在本机存在时,冒烟测试会以 S12 运行同一固件。
注:页面自身不依赖任何 CDN——xterm/xterm-addon-fit 已本地化到
`peris/vendor/`(取自 npm 包的非压缩构建),离线可用。
+13 -2
View File
@@ -69,7 +69,11 @@
is always delivered** (otherwise a final state resting inside the window
would be dropped).
- **Page → device**: View `sendCmd()` → `peris_command` → `runtime.command()` →
`onCommand`.
`onCommand`. Views may own page-side DOM beyond their row (the
logic-analyzer floating window is `position: fixed` on `document.body`,
non-modal by design — no backdrop, so the rest of the page keeps every
pointer event); the panel calls the optional `View.destroy()` on row
removal and list rebuilds so such DOM cannot outlive its device.
- **Input pin levels**: the only writers are `ctx.setPinInput` (devices) and
the Debug grid's `gpio_input`. The page-side `gpioInputLevels[]` is only the
Debug grid's own mirror, not authoritative state; the authority lives in the
@@ -123,7 +127,14 @@ details & config editing, drag sorting, firmware load, LED toggling while
running, PWM/RGB LED auto-binding to the firmware's LEDC channels with smooth
breathing during the P2 phase, the I2C transaction log filling during P4 with
the EEPROM hex dump editable from the View, IR codes appearing on both
ir-nec rows, config import/export round-trip; browser automation to be
ir-nec rows, logic-analyzer: add one, pick channels from the GPIO grid in
the floating window (occupied pins greyed), arm + waveform rendering while
the P4 soft-I2C bus runs, window drag/resize with the page underneath
still interactive, Saleae-style plot checks (wheel zoom at the cursor,
drag pan, hover measurement shows pulse/period/frequency/duty, two clicks
drop A/B cursors with Δt + 1/Δt, double-click re-fits), window closes
when the row is removed, config
import/export round-trip; browser automation to be
introduced with Playwright once the network allows —
`PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright`).
+10 -1
View File
@@ -56,6 +56,10 @@
- **设备 → 页面**:设备 `markDirty()` → `flushStates` 节流 ~30fps;**节流窗口内挂尾随
定时器,保证最后一次 dirty 必达**(否则静止在窗口内的最终状态会被丢弃)。
- **页面 → 设备**:View `sendCmd()` → `peris_command` → `runtime.command()` → `onCommand`。
View 可以持有行以外的页面 DOM(逻辑分析仪的浮动窗口挂在 `document.body`
上、`position: fixed`,刻意做成非模态——无遮罩,页面其余部分的指针事件
照常);面板在行移除与列表重建时调用可选的 `View.destroy()`,此类 DOM
不会比设备活得久。
- **输入引脚电平**:唯一写者是 `ctx.setPinInput`(设备)与 Debug 网格的 `gpio_input`。
页面侧 `gpioInputLevels[]` 只是 Debug 网格自己的镜像,不是权威状态;权威状态在设备里
(`onEmuReady` 时重驱动)。
@@ -99,7 +103,12 @@ S7/S8 以 `###P2` 标记驱动,S9 按 `###P3`,S10/S11(soft-I2C 从机+NEC 双路
后行式渲染、行内改名/改 GPIO/翻 V·G、行内图标交互(点灯换色/按压/开关)、展开详情
与配置编辑、拖动排序、载固件、Run 后 LED 翻转、PWM/RGB LED 自动绑定固件
LEDC 通道且 P2 阶段呼吸平滑渐变、P4 阶段 I2C 事务日志滚动且 EEPROM 十六进
制 dump 可从 View 预写、两行 ir-nec 上红外码出现、配置导入导出往返;浏览器
制 dump 可从 View 预写、两行 ir-nec 上红外码出现、逻辑分析仪:添加后在浮
动窗口的 GPIO 网格勾选通道(被占用引脚置灰)、Arm 后 P4 软 I2C 总线运行期
间波形渲染、窗口拖动/调大调小且下层页面照常交互、Saleae 风格绘图检查
(滚轮以光标为锚点缩放、拖拽平移、悬停测量显示脉宽/周期/频率/占空比、
两击放置 A/B 游标并读 Δt 与 1/Δt、双击复位适配)、行移除时窗口关闭、
配置导入导出往返;浏览器
自动化待网络环境允许时引入 Playwright,
`PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright`)。
+92
View File
@@ -8,6 +8,98 @@ CHANGELOG.zh-cn.md).
Rules: major = breaking protocol/architecture change; minor = new capability;
patch = fixes and small changes.
## peris 0.10.0
- **Logic analyzer** (`logic-analyzer.js` v1.0.0, logic in `la-lib.js`):
multi-channel GPIO waveform capture for firmware-driven signals — up to
8 arbitrary GPIOs, one trigger (channel + rising/falling/any edge, or
Immediate), a 1–1000 ms guest-time capture window and an optional auto
re-arm. Levels are 1/0 while driven and **Z** when the guest releases the
pad (oe drops); times are guest µs from the cycle counter (the guest clock
is not wall-accurate, M4). Sampling rides the M4 scheduler exactly like
the other bit-bang devices: an `arm` lease (1000) while waiting for the
trigger, a fine lease renewed per edge while recording (`resolution`
preset: fine 20 = the scheduler floor / normal 100 / coarse 300); two
flips inside one batch merge — the preset picks how fine that is. A
probe never drives: `sharedBus` lets it tap pads other bus devices own
(the I2C pair, a MAX7219 bus, other analyzers), and the 20 000-edge
buffer freezes with an `overflow` flag instead of garbling.
- **Floating analyzer window**: the waveform UI is big, so it lives in a
draggable, resizable, **non-modal** popup (`la-lib.makeFloatingWindow`,
fixed-position, no backdrop — the page underneath keeps every pointer
event; geometry clamped to the viewport and persisted to localStorage).
The row icon and the expanded details show a live mini-preview of the
same painter (`drawWaveform` — one paint path for all three sizes);
clicking either opens the window. Channels are picked from a GPIO chip
grid (occupied pins disabled via the new `host.pinInfo()`), trigger /
window / resolution / label live in the config editor and the window
toolbar.
- **Saleae-style plot interactions** (0.10.0, same release): **wheel zoom**
anchored at the cursor (zoom floor 1 ns, clamped to the capture extent),
**left/middle-drag pan**, **double-click / F / ⤢ zoom-to-fit**; a fresh
capture re-fits the view and drops the cursors (`capId` in the state).
**Hover measurement** shades the pulse under the cursor and reads out
level · pulse width · period · frequency · **PWM duty** (cycle =
rise→rise; computed from the visible channel, live in the measurement
bar); the lane gutter shows each channel's level at the view's right
edge. **A/B measurement cursors**: click twice to drop them, drag the
lines to move, read Δt and 1/Δt frequency; ✕ clears. **Edge jumping**:
click a lane label to select the channel, then ‹/› buttons or ←/→ keys
pan view-centre onto the previous/next edge (zoom preserved).
- **Device API v1 additions** (backwards compatible): `ctx.pinLevel(pin)`
— current driven level straight from `gpio_state()` (null when released;
the LA snapshots channel levels at capture start); `ViewHost.pinInfo()`
— valid + used pins for Views building their own multi-pin picker;
`View.destroy()` — teardown when the row is removed or the list is
rebuilt, so Views owning DOM outside their row (floating windows) clean
it up; the panel calls it on remove and on `renderAll`.
- Tests: unit cases for the readout math (parsePins, levelAt, axis ticks,
fmt, fit span, pixel transform), the full capture state machine through
a fake ctx (immediate/triggered starts, direction filtering, Z edges,
window end, stop/clear/auto — incl. the auto+Immediate warning — buffer
overflow, onEmuReady reset) and `sigbus.pinLevel` against a fake
emulator; smoke S13 arms the analyzer on the P4 soft-I2C bus (SCL 1950 /
SDA 352 alternating edges in a 40 ms window, frozen afterwards);
`examples/esp32c3-probe.json` now wires 12 devices.
## peris 0.9.0
- **MAX7219 Display** (`max7219.js` v1.0.0, logic in `max7219-lib.js`): the
8-digit LED driver behind a firmware **bit-banged DIN/CLK/LOAD bus**.
Hardware SPI wiring stays invisible to `gpio_state()` (M0), so the device
targets soft-SPI code — from delay-paced bit-bang down to back-to-back
`gpio_set_level` pulses. A level-driven shift engine samples DIN on the
CLK rising edge, assembles MSB-first 16-bit words and latches a frame on
the LOAD rising edge; misaligned frames (edges lost to sampling) are
dropped and counted in `dropped` instead of garbling the display. The
register model follows the datasheet: Code B decode (D3–D0, D7 = DP
independent), intensity PWM (16 steps, (I+1)/32 duty), scan limit,
shutdown / display test, power-on defaults (shutdown, scan 1 digit,
minimum intensity). Views: 8×8 matrix (digit = row, D7..D0 = columns) or
7-segment digits, auto-selected from the decode-mode register; daisy
chains legally share the three pads (`sharedBus`) and each part latches
the word that ended in its chain position (`chain` + `pos` config).
Lease sizing measured against the tightest real firmware (gun 0.1.0:
delay-free pulses, edges ~20 instructions apart, LOAD fall → first CLK
rise ~80): fine batches at the scheduler floor (**20**), arm at **60**.
- **Smoke S12** (machine-local, skips with a notice when absent): the gun
0.1.0 release (ESP32-S3, DIN=21/CLK=13/LOAD=14; later gun releases moved
to hardware SPI, invisible by M0) boots in a second emulator instance —
`display_init()`'s 14 register words decode with **zero** misalignment
drops and the control task's value pushes follow. New example
`examples/esp32s3-gun.json` wires the display for that firmware.
| device | version |
|---|---|
| led | 1.1.1 |
| button / touch-button / toggle-switch | 1.1.0 |
| pwm-led / rgb-led | 1.0.0 |
| ws2812 | 1.0.0 |
| bitbang-i2c-slave | 1.0.0 |
| ir-nec | 1.0.0 |
| max7219 | 1.0.0 |
| logic-analyzer | 1.0.0 |
## peris 0.8.1
- **Test firmware P1b** (digital-in sampling): every round now samples the
+73
View File
@@ -6,6 +6,79 @@
与英文版 CHANGELOG.md)。
规则:主版本=协议/架构破坏性变更;次版本=新能力;修订=修复与小改。
## peris 0.10.0
- **逻辑分析仪**(`logic-analyzer.js` v1.0.0,逻辑在 `la-lib.js`):面向固件
驱动信号的多通道 GPIO 波形捕获——最多 8 个任意 GPIO、一个触发(通道 +
上升/下降/任意沿,或"立即")、1–1000 ms 客机时间捕获窗口、可选自动重新
武装。电平在驱动时为 1/0,客机释放引脚(oe 撤销)时记录为 **Z**;时间为
周期计数换算的客机 µs(客机时钟不等于墙钟,M4)。采样完全复用 M4 调度
器,与其他位绑定设备一致:等待触发时持有 arm 租约(1000),录制期间每次
边沿续租细批(`resolution` 预设:fine 20=调度器下限 / normal 100 /
coarse 300);一个批次内的两次翻转会合并——预设决定合并到多细。探针
从不驱动:`sharedBus` 允许它搭接其他总线设备占用的引脚(I2C 双线、
MAX7219 总线、其他分析仪),2 万条边沿的缓冲区写满时带 `overflow` 标志
冻结而不是出乱码。
- **浮动分析窗口**:波形界面较大,放在可拖动、可调大小的**非模态**弹窗中
(`la-lib.makeFloatingWindow`,fixed 定位、无遮罩——下层页面的一切交互
照常;几何位置钳制在视口内并持久化到 localStorage)。行图标与展开详情
用同一绘制器(`drawWaveform`)显示实时迷你预览(三种尺寸一条绘制路
径);点击任意一个即可弹出窗口。通道从 GPIO 芯片网格中勾选(被占用引脚
通过新增的 `host.pinInfo()` 置灰),触发/窗口/分辨率/标签同时存在于配置
编辑器与窗口工具栏。
- **Saleae 风格绘图交互**(同属 0.10.0):**滚轮缩放**以光标处的时间为锚
点(下限 1 ns,钳制在捕获范围内),**左键/中键拖拽平移**,**双击 / F /
⤢ 复位适配**;新捕获自动重新适配视图并清除游标(状态中的 `capId`)。
**悬停测量**高亮光标所在脉冲,测量栏实时读出电平 · 脉宽 · 周期 · 频率
· **PWM 占空比**(周期=相邻两个上升沿;取自悬停通道);通道栏在视图右
缘显示各通道当前电平。**A/B 测量游标**:点两下放置、拖线移动、读出 Δt
与 1/Δt 频率,✕ 清除。**边沿跳转**:点击通道标签选中,再用 ‹/› 按钮
或 ←/→ 方向键把视图中心平移到上一/下一边沿(缩放保持不变)。
- **设备 API v1 增补**(向后兼容):`ctx.pinLevel(pin)`——直接读
`gpio_state()` 的当前驱动电平(释放时为 null;逻辑分析仪在捕获起点快照
各通道电平);`ViewHost.pinInfo()`——为自建多引脚选择器的 View 提供
有效/已占用引脚;`View.destroy()`——行被移除或列表重建时的清理钩子,
让持有行外 DOM(浮动窗口)的 View 自行收尾;面板在 remove 与
`renderAll` 时调用。
- 测试:读数数学(parsePins、levelAt、轴刻度、格式化、适配跨度、像素变
换)、经假 ctx 的完整捕获状态机(立即/触发启动、方向过滤、Z 沿、窗口结
束、stop/clear/auto——含 auto+立即的警告——缓冲溢出、onEmuReady 复
位)与针对假模拟器的 `sigbus.pinLevel` 单元用例;冒烟 S13 在 P4 软 I2C
总线上武装分析仪(40 ms 窗口内 SCL 1950 / SDA 352 条交替边沿,之后冻
结);`examples/esp32c3-probe.json` 增至 12 个设备。
## peris 0.9.0
- **MAX7219 显示**(`max7219.js` v1.0.0,逻辑在 `max7219-lib.js`):固件
**位绑定 DIN/CLK/LOAD 总线**上的 8 位 LED 驱动器。硬件 SPI 走线对
`gpio_state()` 不可见(M0),因此设备面向软 SPI 代码——从带延时的位绑定
到背靠背 `gpio_set_level` 脉冲都可解码。电平驱动的移位引擎在 CLK 上升沿
采样 DIN,按 MSB-first 拼装 16 位字,在 LOAD 上升沿锁存帧;失准帧(采样
丢边沿)被丢弃并计入 `dropped`,绝不让屏幕出乱码。寄存器模型遵循数据表:
Code B 译码(D3–D0,D7=小数点独立)、亮度 PWM(16 级,(I+1)/32 占空)、
扫描界、停机/显示测试、上电默认值(停机、扫 1 位、最低亮度)。View 分
8×8 矩阵(位=行,D7..D0=列)与七段数码管两种,按译码寄存器自动选择;
菊花链合法共享三根线(`sharedBus`),各片锁存落在自己链位上的字
(`chain`+`pos` 配置)。租约尺寸按最紧的真实固件实测标定(gun 0.1.0:无
延时脉冲,边沿相距约 20 条指令,LOAD 下降沿距首个 CLK 上升沿约 80):细
批取调度器下限(**20**),警戒批取 **60**。
- **冒烟 S12**(本机可选,固件缺失时打印提示跳过):gun 0.1.0 发布件
(ESP32-S3,DIN=21/CLK=13/LOAD=14;后续 gun 版本改用硬件 SPI,按 M0 不可
见)在第二个模拟器实例中启动——`display_init()` 的 14 个寄存器字全部解码
且**零**失准丢帧,随后跟随控制任务的数值推送。新增示例
`examples/esp32s3-gun.json` 为该固件接线显示。
| 设备 | 版本 |
|---|---|
| led | 1.1.1 |
| button / touch-button / toggle-switch | 1.1.0 |
| pwm-led / rgb-led | 1.0.0 |
| ws2812 | 1.0.0 |
| bitbang-i2c-slave | 1.0.0 |
| ir-nec | 1.0.0 |
| max7219 | 1.0.0 |
| logic-analyzer | 1.0.0 |
## peris 0.8.1
- **测试固件 P1b**(数字输入采样):每轮循环新增 2 秒采样阶段,用
+56 -2
View File
@@ -51,10 +51,13 @@ export class Device { // runs in the worker, talks to the emulator
export class View { // runs on the page, renders the device view
constructor(el, sendCmd, host) {} // el = details body container;
// sendCmd(cmd, params);
// host.updateConfig(patch) requests a config change
// host.updateConfig(patch) requests a config change;
// host.pinInfo?() → { valid, used } pins for multi-pin pickers
rowIcon(el) {} // optional: compact interactive row icon (~24px), see below
render(state) {} // state comes from Device.getState(); drives icon and body together
setConfig(cfg) {} // optional: called after config edits (e.g. LED colour)
destroy() {} // optional: row removed / list rebuilt — Views owning DOM
// outside their row (floating windows) must clean it up
}
```
@@ -98,6 +101,7 @@ The peripherals pane is its own column in the three-pane layout
| `ctx.setPinInput(pin, high)` | drive a pin's input level (firmware reads it; raises GPIO interrupts) |
| `ctx.subscribe(pins)` / `ctx.unsubscribe()` | subscribe to pin-output changes (onPinState) |
| `ctx.readMem(addr, len)` / `ctx.readU32(addr)` | bus/MMIO reads (LEDC duty, RMT RAM, …) |
| `ctx.pinLevel(pin)` | current driven level straight from `gpio_state()` — true/false while the guest drives the pad (oe), null when released. One wasm read: snapshot channels sparingly (the logic analyzer samples at capture start), never per batch |
| `ctx.cycles()` | emulator cycle counter |
| `ctx.cyclesPerUs` | emulator clock in cycles per µs (edge timing → time units) |
| `ctx.armSampling(batch)` | M4 scheduler: standing run_batch size while this device watches an idle bus |
@@ -173,6 +177,50 @@ level- or ratio-based, never absolute-µs. The M4 plugins follow both rules:
leaves `APB_MEM_WADDR` at 0 so mid-TX stitching is impossible). Ratio-
based decode; `source: auto` prefers RMT routing. Envelope + decode in
`peris/devices/nec-lib.js`.
- `peris/devices/max7219.js` — MAX7219 display driver on a bit-banged
DIN/CLK/LOAD bus (write-only: no input path needed). The shift engine
samples DIN on CLK rising edges, latches 16-bit words on the LOAD rising
edge, and drops misaligned frames (counted in `dropped`) instead of
garbling the display. Register model + Code B font + painters in
`peris/devices/max7219-lib.js`; daisy chains share the pads
(`sharedBus`, `chain`/`pos` pick each part's word). Lease sizing is at
the scheduler floor (fine 20 / arm 60): measured against delay-free
back-to-back `gpio_set_level` pulses (edges ~20 instructions apart), the
tightest real firmware found — delay-paced bit-bang is covered with
much wider margins.
## Instrument devices (0.10.0)
- `peris/devices/logic-analyzer.js` — multi-channel GPIO waveform capture
(up to 8 pads, one trigger: channel + rising/falling/any edge or
Immediate, guest-time window, auto re-arm). Readout math, the waveform
painter and the floating window live in `peris/devices/la-lib.js`; the
painter is one implementation shared by the popup, the details
mini-preview and the row icon. Sampling rides the M4 scheduler like the
bit-bang devices (arm lease while waiting for the trigger, fine lease
renewed per edge while recording — the `resolution` config picks the
fine batch: 20/100/300 instructions). Levels are 1/0 driven and -1 (Z)
when the guest releases the pad; the analyzer never drives anything, so
it declares `sharedBus` and legally taps pads other bus devices own.
Capture data is pushed in full only when the buffer freezes (`done`);
run pushes carry progress + live per-channel edge counts.
- **Floating windows** (`la-lib.makeFloatingWindow`): big UIs open a
draggable, resizable, **non-modal** popup — `position: fixed`, no
backdrop, so the page underneath keeps every pointer event. Geometry is
clamped to the viewport and persisted to localStorage. Because such a
window lives on `document.body` (outside the device row), its View must
implement `destroy()` to remove it when the row goes away — the panel
calls `destroy()` on row removal and on list rebuilds. Multi-pin pickers
inside a View grey out occupied GPIOs through `host.pinInfo()` (the same
candidate-aware set the generic editor uses).
- **Saleae-style plot model** (also in `la-lib`): a zoomable view
`{t0, spanUs}` with `viewXform`/`clampView`/`zoomAt` (wheel zoom anchored
at the cursor, drag pan, fit on double-click), hover measurement
(`pulseAround` for the pulse under the cursor, `pwmOf` for
period/frequency/duty from consecutive rising edges), A/B cursors with
Δt + 1/Δt and edge jumping (`levelAt`, `fmtHz`). The device bumps `capId`
on every capture start so Views reset zoom/cursors when fresh data
arrives.
## Message protocol (page ↔ worker)
@@ -247,4 +295,10 @@ Added on top of the stock worker protocol (see `peris/protocol.js`):
- `peris/devices/ws2812-lib.js` — shared helper module (not a device)
- `peris/devices/bitbang-i2c-slave.js` — bit-bang I2C slave (i2c-lib.js engine)
- `peris/devices/ir-nec.js` — NEC receiver, gpio + RMT capture (nec-lib.js)
- `peris/devices/i2c-lib.js` / `peris/devices/nec-lib.js` — shared helper modules (not devices)
- `peris/devices/max7219.js` — MAX7219 display on a bit-banged 3-wire bus
(max7219-lib.js engine, Code B font, matrix/7-seg painters)
- `peris/devices/logic-analyzer.js` — multi-GPIO waveform capture with a
floating analyzer window (la-lib.js readout math + painter + window)
- `peris/devices/i2c-lib.js` / `peris/devices/nec-lib.js` /
`peris/devices/max7219-lib.js` / `peris/devices/la-lib.js` — shared
helper modules (not devices)
+44 -2
View File
@@ -45,10 +45,13 @@ export class Device { // 运行在 worker,与模拟器交互
export class View { // 运行在页面,渲染设备视图
constructor(el, sendCmd, host) {} // el=详情区主体容器; sendCmd(cmd, params);
// host.updateConfig(patch) 请求改本设备配置
// host.updateConfig(patch) 请求改本设备配置;
// host.pinInfo?() → { valid, used } 供多引脚选择器
rowIcon(el) {} // 可选:行内交互图标(el≈24px 容器),见下文
render(state) {} // state 来自 Device.getState();行图标与详情主体一起驱动
setConfig(cfg) {} // 可选:配置编辑后回调(如换 LED 颜色)
destroy() {} // 可选:行被移除/列表重建时回调——持有行外 DOM
// (浮动窗口)的 View 必须在此清理
}
```
@@ -84,6 +87,7 @@ export class View { // 运行在页面,渲染设备视图
| `ctx.setPinInput(pin, high)` | 驱动引脚输入电平(固件可读到、可触发 GPIO 中断) |
| `ctx.subscribe(pins)` / `ctx.unsubscribe()` | 订阅引脚输出变化(onPinState) |
| `ctx.readMem(addr, len)` / `ctx.readU32(addr)` | 总线/MMIO 读取(LEDC duty、RMT RAM 等) |
| `ctx.pinLevel(pin)` | 直接读 `gpio_state()` 的当前驱动电平——客机驱动引脚(oe)时为 true/false,释放时为 null。一次 wasm 读:快照要节制(逻辑分析仪仅在捕获起点采样),不要每批都读 |
| `ctx.cycles()` | 模拟器周期计数 |
| `ctx.cyclesPerUs` | 模拟器时钟(周期/µs),边沿计时换算真实时间用 |
| `ctx.armSampling(batch)` | M4 调度器:设备监视空闲总线期间的常设 run_batch 尺寸 |
@@ -121,6 +125,31 @@ M3 的 WS2812 插件遵循同一模式,共享库为 **`peris/devices/ws2812-lib.
最后 46 位,长灯带只能解码出尾部 LED(见 FEASIBILITY.zh-cn.md
"关键技术细节" 3–4)。
## 仪器类设备(0.10.0)
- `peris/devices/logic-analyzer.js` —— 多通道 GPIO 波形捕获(最多 8 个引
脚,一个触发:通道 + 上升/下降/任意沿或"立即",客机时间窗口,自动重新
武装)。读数数学、波形绘制器与浮动窗口都在 `peris/devices/la-lib.js`;
绘制器只有一份实现,弹窗、详情迷你预览与行图标共用。采样复用 M4 调度
器,与位绑定设备一致(等触发时持 arm 租约,录制期间每次边沿续租细批
——`resolution` 配置决定细批:20/100/300 条指令)。电平为驱动 1/0、
客机释放引脚时 -1(Z);分析仪从不驱动任何东西,因此声明 `sharedBus`,
合法搭接其他总线设备占用的引脚。捕获数据仅在缓冲冻结(`done`)时全量
推送;运行中的推送只带进度 + 各通道实时边沿计数。
- **浮动窗口**(`la-lib.makeFloatingWindow`):大界面以可拖动、可调大小、
**非模态**弹窗打开——`position: fixed`、无遮罩,下层页面的一切指针事件
照常。几何位置钳制在视口内并持久化到 localStorage。此类窗口挂在
`document.body`(不在设备行内),其 View 必须实现 `destroy()` 在行销毁
时移除——面板在行移除与列表重建时都会调用 `destroy()`。View 内的多引
脚选择器通过 `host.pinInfo()` 将被占用 GPIO 置灰(与通用编辑器同一份、
按候选设备感知的引脚集合)。
- **Saleae 风格绘图模型**(同在 `la-lib`):可缩放视图 `{t0, spanUs}` 配
`viewXform`/`clampView`/`zoomAt`(滚轮以光标为锚点缩放、拖拽平移、双击
复位适配),悬停测量(`pulseAround` 找光标所在脉冲,`pwmOf` 按相邻上升
沿算周期/频率/占空比),A/B 游标读 Δt 与 1/Δt,边沿跳转(`levelAt`、
`fmtHz`)。设备在每次捕获开始时递增 `capId`,View 据此在新数据到来时
重置缩放与游标。
## 消息协议(页面 ↔ worker)
在原版 worker 协议之外增加(见 `peris/protocol.js`):
@@ -187,6 +216,13 @@ CPU 驱动(位绑定)的波形**可以**出现在 `onPinState` 里——但
发送后的通道 RAM 里;模拟器不实现 `APB_MEM_WADDR`(恒 0),发送中途
拼接不可行)。按比值解码;`source: auto` 优先 RMT 路由。包络组装与
解码在 `peris/devices/nec-lib.js`。
- `peris/devices/max7219.js` —— 位绑定 DIN/CLK/LOAD 总线上的 MAX7219
显示驱动(只写器件,无需输入路径)。移位引擎在 CLK 上升沿采样 DIN、
LOAD 上升沿锁存 16 位字;失准帧被丢弃并计入 `dropped`,绝不出花屏。
寄存器模型、Code B 字体与画图在 `peris/devices/max7219-lib.js`;菊花链
共享焊盘(`sharedBus`,`chain`/`pos` 取各片的字)。租约取调度器下限
(细批 20/警戒 60):按无延时背靠背 `gpio_set_level` 脉冲实测标定(边沿
相距约 20 条指令)——这是已知最紧的真实固件,带延时的位绑定余量更大。
## 示例
@@ -200,6 +236,12 @@ CPU 驱动(位绑定)的波形**可以**出现在 `onPinState` 里——但
(onBatch + ctx.mmio + ws2812-lib 帧解码,灯带/环形/矩阵 View)
- `peris/devices/bitbang-i2c-slave.js` — 位绑定 I2C 从机(i2c-lib.js 引擎)
- `peris/devices/ir-nec.js` — NEC 接收器,gpio + RMT 双路捕获(nec-lib.js)
- `peris/devices/max7219.js` — 位绑定三线总线上的 MAX7219 显示
(max7219-lib.js 引擎、Code B 字体、矩阵/七段画图)
- `peris/devices/logic-analyzer.js` — 带浮动分析窗口的多 GPIO 波形捕获
(la-lib.js 读数数学 + 绘制器 + 浮窗)
- `peris/devices/ledc-lib.js` — 共享辅助模块(不是设备)
- `peris/devices/ws2812-lib.js` — 共享辅助模块(不是设备)
- `peris/devices/i2c-lib.js` / `peris/devices/nec-lib.js` — 共享辅助模块(不是设备)
- `peris/devices/i2c-lib.js` / `peris/devices/nec-lib.js` /
`peris/devices/max7219-lib.js` / `peris/devices/la-lib.js` — 共享辅助
模块(不是设备)
+42
View File
@@ -2,6 +2,48 @@
# Roadmap
## Logic analyzer — shipped in peris 0.10.0
- ✅ `logic-analyzer.js`: multi-channel GPIO waveform capture (up to 8
pads, one trigger: channel + edge or Immediate, guest-time window,
auto re-arm). Driven levels 1/0, released pads Z, times in guest µs.
Rides the M4 scheduler (arm 1000 watch, fine lease per `resolution`
preset: 20/100/300); 20 000-edge buffer freezes with an `overflow`
flag. Shared logic in `peris/devices/la-lib.js`; `sharedBus` makes it a
pure tap on other bus devices' pads.
- ✅ Floating analyzer window: draggable, resizable, non-modal popup
(fixed position, no backdrop — the page underneath keeps working);
geometry persisted. One `drawWaveform` painter feeds the popup, the
details mini-preview and the row icon; channels picked from a GPIO grid
(`host.pinInfo()` disables occupied pins). Framework: `ctx.pinLevel`,
`ViewHost.pinInfo`, `View.destroy` (row removal/rebuild teardown).
- ✅ Saleae-style plot interactions: wheel zoom anchored at the cursor,
drag pan, double-click/F fit; hover measurement shades the pulse and
reads level · width · period · frequency · PWM duty; A/B cursors with
Δt + 1/Δt; lane selection + ‹/›/←/→ edge jumping; lane gutter shows the
right-edge level per channel.
- Tests: readout math + capture state machine units, `sigbus.pinLevel`,
smoke S13 (P4 soft-I2C bus capture). Ideas not in v1: pre-trigger
buffer, incremental edge streaming during a run, protocol decoders on
top of a capture (I2C/NEC annotations) — the frozen buffer already
carries everything a decoder needs.
## MAX7219 display — shipped in peris 0.9.0
- ✅ `max7219.js`: the 8-digit LED driver on a firmware bit-banged
DIN/CLK/LOAD bus (hardware SPI stays invisible — M0). Level-driven shift
engine (MSB-first, words latched on the LOAD rising edge, misaligned
frames dropped and counted, never garbled) + datasheet register model
(Code B decode with independent DP, intensity PWM, scan limit,
shutdown / display test, power-on defaults). 8×8 matrix and 7-segment
Views auto-selected from the decode-mode register; daisy chains share
the three pads. Shared logic in `peris/devices/max7219-lib.js`.
- ✅ Real-firmware integration: smoke S12 boots the gun 0.1.0 release
(ESP32-S3, delay-free bit-bang — edges ~20 instructions apart, the
tightest pulses the M4 scheduler resolves: fine lease at the floor 20,
arm 60) and decodes its `display_init()` with zero drops;
`examples/esp32s3-gun.json` wires the display for the browser.
## M4 · Bit-bang protocol devices + IR — shipped in peris 0.8.0
- ✅ `bitbang-i2c-slave.js`: 24C02 EEPROM and BME280 models behind two
+35
View File
@@ -2,6 +2,41 @@
# 路线图
## 逻辑分析仪——已于 peris 0.10.0 交付
- ✅ `logic-analyzer.js`:多通道 GPIO 波形捕获(最多 8 个引脚,一个触
发:通道 + 边沿或"立即",客机时间窗口,自动重新武装)。驱动电平
1/0,释放引脚记 Z,时间为客机 µs。复用 M4 调度器(警戒 1000,细批随
`resolution` 预设:20/100/300);2 万条边沿的缓冲区写满带 `overflow`
冻结。共享逻辑在 `peris/devices/la-lib.js`;`sharedBus` 使其成为其他
总线设备引脚上的纯搭接。
- ✅ 浮动分析窗口:可拖动、可调大小、非模态弹窗(fixed 定位、无遮罩——
下层页面照常交互);几何位置持久化。一个 `drawWaveform` 绘制器同时供
弹窗、详情迷你预览与行图标使用;通道从 GPIO 网格勾选
(`host.pinInfo()` 将被占用引脚置灰)。框架:`ctx.pinLevel`、
`ViewHost.pinInfo`、`View.destroy`(行移除/重建时的清理钩子)。
- ✅ Saleae 风格绘图交互:滚轮以光标为锚点缩放、拖拽平移、双击/F 复位
适配;悬停测量高亮所在脉冲并读出电平 · 脉宽 · 周期 · 频率 · PWM 占空
比;A/B 游标读 Δt 与 1/Δt;通道选中 + ‹/›/←/→ 边沿跳转;通道栏在视图
右缘显示各通道电平。
- 测试:读数数学 + 捕获状态机单元、`sigbus.pinLevel`、冒烟 S13(P4 软
I2C 总线捕获)。v1 未含的想法:预触发缓冲、运行中增量边沿流、在捕获
之上叠加协议解码(I2C/NEC 标注)——冻结缓冲已携带解码器所需的全部数
据。
## MAX7219 显示——已于 peris 0.9.0 交付
- ✅ `max7219.js`:固件位绑定 DIN/CLK/LOAD 总线上的 8 位 LED 驱动器(硬件
SPI 不可见——M0)。电平驱动移位引擎(MSB-first,字在 LOAD 上升沿锁存,
失准帧丢弃并计数,绝不出花屏)+ 遵循数据表的寄存器模型(小数点独立的
Code B 译码、亮度 PWM、扫描界、停机/显示测试、上电默认值)。8×8 矩阵与
七段两种 View,按译码寄存器自动选择;菊花链共享三根线。共享逻辑在
`peris/devices/max7219-lib.js`。
- ✅ 真实固件集成:冒烟 S12 启动 gun 0.1.0 发布件(ESP32-S3,无延时位绑
定——边沿相距约 20 条指令,是 M4 调度器能分辨的最紧脉冲:细批取下限
20,警戒批 60),零丢帧解码其 `display_init()`;`examples/esp32s3-gun.json`
为浏览器接线显示。
## M4 · 位绑定协议设备 + IR——已于 peris 0.8.0 交付
- ✅ `bitbang-i2c-slave.js`:固件位绑定 GPIO(SCL/SDA)上的 24C02 EEPROM
+2 -1
View File
@@ -12,6 +12,7 @@
{ "id": "ee-1", "type": "bitbang-i2c-slave", "name": "24C02 EEPROM (I2C 0x50)", "model": "eeprom24c02", "scl": 1, "sda": 2, "address": 80 },
{ "id": "bme-1", "type": "bitbang-i2c-slave", "name": "BME280 sensor (I2C 0x76)", "model": "bme280", "scl": 1, "sda": 2, "address": 118 },
{ "id": "ir-bb", "type": "ir-nec", "name": "IR NEC, bit-bang capture (GPIO5)", "pin": 5, "source": "gpio" },
{ "id": "ir-rmt", "type": "ir-nec", "name": "IR NEC, RMT capture (ch1)", "pin": 10, "source": "rmt", "channel": 1 }
{ "id": "ir-rmt", "type": "ir-nec", "name": "IR NEC, RMT capture (ch1)", "pin": 10, "source": "rmt", "channel": 1 },
{ "id": "la-1", "type": "logic-analyzer", "name": "I2C bus LA (SCL+SDA)", "pins": [1, 2], "durationMs": 40, "trigCh": -1 }
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"perisVersion": 1,
"chip": "esp32s3",
"devices": [
{ "id": "mx-1", "type": "max7219", "name": "Gun 7-seg display (DIN21/CLK13/LOAD14)", "din": 21, "clk": 13, "load": 14, "mode": "auto" }
]
}
+20 -20
View File
@@ -1,20 +1,20 @@
{
"name": "esp-emu-peris",
"version": "0.8.1",
"private": true,
"type": "module",
"description": "Simulated peripherals for the esp-emulator browser (wasm) build",
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"serve": "python serve.py",
"sync": "python sync_www.py",
"test": "node --test tests/unit/core.test.mjs tests/unit/devices.test.mjs tests/unit/protocol.test.mjs && node tests/m1_smoke.mjs",
"test:unit": "node --test tests/unit/core.test.mjs tests/unit/devices.test.mjs tests/unit/protocol.test.mjs",
"test:smoke": "node tests/m1_smoke.mjs",
"test:m0": "node tests/m0_probe.mjs"
},
"devDependencies": {
"typescript": "5.9"
}
}
{
"name": "esp-emu-peris",
"version": "0.10.0",
"private": true,
"type": "module",
"description": "Simulated peripherals for the esp-emulator browser (wasm) build",
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"serve": "python serve.py",
"sync": "python sync_www.py",
"test": "node --test tests/unit/core.test.mjs tests/unit/devices.test.mjs tests/unit/protocol.test.mjs && node tests/m1_smoke.mjs",
"test:unit": "node --test tests/unit/core.test.mjs tests/unit/devices.test.mjs tests/unit/protocol.test.mjs",
"test:smoke": "node tests/m1_smoke.mjs",
"test:m0": "node tests/m0_probe.mjs"
},
"devDependencies": {
"typescript": "5.9"
}
}
+621
View File
@@ -0,0 +1,621 @@
/*
* la-lib — shared helpers for the logic-analyzer device (0.10.0): config
* parsing, capture readout math (level lookup, axis ticks, time formatting)
* and the page-side waveform painter + floating window. One semantic, one
* implementation (AGENT.md §3.8): the plugin, the unit tests and both View
* surfaces (details mini-preview and the popup) all import this module; it
* is not listed in manifest.json and must stay importable from Node — the
* DOM-touching helpers (window, painter) only run when called from a View.
*
* Capture model (worker side, `logic-analyzer.js`):
* status idle → armed (waiting for the trigger edge; arm lease holds a
* standing watch batch) → run (recording edges; fine lease renewed
* while edges flow) → done (buffer frozen, readable). 'auto'
* re-arms right after a capture finishes.
* edges per channel a flat array [tUs, level, tUs, level, …] with times
* relative to the capture start (the trigger edge, or the arm
* moment for an immediate trigger) and levels 1/0 driven, -1
* released (oe dropped — drawn as a Z zone, we cannot know the
* pad's pulled level).
*
* Emulator constraints this builds on (M0/M4, docs/FEASIBILITY.md):
* - gpio_state() is sampled once per run_batch, so two flips inside one
* batch merge into a single visible edge — resolution is leased through
* the M4 scheduler (arm/fine batches) like the other bit-bang devices.
* - the guest clock is not wall-accurate (esp_rom_delay_us runs ~50% of
* nominal), so all displayed times are GUEST microseconds derived from
* the cycle counter, never wall time.
*/
/** Most channels one analyzer may claim (UI + colour palette sized for 8). */
export const MAX_CHANNELS = 8;
/** Total edge cap across all channels — protects the state push and the
* painter; a capture that hits it stops with status 'done' + overflow. */
export const MAX_EDGES = 20000;
/** Standing watch batch (instructions) while armed — any single edge is
* notified regardless of batch size, so the trigger resolves fine; 1000
* matches the other M4 bus watchers. */
export const ARM_BATCH = 1000;
/** Fine batch sizes per resolution preset (instructions per batch). Edges
* closer together than the batch merge — 'fine' sits at the scheduler floor
* and resolves back-to-back gpio_set_level pulses (~20 instructions apart,
* the tightest real firmware measured, gun 0.1.0). */
export const FINE_BATCHES = { fine: 20, normal: 100, coarse: 300 };
/** Fine lease budget (cycles), renewed on every edge while recording:
* bridges the slowest plausible mid-transfer pause (delay-paced bit-bang). */
export const FINE_HOLD_CYCLES = 2000000;
/** Channel trace colours (index order, dark-theme tuned). */
export const LA_COLORS = [
'#4fc3f7', '#ffb74d', '#81c784', '#e57373',
'#ba68c8', '#fff176', '#4db6ac', '#f06292',
];
/**
* Normalise a config `pins` value into a clean channel list: keeps integers
* ≥ 0, dedupes (first occurrence wins), caps at MAX_CHANNELS. Always returns
* a fresh array — the config merge is shallow, so mutating a default array
* would corrupt every future device (AGENT.md §4 shallow-copy class).
*/
export function parsePins(v) {
const out = [];
if (!Array.isArray(v)) return out;
for (const p of v) {
const n = Number(p);
if (Number.isInteger(n) && n >= 0 && !out.includes(n)) out.push(n);
if (out.length >= MAX_CHANNELS) break;
}
return out;
}
/**
* Level of one channel at time tUs (µs). flat is the channel's edge array,
* initial the level sampled at capture start (null = never seen driven).
* Returns 1/0/-1 (Z) or null (unknown before the first edge).
*/
export function levelAt(flat, initial, tUs) {
let level = initial == null ? null : initial;
for (let i = 0; i + 1 < flat.length; i += 2) {
if (flat[i] > tUs) break;
level = flat[i + 1];
}
return level;
}
/**
* Nice axis tick step (µs) for a time span: a 1/2/5 × 10^k value that
* yields roughly `target` ticks across the span.
*/
export function niceTickStep(spanUs, target = 6) {
if (!(spanUs > 0)) return 1;
const raw = spanUs / Math.max(1, target);
const pow = Math.pow(10, Math.floor(Math.log10(raw)));
const m = raw / pow;
const mult = m < 1.5 ? 1 : m < 3.5 ? 2 : m < 7.5 ? 5 : 10;
return mult * pow;
}
/** Format a guest duration in µs for labels ("840 µs", "12.5 ms", "1.20 s"). */
export function fmtUs(us) {
if (!Number.isFinite(us)) return '—';
if (us < 1000) return `${Math.round(us)} µs`;
if (us < 1000000) return `${(us / 1000).toFixed(us < 10000 ? 2 : 1)} ms`;
return `${(us / 1000000).toFixed(2)} s`;
}
/** µs↔pixel mapping for the waveform plot area (pure; painter + hover share it). */
export function waveformXform(w, gutterW, tMaxUs) {
return viewXform(w, gutterW, { t0: 0, spanUs: tMaxUs });
}
/** µs↔pixel mapping for a zoomable view {t0, spanUs} — the Saleae-style
* zoom/pan model: every pixel conversion funnels through here, so the
* painter, the wheel zoom and the hover measurement always agree. */
export function viewXform(w, gutterW, view) {
const plotW = Math.max(1, w - gutterW);
const span = Math.max(1e-9, view.spanUs);
const t0 = view.t0;
return {
usToX: (us) => gutterW + ((Math.min(Math.max(us, t0), t0 + span) - t0) / span) * plotW,
xToUs: (x) => t0 + ((x - gutterW) / plotW) * span,
plotW,
};
}
/* ---- zoom / pan view model (Saleae-style) ---------------------------------- */
/** Smallest visible span (1 ns) — the zoom floor. */
export const MIN_SPAN_US = 0.001;
/** Scrollable/clampable time range for a captured state: the data extent
* (last edge / captured time / window) plus 5% breathing room on each side.
* Floored at 1 ms so an empty/never-captured state still yields a sane,
* zoomable view (a 0 extent would collapse every span to 0). */
export function viewBounds(state) {
let extent = Number(state?.windowUs) || 0;
for (const flat of state?.edges ?? []) {
if (flat.length >= 2) extent = Math.max(extent, flat[flat.length - 2]);
}
extent = Math.max(extent, Number(state?.capturedUs) || 0, 1000);
return { tMin: -extent * 0.05, tMax: extent * 1.05, maxSpan: extent * 1.2 };
}
/** Clamp a view into the state's bounds (span floor … maxSpan, t0 inside). */
export function clampView(view, state) {
const b = viewBounds(state);
const spanUs = Math.min(Math.max(view.spanUs || MIN_SPAN_US, MIN_SPAN_US), b.maxSpan);
const t0 = Math.min(Math.max(view.t0 || 0, b.tMin), b.tMax - spanUs);
return { t0, spanUs };
}
/**
* Zoom keeping `anchorUs` (the time under the cursor) pinned to the same
* pixel — factor < 1 zooms in, > 1 zooms out. Pure; the wheel handler and
* the keyboard zoom both call this.
*/
export function zoomAt(view, anchorUs, factor, state) {
const cur = clampView(view, state);
const f = Math.min(Math.max(factor, 0.02), 50);
const spanUs = Math.min(Math.max(cur.spanUs * f, MIN_SPAN_US), viewBounds(state).maxSpan);
const t0 = anchorUs - (anchorUs - cur.t0) * (spanUs / cur.spanUs);
return clampView({ t0, spanUs }, state);
}
/* ---- hover measurement (Saleae-style) -------------------------------------- */
/**
* The pulse (constant-level run) containing `tUs`: its level, the edge that
* started it, the edge that ended it (null = open-ended), and the width
* when both bounds exist. Before the first edge the level comes from
* `initial` (null = never sampled → unknown).
*/
export function pulseAround(edges, initial, tUs) {
let tStart = null;
let level = initial == null ? null : initial;
let tEnd = null;
for (let k = 0; k + 1 < edges.length; k += 2) {
if (edges[k] <= tUs) {
tStart = edges[k];
level = edges[k + 1];
} else {
tEnd = edges[k];
break;
}
}
return {
level,
tStart,
tEnd,
widthUs: tStart != null && tEnd != null ? tEnd - tStart : null,
};
}
/**
* PWM cycle containing (or nearest to) `tUs`: the interval between two
* consecutive rising edges. Returns { periodUs, highUs, duty, freqHz } or
* null when the channel shows no full cycle (fewer than two rising edges).
* A high run without a following falling edge counts as high for the whole
* period (100% duty). Z runs are ignored (not driven levels).
*/
export function pwmOf(edges, tUs) {
const ris = [];
for (let k = 0; k + 1 < edges.length; k += 2) {
if (edges[k + 1] === 1) ris.push(edges[k]);
}
if (ris.length < 2) return null;
let i = 0;
while (i + 1 < ris.length && ris[i + 1] <= tUs) i++; // clamp to the last cycle
const periodUs = ris[i + 1] - ris[i];
if (!(periodUs > 0)) return null;
let highUs = periodUs;
for (let k = 0; k + 1 < edges.length; k += 2) {
if (edges[k] > ris[i] && edges[k] < ris[i + 1] && edges[k + 1] === 0) {
highUs = edges[k] - ris[i];
break;
}
}
return { periodUs, highUs, duty: highUs / periodUs, freqHz: 1e6 / periodUs };
}
/** One-character level label for lane gutters and the measurement bar. */
export function levelChar(level) {
if (level === 1 || level === true) return '1';
if (level === 0 || level === false) return '0';
if (level === -1) return 'Z';
return '?';
}
/** Format a frequency (Hz) for readouts ("156.3 Hz", "20.00 kHz", "1.000 MHz"). */
export function fmtHz(hz) {
if (!Number.isFinite(hz) || hz <= 0) return '—';
if (hz < 1000) return `${hz.toFixed(1)} Hz`;
if (hz < 1e6) return `${(hz / 1000).toFixed(2)} kHz`;
return `${(hz / 1e6).toFixed(3)} MHz`;
}
/* ---------------------------------------------------------------------------
* Page-only helpers below — never executed at import time (Node-safe).
* ------------------------------------------------------------------------- */
let topZ = 3000; // stacking counter shared by every floating window
/**
* A draggable, resizable, NON-MODAL floating window appended to
* document.body (position: fixed; no backdrop → the page underneath keeps
* receiving pointer events). Header drags, bottom-right corner resizes,
* geometry is clamped to the viewport and persisted to localStorage under
* `storeKey`. Returns { el, body, destroy(), onResize(setup by caller) } —
* the caller passes onResize(w, h) to re-fit its content (e.g. a canvas).
*/
export function makeFloatingWindow({ title, cls = '', storeKey = null,
x = 80, y = 80, w = 560, h = 380, minW = 320, minH = 220, onResize }) {
let saved = null;
if (storeKey) {
try { saved = JSON.parse(localStorage.getItem(storeKey) || 'null'); } catch { /* ignore */ }
}
const vp = () => ({ vw: window.innerWidth, vh: window.innerHeight });
const clamp = (g) => {
const { vw, vh } = vp();
g.w = Math.min(Math.max(Math.round(g.w), minW), Math.max(minW, vw - 8));
g.h = Math.min(Math.max(Math.round(g.h), minH), Math.max(minH, vh - 8));
g.x = Math.min(Math.max(0, Math.round(g.x)), Math.max(0, vw - g.w));
g.y = Math.min(Math.max(0, Math.round(g.y)), Math.max(0, vh - g.h));
return g;
};
const geo = clamp(saved ? { ...saved } : (() => {
// first open: cascade away from the panel, towards the viewport centre
const { vw, vh } = vp();
return { x: Math.max(8, (vw - w) / 2 - 60), y: Math.max(8, (vh - h) / 2 - 40), w, h };
})());
const el = document.createElement('div');
el.className = `float-win ${cls}`;
el.style.zIndex = String(++topZ);
const head = document.createElement('div');
head.className = 'float-win-head';
const label = document.createElement('span');
label.className = 'float-win-title';
label.textContent = title;
head.appendChild(label);
const close = document.createElement('button');
close.type = 'button';
close.className = 'float-win-close';
close.title = 'Close window';
close.textContent = '×';
head.appendChild(close);
const body = document.createElement('div');
body.className = 'float-win-body';
const grip = document.createElement('span');
grip.className = 'float-win-resize';
grip.title = 'Drag to resize';
el.appendChild(head);
el.appendChild(body);
el.appendChild(grip);
document.body.appendChild(el);
const apply = () => {
el.style.left = `${geo.x}px`;
el.style.top = `${geo.y}px`;
el.style.width = `${geo.w}px`;
el.style.height = `${geo.h}px`;
};
apply();
const persist = () => {
if (!storeKey) return;
try { localStorage.setItem(storeKey, JSON.stringify(geo)); } catch { /* ignore */ }
};
const emit = () => {
apply();
onResize?.(geo.w, geo.h);
};
// focus stacking: any pointerdown raises this window above its siblings
const raise = () => { el.style.zIndex = String(++topZ); };
el.addEventListener('pointerdown', raise);
// --- drag by header (buttons inside the header excluded). Move/up live
// on window (with optional pointer capture) so the drag survives the
// cursor leaving the header — and works without an active pointer id
// (synthetic events cannot setPointerCapture). ---
head.addEventListener('pointerdown', (e) => {
if (e.target.closest('button, input, select')) return;
e.preventDefault();
const sx = e.clientX - geo.x, sy = e.clientY - geo.y;
try { head.setPointerCapture(e.pointerId); } catch { /* synthetic drag */ }
const move = (ev) => {
geo.x = ev.clientX - sx;
geo.y = ev.clientY - sy;
clamp(geo);
apply();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
persist();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
// --- resize by the bottom-right corner ---
grip.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
const sx = e.clientX, sy = e.clientY, w0 = geo.w, h0 = geo.h;
try { grip.setPointerCapture(e.pointerId); } catch { /* synthetic drag */ }
const move = (ev) => {
geo.w = w0 + (ev.clientX - sx);
geo.h = h0 + (ev.clientY - sy);
clamp(geo);
emit();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
persist();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
let closed = false;
const destroy = () => {
if (closed) return;
closed = true;
el.remove();
};
return { el, body, head, close, destroy, raise, get geo() { return { ...geo }; } };
}
/**
* (Re)size an existing canvas to a CSS box, DPI-aware — the counterpart of
* ws2812-lib's makeCanvas for windows whose size changes at runtime.
* Returns the 2D context scaled back to CSS pixels.
*/
export function resizeCanvas(cv, w, h) {
const dpr = globalThis.devicePixelRatio || 1;
cv.width = Math.max(1, Math.round(w * dpr));
cv.height = Math.max(1, Math.round(h * dpr));
cv.style.width = `${w}px`;
cv.style.height = `${h}px`;
const g = cv.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
return g;
}
/**
* The ONE waveform painter — the popup canvas, the details mini-preview and
* the row icon are three sizes of the same picture (AGENT.md §3.8). state is
* the device's getState() payload; opts tune the chrome:
* gutter draw the GPIO labels column (width px, 0 = off)
* axis draw the time axis + grid ticks
* view {t0, spanUs} visible window (default: fit the whole capture)
* hover { x } in CSS pixels → crosshair + time readout
* measure { lane, from, to } → shaded pulse highlight (hover measurement)
* cursors { a, b } (µs|null) → Saleae-style A/B rulers with flags
* selectedLane index → subtle lane tint (edge-jump channel)
* fitUs legacy span override (defaults: run → window, done → captured)
*/
export function drawWaveform(g, w, h, state, opts = {}) {
const gutter = opts.gutter ?? 54;
const axis = opts.axis !== false;
const axisH = axis ? Math.max(14, Math.round(h * 0.08)) : 0;
const chs = state?.channels ?? [];
const n = Math.max(1, chs.length);
const view = opts.view ?? { t0: 0, spanUs: Math.max(1e-6, opts.fitUs ?? fitSpanUs(state)) };
const t0 = view.t0, t1 = t0 + view.spanUs;
g.clearRect(0, 0, w, h);
const { usToX, xToUs, plotW } = viewXform(w, gutter, view);
// lanes
const plotH = Math.max(8, h - axisH);
const laneH = plotH / n;
const pad = Math.min(7, Math.max(2, laneH * 0.22));
const laneY = (i) => i * laneH;
// selected-lane tint (the channel edge-jump acts on)
if (Number.isInteger(opts.selectedLane) && opts.selectedLane >= 0 && opts.selectedLane < chs.length) {
g.fillStyle = 'rgba(255,255,255,0.04)';
g.fillRect(0, laneY(opts.selectedLane), w, laneH);
}
// axis + grid (ticks derived from the visible span, not the capture)
if (axis) {
const step = niceTickStep(view.spanUs, Math.max(2, Math.round(plotW / 90)));
g.strokeStyle = 'rgba(255,255,255,0.07)';
g.fillStyle = 'rgba(255,255,255,0.35)';
g.font = '9px ui-monospace, monospace';
g.textAlign = 'center';
g.lineWidth = 1;
for (let t = Math.ceil(t0 / step) * step; t <= t1 + 1e-9; t += step) {
const x = Math.round(usToX(t)) + 0.5;
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x, plotH);
g.stroke();
g.fillText(fmtUs(t), x, h - 4);
}
// baseline between the lanes and the axis
g.strokeStyle = 'rgba(255,255,255,0.12)';
g.beginPath();
g.moveTo(0, plotH + 0.5);
g.lineTo(w, plotH + 0.5);
g.stroke();
}
// progress mask while recording: the un-captured tail dims out
if (state?.status === 'run' && Number.isFinite(state.capturedUs)) {
const maskFrom = Math.max(state.capturedUs, t0);
if (maskFrom < t1) {
g.fillStyle = 'rgba(0,0,0,0.45)';
g.fillRect(usToX(maskFrom), 0, Math.max(0, w - usToX(maskFrom)), plotH);
}
}
// per-channel traces
for (let i = 0; i < chs.length; i++) {
const yTop = laneY(i) + pad;
const yBot = laneY(i) + laneH - pad;
const yMid = (yTop + yBot) / 2;
const colour = LA_COLORS[i % LA_COLORS.length];
const flat = state.edges?.[i] ?? [];
const initial = state.initial?.[i];
if (gutter > 0) {
g.fillStyle = colour;
g.font = '9.5px ui-monospace, monospace';
g.textAlign = 'left';
g.fillText(`GPIO${chs[i]}`, 4, yMid + 3);
// level at the right edge of the view (Saleae shows the live value)
g.textAlign = 'right';
g.fillStyle = 'rgba(255,255,255,0.55)';
g.fillText(levelChar(levelAt(flat, initial, t1)), w - 4, yMid + 3);
}
if (flat.length < 2 && initial == null) {
// nothing known: dashed placeholder mid-lane
g.strokeStyle = 'rgba(255,255,255,0.18)';
g.setLineDash([3, 3]);
g.lineWidth = 1;
g.beginPath();
g.moveTo(gutter, yMid);
g.lineTo(w, yMid);
g.stroke();
g.setLineDash([]);
continue;
}
g.strokeStyle = colour;
g.lineWidth = Math.min(2, Math.max(1, laneH / 14));
g.lineJoin = 'miter';
g.beginPath();
let prevY = initial == null ? yMid : initial > 0 ? yTop : initial === 0 ? yBot : yMid;
// unknown head until the first edge (Z / never-sampled)
if (initial == null) {
g.setLineDash([3, 3]);
g.moveTo(gutter, yMid);
g.lineTo(usToX(flat[0] ?? t1), yMid);
g.stroke();
g.setLineDash([]);
g.beginPath();
prevY = yMid;
}
let x = gutter;
g.moveTo(x, prevY);
for (let k = 0; k + 1 < flat.length; k += 2) {
const nx = usToX(flat[k]);
const lvl = flat[k + 1];
const ny = lvl > 0 ? yTop : lvl === 0 ? yBot : yMid;
if (nx > x) g.lineTo(nx, prevY); // horizontal run
g.lineTo(nx, ny); // edge (vertical; Z drawn to mid)
x = nx; prevY = ny;
}
// tail: level holds; a Z level renders dashed to the end
if (prevY === yMid && flat.length >= 2 && flat[flat.length - 1] === -1) {
g.lineTo(x, prevY);
g.stroke();
g.setLineDash([3, 3]);
g.beginPath();
g.moveTo(x, yMid);
g.lineTo(w, yMid);
g.stroke();
g.setLineDash([]);
} else {
g.lineTo(w, prevY);
g.stroke();
}
}
// hover-measurement highlight: shade the measured pulse on its lane
if (opts.measure && chs[opts.measure.lane] != null) {
const { lane, from, to } = opts.measure;
if (to > from) {
const x0 = Math.max(gutter, usToX(from));
const x1 = Math.min(w, usToX(to));
if (x1 > x0) {
g.fillStyle = LA_COLORS[lane % LA_COLORS.length] + '22';
g.fillRect(x0, laneY(lane), x1 - x0, laneH);
g.strokeStyle = LA_COLORS[lane % LA_COLORS.length] + '66';
g.lineWidth = 1;
g.strokeRect(x0 + 0.5, laneY(lane) + 0.5, x1 - x0 - 1, laneH - 1);
}
}
}
// trigger marker (only meaningful for a triggered capture)
if (state?.triggered && 0 >= t0 && 0 <= t1) {
const x = Math.round(usToX(0)) + 0.5;
g.strokeStyle = 'rgba(255,179,64,0.85)';
g.setLineDash([4, 3]);
g.lineWidth = 1;
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x, plotH);
g.stroke();
g.setLineDash([]);
g.fillStyle = 'rgba(255,179,64,0.9)';
g.font = 'bold 9px ui-monospace, monospace';
g.textAlign = 'left';
g.fillText('T', x + 3, 9);
}
// A/B measurement cursors with letter flags
const CURSOR_STYLE = { A: '#ffd54f', B: '#4dd0e1' };
for (const key of ['A', 'B']) {
const t = opts.cursors?.[key.toLowerCase()];
if (t == null || t < t0 || t > t1) continue;
const x = Math.round(usToX(t)) + 0.5;
g.strokeStyle = CURSOR_STYLE[key];
g.lineWidth = 1;
g.setLineDash([]);
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x, plotH);
g.stroke();
g.fillStyle = CURSOR_STYLE[key];
g.font = 'bold 9px ui-monospace, monospace';
g.textAlign = 'left';
g.fillText(key, x + 3, plotH - 3);
}
// hover crosshair + readout
if (opts.hover != null && opts.hover.x >= gutter) {
const hx = Math.round(opts.hover.x) + 0.5;
g.strokeStyle = 'rgba(255,255,255,0.35)';
g.lineWidth = 1;
g.beginPath();
g.moveTo(hx, 0);
g.lineTo(hx, h - axisH);
g.stroke();
const t = xToUs(opts.hover.x);
const text = fmtUs(t);
g.font = '10px ui-monospace, monospace';
g.textAlign = hx > w - 70 ? 'right' : 'left';
g.fillStyle = 'rgba(0,0,0,0.55)';
const tw = g.measureText(text).width + 8;
const tx = hx > w - 70 ? hx - tw - 2 : hx + 2;
g.fillRect(tx, 2, tw, 14);
g.fillStyle = 'rgba(255,255,255,0.85)';
g.fillText(text, tx + 4, 13);
}
}
/** Time span the painter fits: the configured window while running, the
* actually captured extent once done (an early stop should not show empty
* space), floored at 1 µs. Pure — shared by the View and the tests. */
export function fitSpanUs(state) {
const win = Math.max(1e-6, Number(state?.windowUs) || 0);
if (state?.status === 'run') return win;
let last = 0;
for (const flat of state?.edges ?? []) {
if (flat.length >= 2) last = Math.max(last, flat[flat.length - 2]);
}
const extent = Math.max(last, Number(state?.capturedUs) || 0);
if (!(extent > 0)) return win; // nothing captured: keep the window frame
return Math.max(Math.min(win, extent), 1e-6);
}
+913
View File
@@ -0,0 +1,913 @@
/*
* Logic analyzer — multi-channel GPIO waveform capture for firmware-driven
* (bit-banged) signals: up to MAX_CHANNELS arbitrary GPIOs, one trigger
* (channel + edge, or immediate), a guest-time capture window and an
* optional auto re-arm. The waveform UI is a large, draggable + resizable
* floating window (non-modal: the page underneath keeps working); the row
* icon and the expanded details show a live mini-preview that opens the
* window on click.
*
* Sampling rides the M4 scheduler like the other bit-bang devices:
* armed → a standing ARM_BATCH watch (any single edge is notified whatever
* the batch size, so the trigger always resolves); recording → a fine lease
* (FINE_BATCHES[resolution]) renewed on every edge and from onBatch while
* edges keep flowing, expiring on its deadline or a CPU-sleep fast-forward.
* Two flips inside one batch merge into one visible edge — the resolution
* preset picks how fine that is (M0 Q1a; 'fine' = the scheduler floor,
* resolving back-to-back gpio_set_level pulses).
*
* Levels are 1/0 while the guest drives the pad and -1 (Z) when it releases
* it (oe drops) — the pad's pulled level is unknowable from gpio_state().
* Times are GUEST µs from the cycle counter (the guest clock is not
* wall-accurate, M4). Capture data is pushed in full only when the buffer
* freezes (status 'done') — during 'run' the state carries progress +
* per-channel edge counts, keeping the ~30 fps pushes tiny.
*/
import {
parsePins, MAX_CHANNELS, MAX_EDGES, ARM_BATCH, FINE_BATCHES, FINE_HOLD_CYCLES,
drawWaveform, fitSpanUs, makeFloatingWindow, resizeCanvas, fmtUs, fmtHz,
viewXform, clampView, zoomAt, pulseAround, pwmOf, levelChar, LA_COLORS,
} from './la-lib.js';
import { makeCanvas } from './ws2812-lib.js';
/* re-exported so tests and the plugin import one place (ws2812-lib pattern) */
export { MAX_CHANNELS, MAX_EDGES, ARM_BATCH, FINE_BATCHES, FINE_HOLD_CYCLES };
/** Progress push cadence while recording (guest cycles between markDirty). */
const PROGRESS_CYCLES = 2000000;
const clampNum = (v, dflt, lo, hi) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
};
export class Device {
static typeId = 'logic-analyzer';
static version = '1.0.0';
static title = 'Logic analyzer';
static category = 'instrument';
static description = 'Multi-GPIO waveform capture — click the row icon (or the preview) for the draggable analyzer window; trigger, window and resolution configurable.';
static defaultConfig = {
name: 'LA', pins: [], durationMs: 50, resolution: 'normal', trigCh: -1, trigEdge: 'any',
};
/* A probe taps lines without driving them: sharing pads with other bus
* devices (I2C lines, MAX7219 buses, other analyzers) is legal wiring. */
static sharedBus = true;
static pins(cfg) { return parsePins(cfg.pins); }
static configSchema = [
{ key: 'durationMs', label: 'Window (ms)', type: 'number', min: 1, max: 1000, step: 1 },
{ key: 'resolution', label: 'Resolution', type: 'select', options: [
{ value: 'fine', label: 'Fine — tightest pulses (slowest)' },
{ value: 'normal', label: 'Normal (default)' },
{ value: 'coarse', label: 'Coarse — fastest' },
] },
{ key: 'name', label: 'Label', type: 'text' },
// channels (pins), trigger channel/edge are edited in the analyzer
// window (they need the channel picker); they live in defaultConfig
// so normaliseConfig keeps them across config round-trips
];
constructor(ctx, cfg) {
this.ctx = ctx;
this.cfg = cfg;
this.pins = parsePins(cfg.pins);
this.windowMs = clampNum(cfg.durationMs, 50, 1, 1000);
this.fine = FINE_BATCHES[cfg.resolution] ?? FINE_BATCHES.normal;
const tc = Number(cfg.trigCh);
this.trigCh = Number.isInteger(tc) && tc >= 0 && tc < this.pins.length ? tc : -1;
this.trigEdge = ['any', 'rise', 'fall'].includes(cfg.trigEdge) ? cfg.trigEdge : 'any';
this.autoRun = false; // session control (Arm button), not config
this.status = 'idle'; // idle | armed | run | done
this.triggered = false;
this.overflow = false;
this.capId = 0; // bumped at every _start — the View resets
// its zoom/cursors when it changes
this.t0 = 0; // capture start (cycles)
this.windowCycles = 0;
this.edges = this.pins.map(() => []); // per channel [tUs, level, …]
this.initial = this.pins.map(() => null);
this.capturedUs = 0;
this.lastEdgeCycles = -1;
this.lastMarkCycles = 0;
this.cpus = ctx.cyclesPerUs || 160;
if (this.pins.length) ctx.subscribe(this.pins);
}
onEmuReady() {
// fresh emulator: no waveform history survives, leases are gone
this._resetData();
this.status = 'idle';
this.autoRun = false;
this.capId = 0;
this.cpus = this.ctx.cyclesPerUs || 160;
this.ctx.markDirty();
}
onPinState(pin, s) {
const level = s.oe ? (s.out ? 1 : 0) : -1;
if (this.status === 'armed') {
// trigger watch: the edge must sit on the trigger channel and
// match the direction (rising arrives as level 1; a release to
// Z counts as falling, the open-drain idiom)
if (pin !== this.pins[this.trigCh]) return;
if (this.trigEdge === 'rise' && level !== 1) return;
if (this.trigEdge === 'fall' && level !== 0 && level !== -1) return;
this._start(s.cycles, { pin, level });
return;
}
if (this.status !== 'run') return;
const idx = this.pins.indexOf(pin);
if (idx < 0) return;
const tUs = (s.cycles - this.t0) / this.cpus;
if (tUs < 0) return;
this.edges[idx].push(tUs, level);
this.lastEdgeCycles = s.cycles;
this.ctx.fineSampling(this.fine, FINE_HOLD_CYCLES);
if (this.edges.reduce((n, a) => n + a.length, 0) > MAX_EDGES * 2) {
this._finish(true); // buffer full — freeze what we have
}
}
onBatch() {
if (this.status !== 'run') return;
const now = this.ctx.cycles();
if (now - this.t0 >= this.windowCycles) {
this._finish(false);
return;
}
// renew the fine lease only while edges keep flowing — a quiet line
// falls back to the arm batch, and the NEXT isolated edge still
// lands (single flips are notified at any batch size)
if (this.lastEdgeCycles >= 0 && now - this.lastEdgeCycles <= FINE_HOLD_CYCLES) {
this.ctx.fineSampling(this.fine, FINE_HOLD_CYCLES);
}
if (now - this.lastMarkCycles >= PROGRESS_CYCLES) {
this.lastMarkCycles = now;
this.ctx.markDirty(); // progress readout while recording
}
}
onCommand(cmd, params) {
if (cmd === 'arm') {
if (this.status === 'run' || this.status === 'armed') return;
this.autoRun = !!(params && params.auto);
this.overflow = false;
if (this.trigCh >= 0 && this.pins.length) {
this.status = 'armed';
this.ctx.armSampling(ARM_BATCH);
} else {
this._start(this.ctx.cycles(), null); // immediate
}
this.ctx.markDirty();
} else if (cmd === 'stop') {
if (this.status === 'run') this._finish(false);
else if (this.status === 'armed') {
this.status = 'idle';
this.autoRun = false;
this.ctx.releaseSampling();
this.ctx.markDirty();
}
} else if (cmd === 'clear') {
this._resetData();
this.status = 'idle';
this.autoRun = false;
this.ctx.releaseSampling();
this.ctx.markDirty();
}
}
getState() {
const live = this.status === 'run';
const capturedUs = live
? Math.min(this.windowMs * 1000, Math.max(0, (this.ctx.cycles() - this.t0) / this.cpus))
: this.capturedUs;
const r3 = (v) => (typeof v === 'number' ? Math.round(v * 1000) / 1000 : v);
return {
status: this.status,
auto: this.autoRun,
overflow: this.overflow,
capId: this.capId,
channels: this.pins.slice(),
windowUs: this.windowMs * 1000,
capturedUs,
triggered: this.triggered,
// full waveform only once frozen — the ~30 fps run pushes carry
// progress + live per-channel edge counts instead
edges: live
? this.pins.map(() => [])
: this.edges.map((a) => a.map(r3)),
initial: live ? this.pins.map(() => null) : this.initial.slice(),
counts: this.edges.map((a) => a.length / 2),
totalEdges: this.edges.reduce((n, a) => n + a.length / 2, 0),
};
}
destroy() {
this.ctx.releaseSampling();
this.ctx.unsubscribe();
}
_resetData() {
this.edges = this.pins.map(() => []);
this.initial = this.pins.map(() => null);
this.capturedUs = 0;
this.triggered = false;
this.overflow = false;
this.lastEdgeCycles = -1;
}
/** Begin recording at `cycles` (the trigger edge's own sample time when
* triggered; the command time for an immediate start). */
_start(cycles, trig) {
this.t0 = cycles;
this.windowCycles = this.windowMs * 1000 * this.cpus;
this._resetData();
this.status = 'run';
this.capId++; // fresh capture → the View re-fits zoom/cursors
this.lastMarkCycles = cycles;
// channel levels at capture start; the trigger channel's pre-edge
// level is the edge's complement, every other pad is snapshotted
// (pinLevel speaks booleans — the waveform speaks 1/0/-1)
for (let i = 0; i < this.pins.length; i++) {
const lvl = this.ctx.pinLevel(this.pins[i]);
this.initial[i] = lvl == null ? null : lvl ? 1 : 0;
}
if (trig) {
const idx = this.pins.indexOf(trig.pin);
if (idx >= 0) {
if (trig.level === 1 || trig.level === 0) this.initial[idx] = trig.level ? 0 : 1;
this.edges[idx].push(0, trig.level); // the trigger edge itself
this.lastEdgeCycles = cycles;
}
}
this.triggered = !!trig;
this.ctx.armSampling(ARM_BATCH); // single flips stay visible while idle-ish
this.ctx.fineSampling(this.fine, FINE_HOLD_CYCLES);
this.ctx.markDirty();
}
_finish(overflow) {
this.status = 'done';
this.overflow = overflow;
this.capturedUs = Math.min(this.windowMs * 1000,
Math.max(0, (this.ctx.cycles() - this.t0) / this.cpus));
if (this.autoRun) {
if (this.trigCh >= 0 && this.pins.length) {
this.status = 'armed'; // wait for the next trigger
this.ctx.armSampling(ARM_BATCH);
} else {
// auto + Immediate would restart instantly and overwrite the
// frozen buffer before any push could show it — pointless
// churn, so auto quietly disarms instead
this.autoRun = false;
this.ctx.releaseSampling();
this.ctx.log('logic-analyzer: auto re-arm needs a trigger channel — staying idle', 'warn');
}
} else {
this.ctx.releaseSampling();
}
this.ctx.markDirty();
}
}
/* ---------------------------------------------------------------------------
* Page side. Three visuals, one painter (AGENT.md §3.8): the row icon, the
* details mini-preview and the floating analyzer window all call
* drawWaveform on the same captured state.
* ------------------------------------------------------------------------- */
export class View {
constructor(el, sendCmd, host) {
this.el = el;
this.send = sendCmd;
this.host = host;
this.cfg = { name: 'LA', pins: [], durationMs: 50, resolution: 'normal', trigCh: -1, trigEdge: 'any' };
this.state = null;
this.autoUI = false; // session Auto re-arm checkbox (an Arm control)
this.hover = null;
this.win = null;
// Saleae-style plot model: zoomable view, hover measurement, A/B
// cursors, selected channel (edge jumps act on it)
this.view = null; // {t0, spanUs}; null = fit
this.capSeen = -1; // capId the view was built for
this.cursors = { a: null, b: null };
this.selCh = null;
this.measure = null; // {lane, from, to} painter highlight
this.measureInfo = null; // {ch, pulse, pwm} for the bar
this.drag = null; // active pan / cursor drag
this.cursorTimer = null; // click-vs-dblclick debounce
this.plotW = 10;
el.className = 'dev-body la-body';
const mini = document.createElement('div');
mini.className = 'la-mini';
this.miniWrap = mini;
const { cv, g } = makeCanvas(300, 64, 'la-mini-cv');
this.miniCv = cv; this.miniG = g;
mini.appendChild(cv);
const open = document.createElement('button');
open.type = 'button';
open.className = 'la-open';
open.textContent = '⤢ open analyzer window';
open.title = 'Open the draggable analyzer window (drag header to move, corner to resize; the page keeps working underneath)';
const cap = document.createElement('div');
cap.className = 'la-cap';
this.cap = cap;
mini.addEventListener('click', () => this.openWin());
open.addEventListener('click', () => this.openWin());
el.appendChild(mini);
el.appendChild(open);
el.appendChild(cap);
}
rowIcon(el) {
el.title = 'Logic analyzer — click to open the analyzer window';
const { cv, g } = makeCanvas(22, 22, 'la-icon');
this.iconCv = cv; this.iconG = g;
cv.addEventListener('click', () => this.openWin());
el.appendChild(cv);
this._apply();
}
/* One application of a captured state → icon + mini + window. */
_apply() {
const st = this.state;
const optsMini = { gutter: 40, axis: false };
if (this.iconG) {
this.iconG.clearRect(0, 0, 22, 22);
if (st && st.channels.length) {
drawWaveform(this.iconG, 22, 22, st, { gutter: 0, axis: false });
} else {
this.iconG.strokeStyle = 'rgba(255,255,255,0.3)';
this.iconG.lineWidth = 1.5;
this.iconG.beginPath();
this.iconG.moveTo(3, 15); this.iconG.lineTo(8, 15); this.iconG.lineTo(8, 7);
this.iconG.lineTo(14, 7); this.iconG.lineTo(14, 15); this.iconG.lineTo(19, 15);
this.iconG.stroke();
}
if (st) {
// status dot: armed=warn, run=ok, done=muted
this.iconG.fillStyle = st.status === 'armed' ? '#ffb340'
: st.status === 'run' ? '#7ad17a' : 'rgba(255,255,255,0.35)';
this.iconG.beginPath();
this.iconG.arc(18, 4, 2.4, 0, Math.PI * 2);
this.iconG.fill();
}
}
if (this.miniG) drawWaveform(this.miniG, 300, 64, st, optsMini);
this._renderCaption();
if (this.win) this._renderWin();
}
_renderCaption() {
const st = this.state;
if (!st || !st.channels.length) {
this.cap.textContent = 'no channels — open the window and pick GPIOs to probe';
return;
}
const parts = [st.channels.map((p) => `GPIO${p}`).join(', '),
st.status === 'run' ? `capturing ${fmtUs(st.capturedUs)} / ${fmtUs(st.windowUs)}`
: st.capturedUs > 0 ? fmtUs(st.capturedUs) : '—',
`${st.totalEdges} edges`];
this.cap.textContent = parts.join(' · ') + (st.overflow ? ' · buffer full' : '');
}
render(state) {
this.state = state;
this._apply();
}
setConfig(cfg) {
this.cfg = {
name: String(cfg.name ?? 'LA'),
pins: parsePins(cfg.pins),
durationMs: clampNum(cfg.durationMs, 50, 1, 1000),
resolution: FINE_BATCHES[cfg.resolution] ? cfg.resolution : 'normal',
trigCh: Number.isInteger(Number(cfg.trigCh)) ? Number(cfg.trigCh) : -1,
trigEdge: ['any', 'rise', 'fall'].includes(cfg.trigEdge) ? cfg.trigEdge : 'any',
};
if (this.win) {
this.winTitle.textContent = `⌁ Logic analyzer — ${this.cfg.name}`;
this._syncWinControls();
this._renderChans();
}
this._apply();
}
destroy() {
this.destroyWin();
}
/* ---- the floating analyzer window ------------------------------------- */
openWin() {
if (this.win) {
this.win.raise(); // bring an existing window above its siblings
return;
}
const win = makeFloatingWindow({
title: `⌁ Logic analyzer — ${this.cfg.name}`,
cls: 'la-win',
storeKey: 'peris.la.win.v1',
w: 680, h: 440, minW: 360, minH: 240,
onResize: () => this._fitCanvas(),
});
this.win = win;
this.winTitle = win.head.querySelector('.float-win-title');
win.close.addEventListener('click', () => this.destroyWin());
const bar = document.createElement('div');
bar.className = 'la-bar';
this.armBtn = this._btn('la-arm', '● Arm', 'Start capturing (immediately, or when the trigger fires)',
() => this.send('arm', { auto: this.autoUI }));
this.stopBtn = this._btn('la-stop', '■ Stop', 'Stop the running capture / disarm',
() => this.send('stop'));
this.clearBtn = this._btn('la-clear', 'clear', 'Erase the captured waveform',
() => this.send('clear'));
bar.appendChild(this.armBtn);
bar.appendChild(this.stopBtn);
bar.appendChild(this.clearBtn);
const trigLab = document.createElement('span');
trigLab.className = 'la-lab';
trigLab.textContent = 'Trigger';
this.trigSel = document.createElement('select');
this.trigSel.title = 'Capture starts on this channel\'s edge (Immediate = start at Arm)';
this.trigSel.addEventListener('change', () =>
this.host.updateConfig({ trigCh: Number(this.trigSel.value) }));
this.edgeSel = document.createElement('select');
for (const [v, l] of [['any', 'any edge'], ['rise', 'rising'], ['fall', 'falling']]) {
const o = document.createElement('option');
o.value = v; o.textContent = l;
this.edgeSel.appendChild(o);
}
this.edgeSel.addEventListener('change', () =>
this.host.updateConfig({ trigEdge: this.edgeSel.value }));
bar.appendChild(trigLab);
bar.appendChild(this.trigSel);
bar.appendChild(this.edgeSel);
const winLab = document.createElement('span');
winLab.className = 'la-lab';
winLab.textContent = 'Window';
this.winInput = document.createElement('input');
this.winInput.type = 'number';
this.winInput.min = '1'; this.winInput.max = '1000'; this.winInput.step = '1';
this.winInput.title = 'Capture window length in guest milliseconds (emulator time is not wall time)';
this.winInput.addEventListener('change', () =>
this.host.updateConfig({ durationMs: clampNum(this.winInput.value, 50, 1, 1000) }));
const winMs = document.createElement('span');
winMs.className = 'la-lab';
winMs.textContent = 'ms';
bar.appendChild(winLab);
bar.appendChild(this.winInput);
bar.appendChild(winMs);
this.resSel = document.createElement('select');
for (const [v, l] of [['fine', 'fine'], ['normal', 'normal'], ['coarse', 'coarse']]) {
const o = document.createElement('option');
o.value = v; o.textContent = l;
this.resSel.appendChild(o);
}
this.resSel.title = 'Sampling resolution: edges closer together than the batch size merge';
this.resSel.addEventListener('change', () =>
this.host.updateConfig({ resolution: this.resSel.value }));
bar.appendChild(this.resSel);
this.autoCb = document.createElement('input');
this.autoCb.type = 'checkbox';
this.autoCb.id = `la-auto-${Math.random().toString(36).slice(2, 8)}`;
this.autoCb.title = 'Re-arm automatically after each capture (pick a trigger channel — Immediate ignores auto)';
this.autoCb.addEventListener('change', () => {
this.autoUI = this.autoCb.checked;
});
const autoLab = document.createElement('label');
autoLab.className = 'la-auto';
autoLab.htmlFor = this.autoCb.id;
autoLab.appendChild(this.autoCb);
const t = document.createElement('span');
t.textContent = 'auto';
autoLab.appendChild(t);
bar.appendChild(autoLab);
// view cluster: zoom in/out/fit + edge jumps of the selected channel
const spacer = document.createElement('span');
spacer.className = 'la-bar-spacer';
bar.appendChild(spacer);
this.zoomInBtn = this._btn('la-zbtn', '+', 'Zoom in (wheel up / +)', () => this._zoomCenter(1 / 1.35));
this.zoomOutBtn = this._btn('la-zbtn', '-', 'Zoom out (wheel down / -)', () => this._zoomCenter(1.35));
this.fitBtn = this._btn('la-zbtn', '⤢', 'Zoom to fit (F / double-click the plot)', () => this._fit());
this.prevEdgeBtn = this._btn('la-zbtn', '‹', 'Jump to the previous edge of the selected channel (←). Click a channel label in the plot to select.',
() => this._jumpEdge(-1));
this.nextEdgeBtn = this._btn('la-zbtn', '›', 'Jump to the next edge of the selected channel (→)',
() => this._jumpEdge(1));
bar.appendChild(this.zoomInBtn);
bar.appendChild(this.zoomOutBtn);
bar.appendChild(this.fitBtn);
bar.appendChild(this.prevEdgeBtn);
bar.appendChild(this.nextEdgeBtn);
const chans = document.createElement('div');
chans.className = 'la-chans';
this.chansEl = chans;
const wrap = document.createElement('div');
wrap.className = 'la-plot';
wrap.tabIndex = 0; // keyboard: ←/→ edge jump, +/- zoom, F fit
const { cv } = makeCanvas(10, 10, 'la-cv');
this.plotCv = cv;
this.plotG = cv.getContext('2d');
wrap.appendChild(cv);
this.hint = document.createElement('div');
this.hint.className = 'la-hint';
this.hint.textContent = 'tick GPIOs above to add channels';
wrap.appendChild(this.hint);
this.plotWrap = wrap;
this._wirePlot(wrap);
// measurement bar: hover readout (pulse/period/frequency/duty) and
// the A/B cursor delta — Saleae's two signature measurements
this.measureBar = document.createElement('div');
this.measureBar.className = 'la-measure';
this.mHover = document.createElement('span');
this.mHover.className = 'm-hover';
this.mCurs = document.createElement('span');
this.mCurs.className = 'm-curs';
this.mClear = this._btn('la-cursor-clear', '✕ cursors', 'Remove both measurement cursors', () => {
this.cursors = { a: null, b: null };
this._renderWin();
});
this.measureBar.appendChild(this.mHover);
this.measureBar.appendChild(this.mCurs);
this.measureBar.appendChild(this.mClear);
this.foot = document.createElement('div');
this.foot.className = 'la-foot';
win.body.appendChild(bar);
win.body.appendChild(chans);
win.body.appendChild(wrap);
win.body.appendChild(this.measureBar);
win.body.appendChild(this.foot);
this.ro = new ResizeObserver(() => this._fitCanvas());
this.ro.observe(wrap);
this._syncWinControls();
this._renderChans();
this._fitCanvas();
this._renderWin();
}
destroyWin() {
if (this.cursorTimer != null) { clearTimeout(this.cursorTimer); this.cursorTimer = null; }
this.drag = null;
this.ro?.disconnect();
this.ro = null;
this.win?.destroy();
this.win = null;
this.hover = null;
this.measure = null;
this.measureInfo = null;
}
_btn(cls, text, title, onClick) {
const b = document.createElement('button');
b.type = 'button';
b.className = `la-btn ${cls}`;
b.textContent = text;
b.title = title;
b.addEventListener('click', onClick);
return b;
}
_syncWinControls() {
if (!this.win) return;
const c = this.cfg;
this.trigSel.innerHTML = '';
const imm = document.createElement('option');
imm.value = '-1'; imm.textContent = 'Immediate';
this.trigSel.appendChild(imm);
for (let i = 0; i < c.pins.length; i++) {
const o = document.createElement('option');
o.value = String(i);
o.textContent = `GPIO${c.pins[i]}`;
this.trigSel.appendChild(o);
}
const tc = c.trigCh >= 0 && c.trigCh < c.pins.length ? c.trigCh : -1;
this.trigSel.value = String(tc);
this.edgeSel.value = c.trigEdge;
this.winInput.value = String(c.durationMs);
this.resSel.value = c.resolution;
this.autoCb.checked = this.autoUI;
}
/* Channel chips from the panel's candidate-aware pin info — pins used by
* other non-bus devices are disabled, shared bus pads stay pickable. */
_renderChans() {
if (!this.win) return;
const el = this.chansEl;
el.innerHTML = '';
const info = this.host?.pinInfo?.();
const valid = info ? info.valid : this.cfg.pins.slice();
const used = info ? info.used : new Set();
for (const p of valid) {
const on = this.cfg.pins.includes(p);
const taken = !on && used.has(p);
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'la-chan' + (on ? ' on' : '');
chip.textContent = String(p);
chip.title = taken
? `GPIO${p} — used by another device`
: `GPIO${p} — click to ${on ? 'remove' : 'add'}`;
if (taken) chip.disabled = true;
else chip.addEventListener('click', () => {
const pins = this.cfg.pins.filter((x) => x !== p);
if (!on) {
if (pins.length >= MAX_CHANNELS) return;
pins.push(p);
pins.sort((a, b) => a - b);
}
this.host.updateConfig({ pins });
});
el.appendChild(chip);
}
this.hint.style.display = this.cfg.pins.length ? 'none' : '';
}
_fitCanvas() {
if (!this.win || !this.plotWrap) return;
const r = this.plotWrap.getBoundingClientRect();
this.plotW = Math.max(10, r.width);
this.plotG = resizeCanvas(this.plotCv, this.plotW, Math.max(10, r.height));
this._renderWin();
}
_renderWin() {
if (!this.win) return;
const st = this.state;
// a fresh capture re-fits the view and drops the cursors (their
// absolute times belong to the previous capture)
if (st && st.capId !== this.capSeen) {
this.capSeen = st.capId;
this.view = null;
this.cursors = { a: null, b: null };
}
if (this.plotG) {
const r = this.plotCv.getBoundingClientRect();
drawWaveform(this.plotG, r.width, r.height, st, {
gutter: 56, axis: true,
view: this._view(),
hover: this.hover,
measure: this.measure,
cursors: this.cursors,
selectedLane: this.selCh,
});
}
this._renderMeasure();
// controls reflect the live status
const s = st?.status ?? 'idle';
this.armBtn.disabled = s === 'run' || s === 'armed';
this.stopBtn.disabled = s === 'idle' || s === 'done';
this.clearBtn.disabled = s === 'run';
this.mClear.style.display = this.cursors.a != null || this.cursors.b != null ? '' : 'none';
const dot = this.win.el.querySelector('.float-win-title');
dot.dataset.status = s;
let text;
if (s === 'idle') text = 'idle — Arm to capture';
else if (s === 'armed') text = `armed — waiting for trigger: GPIO${this.cfg.pins[this.cfg.trigCh] ?? '?'} ${this.cfg.trigEdge}`;
else if (s === 'run') text = `capturing… ${fmtUs(st.capturedUs)} / ${fmtUs(st.windowUs)} · ${st.totalEdges} edges`;
else text = `done · ${fmtUs(st.capturedUs)} · ${st.totalEdges} edges` + (st.overflow ? ' · buffer full (stopped early)' : '');
this.foot.textContent = text
+ (st?.auto && s !== 'idle' && s !== 'done' ? ' · auto' : '')
+ ` · view ${fmtUs(this._view().spanUs)}`;
}
/* ---- Saleae-style plot interaction -------------------------------------- */
/** Visible window; lazily fit to the current capture. */
_view() {
if (!this.view) {
this.view = clampView({ t0: 0, spanUs: fitSpanUs(this.state) }, this.state);
}
return this.view;
}
_fit() {
this.view = null;
this._renderWin();
}
_zoomCenter(factor) {
const v = this._view();
this.view = zoomAt(v, v.t0 + v.spanUs / 2, factor, this.state);
this._renderWin();
}
_xToUs(x) {
return viewXform(this.plotW, 56, this._view()).xToUs(x);
}
_usToX(us) {
return viewXform(this.plotW, 56, this._view()).usToX(us);
}
/** Lane index at a plot y (mirrors the painter's lane geometry). */
_laneAt(y) {
const st = this.state;
const n = Math.max(1, st?.channels?.length ?? 1);
const h = this.plotCv.getBoundingClientRect().height;
const axisH = Math.max(14, Math.round(h * 0.08));
const laneH = Math.max(8, h - axisH) / n;
return Math.min(n - 1, Math.max(0, Math.floor(y / laneH)));
}
/**
* All plot interaction in one place: wheel zoom (anchored at the
* cursor), left/middle-drag pan, drag the A/B cursor lines, click to
* drop a cursor, click a lane label to select the edge-jump channel,
* double-click to fit, keyboard (←/→ edges, +/- zoom, F fit).
*/
_wirePlot(wrap) {
const GUTTER = 56;
wrap.addEventListener('wheel', (e) => {
e.preventDefault();
const r = this.plotCv.getBoundingClientRect();
const anchor = this._xToUs(e.clientX - r.left);
const f = e.deltaY > 0 ? 1.35 : 1 / 1.35;
this.view = zoomAt(this._view(), anchor, f, this.state);
this._renderWin();
}, { passive: false });
wrap.addEventListener('pointerdown', (e) => {
if (e.button === 1) e.preventDefault(); // middle-drag pans, no autoscroll
wrap.focus({ preventScroll: true });
const r = this.plotCv.getBoundingClientRect();
const x = e.clientX - r.left;
const near = (t) => t != null && Math.abs(this._usToX(t) - x) <= 5;
let mode = 'pan';
if (x > GUTTER && near(this.cursors.a)) mode = 'curA';
else if (x > GUTTER && near(this.cursors.b)) mode = 'curB';
this.drag = { mode, x0: x, y0: e.clientY - r.top, t0v: this._view().t0, moved: false, button: e.button };
const move = (ev) => {
const d = this.drag;
if (!d) return;
const mx = ev.clientX - r.left;
if (Math.abs(mx - d.x0) > 3) d.moved = true;
if (d.mode === 'pan') {
const v = this._view();
const dtUs = -((mx - d.x0) / Math.max(1, this.plotW - GUTTER)) * v.spanUs;
this.view = clampView({ t0: d.t0v + dtUs, spanUs: v.spanUs }, this.state);
} else {
this.cursors[d.mode === 'curA' ? 'a' : 'b'] = this._xToUs(mx);
}
this._renderWin();
};
const up = (ev) => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
const d = this.drag;
this.drag = null;
if (!d || d.moved || d.button !== 0) return;
const ux = ev.clientX - r.left;
const uy = ev.clientY - r.top;
if (ux <= GUTTER) {
// label gutter: select the channel edge jumps act on
this.selCh = this._laneAt(uy);
} else if (d.mode === 'pan') {
this._scheduleCursor(this._xToUs(ux));
}
this._renderWin();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
wrap.addEventListener('pointermove', (e) => {
if (this.drag) return; // a drag owns the pointer
const r = this.plotCv.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
this.hover = { x };
this._updateMeasure(x, y);
this._renderWin();
});
wrap.addEventListener('pointerleave', () => {
this.hover = null;
this.measure = null;
this.measureInfo = null;
this._renderWin();
});
wrap.addEventListener('dblclick', () => {
if (this.cursorTimer != null) { clearTimeout(this.cursorTimer); this.cursorTimer = null; }
this._fit();
});
wrap.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); this._jumpEdge(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); this._jumpEdge(1); }
else if (e.key === 'f' || e.key === 'F') this._fit();
else if (e.key === '+' || e.key === '=') this._zoomCenter(1 / 1.35);
else if (e.key === '-') this._zoomCenter(1.35);
});
}
/** Hover measurement for the lane under the cursor (pulse + PWM cycle). */
_updateMeasure(x, y) {
const st = this.state;
if (!st || !st.channels.length || x <= 56) {
this.measure = null;
this.measureInfo = null;
return;
}
const ch = this._laneAt(y);
const t = this._xToUs(x);
const flat = st.edges[ch] ?? [];
const pulse = pulseAround(flat, st.initial?.[ch], t);
const pwm = pwmOf(flat, t);
this.measureInfo = { ch, pulse, pwm };
const from = pulse.tStart, to = pulse.tEnd;
const v = this._view();
this.measure = from != null || to != null
? { lane: ch, from: from ?? 0, to: to ?? Math.max(t, v.t0 + v.spanUs) }
: null;
}
/** Click → cursor A, then B, then the nearest (debounced vs dblclick). */
_scheduleCursor(t) {
if (this.cursorTimer != null) clearTimeout(this.cursorTimer);
this.cursorTimer = setTimeout(() => {
this.cursorTimer = null;
if (this.cursors.a == null) this.cursors.a = t;
else if (this.cursors.b == null) this.cursors.b = t;
else if (Math.abs(t - this.cursors.a) <= Math.abs(t - this.cursors.b)) this.cursors.a = t;
else this.cursors.b = t;
this._renderWin();
}, 220);
}
/** Pan to the previous/next edge of the selected channel (zoom kept). */
_jumpEdge(dir) {
const st = this.state;
if (!st || !st.channels.length) return;
let ch = this.selCh;
if (ch == null || ch < 0 || ch >= st.channels.length) {
ch = this.cfg.trigCh >= 0 && this.cfg.trigCh < st.channels.length ? this.cfg.trigCh : 0;
}
const flat = st.edges[ch] ?? [];
const times = [];
for (let k = 0; k + 1 < flat.length; k += 2) times.push(flat[k]);
const v = this._view();
const centre = v.t0 + v.spanUs / 2;
const t = dir > 0
? times.find((x) => x > centre)
: times.slice().reverse().find((x) => x < centre);
if (t == null) return;
this.selCh = ch;
this.view = clampView({ t0: t - v.spanUs / 2, spanUs: v.spanUs }, st);
this._renderWin();
}
/** The measurement bar: hover readout + A/B cursor delta. */
_renderMeasure() {
if (!this.win) return;
const st = this.state;
const info = this.measureInfo;
// hover half
const parts = [];
if (info && st) {
const pin = st.channels[info.ch];
const lvl = document.createElement('b');
lvl.textContent = `GPIO${pin}`;
lvl.style.color = LA_COLORS[info.ch % LA_COLORS.length];
this.mHover.replaceChildren(lvl);
const p = info.pulse;
this.mHover.appendChild(document.createTextNode(
` ${levelChar(p.level)} · pulse ${p.widthUs != null ? fmtUs(p.widthUs) : '—'}`));
if (info.pwm) {
parts.push(`period ${fmtUs(info.pwm.periodUs)} · ${fmtHz(info.pwm.freqHz)}`
+ ` · duty ${(info.pwm.duty * 100).toFixed(1)}%`);
}
for (const t of parts) this.mHover.appendChild(document.createTextNode(' · ' + t));
} else {
this.mHover.textContent = st && st.channels.length
? 'hover a channel to measure · wheel zoom · drag pan · click drops A/B cursors · double-click fits'
: '—';
}
// cursor half
const { a, b } = this.cursors;
if (a != null || b != null) {
const bits = [`A ${a != null ? fmtUs(a) : '—'}`, `B ${b != null ? fmtUs(b) : '—'}`];
if (a != null && b != null) {
const d = Math.abs(b - a);
bits.push(`Δ ${fmtUs(d)} · ${fmtHz(1e6 / d)}`);
}
this.mCurs.textContent = bits.join(' · ');
} else {
this.mCurs.textContent = '';
}
}
}
+3 -1
View File
@@ -8,6 +8,8 @@
{ "module": "rgb-led.js" },
{ "module": "ws2812.js" },
{ "module": "bitbang-i2c-slave.js" },
{ "module": "ir-nec.js" }
{ "module": "ir-nec.js" },
{ "module": "max7219.js" },
{ "module": "logic-analyzer.js" }
]
}
+354
View File
@@ -0,0 +1,354 @@
/*
* max7219-lib — shared helpers for the MAX7219 display device (0.9.0). One
* semantic, one implementation (AGENT.md §3.8): the wire-level shift engine,
* the register model (datasheet register map incl. Code B font, intensity,
* scan limit, shutdown / display test) and the page-side painters (8×8
* matrix dots, 7-segment digits) live here; the plugin and the unit tests
* import this module. Not listed in manifest.json → never loaded as a device
* itself. No page-only code runs at import time (Node tests import the file).
*
* Datasheet facts this encodes (MAX7219/MAX7221 datasheet, tables 2-8):
* - 16-bit word = 8-bit register address, then 8-bit data, MSB first;
* DIN is sampled on the CLK rising edge, the word is latched on the
* LOAD rising edge. Words shifted while LOAD is low stream through the
* device — a daisy chain of N parts latches the last N words.
* - no-decode mode data bits map one-to-one to segment lines (table 6):
* D7=DP D6=A D5=B D4=C D3=D D2=E D1=F D0=G
* - Code B decode (table 5) looks only at D3–D0 (D4–D6 disregarded);
* D7 is the decimal point, independent of the decoder.
* - power-on: all control registers reset, display blanked, SHUTDOWN mode
* (scan one digit, no decode, minimum intensity).
* - intensity = internal PWM, 16 steps from 1/32 to 31/32 duty (table 7).
*
* Wire model (matches what gpio_state() exposes; see i2c-lib for the M4
* background): a sample carries each pad's END-OF-BATCH level, so the engine
* is level-driven and reconstructs edges from level changes. DIN is always
* stable around a CLK rising edge in SPI-mode-0 masters, so sampling the
* current DIN level on an observed CLK rise is correct even when a DIN
* change and a CLK edge land in the same batch — the sampling lease sizes
* (ARM_BATCH/FINE_BATCH in max7219.js) are chosen so they never do.
*/
import { shapeLevel, drawLedDot, ledPositions } from './ws2812-lib.js';
/* Segment bits in the no-decode data byte (datasheet table 6). */
export const SEG = { DP: 0x80, A: 0x40, B: 0x20, C: 0x10, D: 0x08, E: 0x04, F: 0x02, G: 0x01 };
/** Code B font (datasheet table 5) as segment masks in the SEG layout,
* indexed by the digit register's low nibble. */
export const CODE_B = [
SEG.A | SEG.B | SEG.C | SEG.D | SEG.E | SEG.F, // 0
SEG.B | SEG.C, // 1
SEG.A | SEG.B | SEG.D | SEG.E | SEG.G, // 2
SEG.A | SEG.B | SEG.C | SEG.D | SEG.G, // 3
SEG.B | SEG.C | SEG.F | SEG.G, // 4
SEG.A | SEG.C | SEG.D | SEG.F | SEG.G, // 5
SEG.A | SEG.C | SEG.D | SEG.E | SEG.F | SEG.G, // 6
SEG.A | SEG.B | SEG.C, // 7
SEG.A | SEG.B | SEG.C | SEG.D | SEG.E | SEG.F | SEG.G, // 8
SEG.A | SEG.B | SEG.C | SEG.D | SEG.F | SEG.G, // 9
SEG.G, // 0xA '-'
SEG.A | SEG.D | SEG.E | SEG.F | SEG.G, // 0xB 'E'
SEG.B | SEG.C | SEG.E | SEG.F | SEG.G, // 0xC 'H'
SEG.D | SEG.E | SEG.F, // 0xD 'L'
SEG.A | SEG.B | SEG.E | SEG.F | SEG.G, // 0xE 'P'
0, // 0xF blank
];
/** Register addresses (datasheet table 2). */
export const MAX_REG = {
NOOP: 0x00, DIGIT0: 0x01,
DECODE: 0x09, INTENSITY: 0x0A, SCAN_LIMIT: 0x0B, SHUTDOWN: 0x0C, TEST: 0x0F,
};
/* ---- register model -------------------------------------------------------- */
/**
* MAX7219 register file. write(addr, data) applies one latched word and
* returns true when visible display state changed (digit bytes, mode or
* brightness registers); no-op and undefined addresses are counted but never
* change state. reset() reproduces the power-on state (shutdown, blank,
* scan 1 digit, no decode, minimum intensity).
*/
export class Max7219Model {
constructor() {
this.reset();
}
reset() {
this.digits = new Array(8).fill(0);
this.decodeMode = 0;
this.intensity = 0;
this.scanLimit = 0;
this.shutdown = true;
this.test = false;
this.writes = 0;
}
write(addr, data) {
const a = addr & 0xff, d = data & 0xff;
this.writes++;
if (a >= MAX_REG.DIGIT0 && a <= MAX_REG.DIGIT0 + 7) {
if (this.digits[a - 1] === d) return false;
this.digits[a - 1] = d;
return true;
}
switch (a) {
case MAX_REG.DECODE:
if (this.decodeMode === d) return false;
this.decodeMode = d;
return true;
case MAX_REG.INTENSITY:
if (this.intensity === (d & 0x0f)) return false;
this.intensity = d & 0x0f;
return true;
case MAX_REG.SCAN_LIMIT:
if (this.scanLimit === (d & 0x07)) return false;
this.scanLimit = d & 0x07;
return true;
case MAX_REG.SHUTDOWN: {
const down = (d & 1) === 0;
if (this.shutdown === down) return false;
this.shutdown = down;
return true;
}
case MAX_REG.TEST: {
const on = (d & 1) !== 0;
if (this.test === on) return false;
this.test = on;
return true;
}
default:
return false; // no-op (0x00) and undefined (0x0D/0x0E)
}
}
/** Effective segment byte of one digit: Code B decode applied (D7=DP
* independent), or the raw byte in no-decode mode. */
digitSegments(d) {
const raw = this.digits[d] & 0xff;
if ((this.decodeMode >> d) & 1) return (CODE_B[raw & 0x0f]) | (raw & SEG.DP);
return raw;
}
/** Average segment current duty for the intensity register (1/32…31/32). */
brightness() {
return (this.intensity + 1) / 32;
}
}
/**
* One latched LOAD frame → the word this chain position receives. Words
* stream through the chain: the LAST word on the wire stays in the part
* nearest the master, earlier words land further down. pos is 0-based from
* the master (DIN) side, so pos p receives words[len-1-p]. Frames shorter
* than the chain leave the far parts without a word (null).
*/
export function pickChainWord(words, chain, pos) {
if (pos < 0 || pos >= chain) return null;
const i = words.length - 1 - pos;
return i >= 0 ? words[i] : null;
}
/**
* Effective per-digit segment bytes for rendering, from a plain state
* snapshot (the Device's getState output — pure function, shared by the View
* and the unit tests): display test lights every segment, shutdown blanks,
* digits beyond the scan limit are dark, Code B decode is applied. Returns
* { segs: number[8], brightness }.
*/
export function displayFromSnapshot(st) {
const segs = new Array(8).fill(0);
const scan = Math.min(7, Math.max(0, st.scanLimit | 0));
for (let d = 0; d < 8; d++) {
if (st.test) segs[d] = 0xff;
else if (!st.shutdown && d <= scan) {
const raw = (st.digits?.[d] ?? 0) & 0xff;
segs[d] = (st.decodeMode >> d) & 1 ? (CODE_B[raw & 0x0f] | (raw & SEG.DP)) : raw;
}
}
return { segs, brightness: Math.min(15, Math.max(0, st.intensity | 0)) };
}
/* ---- wire engine ------------------------------------------------------------ */
/**
* Level-driven MAX7219 shift register. Feed it one (clk, din, load) level
* triple per observed pad change. A CLK rising edge shifts the current DIN
* level in (MSB first); every 16 edges complete one word. The LOAD falling
* edge starts a frame, the rising edge latches it: onFrame(words, bitCount)
* fires with the decoded 16-bit words, or (null, bitCount) when the frame
* was misaligned (a bit count that is not a multiple of 16 — the signature
* of edges lost to sampling; the frame is dropped like a real chain would
* ignore garbage).
*
* Unknown (null) levels bootstrap from the first sighting: a pad that
* already sits at its idle level since emulator reset never produces a
* change event, so CLK's null→high IS its first rising edge (the sigbus
* reports changes only), while a LOAD sighting only learns the idle level —
* a frame always needs its falling edge observed (see step()).
*
* Bits clocked while LOAD is high (no sane master does this) are reset at
* the LOAD falling edge — the frame always starts clean.
*/
export class Max7219Wire {
constructor(onFrame) {
this.onFrame = onFrame || (() => {});
this.reset();
}
reset() {
this.clk = null;
this.din = null;
this.load = null;
this.bits = 0;
this.shift = 0;
this.words = [];
}
/** A frame is in progress (LOAD observed low). */
get busy() {
return this.load === false;
}
/** One level sample; returns true when an edge was consumed. Null→high
* on CLK counts as a genuine rising edge: the sigbus reports changes
* only, so the pre-change level was the real sampled low (a fresh
* emulator leaves every pad low and idle-level writes may never change
* a pin that already sits there). LOAD latches only after its falling
* edge was seen — a learned idle-high LOAD is not a frame. */
step(clk, din, load) {
this.din = din;
let edge = false;
if (clk && (this.clk === null || !this.clk)) { // CLK rising: sample DIN
this.shift = ((this.shift << 1) | (din ? 1 : 0)) & 0xffff;
this.bits++;
edge = true;
if (this.bits % 16 === 0) this.words.push(this.shift);
}
this.clk = clk;
if (this.load !== null && load !== this.load) {
edge = true;
if (!load) { // LOAD falling: frame start
this.bits = 0;
this.shift = 0;
this.words = [];
} else { // LOAD rising: latch
if (this.bits > 0) {
const words = this.bits % 16 === 0 && this.words.length > 0 ? this.words.slice() : null;
this.onFrame(words, this.bits);
}
this.bits = 0;
this.words = [];
}
} else if (this.load === null && load !== null) {
edge = true; // first sighting of LOAD: learn the idle level only
}
this.load = load;
return edge;
}
}
/* ---- rendering (page side) --------------------------------------------------- */
/** Classic red LED colour of MAX7219 modules (r,g,b 0-255). */
export const MX_COLOR = [255, 92, 77];
/**
* Paint one 7-segment digit (plus DP) into a 2D context. segs is a segment
* byte in the SEG layout; level a shaped 0..1 brightness (0 = unlit body).
* Digits are drawn "right side up" with segment A on top; the DP dot sits at
* the bottom-right. Pure painter, no state — the body canvas and the row
* icon both call it (AGENT.md §3.8).
*/
export function drawSeg7(g, x, y, w, h, segs, level) {
const th = w * 0.17; // segment thickness
const gap = th * 0.35;
const half = h / 2;
const x1 = x + th / 2 + gap, x2 = x + w - th / 2 - gap;
const yA = y + th / 2 + gap, yG = y + half, yD = y + h - th / 2 - gap;
const hex = (pts) => {
g.beginPath();
g.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
g.closePath();
g.fill();
};
const hseg = (cy) => hex([
[x1 - th / 2, cy], [x1, cy - th / 2], [x2, cy - th / 2],
[x2 + th / 2, cy], [x2, cy + th / 2], [x1, cy + th / 2],
]);
const vseg = (cx, cy1, cy2) => hex([
[cx, cy1 - th / 2], [cx + th / 2, cy1], [cx + th / 2, cy2],
[cx, cy2 + th / 2], [cx - th / 2, cy2], [cx - th / 2, cy1],
]);
const paint = (mask, shape) => {
const on = (segs & mask) !== 0 && level > 0.003;
g.save();
g.fillStyle = on
? `rgba(${MX_COLOR[0]},${MX_COLOR[1]},${MX_COLOR[2]},${Math.min(1, level * 1.25)})`
: 'rgba(255,255,255,0.05)';
if (on) {
g.shadowColor = `rgba(${MX_COLOR[0]},${MX_COLOR[1]},${MX_COLOR[2]},${Math.min(1, level)})`;
g.shadowBlur = 6 * level;
}
shape();
g.restore();
};
paint(SEG.A, () => hseg(yA));
paint(SEG.B, () => vseg(x2, yA, yG - th / 2));
paint(SEG.C, () => vseg(x2, yG + th / 2, yD));
paint(SEG.D, () => hseg(yD));
paint(SEG.E, () => vseg(x1, yG + th / 2, yD));
paint(SEG.F, () => vseg(x1, yA, yG - th / 2));
paint(SEG.G, () => hseg(yG));
paint(SEG.DP, () => {
g.beginPath();
g.arc(x + w + th * 0.55, yD, th / 2, 0, Math.PI * 2);
g.fill();
});
}
/** Brightness level for a snapshot: intensity PWM duty, gamma-shaped. */
export function displayLevel(brightness) {
return shapeLevel((brightness + 1) / 16, 1, 2.2);
}
/**
* Geometry + painting of the whole 8-digit / 8×8 display into one canvas —
* the ONE application of a display snapshot (body canvas and row icon both
* call it). mode 'seg7' draws digits 7..0 left to right (DIG0 rightmost,
* the usual wiring); 'matrix' draws digit d as row d, data bits D7..D0 as
* columns left to right (segment lines are the columns of a matrix module).
* Returns the canvas size used { w, h }.
*/
export function drawDisplay(g, geo, mode, segs, brightness) {
const level = displayLevel(brightness);
g.clearRect(0, 0, geo.w, geo.h);
if (mode === 'seg7') {
const dw = geo.dw, dh = geo.dh;
for (let i = 0; i < 8; i++) {
const d = 7 - i; // DIG0 rightmost
drawSeg7(g, geo.gap + i * (dw + geo.gap + dw * 0.2), 0, dw, dh, segs[d], segs[d] ? level : 0);
}
return;
}
for (let d = 0; d < 8; d++) {
for (let c = 0; c < 8; c++) {
const dot = geo.dots[d * 8 + c];
const on = (segs[d] >> (7 - c)) & 1; // D7 leftmost … D0 rightmost
drawLedDot(g, dot.x, dot.y, dot.r, MX_COLOR, on ? level : 0);
}
}
}
/** Canvas geometry for one display mode (pure math; shares ledPositions for
* the matrix grid so every MAX7219 visual uses one dot layout). */
export function displayGeometry(mode, maxW = 240) {
if (mode === 'seg7') {
const dw = Math.floor((maxW - 9 * 4) / 8.9); // 8 digits + gaps + dp overhang
const dh = Math.round(dw * 1.55);
return { w: Math.ceil(8 * (dw + 4) + dw * 0.2 + 4), h: dh, dw, dh, gap: 4 };
}
return ledPositions('matrix', 64, 8, maxW);
}
+267
View File
@@ -0,0 +1,267 @@
/*
* MAX7219 display driver — watches a firmware bit-banged 3-wire bus
* (DIN / CLK / LOAD) and renders the latched 8-digit display as 8×8 matrix
* dots or 7-segment digits.
*
* Why bit-bang only: signals routed through a hardware SPI peripheral never
* appear in gpio_state() (M0 — the GPIO-matrix path is invisible), so the
* device requires firmware that drives DIN/CLK/LOAD with CPU GPIO writes
* (any soft-SPI code, delay-paced or back-to-back). CPU-driven edges are
* only observable while a fine sampling lease is held (M4 scheduler): the
* device arms a standing watch and escalates on the first bus edge, exactly
* like the bit-bang I2C slave.
*
* Lease sizing (measured against the reference firmware, gun 0.1.0: pulses
* with NO delays — back-to-back gpio_set_level calls): one CLK edge costs
* ~20 instructions, the shortest CLK→DIN / CLK→CLK gap is ~20-40, the LOAD
* fall leads the first CLK rise by ~80, a whole 16-bit frame spans ~1820.
* So the fine lease sits at the scheduler's floor (20) — every edge then
* lands in its own batch and DIN is never ambiguous against a CLK rise —
* and the arm batch (60) always separates the LOAD fall from the first
* CLK rise. Delay-paced bit-bang (esp_rom_delay_us(2), gun 0.1.5-class,
* gaps ≥160) is covered with even wider margins. Firmwares with pulses
* below ~20 instructions lose edges; the engine drops misaligned frames
* and counts them in `dropped` (visible in the caption) instead of showing
* garbage — drivers that re-push their registers periodically self-heal.
*
* Daisy chains: N parts share DIN/CLK/LOAD (`sharedBus`); each instance
* latches the word that ended in its chain position (chain length + position
* in the config). Single devices (chain 1) simply take the last word.
*/
import {
Max7219Model, Max7219Wire, pickChainWord, displayFromSnapshot,
drawDisplay, drawSeg7, displayLevel, displayGeometry, MX_COLOR,
} from './max7219-lib.js';
import { makeCanvas } from './ws2812-lib.js';
/** Standing watch batch (instructions) while the bus is idle — below the
* LOAD-fall → first-CLK-rise gap (~80 instructions measured), so a frame's
* first bit can never be swallowed by the watch batch. */
export const ARM_BATCH = 60;
/** Fine batch size while a frame is in flight — the scheduler floor: every
* edge of a back-to-back bit-bang (gaps ≥20) lands in its own batch. */
export const FINE_BATCH = 20;
/** Fine lease budget (cycles), renewed on every edge: bridges the slowest
* plausible mid-frame pause (8-word chain frames run ~600k cycles). */
export const FINE_HOLD_CYCLES = 2000000;
const pinNum = (v, dflt) => {
const n = Number(v);
return Number.isInteger(n) && n >= 0 ? n : dflt;
};
const chainFrom = (v) => {
const n = Number(v);
return Number.isInteger(n) ? Math.min(8, Math.max(1, n)) : 1;
};
const posFrom = (v, chain) => {
const n = Number(v);
return Number.isInteger(n) ? Math.min(chain - 1, Math.max(0, n)) : 0;
};
export class Device {
static typeId = 'max7219';
static version = '1.0.0';
static title = 'MAX7219 Display';
static category = 'display';
static description = '8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus — 8×8 matrix or 7-segment view, daisy-chainable.';
static defaultConfig = {
name: 'MAX7219', din: 21, clk: 13, load: 14, chain: 1, pos: 0, mode: 'auto',
};
/* DIN/CLK/LOAD are a bus: daisy-chained parts legally share all three. */
static sharedBus = true;
static pins(cfg) {
return [pinNum(cfg.din, 21), pinNum(cfg.clk, 13), pinNum(cfg.load, 14)];
}
static configSchema = [
{ key: 'din', label: 'DIN GPIO', type: 'pin' },
{ key: 'clk', label: 'CLK GPIO', type: 'pin' },
{ key: 'load', label: 'LOAD/CS GPIO', type: 'pin' },
{ key: 'chain', label: 'Chain length', type: 'number', min: 1, max: 8, step: 1 },
{ key: 'pos', label: 'Chain position', type: 'number', min: 0, max: 7, step: 1 },
{ key: 'mode', label: 'Display view', type: 'select', options: [
{ value: 'auto', label: 'Auto (decode-mode register)' },
{ value: 'matrix', label: '8×8 matrix' },
{ value: 'seg7', label: '7-segment digits' },
] },
{ key: 'name', label: 'Label', type: 'text' },
];
constructor(ctx, cfg) {
this.ctx = ctx;
this.cfg = cfg;
this.din = pinNum(cfg.din, 21);
this.clk = pinNum(cfg.clk, 13);
this.load = pinNum(cfg.load, 14);
this.chain = chainFrom(cfg.chain);
this.pos = posFrom(cfg.pos, this.chain);
this.model = new Max7219Model();
this.levels = { clk: null, din: null, load: null };
this.frames = 0; // aligned LOAD frames seen (any chain length)
this.dropped = 0; // misaligned frames — edges lost to sampling
this.wire = new Max7219Wire((words, bits) => this._onFrame(words, bits));
ctx.subscribe([this.din, this.clk, this.load]);
ctx.armSampling(ARM_BATCH);
}
onEmuReady() {
// fresh emulator: the chip powers up in shutdown, blank, scan 1 digit
this.model.reset();
this.wire.reset();
this.levels = { clk: null, din: null, load: null };
this.frames = 0;
this.dropped = 0;
this.ctx.armSampling(ARM_BATCH);
this.ctx.markDirty();
}
onPinState(pin, s) {
const high = !!(s.out && s.oe);
if (pin === this.din) this.levels.din = high;
else if (pin === this.clk) this.levels.clk = high;
else this.levels.load = high;
if (this.wire.step(this.levels.clk, this.levels.din, this.levels.load)) {
// any edge: keep the fine window alive for the rest of the frame
this.ctx.fineSampling(FINE_BATCH, FINE_HOLD_CYCLES);
}
}
onBatch() {
if (this.wire.busy) {
// mid-frame: renew even without edges (slow bit-bang pauses)
this.ctx.fineSampling(FINE_BATCH, FINE_HOLD_CYCLES);
}
}
getState() {
const m = this.model;
return {
din: this.din, clk: this.clk, load: this.load,
chain: this.chain, pos: this.pos,
digits: m.digits.slice(),
decodeMode: m.decodeMode,
intensity: m.intensity,
scanLimit: m.scanLimit,
shutdown: m.shutdown,
test: m.test,
frames: this.frames,
dropped: this.dropped,
writes: m.writes,
};
}
destroy() {
this.ctx.releaseSampling();
this.ctx.unsubscribe();
}
_onFrame(words) {
if (words == null) {
this.dropped++;
this.ctx.markDirty();
return;
}
this.frames++;
const word = pickChainWord(words, this.chain, this.pos);
if (word != null && this.model.write((word >> 8) & 0xff, word & 0xff)) {
this.ctx.markDirty();
}
}
}
export class View {
constructor(el, sendCmd) {
this.el = el;
this.send = sendCmd;
this.cfg = { mode: 'auto', chain: 1, pos: 0 };
this.state = null;
el.className = 'dev-body mx-body';
const wrap = document.createElement('div');
wrap.className = 'mx-wrap';
this.wrap = wrap;
const cap = document.createElement('div');
cap.className = 'mx-cap';
this.cap = cap;
el.appendChild(wrap);
el.appendChild(cap);
this.cv = null; this.g = null; this.geo = null;
this._rebuild('matrix'); // provisional until the first render/config
}
/* Compact row icon: the live display in miniature (matrix dots, or one
* lit 7-segment digit). Same painters as the details canvas. */
rowIcon(el) {
el.title = 'MAX7219 display';
const { cv, g } = makeCanvas(22, 22, 'mx-icon');
this.iconCv = cv; this.iconG = g;
el.appendChild(cv);
this._apply(this.state);
}
/* Geometry from the view mode — one place builds the canvas. */
_rebuild(mode) {
this.mode = mode;
const geo = displayGeometry(mode);
if (this.cv) this.cv.remove();
const { cv, g } = makeCanvas(geo.w, geo.h, 'mx-canvas');
this.cv = cv; this.g = g; this.geo = geo;
this.wrap.appendChild(cv);
}
/* The ONE application of a display state — details canvas + row icon
* from the same derived segments (AGENT.md §3.8). */
_apply(state) {
this.state = state;
if (!state) return;
const mode = this.cfg.mode === 'auto'
? (state.decodeMode ? 'seg7' : 'matrix')
: this.cfg.mode;
if (mode !== this.mode) this._rebuild(mode);
const { segs, brightness } = displayFromSnapshot(state);
if (this.g) drawDisplay(this.g, this.geo, this.mode, segs, brightness);
if (this.iconG) this._applyIcon(this.mode, segs, brightness);
this.cap.textContent = `DIN GPIO${state.din} · CLK GPIO${state.clk} · LOAD GPIO${state.load}`
+ (state.chain > 1 ? ` · chain ${state.pos + 1}/${state.chain}` : '')
+ ` · ${state.frames} frames` + (state.dropped ? ` · ${state.dropped} dropped` : '')
+ (state.shutdown ? ' · shutdown' : '')
+ (state.test ? ' · display test' : '');
}
_applyIcon(mode, segs, brightness) {
const g = this.iconG;
g.clearRect(0, 0, 22, 22);
const level = displayLevel(brightness);
const lit = segs.some((s) => s);
if (!lit) return;
if (mode === 'seg7') {
const d = segs.reduce((best, s, i) => (s ? i : best), -1);
if (d >= 0) drawSeg7(g, 4, 1, 12, 20, segs[d], level);
return;
}
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
if ((segs[row] >> (7 - col)) & 1) {
g.fillStyle = `rgba(${MX_COLOR[0]},${MX_COLOR[1]},${MX_COLOR[2]},${Math.min(1, level * 1.3)})`;
g.beginPath();
g.arc(2.5 + col * 2.4, 2.5 + row * 2.4, 1, 0, Math.PI * 2);
g.fill();
}
}
}
}
render(state) {
this._apply(state);
}
setConfig(cfg) {
this.cfg = {
mode: ['auto', 'matrix', 'seg7'].includes(cfg.mode) ? cfg.mode : 'auto',
chain: chainFrom(cfg.chain),
pos: posFrom(cfg.pos, chainFrom(cfg.chain)),
};
this._apply(this.state);
}
}
+121
View File
@@ -386,3 +386,124 @@
.ir-list { max-height: 92px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
.ir-entry { font-family: var(--mono); color: var(--muted); }
.ir-cap { text-align: center; font-size: 10.5px; color: var(--faint); font-family: var(--mono); }
/* ---- MAX7219 display (matrix / 7-segment, canvas painted by max7219-lib) --- */
.mx-body { display: flex; flex-direction: column; align-items: stretch; gap: 6px; font-size: 11px; }
.mx-wrap { display: flex; align-items: center; justify-content: center; padding: 6px 0; }
.mx-canvas { display: block; }
.mx-icon { display: block; }
.mx-cap { text-align: center; font-size: 10.5px; color: var(--faint); font-family: var(--mono); }
/* ---- Logic analyzer (la-lib painter + floating window) ---------------------- */
.la-body { display: flex; flex-direction: column; align-items: stretch; gap: 6px; font-size: 11px; }
.la-mini { cursor: pointer; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); padding: 4px; transition: border-color 0.12s; }
.la-mini:hover { border-color: var(--accent); }
.la-mini-cv { display: block; width: 100%; }
.la-open { align-self: center; background: var(--surface-3); border: 1px solid var(--border-strong);
border-radius: 4px; color: var(--muted); font-size: 10.5px; padding: 3px 10px; cursor: pointer;
transition: color 0.1s, border-color 0.1s; }
.la-open:hover { color: var(--text); border-color: var(--accent); }
.la-cap { text-align: center; font-size: 10px; color: var(--faint); font-family: var(--mono);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.la-icon { display: block; cursor: pointer; }
/* floating windows (la-lib makeFloatingWindow): fixed overlay, no backdrop —
the page underneath keeps every pointer event */
.float-win {
position: fixed;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: 0 10px 34px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.3);
display: flex; flex-direction: column;
overflow: hidden;
min-width: 320px; min-height: 220px;
}
.float-win-head {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px 6px 12px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
cursor: move; user-select: none;
flex-shrink: 0;
}
.float-win-title { flex: 1; min-width: 0; font-size: 11.5px; font-weight: 600; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.float-win-title[data-status="armed"] { color: var(--warn); }
.float-win-title[data-status="run"] { color: var(--ok); }
.float-win-close {
width: 22px; height: 22px; flex-shrink: 0; padding: 0;
background: none; border: 0; border-radius: 4px; color: var(--faint);
font-size: 14px; line-height: 1; cursor: pointer;
}
.float-win-close:hover { background: var(--surface-3); color: var(--err); }
.float-win-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.float-win-resize {
position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
cursor: nwse-resize; z-index: 2;
background: linear-gradient(135deg, transparent 50%, var(--border-strong) 50%);
border-bottom-right-radius: 8px;
}
/* analyzer window contents */
.la-bar { display: flex; align-items: center; gap: 6px; padding: 6px 8px;
border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; }
.la-btn {
background: var(--surface-3); border: 1px solid var(--border-strong); border-radius: 4px;
color: var(--text); font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.la-btn:hover:not(:disabled) { border-color: var(--accent); }
.la-btn:disabled { opacity: 0.4; cursor: default; }
.la-btn.la-arm { color: var(--ok); }
.la-btn.la-arm:hover:not(:disabled) { border-color: var(--ok); }
.la-btn.la-stop { color: var(--err); }
.la-btn.la-clear { color: var(--muted); font-weight: 400; }
.la-lab { font-size: 10.5px; color: var(--muted); white-space: nowrap; }
.la-bar select, .la-bar input[type="number"] {
background: var(--bg); border: 1px solid var(--border-strong); border-radius: 4px;
color: var(--text); font-size: 11px; padding: 2px 4px; outline: none;
}
.la-bar select:focus, .la-bar input[type="number"]:focus { border-color: var(--accent); }
.la-bar input[type="number"] { width: 52px; }
.la-auto { display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px;
color: var(--muted); cursor: pointer; user-select: none; }
.la-auto input { accent-color: var(--accent); }
.la-chans { display: flex; flex-wrap: wrap; gap: 3px; padding: 5px 8px;
border-bottom: 1px solid var(--border); flex-shrink: 0; }
.la-chan {
font-family: var(--mono); font-size: 10px; line-height: 1;
min-width: 24px; height: 18px; padding: 0 4px; text-align: center;
background: var(--bg); color: var(--faint);
border: 1px solid var(--border-strong); border-radius: 4px; cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.la-chan:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.la-chan.on { color: var(--accent-text); border-color: var(--accent); background: var(--accent-soft); }
.la-chan:disabled { opacity: 0.35; cursor: not-allowed; text-decoration: line-through; }
.la-plot { flex: 1; min-height: 0; position: relative; background: var(--bg);
border-bottom: 1px solid var(--border); overflow: hidden;
touch-action: none; outline: none; cursor: crosshair; }
.la-plot:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
.la-cv { display: block; position: absolute; inset: 0; }
.la-hint { position: absolute; inset: 0; display: grid; place-items: center;
color: var(--faint); font-size: 11.5px; font-style: italic; pointer-events: none; }
.la-measure { display: flex; align-items: center; gap: 12px; padding: 3px 10px;
font-family: var(--mono); font-size: 10.5px; color: var(--muted);
border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 22px; }
.la-measure .m-hover { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; }
.la-measure .m-curs { white-space: nowrap; color: var(--faint); }
.la-measure b { font-weight: 700; }
.la-cursor-clear { padding: 1px 7px; font-size: 9.5px; font-weight: 400;
color: var(--faint); flex-shrink: 0; }
.la-cursor-clear:hover { color: var(--text); }
.la-bar-spacer { flex: 1; }
.la-zbtn { padding: 3px 7px; font-size: 13px; font-weight: 400; line-height: 1;
color: var(--muted); }
.la-foot { padding: 4px 10px; font-size: 10.5px; color: var(--muted);
font-family: var(--mono); flex-shrink: 0; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; }
+1
View File
@@ -249,6 +249,7 @@ export class PerisRuntime {
},
cycles: () => rt.emu ? rt.emu.cycles() : 0,
cyclesPerUs: rt.cyclesPerUsCached,
pinLevel: (pin) => rt.sigbus.pinLevel(pin),
subscribe: (pins) => rt.sigbus.subscribe(subFn, pins),
unsubscribe: () => rt.sigbus.unsubscribe(subFn),
markDirty: () => { rt.dirty.add(id); },
+15
View File
@@ -50,6 +50,21 @@ export class Sigbus {
if (this.emu) this.emu.gpio_set_input(pin, high);
}
/**
* Current driven level of one pad straight from gpio_state(): true/false
* while the guest drives it (oe set), null when released. For snapshot
* use (e.g. logic-analyzer channel levels at capture start) — the
* per-batch change stream stays the onPinState path.
*/
pinLevel(pin: number): boolean | null {
if (!this.emu) return null;
const s = this.emu.gpio_state();
const bit = pin % 32;
const oe = ((pin < 32 ? s[2] : s[3]) >>> bit) & 1;
if (!oe) return null;
return (((pin < 32 ? s[0] : s[1]) >>> bit) & 1) !== 0;
}
/** Read gpio_state() and notify subscribers of changed pins. */
sample() {
if (this.subs.size === 0 || !this.emu) return;
+19
View File
@@ -47,6 +47,12 @@ export interface DeviceCtx {
/** Bus/MMIO read (little-endian), e.g. LEDC duty, RMT channel RAM. */
readMem(addr: number, len: number): Uint8Array;
readU32(addr: number): number;
/**
* Current driven level of one pad straight from gpio_state(): true/false
* while the guest drives it (oe), null when released. One wasm read —
* snapshot channels sparingly (capture start), never per batch.
*/
pinLevel(pin: number): boolean | null;
cycles(): number;
/** Emulator clock in cycles per µs (edge timing → real time units). */
readonly cyclesPerUs: number;
@@ -135,6 +141,12 @@ export interface DeviceLike {
export interface ViewHost {
/** Merge a patch into this device's config, re-apply and persist. */
updateConfig(patch: Record<string, unknown>): void;
/**
* Optional: the chip's valid GPIO pins plus the pins claimed by other
* devices (candidate-aware for sharedBus) — for Views that build their
* own multi-pin picker outside the generic config editor.
*/
pinInfo?(): { valid: number[]; used: Set<number> };
}
/** Page-side view of a device plugin. */
@@ -147,6 +159,13 @@ export interface ViewLike {
* The same render(state) calls drive it; `el` is a ~24px container.
*/
rowIcon?(el: HTMLElement): void;
/**
* Optional: teardown when the row is removed or the whole list is
* rebuilt (import). Views that own page-side resources outside their
* row element — e.g. a floating popup window appended to document.body —
* must remove them here, or the DOM leaks a dead window.
*/
destroy?(): void;
}
export interface ViewClass {
new (el: HTMLElement, sendCmd: (cmd: string, params?: Record<string, unknown>) => void, host?: ViewHost): ViewLike;
+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.8.1';
export const PERIS_VERSION = '0.10.0';
+11
View File
@@ -254,6 +254,7 @@ export class PerisPanel {
remove.textContent = '×';
remove.addEventListener('click', () => {
this.config.devices = this.config.devices.filter((d) => d.id !== devCfg.id);
this.views.get(devCfg.id)?.view?.destroy?.(); // popups etc. live outside the row
this.views.delete(devCfg.id);
this.expanded.delete(devCfg.id);
row.remove();
@@ -304,6 +305,13 @@ export class PerisPanel {
view?.setConfig?.(devCfg);
this.renderRows();
},
// multi-pin pickers built by a View (logic-analyzer channels)
// grey out occupied GPIOs with the same candidate-aware set the
// generic editor uses — one source, never a forked copy
pinInfo: () => ({
valid: validPins(this.deps.getChip()),
used: this.usedPins(devCfg.id, meta),
}),
};
const ViewClass: ViewClass | undefined = this.registry?.View(devCfg.type);
if (ViewClass) {
@@ -427,6 +435,9 @@ export class PerisPanel {
private renderAll() {
const list = document.getElementById('peris-cards')!;
list.innerHTML = '';
// Views may own DOM outside their row (floating windows) — give
// every old view a teardown before the rows are discarded.
for (const { view } of this.views.values()) view?.destroy?.();
this.views.clear();
for (const devCfg of this.config.devices) {
const meta = this.registry?.meta(devCfg.type);
+138 -7
View File
@@ -26,14 +26,26 @@
* bit-banged 38 kHz on GPIO5 (envelope from fine-batch edges) and
* carrier-less RMT ch1 symbols on GPIO10 (post-TX channel RAM),
* including repeat frames (M4)
* S12 max7219 on real-world firmware (optional, machine-local): the gun
* 0.1.0 release (ESP32-S3, delay-free bit-bang DI=21/CLK=13/LOAD=14 —
* the tightest real pulses the scheduler must resolve; later releases
* moved to hardware SPI, which gpio_state cannot see, M0) boots through
* display_init()'s 14-word init sequence; the device must decode every
* frame with zero misalignment drops, then follow the control task's
* value pushes. Skipped with a notice when the binary is absent.
* S13 logic-analyzer: armed for a 40 ms guest window on the P4 soft-I2C
* bus (SCL=GPIO1 + SDA=GPIO2, pads legally shared with the two i2c
* slaves — a probe taps, it never drives). The ~20 kHz clock must land
* as alternating SCL edges inside the window, the buffer must freeze
* when the window closes and stay frozen afterwards.
*
* Batches run through rt.batchSize(50000): the M4 scheduler shrinks them
* while the i2c/ir devices hold sampling leases, so loop caps are generous
* rather than marker-exact (the firmware prints ###P1..P4 markers).
* while the i2c/ir/max7219 devices hold sampling leases, so loop caps are
* generous rather than marker-exact (the firmware prints ###P1..P4 markers).
*
* Usage: node tests/m1_smoke.mjs [merged-firmware.bin]
*/
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { pathToFileURL, fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -48,7 +60,7 @@ initSync(readFileSync(path.join(WWW, 'pkg/esp_emu_bg.wasm')));
const { PerisRuntime } = await import(pathToFileURL(path.join(REPO, 'build/core/runtime.js')).href);
const devices = {};
for (const m of ['led', 'button', 'touch-button', 'toggle-switch', 'pwm-led', 'rgb-led', 'ws2812', 'bitbang-i2c-slave', 'ir-nec']) {
for (const m of ['led', 'button', 'touch-button', 'toggle-switch', 'pwm-led', 'rgb-led', 'ws2812', 'bitbang-i2c-slave', 'ir-nec', 'max7219', 'logic-analyzer']) {
devices[m] = await import(pathToFileURL(path.join(REPO, `peris/devices/${m}.js`)).href);
}
const i2cLib = await import(pathToFileURL(path.join(REPO, 'peris/devices/i2c-lib.js')).href);
@@ -150,13 +162,14 @@ await rt.applyConfig({
{ id: 'bme-1', type: 'bitbang-i2c-slave', model: 'bme280', scl: 1, sda: 2, address: 0x76 },
{ id: 'ir-bb', type: 'ir-nec', pin: 5, source: 'gpio' },
{ id: 'ir-rmt', type: 'ir-nec', pin: 10, source: 'rmt', channel: 1 },
{ id: 'la-1', type: 'logic-analyzer', pins: [1, 2], durationMs: 40, name: 'I2C bus LA' },
],
});
const ready1 = msgs.find((m) => m.type === 'peris_ready');
check('S1 config applied (11 devices, I2C bus shared)',
ready1?.ok === true && ready1.devices.length === 11, ready1?.errors.join('; '));
check('S1 config applied (12 devices, I2C bus shared)',
ready1?.ok === true && ready1.devices.length === 12, ready1?.errors.join('; '));
check('S1 initial states pushed',
['led-1', 'btn-1', 'sw-1', 'tch-1', 'pwm-1', 'rgb-1', 'ws-1', 'ee-1', 'bme-1', 'ir-bb', 'ir-rmt']
['led-1', 'btn-1', 'sw-1', 'tch-1', 'pwm-1', 'rgb-1', 'ws-1', 'ee-1', 'bme-1', 'ir-bb', 'ir-rmt', 'la-1']
.every((id) => states().some((s) => s.id === id)));
// S4: toggle switch drives pin level (GPIO0 input)
@@ -383,6 +396,11 @@ for (let i = 0; i < 100000 && !inP4; i++) {
batches++;
}
check('S10/S11 reached the P4 phase', inP4);
// S13: arm the logic analyzer on the soft-I2C bus right at phase entry —
// an immediate 40 ms guest window overlaps the eeprom transaction, whose
// ~20 kHz clock must survive as fine-sampled edges (the LA holds a fine
// lease while recording; the i2c slaves' leases cover the same transfer)
rt.command('la-1', 'arm');
msgs.length = 0;
rt.flushStates(true);
for (let i = 0; i < 2000000 && inP4; i++) {
@@ -475,6 +493,52 @@ check('S11 ir-nec rmt capture: 2 codes + repeat from channel RAM',
&& (rmtLast?.log || []).some((f) => !f.repeat && f.addr === 0xff && f.cmd === 0x22 && f.repeats >= 1),
`count=${rmtLast?.count} log=${JSON.stringify(rmtLast?.log?.map((f) => [f.addr?.toString(16), f.cmd?.toString(16), f.valid, f.repeats]))}`);
// ---- S13: logic analyzer ---------------------------------------------------
const laStates = states().filter((s) => s.id === 'la-1').map((s) => s.state);
const laLast = laStates[laStates.length - 1];
if (process.env.VERBOSE) {
console.log('DEBUG la last:', laLast?.status, 'counts:', JSON.stringify(laLast?.counts),
'capturedUs:', laLast?.capturedUs, 'initial:', JSON.stringify(laLast?.initial));
}
const scl = laLast?.edges?.[0] ?? [];
// consecutive recorded levels may legitimately repeat: the firmware emulates
// open-drain by flipping DIRECTION (oe), and an oe-only change records a Z
// between two identical drives. What must hold: every level CHANGE is a real
// edge (no two equal driven levels back-to-back without a Z between), times
// never go backwards, and everything stays inside the window.
const seq = laLast?.initial?.[0] == null ? [] : [laLast.initial[0]];
for (let k = 1; k < scl.length; k += 2) seq.push(scl[k]);
let alternating = seq.length >= 4;
for (let k = 1; k < seq.length; k++) {
if (seq[k] === seq[k - 1] && seq[k] !== -1) alternating = false;
}
let ascending = scl.length >= 4;
for (let k = 2; k < scl.length; k += 2) if (scl[k] < scl[k - 2]) ascending = false;
const inWindow = scl.length >= 2 && scl[scl.length - 2] <= (laLast?.capturedUs ?? 0) + 1;
check('S13 logic analyzer captured the soft-I2C bus (SCL+SDA edges)',
laLast?.status === 'done' && !laLast?.overflow
&& JSON.stringify(laLast?.channels) === JSON.stringify([1, 2])
&& (laLast?.counts?.[0] ?? 0) > 20 && (laLast?.counts?.[1] ?? 0) > 5,
`SCL ${laLast?.counts?.[0] ?? 0} edges, SDA ${laLast?.counts?.[1] ?? 0}, captured ${laLast?.capturedUs?.toFixed(0)} µs`);
check('S13 SCL waveform is sane (real edges, ascending in-window times)',
alternating && ascending && inWindow,
`alt=${alternating} asc=${ascending} win=${inWindow} initial=${JSON.stringify(laLast?.initial?.[0])}`
+ ` first ${JSON.stringify(scl.slice(0, 6))} last t=${scl.length ? scl[scl.length - 2].toFixed(1) : '-'} µs`);
// the buffer must stay frozen once done
const frozenAt = laLast?.totalEdges;
for (let i = 0; i < 500; i++) {
uart += emu.run_batch(rt.batchSize(50000));
trackMarkers();
rt.onBatchEnd();
batches++;
}
rt.flushStates(true);
const laAfter = states().filter((s) => s.id === 'la-1').pop()?.state;
check('S13 capture frozen after the window closed',
laAfter?.status === 'done' && laAfter?.totalEdges === frozenAt,
`edges ${frozenAt} → ${laAfter?.totalEdges}`);
// S5: conflict rejected
msgs.length = 0;
await rt.applyConfig({
@@ -522,6 +586,73 @@ await rt.applyConfig({ perisVersion: 1, devices: [{ id: 'x', type: 'nope', pin:
const ready3 = msgs.find((m) => m.type === 'peris_ready');
check('S6 unknown type rejected', ready3?.ok === false && ready3.errors.some((e) => e.includes('unknown type')));
// ---- S12: MAX7219 against real-world firmware (separate emulator) --------
//
// The gun 0.1.0 release (ESP32-S3) drives its 7-segment display through a
// firmware bit-bang transport with NO inter-write delays (DIN=21, CLK=13,
// LOAD=14) — the tightest real-world pulses the M4 scheduler must resolve
// (edges ~20 instructions apart; the max7219 lease sizes derive from it).
// Later gun releases moved to hardware SPI, which gpio_state cannot see
// (M0). Boot expectations: display_init() writes shutdown 0, test 0,
// decode 0xFF (Code B), scan-limit 7, intensity 4, eight blank digits,
// shutdown 1 — 14 register words, then the control task pushes values
// once WiFi is up (display_show → display_tick → non-zero digits).
const GUN_FW = 'D:/d3/_git/esp_proj/gun_flag_monster/gun/release/0.1.0/gun-0.1.0-merged.bin';
if (existsSync(GUN_FW)) {
const emu2 = new WasmEmulator('esp32s3');
if (emu2.has_default_rom()) emu2.load_default_rom();
emu2.set_boot_from_rom(true);
emu2.load_firmware(new Uint8Array(readFileSync(GUN_FW)));
const msgs2 = [];
const rt2 = new PerisRuntime();
rt2.post = (m) => msgs2.push(m);
rt2.registry = registry;
rt2.attachEmu(emu2, 'esp32s3');
await rt2.applyConfig({
perisVersion: 1,
devices: [{ id: 'mx-1', type: 'max7219', din: 21, clk: 13, load: 14, name: 'Gun display' }],
});
const mxStates = () => msgs2.filter((m) => m.type === 'peris_state' && m.id === 'mx-1').map((m) => m.state);
const lastState = () => {
const ss = mxStates();
return ss.length ? ss[ss.length - 1] : null;
};
const run2 = (n, done) => {
for (let i = 0; i < n; i++) {
emu2.run_batch(rt2.batchSize(50000));
rt2.onBatchEnd();
if (i % 200 === 0) {
rt2.flushStates(true);
if (done(lastState())) return true;
}
}
rt2.flushStates(true);
return done(lastState());
};
// boot: display_init's 14 words land around 7.2M emulated cycles
const booted = run2(400000, (st) => st && st.frames >= 14 && st.shutdown === false);
let mxLast = lastState();
// value traffic once the control task runs (WiFi bring-up first)
let sawValue = false;
if (booted) sawValue = run2(600000, (st) => st && st.digits.some((d) => d !== 0));
mxLast = lastState();
if (process.env.VERBOSE) {
console.log('DEBUG S12 final state:', JSON.stringify(mxLast));
}
check('S12 gun 0.1.0: display_init decoded (Code B, scan 8, intensity 4, running)',
booted && mxLast?.decodeMode === 0xff && mxLast?.scanLimit === 7
&& mxLast?.intensity === 4 && mxLast?.shutdown === false,
`frames=${mxLast?.frames}`);
check('S12 gun 0.1.0: every frame aligned (no sampling drops)',
mxLast != null && mxLast.frames >= 14 && mxLast.dropped === 0,
`frames=${mxLast?.frames} dropped=${mxLast?.dropped}`);
check('S12 gun 0.1.0: control task pushed display values', sawValue
&& mxLast?.digits?.some((d) => d !== 0),
`digits=${JSON.stringify(mxLast?.digits?.map((d) => d.toString(16)))}`);
} else {
console.log('SKIP S12 (gun 0.1.0 firmware not present on this machine)');
}
const failed = results.filter((r) => !r.ok);
console.log(failed.length ? `\n${failed.length} FAILED` : '\nALL PASS');
process.exit(failed.length ? 1 : 0);
+11
View File
@@ -71,6 +71,17 @@ test('sigbus setInput forwards to emulator', () => {
assert.deepEqual(emu.inputs, [[4, false], [4, true]]);
});
test('sigbus pinLevel reads the driven level, null when released', () => {
// pin8 driven high, pin9 driven low, pin33 driven high (word 1), pin4 released
const emu = new FakeEmu([(1 << 8), (1 << 1), (1 << 8) | (1 << 9), (1 << 1)]);
const bus = new Sigbus(emu);
assert.equal(bus.pinLevel(8), true);
assert.equal(bus.pinLevel(9), false);
assert.equal(bus.pinLevel(33), true);
assert.equal(bus.pinLevel(4), null, 'oe clear → released, level unknowable');
assert.equal(new Sigbus(null).pinLevel(8), null, 'no emulator attached');
});
// ---- chips ----
test('chips tables: counts, absent pins, valid pins', () => {
+1179 -596
View File
File diff suppressed because it is too large. Load diff