4bnode 4.1.9 → 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.
- package/add-bonjour.js +29 -1
- package/add-email.js +33 -15
- package/add-serialport.js +133 -41
- package/index.js +3 -3
- package/lib/codegen.js +1442 -36
- package/lib/codegen.test.js +233 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +116 -7
- package/skeleton/.4bnode/assets/js/components/CommandPalette.js +165 -0
- package/skeleton/.4bnode/assets/js/components/SerialWorkspace.js +634 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +64 -64
- package/skeleton/.4bnode/assets/js/lib.js +76 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +113 -53
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +240 -403
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +86 -8
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +37 -19
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1 -1
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +5 -5
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +154 -3
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +455 -139
- package/skeleton/.4bnode/codegen.js +1442 -36
- package/skeleton/.4bnode/dev-api.js +568 -95
- package/skeleton/.4bnode/ui.html +1241 -240
- package/skeleton/.env.example +23 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +8 -0
- package/skeleton/public/index.html +345 -237
- package/skeleton/src/discovery.js +128 -6
- package/skeleton/src/middleware/apiKey.js +63 -20
|
@@ -1,170 +1,486 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { LuCable } from "react-icons/lu";
|
|
3
|
-
import { html, api
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
149
|
+
const [identifying, setIdentifying] = useState(false);
|
|
150
|
+
async function identifyAll() {
|
|
151
|
+
setIdentifying(true);
|
|
22
152
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
159
|
+
setIdentifying(false);
|
|
36
160
|
}
|
|
37
161
|
|
|
38
|
-
async function
|
|
39
|
-
|
|
40
|
-
if (!ok) return;
|
|
162
|
+
async function probe(p) {
|
|
163
|
+
setProbing(p.path);
|
|
41
164
|
try {
|
|
42
|
-
await api("/
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
51
|
-
const
|
|
173
|
+
const usb = (ports || []).filter((p) => p.usb);
|
|
174
|
+
const other = (ports || []).filter((p) => !p.usb);
|
|
52
175
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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="
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
<div style=${{ padding:
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
${
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|