From 9cd5863d36d6f1e5b409df68136102ca722a5093 Mon Sep 17 00:00:00 2001 From: darceye Date: Mon, 2 Nov 2026 00:05:48 +0800 Subject: [PATCH] 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. --- AGENT.md | 18 +- AGENT.zh-cn.md | 14 +- README.md | 26 +- README.zh-cn.md | 22 +- docs/ARCHITECTURE.md | 15 +- docs/ARCHITECTURE.zh-cn.md | 11 +- docs/CHANGELOG.md | 92 ++ docs/CHANGELOG.zh-cn.md | 73 ++ docs/DEVICE-API.md | 58 +- docs/DEVICE-API.zh-cn.md | 46 +- docs/ROADMAP.md | 42 + docs/ROADMAP.zh-cn.md | 35 + examples/esp32c3-probe.json | 3 +- examples/esp32s3-gun.json | 7 + package.json | 40 +- peris/devices/la-lib.js | 621 +++++++++++ peris/devices/logic-analyzer.js | 913 ++++++++++++++++ peris/devices/manifest.json | 4 +- peris/devices/max7219-lib.js | 354 ++++++ peris/devices/max7219.js | 267 +++++ peris/peris.css | 121 +++ src/core/runtime.ts | 1 + src/core/sigbus.ts | 15 + src/core/types.ts | 19 + src/core/version.ts | 2 +- src/ui/peris-panel.ts | 11 + tests/m1_smoke.mjs | 145 ++- tests/unit/core.test.mjs | 11 + tests/unit/protocol.test.mjs | 1775 ++++++++++++++++++++----------- 29 files changed, 4114 insertions(+), 647 deletions(-) create mode 100644 examples/esp32s3-gun.json create mode 100644 peris/devices/la-lib.js create mode 100644 peris/devices/logic-analyzer.js create mode 100644 peris/devices/max7219-lib.js create mode 100644 peris/devices/max7219.js diff --git a/AGENT.md b/AGENT.md index 2a2742d..1fca3c4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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 diff --git a/AGENT.zh-cn.md b/AGENT.zh-cn.md index 9c79cee..c3a0941 100644 --- a/AGENT.zh-cn.md +++ b/AGENT.zh-cn.md @@ -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`); diff --git a/README.md b/README.md index 6d387cf..fb708dd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-cn.md b/README.zh-cn.md index c6540e0..961198f 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -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 包的非压缩构建),离线可用。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ea0ee2..f8f85d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`). diff --git a/docs/ARCHITECTURE.zh-cn.md b/docs/ARCHITECTURE.zh-cn.md index ddbf3c2..4e44af1 100644 --- a/docs/ARCHITECTURE.zh-cn.md +++ b/docs/ARCHITECTURE.zh-cn.md @@ -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`)。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 464f16b..d0e2df3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.zh-cn.md b/docs/CHANGELOG.zh-cn.md index f74d29a..ad62455 100644 --- a/docs/CHANGELOG.zh-cn.md +++ b/docs/CHANGELOG.zh-cn.md @@ -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 秒采样阶段,用 diff --git a/docs/DEVICE-API.md b/docs/DEVICE-API.md index 13ef770..219a266 100644 --- a/docs/DEVICE-API.md +++ b/docs/DEVICE-API.md @@ -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) diff --git a/docs/DEVICE-API.zh-cn.md b/docs/DEVICE-API.zh-cn.md index f3b18d1..fc45740 100644 --- a/docs/DEVICE-API.zh-cn.md +++ b/docs/DEVICE-API.zh-cn.md @@ -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` — 共享辅助 + 模块(不是设备) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 93501b3..65cd6a3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/ROADMAP.zh-cn.md b/docs/ROADMAP.zh-cn.md index c287be7..a034712 100644 --- a/docs/ROADMAP.zh-cn.md +++ b/docs/ROADMAP.zh-cn.md @@ -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 diff --git a/examples/esp32c3-probe.json b/examples/esp32c3-probe.json index b806f33..cbfd2fb 100644 --- a/examples/esp32c3-probe.json +++ b/examples/esp32c3-probe.json @@ -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 } ] } diff --git a/examples/esp32s3-gun.json b/examples/esp32s3-gun.json new file mode 100644 index 0000000..b2b810f --- /dev/null +++ b/examples/esp32s3-gun.json @@ -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" } + ] +} diff --git a/package.json b/package.json index c9cb732..7bd4230 100644 --- a/package.json +++ b/package.json @@ -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" + } +} diff --git a/peris/devices/la-lib.js b/peris/devices/la-lib.js new file mode 100644 index 0000000..092846a --- /dev/null +++ b/peris/devices/la-lib.js @@ -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); +} diff --git a/peris/devices/logic-analyzer.js b/peris/devices/logic-analyzer.js new file mode 100644 index 0000000..dd91619 --- /dev/null +++ b/peris/devices/logic-analyzer.js @@ -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 = ''; + } + } +} diff --git a/peris/devices/manifest.json b/peris/devices/manifest.json index cdb8496..99dd120 100644 --- a/peris/devices/manifest.json +++ b/peris/devices/manifest.json @@ -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" } ] } diff --git a/peris/devices/max7219-lib.js b/peris/devices/max7219-lib.js new file mode 100644 index 0000000..0542742 --- /dev/null +++ b/peris/devices/max7219-lib.js @@ -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); +} diff --git a/peris/devices/max7219.js b/peris/devices/max7219.js new file mode 100644 index 0000000..609529c --- /dev/null +++ b/peris/devices/max7219.js @@ -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); + } +} diff --git a/peris/peris.css b/peris/peris.css index 30756a7..597a222 100644 --- a/peris/peris.css +++ b/peris/peris.css @@ -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; } diff --git a/src/core/runtime.ts b/src/core/runtime.ts index 4875ab5..445582f 100644 --- a/src/core/runtime.ts +++ b/src/core/runtime.ts @@ -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); }, diff --git a/src/core/sigbus.ts b/src/core/sigbus.ts index fdc093a..195fab7 100644 --- a/src/core/sigbus.ts +++ b/src/core/sigbus.ts @@ -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; diff --git a/src/core/types.ts b/src/core/types.ts index 9fa2de7..35f5517 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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): 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 }; } /** 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) => void, host?: ViewHost): ViewLike; diff --git a/src/core/version.ts b/src/core/version.ts index b2e8aa2..3a848d0 100644 --- a/src/core/version.ts +++ b/src/core/version.ts @@ -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'; diff --git a/src/ui/peris-panel.ts b/src/ui/peris-panel.ts index dbeace8..7bc6c9f 100644 --- a/src/ui/peris-panel.ts +++ b/src/ui/peris-panel.ts @@ -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); diff --git a/tests/m1_smoke.mjs b/tests/m1_smoke.mjs index 84fb86a..d5df984 100644 --- a/tests/m1_smoke.mjs +++ b/tests/m1_smoke.mjs @@ -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); diff --git a/tests/unit/core.test.mjs b/tests/unit/core.test.mjs index 1196437..fc2818a 100644 --- a/tests/unit/core.test.mjs +++ b/tests/unit/core.test.mjs @@ -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', () => { diff --git a/tests/unit/protocol.test.mjs b/tests/unit/protocol.test.mjs index a72ba38..b37652a 100644 --- a/tests/unit/protocol.test.mjs +++ b/tests/unit/protocol.test.mjs @@ -1,596 +1,1179 @@ -/* - * Unit tests for the M4 protocol layer (no emulator): - * - FineScheduler lease policy (arm/fine/expiry/CPU-sleep escape) - * - I2C slave engine against a synthetic soft master (ACK path, write - * pointer, sequential read, address mismatch, repeated START, STOP) - * - 24C02 / BME280 register models incl. the factory calibration block - * - Bosch compensation (T hand-derived; P/H pinned constants) - * - NEC: envelope assembly, frame decode, RMT symbol-RAM path - * - device plugins: bitbang-i2c-slave end-to-end via the master sim, - * ir-nec gpio capture + rmt RAM decode against fake MMIO - * Run: node --test (see package.json — explicit list for Git Bash) - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { FineScheduler, SLEEP_FACTOR, clampBatch } from '../../build/core/scheduler.js'; -import { validateDeviceModule } from '../../build/core/types.js'; -import { esp32c3 as C3_MMIO } from '../../build/core/mmio/c3.js'; -import * as i2c from '../../peris/devices/bitbang-i2c-slave.js'; -import * as ir from '../../peris/devices/ir-nec.js'; -import * as i2cLib from '../../peris/devices/i2c-lib.js'; -import * as necLib from '../../peris/devices/nec-lib.js'; -import * as ledMod from '../../peris/devices/led.js'; - -// --------------------------------------------------------------------------- -// FineScheduler - -test('scheduler: no leases → coarse passthrough', () => { - const s = new FineScheduler(); - assert.equal(s.nextSize(50000, 0), 50000); - assert.equal(s.nextSize(50000, 123456), 50000); -}); - -test('scheduler: armed lease holds a standing batch size', () => { - const s = new FineScheduler(); - s.arm('dev', 2000); - assert.equal(s.nextSize(50000, 0), 2000); - assert.equal(s.nextSize(50000, 999999), 2000, 'armed survives idle time'); -}); - -test('scheduler: fine beats armed; multiple fine leases take the min', () => { - const s = new FineScheduler(); - s.arm('a', 2000); - s.requestFine('a', 400, 10000); - assert.equal(s.nextSize(50000, 0), 400); - s.requestFine('b', 250, 10000); - assert.equal(s.nextSize(50000, 100), 250); -}); - -test('scheduler: fine lease expires at its cycle deadline, armed stays', () => { - const s = new FineScheduler(); - s.arm('a', 2000); - assert.equal(s.nextSize(50000, 0), 0 + 2000); // also primes lastCycles=0 - s.requestFine('a', 400, 10000); // until cycle 10000 (exclusive) - assert.equal(s.isFine('a', 9999), true); - assert.equal(s.nextSize(50000, 9999), 400); - assert.equal(s.nextSize(50000, 10000), 2000, 'expired → armed'); - assert.equal(s.isFine('a', 10000), false); -}); - -test('scheduler: CPU-sleep fast-forward drops fine leases, not armed', () => { - const s = new FineScheduler(); - s.arm('a', 2000); - s.requestFine('a', 400, 10_000_000); - assert.equal(s.nextSize(50000, 0), 400); - // one batch jumped far beyond size×SLEEP_FACTOR → emulator slept - const size = s.nextSize(50000, 0 + 400 * SLEEP_FACTOR + 1); - assert.equal(size, 2000, 'fine cleared by the sleep escape'); - assert.equal(s.isFine('a'), false); -}); - -test('scheduler: release/reset/clamp', () => { - const s = new FineScheduler(); - s.arm('a', 2000); - s.release('a'); - assert.equal(s.nextSize(50000, 0), 50000); - s.arm('a', 2000); - s.reset(); - assert.equal(s.nextSize(50000, 0), 50000); - assert.equal(clampBatch(1), 20); - assert.equal(clampBatch(1e9), 100000); - assert.equal(clampBatch(1234.7), 1235); -}); - -// --------------------------------------------------------------------------- -// soft master simulator (open-drain idiom, like the probe firmware) - -/** - * Drives an I2cSlaveEngine the way the P4 firmware does: drive-low = - * master asserts, release = the line follows the slave's drive (readLine). - * Data/ownership changes only while SCL is LOW (a level change fed in at - * an SCL-high step would read as START/STOP — exactly what real wiring - * avoids), so releases get their own low-phase step. - */ -function makeMaster(readLine, engine) { - const m = { - start() { - // universal for initial and repeated START: ensure SDA released - // while SCL is low, raise SCL, then drop SDA while SCL is high - engine.step(false, true); - engine.step(true, true); - engine.step(true, false); // SDA falls while SCL high - engine.step(false, false); // SCL low - }, - stop() { - engine.step(false, false); // SCL low, SDA driven low - engine.step(true, false); - engine.step(true, true); // SDA released while SCL high = STOP - }, - writeBit(b) { - engine.step(false, b); // fall + master drives the bit - engine.step(true, b); // rise: slave samples - }, - readBit() { - engine.step(false, readLine()); // fall: slave drives the next bit - engine.step(false, readLine()); // master releases while SCL low - engine.step(true, readLine()); // rise: master samples - return readLine(); - }, - txByte(v) { - for (let i = 7; i >= 0; i--) m.writeBit((v >> i) & 1); - // ACK clock: SCL falls (slave may pull low), master releases - // during the low phase, then samples on the rise - engine.step(false, (v & 1) === 1); - engine.step(false, readLine()); - engine.step(true, readLine()); - return !readLine(); // true = ACK - }, - rxByte(ack) { - let v = 0; - for (let i = 0; i < 8; i++) v = (v << 1) | (m.readBit() ? 1 : 0); - m.writeBit(ack ? 0 : 1); // master ACK = drive low - return v; - }, - }; - return m; -} - -function makeEngine(model, addr = 0x50) { - const events = []; - let line = true; // current level the slave drives (true = released) - const engine = new i2cLib.I2cSlaveEngine(addr, model, (h) => { line = h; }, (e) => events.push(e)); - const m = makeMaster(() => line, engine); - return { engine, m, events, line: () => line }; -} - -test('engine: addressed write lands in the model and is ACKed', () => { - const model = new i2cLib.Eeprom24c02(); - const { m } = makeEngine(model); - m.start(); - assert.equal(m.txByte(0x50 << 1), true, 'address ACKed'); - assert.equal(m.txByte(0x10), true, 'pointer byte ACKed'); - assert.equal(m.txByte(0xA5), true, 'data byte ACKed'); - assert.equal(m.txByte(0x3C), true); - m.stop(); - assert.equal(model.mem[0x10], 0xa5); - assert.equal(model.mem[0x11], 0x3c); -}); - -test('engine: sequential read returns the bytes the slave drove', () => { - const model = new i2cLib.Eeprom24c02(); - model.load(0x40, [0x12, 0x34, 0x56]); - const { m } = makeEngine(model); - m.start(); - assert.equal(m.txByte(0x50 << 1), true); - assert.equal(m.txByte(0x40), true); - m.start(); // repeated START - assert.equal(m.txByte(0x50 << 1 | 1), true, 'read address ACKed'); - assert.equal(m.rxByte(true), 0x12, 'byte 0 (MSB first from the slave)'); - assert.equal(m.rxByte(true), 0x34); - assert.equal(m.rxByte(false), 0x56); // final byte NACKed by the master - m.stop(); -}); - -test('engine: address mismatch → no ACK, bytes ignored', () => { - const model = new i2cLib.Eeprom24c02(); - const { m, engine } = makeEngine(model); - m.start(); - assert.equal(m.txByte(0x51 << 1), false, 'foreign address NACKed'); - assert.equal(m.txByte(0x77), false); - m.stop(); - assert.equal(model.writes, 0); - assert.equal(engine.phase, 'idle'); -}); - -test('engine: STOP releases the SDA drive', () => { - const model = new i2cLib.Eeprom24c02(); - const { m, line } = makeEngine(model); - m.start(); - m.txByte(0x50 << 1); - m.stop(); - assert.equal(line(), true, 'SDA released after STOP'); -}); - -test('engine: BME280 forced measurement advances the signal', () => { - const model = new i2cLib.Bme280Model(); - const { m } = makeEngine(model, 0x76); - const readReg = (reg) => { - m.start(); - m.txByte(0x76 << 1); - m.txByte(reg); - m.start(); - m.txByte(0x76 << 1 | 1); - const v = m.rxByte(false); - m.stop(); - return v; - }; - assert.equal(readReg(0xD0), 0x60, 'chip id'); - assert.equal(readReg(0xF4), 0, 'ctrl_meas reset value'); - // forced trigger: write 0xF4 = 0x25 - m.start(); - m.txByte(0x76 << 1); - m.txByte(0xF4); - m.txByte(0x25); - m.stop(); - assert.equal(model.n, 1); - assert.equal(readReg(0xF4), 0x25, 'shadow register kept'); - // measurement registers decode back to the fake adc triple - const ms = model.measurement(); - const bytes = [readReg(0xF7), readReg(0xF8), readReg(0xF9)]; - const adcP = (bytes[0] << 12) | (bytes[1] << 4) | (bytes[2] >> 4); - assert.equal(adcP, ms.adcP & 0xfffff, 'adc_P round-trip through 0xF7..F9'); -}); - -// --------------------------------------------------------------------------- -// models + compensation - -test('eeprom: erased fill, pointer wrap, load', () => { - const e = new i2cLib.Eeprom24c02(); - assert.equal(e.mem[0], 0xff, 'factory-erased'); - e.setPointer(0xfe); - e.writeByte(1); - e.writeByte(2); - assert.equal(e.mem[0xfe], 1); - assert.equal(e.mem[0xff], 2); - assert.equal(e.ptr, 0, 'wrapped'); - e.reset(); - assert.equal(e.mem[0xfe], 1, 'non-volatile across reset'); -}); - -test('bme280: factory calibration block matches the pinned dig values', () => { - const m = new i2cLib.Bme280Model(); - const d = m.dig; - assert.equal(d.T1, 28100); - assert.equal(d.T2, 23593); - assert.equal(d.P1, 36477); - assert.equal(d.P6, -7); - assert.equal(d.H4, 322, 'H4 low nibble = 0xE5 high nibble'); - assert.equal(d.H5, 383, 'H5 low nibble = 0xE5 low nibble'); - assert.equal(d.H6, 30); - // the probe firmware prints the mod-256 sum of the 33 calib bytes - let sum = 0; - for (let a = 0x88; a <= 0xA1; a++) sum = (sum + m.regs[a]) & 0xff; - for (let a = 0xE1; a <= 0xE7; a++) sum = (sum + m.regs[a]) & 0xff; - assert.equal(sum, 0x9c); -}); - -test('bme280 compensation: T hand-derived, P/H pinned (C reference parity)', () => { - const m = new i2cLib.Bme280Model(); - // T(adc 524288): var1 = ((524288>>3) - 2*28100)*23593>>11 = 107549 … - // hand: 65536-56200=9336; 9336*23593=220264048; >>11=107549; T=(537645+128)>>8 - // but the engine pins the lib's own numbers — verified against the C - // build by tests/m1_smoke.mjs S10 (bit-exact cross-check). - const pins = [ - [0, 524288, 1006500, 30000, 2101, 102772, 48035], - [1, 528384, 1006700, 34096, 2216, 91849, 69917], - [2, 532480, 1006900, 38192, 2331, 80896, 91772], - ]; - for (const [n, rt, rp, rh, T, P, H] of pins) { - m.n = n; - const ms = m.measurement(); - assert.deepEqual([ms.adcT, ms.adcP, ms.adcH], [rt, rp, rh], `raw n=${n}`); - const c = i2cLib.bme280Compensate(rt, rp, rh, m.dig); - assert.deepEqual([c.tCx100, c.pPa, c.hRx1024], [T, P, H], `compensated n=${n}`); - } -}); - -// --------------------------------------------------------------------------- -// NEC decode - -test('nec: standard frame round-trip (LSB-first bytes, inverse fields)', () => { - const f = necLib.decodeNec(necLib.necFrameSegments(0x00FF, 0x11)); - assert.equal(f.repeat, false); - assert.equal(f.addr, 0xff); - assert.equal(f.invAddr, 0x00); - assert.equal(f.cmd, 0x11); - assert.equal(f.invCmd, 0xee); - assert.equal(f.valid, true); - assert.equal(f.addr16, 0x00ff); -}); - -test('nec: 16-bit address frames decode with valid=false when inverses break', () => { - const segs = necLib.necFrameSegments(0x00FF, 0x11); - segs[9].off = necLib.NEC_TIMING.bit1Off; // flip one invAddr bit (0 → 1) - const f = necLib.decodeNec(segs); - assert.notEqual(f, null); - assert.equal(f.valid, false, 'inverse check fails, frame still readable'); - assert.equal(f.invAddr, 0x01); -}); - -test('nec: repeat frame', () => { - const t = necLib.NEC_TIMING; - const f = necLib.decodeNec([ - { on: t.leaderOn, off: t.repeatOff }, - { on: t.burst, off: 0 }, - ]); - assert.equal(f.repeat, true); -}); - -test('nec: garbage and truncated inputs reject', () => { - assert.equal(necLib.decodeNec([]), null); - assert.equal(necLib.decodeNec([{ on: 5000, off: 5000 }, { on: 600, off: 600 }]), null, 'leader too short'); - const short = necLib.necFrameSegments(0x11, 0x22).slice(0, 20); - assert.equal(necLib.decodeNec(short), null, 'missing bits'); - const badBit = necLib.necFrameSegments(0x11, 0x22); - badBit[5].off = 3000; // not 0-ish nor 1-ish - assert.equal(necLib.decodeNec(badBit), null); -}); - -test('nec: carrier edges fold into the right envelope', () => { - // 38 kHz carrier: rising edges 26 µs apart inside bursts - const edges = []; - const burst = (startUs, lenUs) => { - for (let t = 0; t <= lenUs - 13; t += 26) edges.push({ up: true, us: startUs + t }); - }; - const t = necLib.NEC_TIMING; - burst(0, t.leaderOn); - // bit-0 (560 gap), bit-1 (1690 gap), then the stop burst - let at = t.leaderOn + t.leaderOff; - burst(at, t.burst); - at += t.burst + t.bit0Off; - burst(at, t.burst); - at += t.burst + t.bit1Off; - burst(at, t.burst); - const segs = necLib.edgesToSegments(edges); - assert.equal(segs.length, 4); - assert.ok(Math.abs(segs[0].on - (t.leaderOn - 26)) < 30, 'leader ≈ 9 ms'); - assert.ok(Math.abs(segs[0].off - t.leaderOff) < 40, 'leader gap (edge quantised)'); - assert.ok(Math.abs(segs[1].off - t.bit0Off) < 40, 'gap to next burst start'); - assert.ok(Math.abs(segs[2].off - t.bit1Off) < 40); - assert.equal(segs[3].off, 0, 'trailing gap unknown'); -}); - -test('nec: RMT symbol words decode (tick-agnostic via leader normalisation)', () => { - // 1 µs ticks like the probe firmware's RMT ch1 - const t = necLib.NEC_TIMING; - const segs = necLib.necFrameSegments(0x1234, 0xAB).map((s) => ({ - on: Math.round(s.on), off: Math.round(s.off), - })); - const words = segs.map((s) => s.on | (1 << 15) | (s.off << 16)); - words.push(0); // transmit terminator - const conv = necLib.rmtWordsToSegments(words); - assert.notEqual(conv, null); - const f = necLib.decodeNec(conv); - assert.equal(f.addr, 0x34); - assert.equal(f.cmd, 0xab); - assert.equal(f.valid, true); - // scaled ticks (a 0.5 µs resolution) decode identically - const scaled = words.map((w) => { - const d0 = (w & 0x7fff) * 2, d1 = ((w >>> 16) & 0x7fff) * 2; - return d0 | (1 << 15) | (d1 << 16); - }); - const f2 = necLib.decodeNec(necLib.rmtWordsToSegments(scaled)); - assert.deepEqual([f2.addr, f2.cmd, f2.valid], [0x34, 0xab, true]); - // junk RAM rejected - assert.equal(necLib.rmtWordsToSegments(new Uint32Array(48)), null); - const notTx = new Uint32Array(48).fill((5 << 16) | 5); // level0 = 0 - assert.equal(necLib.rmtWordsToSegments(notTx), null); -}); - -// --------------------------------------------------------------------------- -// device plugins - -/** Fake runtime ctx with scheduler-lease + input recording. */ -function makeDevCtx({ regs } = {}) { - const inputs = []; - const leases = { armed: [], fine: [], released: 0 }; - let dirty = 0; - let cycles = 0; - const ctx = { - id: 't', chip: 'esp32c3', gpioCount: 22, mmio: regs ? C3_MMIO : null, - cyclesPerUs: 160, - setCycles: (c) => { cycles = c; }, // test-only knob - setPinInput: (pin, high) => inputs.push([pin, high]), - armSampling: (batch) => leases.armed.push(batch), - fineSampling: (batch, forCycles) => leases.fine.push([batch, forCycles]), - releaseSampling: () => leases.released++, - readMem: (addr, len) => { - const b = new Uint8Array(len); - for (let i = 0; i < len; i += 4) { - const v = regs.get((addr + i) >>> 0) ?? 0; - b[i] = v & 0xff; b[i + 1] = (v >>> 8) & 0xff; - b[i + 2] = (v >>> 16) & 0xff; b[i + 3] = (v >>> 24) & 0xff; - } - return b; - }, - readU32: (addr) => regs.get(addr >>> 0) ?? 0, - cycles: () => cycles, - subscribe: () => {}, unsubscribe: () => {}, - markDirty: () => { dirty++; }, - log: () => {}, - }; - return { ctx, inputs, leases, dirty: () => dirty }; -} - -test('m4 device modules pass meta validation and claim pins', () => { - assert.equal(validateDeviceModule(i2c), null); - assert.equal(validateDeviceModule(ir), null); - assert.deepEqual(i2c.Device.pins({ scl: 1, sda: 2 }), [1, 2]); - assert.deepEqual(ir.Device.pins({ pin: 5, source: 'gpio' }), [5]); - assert.deepEqual(ir.Device.pins({ pin: 5, source: 'rmt' }), [], 'rmt mode claims no GPIO'); -}); - -test('bitbang-i2c-slave: full transaction through the device (ACK + log)', () => { - const { ctx, inputs, leases } = makeDevCtx(); - const dev = new i2c.Device(ctx, { id: 'x', type: 'bitbang-i2c-slave', scl: 1, sda: 2, model: 'eeprom24c02' }); - assert.deepEqual(leases.armed, [i2c.ARM_BATCH], 'standing watch armed'); - // tap the device's engine drive callback so the master can read the - // line, and feed wire levels through onPinState (the sigbus path) so - // the fine-sampling request logic runs too - let line = true; - const origDrive = dev.engine.driveSda; - dev.engine.driveSda = (h) => { line = h; origDrive(h); }; - const m = makeMaster(() => line, { - step: (scl, sda) => { - dev.onPinState(1, { out: !!scl, oe: true, cycles: 0 }); - dev.onPinState(2, { out: sda == null ? line : !!sda, oe: true, cycles: 0 }); - }, - }); - - m.start(); - assert.equal(m.txByte(0x50 << 1), true); - m.txByte(0x10); - m.txByte(0xA5); - m.txByte(0x3C); - m.stop(); - - const st = dev.getState(); - assert.equal(st.stats.starts, 1); - assert.equal(st.stats.stops, 1); - assert.equal(st.model, 'eeprom24c02'); - assert.deepEqual(st.mem.slice(0x10, 0x12), [0xa5, 0x3c]); - assert.equal(st.log.length, 1); - assert.equal(st.log[0].rw, 0); - assert.equal(st.log[0].ptr, 0x10); - assert.deepEqual(st.log[0].bytes, [0xa5, 0x3c]); - assert.ok(inputs.some(([pin, high]) => pin === 2 && high === false), 'ACK pulled SDA low'); - assert.ok(leases.fine.length > 0, 'fine sampling requested during the transfer'); - - dev.onCommand('eeprom-load', { addr: 0x80, bytes: [0xde, 0xad] }); - assert.deepEqual(dev.getState().mem.slice(0x80, 0x82), [0xde, 0xad]); - dev.destroy(); - assert.equal(leases.released, 1); -}); - -test('bitbang-i2c-slave: bme280 state exposes the compensated readout', () => { - const { ctx } = makeDevCtx(); - const dev = new i2c.Device(ctx, { id: 'x', type: 'bitbang-i2c-slave', model: 'bme280', scl: 1, sda: 2, address: 0x76 }); - dev.model.n = 1; - const st = dev.getState(); - assert.equal(st.model, 'bme280'); - assert.equal(st.address, 0x76); - assert.equal(st.bme.n, 1); - assert.equal(st.bme.tCx100, 2216); - assert.equal(st.bme.pPa, 91849); - assert.equal(st.bme.hRx1024, 69917); -}); - -test('ir-nec: gpio edge capture decodes after the line goes quiet', () => { - const { ctx, leases } = makeDevCtx(); - const dev = new ir.Device(ctx, { id: 'x', type: 'ir-nec', pin: 5, source: 'gpio' }); - dev.onBatch(); // resolves the mode - assert.equal(dev.getState().mode, 'gpio'); - - // feed a synthetic bit-banged frame: rising edges every 26 µs in bursts; - // bytes [0xAA, 0x55, 0x37, 0xC8] = a valid addr/cmd + inverse pair - const bytes = [0xaa, 0x55, 0x37, 0xc8]; - let cycles = 0; - const t = necLib.NEC_TIMING; - const burst = (lenUs) => { - for (let u = 0; u <= lenUs - 13; u += 26) { - ctx.setCycles(cycles + u * 160); - dev.onPinState(5, { out: true, oe: true, cycles: cycles + u * 160 }); - } - cycles += lenUs * 160; - }; - burst(t.leaderOn); - cycles += t.leaderOff * 160; - for (let b = 0; b < 32; b++) { - burst(t.burst); - const one = (bytes[b >> 3] >> (b & 7)) & 1; // LSB first per byte - cycles += (one ? t.bit1Off : t.bit0Off) * 160; - } - burst(t.burst); - assert.ok(leases.fine.length > 100, 'fine lease renewed per edge'); - - ctx.setCycles(cycles + 30_000 * 160); // long quiet → flush - dev.onBatch(); - const st = dev.getState(); - assert.equal(st.mode, 'gpio'); - assert.equal(st.count, 1); - assert.equal(st.last.addr, 0xaa); - assert.equal(st.last.cmd, 0x37); - assert.equal(st.last.valid, true); - dev.destroy(); -}); - -test('ir-nec: rmt RAM capture (auto source via pin routing)', () => { - const regs = new Map(); - const { ctx } = makeDevCtx({ regs }); - // route GPIO10 → RMT ch1 (signal 52), leave ch0 unrouted - regs.set(C3_MMIO.gpio.funcOutSelBase + 4 * 10, C3_MMIO.signals.rmtCh1); - const dev = new ir.Device(ctx, { id: 'x', type: 'ir-nec', pin: 10, source: 'auto' }); - dev.onBatch(); - let st = dev.getState(); - assert.equal(st.mode, 'rmt', 'auto picks the routed RMT channel'); - assert.equal(st.channel, 1); - assert.equal(st.pin, 10); - - // load a completed NEC transmission into the channel RAM (1 µs ticks) - const segs = necLib.necFrameSegments(0x00FF, 0x22).map((s) => ({ - on: Math.round(s.on), off: Math.round(s.off), - })); - const r = C3_MMIO.rmt.ch(1); - segs.forEach((s, i) => regs.set(r.mem + 4 * i, s.on | (1 << 15) | (s.off << 16))); - regs.set(r.mem + 4 * segs.length, 0); // terminator - regs.set(r.status, 0); - dev.onBatch(); - st = dev.getState(); - assert.equal(st.count, 1); - assert.equal(st.last.addr, 0xff); - assert.equal(st.last.cmd, 0x22); - assert.equal(st.last.valid, true); - - // repeat frame follows - const rep = [ - { on: 9000, off: 2250 }, { on: 560, off: 0 }, - ]; - rep.forEach((s, i) => regs.set(r.mem + 4 * i, s.on | (1 << 15) | (s.off << 16))); - regs.set(r.mem + 4 * rep.length, 0); - dev.onBatch(); - st = dev.getState(); - assert.equal(st.count, 1, 'repeats do not add frames'); - assert.equal(st.last.repeats, 1); - assert.equal(st.last.cmd, 0x22, 'repeat refers to the previous code'); - - // fresh emulator: binding resets (auto), state clears - dev.onEmuReady(); - st = dev.getState(); - assert.equal(st.count, 0); - assert.equal(st.mode, null); -}); - -// --------------------------------------------------------------------------- -// shared bus wiring (v1 conflict rule + the sharedBus exception) - -import { PerisRuntime } from '../../build/core/runtime.js'; - -test('runtime: sharedBus devices share pins; non-bus devices still conflict', async () => { - const rt = new PerisRuntime(); - const msgs = []; - rt.post = (m) => msgs.push(m); - rt.registry = { - has: (t) => ['led', 'i2c'].includes(t), - meta: (t) => (t === 'led' ? ledMod.Device : i2c.Device), - View: () => null, - types: () => [], - errors: [], - }; - // two I2C slaves on one SCL/SDA pair: legal wiring - await rt.applyConfig({ - perisVersion: 1, - devices: [ - { id: 'ee', type: 'i2c', model: 'eeprom24c02', scl: 1, sda: 2 }, - { id: 'bm', type: 'i2c', model: 'bme280', scl: 1, sda: 2, address: 0x76 }, - ], - }); - let ready = msgs.find((m) => m.type === 'peris_ready'); - assert.equal(ready.ok, true, ready.errors.join('; ')); - assert.equal(rt.has('ee') && rt.has('bm'), true); - - // a plain LED on the same pin is still a conflict - msgs.length = 0; - await rt.applyConfig({ - perisVersion: 1, - devices: [ - { id: 'ee', type: 'i2c', scl: 1, sda: 2 }, - { id: 'led', type: 'led', pin: 1 }, - ], - }); - ready = msgs.find((m) => m.type === 'peris_ready'); - assert.equal(ready.ok, false); - assert.ok(ready.errors.some((e) => e.includes('already used')), ready.errors.join('; ')); - // v1 semantics: the conflict is reported (yellow bar), the instance - // still runs — the page's option builder is the real gate - assert.equal(rt.has('led'), true); -}); +/* + * Unit tests for the M4 protocol layer (no emulator): + * - FineScheduler lease policy (arm/fine/expiry/CPU-sleep escape) + * - I2C slave engine against a synthetic soft master (ACK path, write + * pointer, sequential read, address mismatch, repeated START, STOP) + * - 24C02 / BME280 register models incl. the factory calibration block + * - Bosch compensation (T hand-derived; P/H pinned constants) + * - NEC: envelope assembly, frame decode, RMT symbol-RAM path + * - device plugins: bitbang-i2c-slave end-to-end via the master sim, + * ir-nec gpio capture + rmt RAM decode against fake MMIO + * - MAX7219: Code B font, register model (datasheet tables 2-8), wire + * shift engine (MSB-first, LOAD latch, misaligned frames), daisy-chain + * word picking, the max7219 plugin end-to-end through the sigbus path + * Run: node --test (see package.json — explicit list for Git Bash) + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FineScheduler, SLEEP_FACTOR, clampBatch } from '../../build/core/scheduler.js'; +import { validateDeviceModule } from '../../build/core/types.js'; +import { esp32c3 as C3_MMIO } from '../../build/core/mmio/c3.js'; +import * as i2c from '../../peris/devices/bitbang-i2c-slave.js'; +import * as ir from '../../peris/devices/ir-nec.js'; +import * as mx from '../../peris/devices/max7219.js'; +import * as i2cLib from '../../peris/devices/i2c-lib.js'; +import * as necLib from '../../peris/devices/nec-lib.js'; +import * as mxLib from '../../peris/devices/max7219-lib.js'; +import * as ledMod from '../../peris/devices/led.js'; + +// --------------------------------------------------------------------------- +// FineScheduler + +test('scheduler: no leases → coarse passthrough', () => { + const s = new FineScheduler(); + assert.equal(s.nextSize(50000, 0), 50000); + assert.equal(s.nextSize(50000, 123456), 50000); +}); + +test('scheduler: armed lease holds a standing batch size', () => { + const s = new FineScheduler(); + s.arm('dev', 2000); + assert.equal(s.nextSize(50000, 0), 2000); + assert.equal(s.nextSize(50000, 999999), 2000, 'armed survives idle time'); +}); + +test('scheduler: fine beats armed; multiple fine leases take the min', () => { + const s = new FineScheduler(); + s.arm('a', 2000); + s.requestFine('a', 400, 10000); + assert.equal(s.nextSize(50000, 0), 400); + s.requestFine('b', 250, 10000); + assert.equal(s.nextSize(50000, 100), 250); +}); + +test('scheduler: fine lease expires at its cycle deadline, armed stays', () => { + const s = new FineScheduler(); + s.arm('a', 2000); + assert.equal(s.nextSize(50000, 0), 0 + 2000); // also primes lastCycles=0 + s.requestFine('a', 400, 10000); // until cycle 10000 (exclusive) + assert.equal(s.isFine('a', 9999), true); + assert.equal(s.nextSize(50000, 9999), 400); + assert.equal(s.nextSize(50000, 10000), 2000, 'expired → armed'); + assert.equal(s.isFine('a', 10000), false); +}); + +test('scheduler: CPU-sleep fast-forward drops fine leases, not armed', () => { + const s = new FineScheduler(); + s.arm('a', 2000); + s.requestFine('a', 400, 10_000_000); + assert.equal(s.nextSize(50000, 0), 400); + // one batch jumped far beyond size×SLEEP_FACTOR → emulator slept + const size = s.nextSize(50000, 0 + 400 * SLEEP_FACTOR + 1); + assert.equal(size, 2000, 'fine cleared by the sleep escape'); + assert.equal(s.isFine('a'), false); +}); + +test('scheduler: release/reset/clamp', () => { + const s = new FineScheduler(); + s.arm('a', 2000); + s.release('a'); + assert.equal(s.nextSize(50000, 0), 50000); + s.arm('a', 2000); + s.reset(); + assert.equal(s.nextSize(50000, 0), 50000); + assert.equal(clampBatch(1), 20); + assert.equal(clampBatch(1e9), 100000); + assert.equal(clampBatch(1234.7), 1235); +}); + +// --------------------------------------------------------------------------- +// soft master simulator (open-drain idiom, like the probe firmware) + +/** + * Drives an I2cSlaveEngine the way the P4 firmware does: drive-low = + * master asserts, release = the line follows the slave's drive (readLine). + * Data/ownership changes only while SCL is LOW (a level change fed in at + * an SCL-high step would read as START/STOP — exactly what real wiring + * avoids), so releases get their own low-phase step. + */ +function makeMaster(readLine, engine) { + const m = { + start() { + // universal for initial and repeated START: ensure SDA released + // while SCL is low, raise SCL, then drop SDA while SCL is high + engine.step(false, true); + engine.step(true, true); + engine.step(true, false); // SDA falls while SCL high + engine.step(false, false); // SCL low + }, + stop() { + engine.step(false, false); // SCL low, SDA driven low + engine.step(true, false); + engine.step(true, true); // SDA released while SCL high = STOP + }, + writeBit(b) { + engine.step(false, b); // fall + master drives the bit + engine.step(true, b); // rise: slave samples + }, + readBit() { + engine.step(false, readLine()); // fall: slave drives the next bit + engine.step(false, readLine()); // master releases while SCL low + engine.step(true, readLine()); // rise: master samples + return readLine(); + }, + txByte(v) { + for (let i = 7; i >= 0; i--) m.writeBit((v >> i) & 1); + // ACK clock: SCL falls (slave may pull low), master releases + // during the low phase, then samples on the rise + engine.step(false, (v & 1) === 1); + engine.step(false, readLine()); + engine.step(true, readLine()); + return !readLine(); // true = ACK + }, + rxByte(ack) { + let v = 0; + for (let i = 0; i < 8; i++) v = (v << 1) | (m.readBit() ? 1 : 0); + m.writeBit(ack ? 0 : 1); // master ACK = drive low + return v; + }, + }; + return m; +} + +function makeEngine(model, addr = 0x50) { + const events = []; + let line = true; // current level the slave drives (true = released) + const engine = new i2cLib.I2cSlaveEngine(addr, model, (h) => { line = h; }, (e) => events.push(e)); + const m = makeMaster(() => line, engine); + return { engine, m, events, line: () => line }; +} + +test('engine: addressed write lands in the model and is ACKed', () => { + const model = new i2cLib.Eeprom24c02(); + const { m } = makeEngine(model); + m.start(); + assert.equal(m.txByte(0x50 << 1), true, 'address ACKed'); + assert.equal(m.txByte(0x10), true, 'pointer byte ACKed'); + assert.equal(m.txByte(0xA5), true, 'data byte ACKed'); + assert.equal(m.txByte(0x3C), true); + m.stop(); + assert.equal(model.mem[0x10], 0xa5); + assert.equal(model.mem[0x11], 0x3c); +}); + +test('engine: sequential read returns the bytes the slave drove', () => { + const model = new i2cLib.Eeprom24c02(); + model.load(0x40, [0x12, 0x34, 0x56]); + const { m } = makeEngine(model); + m.start(); + assert.equal(m.txByte(0x50 << 1), true); + assert.equal(m.txByte(0x40), true); + m.start(); // repeated START + assert.equal(m.txByte(0x50 << 1 | 1), true, 'read address ACKed'); + assert.equal(m.rxByte(true), 0x12, 'byte 0 (MSB first from the slave)'); + assert.equal(m.rxByte(true), 0x34); + assert.equal(m.rxByte(false), 0x56); // final byte NACKed by the master + m.stop(); +}); + +test('engine: address mismatch → no ACK, bytes ignored', () => { + const model = new i2cLib.Eeprom24c02(); + const { m, engine } = makeEngine(model); + m.start(); + assert.equal(m.txByte(0x51 << 1), false, 'foreign address NACKed'); + assert.equal(m.txByte(0x77), false); + m.stop(); + assert.equal(model.writes, 0); + assert.equal(engine.phase, 'idle'); +}); + +test('engine: STOP releases the SDA drive', () => { + const model = new i2cLib.Eeprom24c02(); + const { m, line } = makeEngine(model); + m.start(); + m.txByte(0x50 << 1); + m.stop(); + assert.equal(line(), true, 'SDA released after STOP'); +}); + +test('engine: BME280 forced measurement advances the signal', () => { + const model = new i2cLib.Bme280Model(); + const { m } = makeEngine(model, 0x76); + const readReg = (reg) => { + m.start(); + m.txByte(0x76 << 1); + m.txByte(reg); + m.start(); + m.txByte(0x76 << 1 | 1); + const v = m.rxByte(false); + m.stop(); + return v; + }; + assert.equal(readReg(0xD0), 0x60, 'chip id'); + assert.equal(readReg(0xF4), 0, 'ctrl_meas reset value'); + // forced trigger: write 0xF4 = 0x25 + m.start(); + m.txByte(0x76 << 1); + m.txByte(0xF4); + m.txByte(0x25); + m.stop(); + assert.equal(model.n, 1); + assert.equal(readReg(0xF4), 0x25, 'shadow register kept'); + // measurement registers decode back to the fake adc triple + const ms = model.measurement(); + const bytes = [readReg(0xF7), readReg(0xF8), readReg(0xF9)]; + const adcP = (bytes[0] << 12) | (bytes[1] << 4) | (bytes[2] >> 4); + assert.equal(adcP, ms.adcP & 0xfffff, 'adc_P round-trip through 0xF7..F9'); +}); + +// --------------------------------------------------------------------------- +// models + compensation + +test('eeprom: erased fill, pointer wrap, load', () => { + const e = new i2cLib.Eeprom24c02(); + assert.equal(e.mem[0], 0xff, 'factory-erased'); + e.setPointer(0xfe); + e.writeByte(1); + e.writeByte(2); + assert.equal(e.mem[0xfe], 1); + assert.equal(e.mem[0xff], 2); + assert.equal(e.ptr, 0, 'wrapped'); + e.reset(); + assert.equal(e.mem[0xfe], 1, 'non-volatile across reset'); +}); + +test('bme280: factory calibration block matches the pinned dig values', () => { + const m = new i2cLib.Bme280Model(); + const d = m.dig; + assert.equal(d.T1, 28100); + assert.equal(d.T2, 23593); + assert.equal(d.P1, 36477); + assert.equal(d.P6, -7); + assert.equal(d.H4, 322, 'H4 low nibble = 0xE5 high nibble'); + assert.equal(d.H5, 383, 'H5 low nibble = 0xE5 low nibble'); + assert.equal(d.H6, 30); + // the probe firmware prints the mod-256 sum of the 33 calib bytes + let sum = 0; + for (let a = 0x88; a <= 0xA1; a++) sum = (sum + m.regs[a]) & 0xff; + for (let a = 0xE1; a <= 0xE7; a++) sum = (sum + m.regs[a]) & 0xff; + assert.equal(sum, 0x9c); +}); + +test('bme280 compensation: T hand-derived, P/H pinned (C reference parity)', () => { + const m = new i2cLib.Bme280Model(); + // T(adc 524288): var1 = ((524288>>3) - 2*28100)*23593>>11 = 107549 … + // hand: 65536-56200=9336; 9336*23593=220264048; >>11=107549; T=(537645+128)>>8 + // but the engine pins the lib's own numbers — verified against the C + // build by tests/m1_smoke.mjs S10 (bit-exact cross-check). + const pins = [ + [0, 524288, 1006500, 30000, 2101, 102772, 48035], + [1, 528384, 1006700, 34096, 2216, 91849, 69917], + [2, 532480, 1006900, 38192, 2331, 80896, 91772], + ]; + for (const [n, rt, rp, rh, T, P, H] of pins) { + m.n = n; + const ms = m.measurement(); + assert.deepEqual([ms.adcT, ms.adcP, ms.adcH], [rt, rp, rh], `raw n=${n}`); + const c = i2cLib.bme280Compensate(rt, rp, rh, m.dig); + assert.deepEqual([c.tCx100, c.pPa, c.hRx1024], [T, P, H], `compensated n=${n}`); + } +}); + +// --------------------------------------------------------------------------- +// NEC decode + +test('nec: standard frame round-trip (LSB-first bytes, inverse fields)', () => { + const f = necLib.decodeNec(necLib.necFrameSegments(0x00FF, 0x11)); + assert.equal(f.repeat, false); + assert.equal(f.addr, 0xff); + assert.equal(f.invAddr, 0x00); + assert.equal(f.cmd, 0x11); + assert.equal(f.invCmd, 0xee); + assert.equal(f.valid, true); + assert.equal(f.addr16, 0x00ff); +}); + +test('nec: 16-bit address frames decode with valid=false when inverses break', () => { + const segs = necLib.necFrameSegments(0x00FF, 0x11); + segs[9].off = necLib.NEC_TIMING.bit1Off; // flip one invAddr bit (0 → 1) + const f = necLib.decodeNec(segs); + assert.notEqual(f, null); + assert.equal(f.valid, false, 'inverse check fails, frame still readable'); + assert.equal(f.invAddr, 0x01); +}); + +test('nec: repeat frame', () => { + const t = necLib.NEC_TIMING; + const f = necLib.decodeNec([ + { on: t.leaderOn, off: t.repeatOff }, + { on: t.burst, off: 0 }, + ]); + assert.equal(f.repeat, true); +}); + +test('nec: garbage and truncated inputs reject', () => { + assert.equal(necLib.decodeNec([]), null); + assert.equal(necLib.decodeNec([{ on: 5000, off: 5000 }, { on: 600, off: 600 }]), null, 'leader too short'); + const short = necLib.necFrameSegments(0x11, 0x22).slice(0, 20); + assert.equal(necLib.decodeNec(short), null, 'missing bits'); + const badBit = necLib.necFrameSegments(0x11, 0x22); + badBit[5].off = 3000; // not 0-ish nor 1-ish + assert.equal(necLib.decodeNec(badBit), null); +}); + +test('nec: carrier edges fold into the right envelope', () => { + // 38 kHz carrier: rising edges 26 µs apart inside bursts + const edges = []; + const burst = (startUs, lenUs) => { + for (let t = 0; t <= lenUs - 13; t += 26) edges.push({ up: true, us: startUs + t }); + }; + const t = necLib.NEC_TIMING; + burst(0, t.leaderOn); + // bit-0 (560 gap), bit-1 (1690 gap), then the stop burst + let at = t.leaderOn + t.leaderOff; + burst(at, t.burst); + at += t.burst + t.bit0Off; + burst(at, t.burst); + at += t.burst + t.bit1Off; + burst(at, t.burst); + const segs = necLib.edgesToSegments(edges); + assert.equal(segs.length, 4); + assert.ok(Math.abs(segs[0].on - (t.leaderOn - 26)) < 30, 'leader ≈ 9 ms'); + assert.ok(Math.abs(segs[0].off - t.leaderOff) < 40, 'leader gap (edge quantised)'); + assert.ok(Math.abs(segs[1].off - t.bit0Off) < 40, 'gap to next burst start'); + assert.ok(Math.abs(segs[2].off - t.bit1Off) < 40); + assert.equal(segs[3].off, 0, 'trailing gap unknown'); +}); + +test('nec: RMT symbol words decode (tick-agnostic via leader normalisation)', () => { + // 1 µs ticks like the probe firmware's RMT ch1 + const t = necLib.NEC_TIMING; + const segs = necLib.necFrameSegments(0x1234, 0xAB).map((s) => ({ + on: Math.round(s.on), off: Math.round(s.off), + })); + const words = segs.map((s) => s.on | (1 << 15) | (s.off << 16)); + words.push(0); // transmit terminator + const conv = necLib.rmtWordsToSegments(words); + assert.notEqual(conv, null); + const f = necLib.decodeNec(conv); + assert.equal(f.addr, 0x34); + assert.equal(f.cmd, 0xab); + assert.equal(f.valid, true); + // scaled ticks (a 0.5 µs resolution) decode identically + const scaled = words.map((w) => { + const d0 = (w & 0x7fff) * 2, d1 = ((w >>> 16) & 0x7fff) * 2; + return d0 | (1 << 15) | (d1 << 16); + }); + const f2 = necLib.decodeNec(necLib.rmtWordsToSegments(scaled)); + assert.deepEqual([f2.addr, f2.cmd, f2.valid], [0x34, 0xab, true]); + // junk RAM rejected + assert.equal(necLib.rmtWordsToSegments(new Uint32Array(48)), null); + const notTx = new Uint32Array(48).fill((5 << 16) | 5); // level0 = 0 + assert.equal(necLib.rmtWordsToSegments(notTx), null); +}); + +// --------------------------------------------------------------------------- +// device plugins + +/** Fake runtime ctx with scheduler-lease + input recording. `levels` maps + * pin → driven level for ctx.pinLevel (the logic-analyzer start snapshot). */ +function makeDevCtx({ regs, levels = {} } = {}) { + const inputs = []; + const leases = { armed: [], fine: [], released: 0 }; + const logs = []; + let dirty = 0; + let cycles = 0; + const ctx = { + id: 't', chip: 'esp32c3', gpioCount: 22, mmio: regs ? C3_MMIO : null, + cyclesPerUs: 160, + setCycles: (c) => { cycles = c; }, // test-only knob + setPinInput: (pin, high) => inputs.push([pin, high]), + armSampling: (batch) => leases.armed.push(batch), + fineSampling: (batch, forCycles) => leases.fine.push([batch, forCycles]), + releaseSampling: () => leases.released++, + readMem: (addr, len) => { + const b = new Uint8Array(len); + for (let i = 0; i < len; i += 4) { + const v = regs.get((addr + i) >>> 0) ?? 0; + b[i] = v & 0xff; b[i + 1] = (v >>> 8) & 0xff; + b[i + 2] = (v >>> 16) & 0xff; b[i + 3] = (v >>> 24) & 0xff; + } + return b; + }, + readU32: (addr) => regs.get(addr >>> 0) ?? 0, + cycles: () => cycles, + pinLevel: (pin) => (pin in levels ? levels[pin] : null), + subscribe: () => {}, unsubscribe: () => {}, + markDirty: () => { dirty++; }, + log: (text, level) => logs.push([text, level]), + }; + return { ctx, inputs, leases, logs, dirty: () => dirty }; +} + +test('m4 device modules pass meta validation and claim pins', () => { + assert.equal(validateDeviceModule(i2c), null); + assert.equal(validateDeviceModule(ir), null); + assert.deepEqual(i2c.Device.pins({ scl: 1, sda: 2 }), [1, 2]); + assert.deepEqual(ir.Device.pins({ pin: 5, source: 'gpio' }), [5]); + assert.deepEqual(ir.Device.pins({ pin: 5, source: 'rmt' }), [], 'rmt mode claims no GPIO'); +}); + +test('bitbang-i2c-slave: full transaction through the device (ACK + log)', () => { + const { ctx, inputs, leases } = makeDevCtx(); + const dev = new i2c.Device(ctx, { id: 'x', type: 'bitbang-i2c-slave', scl: 1, sda: 2, model: 'eeprom24c02' }); + assert.deepEqual(leases.armed, [i2c.ARM_BATCH], 'standing watch armed'); + // tap the device's engine drive callback so the master can read the + // line, and feed wire levels through onPinState (the sigbus path) so + // the fine-sampling request logic runs too + let line = true; + const origDrive = dev.engine.driveSda; + dev.engine.driveSda = (h) => { line = h; origDrive(h); }; + const m = makeMaster(() => line, { + step: (scl, sda) => { + dev.onPinState(1, { out: !!scl, oe: true, cycles: 0 }); + dev.onPinState(2, { out: sda == null ? line : !!sda, oe: true, cycles: 0 }); + }, + }); + + m.start(); + assert.equal(m.txByte(0x50 << 1), true); + m.txByte(0x10); + m.txByte(0xA5); + m.txByte(0x3C); + m.stop(); + + const st = dev.getState(); + assert.equal(st.stats.starts, 1); + assert.equal(st.stats.stops, 1); + assert.equal(st.model, 'eeprom24c02'); + assert.deepEqual(st.mem.slice(0x10, 0x12), [0xa5, 0x3c]); + assert.equal(st.log.length, 1); + assert.equal(st.log[0].rw, 0); + assert.equal(st.log[0].ptr, 0x10); + assert.deepEqual(st.log[0].bytes, [0xa5, 0x3c]); + assert.ok(inputs.some(([pin, high]) => pin === 2 && high === false), 'ACK pulled SDA low'); + assert.ok(leases.fine.length > 0, 'fine sampling requested during the transfer'); + + dev.onCommand('eeprom-load', { addr: 0x80, bytes: [0xde, 0xad] }); + assert.deepEqual(dev.getState().mem.slice(0x80, 0x82), [0xde, 0xad]); + dev.destroy(); + assert.equal(leases.released, 1); +}); + +test('bitbang-i2c-slave: bme280 state exposes the compensated readout', () => { + const { ctx } = makeDevCtx(); + const dev = new i2c.Device(ctx, { id: 'x', type: 'bitbang-i2c-slave', model: 'bme280', scl: 1, sda: 2, address: 0x76 }); + dev.model.n = 1; + const st = dev.getState(); + assert.equal(st.model, 'bme280'); + assert.equal(st.address, 0x76); + assert.equal(st.bme.n, 1); + assert.equal(st.bme.tCx100, 2216); + assert.equal(st.bme.pPa, 91849); + assert.equal(st.bme.hRx1024, 69917); +}); + +test('ir-nec: gpio edge capture decodes after the line goes quiet', () => { + const { ctx, leases } = makeDevCtx(); + const dev = new ir.Device(ctx, { id: 'x', type: 'ir-nec', pin: 5, source: 'gpio' }); + dev.onBatch(); // resolves the mode + assert.equal(dev.getState().mode, 'gpio'); + + // feed a synthetic bit-banged frame: rising edges every 26 µs in bursts; + // bytes [0xAA, 0x55, 0x37, 0xC8] = a valid addr/cmd + inverse pair + const bytes = [0xaa, 0x55, 0x37, 0xc8]; + let cycles = 0; + const t = necLib.NEC_TIMING; + const burst = (lenUs) => { + for (let u = 0; u <= lenUs - 13; u += 26) { + ctx.setCycles(cycles + u * 160); + dev.onPinState(5, { out: true, oe: true, cycles: cycles + u * 160 }); + } + cycles += lenUs * 160; + }; + burst(t.leaderOn); + cycles += t.leaderOff * 160; + for (let b = 0; b < 32; b++) { + burst(t.burst); + const one = (bytes[b >> 3] >> (b & 7)) & 1; // LSB first per byte + cycles += (one ? t.bit1Off : t.bit0Off) * 160; + } + burst(t.burst); + assert.ok(leases.fine.length > 100, 'fine lease renewed per edge'); + + ctx.setCycles(cycles + 30_000 * 160); // long quiet → flush + dev.onBatch(); + const st = dev.getState(); + assert.equal(st.mode, 'gpio'); + assert.equal(st.count, 1); + assert.equal(st.last.addr, 0xaa); + assert.equal(st.last.cmd, 0x37); + assert.equal(st.last.valid, true); + dev.destroy(); +}); + +test('ir-nec: rmt RAM capture (auto source via pin routing)', () => { + const regs = new Map(); + const { ctx } = makeDevCtx({ regs }); + // route GPIO10 → RMT ch1 (signal 52), leave ch0 unrouted + regs.set(C3_MMIO.gpio.funcOutSelBase + 4 * 10, C3_MMIO.signals.rmtCh1); + const dev = new ir.Device(ctx, { id: 'x', type: 'ir-nec', pin: 10, source: 'auto' }); + dev.onBatch(); + let st = dev.getState(); + assert.equal(st.mode, 'rmt', 'auto picks the routed RMT channel'); + assert.equal(st.channel, 1); + assert.equal(st.pin, 10); + + // load a completed NEC transmission into the channel RAM (1 µs ticks) + const segs = necLib.necFrameSegments(0x00FF, 0x22).map((s) => ({ + on: Math.round(s.on), off: Math.round(s.off), + })); + const r = C3_MMIO.rmt.ch(1); + segs.forEach((s, i) => regs.set(r.mem + 4 * i, s.on | (1 << 15) | (s.off << 16))); + regs.set(r.mem + 4 * segs.length, 0); // terminator + regs.set(r.status, 0); + dev.onBatch(); + st = dev.getState(); + assert.equal(st.count, 1); + assert.equal(st.last.addr, 0xff); + assert.equal(st.last.cmd, 0x22); + assert.equal(st.last.valid, true); + + // repeat frame follows + const rep = [ + { on: 9000, off: 2250 }, { on: 560, off: 0 }, + ]; + rep.forEach((s, i) => regs.set(r.mem + 4 * i, s.on | (1 << 15) | (s.off << 16))); + regs.set(r.mem + 4 * rep.length, 0); + dev.onBatch(); + st = dev.getState(); + assert.equal(st.count, 1, 'repeats do not add frames'); + assert.equal(st.last.repeats, 1); + assert.equal(st.last.cmd, 0x22, 'repeat refers to the previous code'); + + // fresh emulator: binding resets (auto), state clears + dev.onEmuReady(); + st = dev.getState(); + assert.equal(st.count, 0); + assert.equal(st.mode, null); +}); + +// --------------------------------------------------------------------------- +// MAX7219 (datasheet tables 2–8) + +test('max7219: Code B font matches datasheet table 5', () => { + const f = mxLib.CODE_B; + assert.deepEqual( + [f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7]], + [0x7e, 0x30, 0x6d, 0x79, 0x33, 0x5b, 0x5f, 0x70], + ); + assert.deepEqual( + [f[8], f[9], f[0xa], f[0xb], f[0xc], f[0xd], f[0xe], f[0xf]], + [0x7f, 0x7b, 0x01, 0x4f, 0x37, 0x0e, 0x67, 0x00], + ); + // the reference driver's no-decode digit glyphs use the same segment + // mapping (D7=dp D6=a … D0=g) — cross-check its font constants + assert.deepEqual([f[0], f[2], f[8]], [0x7e, 0x6d, 0x7f]); +}); + +test('max7219: register model, power-on defaults, masking', () => { + const m = new mxLib.Max7219Model(); + assert.equal(m.shutdown, true, 'powers up in shutdown'); + assert.equal(m.scanLimit, 0, 'scanning one digit'); + assert.equal(m.decodeMode, 0); + assert.equal(m.intensity, 0); + assert.deepEqual(m.digits, new Array(8).fill(0)); + assert.equal(m.write(0x01, 0x42), true, 'digit write changes state'); + assert.equal(m.write(0x01, 0x42), false, 'same byte = no visible change'); + assert.equal(m.write(0x00, 0xff), false, 'no-op register ignored'); + assert.equal(m.writes, 3, 'no-ops still counted'); + m.write(0x0a, 0x2c); // intensity keeps the low nibble + assert.equal(m.intensity, 0x0c); + m.write(0x0b, 0x55); // scan limit keeps 3 bits + assert.equal(m.scanLimit, 5); + m.write(0x0c, 0x01); + assert.equal(m.shutdown, false); + m.write(0x0f, 0x01); + assert.equal(m.test, true); + assert.equal(m.brightness(), 13 / 32, 'PWM duty (intensity+1)/32'); + assert.equal(m.digitSegments(0), 0x42, 'no decode: raw byte'); + m.write(0x09, 0x01); // Code B decode for digit 0 only + assert.equal(m.digitSegments(0), mxLib.CODE_B[2], '0x42 decodes low nibble'); + m.write(0x01, 0x82); // 2 + DP (D7 independent of the decoder) + assert.equal(m.digitSegments(0), mxLib.CODE_B[2] | 0x80); +}); + +test('max7219: display derivation (decode, scan limit, shutdown, test)', () => { + const base = { digits: new Array(8).fill(0), decodeMode: 0, scanLimit: 7, shutdown: false, test: false, intensity: 3 }; + base.digits[0] = 0x05; base.digits[1] = 0x8f; base.digits[7] = 0xff; + let v = mxLib.displayFromSnapshot(base); + assert.equal(v.segs[0], 0x05, 'no decode: raw byte is the segment byte'); + assert.equal(v.segs[1], 0x8f); + assert.equal(v.segs[7], 0xff); + base.decodeMode = 0x03; // decode digits 0 and 1 + v = mxLib.displayFromSnapshot(base); + assert.equal(v.segs[0], mxLib.CODE_B[5], 'digit 0 decoded'); + assert.equal(v.segs[1], mxLib.CODE_B[0xf] | 0x80, 'digit 1: blank glyph + DP'); + base.scanLimit = 0; // only digit 0 scanned + v = mxLib.displayFromSnapshot(base); + assert.equal(v.segs[7], 0, 'beyond scan limit: dark'); + assert.notEqual(v.segs[0], 0); + base.shutdown = true; + v = mxLib.displayFromSnapshot(base); + assert.deepEqual(v.segs, new Array(8).fill(0), 'shutdown blanks, registers kept'); + base.test = true; base.shutdown = false; + v = mxLib.displayFromSnapshot(base); + assert.deepEqual(v.segs, new Array(8).fill(0xff), 'display test lights everything'); +}); + +test('max7219 wire: MSB-first shift, LOAD latch, misaligned frames dropped', () => { + const got = []; + const w = new mxLib.Max7219Wire((words, bits) => got.push({ words, bits })); + // the first LOAD sighting learns the idle level (consumed, not a latch) + assert.equal(w.step(false, false, true), true); + assert.deepEqual(got, []); + assert.equal(w.busy, false); + // one frame, two words, mode-0 order (DIN settles while CLK low) + w.step(false, false, false); // LOAD fall: frame start + assert.equal(w.busy, true); + const bit = (b) => { w.step(false, b, false); w.step(true, b, false); }; + // 0xAF16 then 0x00FF, MSB first + for (let i = 15; i >= 0; i--) bit((0xaf16 >> i) & 1); + for (let i = 15; i >= 0; i--) bit((0x00ff >> i) & 1); + w.step(false, false, true); // LOAD rise: latch + assert.deepEqual(got, [{ words: [0xaf16, 0x00ff], bits: 32 }]); + assert.equal(w.busy, false); + // reference-driver order: DIN changes after the rise, before the fall + got.length = 0; + w.step(false, false, false); + for (let i = 15; i >= 0; i--) { + const b = (0x1234 >> i) & 1; + w.step(true, b, false); // rise samples the CURRENT din + w.step(false, b, false); + w.step(false, b, false); // (din still stable around the next rise) + } + w.step(false, false, true); + assert.deepEqual(got, [{ words: [0x1234], bits: 16 }]); + // 17 clocks = misaligned: frame dropped, not garbled + got.length = 0; + w.step(false, false, false); + for (let i = 0; i < 17; i++) { w.step(true, i === 0, false); w.step(false, i === 0, false); } + w.step(false, false, true); + assert.deepEqual(got, [{ words: null, bits: 17 }]); + // reset forgets levels — and a fresh emulator's pads sit low, so the + // first CLK sighting at high IS a genuine rising edge (the sigbus + // reports changes only; the pre-change level was the real sampled low) + w.reset(); + assert.equal(w.step(true, false, false), true, 'null→high CLK counts as a rise'); +}); + +test('max7219 wire: fresh-emulator bootstrap (idle CLK is never announced)', () => { + const got = []; + const w = new mxLib.Max7219Wire((words, bits) => got.push({ words, bits })); + // pads default low at reset: the driver's init write announces LOAD=1, + // CLK and DIN sit at their idle levels and produce no events — the + // first CLK event of the first frame is a rising edge from null + assert.equal(w.step(null, null, true), true, 'LOAD init write consumed'); + assert.deepEqual(got, [], 'learned idle LOAD is not a latch'); + w.step(null, null, false); // LOAD falls: frame start + for (let i = 15; i >= 0; i--) { + const b = (0x0b07 >> i) & 1; + w.step(true, b, false); + w.step(false, b, false); + } + w.step(false, false, true); // LOAD rises: latch + assert.deepEqual(got, [{ words: [0x0b07], bits: 16 }]); + // an empty LOAD pulse (fall + rise, no clocks) is not a frame + got.length = 0; + w.step(false, false, false); + w.step(false, false, true); + assert.deepEqual(got, []); +}); + +test('max7219: daisy-chain word picking (stream runs away from the master)', () => { + const words = [0x0100, 0x0200, 0x0300]; + assert.equal(mxLib.pickChainWord(words, 1, 0), 0x0300, 'single device: last word'); + assert.equal(mxLib.pickChainWord(words, 3, 0), 0x0300, 'pos0 (DIN side): last word'); + assert.equal(mxLib.pickChainWord(words, 3, 1), 0x0200); + assert.equal(mxLib.pickChainWord(words, 3, 2), 0x0100, 'far end: first word'); + assert.equal(mxLib.pickChainWord([0x0100], 2, 0), 0x0100, 'short frame feeds pos0'); + assert.equal(mxLib.pickChainWord([0x0100], 2, 1), null, 'pos1 keeps its old word'); + assert.equal(mxLib.pickChainWord(words, 2, 5), null, 'pos beyond the chain'); +}); + +/** Soft master driving the plugin through onPinState (the sigbus path). */ +function makeMaxMaster(devs, pins, order = 'after-rise') { + const feed = (pin, high) => { + for (const d of devs) d.onPinState(pin, { out: high, oe: true, cycles: 0 }); + }; + feed(pins.load, true); // the firmware's init writes teach idle levels + feed(pins.clk, false); + feed(pins.din, false); + return { + frame(words) { + feed(pins.load, false); + for (const word of words) { + for (let i = 15; i >= 0; i--) { + const b = (word >> i) & 1; + if (order === 'after-rise') { + feed(pins.din, b); + feed(pins.clk, true); + feed(pins.clk, false); + } else { // classic mode-0: DIN settles while CLK is low + feed(pins.clk, false); + feed(pins.din, b); + feed(pins.clk, true); + } + } + } + feed(pins.load, true); + }, + /** nBits clock rises then LOAD rise — a frame an edge-sampling + * window could not fully resolve (bit count not a multiple of 16). */ + garbage(nBits) { + feed(pins.clk, false); // mode0 frames leave CLK high at the end + feed(pins.load, false); + for (let i = 0; i < nBits; i++) { + feed(pins.din, i & 1); + feed(pins.clk, true); + feed(pins.clk, false); + } + feed(pins.load, true); + }, + }; +} + +const GUN_PINS = { din: 21, clk: 13, load: 14 }; + +test('max7219 device: meta validation and pins', () => { + assert.equal(validateDeviceModule(mx), null); + assert.deepEqual(mx.Device.pins({ din: 21, clk: 13, load: 14 }), [21, 13, 14]); + assert.equal(mx.Device.sharedBus, true); +}); + +test('max7219 device: decodes reg_init + digit frame, both bit orders', () => { + for (const order of ['after-rise', 'mode0']) { + const { ctx, leases } = makeDevCtx(); + const dev = new mx.Device(ctx, { id: 'x', type: 'max7219', din: 21, clk: 13, load: 14 }); + assert.deepEqual(leases.armed, [mx.ARM_BATCH], 'standing watch armed'); + const m = makeMaxMaster([dev], GUN_PINS, order); + // the reference driver's reg_init: shutdown, test off, no decode, + // scan 8 digits, intensity 4, normal operation + for (const [a, d] of [[0x0c, 0x00], [0x0f, 0x00], [0x09, 0x00], [0x0b, 0x07], [0x0a, 0x04], [0x0c, 0x01]]) { + m.frame([(a << 8) | d]); + } + // "CAL " mirrored on both halves: digits 7..4 = C A L blank, 3..0 + // same. Single-device drivers pulse LOAD per register write (the + // reference driver does one 16-bit LOAD frame per write_reg). + for (const [a, d] of [[1, 0x00], [2, 0x0e], [3, 0x77], [4, 0x4e], + [5, 0x00], [6, 0x0e], [7, 0x77], [8, 0x4e]]) { + m.frame([(a << 8) | d]); + } + const st = dev.getState(); + assert.equal(st.shutdown, false); + assert.equal(st.scanLimit, 7); + assert.equal(st.intensity, 4); + assert.equal(st.decodeMode, 0); + assert.equal(st.frames, 14, '6 init words + 8 digit words, one frame each'); + assert.equal(st.dropped, 0); + assert.deepEqual(st.digits, [0x00, 0x0e, 0x77, 0x4e, 0x00, 0x0e, 0x77, 0x4e]); + assert.ok(leases.fine.length > 0, 'fine sampling requested during frames'); + // a misaligned frame (17 clocks) is dropped, the display untouched + m.garbage(17); + const st2 = dev.getState(); + assert.equal(st2.dropped, 1); + assert.deepEqual(st2.digits, [0x00, 0x0e, 0x77, 0x4e, 0x00, 0x0e, 0x77, 0x4e]); + assert.equal(leases.released, 0); + dev.destroy(); + assert.equal(leases.released, 1); + } +}); + +test('max7219 device: two chained parts split one frame, shared pins', () => { + const { ctx } = makeDevCtx(); + const near = new mx.Device(ctx, { id: 'n', type: 'max7219', din: 21, clk: 13, load: 14, chain: 2, pos: 0 }); + const far = new mx.Device(ctx, { id: 'f', type: 'max7219', din: 21, clk: 13, load: 14, chain: 2, pos: 1 }); + const m = makeMaxMaster([near, far], GUN_PINS); + // first word travels through to the far part, last word stays near + m.frame([(0x01 << 8) | 0xaa, (0x02 << 8) | 0xbb]); + assert.deepEqual(near.getState().digits, [0, 0xbb, 0, 0, 0, 0, 0, 0], 'pos0 latched the last word (digit1)'); + assert.deepEqual(far.getState().digits, [0xaa, 0, 0, 0, 0, 0, 0, 0], 'pos1 latched the first word (digit0)'); + // onEmuReady: power-on defaults again + near.onEmuReady(); + assert.deepEqual(near.getState().digits, new Array(8).fill(0)); + assert.equal(near.getState().shutdown, true); + assert.equal(near.getState().frames, 0); +}); + +// --------------------------------------------------------------------------- +// shared bus wiring (v1 conflict rule + the sharedBus exception) + +import { PerisRuntime } from '../../build/core/runtime.js'; + +test('runtime: sharedBus devices share pins; non-bus devices still conflict', async () => { + const rt = new PerisRuntime(); + const msgs = []; + rt.post = (m) => msgs.push(m); + rt.registry = { + has: (t) => ['led', 'i2c', 'max7219'].includes(t), + meta: (t) => (t === 'led' ? ledMod.Device : t === 'i2c' ? i2c.Device : mx.Device), + View: () => null, + types: () => [], + errors: [], + }; + // two I2C slaves on one SCL/SDA pair: legal wiring + await rt.applyConfig({ + perisVersion: 1, + devices: [ + { id: 'ee', type: 'i2c', model: 'eeprom24c02', scl: 1, sda: 2 }, + { id: 'bm', type: 'i2c', model: 'bme280', scl: 1, sda: 2, address: 0x76 }, + ], + }); + let ready = msgs.find((m) => m.type === 'peris_ready'); + assert.equal(ready.ok, true, ready.errors.join('; ')); + assert.equal(rt.has('ee') && rt.has('bm'), true); + + // a MAX7219 daisy chain on one DIN/CLK/LOAD triple: legal wiring too + msgs.length = 0; + await rt.applyConfig({ + perisVersion: 1, + devices: [ + { id: 'mx0', type: 'max7219', din: 21, clk: 13, load: 14, chain: 2, pos: 0 }, + { id: 'mx1', type: 'max7219', din: 21, clk: 13, load: 14, chain: 2, pos: 1 }, + ], + }); + ready = msgs.find((m) => m.type === 'peris_ready'); + assert.equal(ready.ok, true, ready.errors.join('; ')); + + // a plain LED on the same pin is still a conflict + msgs.length = 0; + await rt.applyConfig({ + perisVersion: 1, + devices: [ + { id: 'ee', type: 'i2c', scl: 1, sda: 2 }, + { id: 'led', type: 'led', pin: 1 }, + ], + }); + ready = msgs.find((m) => m.type === 'peris_ready'); + assert.equal(ready.ok, false); + assert.ok(ready.errors.some((e) => e.includes('already used')), ready.errors.join('; ')); + // v1 semantics: the conflict is reported (yellow bar), the instance + // still runs — the page's option builder is the real gate + assert.equal(rt.has('led'), true); +}); + +// --------------------------------------------------------------------------- +// Logic analyzer (0.10.0): la-lib readout math + the capture state machine + +import * as la from '../../peris/devices/logic-analyzer.js'; +import * as laLib from '../../peris/devices/la-lib.js'; + +test('la-lib: parsePins cleans config input and never shares arrays', () => { + assert.deepEqual(laLib.parsePins([3, 1, 1, 'x', -2, 3.5]), [3, 1]); + assert.deepEqual(laLib.parsePins('nope'), []); + assert.deepEqual(laLib.parsePins(null), []); + const capped = []; + for (let i = 0; i < 20; i++) capped.push(i); + assert.equal(laLib.parsePins(capped).length, laLib.MAX_CHANNELS); + const shared = []; + assert.notEqual(laLib.parsePins(shared), shared, 'fresh array — the config merge is shallow'); +}); + +test('la-lib: levelAt walks the flat edge array with unknown head', () => { + const flat = [5, 1, 10, 0, 20, -1]; + assert.equal(laLib.levelAt(flat, null, 4), null, 'before the first edge: unknown'); + assert.equal(laLib.levelAt(flat, null, 5), 1); + assert.equal(laLib.levelAt(flat, null, 12), 0); + assert.equal(laLib.levelAt(flat, null, 99), -1, 'Z holds to the end'); + assert.equal(laLib.levelAt(flat, 1, 3), 1, 'initial level before any edge'); + assert.equal(laLib.levelAt([], 0, 42), 0); +}); + +test('la-lib: axis ticks, formatting, fit span and the pixel transform', () => { + assert.equal(laLib.niceTickStep(1000, 6), 200); + assert.equal(laLib.niceTickStep(50000, 6), 10000); + assert.equal(laLib.niceTickStep(0.5, 5), 0.1); + assert.equal(laLib.fmtUs(840), '840 µs'); + assert.equal(laLib.fmtUs(12500), '12.5 ms'); + assert.equal(laLib.fmtUs(2500000), '2.50 s'); + assert.equal(laLib.fmtUs(NaN), '—'); + // fit: full window while running, captured extent once done + assert.equal(laLib.fitSpanUs({ status: 'run', windowUs: 50000, capturedUs: 1000, edges: [[]] }), 50000); + assert.equal(laLib.fitSpanUs({ status: 'done', windowUs: 50000, capturedUs: 5000, edges: [[4000, 1]] }), 5000); + assert.equal(laLib.fitSpanUs({ status: 'done', windowUs: 50000, capturedUs: 0, edges: [[]] }), 50000, 'empty capture fits the window'); + const { usToX, xToUs } = laLib.waveformXform(356, 56, 1000); + assert.equal(usToX(0), 56); + assert.equal(usToX(1000), 356); + assert.equal(usToX(5000), 356, 'clamped'); + assert.ok(Math.abs(xToUs(usToX(123)) - 123) < 1e-9); +}); + +test('logic-analyzer: meta validation, pin claim, sharedBus probe semantics', () => { + assert.equal(validateDeviceModule(la), null); + assert.deepEqual(la.Device.pins({ pins: [1, 2] }), [1, 2]); + assert.deepEqual(la.Device.pins({ pins: [] }), []); + assert.equal(la.Device.sharedBus, true, 'a probe taps lines other bus devices drive'); +}); + +test('logic-analyzer: immediate capture records edges, freezes at window end', () => { + const { ctx, leases } = makeDevCtx({ levels: { 1: true, 2: false } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1, 2], durationMs: 10 }); + let st = dev.getState(); + assert.equal(st.status, 'idle'); + assert.deepEqual(st.channels, [1, 2]); + assert.equal(st.edges.length, 2); + + dev.onCommand('arm'); + st = dev.getState(); + assert.equal(st.status, 'run', 'immediate trigger starts at once'); + assert.equal(st.triggered, false); + assert.deepEqual(leases.armed, [la.ARM_BATCH]); + assert.deepEqual(leases.fine[leases.fine.length - 1], [la.FINE_BATCHES.normal, la.FINE_HOLD_CYCLES]); + + // edges on both channels, µs from the capture start (160 cycles/µs) + dev.onPinState(1, { out: false, oe: true, cycles: 1600 }); // 10 µs, falling + dev.onPinState(2, { out: true, oe: true, cycles: 4800 }); // 30 µs, rising + dev.onPinState(1, { out: true, oe: false, cycles: 8000 }); // 50 µs, released → Z + st = dev.getState(); + assert.equal(st.status, 'run'); + assert.deepEqual(st.edges, [[], []], 'run pushes carry no waveform (progress only)'); + assert.deepEqual(st.counts, [2, 1], 'live per-channel edge counts'); + assert.deepEqual(st.initial, [null, null]); + assert.ok(leases.fine.length >= 4, 'fine lease renewed per edge'); + + ctx.setCycles(10 * 1000 * 160 + 1); // past the 10 ms guest window + dev.onBatch(); + st = dev.getState(); + assert.equal(st.status, 'done'); + assert.equal(st.triggered, false); + assert.ok(Math.abs(st.capturedUs - 10000) < 1); + assert.deepEqual(st.initial, [1, 0], 'levels snapshotted at capture start'); + assert.deepEqual(st.edges[0], [10, 0, 50, -1]); + assert.deepEqual(st.edges[1], [30, 1]); + assert.equal(st.totalEdges, 3); + assert.ok(leases.released >= 1, 'leases dropped when done without auto'); + + // frozen: late edges are ignored + dev.onPinState(2, { out: false, oe: true, cycles: 20 * 1000 * 160 }); + assert.equal(dev.getState().totalEdges, 3); + dev.destroy(); + assert.equal(leases.released >= 2, true); +}); + +test('logic-analyzer: channel trigger — direction, wrong channel, t0 at the edge', () => { + const { ctx, leases } = makeDevCtx({ levels: { 4: false, 5: false } }); + const dev = new la.Device(ctx, { + id: 'la', type: 'logic-analyzer', pins: [4, 5], trigCh: 0, trigEdge: 'rise', durationMs: 5, + }); + dev.onCommand('arm'); + assert.equal(dev.getState().status, 'armed'); + assert.deepEqual(leases.armed, [la.ARM_BATCH], 'standing watch while waiting'); + + dev.onPinState(4, { out: false, oe: true, cycles: 1000 }); // falling: ignored + dev.onPinState(5, { out: true, oe: true, cycles: 1500 }); // wrong channel + assert.equal(dev.getState().status, 'armed'); + + dev.onPinState(4, { out: true, oe: true, cycles: 16000 }); // rising on GPIO4 + let st = dev.getState(); + assert.equal(st.status, 'run'); + assert.equal(st.triggered, true); + assert.equal(st.capturedUs >= 0, true); + // the trigger edge itself is the first sample at t=0; pre-edge level = 0 + ctx.setCycles(16000 + 5 * 1000 * 160 + 1); + dev.onBatch(); + st = dev.getState(); + assert.equal(st.status, 'done'); + assert.deepEqual(st.edges[0], [0, 1]); + assert.equal(st.initial[0], 0, 'trigger channel pre-edge level is the complement'); + assert.equal(st.initial[1], 0, 'other channels snapshot via pinLevel'); + dev.destroy(); +}); + +test('logic-analyzer: falling trigger counts a release-to-Z as an edge', () => { + const { ctx } = makeDevCtx({ levels: { 7: true } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [7], trigCh: 0, trigEdge: 'fall' }); + dev.onCommand('arm'); + dev.onPinState(7, { out: true, oe: true, cycles: 100 }); + assert.equal(dev.getState().status, 'armed', 'rising ignored'); + dev.onPinState(7, { out: false, oe: false, cycles: 200 }); // released → Z + assert.equal(dev.getState().status, 'run', 'Z counts as falling (open-drain idiom)'); + assert.deepEqual(dev.getState().counts, [1]); + dev.destroy(); +}); + +test('logic-analyzer: stop / clear / auto re-arm / auto+immediate warns', () => { + // stop mid-run keeps the partial capture + { + const { ctx, leases } = makeDevCtx({ levels: { 1: true } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1], durationMs: 1000 }); + dev.onCommand('arm'); + dev.onPinState(1, { out: false, oe: true, cycles: 160000 }); // 1 ms in + ctx.setCycles(1600000); // ~9 ms of the 1000 ms window + dev.onCommand('stop'); + const st = dev.getState(); + assert.equal(st.status, 'done'); + assert.ok(st.capturedUs > 0 && st.capturedUs < 1000000, 'partial capture kept'); + assert.equal(st.totalEdges, 1); + assert.ok(leases.released >= 1); + // clear → idle, empty + dev.onCommand('clear'); + const st2 = dev.getState(); + assert.equal(st2.status, 'idle'); + assert.equal(st2.totalEdges, 0); + assert.equal(st2.capturedUs, 0); + dev.destroy(); + } + // stop while merely armed disarms without touching data + { + const { ctx } = makeDevCtx(); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [2], trigCh: 0 }); + dev.onCommand('arm'); + assert.equal(dev.getState().status, 'armed'); + dev.onCommand('stop'); + assert.equal(dev.getState().status, 'idle'); + dev.destroy(); + } + // auto re-arm with a trigger: after done → armed again + { + const { ctx, leases } = makeDevCtx({ levels: { 3: false } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [3], trigCh: 0, durationMs: 1 }); + dev.onCommand('arm', { auto: true }); + dev.onPinState(3, { out: true, oe: true, cycles: 1000 }); + ctx.setCycles(1000 + 1000 * 160 + 1); + dev.onBatch(); + const st = dev.getState(); + assert.equal(st.status, 'armed', 'auto re-armed after the capture'); + assert.equal(st.auto, true); + assert.deepEqual(st.edges[0], [0, 1], 'finished waveform still readable'); + assert.ok(leases.armed.length >= 2, 'watch re-armed'); + dev.destroy(); + } + // auto + immediate: the capture completes, auto just does not re-arm + // (an immediate restart would overwrite the buffer unseen) — warn once + { + const { ctx, logs, leases } = makeDevCtx(); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1], durationMs: 1 }); + dev.onCommand('arm', { auto: true }); + assert.equal(dev.getState().status, 'run'); + ctx.setCycles(1000 * 160 + 1); + dev.onBatch(); + const st = dev.getState(); + assert.equal(st.status, 'done', 'capture itself finishes normally'); + assert.equal(st.auto, false); + assert.ok(logs.some(([t, l]) => l === 'warn' && t.includes('auto re-arm'))); + assert.ok(leases.released >= 1); + dev.destroy(); + } +}); + +test('logic-analyzer: buffer overflow freezes the capture flagged', () => { + const { ctx } = makeDevCtx({ levels: { 1: true } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1], durationMs: 1000 }); + dev.onCommand('arm'); + let cyc = 0; + for (let i = 1; i <= la.MAX_EDGES + 1; i++) { + cyc += 40; // 0.25 µs apart — raw unit feed, no scheduler in between + dev.onPinState(1, { out: i & 1, oe: true, cycles: cyc }); + if (i % 500 === 0 && dev.status === 'done') break; // probe the field, not the full getState + } + const st = dev.getState(); + assert.equal(st.status, 'done'); + assert.equal(st.overflow, true); + // the edge that trips the cap lands too, so at most one over + assert.ok(st.totalEdges >= la.MAX_EDGES && st.totalEdges <= la.MAX_EDGES + 1, + `totalEdges ${st.totalEdges} vs cap ${la.MAX_EDGES}`); + dev.destroy(); +}); + +test('logic-analyzer: onEmuReady resets waveform history', () => { + const { ctx } = makeDevCtx({ levels: { 1: true } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1], durationMs: 1 }); + dev.onCommand('arm'); + dev.onPinState(1, { out: false, oe: true, cycles: 1000 }); + ctx.setCycles(1000 * 160 + 1); + dev.onBatch(); + assert.equal(dev.getState().status, 'done'); + dev.onEmuReady(); + const st = dev.getState(); + assert.equal(st.status, 'idle'); + assert.equal(st.totalEdges, 0); + assert.equal(st.overflow, false); + dev.destroy(); +}); + +test('la-lib: view model — zoom anchors the cursor, clamps span and t0', () => { + const st = { status: 'done', windowUs: 50000, capturedUs: 50000, edges: [[49000, 1]] }; + // zoom in 2× around t=25 ms: the anchor stays at the same fraction + let v = laLib.zoomAt({ t0: 0, spanUs: 50000 }, 25000, 0.5, st); + assert.equal(v.spanUs, 25000); + assert.equal(v.t0, 12500); + // zooming out past the data clamps at maxSpan (extent × 1.2) + v = laLib.zoomAt(v, 20000, 50, st); + assert.equal(v.spanUs, laLib.viewBounds(st).maxSpan); + // t0 never scrolls past the bounds + v = laLib.clampView({ t0: 1e9, spanUs: 1000 }, st); + assert.ok(v.t0 <= laLib.viewBounds(st).tMax - 1000 + 1e-9); + // the zoom floor + v = laLib.zoomAt({ t0: 0, spanUs: 1 }, 0.5, 1e-9, st); + assert.ok(v.spanUs >= laLib.MIN_SPAN_US); + // an empty state still yields a zoomable (non-degenerate) view + const b = laLib.viewBounds(null); + assert.ok(b.maxSpan > 0 && b.tMax > b.tMin); + assert.equal(laLib.clampView({ t0: 0, spanUs: 0 }, null).spanUs, laLib.MIN_SPAN_US); +}); + +test('la-lib: pulseAround finds the run containing the cursor', () => { + const flat = [5, 1, 10, 0, 20, 1]; + assert.deepEqual(laLib.pulseAround(flat, null, 7), + { level: 1, tStart: 5, tEnd: 10, widthUs: 5 }); + assert.deepEqual(laLib.pulseAround(flat, null, 3), + { level: null, tStart: null, tEnd: 5, widthUs: null }, 'unknown head'); + assert.equal(laLib.pulseAround(flat, 0, 3).level, 0, 'initial level before the first edge'); + const open = laLib.pulseAround(flat, null, 25); + assert.deepEqual(open, { level: 1, tStart: 20, tEnd: null, widthUs: null }); + assert.equal(laLib.pulseAround([8, -1], null, 9).level, -1, 'Z runs measurable'); +}); + +test('la-lib: pwmOf derives period, duty and frequency from rising edges', () => { + // 50% square wave, 10 µs period → 100 kHz + const sq = [0, 1, 5, 0, 10, 1, 15, 0, 20, 1]; + const m = laLib.pwmOf(sq, 7); + assert.equal(m.periodUs, 10); + assert.equal(m.highUs, 5); + assert.equal(m.duty, 0.5); + assert.equal(m.freqHz, 100000); + // hovering before the first rise still measures cycle 0 + assert.equal(laLib.pwmOf(sq, -5).periodUs, 10); + // a high run with no fall inside the cycle = 100% duty + const stuck = [0, 1, 10, 1, 20, 1]; + assert.equal(laLib.pwmOf(stuck, 12).duty, 1); + // fewer than two rising edges: no cycle, no numbers + assert.equal(laLib.pwmOf([0, 1, 5, 0], 2), null); + assert.equal(laLib.pwmOf([], 2), null); +}); + +test('la-lib: levelChar and fmtHz formatting', () => { + assert.equal(laLib.levelChar(1), '1'); + assert.equal(laLib.levelChar(0), '0'); + assert.equal(laLib.levelChar(-1), 'Z'); + assert.equal(laLib.levelChar(null), '?'); + assert.equal(laLib.fmtHz(156.25), '156.3 Hz'); + assert.equal(laLib.fmtHz(100000), '100.00 kHz'); + assert.equal(laLib.fmtHz(1.5e6), '1.500 MHz'); + assert.equal(laLib.fmtHz(0), '—'); +}); + +test('logic-analyzer: capId bumps per capture (View zoom resets on it)', () => { + const { ctx } = makeDevCtx({ levels: { 1: true } }); + const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1], durationMs: 1 }); + assert.equal(dev.getState().capId, 0); + dev.onCommand('arm'); + assert.equal(dev.getState().capId, 1); + ctx.setCycles(1000 * 160 + 1); + dev.onBatch(); // done + dev.onCommand('arm'); // immediate again + assert.equal(dev.getState().capId, 2); + dev.onEmuReady(); + assert.equal(dev.getState().capId, 0, 'fresh emulator resets history identity'); + dev.destroy(); +});