Logic analyzer probe freedoms: passive pads, any-GPIO trigger, 1 h window (0.11.0)

- passive probes (new `static passive = true` device metadata): a pure
  listener never drives an input and claims no pad, so it may clamp onto
  ANY GPIO - including pads other devices own (an LED, an I2C pair, a
  MAX7219 bus) and other analyzers. The runtime's pin-conflict check and
  the wired-AND arbitration skip passive pads; the panel's usedPins
  ignores them, so other devices' pickers never show them as "in use";
  the analyzer's channel grid never disables a GPIO (pads owned elsewhere
  get a dashed "shared" hint). Replaces the analyzer's sharedBus flag.
- trigger on any GPIO: trigCh (channel index) becomes trigPin (GPIO
  number) - the trigger pad gets its own subscription and need not be an
  analysed channel (off-channel triggers land in no lane, channel levels
  still snapshot at capture start). The window dropdown lists every valid
  GPIO. Tooltip documents the gpio_state visibility caveat: a trigger
  fires only when the FIRMWARE drives the pad - pure-input pads (driven
  by our button devices) never toggle the output registers.
- window cap lifted 1 s -> 1 h of guest time (was a conservative guard;
  dense signals still freeze earlier at the 20 000-edge buffer with the
  buffer-full note - the natural limit, since Saleae streams into host
  RAM while we capture into a fixed buffer).
- tests: runtime-level passive sharing (probe onto an LED pad + I2C pair
  + second probe, zero conflicts), off-channel trigger, 1 h clamp,
  trigger cases migrated to trigPin; device bumped to 1.1.0; GUI checked
  (shared chip clickable, 23 trigger options, 5000 ms window accepted).
- docs (both languages): CHANGELOG 0.11.0 + device version table,
  DEVICE-API passive metadata + instrument section, README capability
  row, ROADMAP probe-freedoms bullet, AGENT rule 3.11; version synced to
  0.11.0.
This commit is contained in:
darceye committed 2026-11-02 10:58:17 +08:00
1 parent 58452f4031
commit 7f5f1aaa88
19 files changed
+278 -88

No files matched your search

