crosspad-mcp-server 8.1.2 → 9.0.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 +369 -49
  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 +1066 -0
  69. package/tracer/ui/index.html +834 -0
@@ -0,0 +1,834 @@
1
+ <!doctype html>
2
+ <html><head><meta charset="utf-8"><title>CrossPad SWD Trace</title>
3
+ <style>
4
+ *{box-sizing:border-box}
5
+ body{font:13px monospace;margin:0;background:#111;color:#ddd}
6
+ #wrap{display:flex;height:100vh}
7
+ #side{width:340px;min-width:340px;padding:8px;overflow:auto;border-right:1px solid #333;display:flex;flex-direction:column;gap:8px}
8
+ #main{flex:1;display:flex;flex-direction:column;min-width:0}
9
+ canvas{flex:1;background:#000;display:block}
10
+ .sw{width:10px;height:10px;border-radius:2px;display:inline-block}
11
+ button{background:#222;color:#ddd;border:1px solid #444;padding:3px 8px;cursor:pointer;border-radius:3px}
12
+ button:hover{background:#2c2c2c}
13
+ button.active{background:#264;border-color:#5a5}
14
+ input,select{background:#1a1a1a;color:#ddd;border:1px solid #444;padding:2px 4px;border-radius:3px;font:12px monospace}
15
+ #bar{padding:4px;display:flex;gap:6px;border-bottom:1px solid #333;align-items:center;flex-wrap:wrap}
16
+ #bar #fs{margin-left:auto;color:#6cf;font-weight:bold}
17
+ #bar #stat{color:#9c9}
18
+ h3,h4{margin:4px 0;font-weight:normal;color:#888;text-transform:uppercase;font-size:11px;letter-spacing:1px}
19
+ .panel{border:1px solid #2a2a2a;border-radius:4px;padding:6px;background:#161616}
20
+ .panel.collapsed .body{display:none}
21
+ .panel h3{cursor:pointer;user-select:none;display:flex;align-items:center;gap:4px}
22
+ .panel h3 .arrow{color:#666}
23
+ .row{display:flex;gap:4px;align-items:center;margin:3px 0}
24
+ .row label{flex:1;color:#aaa}
25
+ .row input[type=number]{width:90px}
26
+ .presets{display:flex;flex-wrap:wrap;gap:4px}
27
+ .presets button{font-size:11px;padding:2px 6px}
28
+ #addrow{display:flex;gap:4px}
29
+ #addrow input{flex:1}
30
+ table{width:100%;border-collapse:collapse;font-size:11px}
31
+ #tbl td{padding:1px 3px;vertical-align:top}
32
+ .sigrow{border-bottom:1px solid #222}
33
+ .signame{cursor:pointer;word-break:break-all}
34
+ .rm{color:#a44;cursor:pointer;font-weight:bold;padding:0 4px}
35
+ .rm:hover{color:#f66}
36
+ .stats{font-size:10px;color:#999;padding-left:18px}
37
+ .stats span{display:inline-block;margin-right:8px}
38
+ .stats b{color:#ccc;font-weight:normal}
39
+ #toast{position:fixed;top:8px;right:8px;max-width:380px;display:flex;flex-direction:column;gap:4px;z-index:10}
40
+ .t{background:#3a1a1a;border:1px solid #a44;color:#fcc;padding:6px 10px;border-radius:4px;font-size:11px;box-shadow:0 2px 8px #000a}
41
+ .t.info{background:#1a2a3a;border-color:#48a;color:#cdf}
42
+ .off{opacity:.4}
43
+ .muted{color:#777}
44
+ /* connection indicator (top bar) */
45
+ #bar .conn{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:#999}
46
+ .cdot{font-size:11px;line-height:1;transition:color .2s}
47
+ .cdot.ok{color:#5c5} /* connected */
48
+ .cdot.idle{color:#cb5} /* connected but no live trace */
49
+ .cdot.retry{color:#c95} /* reconnecting (backoff) */
50
+ .cdot.retry{animation:blink 1s steps(1) infinite}
51
+ .cdot.down{color:#a44} /* disconnected, not yet retrying */
52
+ @keyframes blink{50%{opacity:.25}}
53
+ #reconnect{font-size:11px;padding:2px 6px}
54
+ /* idle / ended banner — unobtrusive strip under the top bar */
55
+ .banner{padding:4px 10px;font-size:11px;border-bottom:1px solid #333;background:#1a1d22;color:#bb9}
56
+ .banner.ended{background:#221a1a;color:#caa}
57
+ .banner.live{display:none!important}
58
+ </style></head>
59
+ <body>
60
+ <div id="toast"></div>
61
+ <div id="wrap">
62
+ <div id="side">
63
+ <div class="panel" id="p-watch">
64
+ <h3 data-tgl><span class="arrow">▾</span> Watch list</h3>
65
+ <div class="body">
66
+ <div id="addrow">
67
+ <input id="addInput" list="symDL" autocomplete="off" placeholder="s_vbat_mv, s_inputs[3], foo.bar" title="comma-separated signal specs (autocomplete from ELF symbols)">
68
+ <!-- datalist is repopulated per typed token (see refreshDatalist) so the
69
+ native dropdown suggests names for the current comma-separated token -->
70
+ <datalist id="symDL"></datalist>
71
+ <button id="addBtn">Add</button>
72
+ </div>
73
+ <h4>Presets</h4>
74
+ <div class="presets" id="presets"></div>
75
+ <div style="margin-top:4px"><button id="clearAll">Clear all</button></div>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="panel" id="p-cfg">
80
+ <h3 data-tgl><span class="arrow">▾</span> Config</h3>
81
+ <div class="body">
82
+ <div class="row"><label>Trailing window (s)</label><input type="number" id="cfgWindow" value="15" min="0.1" step="1"></div>
83
+ <div class="row"><label>Full history</label><input type="checkbox" id="cfgFull"></div>
84
+ <div class="row"><label>Max samples / signal</label><input type="number" id="cfgMax" value="100000" min="100" step="1000"></div>
85
+ <div class="row"><label>Y-axis mode</label>
86
+ <select id="cfgYMode">
87
+ <option value="shared">Shared auto-Y</option>
88
+ <option value="norm">Per-signal normalized</option>
89
+ </select></div>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="panel" id="p-sigs">
94
+ <h3 data-tgl><span class="arrow">▾</span> Signals &amp; stats</h3>
95
+ <div class="body"><table id="tbl"></table>
96
+ <div id="noSigs" class="muted" style="padding:4px">No signals watched.</div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <div id="main">
102
+ <div id="bar">
103
+ <button id="pause">Pause</button>
104
+ <button id="auto" title="Reset view to live trailing window">Reset view</button>
105
+ <!-- Single global Fs readout (PROTOCOL §10): sample rate is a property of the
106
+ whole trace, not per-signal, so it lives here in exactly one place. -->
107
+ <span id="fs" class="muted" title="Trace sample rate (global)">Fs —</span>
108
+ <!-- Connection indicator + forced reconnect (PROTOCOL §12.5). The dot color +
109
+ glyph reflect the WS state machine (connected / reconnecting / disconnected);
110
+ the text gives a human-readable state. -->
111
+ <span id="conn" class="conn" title="WebSocket connection state"><span id="connDot" class="cdot">●</span><span id="connTxt">connecting…</span></span>
112
+ <button id="reconnect" title="Force-reconnect the WebSocket now">Reconnect</button>
113
+ <span id="stat" class="muted">connecting…</span>
114
+ </div>
115
+ <!-- Idle / ended banner (PROTOCOL §12.5): shown when no trace is live. Last
116
+ captured data stays plotted underneath so history is still pannable. -->
117
+ <div id="banner" class="banner" style="display:none"></div>
118
+ <canvas id="cv"></canvas>
119
+ </div>
120
+ </div>
121
+ <script>
122
+ "use strict";
123
+ /* ---------- palette + stable color assignment ----------
124
+ Colors are assigned from a rotating index so a signal keeps its color
125
+ across reconciles even if other signals are added/removed. */
126
+ const palette=["#4af","#fa4","#4f8","#f48","#af4","#8af","#fd4","#f84","#6df","#d6f","#9f6","#f96"];
127
+ let colorIdx=0;
128
+ function nextColor(){return palette[(colorIdx++)%palette.length];}
129
+
130
+ /* sigs: name -> {color,on,data:[{t,v}],address,size,encoding} */
131
+ const sigs=new Map();
132
+
133
+ /* ---------- symbol autocomplete ("podpowiedzi zmiennych") ----------
134
+ On load we GET /symbols (PROTOCOL §8/§9) and build a flat list of
135
+ suggestion strings from the metadata. The list feeds a native <datalist>
136
+ bound to the add input, repopulated per typed token so comma-separated
137
+ entry still gets relevant suggestions. Free-form expansion specs the user
138
+ types (vec[*], vec[a:b], mat[*][k]) are NEVER blocked — the datalist only
139
+ ASSISTS; the server expands whatever is actually submitted.
140
+
141
+ suggestList: array of suggestion strings (deduped). */
142
+ let suggestList=[];
143
+
144
+ /* Build suggestion strings from one symbol metadata entry (§8 shape). */
145
+ function suggestionsForSymbol(sym){
146
+ const out=[];
147
+ const name=sym&&typeof sym.name==="string"?sym.name:null;
148
+ if(!name)return out;
149
+ const kind=sym.kind;
150
+ if(kind==="array"){
151
+ // arrays: base name (expands server-side), an explicit first element, and
152
+ // the wildcard form. Hint the element count via the label when known.
153
+ const cnt=(typeof sym.count==="number"&&sym.count>0)?sym.count
154
+ :(Array.isArray(sym.dims)?sym.dims.reduce((a,b)=>a*(b||1),1):null);
155
+ out.push(name); // bare array name → whole-array expansion
156
+ out.push(name+"[0]"); // first element
157
+ out.push(name+"[*]"); // all elements (wildcard)
158
+ // For 2-D arrays also offer a row-wildcard hint.
159
+ if(Array.isArray(sym.dims)&&sym.dims.length>=2){
160
+ out.push(name+"[0][0]");
161
+ out.push(name+"[*][0]");
162
+ }
163
+ void cnt; // count is surfaced via the datalist label, see refreshDatalist
164
+ }else if(kind==="struct"||kind==="union"){
165
+ out.push(name); // base (may be unresolved if aggregate, but harmless to suggest)
166
+ if(Array.isArray(sym.members)){
167
+ for(const mb of sym.members){ if(typeof mb==="string"&&mb) out.push(name+"."+mb); }
168
+ }
169
+ }else{
170
+ // scalar / other / unknown kind: just the base name
171
+ out.push(name);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /* symMeta: name -> entry, used to annotate datalist options with a count/dims
177
+ hint. */
178
+ const symMeta=new Map();
179
+
180
+ /* Fetch /symbols and rebuild suggestList. Degrades gracefully: any failure
181
+ (older server, no active session, non-JSON) just leaves autocomplete empty
182
+ and the input still works for manual entry. */
183
+ function loadSymbols(){
184
+ fetch("/symbols").then(r=>{
185
+ if(!r.ok) throw new Error("HTTP "+r.status);
186
+ return r.json();
187
+ }).then(j=>{
188
+ const arr=(j&&Array.isArray(j.symbols))?j.symbols:[];
189
+ const seen=new Set();
190
+ const list=[];
191
+ symMeta.clear();
192
+ for(const sym of arr){
193
+ if(sym&&typeof sym.name==="string") symMeta.set(sym.name,sym);
194
+ for(const sug of suggestionsForSymbol(sym)){
195
+ if(!seen.has(sug)){ seen.add(sug); list.push(sug); }
196
+ }
197
+ }
198
+ list.sort();
199
+ suggestList=list;
200
+ refreshDatalist(); // populate with the current token's matches
201
+ }).catch(()=>{ /* no autocomplete — manual entry still works */ });
202
+ }
203
+
204
+ /* Split the input on commas and return the token the caret is in (the one the
205
+ user is actively typing), so we can suggest for THAT token only. */
206
+ function currentToken(inputEl){
207
+ const val=inputEl.value;
208
+ const caret=(inputEl.selectionStart!=null)?inputEl.selectionStart:val.length;
209
+ // find comma boundaries around the caret
210
+ let start=val.lastIndexOf(",",caret-1)+1;
211
+ let end=val.indexOf(",",caret);
212
+ if(end<0)end=val.length;
213
+ return val.slice(start,end).trim();
214
+ }
215
+
216
+ /* Repopulate the <datalist> with suggestions matching the current token.
217
+ Native <datalist> on most browsers matches against the WHOLE field value,
218
+ not the active token, so for comma-separated entry we inject full-field
219
+ completions (prefix + matched suggestion) as the option VALUE. That makes
220
+ selecting an option replace just the active token while preserving earlier
221
+ tokens. Falls back to whole-list when the field is empty. */
222
+ function refreshDatalist(){
223
+ const inp=document.getElementById("addInput");
224
+ const dl=document.getElementById("symDL");
225
+ if(!inp||!dl)return;
226
+ const val=inp.value;
227
+ const caret=(inp.selectionStart!=null)?inp.selectionStart:val.length;
228
+ const tokStart=val.lastIndexOf(",",caret-1)+1;
229
+ let tokEnd=val.indexOf(",",caret); if(tokEnd<0)tokEnd=val.length;
230
+ const prefix=val.slice(0,tokStart); // earlier tokens (kept verbatim)
231
+ const suffix=val.slice(tokEnd); // later tokens (kept verbatim)
232
+ const tok=val.slice(tokStart,tokEnd).trim();
233
+ const tokLc=tok.toLowerCase();
234
+ // case-insensitive substring match; cap the option count so the dropdown
235
+ // stays responsive on large symbol tables.
236
+ let matches=suggestList;
237
+ if(tok){
238
+ const pref=[], sub=[];
239
+ for(const s of suggestList){
240
+ const sl=s.toLowerCase();
241
+ if(sl.startsWith(tokLc)) pref.push(s);
242
+ else if(sl.indexOf(tokLc)>=0) sub.push(s);
243
+ }
244
+ matches=pref.concat(sub);
245
+ }
246
+ matches=matches.slice(0,200);
247
+ const lead=prefix.length?(prefix.replace(/\s*$/,"")+" "):""; // tidy spacing after comma
248
+ let html="";
249
+ for(const m of matches){
250
+ // option value = full field text with the active token replaced, so picking
251
+ // it preserves the comma-separated context.
252
+ const full=lead+m+suffix;
253
+ const meta=symMeta.get(baseName(m));
254
+ let label=m;
255
+ if(meta&&meta.kind==="array"){
256
+ const cnt=(typeof meta.count==="number")?meta.count
257
+ :(Array.isArray(meta.dims)?meta.dims.join("×"):null);
258
+ if(cnt!=null) label=m+" ("+(Array.isArray(meta.dims)?meta.dims.join("×")+", ":"")+"n="+(meta.count!=null?meta.count:"?")+")";
259
+ }else if(meta&&(meta.kind==="struct"||meta.kind==="union")){
260
+ label=m+" ("+meta.kind+")";
261
+ }
262
+ html+=`<option value="${esc(full)}" label="${esc(label)}"></option>`;
263
+ }
264
+ dl.innerHTML=html;
265
+ }
266
+
267
+ /* Strip the [i]/[*]/.member suffix to get the base symbol name for metadata
268
+ lookup. */
269
+ function baseName(spec){
270
+ const m=/^[A-Za-z_$][A-Za-z0-9_$]*/.exec(spec);
271
+ return m?m[0]:spec;
272
+ }
273
+
274
+ /* ---------- config (client-side only) ---------- */
275
+ const cfg={windowSec:15,full:false,maxSamples:100000,yMode:"shared"};
276
+
277
+ let paused=false, viewT0=null, viewT1=null;
278
+
279
+ /* ---------- global Fs (PROTOCOL §10) ----------
280
+ Sample rate is a property of the whole trace. We estimate it ONCE, globally,
281
+ from the spacing of incoming `sample` frame timestamps (one `t` per poll
282
+ iteration, shared by all signals). A short ring of recent sample timestamps
283
+ gives a smoothed rate. If a `status`/other frame ever reports an explicit
284
+ `actual_fs`/`fs`, that authoritative value wins. The result is shown in a
285
+ single readout in the top bar — never per-signal. */
286
+ const fsState={
287
+ stamps:[], // recent sample-frame timestamps (seconds)
288
+ reported:null, // authoritative Fs from a frame, if any
289
+ lastShown:"" // last text rendered, to avoid needless DOM writes
290
+ };
291
+ const FS_RING=64; // window of timestamps used for the estimate
292
+ /* Record one sample-frame timestamp into the ring. */
293
+ function fsNoteSample(t){
294
+ if(typeof t!=="number"||!isFinite(t))return;
295
+ const r=fsState.stamps;
296
+ // ignore duplicate/out-of-order timestamps so the estimate stays sane
297
+ if(r.length && t<=r[r.length-1]) return;
298
+ r.push(t);
299
+ if(r.length>FS_RING) r.shift();
300
+ }
301
+ /* Compute the current global Fs estimate (Hz) or null if unknown. */
302
+ function fsEstimate(){
303
+ if(fsState.reported!=null && isFinite(fsState.reported) && fsState.reported>0) return fsState.reported;
304
+ const r=fsState.stamps;
305
+ if(r.length<2) return null;
306
+ const dt=r[r.length-1]-r[0];
307
+ if(!(dt>0)) return null;
308
+ return (r.length-1)/dt;
309
+ }
310
+ const cv=document.getElementById("cv"), ctx=cv.getContext("2d");
311
+ function fit(){cv.width=cv.clientWidth;cv.height=cv.clientHeight;}
312
+ window.addEventListener("resize",fit); fit();
313
+
314
+ /* ---------- toasts (unresolved specs / errors / info) ---------- */
315
+ function toast(msg,kind){
316
+ const box=document.getElementById("toast");
317
+ const el=document.createElement("div");
318
+ el.className="t"+(kind==="info"?" info":"");
319
+ el.textContent=msg;
320
+ box.appendChild(el);
321
+ setTimeout(()=>{el.style.opacity="0";el.style.transition="opacity .4s";},kind==="info"?2500:4500);
322
+ setTimeout(()=>el.remove(),kind==="info"?2900:4900);
323
+ }
324
+
325
+ /* ---------- WebSocket + connection/trace state machine (PROTOCOL §12.5) ----------
326
+
327
+ The Node UI server is now a PERSISTENT singleton: it stays up across trace
328
+ start/stop and only the bound session comes and goes. So the UI must treat
329
+ the socket and the trace as two INDEPENDENT lifecycles:
330
+
331
+ connState — the WebSocket link itself:
332
+ "connected" socket open, talking to the server
333
+ "reconnecting" socket closed, backoff timer pending a retry (loops forever)
334
+ "disconnected" transient: just closed, about to schedule a retry
335
+
336
+ traceState — whether a trace is actively producing samples (within a connection):
337
+ "active" a trace is running (hello.active=true / trace_start) → live plotting
338
+ "idle" no trace right now (hello.active=false / trace_end) → keep last data,
339
+ stop expecting samples, show the idle/ended banner
340
+
341
+ The two combine into what the user sees: the connection dot/indicator reflects
342
+ connState; the banner reflects traceState (only meaningful while connected).
343
+
344
+ Reconnect loop: on close we schedule a retry with exponential backoff
345
+ (RECONNECT_MIN → RECONNECT_MAX) and KEEP retrying forever. A successful open
346
+ resets the backoff. The same loop is reused by the manual Reconnect button
347
+ (which just force-closes and immediately retries at the minimum delay). This
348
+ survives the server briefly restarting, trace_end, and the tab being
349
+ backgrounded (timers fire on foreground; a backgrounded close just queues a
350
+ retry that runs when the tab wakes — and we also retry on visibilitychange). */
351
+
352
+ let ws=null;
353
+ let connState="reconnecting"; // start pessimistic; flips to "connected" on open
354
+ let traceState="idle"; // no live trace until hello.active/trace_start says so
355
+ let reconnectTimer=null; // pending backoff timer handle
356
+ let reconnectDelay=0; // current backoff delay (ms)
357
+ let manualClose=false; // true while we intentionally tear down for a forced reconnect
358
+ const RECONNECT_MIN=500; // first retry delay
359
+ const RECONNECT_MAX=5000; // backoff cap
360
+
361
+ /* Open a fresh socket. Never throws; any failure falls through to onclose →
362
+ scheduleReconnect, so the loop is self-healing. */
363
+ function connect(){
364
+ // clear any pending retry — we're connecting now
365
+ if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
366
+ let sock;
367
+ try{
368
+ sock=new WebSocket("ws://"+location.host);
369
+ }catch(_){
370
+ // constructor can throw on a bad URL/state — treat as a failed attempt
371
+ scheduleReconnect();
372
+ return;
373
+ }
374
+ ws=sock;
375
+ sock.onopen=()=>{
376
+ if(ws!==sock)return; // stale socket from a superseded attempt
377
+ reconnectDelay=0; // reset backoff on success
378
+ setConn("connected");
379
+ // traceState is (re)established by the fresh `hello` the server sends on
380
+ // every (re)connect — don't assume; wait for it. Until then show "syncing".
381
+ setBanner("syncing","sync");
382
+ // Re-fetch symbols on every (re)connect: the server may have just come back,
383
+ // bound a new session, or fallen back to the default ELF (PROTOCOL §12.3).
384
+ // Cheap + graceful; keeps autocomplete fresh across traces.
385
+ loadSymbols();
386
+ };
387
+ sock.onclose=()=>{
388
+ if(ws!==sock)return; // ignore close of a socket we already replaced
389
+ ws=null;
390
+ if(manualClose){manualClose=false;} // intentional: connect() already queued
391
+ scheduleReconnect();
392
+ };
393
+ // swallow errors — the close handler drives recovery. Never throw here.
394
+ sock.onerror=()=>{};
395
+ sock.onmessage=(e)=>{ try{ handleFrame(e.data); }catch(_){ /* never throw in WS handler */ } };
396
+ }
397
+
398
+ /* Schedule the next reconnect attempt with exponential backoff (capped). Loops
399
+ forever: every failed attempt re-enters here via onclose. */
400
+ function scheduleReconnect(){
401
+ setConn("reconnecting");
402
+ if(reconnectTimer)return; // already scheduled
403
+ reconnectDelay=reconnectDelay?Math.min(reconnectDelay*2,RECONNECT_MAX):RECONNECT_MIN;
404
+ reconnectTimer=setTimeout(()=>{reconnectTimer=null;connect();},reconnectDelay);
405
+ }
406
+
407
+ /* Forced reconnect (Reconnect button): drop the current socket immediately and
408
+ retry now at the minimum delay, regardless of any pending backoff. */
409
+ function forceReconnect(){
410
+ reconnectDelay=0;
411
+ if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
412
+ const old=ws; ws=null;
413
+ if(old){
414
+ manualClose=true;
415
+ try{old.onopen=old.onclose=old.onerror=old.onmessage=null;}catch(_){}
416
+ try{old.close();}catch(_){}
417
+ }
418
+ setConn("reconnecting");
419
+ connect();
420
+ }
421
+
422
+ /* Parse + dispatch one inbound frame. Defensive: tolerates junk, unknown types
423
+ and missing fields; never throws. */
424
+ function handleFrame(data){
425
+ let m; try{m=JSON.parse(data);}catch(_){return;} // tolerate junk
426
+ if(!m||typeof m!=="object")return;
427
+ switch(m.type){
428
+ case "hello":
429
+ // Sent on every (re)connect. Reconcile to the server's current signal set and
430
+ // adopt its trace state. active=false ⇒ idle (no live trace right now).
431
+ if(Array.isArray(m.signals)) reconcile(m.signals.map(toDesc), null);
432
+ if(m.active){ setTrace("active"); }
433
+ else { setTrace("idle"); }
434
+ break;
435
+ case "trace_start":
436
+ // A brand-new trace began: reset the plot and start fresh with these signals.
437
+ resetPlot();
438
+ if(Array.isArray(m.signals)) reconcile(m.signals.map(toDesc), null);
439
+ setTrace("active");
440
+ break;
441
+ case "trace_end":
442
+ // Trace stopped but the server stays up. Keep the last data visible (pannable
443
+ // history); just stop expecting new samples and show the "ended" banner.
444
+ setTrace("ended");
445
+ break;
446
+ case "signals":
447
+ // the watched set CHANGED mid-trace — reconcile to match exactly
448
+ if(Array.isArray(m.signals)) reconcile(m.signals, m.unresolved);
449
+ break;
450
+ case "sample":
451
+ if(!paused && m.values && typeof m.values==="object"){
452
+ // a sample implies the trace is live — recover the active state if a frame
453
+ // arrives before/without an explicit trace_start (defensive).
454
+ if(traceState!=="active") setTrace("active");
455
+ const t=typeof m.t==="number"?m.t:performance.now()/1000;
456
+ fsNoteSample(t); // feed the single global Fs estimator
457
+ for(const k in m.values){const v=m.values[k]; if(typeof v==="number") push(k,t,v);}
458
+ }
459
+ break;
460
+ case "status":
461
+ setStat("device: "+(m.device_state||"?"));
462
+ // authoritative Fs if the daemon reports it (PROTOCOL §10)
463
+ {const f=(typeof m.actual_fs==="number")?m.actual_fs:(typeof m.fs==="number"?m.fs:null);
464
+ if(f!=null&&isFinite(f)&&f>0) fsState.reported=f;}
465
+ break;
466
+ case "error":
467
+ toast("error: "+(m.error||"unknown"));
468
+ break;
469
+ default: break; // ignore unknown frame types
470
+ }
471
+ }
472
+
473
+ /* hello.signals are plain name strings; signals frames are full descriptors. */
474
+ function toDesc(n){ return (n&&typeof n==="object")?n:{name:String(n)}; }
475
+
476
+ /* ---------- presentation of the two state machines ---------- */
477
+ function setStat(s){const el=document.getElementById("stat"); if(el)el.textContent=s;}
478
+
479
+ /* Reflect connState in the indicator dot + text. */
480
+ function setConn(state){
481
+ connState=state;
482
+ const dot=document.getElementById("connDot");
483
+ const txt=document.getElementById("connTxt");
484
+ if(!dot||!txt)return;
485
+ dot.className="cdot";
486
+ if(state==="connected"){
487
+ // dot color further refined by traceState (ok=active, idle=no trace)
488
+ dot.classList.add(traceState==="active"?"ok":"idle");
489
+ dot.textContent="●"; txt.textContent="connected";
490
+ }else if(state==="reconnecting"){
491
+ dot.classList.add("retry"); dot.textContent="○"; txt.textContent="reconnecting…";
492
+ }else{ // disconnected
493
+ dot.classList.add("down"); dot.textContent="×"; txt.textContent="disconnected";
494
+ }
495
+ }
496
+
497
+ /* Reflect traceState (active / idle / ended) in the banner + dot tint. During a
498
+ brief reconnect we deliberately leave the last banner up (data stays plotted,
499
+ so a flickering banner would be noise); the connection dot/text owns the
500
+ "reconnecting/disconnected" messaging instead. A fresh `hello` on reconnect
501
+ re-derives the real trace state. */
502
+ function setTrace(state){
503
+ // normalize: "ended" is an idle sub-state with a distinct banner
504
+ traceState=(state==="active")?"active":(state==="ended"?"ended":"idle");
505
+ if(traceState==="active"){
506
+ setBanner("","live");
507
+ }else if(traceState==="ended"){
508
+ setBanner("trace ended — waiting for next trace…","ended");
509
+ }else{
510
+ setBanner("waiting for trace…","idle");
511
+ }
512
+ // refresh the dot tint (active vs idle) without changing connState
513
+ if(connState==="connected") setConn("connected");
514
+ }
515
+
516
+ /* Show/hide the idle/ended banner. kind: "live" hides it; "idle"/"ended"/"sync"
517
+ show it with the matching style. */
518
+ function setBanner(text,kind){
519
+ const b=document.getElementById("banner");
520
+ if(!b)return;
521
+ if(kind==="live"){ b.style.display="none"; b.className="banner"; return; }
522
+ b.className="banner"+(kind==="ended"?" ended":"");
523
+ b.textContent=text;
524
+ b.style.display="block";
525
+ }
526
+
527
+ /* Clear plotted data + reset view for a fresh trace. Keeps the watch-list table
528
+ structure (reconcile rebuilds it); just drops sample history and Fs estimate. */
529
+ function resetPlot(){
530
+ for(const[,s]of sigs) s.data=[];
531
+ viewT0=null; viewT1=null;
532
+ fsState.stamps=[]; fsState.reported=null; fsState.lastShown="";
533
+ }
534
+
535
+ /* ---------- reconcile watched set with a signals frame ----------
536
+ `descs` = array of {name,address?,size?,encoding?}. We make the local
537
+ sigs map match this set exactly: add new ones (stable color, plotting
538
+ immediately), drop ones no longer present (keeps view clean). History of
539
+ a removed signal is discarded locally. `unresolved` (if given) is shown
540
+ as a transient warning so the user knows a spec didn't land. */
541
+ function reconcile(descs, unresolved){
542
+ const want=new Set(descs.map(d=>d.name));
543
+ // remove signals no longer in the watched set
544
+ for(const n of [...sigs.keys()]) if(!want.has(n)) sigs.delete(n);
545
+ // add / update
546
+ for(const d of descs){
547
+ let s=sigs.get(d.name);
548
+ if(!s){ s={color:nextColor(),on:true,data:[]}; sigs.set(d.name,s); }
549
+ if(d.address!=null) s.address=d.address;
550
+ if(d.size!=null) s.size=d.size;
551
+ if(d.encoding!=null) s.encoding=d.encoding;
552
+ }
553
+ if(unresolved && unresolved.length){
554
+ toast("unresolved: "+unresolved.join(", "));
555
+ }
556
+ buildTable();
557
+ // If autocomplete is still empty (e.g. /symbols was fetched before a session
558
+ // was active), retry once now that the server is clearly talking to a target.
559
+ if(!suggestList.length && !symbolsRetried){ symbolsRetried=true; loadSymbols(); }
560
+ }
561
+ let symbolsRetried=false;
562
+
563
+ function push(n,t,v){
564
+ const s=sigs.get(n); if(!s)return;
565
+ s.data.push({t,v});
566
+ const cap=Math.max(10,cfg.maxSamples|0);
567
+ if(s.data.length>cap) s.data.shift();
568
+ }
569
+
570
+ /* ---------- outbound commands ---------- */
571
+ function wsSend(o){ if(ws&&ws.readyState===1) ws.send(JSON.stringify(o)); else toast("not connected — cannot send"); }
572
+ function addSpecs(specs){
573
+ const list=specs.map(s=>s.trim()).filter(Boolean);
574
+ if(!list.length)return;
575
+ wsSend({cmd:"add",signals:list});
576
+ }
577
+ function removeSignal(name){ wsSend({cmd:"remove",signals:[name]}); }
578
+
579
+ /* ---------- side panel: watch list controls ---------- */
580
+ document.getElementById("addBtn").onclick=()=>{
581
+ const inp=document.getElementById("addInput");
582
+ const specs=inp.value.split(",");
583
+ if(specs.some(s=>s.trim())){ addSpecs(specs); inp.value=""; }
584
+ };
585
+ document.getElementById("addInput").addEventListener("keydown",e=>{ if(e.key==="Enter")document.getElementById("addBtn").click(); });
586
+ // Re-suggest per active token as the user types / moves the caret.
587
+ document.getElementById("addInput").addEventListener("input",refreshDatalist);
588
+ document.getElementById("addInput").addEventListener("click",refreshDatalist);
589
+ document.getElementById("addInput").addEventListener("focus",refreshDatalist);
590
+
591
+ const PRESETS=[
592
+ ["ADC raw","s_adc_raw[0],s_adc_raw[1],s_adc_raw[2],s_adc_raw[3]"],
593
+ ["Voltages","s_vbat_mv,s_vbus_stm_mv,s_vbus_esp_mv"],
594
+ ["Pads","s_inputs[1],s_inputs[2]"],
595
+ ["Pad pressure","s_inputs[3],s_inputs[4],s_inputs[5],s_inputs[6]"],
596
+ ["Encoder","s_inputs[0]"],
597
+ ["Buttons","s_inputs[44]"],
598
+ ];
599
+ (function buildPresets(){
600
+ const box=document.getElementById("presets");
601
+ for(const [label,specs] of PRESETS){
602
+ const b=document.createElement("button");
603
+ b.textContent=label; b.title=specs;
604
+ b.onclick=()=>addSpecs(specs.split(","));
605
+ box.appendChild(b);
606
+ }
607
+ })();
608
+ document.getElementById("clearAll").onclick=()=>{
609
+ const names=[...sigs.keys()];
610
+ if(names.length) wsSend({cmd:"remove",signals:names});
611
+ };
612
+
613
+ /* ---------- config wiring ---------- */
614
+ const cfgWindow=document.getElementById("cfgWindow"),
615
+ cfgFull=document.getElementById("cfgFull"),
616
+ cfgMax=document.getElementById("cfgMax"),
617
+ cfgYMode=document.getElementById("cfgYMode");
618
+ cfgWindow.onchange=()=>{ const v=parseFloat(cfgWindow.value); if(v>0) cfg.windowSec=v; };
619
+ cfgFull.onchange=()=>{ cfg.full=cfgFull.checked; cfgWindow.disabled=cfg.full; };
620
+ cfgMax.onchange=()=>{ const v=parseInt(cfgMax.value,10); if(v>=100){ cfg.maxSamples=v; for(const[,s]of sigs) while(s.data.length>v) s.data.shift(); } };
621
+ cfgYMode.onchange=()=>{ cfg.yMode=cfgYMode.value; };
622
+
623
+ /* ---------- collapsible panels ---------- */
624
+ document.querySelectorAll(".panel h3[data-tgl]").forEach(h=>{
625
+ h.onclick=()=>{ const p=h.parentElement; p.classList.toggle("collapsed");
626
+ h.querySelector(".arrow").textContent=p.classList.contains("collapsed")?"▸":"▾"; };
627
+ });
628
+
629
+ /* ---------- stats math ----------
630
+ Computed over the currently-visible window (respects trailing-window /
631
+ full-history config). VALUE-DOMAIN metrics only — current/min/max/mean/
632
+ p2p/rms/n. Sample rate is intentionally NOT here: Fs is a whole-trace
633
+ property shown once globally in the top bar (PROTOCOL §10). */
634
+ function statsFor(s,t0,t1){
635
+ let n=0,sum=0,sq=0,mn=Infinity,mx=-Infinity,cur=null;
636
+ const d=s.data;
637
+ for(let i=0;i<d.length;i++){
638
+ const p=d[i];
639
+ if(p.t<t0||p.t>t1) continue;
640
+ n++; sum+=p.v; sq+=p.v*p.v;
641
+ if(p.v<mn)mn=p.v; if(p.v>mx)mx=p.v;
642
+ cur=p.v;
643
+ }
644
+ if(n===0){
645
+ // fall back to last overall value if nothing visible
646
+ cur=d.length?d[d.length-1].v:null;
647
+ return {n:0,cur,min:null,max:null,mean:null,p2p:null,rms:null};
648
+ }
649
+ const mean=sum/n;
650
+ const rms=Math.sqrt(sq/n);
651
+ return {n,cur,min:mn,max:mx,mean,p2p:mx-mn,rms};
652
+ }
653
+ function fmt(x){ if(x===null||x===undefined||!isFinite(x))return "—";
654
+ const a=Math.abs(x);
655
+ if(a!==0&&(a<0.01||a>=1e6))return x.toExponential(2);
656
+ if(Number.isInteger(x))return String(x);
657
+ return x.toFixed(a<1?4:2);
658
+ }
659
+
660
+ /* ---------- side table (signals + per-signal stats + remove) ---------- */
661
+ function buildTable(){
662
+ const tb=document.getElementById("tbl");
663
+ tb.innerHTML="";
664
+ document.getElementById("noSigs").style.display=sigs.size?"none":"block";
665
+ for(const [n,s] of sigs){
666
+ const tr=document.createElement("tr"); tr.className="sigrow";
667
+ const enc=s.encoding?` <span class="muted">${s.encoding}</span>`:"";
668
+ tr.innerHTML=
669
+ `<td colspan="2">`+
670
+ `<span class="sw" style="background:${s.color}"></span> `+
671
+ `<input type="checkbox" ${s.on?"checked":""} data-n="${esc(n)}" title="show/hide"> `+
672
+ `<span class="signame" data-n="${esc(n)}">${esc(n)}</span>${enc} `+
673
+ `<span class="rm" data-n="${esc(n)}" title="remove">×</span>`+
674
+ `<div class="stats" id="st-${cssid(n)}"></div>`+
675
+ `</td>`;
676
+ tb.appendChild(tr);
677
+ }
678
+ // wire toggles
679
+ tb.querySelectorAll('input[type=checkbox]').forEach(c=>c.onchange=()=>{
680
+ const s=sigs.get(c.dataset.n); if(s) s.on=c.checked;
681
+ });
682
+ // wire remove
683
+ tb.querySelectorAll('.rm').forEach(x=>x.onclick=()=>removeSignal(x.dataset.n));
684
+ // click name = toggle visibility too (convenience)
685
+ tb.querySelectorAll('.signame').forEach(el=>el.onclick=()=>{
686
+ const s=sigs.get(el.dataset.n); if(!s)return; s.on=!s.on;
687
+ const cb=tb.querySelector('input[data-n="'+cssAttr(el.dataset.n)+'"]'); if(cb)cb.checked=s.on;
688
+ });
689
+ }
690
+ function cssid(n){return n.replace(/[^a-z0-9]/gi,"_");}
691
+ function esc(n){return n.replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}
692
+ function cssAttr(n){return n.replace(/"/g,'\\"');}
693
+
694
+ /* ---------- toolbar ---------- */
695
+ const pauseBtn=document.getElementById("pause");
696
+ pauseBtn.onclick=()=>{paused=!paused;pauseBtn.classList.toggle("active",paused);pauseBtn.textContent=paused?"Resume":"Pause";};
697
+ document.getElementById("auto").onclick=()=>{viewT0=null;viewT1=null;};
698
+ // Force-reconnect button (PROTOCOL §12.5): close + reopen the WS immediately.
699
+ document.getElementById("reconnect").onclick=()=>forceReconnect();
700
+ // If the tab was backgrounded and the socket died meanwhile, kick a retry as
701
+ // soon as it's visible again (timers may have been throttled while hidden).
702
+ document.addEventListener("visibilitychange",()=>{
703
+ if(document.visibilityState==="visible" && !ws && connState!=="connected") forceReconnect();
704
+ });
705
+
706
+ /* ---------- zoom / pan (preserved) ---------- */
707
+ cv.addEventListener("wheel",(e)=>{e.preventDefault();
708
+ const [t0,t1]=curRange();const span=t1-t0;const f=e.deltaY<0?0.8:1.25;
709
+ const cx=t0+span*(e.offsetX/cv.width);
710
+ viewT0=cx-(cx-t0)*f;viewT1=cx+(t1-cx)*f;
711
+ },{passive:false});
712
+ let drag=null;
713
+ cv.addEventListener("mousedown",(e)=>drag={x:e.offsetX,r:curRange()});
714
+ window.addEventListener("mouseup",()=>drag=null);
715
+ cv.addEventListener("mousemove",(e)=>{if(!drag)return;
716
+ const [t0,t1]=drag.r;const span=t1-t0;const dt=(e.offsetX-drag.x)/cv.width*span;
717
+ viewT0=t0-dt;viewT1=t1-dt;
718
+ });
719
+
720
+ /* ---------- visible time range ----------
721
+ Full-history mode shows everything; otherwise the live trailing window
722
+ (cfg.windowSec). Manual zoom/pan (viewT0/viewT1) overrides both. */
723
+ function dataBounds(){
724
+ let lo=Infinity,hi=-Infinity;
725
+ for(const[,s]of sigs){const d=s.data;if(d.length){if(d[0].t<lo)lo=d[0].t;if(d[d.length-1].t>hi)hi=d[d.length-1].t;}}
726
+ return [lo,hi];
727
+ }
728
+ function curRange(){
729
+ const [lo,hi]=dataBounds();
730
+ if(!isFinite(lo))return[0,1];
731
+ if(viewT0!=null)return[viewT0,viewT1];
732
+ if(cfg.full)return[lo,hi>lo?hi:lo+1];
733
+ return[Math.max(lo,hi-cfg.windowSec),hi];
734
+ }
735
+
736
+ /* ---------- render ---------- */
737
+ function render(){
738
+ requestAnimationFrame(render);
739
+ ctx.clearRect(0,0,cv.width,cv.height);
740
+ const [t0,t1]=curRange();const span=(t1-t0)||1;
741
+ const W=cv.width,H=cv.height;
742
+
743
+ // shared Y bounds (used in shared mode)
744
+ let ylo=Infinity,yhi=-Infinity;
745
+ for(const[,s]of sigs){if(!s.on)continue;
746
+ for(const p of s.data){if(p.t<t0||p.t>t1)continue;if(p.v<ylo)ylo=p.v;if(p.v>yhi)yhi=p.v;}}
747
+ if(!isFinite(ylo)){ylo=0;yhi=1;}
748
+ const yspan=(yhi-ylo)||1;
749
+
750
+ const X=(t)=>(t-t0)/span*W;
751
+ const sharedY=(v)=>H-(v-ylo)/yspan*H;
752
+
753
+ // gridlines + axis labels
754
+ drawGrid(t0,t1,W,H,ylo,yhi);
755
+
756
+ // plot each signal
757
+ for(const[n,s] of sigs){
758
+ if(!s.on)continue;
759
+ // per-signal normalized Y: each signal maps its own min..max to 0..1.
760
+ let Y=sharedY;
761
+ if(cfg.yMode==="norm"){
762
+ let smn=Infinity,smx=-Infinity;
763
+ for(const p of s.data){if(p.t<t0||p.t>t1)continue;if(p.v<smn)smn=p.v;if(p.v>smx)smx=p.v;}
764
+ if(!isFinite(smn)){smn=0;smx=1;}
765
+ const ss=(smx-smn)||1;
766
+ Y=(v)=>H-((v-smn)/ss)*(H-8)-4; // small margins so flat lines stay visible
767
+ }
768
+ ctx.strokeStyle=s.color;ctx.lineWidth=1;ctx.beginPath();
769
+ let first=true;
770
+ const d=s.data;
771
+ for(let i=0;i<d.length;i++){
772
+ const p=d[i]; if(p.t<t0||p.t>t1)continue;
773
+ const x=X(p.t),y=Y(p.v);
774
+ first?ctx.moveTo(x,y):ctx.lineTo(x,y); first=false;
775
+ }
776
+ ctx.stroke();
777
+ }
778
+
779
+ // update per-signal stats readouts (throttled)
780
+ updateStats(t0,t1);
781
+ }
782
+
783
+ let lastStatsAt=0;
784
+ function updateStats(t0,t1){
785
+ const now=performance.now();
786
+ if(now-lastStatsAt<200)return; // ~5 Hz refresh, cheap
787
+ lastStatsAt=now;
788
+ for(const [n,s] of sigs){
789
+ const cell=document.getElementById("st-"+cssid(n));
790
+ if(!cell)continue;
791
+ const st=statsFor(s,t0,t1);
792
+ cell.innerHTML=
793
+ `<span>cur <b>${fmt(st.cur)}</b></span>`+
794
+ `<span>min <b>${fmt(st.min)}</b></span>`+
795
+ `<span>max <b>${fmt(st.max)}</b></span>`+
796
+ `<span>mean <b>${fmt(st.mean)}</b></span>`+
797
+ `<span>p2p <b>${fmt(st.p2p)}</b></span>`+
798
+ `<span>rms <b>${fmt(st.rms)}</b></span>`+
799
+ `<span>n <b>${st.n}</b></span>`;
800
+ }
801
+ // single global Fs readout (top bar), updated on the same throttle
802
+ const fsEl=document.getElementById("fs");
803
+ if(fsEl){
804
+ const f=fsEstimate();
805
+ const txt="Fs "+(f!=null?fmt(f)+" Hz":"—");
806
+ if(txt!==fsState.lastShown){ fsEl.textContent=txt; fsState.lastShown=txt; }
807
+ }
808
+ }
809
+
810
+ /* ---------- grid / axes ---------- */
811
+ function drawGrid(t0,t1,W,H,ylo,yhi){
812
+ ctx.strokeStyle="#1c1c1c";ctx.lineWidth=1;ctx.fillStyle="#666";ctx.font="10px monospace";
813
+ // vertical (time) lines — 6 divisions
814
+ for(let i=0;i<=6;i++){
815
+ const x=W*i/6; ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();
816
+ const tt=t0+(t1-t0)*i/6;
817
+ ctx.fillText(tt.toFixed(2)+"s",Math.min(x+2,W-40),H-2);
818
+ }
819
+ // horizontal (value) lines — only meaningful in shared mode
820
+ if(cfg.yMode==="shared"){
821
+ for(let i=0;i<=4;i++){
822
+ const y=H*i/4; ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();
823
+ const vv=yhi-(yhi-ylo)*i/4;
824
+ ctx.fillText(fmt(vv),2,Math.max(10,y+10));
825
+ }
826
+ }
827
+ }
828
+
829
+ setConn("reconnecting"); // initial paint of the indicator before first open
830
+ setBanner("connecting…","idle");
831
+ connect();
832
+ render();
833
+ loadSymbols(); // populate autocomplete from /symbols (graceful if unavailable)
834
+ </script></body></html>