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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +14 -0
- package/.mcp.json +9 -0
- package/README.md +95 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +369 -49
- package/dist/index.js.map +1 -1
- package/dist/tools/idf-flash.js +2 -2
- package/dist/tools/idf-flash.js.map +1 -1
- package/dist/tools/idf-monitor.d.ts +3 -1
- package/dist/tools/idf-monitor.js +19 -3
- package/dist/tools/idf-monitor.js.map +1 -1
- package/dist/tools/midi.js +20 -16
- package/dist/tools/midi.js.map +1 -1
- package/dist/tools/symbols.d.ts +3 -1
- package/dist/tools/symbols.js +31 -1
- package/dist/tools/symbols.js.map +1 -1
- package/dist/tools/trace-buffer.d.ts +40 -0
- package/dist/tools/trace-buffer.js +74 -0
- package/dist/tools/trace-buffer.js.map +1 -0
- package/dist/tools/trace-device.d.ts +10 -0
- package/dist/tools/trace-device.js +26 -0
- package/dist/tools/trace-device.js.map +1 -0
- package/dist/tools/trace-doctor.d.ts +43 -0
- package/dist/tools/trace-doctor.js +150 -0
- package/dist/tools/trace-doctor.js.map +1 -0
- package/dist/tools/trace-export.d.ts +4 -0
- package/dist/tools/trace-export.js +14 -0
- package/dist/tools/trace-export.js.map +1 -0
- package/dist/tools/trace-session.d.ts +118 -0
- package/dist/tools/trace-session.js +346 -0
- package/dist/tools/trace-session.js.map +1 -0
- package/dist/tools/trace-symbols.d.ts +24 -0
- package/dist/tools/trace-symbols.js +44 -0
- package/dist/tools/trace-symbols.js.map +1 -0
- package/dist/tools/trace-webui.d.ts +53 -0
- package/dist/tools/trace-webui.js +222 -0
- package/dist/tools/trace-webui.js.map +1 -0
- package/dist/utils/device.d.ts +5 -0
- package/dist/utils/device.js +43 -15
- package/dist/utils/device.js.map +1 -1
- package/dist/utils/exec.js +26 -0
- package/dist/utils/exec.js.map +1 -1
- package/dist/utils/userConfig.d.ts +13 -0
- package/dist/utils/userConfig.js +43 -0
- package/dist/utils/userConfig.js.map +1 -0
- package/package.json +12 -4
- package/skills/crosspad/SKILL.md +58 -0
- package/skills/crosspad/reference/faq.md +40 -0
- package/skills/crosspad/reference/install.md +84 -0
- package/skills/crosspad/reference/repos.md +29 -0
- package/skills/crosspad/reference/role-contributor.md +64 -0
- package/skills/crosspad/reference/role-fw-dev.md +44 -0
- package/skills/crosspad/reference/role-user.md +49 -0
- package/skills/crosspad/reference/tools.md +68 -0
- package/skills/crosspad/scripts/doctor.sh +65 -0
- package/skills/crosspad/scripts/setup.sh +53 -0
- package/skills/swd-tracer/SKILL.md +135 -0
- package/skills/swd-tracer/reference/signals.md +42 -0
- package/skills/swd-tracer/scripts/detect-env.sh +61 -0
- package/skills/swd-tracer/scripts/install-udev-rules.sh +25 -0
- package/skills/swd-tracer/scripts/setup-venv.sh +26 -0
- package/tracer/PROTOCOL.md +260 -0
- package/tracer/README.md +327 -0
- package/tracer/swd_tracer.py +1066 -0
- 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 & 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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|