+4
View File
@@ -153,6 +153,10 @@ and `docs/FEASIBILITY.md` (M0 measurements).
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`.
Pure listeners declare `static passive = true` (0.11): the runtime's
conflict check and wired-AND skip their pads, and other devices' pin
pickers never show them as used — a probe clamps anywhere, an active
device does not.
## 4. Past bugs (prevent regressions)
+3 -1
View File
@@ -117,7 +117,9 @@ esp-emulator wasm 能力边界见 `docs/EMU-API.zh-cn.md` 与 `docs/FEASIBILITY.
View 必须实现 `destroy()`(面板在行移除与列表重建时调用),多引脚选择
器必须基于 `host.pinInfo()` 构建——与通用编辑器同一份、按候选设备感知
的引脚集合,禁止另抄一份。`ctx.pinLevel(pin)` 只用于快照式读取当前驱动
电平;变化流仍然走 `onPinState`。
电平;变化流仍然走 `onPinState`。纯监听设备声明 `static passive =
true`(0.11):runtime 的冲突检测与线与仲裁跳过其焊盘,其他设备的引脚
选择器也不把它们显示为已占用——探针可搭接任意位置,主动设备不行。
## 4. 曾踩过的 bug(防止回潮)
+1 -1
View File
@@ -102,7 +102,7 @@ esp-emulator version: `./install.sh --version 0.52.0` (or
| 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 |
| 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`) |
| Logic analyzer (multi-GPIO waveforms) | ✅ 0.10.0 | up to 8 pads, any-GPIO + edge or immediate trigger (0.11), guest-time window up to 1 h, 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; `passive` probe — clamps onto ANY in-use GPIO without conflicts |
| 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
+1 -1
View File
@@ -90,7 +90,7 @@ python serve.py # http://127.0.0.1:8080/esp-emu-peris.html
| 位绑定 I2C 从机(24C02 EEPROM、BME280) | ✅ M4 | 两个位绑定 GPIO(SCL/SDA)上的电平驱动协议引擎;经 SDA 输入路径应答(开漏惯用法);多个从机可共享一条总线(线与仲裁);BME280 补偿逐位精确镜像 |
| IR NEC 接收器 | ✅ M4 | 双路捕获固件码:位绑定 GPIO(细批边沿采样)或 RMT TX 通道(通道 RAM);按比值解码(客户时钟不按墙钟走);支持重复帧 |
| 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` 使其可合法搭接其他总线设备的引脚 |
| 逻辑分析仪(多 GPIO 波形) | ✅ 0.10.0 | 最多 8 个引脚,任意 GPIO+边沿或立即触发(0.11),客机时间窗口最长 1 小时,自动重新武装;引脚释放显示 Z;依托 M4 采样调度器(分辨率预设);界面在可拖动/可调大小的非模态浮动窗口中(下层页面照常交互);Saleae 风格绘图:滚轮以光标为中心缩放、拖拽平移、悬停测脉宽/周期/频率/占空比、A/B 游标测 Δt、边沿跳转;`passive` 探针——可无冲突搭接任意已占用 GPIO |
| 硬件 I2C/SPI 总线(总线挂载的显示屏、SD 等)、ADC、真触摸、I2S | ⏸ M5 | 等 esp-emulator 上游暴露 API |
注:触摸**外设**未被模拟——触摸按键以 GPIO 输入呈现,固件按 GPIO 读取即可
+38
View File
@@ -8,6 +8,44 @@ CHANGELOG.zh-cn.md).
Rules: major = breaking protocol/architecture change; minor = new capability;
patch = fixes and small changes.
## peris 0.11.0
- **Logic analyzer probes anything** (`logic-analyzer.js` v1.1.0): the new
`static passive = true` device metadata marks a pure listener — it never
drives an input and claims no pad, so it may clamp onto ANY GPIO,
including pads other devices own (an LED, an I2C pair, a MAX7219 bus)
and other analyzers. The runtime's conflict check skips passive pads,
they stay out of the wired-AND arbitration, and other devices' pin
pickers no longer show them as "in use"; the analyzer's channel grid
never disables a GPIO (pads owned elsewhere get a dashed "shared" hint).
- **Trigger on any GPIO** (not only analysed channels): `trigCh` (channel
index) becomes `trigPin` (GPIO number) — the trigger pad gets its own
subscription and need not be a channel; the window dropdown lists every
valid GPIO. Visibility caveat, surfaced in the tooltip: a trigger fires
only when the FIRMWARE drives the pad (gpio_state shows CPU GPIO
writes) — pure-input pads driven by our button devices never toggle.
- **Window cap lifted**: 1 s → **1 h** of guest time (was a conservative
guard). Dense signals still freeze earlier at the 20 000-edge buffer
with the `buffer full` note — that is the natural limit (Saleae streams
into host RAM, we capture into a fixed buffer); quiet signals can now
record for minutes.
- Tests: passive sharing at the runtime level (probe onto an LED pad +
I2C pair + second probe, no conflicts), off-channel trigger (edge lands
in no lane, channel levels still snapshot), 1 h clamp, migrated
trigger cases. GUI: shared chip clickable, 23 trigger options
(Immediate + all 22 C3 GPIOs), 5000 ms window accepted.
| 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.1.0 |
## peris 0.10.3
- **`python serve.py` self-bootstraps**: when the default
+34
View File
@@ -6,6 +6,40 @@
与英文版 CHANGELOG.md)。
规则:主版本=协议/架构破坏性变更;次版本=新能力;修订=修复与小改。
## peris 0.11.0
- **逻辑分析仪可探测任意引脚**(`logic-analyzer.js` v1.1.0):新增
`static passive = true` 设备元数据标记纯监听者——从不驱动输入、不声明
焊盘,因此可搭接**任意 GPIO**,包括其他设备占用的引脚(LED、I2C 双
线、MAX7219 总线)以及其他分析仪。runtime 的冲突检测跳过 passive 焊
盘、不参与线与仲裁,其他设备的引脚选择器不再把它们显示为 "in use";
分析仪的通道网格不再禁用任何 GPIO(他设备占用的焊盘以虚线 "shared"
样式提示)。
- **任意 GPIO 触发**(不再限于分析通道):`trigCh`(通道索引)改为
`trigPin`(GPIO 号)——触发焊盘独立订阅、无需是分析通道;窗口下拉列出
全部有效 GPIO。可见性边界已在提示中说明:触发只在**固件驱动**该焊盘
时有效(gpio_state 只反映 CPU 的 GPIO 写)——由我们的按键设备驱动的
纯输入焊盘不会翻转。
- **窗口上限放宽**:1 秒 → **1 小时**客机时间(原为保守护栏)。密集信
号仍会在 2 万条边沿缓冲处提前冻结并显示 `buffer full`——这才是自然限
制(Saleae 流式写入主机内存,我们是固定缓冲捕获);安静信号现在可以
录制数分钟。
- 测试:runtime 层 passive 共享(探针搭 LED 焊盘 + I2C 双线 + 第二个探
针,零冲突)、非通道触发(边沿不落入任何泳道、通道电平仍快照)、1 小
时钳制、触发用例迁移。GUI:shared 芯片可点击、触发下拉 23 项
(Immediate + C3 全部 22 个 GPIO)、5000 ms 窗口被接受。
| 设备 | 版本 |
|---|---|
| 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.1.0 |
## peris 0.10.3
- **`python serve.py` 自举**:默认的 `vendor/esp-emu-browser/www/pkg`
+12 -6
View File
@@ -25,6 +25,10 @@ export class Device { // runs in the worker, talks to the emulator
static defaultConfig = {...}; // defaults for new instances
static sharedBus = true; // optional (M4): pins may overlap with
// OTHER sharedBus devices (bus wiring)
static passive = true; // optional (0.11): pure listener — never
// drives an input, claims no pad; may
// overlap with ANY device, and other
// devices' pin pickers ignore its pads
static configSchema = [ // generic config editor fields (in the
// expanded details, one per line)
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
@@ -192,18 +196,20 @@ level- or ratio-based, never absolute-µs. The M4 plugins follow both rules:
## 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
(up to 8 pads, one trigger: ANY GPIO + rising/falling/any edge or
Immediate, guest-time window up to 1 h, 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.
it declares `passive` and legally taps pads ANY device owns (the trigger
pad gets its own subscription and need not be a channel; it fires only
when the firmware drives the pad — gpio_state never shows pure-input
pads). 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
+16 -11
View File
@@ -30,6 +30,9 @@ export class Device { // 运行在 worker,与模拟器交互
];
static sharedBus = true; // 可选(M4):允许与其他 sharedBus 设备
// 共享引脚(总线接线,如两个 I2C 从机)
static passive = true; // 可选(0.11):纯监听——从不驱动输入、
// 不声明焊盘;可与任意设备搭接,其他
// 设备的引脚选择器忽略它的焊盘
static pins(cfg) { return [cfg.pin]; } // 声明占用的 GPIO(用于冲突检测)
constructor(ctx, cfg) {} // cfg = { id, type, ...设备配置 }
@@ -125,24 +128,26 @@ M3 的 WS2812 插件遵循同一模式,共享库为 **`peris/devices/ws2812-lib.
最后 46 位,长灯带只能解码出尾部 LED(见 FEASIBILITY.zh-cn.md
"关键技术细节" 3–4)。
## 仪器类设备(0.10.0)
## 仪器类设备(0.10.0;0.11 增强)
- `peris/devices/logic-analyzer.js` —— 多通道 GPIO 波形捕获(最多 8 个引
脚,一个触发:通道 + 上升/下降/任意沿或"立即",客机时间窗口,自动重新
武装)。读数数学、波形绘制器与浮动窗口都在 `peris/devices/la-lib.js`;
绘制器只有一份实现,弹窗、详情迷你预览与行图标共用。采样复用 M4 调度
器,与位绑定设备一致(等触发时持 arm 租约,录制期间每次边沿续租细批
——`resolution` 配置决定细批:20/100/300 条指令)。电平为驱动 1/0、
客机释放引脚时 -1(Z);分析仪从不驱动任何东西,因此声明 `sharedBus`,
合法搭接其他总线设备占用的引脚。捕获数据仅在缓冲冻结(`done`)时全量
推送;运行中的推送只带进度 + 各通道实时边沿计数。
脚,一个触发:**任意 GPIO** + 上升/下降/任意沿或"立即",客机时间窗口
最长 1 小时,自动重新武装)。读数数学、波形绘制器与浮动窗口都在
`peris/devices/la-lib.js`;绘制器只有一份实现,弹窗、详情迷你预览与行
图标共用。采样复用 M4 调度器,与位绑定设备一致(等触发时持 arm 租约,
录制期间每次边沿续租细批——`resolution` 配置决定细批:20/100/300 条指
令)。电平为驱动 1/0、客机释放引脚时 -1(Z);分析仪从不驱动任何东西,
因此声明 `passive`,合法搭接**任意设备**占用的焊盘(触发焊盘独立订阅、
无需是分析通道;且仅在固件驱动该焊盘时才可能触发——gpio_state 看不到
纯输入焊盘)。捕获数据仅在缓冲冻结(`done`)时全量推送;运行中的推送只
带进度 + 各通道实时边沿计数。
- **浮动窗口**(`la-lib.makeFloatingWindow`):大界面以可拖动、可调大小、
**非模态**弹窗打开——`position: fixed`、无遮罩,下层页面的一切指针事件
照常。几何位置钳制在视口内并持久化到 localStorage。此类窗口挂在
`document.body`(不在设备行内),其 View 必须实现 `destroy()` 在行销毁
时移除——面板在行移除与列表重建时都会调用 `destroy()`。View 内的多引
脚选择器通过 `host.pinInfo()` 将被占用 GPIO 置灰(与通用编辑器同一份、
按候选设备感知的引脚集合)。
脚选择器通过 `host.pinInfo()` 取得有效/已占用引脚集合(passive 探针的
网格从不禁用 GPIO,他设备占用的焊盘仅给 shared 提示)。
- **Saleae 风格绘图模型**(同在 `la-lib`):可缩放视图 `{t0, spanUs}` 配
`viewXform`/`clampView`/`zoomAt`(滚轮以光标为锚点缩放、拖拽平移、双击
复位适配),悬停测量(`pulseAround` 找光标所在脉冲,`pwmOf` 按相邻上升
+11 -5
View File
@@ -2,21 +2,27 @@
# Roadmap
## Logic analyzer — shipped in peris 0.10.0
## Logic analyzer — shipped in peris 0.10.0, probe freedoms in 0.11.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.
flag. Shared logic in `peris/devices/la-lib.js`.
- ✅ 0.11 probe freedoms: `static passive = true` — a probe listens only,
claims no pad and may clamp onto ANY in-use GPIO (conflict check and
wired-AND skip passive pads; other devices' pickers ignore them; the
channel grid never disables a GPIO). Trigger became `trigPin` (any GPIO,
independently subscribed, off-channel triggers fine); the window cap
lifted to 1 h of guest time (the edge buffer is the natural limit).
- ✅ 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).
(occupied pins shown as shared, never disabled). 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
+9 -5
View File
@@ -2,18 +2,22 @@
# 路线图
## 逻辑分析仪——已于 peris 0.10.0 交付
## 逻辑分析仪——已于 peris 0.10.0 交付,0.11.0 探针自由度增强
- ✅ `logic-analyzer.js`:多通道 GPIO 波形捕获(最多 8 个引脚,一个触
发:通道 + 边沿或"立即",客机时间窗口,自动重新武装)。驱动电平
1/0,释放引脚记 Z,时间为客机 µs。复用 M4 调度器(警戒 1000,细批随
`resolution` 预设:20/100/300);2 万条边沿的缓冲区写满带 `overflow`
冻结。共享逻辑在 `peris/devices/la-lib.js`;`sharedBus` 使其成为其他
总线设备引脚上的纯搭接。
冻结。共享逻辑在 `peris/devices/la-lib.js`。
- ✅ 0.11 探针自由度:`static passive = true`——探针只听不占,可搭接
**任意已占用 GPIO**(冲突检测与线与仲裁均跳过 passive 焊盘;其他设备
的选择器忽略它们;通道网格从不禁用 GPIO)。触发改为 `trigPin`(任意
GPIO、独立订阅、可在分析通道之外);窗口上限放宽到 1 小时客机时间
(边沿缓冲才是自然限制)。
- ✅ 浮动分析窗口:可拖动、可调大小、非模态弹窗(fixed 定位、无遮罩——
下层页面照常交互);几何位置持久化。一个 `drawWaveform` 绘制器同时供
弹窗、详情迷你预览与行图标使用;通道从 GPIO 网格勾选
(`host.pinInfo()` 将被占用引脚置灰)。框架:`ctx.pinLevel`、
弹窗、详情迷你预览与行图标使用;通道从 GPIO 网格勾选(被占用引脚仅
显示 shared 提示、从不禁用)。框架:`ctx.pinLevel`、
`ViewHost.pinInfo`、`View.destroy`(行移除/重建时的清理钩子)。
- ✅ Saleae 风格绘图交互:滚轮以光标为锚点缩放、拖拽平移、双击/F 复位
适配;悬停测量高亮所在脉冲并读出电平 · 脉宽 · 周期 · 频率 · PWM 占空
+1 -1
View File
@@ -13,6 +13,6 @@
{ "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": "la-1", "type": "logic-analyzer", "name": "I2C bus LA (SCL+SDA)", "pins": [1, 2], "durationMs": 40, "trigCh": -1 }
{ "id": "la-1", "type": "logic-analyzer", "name": "I2C bus LA (SCL+SDA)", "pins": [1, 2], "durationMs": 40, "trigPin": -1 }
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "esp-emu-peris",
"version": "0.10.3",
"version": "0.11.0",
"private": true,
"type": "module",
"description": "Simulated peripherals for the esp-emulator browser (wasm) build",
+56 -41
View File
@@ -43,27 +43,31 @@ const clampNum = (v, dflt, lo, hi) => {
export class Device {
static typeId = 'logic-analyzer';
static version = '1.0.0';
static version = '1.1.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',
name: 'LA', pins: [], durationMs: 50, resolution: 'normal', trigPin: -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;
/* A probe only listens — it never drives an input and claims no pad, so
* it may clamp onto ANY GPIO, including pads other devices own (an LED,
* an I2C pair, a MAX7219 bus) and other analyzers. */
static passive = true;
static pins(cfg) { return parsePins(cfg.pins); }
static configSchema = [
{ key: 'durationMs', label: 'Window (ms)', type: 'number', min: 1, max: 1000, step: 1 },
// window cap = 1 h of guest time; a dense signal hits the edge
// buffer long before and freezes flagged `overflow` (the natural
// limit — Saleae streams, we capture into a fixed buffer)
{ key: 'durationMs', label: 'Window (ms)', type: 'number', min: 1, max: 3600000, 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
// channels (pins), trigger pin/edge are edited in the analyzer
// window (they need the pickers); they live in defaultConfig
// so normaliseConfig keeps them across config round-trips
];
@@ -71,10 +75,15 @@ export class Device {
this.ctx = ctx;
this.cfg = cfg;
this.pins = parsePins(cfg.pins);
this.windowMs = clampNum(cfg.durationMs, 50, 1, 1000);
this.windowMs = clampNum(cfg.durationMs, 50, 1, 3600000);
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;
// trigger GPIO: any pad (it gets its own subscription), not only an
// analysed channel; -1 = Immediate. Note gpio_state() visibility:
// a trigger fires only when the FIRMWARE drives the pad (CPU GPIO
// writes) — pure-input pads (driven by our button devices) never
// toggle the output registers, so they cannot trigger.
const tp = Number(cfg.trigPin);
this.trigPin = Number.isInteger(tp) && tp >= 0 ? tp : -1;
this.trigEdge = ['any', 'rise', 'fall'].includes(cfg.trigEdge) ? cfg.trigEdge : 'any';
this.autoRun = false; // session control (Arm button), not config
@@ -91,7 +100,9 @@ export class Device {
this.lastEdgeCycles = -1;
this.lastMarkCycles = 0;
this.cpus = ctx.cyclesPerUs || 160;
if (this.pins.length) ctx.subscribe(this.pins);
const watch = this.trigPin >= 0 && !this.pins.includes(this.trigPin)
? [...this.pins, this.trigPin] : this.pins;
if (watch.length) ctx.subscribe(watch);
}
onEmuReady() {
@@ -107,10 +118,10 @@ export class Device {
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
// trigger watch: the edge must sit on the trigger GPIO 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 (pin !== this.trigPin) return;
if (this.trigEdge === 'rise' && level !== 1) return;
if (this.trigEdge === 'fall' && level !== 0 && level !== -1) return;
this._start(s.cycles, { pin, level });
@@ -153,7 +164,7 @@ export class Device {
if (this.status === 'run' || this.status === 'armed') return;
this.autoRun = !!(params && params.auto);
this.overflow = false;
if (this.trigCh >= 0 && this.pins.length) {
if (this.trigPin >= 0) {
this.status = 'armed';
this.ctx.armSampling(ARM_BATCH);
} else {
@@ -253,7 +264,7 @@ export class Device {
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) {
if (this.trigPin >= 0) {
this.status = 'armed'; // wait for the next trigger
this.ctx.armSampling(ARM_BATCH);
} else {
@@ -282,7 +293,7 @@ export class View {
this.el = el;
this.send = sendCmd;
this.host = host;
this.cfg = { name: 'LA', pins: [], durationMs: 50, resolution: 'normal', trigCh: -1, trigEdge: 'any' };
this.cfg = { name: 'LA', pins: [], durationMs: 50, resolution: 'normal', trigPin: -1, trigEdge: 'any' };
this.state = null;
this.autoUI = false; // session Auto re-arm checkbox (an Arm control)
this.hover = null;
@@ -382,9 +393,9 @@ export class View {
this.cfg = {
name: String(cfg.name ?? 'LA'),
pins: parsePins(cfg.pins),
durationMs: clampNum(cfg.durationMs, 50, 1, 1000),
durationMs: clampNum(cfg.durationMs, 50, 1, 3600000),
resolution: FINE_BATCHES[cfg.resolution] ? cfg.resolution : 'normal',
trigCh: Number.isInteger(Number(cfg.trigCh)) ? Number(cfg.trigCh) : -1,
trigPin: Number.isInteger(Number(cfg.trigPin)) ? Number(cfg.trigPin) : -1,
trigEdge: ['any', 'rise', 'fall'].includes(cfg.trigEdge) ? cfg.trigEdge : 'any',
};
if (this.win) {
@@ -433,9 +444,9 @@ export class View {
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.title = 'Capture starts on this GPIO\'s edge — any pad works, not only analysed channels (Immediate = start at Arm). The trigger needs the FIRMWARE to drive the pad; pure-input pads never toggle the output registers';
this.trigSel.addEventListener('change', () =>
this.host.updateConfig({ trigCh: Number(this.trigSel.value) }));
this.host.updateConfig({ trigPin: 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');
@@ -453,10 +464,10 @@ export class View {
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.min = '1'; this.winInput.max = '3600000'; this.winInput.step = '1';
this.winInput.title = 'Capture window length in guest milliseconds (emulator time is not wall time). Dense signals freeze earlier at the 20 000-edge buffer (shown as buffer full)';
this.winInput.addEventListener('change', () =>
this.host.updateConfig({ durationMs: clampNum(this.winInput.value, 50, 1, 1000) }));
this.host.updateConfig({ durationMs: clampNum(this.winInput.value, 50, 1, 3600000) }));
const winMs = document.createElement('span');
winMs.className = 'la-lab';
winMs.textContent = 'ms';
@@ -589,22 +600,26 @@ export class View {
const imm = document.createElement('option');
imm.value = '-1'; imm.textContent = 'Immediate';
this.trigSel.appendChild(imm);
for (let i = 0; i < c.pins.length; i++) {
// any valid GPIO may trigger — not only analysed channels, and
// including pads other devices own (a probe just listens)
const valid = this.host?.pinInfo?.()?.valid ?? c.pins.slice().sort((a, b) => a - b);
for (const p of valid) {
const o = document.createElement('option');
o.value = String(i);
o.textContent = `GPIO${c.pins[i]}`;
o.value = String(p);
o.textContent = `GPIO${p}`;
this.trigSel.appendChild(o);
}
const tc = c.trigCh >= 0 && c.trigCh < c.pins.length ? c.trigCh : -1;
this.trigSel.value = String(tc);
const tp = c.trigPin >= 0 && valid.includes(c.trigPin) ? c.trigPin : -1;
this.trigSel.value = String(tp);
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. */
/* Channel chips from the panel's pin info. A probe claims nothing, so
* NO GPIO is ever disabled — pads other devices own just get a "shared"
* hint (clamping a probe next to an LED disturbs nothing). */
_renderChans() {
if (!this.win) return;
const el = this.chansEl;
@@ -614,16 +629,13 @@ export class View {
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 shared = !on && used.has(p);
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'la-chan' + (on ? ' on' : '');
chip.className = 'la-chan' + (on ? ' on' : '') + (shared ? ' shared' : '');
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', () => {
chip.title = `GPIO${p} — click to ${on ? 'remove' : 'add'}` + (shared ? ' (shared with another device — harmless for a probe)' : '');
chip.addEventListener('click', () => {
const pins = this.cfg.pins.filter((x) => x !== p);
if (!on) {
if (pins.length >= MAX_CHANNELS) return;
@@ -678,7 +690,7 @@ export class View {
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 === 'armed') text = `armed — waiting for trigger: GPIO${this.cfg.trigPin} ${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
@@ -849,13 +861,16 @@ export class View {
}, 220);
}
/** Pan to the previous/next edge of the selected channel (zoom kept). */
/** Pan to the previous/next edge of the selected channel (zoom kept).
* Without a selection the trigger's channel is used when analysed,
* else channel 0. */
_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 ti = this.cfg.trigPin >= 0 ? st.channels.indexOf(this.cfg.trigPin) : -1;
ch = ti >= 0 ? ti : 0;
}
const flat = st.edges[ch] ?? [];
const times = [];
+4 -2
View File
@@ -481,9 +481,11 @@
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:hover { 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; }
/* pads owned by another device: pickable (a probe disturbs nothing),
just visually marked as shared */
.la-chan.shared { border-style: dashed; color: var(--faint); }
.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; }
+8 -3
View File
@@ -100,15 +100,18 @@ export class PerisRuntime {
// Pin conflict check (v1: one device per pin, any direction —
// except bus devices that declare sharedBus, which may share
// pins with each other: two I2C slaves on one SCL/SDA pair).
// pins with each other: two I2C slaves on one SCL/SDA pair.
// Passive probes (logic analyzer) only listen: they claim
// nothing and may overlap with ANY device).
const pins = meta.pins(cfg);
for (const p of pins) {
if (typeof p !== 'number' || p < 0 || p >= gpioCount(this.chip) || absentPins(this.chip).has(p)) {
errors.push(`${id}: pin ${String(p)} is not a valid ${this.chip} GPIO`);
continue;
}
if (meta.passive) continue; // probe pads are not claims
const claim = usedPins.get(p);
if (claim && !(claim.shared && meta.sharedBus)) {
if (claim && !(claim.shared && (meta.sharedBus || meta.passive))) {
errors.push(`${id}: pin ${p} already used by ${claim.id}`);
continue;
}
@@ -132,11 +135,13 @@ export class PerisRuntime {
// Open-drain arbitration map: pins claimed by more than one
// sharedBus device (the conflict rule above already rejected
// mixed sharing).
// mixed sharing). Passive probes never drive an input, so they
// stay out of the wired-AND entirely.
this.busPins = new Set();
this.pulls = new Map();
const claims = new Map<number, { shared: boolean }[]>();
for (const { meta, cfg, instance } of this.devices.values()) {
if (meta.passive) continue;
for (const p of meta.pins(cfg)) {
if (typeof p !== 'number') continue;
const list = claims.get(p) ?? [];
+9
View File
@@ -112,6 +112,15 @@ export interface DeviceMeta {
* Default false: one device per GPIO, either direction.
*/
readonly sharedBus?: boolean;
/**
* Passive probes (0.10.1): the device only LISTENS (never drives an
* input, never claims a pad) — its pins overlap with ANY other device
* legally and occupy nothing (other devices' pin pickers ignore it).
* The logic analyzer is the pattern: clamping a probe next to an LED
* disturbs nothing. Implies sharedBus-level sharing plus overlap with
* non-bus devices.
*/
readonly passive?: boolean;
/** GPIO pins the device claims (conflict detection). Required. */
pins(cfg: DeviceConfig): number[];
new (ctx: DeviceCtx, cfg: DeviceConfig): DeviceLike;
+1 -1
View File
@@ -2,4 +2,4 @@
* Project version (keep in sync with package.json and docs/CHANGELOG.md).
* Device plugins carry their own per-device version in Device.version.
*/
export const PERIS_VERSION = '0.10.3';
export const PERIS_VERSION = '0.11.0';
+4 -1
View File
@@ -148,7 +148,9 @@ export class PerisPanel {
/**
* Pins claimed by other devices, from the perspective of `candidate`
* (the device about to use the set): sharedBus devices ignore each
* other's pins — two I2C slaves on one bus is legal wiring.
* other's pins — two I2C slaves on one bus is legal wiring. Passive
* probes (logic analyzer) claim nothing: their pads never show as
* "in use" for anyone.
*/
private usedPins(exceptId: string, candidate?: DeviceMeta | null): Set<number> {
const used = new Set<number>();
@@ -156,6 +158,7 @@ export class PerisPanel {
if (d.id === exceptId) continue;
const meta = this.registry?.meta(d.type);
if (!meta) continue;
if (meta.passive) continue;
if (candidate?.sharedBus && meta.sharedBus) continue;
for (const p of meta.pins(d)) used.add(p);
}
+65 -8
View File
@@ -862,6 +862,33 @@ test('runtime: sharedBus devices share pins; non-bus devices still conflict', as
assert.equal(rt.has('led'), true);
});
test('runtime: passive probes overlap ANY device without a conflict', async () => {
const rt = new PerisRuntime();
const msgs = [];
rt.post = (m) => msgs.push(m);
rt.registry = {
has: (t) => ['led', 'i2c', 'la'].includes(t),
meta: (t) => (t === 'led' ? ledMod.Device : t === 'i2c' ? i2c.Device : la.Device),
View: () => null,
types: () => [],
errors: [],
};
// a probe clamped onto a plain LED pad, an I2C pair AND another probe
msgs.length = 0;
await rt.applyConfig({
perisVersion: 1,
devices: [
{ id: 'led', type: 'led', pin: 8 },
{ id: 'ee', type: 'i2c', scl: 1, sda: 2 },
{ id: 'la1', type: 'la', pins: [8, 1, 2] },
{ id: 'la2', type: 'la', pins: [8] },
],
});
const ready = msgs.find((m) => m.type === 'peris_ready');
assert.equal(ready.ok, true, ready.errors.join('; '));
assert.equal(rt.has('la1') && rt.has('la2') && rt.has('led'), true);
});
// ---------------------------------------------------------------------------
// Logic analyzer (0.10.0): la-lib readout math + the capture state machine
@@ -908,11 +935,11 @@ test('la-lib: axis ticks, formatting, fit span and the pixel transform', () => {
assert.ok(Math.abs(xToUs(usToX(123)) - 123) < 1e-9);
});
test('logic-analyzer: meta validation, pin claim, sharedBus probe semantics', () => {
test('logic-analyzer: meta validation, pin claim, passive probe semantics', () => {
assert.equal(validateDeviceModule(la), null);
assert.deepEqual(la.Device.pins({ pins: [1, 2] }), [1, 2]);
assert.deepEqual(la.Device.pins({ pins: [] }), []);
assert.equal(la.Device.sharedBus, true, 'a probe taps lines other bus devices drive');
assert.equal(la.Device.passive, true, 'a probe only listens — it claims no pad');
});
test('logic-analyzer: immediate capture records edges, freezes at window end', () => {
@@ -960,17 +987,17 @@ test('logic-analyzer: immediate capture records edges, freezes at window end', (
assert.equal(leases.released >= 2, true);
});
test('logic-analyzer: channel trigger — direction, wrong channel, t0 at the edge', () => {
test('logic-analyzer: GPIO trigger — direction, other pads, t0 at the edge', () => {
const { ctx, leases } = makeDevCtx({ levels: { 4: false, 5: false } });
const dev = new la.Device(ctx, {
id: 'la', type: 'logic-analyzer', pins: [4, 5], trigCh: 0, trigEdge: 'rise', durationMs: 5,
id: 'la', type: 'logic-analyzer', pins: [4, 5], trigPin: 4, trigEdge: 'rise', durationMs: 5,
});
dev.onCommand('arm');
assert.equal(dev.getState().status, 'armed');
assert.deepEqual(leases.armed, [la.ARM_BATCH], 'standing watch while waiting');
dev.onPinState(4, { out: false, oe: true, cycles: 1000 }); // falling: ignored
dev.onPinState(5, { out: true, oe: true, cycles: 1500 }); // wrong channel
dev.onPinState(5, { out: true, oe: true, cycles: 1500 }); // not the trigger pad
assert.equal(dev.getState().status, 'armed');
dev.onPinState(4, { out: true, oe: true, cycles: 16000 }); // rising on GPIO4
@@ -989,9 +1016,39 @@ test('logic-analyzer: channel trigger — direction, wrong channel, t0 at the ed
dev.destroy();
});
test('logic-analyzer: trigger GPIO may sit outside the analysed channels', () => {
const { ctx } = makeDevCtx({ levels: { 5: false } });
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [5], trigPin: 4, trigEdge: 'rise' });
dev.onCommand('arm');
assert.equal(dev.getState().status, 'armed');
dev.onPinState(5, { out: true, oe: true, cycles: 1000 }); // channel edge ≠ trigger
assert.equal(dev.getState().status, 'armed');
dev.onPinState(4, { out: true, oe: true, cycles: 2000 }); // off-channel trigger fires
let st = dev.getState();
assert.equal(st.status, 'run');
assert.equal(st.triggered, true);
assert.equal(st.channels.join(), '5', 'only the analysed channel is recorded');
ctx.setCycles(2000 + 50 * 1000 * 160 + 1); // default 50 ms window
dev.onBatch();
st = dev.getState();
assert.equal(st.status, 'done');
assert.deepEqual(st.edges[0], [], 'the off-channel trigger edge lands in no lane');
assert.deepEqual(st.initial, [0], 'channel levels still snapshot via pinLevel');
dev.destroy();
});
test('logic-analyzer: window clamps up to 1 h of guest time', () => {
const { ctx } = makeDevCtx();
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [1], durationMs: 999999999 });
assert.equal(dev.windowMs, 3600000, 'huge windows clamp at 1 h, not 1 s');
dev.onCommand('arm');
assert.equal(dev.getState().windowUs, 3600000 * 1000);
dev.destroy();
});
test('logic-analyzer: falling trigger counts a release-to-Z as an edge', () => {
const { ctx } = makeDevCtx({ levels: { 7: true } });
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [7], trigCh: 0, trigEdge: 'fall' });
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [7], trigPin: 7, trigEdge: 'fall' });
dev.onCommand('arm');
dev.onPinState(7, { out: true, oe: true, cycles: 100 });
assert.equal(dev.getState().status, 'armed', 'rising ignored');
@@ -1026,7 +1083,7 @@ test('logic-analyzer: stop / clear / auto re-arm / auto+immediate warns', () =>
// stop while merely armed disarms without touching data
{
const { ctx } = makeDevCtx();
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [2], trigCh: 0 });
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [2], trigPin: 2 });
dev.onCommand('arm');
assert.equal(dev.getState().status, 'armed');
dev.onCommand('stop');
@@ -1036,7 +1093,7 @@ test('logic-analyzer: stop / clear / auto re-arm / auto+immediate warns', () =>
// auto re-arm with a trigger: after done → armed again
{
const { ctx, leases } = makeDevCtx({ levels: { 3: false } });
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [3], trigCh: 0, durationMs: 1 });
const dev = new la.Device(ctx, { id: 'la', type: 'logic-analyzer', pins: [3], trigPin: 3, durationMs: 1 });
dev.onCommand('arm', { auto: true });
dev.onPinState(3, { out: true, oe: true, cycles: 1000 });
ctx.setCycles(1000 + 1000 * 160 + 1);