crosspad-mcp-server 8.1.2 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/.mcp.json +9 -0
  4. package/README.md +95 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +8 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +381 -57
  10. package/dist/index.js.map +1 -1
  11. package/dist/tools/idf-flash.js +2 -2
  12. package/dist/tools/idf-flash.js.map +1 -1
  13. package/dist/tools/idf-monitor.d.ts +3 -1
  14. package/dist/tools/idf-monitor.js +19 -3
  15. package/dist/tools/idf-monitor.js.map +1 -1
  16. package/dist/tools/midi.js +20 -16
  17. package/dist/tools/midi.js.map +1 -1
  18. package/dist/tools/symbols.d.ts +3 -1
  19. package/dist/tools/symbols.js +31 -1
  20. package/dist/tools/symbols.js.map +1 -1
  21. package/dist/tools/trace-buffer.d.ts +40 -0
  22. package/dist/tools/trace-buffer.js +74 -0
  23. package/dist/tools/trace-buffer.js.map +1 -0
  24. package/dist/tools/trace-device.d.ts +10 -0
  25. package/dist/tools/trace-device.js +26 -0
  26. package/dist/tools/trace-device.js.map +1 -0
  27. package/dist/tools/trace-doctor.d.ts +43 -0
  28. package/dist/tools/trace-doctor.js +150 -0
  29. package/dist/tools/trace-doctor.js.map +1 -0
  30. package/dist/tools/trace-export.d.ts +4 -0
  31. package/dist/tools/trace-export.js +14 -0
  32. package/dist/tools/trace-export.js.map +1 -0
  33. package/dist/tools/trace-session.d.ts +118 -0
  34. package/dist/tools/trace-session.js +346 -0
  35. package/dist/tools/trace-session.js.map +1 -0
  36. package/dist/tools/trace-symbols.d.ts +24 -0
  37. package/dist/tools/trace-symbols.js +44 -0
  38. package/dist/tools/trace-symbols.js.map +1 -0
  39. package/dist/tools/trace-webui.d.ts +53 -0
  40. package/dist/tools/trace-webui.js +222 -0
  41. package/dist/tools/trace-webui.js.map +1 -0
  42. package/dist/utils/device.d.ts +5 -0
  43. package/dist/utils/device.js +43 -15
  44. package/dist/utils/device.js.map +1 -1
  45. package/dist/utils/exec.js +26 -0
  46. package/dist/utils/exec.js.map +1 -1
  47. package/dist/utils/userConfig.d.ts +13 -0
  48. package/dist/utils/userConfig.js +43 -0
  49. package/dist/utils/userConfig.js.map +1 -0
  50. package/package.json +12 -4
  51. package/skills/crosspad/SKILL.md +58 -0
  52. package/skills/crosspad/reference/faq.md +40 -0
  53. package/skills/crosspad/reference/install.md +84 -0
  54. package/skills/crosspad/reference/repos.md +29 -0
  55. package/skills/crosspad/reference/role-contributor.md +64 -0
  56. package/skills/crosspad/reference/role-fw-dev.md +44 -0
  57. package/skills/crosspad/reference/role-user.md +49 -0
  58. package/skills/crosspad/reference/tools.md +68 -0
  59. package/skills/crosspad/scripts/doctor.sh +65 -0
  60. package/skills/crosspad/scripts/setup.sh +53 -0
  61. package/skills/swd-tracer/SKILL.md +135 -0
  62. package/skills/swd-tracer/reference/signals.md +42 -0
  63. package/skills/swd-tracer/scripts/detect-env.sh +61 -0
  64. package/skills/swd-tracer/scripts/install-udev-rules.sh +25 -0
  65. package/skills/swd-tracer/scripts/setup-venv.sh +26 -0
  66. package/tracer/PROTOCOL.md +260 -0
  67. package/tracer/README.md +327 -0
  68. package/tracer/swd_tracer.py +1083 -0
  69. package/tracer/ui/index.html +834 -0
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: swd-tracer
3
+ description: Use when tracing/plotting STM32 firmware variables in real time over SWD (ST-Link) on the CrossPad r20 board (CrossPad_STM32_r20 repo, STM32G0B1xx), or when the SWD tracer / pyOCD environment needs setting up or repairing. Covers the doctor→config→symbols→start→read/ui→stop workflow, signal-spec syntax (arrays/structs/whole-array expansion), configuring all four environments (pyOCD venv, user config paths, ST-Link udev rules, the Debug ELF), and recovering from no-probe / wedged-probe / halted-core / MCU-STOP conditions. The MCP tool is `crosspad_trace`.
4
+ ---
5
+
6
+ # CrossPad SWD real-time tracer
7
+
8
+ Live, **non-halting** plotting of firmware variables read straight from MCU RAM
9
+ over an ST-Link, à la ST-Studio / CubeMonitor — but driven by the agent. The MCP
10
+ server already lists *which* tools exist; this skill encodes *how* to set the
11
+ tracer up and drive it, and how to recover when the probe misbehaves.
12
+
13
+ **Target:** CrossPad r20 = **STM32G0B1xx (Cortex-M0+)** firmware in the
14
+ `CrossPad_STM32_r20` repo. Variables are resolved from the **Debug ELF** DWARF;
15
+ pyOCD polls their RAM addresses while the core keeps running. (Cortex-M0+ has no
16
+ ITM/SWO/DWT, so SWO/ITM "printf" is impossible — RAM polling is the mechanism.)
17
+
18
+ All operations go through one MCP tool, `crosspad_trace`, via its `action`:
19
+ `doctor · config_set · symbols · start · add · remove · status · read · save ·
20
+ device_state · ui · stop`.
21
+
22
+ ## First move: always `doctor`
23
+
24
+ ```
25
+ crosspad_trace action=doctor
26
+ ```
27
+
28
+ It reports `issues[]` and whether a probe is connected. Resolve blocking issues
29
+ (below) before `start`. If you're unsure what's configured, also run the
30
+ read-only environment probe:
31
+
32
+ ```
33
+ bash scripts/detect-env.sh
34
+ ```
35
+
36
+ The helper scripts are bundled next to this SKILL.md in `scripts/`. Resolve them
37
+ relative to the skill directory — e.g. `~/.claude/skills/swd-tracer/scripts/…`
38
+ (global skill) or `<crosspad-mcp>/skills/swd-tracer/scripts/…` (repo / plugin).
39
+
40
+ ## Configuring the four environments
41
+
42
+ The tracer needs four things in place. `doctor` / `detect-env.sh` tell you which
43
+ are missing; fix only those.
44
+
45
+ 1. **pyOCD venv** — the daemon runs in a Python venv (system Python is usually
46
+ PEP-668 locked). Create it and point config at it:
47
+ ```
48
+ bash scripts/setup-venv.sh
49
+ crosspad_trace action=config_set key=pyocd_python value=$HOME/.local/share/crosspad-mcp/venv/bin/python
50
+ ```
51
+ 2. **User config paths** — persisted to `~/.config/crosspad-mcp/config.json`.
52
+ Keys: `stm_elf_path` (the Debug ELF), `pyocd_python`, `probe_serial`
53
+ (optional; only if multiple ST-Links), `trace_dir` (where `.cptrace`/CSV go).
54
+ ```
55
+ crosspad_trace action=config_set key=stm_elf_path value=<repo>/build/Debug/CrossPad_STM32_r20.elf
56
+ ```
57
+ 3. **ST-Link udev rules** (Linux) — needed for libusb access without root:
58
+ ```
59
+ bash scripts/install-udev-rules.sh # sudo; then replug the ST-Link
60
+ ```
61
+ 4. **Debug ELF with symbols** — build it in the firmware repo so DWARF exists:
62
+ ```
63
+ cmake --preset Debug && cmake --build build/Debug
64
+ ```
65
+
66
+ Resolution order for every config value: `config.json` → env var → default. So
67
+ `config_set` wins; `CROSSPAD_STM_ELF` / `CROSSPAD_TRACE_PYTHON` /
68
+ `CROSSPAD_PROBE_SERIAL` / `CROSSPAD_TRACE_DIR` are fallbacks.
69
+
70
+ ## The trace loop
71
+
72
+ ```
73
+ doctor → green? continue
74
+ symbols [query=…] → discover variable names (rich metadata: kind/dims/members)
75
+ start signals=[…] rate_hz=N → spawns the daemon + opens the localhost UI
76
+ ui → get the http://localhost:7373/ dashboard URL
77
+ read [max_points] [window_…] → downsampled series + per-signal stats (cheap; LLM-safe)
78
+ add / remove signals=[…] → edit the watched set on a LIVE trace (no restart)
79
+ status → device_state, sample_count, actual_fs, signals
80
+ save → export buffer to CSV
81
+ stop → end the trace (always frees the probe)
82
+ ```
83
+
84
+ `rate_hz=0` means "as fast as the probe allows"; the real rate comes back as
85
+ `actual_fs` (ST-Link V2 + pyOCD tops out ~480 Hz total across all ranges).
86
+ Fewer/contiguous signals = higher Fs.
87
+
88
+ ## Signal-spec syntax
89
+
90
+ | Form | Resolves to |
91
+ |---|---|
92
+ | `s_vbat_mv` | a scalar global/static |
93
+ | `s_inputs[3]` | array element (bounds-checked against the DWARF length) |
94
+ | `mat[1][2]` | multi-dim element |
95
+ | `hpcd.Init.speed` | nested struct member |
96
+ | `s_adc_raw` / `s_adc_raw[*]` | **whole array** → expands to every element |
97
+ | `s_inputs[0:8]` | half-open slice → elements 0..7 |
98
+
99
+ Expansion is capped at 256 elements. A spec that lands on an aggregate (struct
100
+ with no trailing index) is reported as `unresolved`, not fatal. See
101
+ [reference/signals.md](reference/signals.md) for the CrossPad-specific cheat sheet
102
+ (pads, ADC channels, and the built-in `g_trace_demo.*` self-test signals).
103
+
104
+ ## Quick self-test (no real inputs needed)
105
+
106
+ If the firmware includes the demo module, `g_trace_demo.demo_sine` draws a clean
107
+ sine — the fastest "is the whole pipeline alive?" check:
108
+ ```
109
+ crosspad_trace action=start signals=["g_trace_demo.demo_sine","g_trace_demo.tick"] rate_hz=200
110
+ crosspad_trace action=read max_points=50
111
+ ```
112
+
113
+ ## Troubleshooting
114
+
115
+ | Symptom | Cause / fix |
116
+ |---|---|
117
+ | `doctor` → `no_probe_detected` | ST-Link not on USB. Plug it in. If it *vanished* mid-session (gone from `lsusb`), a prior wedge knocked it off — **physically replug** it; software can't revive it. |
118
+ | `start` returns `device_state: error: no debug probe detected` | Same — replug, re-run `doctor`. |
119
+ | `start` → `device_state: connect_timeout` / daemon exits | Probe wedged in libusb. The daemon fails fast (≤8 s) + exits now; replug and retry. |
120
+ | Values frozen / every signal a flat line | Core was **halted** on connect. The daemon uses `connect_mode=attach` to avoid this; if you see it, the firmware itself may be in a fault/STOP. Check `device_state`. |
121
+ | `device_state: stop_suspected` then `probe_lost` | MCU entered STOP (RAM unreadable) or the probe dropped. Brief STOP recovers automatically; persistent (>10 s) → daemon reports `probe_lost` and exits. |
122
+ | Out-of-bounds index silently "works" | It no longer does — `s_adc_raw[31]` on a `[15]` array is rejected. Use `symbols` to see real `dims`/`count`. |
123
+ | Deep STOP / low-power analysis | `crosspad_trace action=device_state` dumps PWR/RCC/SCB regs + decodes SLEEPDEEP/LPMS. |
124
+ | Permission/USB errors despite `lsusb` showing it | Missing udev rules — run `install-udev-rules.sh`, replug. |
125
+ | Daemon won't die / port stuck | `stop` escalates SIGTERM→SIGKILL on the daemon; the UI server is persistent and keeps listening (it is NOT torn down). If a stale daemon lingers, `pkill -9 -f swd_tracer.py`. |
126
+
127
+ ## Notes
128
+
129
+ - The web UI is loopback-only (127.0.0.1). The dashboard server is **persistent**:
130
+ it stays up across start/stop cycles (showing a `trace_end` banner while idle),
131
+ so an open tab survives between traces.
132
+ - `start` waits for the daemon's first frame and reports the *real* state
133
+ (`running` / `connecting` / `error`), not an optimistic guess.
134
+ - One active trace per server — `stop` before starting a different signal set,
135
+ or use `add`/`remove` to edit the live set.
@@ -0,0 +1,42 @@
1
+ # CrossPad r20 firmware — handy trace signals
2
+
3
+ Resolved from the Debug ELF DWARF. Names accept array/struct/expansion syntax
4
+ (`name`, `name[i]`, `name[i][j]`, `name.member`, `name[*]`, `name[a:b]`). Use
5
+ `crosspad_trace action=symbols query=<substr>` to discover more.
6
+
7
+ ## Inputs (reg_map.c, `s_inputs[47]`, uint8)
8
+ | Spec | Meaning |
9
+ |---|---|
10
+ | `s_inputs[0]` | encoder detent index |
11
+ | `s_inputs[1]` | pads 8–15 bitmap (PADS_HI) |
12
+ | `s_inputs[2]` | pads 0–7 bitmap (PADS_LO) |
13
+ | `s_inputs[3..18]` | per-pad pressure 0..127 (pad 0 = `[3]`) |
14
+ | `s_inputs[44]` | buttons bitmap (0x2C) |
15
+ | `s_inputs[*]` | whole input block (expands to 47 scalars) |
16
+
17
+ ## Power / ADC (power.c)
18
+ | Spec | Meaning |
19
+ |---|---|
20
+ | `s_adc_raw` | raw ADC DMA buffer, **15** uint16 channels (`ADC_BUF_SIZE`). `s_adc_raw[*]` plots all |
21
+ | `s_vbat_mv` | battery mV |
22
+ | `s_vbus_stm_mv` | VBUS (STM side) mV |
23
+ | `s_vbus_esp_mv` | VBUS (ESP side) mV |
24
+ | `s_temp_c` | die temp °C |
25
+
26
+ > NOTE: `s_adc_raw` is `[15]`, not 32 — indices ≥ 15 are rejected as out-of-bounds
27
+ > (the tracer bounds-checks against the DWARF array length).
28
+
29
+ ## Built-in demo signals (trace_demo.c — RTT-style, for a quick self-test)
30
+ Cortex-M0+ has **no ITM/SWO**, so "printf over SWD" here is a RAM ring polled
31
+ non-intrusively. Flash the firmware, then watch:
32
+
33
+ | Spec | Meaning |
34
+ |---|---|
35
+ | `g_trace_demo.tick` | monotonic main-loop counter |
36
+ | `g_trace_demo.demo_sine` | fixed-point sine, [-1000,+1000] |
37
+ | `g_trace_demo.demo_triangle` | fixed-point triangle, [-1000,+1000] |
38
+ | `g_trace_demo.loop_hz` | estimated main-loop rate |
39
+ | `g_trace_log_wr` | text-ring write count (watch it climb = logging active) |
40
+
41
+ `g_trace_demo.demo_sine` is the ideal "does the whole pipeline work?" signal —
42
+ it should draw a clean sine in the UI.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # Detect the tracer environment and print a readiness report + suggested config.
3
+ # Read-only: writes nothing. Run this first to see what (if anything) is missing.
4
+ set -uo pipefail
5
+
6
+ VENV="${CROSSPAD_TRACE_VENV:-$HOME/.local/share/crosspad-mcp/venv}"
7
+ CFG="${XDG_CONFIG_HOME:-$HOME/.config}/crosspad-mcp/config.json"
8
+
9
+ ok() { printf ' \033[32mOK\033[0m %s\n' "$1"; }
10
+ miss() { printf ' \033[31mMISS\033[0m %s\n' "$1"; }
11
+
12
+ echo "== SWD tracer environment =="
13
+
14
+ # 1. pyOCD venv
15
+ if [ -x "$VENV/bin/python" ] && "$VENV/bin/python" -c "import pyocd, elftools" 2>/dev/null; then
16
+ ok "pyOCD venv: $VENV/bin/python ($("$VENV/bin/python" -c 'import pyocd;print(pyocd.__version__)' 2>/dev/null))"
17
+ else
18
+ miss "pyOCD venv at $VENV — run scripts/setup-venv.sh"
19
+ fi
20
+
21
+ # 2. user config
22
+ if [ -f "$CFG" ]; then
23
+ ok "config: $CFG"
24
+ sed 's/^/ /' "$CFG"
25
+ else
26
+ miss "config $CFG — set keys via crosspad_trace action=config_set (stm_elf_path, pyocd_python)"
27
+ fi
28
+
29
+ # 3. Debug ELF (look in common spots if not configured)
30
+ ELF="${CROSSPAD_STM_ELF:-}"
31
+ if [ -z "$ELF" ]; then
32
+ ELF=$(ls "${CROSSPAD_STM_ROOT:-$HOME/GIT/CrossPad_STM32_r20}"/build/Debug/*.elf 2>/dev/null | head -1)
33
+ fi
34
+ if [ -n "$ELF" ] && [ -f "$ELF" ]; then
35
+ if "${VENV}/bin/python" - "$ELF" <<'PY' 2>/dev/null
36
+ import sys
37
+ from elftools.elf.elffile import ELFFile
38
+ sys.exit(0 if ELFFile(open(sys.argv[1],'rb')).has_dwarf_info() else 1)
39
+ PY
40
+ then ok "Debug ELF with DWARF: $ELF"
41
+ else miss "ELF $ELF has no DWARF — build the Debug preset with -g"
42
+ fi
43
+ else
44
+ miss "Debug ELF not found — build it (cmake --preset Debug && cmake --build build/Debug)"
45
+ fi
46
+
47
+ # 4. ST-Link on USB
48
+ if lsusb 2>/dev/null | grep -qiE '0483:(3744|3748|374b|374d|374e|374f|3753)'; then
49
+ ok "ST-Link present on USB"
50
+ else
51
+ miss "ST-Link NOT on USB — plug it in (or replug if it vanished after a wedged session)"
52
+ fi
53
+
54
+ # 5. udev rules
55
+ if ls /etc/udev/rules.d/*stlink* >/dev/null 2>&1; then
56
+ ok "ST-Link udev rules present"
57
+ else
58
+ miss "no ST-Link udev rules — run scripts/install-udev-rules.sh (needs sudo)"
59
+ fi
60
+
61
+ echo "== done =="
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Install ST-Link udev rules so pyOCD/libusb can claim the probe without root.
3
+ # Requires sudo. Without these, pyOCD may fail with permission/USB errors even
4
+ # though `lsusb` shows the device (st-info succeeding only proves the CURRENT
5
+ # user already happens to have access).
6
+ set -euo pipefail
7
+
8
+ RULE=/etc/udev/rules.d/49-stlink.rules
9
+ echo "[udev] writing $RULE (needs sudo)"
10
+ sudo tee "$RULE" >/dev/null <<'EOF'
11
+ # ST-Link/V1
12
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3744", MODE="0666", TAG+="uaccess"
13
+ # ST-Link/V2
14
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3748", MODE="0666", TAG+="uaccess"
15
+ # ST-Link/V2-1
16
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374b", MODE="0666", TAG+="uaccess"
17
+ # ST-Link/V3
18
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374d", MODE="0666", TAG+="uaccess"
19
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374e", MODE="0666", TAG+="uaccess"
20
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374f", MODE="0666", TAG+="uaccess"
21
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3753", MODE="0666", TAG+="uaccess"
22
+ EOF
23
+ sudo udevadm control --reload-rules
24
+ sudo udevadm trigger
25
+ echo "[udev] DONE. Replug the ST-Link for the new rules to take effect."
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # Create the pyOCD virtualenv the SWD tracer daemon runs in.
3
+ #
4
+ # The system Python is usually PEP-668 "externally managed" (Debian/Ubuntu), so
5
+ # pyocd/pyelftools cannot be pip-installed globally — a venv is required. The
6
+ # tracer's user config key `pyocd_python` must point at this venv's python.
7
+ set -euo pipefail
8
+
9
+ VENV="${CROSSPAD_TRACE_VENV:-$HOME/.local/share/crosspad-mcp/venv}"
10
+
11
+ echo "[setup-venv] target venv: $VENV"
12
+ if [ ! -x "$VENV/bin/python" ]; then
13
+ python3 -m venv "$VENV"
14
+ fi
15
+ "$VENV/bin/pip" install --quiet --upgrade pip
16
+ # pyocd 0.44+ for the SWD backend; pyelftools for DWARF symbol resolution.
17
+ "$VENV/bin/pip" install --quiet "pyocd>=0.44" pyelftools
18
+
19
+ echo "[setup-venv] installed:"
20
+ "$VENV/bin/python" - <<'PY'
21
+ import pyocd, elftools
22
+ print(" pyocd ", pyocd.__version__)
23
+ print(" pyelftools", getattr(elftools, "__version__", "?"))
24
+ PY
25
+ echo "[setup-venv] DONE. Set the tracer config:"
26
+ echo " crosspad_trace action=config_set key=pyocd_python value=$VENV/bin/python"
@@ -0,0 +1,260 @@
1
+ # CrossPad SWD tracer — wire protocol v2
2
+
3
+ Single source of truth shared by the Python daemon (`tracer/swd_tracer.py`), the
4
+ Node session/webui layer (`src/tools/trace-*.ts`), and the browser UI
5
+ (`tracer/ui/index.html`). v2 adds **runtime-editable watching** (add/remove
6
+ signals on a live trace) and richer **signal-spec resolution**.
7
+
8
+ **v2.1 additions** (this revision): whole-array / vector / matrix **expansion**
9
+ (§1.1), richer **symbol metadata** for UI autocomplete (§8), a `/symbols` HTTP
10
+ endpoint (§9), and a hard rule that `add`/`remove` return the **post-reconcile**
11
+ signal set (§4, §6) and that **Fs is reported ONCE globally** (§10).
12
+
13
+ All daemon I/O is NDJSON — one JSON object per line. stdout = machine frames,
14
+ stderr = human logs only.
15
+
16
+ ## 1. Signal spec grammar (resolved from the Debug ELF DWARF)
17
+
18
+ A *signal spec* names a memory location to poll. Supported syntax:
19
+
20
+ | Form | Meaning |
21
+ |---|---|
22
+ | `name` | global / static variable (base type, struct, or array) |
23
+ | `name[i]` | array element (existing) |
24
+ | `name[i][j]` | multi-dimensional array element |
25
+ | `name.member` | struct / union member |
26
+ | `name.a.b` | nested member chain |
27
+ | `name[i].member` / `name.member[j]` | any mix of `[index]` and `.member` |
28
+
29
+ Resolution walks DWARF: base symbol → `DW_AT_type`; `[i]` consumes an
30
+ `DW_TAG_array_type` dimension (stride = element byte size, multi-dim handled via
31
+ successive `DW_TAG_subrange_type`); `.member` consumes a `DW_TAG_structure_type`/
32
+ `DW_TAG_union_type` member (offset = `DW_AT_data_member_location`). The final
33
+ node must resolve to a scalar (`DW_TAG_base_type`, pointer, or enum) — a spec
34
+ that lands on an aggregate is **unresolved** UNLESS it expands (see §1.1).
35
+ Result: `{name: <original spec>, address, size, encoding}`.
36
+
37
+ ## 1.1 Whole-array / vector / matrix expansion
38
+
39
+ A spec that lands on (or leaves trailing) an array dimension **expands** into one
40
+ concrete scalar spec per element instead of being unresolved:
41
+
42
+ | Form | Expands to |
43
+ |---|---|
44
+ | `vec` (bare array name, element scalar) | `vec[0]`, `vec[1]`, … `vec[N-1]` |
45
+ | `vec[*]` | same as bare — all elements of that dimension |
46
+ | `vec[a:b]` | half-open slice `vec[a]` … `vec[b-1]` |
47
+ | `mat` (2-D array of scalar) | every cell `mat[0][0]` … `mat[R-1][C-1]` (row-major) |
48
+ | `mat[*][k]` / `mat[i][*]` | the selected row/column |
49
+ | `arr.field` where `arr` is array-of-struct | `arr[0].field` … (array dim before a member also expands) |
50
+
51
+ Expanded element names use the concrete `name[i]` / `name[i][j]` form so the
52
+ buffer/UI see distinct scalars. **Cap:** an expansion exceeding **256** elements
53
+ is skipped and reported in the `signals` frame's `unresolved` as
54
+ `"<spec> (expands to <N> > 256)"`. A spec landing on a struct/union (no trailing
55
+ array) is still unresolved. Expansion is computed daemon-side (DWARF knows the
56
+ bounds) for both the initial `--signals` set and live `add` commands.
57
+
58
+ `encoding ∈ {int, uint, float, bool, char, uchar, address}`.
59
+
60
+ ## 2. Daemon stdin commands (Node → daemon, NDJSON)
61
+
62
+ ```jsonc
63
+ {"cmd":"stop"} // graceful shutdown (existing)
64
+ {"cmd":"add","signals":["s_vbat_mv","s_inputs[3]"]} // add to live poll set
65
+ {"cmd":"remove","signals":["s_inputs[3]"]} // drop from live poll set
66
+ ```
67
+
68
+ `add`/`remove` mutate the poll set on the running daemon. The poll loop
69
+ re-coalesces ranges on the next iteration. Unknown / unresolvable specs in an
70
+ `add` are skipped (reported via a `signals` frame's `unresolved`), never fatal.
71
+ `remove` of an unknown name is a no-op.
72
+
73
+ ## 3. Daemon stdout frames (daemon → Node, NDJSON)
74
+
75
+ ```jsonc
76
+ // emitted once right after connect, AND after every successful add/remove:
77
+ {"type":"signals","signals":[{"name","address","size","encoding"}],"unresolved":["..."]}
78
+
79
+ {"type":"sample","t":<seconds float>,"values":{"<name>":<number>, ...}}
80
+ {"type":"status","device_state":"stop_suspected|stopped|...","t"?,"samples"?}
81
+ {"type":"error","error":"<message>"}
82
+ ```
83
+
84
+ `values` only contains the currently-watched signals; after an add the next
85
+ sample includes the new names, after a remove they disappear.
86
+
87
+ ## 4. Node TraceSession API (TS)
88
+
89
+ - `addSignals(string[]): Promise<string[]>` — writes `{"cmd":"add",...}` to
90
+ daemon stdin and resolves with the **post-reconcile** signal-name set once the
91
+ next `{"type":"signals"}` frame arrives (timeout ~2 s → resolves with the
92
+ current `buffer.signalNames()`). This kills the old race where the immediate
93
+ return showed the pre-reconcile set.
94
+ - `removeSignals(string[]): Promise<string[]>` — same, for remove.
95
+ - On a `{"type":"signals"}` frame: reconcile `buffer` signal set
96
+ (`buffer.addSignal`/`removeSignal`) and forward the frame to `onFrame`
97
+ subscribers (so the WS rebroadcasts the new set to browsers), then resolve any
98
+ pending add/remove promise.
99
+ - `TraceBuffer` gains `addSignal(name)` / `removeSignal(name)` updating
100
+ `signalNames()`. Stored samples already key values by name, so history of a
101
+ removed signal stays until it ages out of the ring.
102
+
103
+ ## 5. Node TraceWebUi — bidirectional WS
104
+
105
+ - Inbound browser → server messages (JSON): `{"cmd":"add"|"remove","signals":[...]}`.
106
+ Validate shape + cmd; forward to `session.addSignals/removeSignals`. Keep the
107
+ loopback-Origin check. Ignore malformed messages silently.
108
+ - Outbound server → browser: forwards every daemon frame (`sample`, `status`,
109
+ `error`, **`signals`**) plus the initial `{"type":"hello","signals":[...]}`.
110
+
111
+ ## 6. MCP tool (`crosspad_trace`) actions
112
+
113
+ - `add` / `remove`: require an active session; `await` the session promise and
114
+ return the **post-reconcile** `signals` (so the MCP response matches reality,
115
+ including any array expansion and dropped `unresolved` specs).
116
+ - `symbols`: return the richer metadata of §8.
117
+ - All other actions unchanged.
118
+
119
+ ## 7. Browser UI (talks §3/§5 over WS)
120
+
121
+ Receives `hello` → `signals` (live set updates) → `sample`/`status`. Sends
122
+ `add`/`remove`. Fetches `/symbols` (§9) for autocomplete. UI feature scope is
123
+ owned by the UI implementer; the hard contracts are the WS message shapes, the
124
+ `/symbols` shape (§8), and §10 (Fs shown once).
125
+
126
+ ## 8. Symbol metadata (richer `symbols` output — for autocomplete)
127
+
128
+ The daemon `symbols` subcommand and the `/symbols` endpoint return, per symbol,
129
+ in ADDITION to the existing `{name, address, encoding, size}`:
130
+
131
+ ```jsonc
132
+ {
133
+ "name": "s_adc_raw", "address": ..., "encoding": "uint", "size": 64,
134
+ "kind": "array", // "scalar" | "array" | "struct" | "union" | "other"
135
+ "dims": [32], // array only: per-dimension element counts
136
+ "count": 32, // array only: total elements (product of dims)
137
+ "elem_size": 2, // array only: one element's byte size
138
+ "elem_encoding": "uint", // array only: element scalar encoding
139
+ "members": ["a","b"] // struct/union only: member names (one level)
140
+ }
141
+ ```
142
+
143
+ `kind`/`dims`/`count`/`elem_*`/`members` are best-effort and may be omitted for
144
+ `other`. Back-compat: old consumers that read only `{name,address,encoding,size}`
145
+ keep working. The UI uses this to suggest base names, `name[i]`/`name[*]` for
146
+ arrays, and `name.member` for structs.
147
+
148
+ ## 9. `/symbols` HTTP endpoint (browser → Node)
149
+
150
+ `GET /symbols[?query=substr]` on the same loopback origin as the UI returns
151
+ `{"symbols":[ ... §8 entries ... ]}` (Content-Type `application/json`). The
152
+ webui obtains it via the existing Node `listSymbols()` bridge against the active
153
+ session's ELF. Loopback-bound like the rest of the UI server.
154
+
155
+ ## 10. Fs is reported ONCE, globally
156
+
157
+ Sample rate (Fs / actual_fs) is a property of the **whole trace**, not per
158
+ signal — every signal is sampled in the same poll loop. So Fs must be shown in
159
+ exactly ONE place (a single global readout / one `actual_fs` field), never
160
+ duplicated into each per-signal stats row. Per-signal stats keep value-domain
161
+ metrics only (min/max/mean/p2p/RMS/n/slope); the shared Fs lives outside them.
162
+
163
+ ## 11. Robustness contract (v2.2 — fail fast, never wedge)
164
+
165
+ The probe (ST-Link V2) can vanish from USB or wedge libusb after an unclean
166
+ session. The tracer MUST fail fast and surface it, never hang or require
167
+ `pkill -9`. Hard rules:
168
+
169
+ ### 11.1 Daemon connect
170
+ - `session_with_chosen_probe` is called with `blocking=False, return_first=True`
171
+ so a **missing probe returns immediately** → emit
172
+ `{"type":"error","error":"no debug probe detected (replug ST-Link)"}` and exit
173
+ code 3.
174
+ - Creating + opening the session runs inside a **watchdog thread** bounded by
175
+ `--connect-timeout` (default 8 s). If it does not complete in time the worker
176
+ is wedged in uninterruptible C (libusb) — flush an
177
+ `{"type":"error","error":"connect timeout after Ns (probe wedged? replug)"}`
178
+ frame and `os._exit(2)`. The main process dies; the OS reclaims the wedged
179
+ thread. This is the ONLY correct recovery for a libusb wedge.
180
+ - Any other connect exception → `{"type":"error","error":...}` + exit 1.
181
+ - Applies to BOTH `trace` and `device-state` (device-state uses a shorter 6 s).
182
+
183
+ ### 11.2 Daemon run-loop disconnect
184
+ - A read fault emits `{"type":"status","device_state":"stop_suspected"}` as
185
+ before, BUT the daemon now tracks fault duration. If faults persist longer
186
+ than `--lost-timeout` (default 10 s) it emits
187
+ `{"type":"error","error":"probe/target lost (persistent read fault Ns)"}` and
188
+ exits — instead of looping `stop_suspected` forever. A successful read resets
189
+ the fault timer (a genuine MCU STOP that resumes keeps tracing).
190
+
191
+ ### 11.3 ELF / DWARF guard
192
+ - `build_symbol_table` / symbol resolution is wrapped: a bad/missing ELF emits
193
+ `{"type":"error","error":...}` (not a raw traceback) and the daemon exits.
194
+
195
+ ### 11.4 device_state vocabulary
196
+ `connecting` (before first frame) · `running` · `stop_suspected` · `probe_lost`
197
+ · `connect_timeout` · `stopped` · `error: <msg>` · `exited`.
198
+
199
+ ### 11.5 Node teardown — guaranteed kill
200
+ `TraceSession.stop()`: write `{"cmd":"stop"}`, then `SIGTERM` after ~1.5 s, then
201
+ **`SIGKILL` after a further ~3 s if the process is still alive** (a wedged daemon
202
+ ignores SIGTERM). Always tear down the web UI first (existing behavior).
203
+
204
+ ### 11.6 Node diagnostics + start truthfulness
205
+ - The daemon's **stderr is captured** (ring of the last ~30 lines), exposed as
206
+ `session.stderrTail()` and folded into the device_state/error surfaced by the
207
+ MCP `status`/`start`.
208
+ - MCP `start` **waits up to ~3 s for the first frame** (`signals`|`sample`|
209
+ `error`) and returns a device_state reflecting reality
210
+ (`running` vs `connect_timeout` vs `error: …`) instead of an optimistic
211
+ `running` that masks a dead connect.
212
+ - If the daemon exits non-zero with no error frame, `device_state` becomes
213
+ `error: <stderr tail>`.
214
+
215
+ ### 11.7 Doctor probe presence
216
+ `doctor` runs a real probe-presence check (`pyocd list` via the daemon python,
217
+ or `st-info --probe`) and reports a **blocking** issue
218
+ `no_probe_detected` when none is connected (distinct from the udev warning).
219
+ `start` refuses (clear error) when doctor reports `no_probe_detected`.
220
+
221
+ ## 12. Persistent dashboard + reconnect + auto-open (v2.3)
222
+
223
+ The dashboard must feel permanent: open it once (ideally as a VS Code **Simple
224
+ Browser** tab) and it survives every trace start/stop without going dead.
225
+
226
+ ### 12.1 Persistent UI server (decoupled from the session)
227
+ - The web UI HTTP+WS server is a **module-level singleton that OUTLIVES trace
228
+ sessions** — `stop` ends the daemon but the server keeps listening. A
229
+ browser/VS Code tab stays connected across start→stop→start cycles.
230
+ - The singleton holds a mutable `currentSession`. `start` binds the new session
231
+ (subscribes to its frames); `stop` unbinds (server goes idle but stays up).
232
+ - `TraceSession.stop()` no longer tears the UI down; the singleton owns the
233
+ server lifecycle. The port (7373) is therefore stable across traces.
234
+
235
+ ### 12.2 WS messages — new server→client
236
+ - `hello` gains fields: `{"type":"hello","active":bool,"signals":[...]}`.
237
+ - `{"type":"trace_start","signals":[...]}` — a new trace began; UI resets and
238
+ starts plotting the new set.
239
+ - `{"type":"trace_end"}` — the trace stopped; UI keeps the last data but shows an
240
+ idle/"waiting for next trace" state. The WS stays connected (server is up).
241
+ - `sample`/`status`/`error`/`signals` continue, scoped to the bound session.
242
+
243
+ ### 12.3 `/symbols` when idle
244
+ With no active session, `/symbols` falls back to the **configured default ELF**
245
+ so autocomplete keeps working between traces.
246
+
247
+ ### 12.4 Auto-open on `start` (unless already open)
248
+ On `start`, after ensuring the singleton server is up, open the dashboard in the
249
+ user's browser **only if no WS client is currently connected** (i.e. it isn't
250
+ already open — covers both an external browser and a VS Code Simple Browser tab).
251
+ Platform opener (`xdg-open`/`open`/`start`), detached, best-effort, never throws.
252
+ Skip when headless (no `DISPLAY`/`WAYLAND_DISPLAY` on Linux) or when
253
+ `CROSSPAD_TRACE_NO_BROWSER` is set. Because the server persists, a VS Code tab
254
+ opened once stays connected, so auto-open never re-pops it.
255
+
256
+ ### 12.5 UI reconnect
257
+ The UI has a visible **Reconnect** button AND an automatic reconnect loop (with
258
+ backoff) that survives server restarts and `trace_end`. Idle shows "waiting for
259
+ trace…"; `trace_end` shows a "trace ended" banner; reconnect re-establishes the
260
+ WS and re-syncs the signal set from the fresh `hello`.