4bnode 4.2.0 → 4.2.1

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.
@@ -1,170 +1,486 @@
1
- import React, { useState, useEffect } from "react";
2
- import { LuCable } from "react-icons/lu";
3
- import { html, api, Icons } from "../lib.js";
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { LuCable, LuRefreshCw, LuUsb, LuSearch, LuTrash2, LuPlus } from "react-icons/lu";
3
+ import { html, api } from "../lib.js";
4
4
  import { useApp } from "../context.js";
5
+ import { SerialWorkspace } from "../components/SerialWorkspace.js";
5
6
 
6
- export function SerialPortPage() {
7
- const { toast, refresh, status, showConfirm } = useApp();
7
+ const STATUS = {
8
+ connected: { cls: "badge-success", label: "Connected" },
9
+ searching: { cls: "badge-info", label: "Searching" },
10
+ reconnecting: { cls: "badge-warning", label: "Reconnecting" },
11
+ offline: { cls: "badge-muted", label: "Offline" },
12
+ error: { cls: "badge-error", label: "Error" },
13
+ stopped: { cls: "badge-muted", label: "Stopped" },
14
+ idle: { cls: "badge-muted", label: "Idle" },
15
+ disabled: { cls: "badge-muted", label: "Disabled" },
16
+ };
17
+
18
+ const STRATEGIES = [
19
+ { id: "serialNumber", label: "USB serial number", hint: "Best for identical boards. Stable per physical board, survives reboots and port changes." },
20
+ { id: "identity", label: "Firmware identity", hint: "Sends the ID command and matches the reply (e.g. LED-CTRL). Best when each board runs different firmware." },
21
+ { id: "vendorId", label: "USB vendor id", hint: "Matches the USB bridge chip (e.g. 10c4). Fine for one board of that type; a hint, not proof." },
22
+ { id: "path", label: "Fixed USB port (last resort)", hint: "Only for identical boards with no USB serial number and no ID reply: the device then follows this USB socket, not the board. Label the socket." },
23
+ ];
24
+
25
+ const EMPTY_FORM = {
26
+ originalName: null, name: "", strategy: "serialNumber",
27
+ serialNumber: "", identity: "", vendorId: "", productId: "", path: "",
28
+ baudRate: "115200", delimiter: "lf", idCommand: "ID?", timeout: "2000", enabled: true,
29
+ };
30
+
31
+ function strategyOf(match = {}) {
32
+ if (match.serialNumber) return "serialNumber";
33
+ if (match.identity) return "identity";
34
+ if (match.vendorId) return "vendorId";
35
+ return "path";
36
+ }
37
+
38
+ function matchLabel(match = {}) {
39
+ if (match.serialNumber) return "USB serial " + match.serialNumber;
40
+ if (match.identity) return "identity \"" + match.identity + "\"" + (match.vendorId ? " on " + match.vendorId : "");
41
+ if (match.vendorId) return "USB vendor " + match.vendorId + (match.productId ? ":" + match.productId : "");
42
+ return "path " + (match.path || "?");
43
+ }
44
+
45
+ function SetupCard({ info, onDone }) {
46
+ const { toast, refresh } = useApp();
47
+ const [busy, setBusy] = useState(false);
48
+ async function setup() {
49
+ setBusy(true);
50
+ try {
51
+ const r = await api("/serial/setup", { method: "POST" });
52
+ toast(r.message, "success");
53
+ await refresh().catch(() => {});
54
+ setTimeout(onDone, 1500);
55
+ } catch (err) {
56
+ toast(err.message, "error");
57
+ }
58
+ setBusy(false);
59
+ }
60
+ return html`<div className="card" style=${{ maxWidth: "720px" }}>
61
+ <div className="flex items-center gap-2 mb-2">
62
+ <${LuCable} size=${18} />
63
+ <div className="card-title" style=${{ margin: 0 }}>Talk to Arduino / ESP32 boards</div>
64
+ </div>
65
+ <div className="text-sm text-secondary" style=${{ lineHeight: 1.7, marginBottom: "14px" }}>
66
+ Adds a device manager to your app. It finds each board by its USB serial number, USB vendor id, or a firmware
67
+ handshake (<span className="mono">ID?</span>) instead of a fixed COM port, reconnects by itself when a board resets
68
+ or is unplugged, and sends commands that wait for the board's reply. Your app gets HTTP routes under
69
+ <span className="mono"> /api/serial</span>, protected by the API key gate.
70
+ </div>
71
+ <button className="btn btn-primary" onClick=${setup} disabled=${busy}>
72
+ ${busy ? html`<span className="spinner" />` : null}
73
+ ${busy ? "Installing serialport…" : info.installed ? "Set up serial devices" : "Install serialport and set up"}
74
+ </button>
75
+ <div className="text-sm text-muted" style=${{ marginTop: "8px" }}>Installs serialport and @serialport/parser-readline, then restarts the app.</div>
76
+ </div>`;
77
+ }
78
+
79
+ function LegacyPorts({ onChange }) {
80
+ const { toast, showConfirm } = useApp();
8
81
  const [ports, setPorts] = useState([]);
9
- const [selected, setSelected] = useState(null);
10
- const [showAdd, setShowAdd] = useState(false);
11
- const [portName, setPortName] = useState("serialPort");
12
- const [portPath, setPortPath] = useState("/dev/ttyUSB0");
13
- const [baudRate, setBaudRate] = useState("9600");
14
- const [loading, setLoading] = useState(false);
82
+ useEffect(() => { api("/serialports").then(setPorts).catch(() => {}); }, []);
83
+ if (!ports.length) return null;
84
+ async function remove(name) {
85
+ const ok = await showConfirm('Remove the old "' + name + '" code from index.js?', { danger: true, confirmLabel: "Remove" });
86
+ if (!ok) return;
87
+ try {
88
+ await api("/serialports/" + encodeURIComponent(name), { method: "DELETE" });
89
+ toast("Removed " + name, "success");
90
+ setPorts((p) => p.filter((x) => x.name !== name));
91
+ onChange();
92
+ } catch (err) { toast(err.message, "error"); }
93
+ }
94
+ return html`<div className="card mb-4" style=${{ borderColor: "rgba(245,165,36,0.35)" }}>
95
+ <div className="card-title mb-1">Old-style ports in index.js</div>
96
+ <div className="card-subtitle mb-3">These use a hard-coded port path and don't reconnect. Add them again as devices below, then remove the old code.</div>
97
+ ${ports.map((p) => html`<div key=${p.name} className="flex items-center justify-between" style=${{ padding: "6px 0", gap: "10px" }}>
98
+ <span className="mono text-sm">${p.name} · ${p.path} @ ${p.baudRate}</span>
99
+ <button className="btn btn-ghost btn-sm" onClick=${() => remove(p.name)}><${LuTrash2} size=${13} /> Remove</button>
100
+ </div>`)}
101
+ </div>`;
102
+ }
103
+
104
+ function HardwareList({ baudRates, onUse }) {
105
+ const { toast } = useApp();
106
+ const [ports, setPorts] = useState(null);
107
+ const [scanning, setScanning] = useState(false);
108
+ const [probing, setProbing] = useState(null);
109
+ const [ids, setIds] = useState({});
110
+ const [probeBaud, setProbeBaud] = useState("115200");
111
+ const [fresh, setFresh] = useState({}); // path -> time first seen (for the "New" badge)
112
+ const seenRef = useRef(null);
15
113
 
114
+ // quiet=true: background refresh (no spinner, no error toast).
115
+ async function scan(quiet) {
116
+ if (!quiet) setScanning(true);
117
+ try {
118
+ const list = await api("/serial/scan");
119
+ const paths = new Set(list.map((p) => p.path));
120
+ if (seenRef.current) {
121
+ const added = list.filter((p) => p.usb && !seenRef.current.has(p.path)).map((p) => p.path);
122
+ if (added.length) setFresh((f) => { const n = { ...f }; added.forEach((x) => { n[x] = Date.now(); }); return n; });
123
+ }
124
+ seenRef.current = paths;
125
+ setPorts(list);
126
+ } catch (err) {
127
+ if (!quiet) { toast(err.message, "error"); setPorts([]); }
128
+ }
129
+ if (!quiet) setScanning(false);
130
+ }
131
+ // Auto-detect: keep the list live, and refresh at once on a hotplug event.
132
+ useEffect(() => {
133
+ scan();
134
+ const t = setInterval(() => scan(true), 3000);
135
+ const onEvent = () => scan(true);
136
+ window.addEventListener("serial-event", onEvent);
137
+ return () => { clearInterval(t); window.removeEventListener("serial-event", onEvent); };
138
+ }, []);
139
+ // Drop "New" badges after 10s.
16
140
  useEffect(() => {
17
- api("/serialports").then(setPorts).catch(() => {});
18
- }, [status]);
141
+ const t = setInterval(() => setFresh((f) => {
142
+ const n = {};
143
+ for (const k in f) if (Date.now() - f[k] < 10000) n[k] = f[k];
144
+ return Object.keys(n).length === Object.keys(f).length ? f : n;
145
+ }), 1000);
146
+ return () => clearInterval(t);
147
+ }, []);
19
148
 
