MAX7219 display + logic analyzer with Saleae-style floating window (0.9.0 + 0.10.0)
0.9.0 - MAX7219 Display (max7219.js v1.0.0, engine in max7219-lib.js): - 8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus (hardware SPI stays invisible, M0). Level-driven shift engine samples DIN on the CLK rising edge, latches MSB-first 16-bit words on the LOAD rising edge; misaligned frames are dropped and counted in `dropped`, never garbled. Datasheet register model: Code B decode (independent DP), intensity PWM, scan limit, shutdown/display test, power-on defaults. 8x8 matrix and 7-segment Views auto-selected from the decode-mode register; daisy chains share the three pads (sharedBus) and latch their chain position. Lease sizing measured against the tightest real firmware (gun 0.1.0, delay-free pulses ~20 instructions apart): fine at the scheduler floor 20, arm 60. Smoke S12 boots the gun 0.1.0 release (machine-local, skips when absent) with zero drops; examples/esp32s3-gun.json wires it. 0.10.0 - Logic analyzer (logic-analyzer.js v1.0.0, logic in la-lib.js): - Multi-channel GPIO waveform capture: up to 8 pads, channel+edge or immediate trigger, 1-1000 ms guest-time window, auto re-arm, Z shown when the pad is released; rides the M4 scheduler (arm 1000, fine lease per resolution preset 20/100/300, renewed per edge); 20 000-edge buffer freezes with an overflow flag. sharedBus makes it a pure tap on other bus devices' pads (smoke S13 arms it on the P4 soft-I2C bus: SCL 1950 / SDA 352 alternating edges in a 40 ms window, frozen afterwards). - Floating analyzer window: draggable, resizable, non-modal popup (position fixed, no backdrop - the page underneath keeps every pointer event; geometry clamped + persisted). One drawWaveform painter feeds the popup, the details mini-preview and the row icon (AGENT 3.8); channels picked from a GPIO grid, occupied pins greyed. - Saleae-style plot interactions: wheel zoom anchored at the cursor (1 ns floor), left/middle-drag pan, double-click/F fit; hover measurement shades the pulse and reads level + width + period + frequency + PWM duty (rise-to-rise cycle); A/B cursors with delta-t and 1/delta-t; lane selection + </> buttons and arrow keys jump view-centre onto the previous/next edge; lane gutter shows the right-edge level; a fresh capture re-fits the view and drops cursors (capId). - Device API v1 additions (backwards compatible): ctx.pinLevel(pin) snapshot read of the driven level; ViewHost.pinInfo() for multi-pin pickers; View.destroy() teardown on row removal / list rebuild so Views owning DOM outside their row (floating windows) clean it up. - Tests: la-lib units (parsePins, levelAt, axis ticks, fit span, pixel transform, zoom/pan view model, pulseAround, pwmOf, fmtHz/levelChar), the capture state machine via fake ctx (immediate/triggered starts, direction filtering incl. Z-as-falling, window end, stop/clear/auto, buffer overflow, onEmuReady reset, capId), sigbus.pinLevel against a fake emulator; smoke S13 + S1 grows to 12 devices; examples/esp32c3-probe.json wires the analyzer on the I2C pair. - Docs (both languages): CHANGELOG 0.9.0 + 0.10.0 + device version table, ROADMAP shipped sections, DEVICE-API instrument section + new ctx/host/ View members, ARCHITECTURE GUI checklist + floating-window note, README capability table + tree, AGENT rule 3.11 + GUI-smoke bug-table rows; version synced to 0.10.0 in all three places.
This commit is contained in:
1 parent
1da996e657
commit
9cd5863d36
29 files changed
+4114
-647
No files matched your search
@@ -137,6 +137,13 @@ and `docs/FEASIBILITY.md` (M0 measurements).
|
||||
the runtime wired-ANDs shared pads' input drives (gpio_set_input is a
|
||||
single-winner level write - two slave engines otherwise clobber each
|
||||
other's ACK pulls, a real M4 bug).
|
||||
11. **Views may own DOM outside their row** (the logic-analyzer floating
|
||||
window is `position: fixed` on `document.body`, non-modal by design —
|
||||
never add a backdrop). Such Views MUST implement `destroy()` (the panel
|
||||
calls it on row removal and list rebuilds) and build multi-pin pickers
|
||||
from `host.pinInfo()` — the same candidate-aware pin set the generic
|
||||
editor uses, never a forked copy. `ctx.pinLevel(pin)` reads the current
|
||||
driven level for snapshots only; the change stream stays `onPinState`.
|
||||
|
||||
## 4. Past bugs (prevent regressions)
|
||||
|
||||
@@ -156,6 +163,7 @@ and `docs/FEASIBILITY.md` (M0 measurements).
|
||||
| two I2C slaves on one bus NACKed every transaction (M4) | `gpio_set_input` is a single-winner level write; shared-bus pads need the runtime's wired-AND arbitration (`sharedBus` metadata) |
|
||||
| read-address bytes were never ACKed by the engine (M4, unit-caught) | the ACK decision belongs to the *completed byte* (`ackThisByte`), not to the (already switched) phase |
|
||||
| C compensation diverged from the JS model only in pressure (M4) | `dig_P1` is uint16 in the Bosch layout; 36477 wraps negative in int16 - keep the C and JS implementations cross-checked bit-exactly by the smoke test |
|
||||
| the logic-analyzer popup opened empty (0.10.0 GUI smoke): a `this.`-less `trigSel` threw mid-construction and the half-built window stayed on screen | unit tests never run View DOM code — for popup-building plugins, do a GUI pass with a `window.addEventListener('error')` trap; also wrap `setPointerCapture` in try/catch (synthetic/automated pointer events carry no active pointer id and throw NotFoundError, killing the drag) |
|
||||
|
||||
## 5. Testing & verification conventions
|
||||
|
||||
@@ -174,7 +182,15 @@ and `docs/FEASIBILITY.md` (M0 measurements).
|
||||
devices (S1–S6 GPIO devices — S4b drives the `###P1b` digital-in sampling:
|
||||
switch/touch/button levels read back by the guest plus the LED mirror,
|
||||
S7/S8 LEDC register-level devices driven by the `###P2` markers, S9 ws2812
|
||||
by `###P3`, S10/S11 the M4 I2C+IR devices by `###P4`). **Timing lessons**:
|
||||
by `###P3`, S10/S11 the M4 I2C+IR devices by `###P4`, S13 the logic
|
||||
analyzer armed on the P4 soft-I2C bus (SCL/SDA legally shared with the
|
||||
i2c slaves); S12 is
|
||||
**machine-local**: it boots the gun 0.1.0 release — a real-world
|
||||
ESP32-S3 firmware with a delay-free bit-banged MAX7219 — in a second
|
||||
emulator instance and skips with a notice when
|
||||
`D:/d3/_git/gun_flag_monster/gun/release/0.1.0/gun-0.1.0-merged.bin`
|
||||
is absent; the gun project is read-only reference material, never modify
|
||||
it, and its later releases use hardware SPI (invisible, M0)). **Timing lessons**:
|
||||
- **drive by UART markers, never batch counts** — with M4 leases standing
|
||||
a sleep batch fast-forwards ~680k cycles, and a batch-count window
|
||||
overshoots whole rounds (the firmware prints `###P1/P1b..P4 start/end` and
|
||||
|
||||
+13
-1
@@ -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`);
|
||||
|
||||
@@ -18,11 +18,15 @@ peris/
|
||||
led/button/touch-button/toggle-switch + pwm-led/rgb-led (register-level,
|
||||
shared helpers in ledc-lib.js) + ws2812 (RMT channel RAM, ws2812-lib.js)
|
||||
+ bitbang-i2c-slave (24C02/BME280, i2c-lib.js) + ir-nec (NEC receiver,
|
||||
nec-lib.js; both ride the M4 fine-batch sampling scheduler)
|
||||
nec-lib.js) + max7219 (8-digit LED display, max7219-lib.js)
|
||||
+ logic-analyzer (multi-GPIO waveform capture in a floating
|
||||
window, la-lib.js; the bit-bang/protocol/instrument devices
|
||||
ride the M4 fine-batch sampling scheduler)
|
||||
vendor/ peris.css vendored xterm + panel styles
|
||||
examples/ wiring-config examples (JSON, directly importable;
|
||||
esp32c3-probe.json wires all 11 devices to the probe
|
||||
firmware pins — import it to test every peripheral)
|
||||
esp32c3-probe.json wires all 12 probe-firmware devices;
|
||||
esp32s3-gun.json wires the MAX7219 for the gun 0.1.0
|
||||
firmware)
|
||||
tests/unit/ unit tests (node:test)
|
||||
tests/m1_smoke.mjs integration smoke test (real wasm + firmware, full chain)
|
||||
tests/m0_probe.mjs emulator capability probe (run first on upstream upgrades)
|
||||
@@ -76,7 +80,9 @@ npm run sync # copies the entry html + peris/ (incl. build outpu
|
||||
| WS2812 addressable LEDs | ✅ M3 | decodes from RMT channel RAM (pin-level waveform invisible); strip/ring/matrix Views. Long strips recover only their tail — the 48-word channel RAM keeps the last 46 bits of a frame (same as real C3 hardware) |
|
||||
| Bit-bang I2C slave (24C02 EEPROM, BME280) | ✅ M4 | level-driven protocol engine on two bit-banged GPIOs (SCL/SDA); answers via the SDA input path (open-drain idiom); several slaves may share one bus (wired-AND arbitration); BME280 compensation mirrored bit-exactly |
|
||||
| IR NEC receiver | ✅ M4 | captures firmware codes from a bit-banged GPIO (fine-batch edge sampling) or an RMT TX channel (channel RAM); ratio-based decode (the guest clock is not wall-accurate); repeat frames included |
|
||||
| Hardware I2C/SPI devices, displays, ADC, real touch, I2S, SD | ⏸ M5 | waiting for esp-emulator upstream APIs |
|
||||
| MAX7219 display (8×8 matrix / 7-segment) | ✅ 0.9.0 | level-driven shift engine on a firmware bit-banged DIN/CLK/LOAD bus (hardware SPI is invisible, M0); datasheet register model incl. Code B decode, intensity, scan limit, shutdown/test; daisy chains share the bus; misaligned frames dropped and counted, never garbled |
|
||||
| Logic analyzer (multi-GPIO waveforms) | ✅ 0.10.0 | up to 8 pads, channel+edge or immediate trigger, guest-time window, auto re-arm; Z shown when the pad is released; rides the M4 sampling scheduler (resolution presets); shown in a draggable/resizable non-modal floating window (the page underneath keeps working); Saleae-style plot: wheel zoom at the cursor, drag pan, hover pulse/period/frequency/duty measurement, A/B Δt cursors, edge jumping; taps other bus devices' pads legally (`sharedBus`) |
|
||||
| Hardware I2C/SPI buses (bus-attached displays, SD, etc.), ADC, real touch, I2S | ⏸ M5 | waiting for esp-emulator upstream APIs |
|
||||
|
||||
Note: the touch **peripheral** itself is not emulated — the touch button
|
||||
appears as a GPIO input that firmware reads as plain GPIO
|
||||
@@ -130,8 +136,16 @@ half duty) / GPIO4 RMT-WS2812 frames / a ~20 kHz soft-I2C master on
|
||||
SCL=GPIO1+SDA=GPIO2 (24C02 + BME280 transactions, BME compensation
|
||||
cross-checked bit-exactly) plus NEC IR codes bit-banged on GPIO5 and sent via
|
||||
RMT ch1 on GPIO10 — plus GPIO3 input-interrupt counting, for end-to-end
|
||||
verification. Import `examples/esp32c3-probe.json` (wires all 11 devices to
|
||||
those pins) after loading the merged image to exercise every peripheral.
|
||||
verification. Import `examples/esp32c3-probe.json` (wires all 12 devices to
|
||||
those pins — the logic analyzer taps the I2C pair) after loading the merged
|
||||
image to exercise every peripheral.
|
||||
|
||||
A second, real-world integration example: `examples/esp32s3-gun.json` wires
|
||||
the MAX7219 display for the gun 0.1.0 release (ESP32-S3,
|
||||
DIN=GPIO21/CLK=GPIO13/LOAD=GPIO14, bit-banged transport) — load
|
||||
`gun-0.1.0-merged.bin` and the boot register-init sequence and the value
|
||||
pushes decode live. The smoke test runs the same firmware as S12 when the
|
||||
release binary is on this machine.
|
||||
|
||||
Note: the page itself has no CDN dependency — xterm/xterm-addon-fit are
|
||||
vendored into `peris/vendor/` (unminified npm builds), so it works offline.
|
||||
+16
-6
@@ -15,10 +15,13 @@ peris/
|
||||
toggle-switch + pwm-led/rgb-led(寄存器级,共享辅助在 ledc-lib.js)
|
||||
+ ws2812(RMT 通道 RAM,ws2812-lib.js)
|
||||
+ bitbang-i2c-slave(24C02/BME280,i2c-lib.js)+ ir-nec(NEC 接收,
|
||||
nec-lib.js;两者都依托 M4 细批采样调度器)
|
||||
nec-lib.js)+ max7219(8 位 LED 显示,max7219-lib.js)
|
||||
+ logic-analyzer(浮动窗口中的多 GPIO 波形捕获,la-lib.js;
|
||||
位绑定/协议/仪器类设备都依托 M4 细批采样调度器)
|
||||
vendor/ peris.css xterm 本地化 + 面板样式
|
||||
examples/ 接线配置示例(JSON,可直接 Import;esp32c3-probe.json
|
||||
按探针固件引脚接线全部 11 个设备——导入即可测遍外设)
|
||||
examples/ 接线配置示例(JSON,可直接 Import):esp32c3-probe.json
|
||||
按探针固件引脚接线全部 12 个设备;esp32s3-gun.json
|
||||
为 gun 0.1.0 固件接线 MAX7219 显示
|
||||
tests/unit/ 单元测试(node:test)
|
||||
tests/m1_smoke.mjs 集成冒烟(真实 wasm+固件全链路)
|
||||
tests/m0_probe.mjs 模拟器能力探针(上游升级时先跑)
|
||||
@@ -67,7 +70,9 @@ npm run sync # 复制入口 html + peris/(含编译产物)+ exam
|
||||
| WS2812 幻彩灯 | ✅ M3 | 读 RMT 通道 RAM 解码(引脚级波形不可见);灯带/环形/矩阵 View。长灯带只能恢复尾部——48 字通道 RAM 只保留帧的最后 46 位(与真实 C3 硬件一致) |
|
||||
| 位绑定 I2C 从机(24C02 EEPROM、BME280) | ✅ M4 | 两个位绑定 GPIO(SCL/SDA)上的电平驱动协议引擎;经 SDA 输入路径应答(开漏惯用法);多个从机可共享一条总线(线与仲裁);BME280 补偿逐位精确镜像 |
|
||||
| IR NEC 接收器 | ✅ M4 | 双路捕获固件码:位绑定 GPIO(细批边沿采样)或 RMT TX 通道(通道 RAM);按比值解码(客户时钟不按墙钟走);支持重复帧 |
|
||||
| 硬件 I2C/SPI 设备、显示屏、ADC、真触摸、I2S、SD | ⏸ M5 | 等 esp-emulator 上游暴露 API |
|
||||
| MAX7219 显示(8×8 矩阵/七段数码管) | ✅ 0.9.0 | 固件位绑定 DIN/CLK/LOAD 总线上的电平驱动移位引擎(硬件 SPI 不可见,M0);寄存器模型遵循数据表(Code B 译码、亮度、扫描界、停机/测试);菊花链共享总线;失准帧丢弃并计数,绝不出花屏 |
|
||||
| 逻辑分析仪(多 GPIO 波形) | ✅ 0.10.0 | 最多 8 个引脚,通道+边沿或立即触发,客机时间窗口,自动重新武装;引脚释放显示 Z;依托 M4 采样调度器(分辨率预设);界面在可拖动/可调大小的非模态浮动窗口中(下层页面照常交互);Saleae 风格绘图:滚轮以光标为中心缩放、拖拽平移、悬停测脉宽/周期/频率/占空比、A/B 游标测 Δt、边沿跳转;`sharedBus` 使其可合法搭接其他总线设备的引脚 |
|
||||
| 硬件 I2C/SPI 总线(总线挂载的显示屏、SD 等)、ADC、真触摸、I2S | ⏸ M5 | 等 esp-emulator 上游暴露 API |
|
||||
|
||||
注:触摸**外设**未被模拟——触摸按键以 GPIO 输入呈现,固件按 GPIO 读取即可
|
||||
(`tests/firmware/peris_probe` 有示例:GPIO3 输入 + 下降沿 ISR)。
|
||||
@@ -117,8 +122,13 @@ GPIO9+GPIO6+GPIO7 上 LEDC 呼吸(ch0 渐变、ch1 反向、ch2 恒半占空比)
|
||||
GPIO4 RMT-WS2812 发帧 / SCL=GPIO1+SDA=GPIO2 上 ~20 kHz soft-I2C 主机
|
||||
(24C02 + BME280 事务,BME 补偿逐位交叉验证)加 GPIO5 位绑定与 GPIO10
|
||||
RMT ch1 两路 NEC 红外码——并配合 GPIO3 输入中断计数,用于端到端验证。
|
||||
加载合并镜像后导入 `examples/esp32c3-probe.json`(按上述引脚接线全部 11
|
||||
个设备),每个阶段都会驱动到对应外设。
|
||||
加载合并镜像后导入 `examples/esp32c3-probe.json`(按上述引脚接线全部 12
|
||||
个设备,逻辑分析仪搭接 I2C 双线),每个阶段都会驱动到对应外设。
|
||||
|
||||
另一个真实固件集成示例:`examples/esp32s3-gun.json` 为 gun 0.1.0 发布件
|
||||
(ESP32-S3,DIN=GPIO21/CLK=GPIO13/LOAD=GPIO14,位绑定传输)接线 MAX7219
|
||||
显示——加载 `gun-0.1.0-merged.bin`,开机的寄存器初始化序列与数值推送都会
|
||||
实时解码。发布件在本机存在时,冒烟测试会以 S12 运行同一固件。
|
||||
|
||||
注:页面自身不依赖任何 CDN——xterm/xterm-addon-fit 已本地化到
|
||||
`peris/vendor/`(取自 npm 包的非压缩构建),离线可用。
|
||||
+13
-2
@@ -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`).
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,79 @@
|
||||
与英文版 CHANGELOG.md)。
|
||||
规则:主版本=协议/架构破坏性变更;次版本=新能力;修订=修复与小改。
|
||||
|
||||
## peris 0.10.0
|
||||
|
||||
- **逻辑分析仪**(`logic-analyzer.js` v1.0.0,逻辑在 `la-lib.js`):面向固件
|
||||
驱动信号的多通道 GPIO 波形捕获——最多 8 个任意 GPIO、一个触发(通道 +
|
||||
上升/下降/任意沿,或"立即")、1–1000 ms 客机时间捕获窗口、可选自动重新
|
||||
武装。电平在驱动时为 1/0,客机释放引脚(oe 撤销)时记录为 **Z**;时间为
|
||||
周期计数换算的客机 µs(客机时钟不等于墙钟,M4)。采样完全复用 M4 调度
|
||||
器,与其他位绑定设备一致:等待触发时持有 arm 租约(1000),录制期间每次
|
||||
边沿续租细批(`resolution` 预设:fine 20=调度器下限 / normal 100 /
|
||||
coarse 300);一个批次内的两次翻转会合并——预设决定合并到多细。探针
|
||||
从不驱动:`sharedBus` 允许它搭接其他总线设备占用的引脚(I2C 双线、
|
||||
MAX7219 总线、其他分析仪),2 万条边沿的缓冲区写满时带 `overflow` 标志
|
||||
冻结而不是出乱码。
|
||||
- **浮动分析窗口**:波形界面较大,放在可拖动、可调大小的**非模态**弹窗中
|
||||
(`la-lib.makeFloatingWindow`,fixed 定位、无遮罩——下层页面的一切交互
|
||||
照常;几何位置钳制在视口内并持久化到 localStorage)。行图标与展开详情
|
||||
用同一绘制器(`drawWaveform`)显示实时迷你预览(三种尺寸一条绘制路
|
||||
径);点击任意一个即可弹出窗口。通道从 GPIO 芯片网格中勾选(被占用引脚
|
||||
通过新增的 `host.pinInfo()` 置灰),触发/窗口/分辨率/标签同时存在于配置
|
||||
编辑器与窗口工具栏。
|
||||
- **Saleae 风格绘图交互**(同属 0.10.0):**滚轮缩放**以光标处的时间为锚
|
||||
点(下限 1 ns,钳制在捕获范围内),**左键/中键拖拽平移**,**双击 / F /
|
||||
⤢ 复位适配**;新捕获自动重新适配视图并清除游标(状态中的 `capId`)。
|
||||
**悬停测量**高亮光标所在脉冲,测量栏实时读出电平 · 脉宽 · 周期 · 频率
|
||||
· **PWM 占空比**(周期=相邻两个上升沿;取自悬停通道);通道栏在视图右
|
||||
缘显示各通道当前电平。**A/B 测量游标**:点两下放置、拖线移动、读出 Δt
|
||||
与 1/Δt 频率,✕ 清除。**边沿跳转**:点击通道标签选中,再用 ‹/› 按钮
|
||||
或 ←/→ 方向键把视图中心平移到上一/下一边沿(缩放保持不变)。
|
||||
- **设备 API v1 增补**(向后兼容):`ctx.pinLevel(pin)`——直接读
|
||||
`gpio_state()` 的当前驱动电平(释放时为 null;逻辑分析仪在捕获起点快照
|
||||
各通道电平);`ViewHost.pinInfo()`——为自建多引脚选择器的 View 提供
|
||||
有效/已占用引脚;`View.destroy()`——行被移除或列表重建时的清理钩子,
|
||||
让持有行外 DOM(浮动窗口)的 View 自行收尾;面板在 remove 与
|
||||
`renderAll` 时调用。
|
||||
- 测试:读数数学(parsePins、levelAt、轴刻度、格式化、适配跨度、像素变
|
||||
换)、经假 ctx 的完整捕获状态机(立即/触发启动、方向过滤、Z 沿、窗口结
|
||||
束、stop/clear/auto——含 auto+立即的警告——缓冲溢出、onEmuReady 复
|
||||
位)与针对假模拟器的 `sigbus.pinLevel` 单元用例;冒烟 S13 在 P4 软 I2C
|
||||
总线上武装分析仪(40 ms 窗口内 SCL 1950 / SDA 352 条交替边沿,之后冻
|
||||
结);`examples/esp32c3-probe.json` 增至 12 个设备。
|
||||
|
||||
## peris 0.9.0
|
||||
|
||||
- **MAX7219 显示**(`max7219.js` v1.0.0,逻辑在 `max7219-lib.js`):固件
|
||||
**位绑定 DIN/CLK/LOAD 总线**上的 8 位 LED 驱动器。硬件 SPI 走线对
|
||||
`gpio_state()` 不可见(M0),因此设备面向软 SPI 代码——从带延时的位绑定
|
||||
到背靠背 `gpio_set_level` 脉冲都可解码。电平驱动的移位引擎在 CLK 上升沿
|
||||
采样 DIN,按 MSB-first 拼装 16 位字,在 LOAD 上升沿锁存帧;失准帧(采样
|
||||
丢边沿)被丢弃并计入 `dropped`,绝不让屏幕出乱码。寄存器模型遵循数据表:
|
||||
Code B 译码(D3–D0,D7=小数点独立)、亮度 PWM(16 级,(I+1)/32 占空)、
|
||||
扫描界、停机/显示测试、上电默认值(停机、扫 1 位、最低亮度)。View 分
|
||||
8×8 矩阵(位=行,D7..D0=列)与七段数码管两种,按译码寄存器自动选择;
|
||||
菊花链合法共享三根线(`sharedBus`),各片锁存落在自己链位上的字
|
||||
(`chain`+`pos` 配置)。租约尺寸按最紧的真实固件实测标定(gun 0.1.0:无
|
||||
延时脉冲,边沿相距约 20 条指令,LOAD 下降沿距首个 CLK 上升沿约 80):细
|
||||
批取调度器下限(**20**),警戒批取 **60**。
|
||||
- **冒烟 S12**(本机可选,固件缺失时打印提示跳过):gun 0.1.0 发布件
|
||||
(ESP32-S3,DIN=21/CLK=13/LOAD=14;后续 gun 版本改用硬件 SPI,按 M0 不可
|
||||
见)在第二个模拟器实例中启动——`display_init()` 的 14 个寄存器字全部解码
|
||||
且**零**失准丢帧,随后跟随控制任务的数值推送。新增示例
|
||||
`examples/esp32s3-gun.json` 为该固件接线显示。
|
||||
|
||||
| 设备 | 版本 |
|
||||
|---|---|
|
||||
| led | 1.1.1 |
|
||||
| button / touch-button / toggle-switch | 1.1.0 |
|
||||
| pwm-led / rgb-led | 1.0.0 |
|
||||
| ws2812 | 1.0.0 |
|
||||
| bitbang-i2c-slave | 1.0.0 |
|
||||
| ir-nec | 1.0.0 |
|
||||
| max7219 | 1.0.0 |
|
||||
| logic-analyzer | 1.0.0 |
|
||||
|
||||
## peris 0.8.1
|
||||
|
||||
- **测试固件 P1b**(数字输入采样):每轮循环新增 2 秒采样阶段,用
|
||||
|
||||
+56
-2
@@ -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)
|
||||
@@ -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` — 共享辅助
|
||||
模块(不是设备)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"perisVersion": 1,
|
||||
"chip": "esp32s3",
|
||||
"devices": [
|
||||
{ "id": "mx-1", "type": "max7219", "name": "Gun 7-seg display (DIN21/CLK13/LOAD14)", "din": 21, "clk": 13, "load": 14, "mode": "auto" }
|
||||
]
|
||||
}
|
||||
+20
-20
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* MAX7219 display driver — watches a firmware bit-banged 3-wire bus
|
||||
* (DIN / CLK / LOAD) and renders the latched 8-digit display as 8×8 matrix
|
||||
* dots or 7-segment digits.
|
||||
*
|
||||
* Why bit-bang only: signals routed through a hardware SPI peripheral never
|
||||
* appear in gpio_state() (M0 — the GPIO-matrix path is invisible), so the
|
||||
* device requires firmware that drives DIN/CLK/LOAD with CPU GPIO writes
|
||||
* (any soft-SPI code, delay-paced or back-to-back). CPU-driven edges are
|
||||
* only observable while a fine sampling lease is held (M4 scheduler): the
|
||||
* device arms a standing watch and escalates on the first bus edge, exactly
|
||||
* like the bit-bang I2C slave.
|
||||
*
|
||||
* Lease sizing (measured against the reference firmware, gun 0.1.0: pulses
|
||||
* with NO delays — back-to-back gpio_set_level calls): one CLK edge costs
|
||||
* ~20 instructions, the shortest CLK→DIN / CLK→CLK gap is ~20-40, the LOAD
|
||||
* fall leads the first CLK rise by ~80, a whole 16-bit frame spans ~1820.
|
||||
* So the fine lease sits at the scheduler's floor (20) — every edge then
|
||||
* lands in its own batch and DIN is never ambiguous against a CLK rise —
|
||||
* and the arm batch (60) always separates the LOAD fall from the first
|
||||
* CLK rise. Delay-paced bit-bang (esp_rom_delay_us(2), gun 0.1.5-class,
|
||||
* gaps ≥160) is covered with even wider margins. Firmwares with pulses
|
||||
* below ~20 instructions lose edges; the engine drops misaligned frames
|
||||
* and counts them in `dropped` (visible in the caption) instead of showing
|
||||
* garbage — drivers that re-push their registers periodically self-heal.
|
||||
*
|
||||
* Daisy chains: N parts share DIN/CLK/LOAD (`sharedBus`); each instance
|
||||
* latches the word that ended in its chain position (chain length + position
|
||||
* in the config). Single devices (chain 1) simply take the last word.
|
||||
*/
|
||||
import {
|
||||
Max7219Model, Max7219Wire, pickChainWord, displayFromSnapshot,
|
||||
drawDisplay, drawSeg7, displayLevel, displayGeometry, MX_COLOR,
|
||||
} from './max7219-lib.js';
|
||||
import { makeCanvas } from './ws2812-lib.js';
|
||||
|
||||
/** Standing watch batch (instructions) while the bus is idle — below the
|
||||
* LOAD-fall → first-CLK-rise gap (~80 instructions measured), so a frame's
|
||||
* first bit can never be swallowed by the watch batch. */
|
||||
export const ARM_BATCH = 60;
|
||||
/** Fine batch size while a frame is in flight — the scheduler floor: every
|
||||
* edge of a back-to-back bit-bang (gaps ≥20) lands in its own batch. */
|
||||
export const FINE_BATCH = 20;
|
||||
/** Fine lease budget (cycles), renewed on every edge: bridges the slowest
|
||||
* plausible mid-frame pause (8-word chain frames run ~600k cycles). */
|
||||
export const FINE_HOLD_CYCLES = 2000000;
|
||||
|
||||
const pinNum = (v, dflt) => {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n >= 0 ? n : dflt;
|
||||
};
|
||||
|
||||
const chainFrom = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) ? Math.min(8, Math.max(1, n)) : 1;
|
||||
};
|
||||
|
||||
const posFrom = (v, chain) => {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) ? Math.min(chain - 1, Math.max(0, n)) : 0;
|
||||
};
|
||||
|
||||
export class Device {
|
||||
static typeId = 'max7219';
|
||||
static version = '1.0.0';
|
||||
static title = 'MAX7219 Display';
|
||||
static category = 'display';
|
||||
static description = '8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus — 8×8 matrix or 7-segment view, daisy-chainable.';
|
||||
static defaultConfig = {
|
||||
name: 'MAX7219', din: 21, clk: 13, load: 14, chain: 1, pos: 0, mode: 'auto',
|
||||
};
|
||||
/* DIN/CLK/LOAD are a bus: daisy-chained parts legally share all three. */
|
||||
static sharedBus = true;
|
||||
static pins(cfg) {
|
||||
return [pinNum(cfg.din, 21), pinNum(cfg.clk, 13), pinNum(cfg.load, 14)];
|
||||
}
|
||||
static configSchema = [
|
||||
{ key: 'din', label: 'DIN GPIO', type: 'pin' },
|
||||
{ key: 'clk', label: 'CLK GPIO', type: 'pin' },
|
||||
{ key: 'load', label: 'LOAD/CS GPIO', type: 'pin' },
|
||||
{ key: 'chain', label: 'Chain length', type: 'number', min: 1, max: 8, step: 1 },
|
||||
{ key: 'pos', label: 'Chain position', type: 'number', min: 0, max: 7, step: 1 },
|
||||
{ key: 'mode', label: 'Display view', type: 'select', options: [
|
||||
{ value: 'auto', label: 'Auto (decode-mode register)' },
|
||||
{ value: 'matrix', label: '8×8 matrix' },
|
||||
{ value: 'seg7', label: '7-segment digits' },
|
||||
] },
|
||||
{ key: 'name', label: 'Label', type: 'text' },
|
||||
];
|
||||
|
||||
constructor(ctx, cfg) {
|
||||
this.ctx = ctx;
|
||||
this.cfg = cfg;
|
||||
this.din = pinNum(cfg.din, 21);
|
||||
this.clk = pinNum(cfg.clk, 13);
|
||||
this.load = pinNum(cfg.load, 14);
|
||||
this.chain = chainFrom(cfg.chain);
|
||||
this.pos = posFrom(cfg.pos, this.chain);
|
||||
this.model = new Max7219Model();
|
||||
this.levels = { clk: null, din: null, load: null };
|
||||
this.frames = 0; // aligned LOAD frames seen (any chain length)
|
||||
this.dropped = 0; // misaligned frames — edges lost to sampling
|
||||
this.wire = new Max7219Wire((words, bits) => this._onFrame(words, bits));
|
||||
ctx.subscribe([this.din, this.clk, this.load]);
|
||||
ctx.armSampling(ARM_BATCH);
|
||||
}
|
||||
|
||||
onEmuReady() {
|
||||
// fresh emulator: the chip powers up in shutdown, blank, scan 1 digit
|
||||
this.model.reset();
|
||||
this.wire.reset();
|
||||
this.levels = { clk: null, din: null, load: null };
|
||||
this.frames = 0;
|
||||
this.dropped = 0;
|
||||
this.ctx.armSampling(ARM_BATCH);
|
||||
this.ctx.markDirty();
|
||||
}
|
||||
|
||||
onPinState(pin, s) {
|
||||
const high = !!(s.out && s.oe);
|
||||
if (pin === this.din) this.levels.din = high;
|
||||
else if (pin === this.clk) this.levels.clk = high;
|
||||
else this.levels.load = high;
|
||||
if (this.wire.step(this.levels.clk, this.levels.din, this.levels.load)) {
|
||||
// any edge: keep the fine window alive for the rest of the frame
|
||||
this.ctx.fineSampling(FINE_BATCH, FINE_HOLD_CYCLES);
|
||||
}
|
||||
}
|
||||
|
||||
onBatch() {
|
||||
if (this.wire.busy) {
|
||||
// mid-frame: renew even without edges (slow bit-bang pauses)
|
||||
this.ctx.fineSampling(FINE_BATCH, FINE_HOLD_CYCLES);
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
const m = this.model;
|
||||
return {
|
||||
din: this.din, clk: this.clk, load: this.load,
|
||||
chain: this.chain, pos: this.pos,
|
||||
digits: m.digits.slice(),
|
||||
decodeMode: m.decodeMode,
|
||||
intensity: m.intensity,
|
||||
scanLimit: m.scanLimit,
|
||||
shutdown: m.shutdown,
|
||||
test: m.test,
|
||||
frames: this.frames,
|
||||
dropped: this.dropped,
|
||||
writes: m.writes,
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.ctx.releaseSampling();
|
||||
this.ctx.unsubscribe();
|
||||
}
|
||||
|
||||
_onFrame(words) {
|
||||
if (words == null) {
|
||||
this.dropped++;
|
||||
this.ctx.markDirty();
|
||||
return;
|
||||
}
|
||||
this.frames++;
|
||||
const word = pickChainWord(words, this.chain, this.pos);
|
||||
if (word != null && this.model.write((word >> 8) & 0xff, word & 0xff)) {
|
||||
this.ctx.markDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class View {
|
||||
constructor(el, sendCmd) {
|
||||
this.el = el;
|
||||
this.send = sendCmd;
|
||||
this.cfg = { mode: 'auto', chain: 1, pos: 0 };
|
||||
this.state = null;
|
||||
el.className = 'dev-body mx-body';
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mx-wrap';
|
||||
this.wrap = wrap;
|
||||
const cap = document.createElement('div');
|
||||
cap.className = 'mx-cap';
|
||||
this.cap = cap;
|
||||
el.appendChild(wrap);
|
||||
el.appendChild(cap);
|
||||
this.cv = null; this.g = null; this.geo = null;
|
||||
this._rebuild('matrix'); // provisional until the first render/config
|
||||
}
|
||||
|
||||
/* Compact row icon: the live display in miniature (matrix dots, or one
|
||||
* lit 7-segment digit). Same painters as the details canvas. */
|
||||
rowIcon(el) {
|
||||
el.title = 'MAX7219 display';
|
||||
const { cv, g } = makeCanvas(22, 22, 'mx-icon');
|
||||
this.iconCv = cv; this.iconG = g;
|
||||
el.appendChild(cv);
|
||||
this._apply(this.state);
|
||||
}
|
||||
|
||||
/* Geometry from the view mode — one place builds the canvas. */
|
||||
_rebuild(mode) {
|
||||
this.mode = mode;
|
||||
const geo = displayGeometry(mode);
|
||||
if (this.cv) this.cv.remove();
|
||||
const { cv, g } = makeCanvas(geo.w, geo.h, 'mx-canvas');
|
||||
this.cv = cv; this.g = g; this.geo = geo;
|
||||
this.wrap.appendChild(cv);
|
||||
}
|
||||
|
||||
/* The ONE application of a display state — details canvas + row icon
|
||||
* from the same derived segments (AGENT.md §3.8). */
|
||||
_apply(state) {
|
||||
this.state = state;
|
||||
if (!state) return;
|
||||
const mode = this.cfg.mode === 'auto'
|
||||
? (state.decodeMode ? 'seg7' : 'matrix')
|
||||
: this.cfg.mode;
|
||||
if (mode !== this.mode) this._rebuild(mode);
|
||||
const { segs, brightness } = displayFromSnapshot(state);
|
||||
if (this.g) drawDisplay(this.g, this.geo, this.mode, segs, brightness);
|
||||
if (this.iconG) this._applyIcon(this.mode, segs, brightness);
|
||||
this.cap.textContent = `DIN GPIO${state.din} · CLK GPIO${state.clk} · LOAD GPIO${state.load}`
|
||||
+ (state.chain > 1 ? ` · chain ${state.pos + 1}/${state.chain}` : '')
|
||||
+ ` · ${state.frames} frames` + (state.dropped ? ` · ${state.dropped} dropped` : '')
|
||||
+ (state.shutdown ? ' · shutdown' : '')
|
||||
+ (state.test ? ' · display test' : '');
|
||||
}
|
||||
|
||||
_applyIcon(mode, segs, brightness) {
|
||||
const g = this.iconG;
|
||||
g.clearRect(0, 0, 22, 22);
|
||||
const level = displayLevel(brightness);
|
||||
const lit = segs.some((s) => s);
|
||||
if (!lit) return;
|
||||
if (mode === 'seg7') {
|
||||
const d = segs.reduce((best, s, i) => (s ? i : best), -1);
|
||||
if (d >= 0) drawSeg7(g, 4, 1, 12, 20, segs[d], level);
|
||||
return;
|
||||
}
|
||||
for (let row = 0; row < 8; row++) {
|
||||
for (let col = 0; col < 8; col++) {
|
||||
if ((segs[row] >> (7 - col)) & 1) {
|
||||
g.fillStyle = `rgba(${MX_COLOR[0]},${MX_COLOR[1]},${MX_COLOR[2]},${Math.min(1, level * 1.3)})`;
|
||||
g.beginPath();
|
||||
g.arc(2.5 + col * 2.4, 2.5 + row * 2.4, 1, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(state) {
|
||||
this._apply(state);
|
||||
}
|
||||
|
||||
setConfig(cfg) {
|
||||
this.cfg = {
|
||||
mode: ['auto', 'matrix', 'seg7'].includes(cfg.mode) ? cfg.mode : 'auto',
|
||||
chain: chainFrom(cfg.chain),
|
||||
pos: posFrom(cfg.pos, chainFrom(cfg.chain)),
|
||||
};
|
||||
this._apply(this.state);
|
||||
}
|
||||
}
|
||||
+121
@@ -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; }
|
||||
@@ -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); },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface DeviceCtx {
|
||||
/** Bus/MMIO read (little-endian), e.g. LEDC duty, RMT channel RAM. */
|
||||
readMem(addr: number, len: number): Uint8Array;
|
||||
readU32(addr: number): number;
|
||||
/**
|
||||
* Current driven level of one pad straight from gpio_state(): true/false
|
||||
* while the guest drives it (oe), null when released. One wasm read —
|
||||
* snapshot channels sparingly (capture start), never per batch.
|
||||
*/
|
||||
pinLevel(pin: number): boolean | null;
|
||||
cycles(): number;
|
||||
/** Emulator clock in cycles per µs (edge timing → real time units). */
|
||||
readonly cyclesPerUs: number;
|
||||
@@ -135,6 +141,12 @@ export interface DeviceLike {
|
||||
export interface ViewHost {
|
||||
/** Merge a patch into this device's config, re-apply and persist. */
|
||||
updateConfig(patch: Record<string, unknown>): void;
|
||||
/**
|
||||
* Optional: the chip's valid GPIO pins plus the pins claimed by other
|
||||
* devices (candidate-aware for sharedBus) — for Views that build their
|
||||
* own multi-pin picker outside the generic config editor.
|
||||
*/
|
||||
pinInfo?(): { valid: number[]; used: Set<number> };
|
||||
}
|
||||
|
||||
/** Page-side view of a device plugin. */
|
||||
@@ -147,6 +159,13 @@ export interface ViewLike {
|
||||
* The same render(state) calls drive it; `el` is a ~24px container.
|
||||
*/
|
||||
rowIcon?(el: HTMLElement): void;
|
||||
/**
|
||||
* Optional: teardown when the row is removed or the whole list is
|
||||
* rebuilt (import). Views that own page-side resources outside their
|
||||
* row element — e.g. a floating popup window appended to document.body —
|
||||
* must remove them here, or the DOM leaks a dead window.
|
||||
*/
|
||||
destroy?(): void;
|
||||
}
|
||||
export interface ViewClass {
|
||||
new (el: HTMLElement, sendCmd: (cmd: string, params?: Record<string, unknown>) => void, host?: ViewHost): ViewLike;
|
||||
|
||||
+1
-1
@@ -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';
|
||||
@@ -254,6 +254,7 @@ export class PerisPanel {
|
||||
remove.textContent = '×';
|
||||
remove.addEventListener('click', () => {
|
||||
this.config.devices = this.config.devices.filter((d) => d.id !== devCfg.id);
|
||||
this.views.get(devCfg.id)?.view?.destroy?.(); // popups etc. live outside the row
|
||||
this.views.delete(devCfg.id);
|
||||
this.expanded.delete(devCfg.id);
|
||||
row.remove();
|
||||
@@ -304,6 +305,13 @@ export class PerisPanel {
|
||||
view?.setConfig?.(devCfg);
|
||||
this.renderRows();
|
||||
},
|
||||
// multi-pin pickers built by a View (logic-analyzer channels)
|
||||
// grey out occupied GPIOs with the same candidate-aware set the
|
||||
// generic editor uses — one source, never a forked copy
|
||||
pinInfo: () => ({
|
||||
valid: validPins(this.deps.getChip()),
|
||||
used: this.usedPins(devCfg.id, meta),
|
||||
}),
|
||||
};
|
||||
const ViewClass: ViewClass | undefined = this.registry?.View(devCfg.type);
|
||||
if (ViewClass) {
|
||||
@@ -427,6 +435,9 @@ export class PerisPanel {
|
||||
private renderAll() {
|
||||
const list = document.getElementById('peris-cards')!;
|
||||
list.innerHTML = '';
|
||||
// Views may own DOM outside their row (floating windows) — give
|
||||
// every old view a teardown before the rows are discarded.
|
||||
for (const { view } of this.views.values()) view?.destroy?.();
|
||||
this.views.clear();
|
||||
for (const devCfg of this.config.devices) {
|
||||
const meta = this.registry?.meta(devCfg.type);
|
||||
|
||||
+138
-7
@@ -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);
|
||||
@@ -71,6 +71,17 @@ test('sigbus setInput forwards to emulator', () => {
|
||||
assert.deepEqual(emu.inputs, [[4, false], [4, true]]);
|
||||
});
|
||||
|
||||
test('sigbus pinLevel reads the driven level, null when released', () => {
|
||||
// pin8 driven high, pin9 driven low, pin33 driven high (word 1), pin4 released
|
||||
const emu = new FakeEmu([(1 << 8), (1 << 1), (1 << 8) | (1 << 9), (1 << 1)]);
|
||||
const bus = new Sigbus(emu);
|
||||
assert.equal(bus.pinLevel(8), true);
|
||||
assert.equal(bus.pinLevel(9), false);
|
||||
assert.equal(bus.pinLevel(33), true);
|
||||
assert.equal(bus.pinLevel(4), null, 'oe clear → released, level unknowable');
|
||||
assert.equal(new Sigbus(null).pinLevel(8), null, 'no emulator attached');
|
||||
});
|
||||
|
||||
// ---- chips ----
|
||||
|
||||
test('chips tables: counts, absent pins, valid pins', () => {
|
||||
|
||||
+1179
-596
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user