20
- async function handleAdd() {
21
- setLoading(true);
149
+ const [identifying, setIdentifying] = useState(false);
150
+ async function identifyAll() {
151
+ setIdentifying(true);
22
152
  try {
23
- const result = await api("/add-serialport", {
24
- method: "POST",
25
- body: { name: portName, portPath, baudRate: parseInt(baudRate) },
26
- });
27
- toast(result.message, "success");
28
- setShowAdd(false);
29
- setPortName("serialPort" + ((ports?.length || 0) + 1));
30
- setPortPath("/dev/ttyUSB0");
31
- setBaudRate("9600");
32
- refresh();
33
- api("/serialports").then(setPorts).catch(() => {});
153
+ const list = await api("/serial/identify-all", { method: "POST", body: { baudRate: Number(probeBaud), refresh: true } });
154
+ setIds((m) => { const n = { ...m }; list.forEach((d) => { n[d.path] = d.identity || null; }); return n; });
155
+ const named = list.filter((d) => d.identity).length;
156
+ toast(named + " of " + list.length + " board" + (list.length === 1 ? "" : "s") + " answered ID?", named ? "success" : "info");
157
+ scan(true);
34
158
  } catch (err) { toast(err.message, "error"); }
35
- setLoading(false);
159
+ setIdentifying(false);
36
160
  }
37
161
 
38
- async function handleDelete(name) {
39
- const ok = await showConfirm('Remove serial port "' + name + '"? This will remove its code from index.js.', { danger: true, confirmLabel: 'Remove' });
40
- if (!ok) return;
162
+ async function probe(p) {
163
+ setProbing(p.path);
41
164
  try {
42
- await api("/serialports/" + name, { method: "DELETE" });
43
- toast('Serial port "' + name + '" removed', "success");
44
- setSelected(null);
45
- refresh();
46
- api("/serialports").then(setPorts).catch(() => {});
165
+ const r = await api("/serial/probe", { method: "POST", body: { path: p.path, baudRate: Number(probeBaud), tryAll: true } });
166
+ setIds((m) => ({ ...m, [p.path]: r.identity || null }));
167
+ if (r.identity) toast(p.path + " answered " + r.command, "success");
168
+ else toast("No reply to ID?, M115, $I or ATI on " + p.path + ". Check the baud rate and firmware.", "info");
47
169
  } catch (err) { toast(err.message, "error"); }
170
+ setProbing(null);
48
171
  }
49
172
 
50
- const selPort = ports.find(p => p.name === selected);
51
- const hasNoPorts = !ports || ports.length === 0;
173
+ const usb = (ports || []).filter((p) => p.usb);
174
+ const other = (ports || []).filter((p) => !p.usb);
52
175
 
53
- if (hasNoPorts && !showAdd) {
54
- return html`<div>
55
- <div className="flex items-center justify-between mb-4">
56
- <div>
57
- <div className="card-title">SerialPort</div>
58
- <div className="card-subtitle">0 connections</div>
59
- </div>
60
- <button className="btn btn-primary" onClick=${() => setShowAdd(true)}>
61
- <span className="sidebar-icon">${Icons.plus}</span> Add Port
176
+ return html`<div className="card" style=${{ padding: 0, overflow: "hidden" }}>
177
+ <div className="flex items-center justify-between" style=${{ padding: "12px 16px", borderBottom: "1px solid var(--border)", gap: "8px" }}>
178
+ <span className="card-title flex items-center gap-2">Connected hardware <span className="status-dot on" title="Watching for boards being plugged in" /></span>
179
+ <div className="flex items-center gap-2">
180
+ <select className="form-select" style=${{ height: "28px", width: "auto", fontSize: "12px" }} value=${probeBaud}
181
+ title="Baud rate used by Identify" onChange=${(e) => setProbeBaud(e.target.value)}>
182
+ ${baudRates.map((b) => html`<option key=${b} value=${b}>${b}</option>`)}
183
+ </select>
184
+ <button className="btn btn-ghost btn-sm" onClick=${identifyAll} disabled=${identifying || !usb.length}
185
+ title="Ask every free board for its identity (ID?). Opening a port resets most boards.">
186
+ ${identifying ? html`<span className="spinner" />` : html`<${LuSearch} size=${13} />`} Identify all
62
187
  </button>
188
+ <button className="btn btn-ghost btn-sm" onClick=${() => scan()} disabled=${scanning} title="Rescan"><${LuRefreshCw} size=${13} className=${scanning ? "spin" : ""} /></button>
63
189
  </div>
64
- <div className="empty-state">
65
- <div className="empty-icon"><${LuCable} size=${36} /></div>
66
- <h3>No serial ports</h3>
67
- <p>Add a serial port connection to communicate with hardware devices</p>
68
- <button className="btn btn-primary" onClick=${() => setShowAdd(true)}>Add Port</button>
69
- </div>
70
- </div>`;
190
+ </div>
191
+ ${ports === null
192
+ ? html`<div className="ov-empty">Scanning…</div>`
193
+ : usb.length === 0
194
+ ? html`<div className="ov-empty">Waiting for a board… Plug one in and it shows up here automatically.${other.length ? html`<br />${other.length} non-USB port(s) hidden.` : null}</div>`
195
+ : usb.map((p) => html`<div key=${p.path} style=${{ padding: "10px 16px", borderBottom: "1px solid var(--border)" }}>
196
+ <div className="flex items-center justify-between" style=${{ gap: "8px" }}>
197
+ <span className="mono" style=${{ fontSize: "12.5px", fontWeight: 600 }}>${p.path}</span>
198
+ <span className="flex gap-2">
199
+ ${fresh[p.path] ? html`<span className="badge badge-info">New</span>` : null}
200
+ ${p.inUse ? html`<span className="badge badge-success">In use</span>` : null}
201
+ </span>
202
+ </div>
203
+ <div className="text-sm text-muted" style=${{ marginTop: "3px" }}>
204
+ ${p.vendor || p.manufacturer || "Unknown chip"} · <span className="mono">${p.vendorId}:${p.productId || "????"}</span>
205
+ ${p.serialNumber ? html` · SN <span className="mono">${p.serialNumber}</span>` : " · no serial number"}
206
+ </div>
207
+ ${(p.identity || p.path in ids) && html`<div className="text-sm" title=${ids[p.path] || p.identity || ""}
208
+ style=${{ marginTop: "4px", color: (ids[p.path] || p.identity) ? "var(--success)" : "var(--text-muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
209
+ ${(ids[p.path] || p.identity) ? html`Identity: <span className="mono">${ids[p.path] || p.identity}</span>` : "No identity reply"}
210
+ </div>`}
211
+ <div className="flex gap-2" style=${{ marginTop: "8px" }}>
212
+ <button className="btn btn-secondary btn-sm" onClick=${() => probe(p)} disabled=${p.inUse || probing !== null}
213
+ title="Opens the port (resets most boards) and tries ID?, M115, $I and ATI">
214
+ ${probing === p.path ? html`<span className="spinner" />` : html`<${LuSearch} size=${12} />`} Identify
215
+ </button>
216
+ <button className="btn btn-ghost btn-sm" onClick=${() => onUse(p, ids[p.path] || p.identity, probeBaud)} disabled=${p.inUse}>
217
+ <${LuPlus} size=${12} /> Add as device
218
+ </button>
219
+ </div>
220
+ </div>`)}
221
+ </div>`;
222
+ }
223
+
224
+ function DeviceEditor({ initial, baudRates, idPresets, onSaved, onCancel }) {
225
+ const { toast } = useApp();
226
+ const [f, setF] = useState(initial);
227
+ const [saving, setSaving] = useState(false);
228
+ const set = (patch) => setF((x) => ({ ...x, ...patch }));
229
+ const strat = STRATEGIES.find((s) => s.id === f.strategy);
230
+
231
+ async function save() {
232
+ setSaving(true);
233
+ const match =
234
+ f.strategy === "serialNumber" ? { serialNumber: f.serialNumber }
235
+ : f.strategy === "identity" ? { identity: f.identity, vendorId: f.vendorId }
236
+ : f.strategy === "vendorId" ? { vendorId: f.vendorId, productId: f.productId }
237
+ : { path: f.path };
238
+ try {
239
+ const r = await api("/serial/devices", {
240
+ method: "POST",
241
+ body: {
242
+ originalName: f.originalName, name: f.name.trim(), match,
243
+ baudRate: Number(f.baudRate), delimiter: f.delimiter, idCommand: f.idCommand,
244
+ timeout: Number(f.timeout), enabled: f.enabled,
245
+ // Keep what the workspace manages, so editing never drops it.
246
+ commands: f.commands, rules: f.rules, auto: f.auto,
247
+ ignoreLines: String(f.ignoreLines || "").split(",").map((x) => x.trim()).filter(Boolean),
248
+ },
249
+ });
250
+ toast(r.message, "success");
251
+ onSaved(r.device.name);
252
+ } catch (err) { toast(err.message, "error"); }
253
+ setSaving(false);
71
254
  }
72
255
 
73
- return html`<div>
74
- <div className="flex items-center justify-between mb-4">
75
- <div>
76
- <div className="card-title">SerialPort</div>
77
- <div className="card-subtitle">${ports.length} connection${ports.length !== 1 ? 's' : ''}</div>
256
+ return html`<div className="card">
257
+ <div className="card-title mb-1">${f.originalName ? "Edit " + f.originalName : "Add device"}</div>
258
+ <div className="card-subtitle mb-4">Changes apply immediately; no restart.</div>
259
+ <div className="form-group">
260
+ <label className="form-label">Name (used in code and URLs)</label>
261
+ <input className="form-input mono" value=${f.name} placeholder="led" onInput=${(e) => set({ name: e.target.value })} />
262
+ </div>
263
+ <div className="form-group">
264
+ <label className="form-label">Find the device by</label>
265
+ <div className="api-tester-mode-tabs" style=${{ flexWrap: "wrap" }}>
266
+ ${STRATEGIES.map((s) => html`<button key=${s.id} className=${`api-tester-mode-tab${f.strategy === s.id ? " active" : ""}`}
267
+ onClick=${() => set({ strategy: s.id })}>${s.label}</button>`)}
78
268
  </div>
79
- <button className=${`btn ${showAdd ? 'btn-ghost' : 'btn-primary'}`}
80
- onClick=${() => {
81
- if (showAdd) { setShowAdd(false); if (ports.length) setSelected(ports[0].name); }
82
- else { setShowAdd(true); setSelected(null); setPortName("serialPort" + (ports.length + 1)); }
83
- }}>
84
- ${showAdd ? 'Cancel' : html`<span className="sidebar-icon">${Icons.plus}</span> Add Port`}
85
- </button>
269
+ <div className="text-sm" style=${{ marginTop: "6px", color: f.strategy === "path" ? "var(--warning)" : "var(--text-muted)" }}>${strat.hint}</div>
270
+ </div>
271
+ ${f.strategy === "serialNumber" && html`<div className="form-group">
272
+ <label className="form-label">USB serial number</label>
273
+ <input className="form-input mono" value=${f.serialNumber} placeholder="85036313233351C0E1B2" onInput=${(e) => set({ serialNumber: e.target.value })} />
274
+ </div>`}
275
+ ${f.strategy === "identity" && html`<div className="grid-2">
276
+ <div className="form-group">
277
+ <label className="form-label">Reply contains</label>
278
+ <input className="form-input mono" value=${f.identity} placeholder="LED-CTRL" onInput=${(e) => set({ identity: e.target.value })} />
279
+ </div>
280
+ <div className="form-group">
281
+ <label className="form-label">Only probe vendor id (optional)</label>
282
+ <input className="form-input mono" value=${f.vendorId} placeholder="10c4" onInput=${(e) => set({ vendorId: e.target.value })} />
283
+ </div>
284
+ </div>`}
285
+ ${f.strategy === "vendorId" && html`<div className="grid-2">
286
+ <div className="form-group">
287
+ <label className="form-label">USB vendor id</label>
288
+ <input className="form-input mono" value=${f.vendorId} placeholder="10c4" onInput=${(e) => set({ vendorId: e.target.value })} />
289
+ </div>
290
+ <div className="form-group">
291
+ <label className="form-label">USB product id (optional)</label>
292
+ <input className="form-input mono" value=${f.productId} placeholder="ea60" onInput=${(e) => set({ productId: e.target.value })} />
293
+ </div>
294
+ </div>`}
295
+ ${f.strategy === "path" && html`<div className="form-group">
296
+ <label className="form-label">Port path</label>
297
+ <input className="form-input mono" value=${f.path} placeholder="COM5 or /dev/ttyUSB0" onInput=${(e) => set({ path: e.target.value })} />
298
+ </div>`}
299
+ <div className="grid-2">
300
+ <div className="form-group">
301
+ <label className="form-label">Baud rate (must match Serial.begin)</label>
302
+ <select className="form-select" value=${f.baudRate} onChange=${(e) => set({ baudRate: e.target.value })}>
303
+ ${baudRates.map((b) => html`<option key=${b} value=${b}>${b}</option>`)}
304
+ </select>
305
+ </div>
306
+ <div className="form-group">
307
+ <label className="form-label">Line ending</label>
308
+ <select className="form-select" value=${f.delimiter} onChange=${(e) => set({ delimiter: e.target.value })}>
309
+ <option value="lf">\\n (Serial.println on most boards)</option>
310
+ <option value="crlf">\\r\\n</option>
311
+ </select>
312
+ </div>
313
+ <div className="form-group">
314
+ <label className="form-label">Identity command</label>
315
+ <div className="flex gap-2">
316
+ <select className="form-select" style=${{ flex: "1 1 auto" }}
317
+ value=${(idPresets || []).some((p) => p.command === f.idCommand) ? f.idCommand : "__custom"}
318
+ onChange=${(e) => set({ idCommand: e.target.value === "__custom" ? "" : e.target.value })}>
319
+ ${(idPresets || []).map((p) => html`<option key=${p.command} value=${p.command}>${p.label}</option>`)}
320
+ <option value="__custom">Custom…</option>
321
+ </select>
322
+ ${!(idPresets || []).some((p) => p.command === f.idCommand) && html`<input className="form-input mono" style=${{ flex: "0 1 120px" }}
323
+ value=${f.idCommand} placeholder="WHO?" onInput=${(e) => set({ idCommand: e.target.value })} />`}
324
+ </div>
325
+ </div>
326
+ <div className="form-group">
327
+ <label className="form-label">Reply timeout (ms)</label>
328
+ <input className="form-input" type="number" min="100" value=${f.timeout} onInput=${(e) => set({ timeout: e.target.value })} />
329
+ </div>
330
+ </div>
331
+ <div className="form-group">
332
+ <label className="form-label">Ignore lines (never a reply)</label>
333
+ <input className="form-input mono" value=${f.ignoreLines || ""} placeholder="wait, busy" onInput=${(e) => set({ ignoreLines: e.target.value })} />
334
+ <div className="text-sm text-muted" style=${{ marginTop: "6px" }}>Comma-separated. Heartbeats the board prints on its own (like Marlin's "wait") are also skipped automatically.</div>
335
+ </div>
336
+ <label className="flex items-center gap-2 text-sm mb-4" style=${{ cursor: "pointer" }}>
337
+ <input type="checkbox" checked=${f.enabled} onChange=${(e) => set({ enabled: e.target.checked })} /> Connect at startup
338
+ </label>
339
+ <div className="flex gap-2">
340
+ <button className="btn btn-primary" onClick=${save} disabled=${saving}>${saving ? html`<span className="spinner" />` : null} Save device</button>
341
+ <button className="btn btn-ghost" onClick=${onCancel}>Cancel</button>
342
+ </div>
343
+ </div>`;
344
+ }
345
+
346
+ export function SerialPortPage() {
347
+ const { toast, showConfirm, status } = useApp();
348
+ const [info, setInfo] = useState(null);
349
+ const [selected, setSelected] = useState(null);
350
+ const [editing, setEditing] = useState(null); // form object or null
351
+
352
+ async function load() {
353
+ try {
354
+ const r = await api("/serial");
355
+ setInfo(r);
356
+ if (!selected && r.config.length) setSelected(r.config[0].name);
357
+ } catch (err) { toast(err.message, "error"); }
358
+ }
359
+ useEffect(() => { load(); }, [status]);
360
+ useEffect(() => {
361
+ const onEvent = () => load();
362
+ window.addEventListener("serial-event", onEvent);
363
+ return () => window.removeEventListener("serial-event", onEvent);
364
+ }, []);
365
+
366
+ async function setAutoAdd(on) {
367
+ setInfo((i) => ({ ...i, autoAdd: on }));
368
+ try {
369
+ const r = await api("/serial/settings", { method: "POST", body: { autoAdd: on } });
370
+ toast(r.message, "success");
371
+ } catch (err) {
372
+ setInfo((i) => ({ ...i, autoAdd: !on }));
373
+ toast(err.message, "error");
374
+ }
375
+ }
376
+ // Live status + console while a device is visible.
377
+ useEffect(() => {
378
+ if (!info || !info.wired) return;
379
+ const t = setInterval(() => api("/serial").then(setInfo).catch(() => {}), 1500);
380
+ return () => clearInterval(t);
381
+ }, [info && info.wired]);
382
+
383
+ if (!info) return html`<div className="card"><div className="ov-empty">Loading…</div></div>`;
384
+
385
+ const header = html`<div className="flex items-center justify-between mb-4">
386
+ <div>
387
+ <div className="card-title">SerialPort</div>
388
+ <div className="card-subtitle">Arduino / ESP32 boards over USB serial · ${info.config.length} device${info.config.length === 1 ? "" : "s"}</div>
86
389
  </div>
390
+ ${info.wired && html`<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
391
+ <label className="flex items-center gap-2 text-sm" style=${{ cursor: "pointer" }}
392
+ title="When a board nobody has configured is plugged in, add it as a device and connect it. Opening a port resets most boards.">
393
+ <span className="form-toggle">
394
+ <input type="checkbox" checked=${info.autoAdd !== false} onChange=${(e) => setAutoAdd(e.target.checked)} />
395
+ <span className="slider"></span>
396
+ </span>
397
+ Auto-add new boards
398
+ </label>
399
+ <button className="btn btn-primary" onClick=${() => { setEditing({ ...EMPTY_FORM }); setSelected(null); }}>
400
+ <${LuPlus} size=${14} /> Add device
401
+ </button>
402
+ </div>`}
403
+ </div>`;
404
+
405
+ if (!info.wired) {
406
+ return html`<div>${header}${info.legacy && html`<${LegacyPorts} onChange=${load} />`}<${SetupCard} info=${info} onDone=${load} /></div>`;
407
+ }
408
+
409
+ const liveOf = (name) => info.live.find((d) => d.name === name);
410
+ const cfg = info.config.find((d) => d.name === selected);
411
+
412
+ function useHardware(p, identity, baud) {
413
+ const form = { ...EMPTY_FORM, baudRate: String(baud || "115200") };
414
+ if (p.serialNumber) Object.assign(form, { strategy: "serialNumber", serialNumber: p.serialNumber });
415
+ else if (identity) Object.assign(form, { strategy: "identity", identity: identity.split(":")[0], vendorId: p.vendorId || "" });
416
+ else Object.assign(form, { strategy: "vendorId", vendorId: p.vendorId || "", productId: p.productId || "" });
417
+ setEditing(form);
418
+ setSelected(null);
419
+ }
420
+
421
+ function edit(d) {
422
+ const m = d.match || {};
423
+ setEditing({
424
+ ...EMPTY_FORM, originalName: d.name, name: d.name, strategy: strategyOf(m),
425
+ serialNumber: m.serialNumber || "", identity: m.identity || "", vendorId: m.vendorId || "",
426
+ productId: m.productId || "", path: m.path || "", baudRate: String(d.baudRate), delimiter: d.delimiter,
427
+ idCommand: d.idCommand, timeout: String(d.timeout), enabled: d.enabled !== false,
428
+ commands: d.commands, rules: d.rules, auto: d.auto, ignoreLines: (d.ignoreLines || []).join(", "),
429
+ });
430
+ }
431
+
432
+ async function remove(d) {
433
+ const ok = await showConfirm('Remove device "' + d.name + '"? Its port is closed right away.', { danger: true, confirmLabel: "Remove" });
434
+ if (!ok) return;
435
+ try {
436
+ await api("/serial/devices/" + encodeURIComponent(d.name), { method: "DELETE" });
437
+ toast("Removed " + d.name, "success");
438
+ setSelected(null);
439
+ load();
440
+ } catch (err) { toast(err.message, "error"); }
441
+ }
87
442
 
443
+ return html`<div>
444
+ ${header}
445
+ ${info.legacy && html`<${LegacyPorts} onChange=${load} />`}
88
446
  <div className="routes-layout">
89
- <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
90
- <div style=${{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border)' }}>
91
- <span style=${{ fontWeight: 600, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Connections</span>
92
- </div>
93
- <div className="routes-list">
94
- ${ports.map(p => html`<div key=${p.name}
95
- className=${`route-item ${selected === p.name && !showAdd ? 'active' : ''}`}
96
- onClick=${() => { setSelected(p.name); setShowAdd(false); }}>
97
- <div style=${{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 }}>
98
- <span className="ri-name">${p.name}</span>
99
- <span className="ri-file">${p.path} @ ${p.baudRate}</span>
100
- </div>
101
- <span className="badge badge-success" style=${{ fontSize: '10px' }}>Active</span>
102
- </div>`)}
103
- <div className=${`route-item ${showAdd ? 'active' : ''}`}
104
- onClick=${() => { setShowAdd(true); setSelected(null); setPortName("serialPort" + (ports.length + 1)); }}
105
- style=${{ borderTop: ports.length ? '1px solid var(--border)' : 'none' }}>
106
- <span className="sidebar-icon" style=${{ color: 'var(--accent-light)' }}>${Icons.plus}</span>
107
- <span className="ri-name" style=${{ color: 'var(--accent-light)' }}>Add Port</span>
108
- </div>
447
+ <div style=${{ display: "flex", flexDirection: "column", gap: "14px" }}>
448
+ <div className="card" style=${{ padding: 0, overflow: "hidden" }}>
449
+ <div style=${{ padding: "12px 16px", borderBottom: "1px solid var(--border)" }}><span className="card-title">Devices</span></div>
450
+ ${info.config.length === 0
451
+ ? html`<div className="ov-empty">${info.autoAdd !== false ? "Plug in a board and it's added here automatically." : "No devices yet. Pick a board under Connected hardware, or Add device."}</div>`
452
+ : html`<div className="routes-list">
453
+ ${info.config.map((d) => {
454
+ const live = liveOf(d.name);
455
+ const st = d.enabled === false ? STATUS.disabled : STATUS[(live && live.status) || "idle"] || STATUS.idle;
456
+ return html`<div key=${d.name} className=${`route-item ${selected === d.name && !editing ? "active" : ""}`}
457
+ onClick=${() => { setSelected(d.name); setEditing(null); }}>
458
+ <div style=${{ display: "flex", flexDirection: "column", gap: "3px", flex: 1, minWidth: 0 }}>
459
+ <span className="ri-name mono">${d.name}${d.auto ? html` <span className="badge badge-muted" title="Added automatically when it was plugged in">auto</span>` : null}</span>
460
+ <span className="ri-file">${live && live.path ? live.path + " · " : ""}${matchLabel(d.match)}</span>
461
+ </div>
462
+ <span className=${`badge ${st.cls}`}>${st.label}</span>
463
+ </div>`;
464
+ })}
465
+ </div>`}
109
466
  </div>
467
+ <${HardwareList} baudRates=${info.baudRates} onUse=${useHardware} />
110
468
  </div>
111
469
 
112
470
  <div className="routes-detail">
113
- ${showAdd && html`<div className="card">
114
- <div className="card-title mb-2">Add Serial Port</div>
115
- <div className="card-subtitle mb-4">Configure a new serial port connection</div>
116
- <div className="form-group">
117
- <label className="form-label">Variable Name</label>
118
- <input className="form-input" value=${portName} onInput=${e => setPortName(e.target.value)} placeholder="serialPort1" />
119
- </div>
120
- <div className="grid-2">
121
- <div className="form-group">
122
- <label className="form-label">Port Path</label>
123
- <input className="form-input" value=${portPath} onInput=${e => setPortPath(e.target.value)} placeholder="/dev/ttyUSB0" />
124
- </div>
125
- <div className="form-group">
126
- <label className="form-label">Baud Rate</label>
127
- <select className="form-select" value=${baudRate} onChange=${e => setBaudRate(e.target.value)}>
128
- ${['9600', '14400', '19200', '38400', '57600', '115200'].map(r =>
129
- html`<option key=${r} value=${r}>${r}</option>`
130
- )}
131
- </select>
132
- </div>
133
- </div>
134
- <button className="btn btn-primary" onClick=${handleAdd} disabled=${loading}>
135
- ${loading ? html`<span className="spinner" />` : null}
136
- ${loading ? 'Adding...' : 'Add Port'}
137
- </button>
138
- </div>`}
139
-
140
- ${!showAdd && selPort && html`<div>
141
- <div className="card mb-4">
142
- <div className="flex items-center justify-between mb-3">
143
- <div>
144
- <div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>${selPort.name}</div>
145
- <div className="text-sm text-muted" style=${{ fontFamily: "'SF Mono', monospace" }}>${selPort.path} @ ${selPort.baudRate} baud</div>
146
- </div>
147
- <button className="btn btn-danger btn-sm btn-icon" onClick=${() => handleDelete(selPort.name)}>
148
- <span className="sidebar-icon">${Icons.trash}</span>
149
- </button>
150
- </div>
151
- <div style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: '8px' }}>Configuration</div>
152
- <div style=${{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '6px 12px', fontSize: '13px' }}>
153
- <span className="text-muted">Variable</span><span>${selPort.name}</span>
154
- <span className="text-muted">Path</span><span style=${{ fontFamily: 'monospace' }}>${selPort.path}</span>
155
- <span className="text-muted">Baud Rate</span><span>${selPort.baudRate}</span>
156
- <span className="text-muted">Parser</span><span>ReadlineParser (\\r\\n)</span>
157
- </div>
158
- </div>
159
- </div>`}
160
-
161
- ${!showAdd && !selPort && html`<div className="card">
162
- <div className="empty-state" style=${{ padding: '40px' }}>
163
- <p>Select a port to view details</p>
164
- </div>
165
- </div>`}
471
+ ${editing
472
+ ? html`<${DeviceEditor} key=${editing.originalName || "new"} initial=${editing} baudRates=${info.baudRates} idPresets=${info.idPresets}
473
+ onSaved=${(name) => { setEditing(null); setSelected(name); load(); }} onCancel=${() => setEditing(null)} />`
474
+ : cfg
475
+ ? html`<${SerialWorkspace} key=${cfg.name} cfg=${cfg} live=${liveOf(cfg.name)}
476
+ devices=${info.config.map((d) => d.name)} matchText=${matchLabel(cfg.match)}
477
+ statusBadge=${cfg.enabled === false ? STATUS.disabled : STATUS[(liveOf(cfg.name) || {}).status || "idle"] || STATUS.idle}
478
+ onEdit=${() => edit(cfg)} onDelete=${() => remove(cfg)} onSaved=${load} />`
479
+ : html`<div className="card"><div className="empty-state" style=${{ padding: "40px" }}>
480
+ <div className="empty-icon"><${LuUsb} size=${30} /></div>
481
+ <p>Select a device, or add one from Connected hardware.</p>
482
+ </div></div>`}
166
483
  </div>
167
484
  </div>
168
485
  </div>`;
169
486
  }
170
-