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.
- 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 +52 -3
- 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
|
@@ -853,7 +853,6 @@ export const PACKAGE_VERSIONS = {
|
|
|
853
853
|
mongoose: "^8.8.0",
|
|
854
854
|
multer: "^1.4.5-lts.1",
|
|
855
855
|
nodemailer: "^6.9.0",
|
|
856
|
-
resend: "^4.0.0",
|
|
857
856
|
"bonjour-service": "^1.4.3",
|
|
858
857
|
};
|
|
859
858
|
|
|
@@ -955,7 +954,7 @@ function openApiType(t) {
|
|
|
955
954
|
// Build an OpenAPI 3.0 document from parsed endpoints + models.
|
|
956
955
|
// endpoints: [{ method, path, fullPath, bodyFields[], queryFields[], params[], fileFields[], hasAuth }]
|
|
957
956
|
// models: [{ name, fields:[{name,type,required}] }]
|
|
958
|
-
export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [] } = {}) {
|
|
957
|
+
export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [], apiKey = false } = {}) {
|
|
959
958
|
const paths = {};
|
|
960
959
|
for (const ep of endpoints) {
|
|
961
960
|
const raw = ep.fullPath || ep.path || "/";
|
|
@@ -997,7 +996,18 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
|
|
|
997
996
|
const components = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } };
|
|
998
997
|
if (Object.keys(schemas).length) components.schemas = schemas;
|
|
999
998
|
|
|
1000
|
-
|
|
999
|
+
const spec = { openapi: "3.0.0", info: { title, version }, servers: [{ url: "/" }], paths, components };
|
|
1000
|
+
if (apiKey) {
|
|
1001
|
+
// App-wide gate: every operation needs x-api-key; JWT routes need both.
|
|
1002
|
+
components.securitySchemes.apiKeyAuth = { type: "apiKey", in: "header", name: "x-api-key" };
|
|
1003
|
+
spec.security = [{ apiKeyAuth: [] }];
|
|
1004
|
+
for (const ops of Object.values(paths)) {
|
|
1005
|
+
for (const op of Object.values(ops)) {
|
|
1006
|
+
if (op.security) op.security = [{ apiKeyAuth: [], bearerAuth: [] }];
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return spec;
|
|
1001
1011
|
}
|
|
1002
1012
|
|
|
1003
1013
|
// A self-contained, modern API documentation page rendered from the spec.
|
|
@@ -1098,6 +1108,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1098
1108
|
var SPEC_URL='${specUrl}';
|
|
1099
1109
|
var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};
|
|
1100
1110
|
var uid=0; var ITEMS=[];
|
|
1111
|
+
var APIKEY=false; // set from the spec in render()
|
|
1101
1112
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
1102
1113
|
function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}
|
|
1103
1114
|
function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}
|
|
@@ -1122,10 +1133,15 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1122
1133
|
});
|
|
1123
1134
|
return out;
|
|
1124
1135
|
}
|
|
1136
|
+
// Auth headers a sample needs: x-api-key when the app-wide gate is on (spec
|
|
1137
|
+
// top-level security), plus a Bearer token for routes behind the JWT guard.
|
|
1138
|
+
function hasBearer(e){return !!(e.op.security&&e.op.security.some(function(s){return s.bearerAuth;}));}
|
|
1139
|
+
function authPairs(e){var p=[];if(APIKEY)p.push("'x-api-key': '<your-api-key>'");if(hasBearer(e))p.push("'Authorization': 'Bearer <token>'");return p;}
|
|
1125
1140
|
function curlSample(e){
|
|
1126
1141
|
var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
|
|
1127
1142
|
L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
|
|
1128
|
-
if(
|
|
1143
|
+
if(APIKEY)L.push("-H 'x-api-key: <your-api-key>'");
|
|
1144
|
+
if(hasBearer(e))L.push("-H 'Authorization: Bearer <token>'");
|
|
1129
1145
|
if(b&&isMultipart(b)){
|
|
1130
1146
|
// curl sets the multipart Content-Type (with boundary) itself; -F per field.
|
|
1131
1147
|
fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
|
|
@@ -1144,7 +1160,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1144
1160
|
var h=[];
|
|
1145
1161
|
// For multipart, never set Content-Type by hand — the browser adds the boundary.
|
|
1146
1162
|
if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
|
|
1147
|
-
|
|
1163
|
+
authPairs(e).forEach(function(x){h.push(" "+x);});
|
|
1148
1164
|
if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
|
|
1149
1165
|
if(mp)L.push(" body: form");
|
|
1150
1166
|
else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
|
|
@@ -1159,7 +1175,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1159
1175
|
L.push("const res = await axios({");
|
|
1160
1176
|
L.push(" method: '"+e.method.toLowerCase()+"',");
|
|
1161
1177
|
L.push(" url: '"+sampleUrl(e)+"',");
|
|
1162
|
-
if(e.
|
|
1178
|
+
if(authPairs(e).length)L.push(" headers: { "+authPairs(e).join(", ")+" },");
|
|
1163
1179
|
if(mp)L.push(" data: form");
|
|
1164
1180
|
else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
|
|
1165
1181
|
L.push("});");
|
|
@@ -1174,7 +1190,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1174
1190
|
L.push(" method: '"+e.method.toUpperCase()+"',");
|
|
1175
1191
|
if(mp){L.push(" data: form,");L.push(" processData: false,");L.push(" contentType: false,");}
|
|
1176
1192
|
else if(b)L.push(" contentType: 'application/json',");
|
|
1177
|
-
if(e.
|
|
1193
|
+
if(authPairs(e).length)L.push(" headers: { "+authPairs(e).join(", ")+" },");
|
|
1178
1194
|
if(b&&!mp)L.push(" data: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+"),");
|
|
1179
1195
|
L.push(" success: function(data) {");
|
|
1180
1196
|
L.push(" console.log(data);");
|
|
@@ -1258,6 +1274,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1258
1274
|
}
|
|
1259
1275
|
|
|
1260
1276
|
function render(spec){
|
|
1277
|
+
APIKEY=!!(spec.security&&spec.security.some(function(s){return s.apiKeyAuth;}));
|
|
1261
1278
|
document.getElementById('apiTitle').textContent=(spec.info&&spec.info.title)||'API';
|
|
1262
1279
|
document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');
|
|
1263
1280
|
ITEMS=collect(spec);
|
|
@@ -1462,12 +1479,27 @@ export default router;
|
|
|
1462
1479
|
}
|
|
1463
1480
|
|
|
1464
1481
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1465
|
-
// Email — two backends:
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1482
|
+
// Email — two backends: Sancharak via its HTTP API, or any SMTP server via
|
|
1483
|
+
// nodemailer. `type` selects the code path; `apiKeyOnly` tells the dashboard to
|
|
1484
|
+
// show a single API-key field (no host/port/user).
|
|
1468
1485
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1486
|
+
export const SANCHARAK_API_URL = "https://api.sancharak.com/v1/emails";
|
|
1487
|
+
|
|
1488
|
+
// Why a Sancharak API key can't be right, or null if it looks fine. Catches the
|
|
1489
|
+
// common paste mistake of copying a key shown shortened (e.g. "snk_UwtPIq-5…"),
|
|
1490
|
+
// which otherwise fails deep inside fetch with a cryptic ByteString error.
|
|
1491
|
+
export function sancharakKeyProblem(key) {
|
|
1492
|
+
const k = String(key || "").trim();
|
|
1493
|
+
if (!k) return "A Sancharak API key is required.";
|
|
1494
|
+
if (/[^!-~]/.test(k)) return k.includes("\u2026")
|
|
1495
|
+
? 'That key contains "…" — it looks shortened. Copy the full key from Sancharak (use its Copy button), not the abbreviated one shown in a list.'
|
|
1496
|
+
: "That key contains spaces or non-ASCII characters. Copy the key again exactly as Sancharak shows it.";
|
|
1497
|
+
if (!k.startsWith("snk_")) return 'A Sancharak API key starts with "snk_".';
|
|
1498
|
+
if (k.length < 24) return "That key is too short (" + k.length + " characters) — it may be cut off. Copy the full key from Sancharak.";
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1469
1501
|
export const MAIL_PROVIDERS = {
|
|
1470
|
-
|
|
1502
|
+
sancharak: { label: "Sancharak", type: "sancharak", apiKeyOnly: true, hint: "Paste your Sancharak API key (snk_...). Sends over the Sancharak API — no SMTP host or port, no extra package." },
|
|
1471
1503
|
smtp: { label: "Custom SMTP", type: "smtp", host: "", port: 587, secure: false, hint: "Any SMTP server. Tick TLS for port 465, otherwise 587." },
|
|
1472
1504
|
};
|
|
1473
1505
|
|
|
@@ -1486,20 +1518,54 @@ export function isOfficialMailDomain(email) {
|
|
|
1486
1518
|
// is identical regardless of backend, so calling code never changes.
|
|
1487
1519
|
export function buildMailerService() {
|
|
1488
1520
|
return `// Unified mailer. Backend is chosen by MAIL_PROVIDER in .env:
|
|
1489
|
-
// - "
|
|
1490
|
-
// - "smtp":
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1521
|
+
// - "sancharak": the Sancharak HTTP API (SANCHARAK_API_KEY, MAIL_FROM, MAIL_REPLY_TO)
|
|
1522
|
+
// - "smtp": any SMTP server, via nodemailer (SMTP_* vars)
|
|
1523
|
+
// Sancharak needs no package (it uses fetch); nodemailer is imported lazily only
|
|
1524
|
+
// for SMTP. sendMail({ to, subject, html }) works the same either way.
|
|
1525
|
+
import crypto from 'crypto';
|
|
1526
|
+
|
|
1493
1527
|
const PROVIDER = (process.env.MAIL_PROVIDER || 'smtp').toLowerCase();
|
|
1528
|
+
const SANCHARAK_URL = process.env.SANCHARAK_API_URL || '${SANCHARAK_API_URL}';
|
|
1529
|
+
|
|
1530
|
+
const asList = (v) => (v == null || v === '' ? undefined : Array.isArray(v) ? v : String(v).split(',').map((x) => x.trim()).filter(Boolean));
|
|
1531
|
+
|
|
1532
|
+
// POST one email to Sancharak. Each call gets its own Idempotency-Key, so a
|
|
1533
|
+
// retried request can never send the same email twice.
|
|
1534
|
+
async function sancharakSend({ from, to, replyTo, subject, text, html, idempotencyKey }) {
|
|
1535
|
+
const key = String(process.env.SANCHARAK_API_KEY || '').trim();
|
|
1536
|
+
if (!key) throw new Error('SANCHARAK_API_KEY is not set.');
|
|
1537
|
+
// Header values must be plain ASCII; a key pasted in shortened form ("snk_…")
|
|
1538
|
+
// would otherwise fail inside fetch with an unreadable ByteString error.
|
|
1539
|
+
if (/[^!-~]/.test(key) || key.length < 24) throw new Error('SANCHARAK_API_KEY looks shortened or contains invalid characters. Copy the full key from Sancharak.');
|
|
1540
|
+
const body = { from, to: asList(to), subject, stream: 'tx' };
|
|
1541
|
+
if (replyTo) body.reply_to = asList(replyTo);
|
|
1542
|
+
if (text) body.text = text;
|
|
1543
|
+
if (html) body.html = html;
|
|
1544
|
+
const res = await fetch(SANCHARAK_URL, {
|
|
1545
|
+
method: 'POST',
|
|
1546
|
+
headers: {
|
|
1547
|
+
Authorization: 'Bearer ' + key,
|
|
1548
|
+
'Content-Type': 'application/json',
|
|
1549
|
+
'Idempotency-Key': idempotencyKey || crypto.randomUUID(),
|
|
1550
|
+
},
|
|
1551
|
+
body: JSON.stringify(body),
|
|
1552
|
+
signal: AbortSignal.timeout(15000),
|
|
1553
|
+
});
|
|
1554
|
+
const raw = await res.text();
|
|
1555
|
+
let data = null;
|
|
1556
|
+
try { data = raw ? JSON.parse(raw) : null; } catch { data = { raw }; }
|
|
1557
|
+
if (!res.ok) {
|
|
1558
|
+
const msg = (data && (data.message || data.error || (data.error && data.error.message))) || raw || res.statusText;
|
|
1559
|
+
throw new Error('Sancharak ' + res.status + ': ' + (typeof msg === 'string' ? msg : JSON.stringify(msg)));
|
|
1560
|
+
}
|
|
1561
|
+
return data;
|
|
1562
|
+
}
|
|
1494
1563
|
|
|
1495
1564
|
let _clientPromise = null;
|
|
1496
1565
|
function getClient() {
|
|
1497
1566
|
if (_clientPromise) return _clientPromise;
|
|
1498
1567
|
_clientPromise = (async () => {
|
|
1499
|
-
if (PROVIDER === '
|
|
1500
|
-
const { Resend } = await import('resend');
|
|
1501
|
-
return { type: 'resend', resend: new Resend(process.env.RESEND_API_KEY) };
|
|
1502
|
-
}
|
|
1568
|
+
if (PROVIDER === 'sancharak') return { type: 'sancharak' };
|
|
1503
1569
|
const nodemailer = (await import('nodemailer')).default;
|
|
1504
1570
|
return {
|
|
1505
1571
|
type: 'smtp',
|
|
@@ -1534,24 +1600,31 @@ export function emailTemplate({ title = '', body = '' } = {}) {
|
|
|
1534
1600
|
|
|
1535
1601
|
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
1536
1602
|
// attachments: [{ filename, path }] or [{ filename, content }].
|
|
1537
|
-
|
|
1603
|
+
// idempotencyKey (Sancharak only): pass your own to make retries safe across restarts.
|
|
1604
|
+
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments, idempotencyKey } = {}) {
|
|
1538
1605
|
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
1539
1606
|
// 4brains.in is an official domain. Sending from it is only allowed once it has
|
|
1540
1607
|
// been configured through the 4bnode dashboard/CLI with the authorization
|
|
1541
1608
|
// password (which sets OFFICIAL_MAIL_AUTHORIZED). Direct/code use is blocked.
|
|
1542
|
-
const effectiveFrom = from || (PROVIDER === '
|
|
1609
|
+
const effectiveFrom = from || (PROVIDER === 'sancharak' ? process.env.MAIL_FROM : (process.env.SMTP_FROM || process.env.SMTP_USER));
|
|
1543
1610
|
const touchesOfficial = /4brains\\.in/i.test(String(effectiveFrom || '')) || (PROVIDER === 'smtp' && /4brains\\.in/i.test(String(process.env.SMTP_HOST || '')));
|
|
1544
1611
|
if (touchesOfficial && !process.env.OFFICIAL_MAIL_AUTHORIZED) {
|
|
1545
1612
|
throw new Error('The 4brains.in domain is official and cannot be used directly — configure it via the 4bnode dashboard (Email) with the authorization password.');
|
|
1546
1613
|
}
|
|
1547
1614
|
const client = await getClient();
|
|
1548
|
-
if (client.type === '
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1615
|
+
if (client.type === 'sancharak') {
|
|
1616
|
+
if (cc || bcc || (attachments && attachments.length)) {
|
|
1617
|
+
throw new Error('cc, bcc and attachments are not supported by the Sancharak backend yet. Send separate emails, or use SMTP.');
|
|
1618
|
+
}
|
|
1619
|
+
return sancharakSend({
|
|
1620
|
+
from: effectiveFrom,
|
|
1621
|
+
to,
|
|
1622
|
+
replyTo: replyTo || process.env.MAIL_REPLY_TO,
|
|
1623
|
+
subject,
|
|
1624
|
+
text,
|
|
1625
|
+
html,
|
|
1626
|
+
idempotencyKey,
|
|
1552
1627
|
});
|
|
1553
|
-
if (error) throw new Error(error.message || 'Resend send failed');
|
|
1554
|
-
return data;
|
|
1555
1628
|
}
|
|
1556
1629
|
return client.transporter.sendMail({
|
|
1557
1630
|
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
@@ -1559,11 +1632,13 @@ export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo
|
|
|
1559
1632
|
});
|
|
1560
1633
|
}
|
|
1561
1634
|
|
|
1562
|
-
// Verify the mailer is usable (SMTP handshake, or that the
|
|
1635
|
+
// Verify the mailer is usable (SMTP handshake, or that the Sancharak key and
|
|
1636
|
+
// From address are set).
|
|
1563
1637
|
export async function verifyMailer() {
|
|
1564
1638
|
const client = await getClient();
|
|
1565
|
-
if (client.type === '
|
|
1566
|
-
if (!process.env.
|
|
1639
|
+
if (client.type === 'sancharak') {
|
|
1640
|
+
if (!process.env.SANCHARAK_API_KEY) throw new Error('SANCHARAK_API_KEY is not set.');
|
|
1641
|
+
if (!process.env.MAIL_FROM) throw new Error('MAIL_FROM is not set.');
|
|
1567
1642
|
return true;
|
|
1568
1643
|
}
|
|
1569
1644
|
return client.transporter.verify();
|
|
@@ -1611,13 +1686,23 @@ export function buildDiscoveryModule() {
|
|
|
1611
1686
|
// - Clean shutdown sends TTL=0 "goodbye" packets so stale records do not linger
|
|
1612
1687
|
// in client caches for the record's full TTL (can be over an hour).
|
|
1613
1688
|
//
|
|
1614
|
-
//
|
|
1689
|
+
// - OFF by default. Advertising only starts with BONJOUR_ENABLED=on.
|
|
1690
|
+
// - On a machine with several networks (e.g. Wi-Fi AND Ethernet), mDNS is bound
|
|
1691
|
+
// to ONE chosen interface (BONJOUR_INTERFACE=en0) and only that network's
|
|
1692
|
+
// addresses are advertised. Otherwise a phone on Wi-Fi can be handed the
|
|
1693
|
+
// Ethernet IP it cannot reach. The interface NAME is stored, not its IP, so a
|
|
1694
|
+
// new DHCP lease does not break it.
|
|
1695
|
+
//
|
|
1696
|
+
// Configure via .env:
|
|
1697
|
+
// BONJOUR_ENABLED=on | BONJOUR_NAME=... | BONJOUR_TYPE=http | BONJOUR_INTERFACE=en0
|
|
1615
1698
|
|
|
1616
1699
|
import os from "os";
|
|
1700
|
+
import { execFileSync } from "child_process";
|
|
1617
1701
|
|
|
1618
1702
|
let bonjour = null; // shared mDNS controller (created lazily, one per process)
|
|
1619
1703
|
let published = null; // handle to the currently-advertised service, if any
|
|
1620
1704
|
let BonjourCtor = null; // cached constructor after the first successful import
|
|
1705
|
+
let boundInterface = null; // interface name the current controller is bound to ("" = all)
|
|
1621
1706
|
|
|
1622
1707
|
let selfState = {
|
|
1623
1708
|
status: "idle", // idle | advertising | error | unsupported
|
|
@@ -1627,6 +1712,8 @@ let selfState = {
|
|
|
1627
1712
|
host: null,
|
|
1628
1713
|
txt: null,
|
|
1629
1714
|
error: null,
|
|
1715
|
+
interface: null, // { name, label, address } actually used, or null for all
|
|
1716
|
+
warning: null,
|
|
1630
1717
|
};
|
|
1631
1718
|
|
|
1632
1719
|
// Import bonjour-service on demand. Returns the constructor, or null if the
|
|
@@ -1664,9 +1751,89 @@ function defaultServiceName() {
|
|
|
1664
1751
|
return host ? base + " (" + host + ")" : base;
|
|
1665
1752
|
}
|
|
1666
1753
|
|
|
1667
|
-
//
|
|
1754
|
+
// Off by default; opt in with BONJOUR_ENABLED=on.
|
|
1668
1755
|
export function isDiscoveryEnabled() {
|
|
1669
|
-
return process.env.BONJOUR_ENABLED
|
|
1756
|
+
return process.env.BONJOUR_ENABLED === "on";
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Virtual / tunnel / container interfaces that never reach other LAN devices.
|
|
1760
|
+
const VIRTUAL_IFACE = /^(lo|utun|awdl|llw|gif|stf|anpi|ap|bridge|vmnet|vboxnet|docker|br-|veth|virbr|tun|tap|zt|tailscale|wg)/i;
|
|
1761
|
+
|
|
1762
|
+
// macOS: map device names (en0) to their hardware port ("Wi-Fi", "Ethernet").
|
|
1763
|
+
let macPorts = null;
|
|
1764
|
+
function macHardwarePorts() {
|
|
1765
|
+
if (macPorts) return macPorts;
|
|
1766
|
+
macPorts = {};
|
|
1767
|
+
try {
|
|
1768
|
+
const out = execFileSync("networksetup", ["-listallhardwareports"], { encoding: "utf8", timeout: 3000 });
|
|
1769
|
+
let current = null;
|
|
1770
|
+
for (const raw of out.split(String.fromCharCode(10))) {
|
|
1771
|
+
const line = raw.trim(); // trim also drops a Windows CR
|
|
1772
|
+
const port = line.match(/^Hardware Port: (.+)$/);
|
|
1773
|
+
if (port) current = port[1].trim();
|
|
1774
|
+
const dev = line.match(/^Device: (.+)$/);
|
|
1775
|
+
if (dev && current) macPorts[dev[1].trim()] = current;
|
|
1776
|
+
}
|
|
1777
|
+
} catch {}
|
|
1778
|
+
return macPorts;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function interfaceKind(name) {
|
|
1782
|
+
if (os.platform() === "darwin") {
|
|
1783
|
+
const hw = macHardwarePorts()[name];
|
|
1784
|
+
if (hw) return /wi-?fi|airport/i.test(hw) ? "Wi-Fi" : hw;
|
|
1785
|
+
}
|
|
1786
|
+
if (/^(wl|wlan|wifi)/i.test(name) || /wi-?fi|wireless|wlan/i.test(name)) return "Wi-Fi";
|
|
1787
|
+
if (/^(eth|en|em|eno|ens|enp)/i.test(name) || /ethernet|lan/i.test(name)) return "Ethernet";
|
|
1788
|
+
return "Network";
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Physical networks this machine is connected to, each with its IPv4 address.
|
|
1792
|
+
// Used by the dashboard / CLI to let the user choose which one to advertise on.
|
|
1793
|
+
export function listNetworkInterfaces() {
|
|
1794
|
+
const all = os.networkInterfaces();
|
|
1795
|
+
const result = [];
|
|
1796
|
+
for (const name of Object.keys(all)) {
|
|
1797
|
+
if (VIRTUAL_IFACE.test(name)) continue;
|
|
1798
|
+
const addrs = all[name] || [];
|
|
1799
|
+
const v4 = addrs.find((a) => a.family === "IPv4" && !a.internal);
|
|
1800
|
+
if (!v4) continue;
|
|
1801
|
+
const kind = interfaceKind(name);
|
|
1802
|
+
result.push({
|
|
1803
|
+
name,
|
|
1804
|
+
kind,
|
|
1805
|
+
label: kind + " (" + name + ")",
|
|
1806
|
+
address: v4.address,
|
|
1807
|
+
// 169.254.x.x = self-assigned: the cable/adapter is up but no network answered.
|
|
1808
|
+
selfAssigned: v4.address.startsWith("169.254."),
|
|
1809
|
+
ipv6: addrs.filter((a) => a.family === "IPv6" && !a.internal).map((a) => a.address),
|
|
1810
|
+
mac: v4.mac,
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
return result;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// Resolve BONJOUR_INTERFACE to a live interface. Returns { iface, warning }:
|
|
1817
|
+
// iface null means "all interfaces".
|
|
1818
|
+
function resolveInterface() {
|
|
1819
|
+
const wanted = (process.env.BONJOUR_INTERFACE || "").trim();
|
|
1820
|
+
const list = listNetworkInterfaces();
|
|
1821
|
+
if (wanted) {
|
|
1822
|
+
const hit = list.find((i) => i.name === wanted);
|
|
1823
|
+
if (hit) return { iface: hit, warning: null };
|
|
1824
|
+
return {
|
|
1825
|
+
iface: null,
|
|
1826
|
+
warning: "Network " + JSON.stringify(wanted) + " is not connected; advertising on all networks instead.",
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
if (list.length > 1) {
|
|
1830
|
+
return {
|
|
1831
|
+
iface: null,
|
|
1832
|
+
warning: "Several networks are connected (" + list.map((i) => i.label).join(", ") +
|
|
1833
|
+
"). Advertising on all of them; choose one with BONJOUR_INTERFACE or in the dashboard.",
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
return { iface: null, warning: null };
|
|
1670
1837
|
}
|
|
1671
1838
|
|
|
1672
1839
|
// Snapshot of what this process is currently advertising (read by the dashboard).
|
|
@@ -1674,6 +1841,29 @@ export function getSelfState() {
|
|
|
1674
1841
|
return { ...selfState, enabled: isDiscoveryEnabled() };
|
|
1675
1842
|
}
|
|
1676
1843
|
|
|
1844
|
+
// (Re)create the mDNS controller bound to the chosen interface. Binding the
|
|
1845
|
+
// socket to 0.0.0.0 while pinning multicast to the interface IP keeps receiving
|
|
1846
|
+
// queries reliable on macOS/Linux.
|
|
1847
|
+
function ensureController(Ctor, iface) {
|
|
1848
|
+
const key = iface ? iface.name : "";
|
|
1849
|
+
if (bonjour && boundInterface === key) return;
|
|
1850
|
+
if (bonjour) {
|
|
1851
|
+
try { bonjour.destroy(); } catch {}
|
|
1852
|
+
}
|
|
1853
|
+
bonjour = iface ? new Ctor({ interface: iface.address, bind: "0.0.0.0" }) : new Ctor();
|
|
1854
|
+
boundInterface = key;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Keep only the chosen interface's addresses in A/AAAA records, so clients on
|
|
1858
|
+
// that network are never handed an IP from another network.
|
|
1859
|
+
function restrictAddresses(service, iface) {
|
|
1860
|
+
if (!iface || typeof service.records !== "function") return;
|
|
1861
|
+
const allowed = new Set([iface.address].concat(iface.ipv6));
|
|
1862
|
+
const original = service.records.bind(service);
|
|
1863
|
+
service.records = () =>
|
|
1864
|
+
original().filter((r) => (r.type !== "A" && r.type !== "AAAA") || allowed.has(r.data));
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1677
1867
|
// Advertise this app on the local network. Safe to call repeatedly; each call
|
|
1678
1868
|
// replaces any prior advertisement, so a port change re-publishes cleanly. Must
|
|
1679
1869
|
// be called AFTER the HTTP server is actually listening (so the port is bound).
|
|
@@ -1703,7 +1893,9 @@ export async function startAdvertising({ port, name, type } = {}) {
|
|
|
1703
1893
|
|
|
1704
1894
|
// Replace any previous advertisement so two conflicting records never coexist.
|
|
1705
1895
|
await stopAdvertising();
|
|
1706
|
-
|
|
1896
|
+
const { iface, warning } = resolveInterface();
|
|
1897
|
+
ensureController(Ctor, iface);
|
|
1898
|
+
if (warning) console.warn("mDNS: " + warning);
|
|
1707
1899
|
|
|
1708
1900
|
const serviceName = name || defaultServiceName();
|
|
1709
1901
|
const serviceType = type || process.env.BONJOUR_TYPE || "http";
|
|
@@ -1715,6 +1907,7 @@ export async function startAdvertising({ port, name, type } = {}) {
|
|
|
1715
1907
|
|
|
1716
1908
|
try {
|
|
1717
1909
|
published = bonjour.publish({ name: serviceName, type: serviceType, port, host, txt });
|
|
1910
|
+
restrictAddresses(published, iface);
|
|
1718
1911
|
selfState = {
|
|
1719
1912
|
status: "advertising",
|
|
1720
1913
|
name: serviceName,
|
|
@@ -1723,10 +1916,13 @@ export async function startAdvertising({ port, name, type } = {}) {
|
|
|
1723
1916
|
host,
|
|
1724
1917
|
txt,
|
|
1725
1918
|
error: null,
|
|
1919
|
+
interface: iface ? { name: iface.name, label: iface.label, address: iface.address } : null,
|
|
1920
|
+
warning,
|
|
1726
1921
|
};
|
|
1727
1922
|
published.on("up", () => {
|
|
1728
1923
|
console.log(
|
|
1729
|
-
"mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)"
|
|
1924
|
+
"mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)" +
|
|
1925
|
+
(iface ? " on " + iface.label + " " + iface.address : " on all networks"),
|
|
1730
1926
|
);
|
|
1731
1927
|
});
|
|
1732
1928
|
published.on("error", (err) => {
|
|
@@ -1776,6 +1972,7 @@ export async function destroyDiscovery() {
|
|
|
1776
1972
|
if (bonjour) bonjour.destroy();
|
|
1777
1973
|
} catch {}
|
|
1778
1974
|
bonjour = null;
|
|
1975
|
+
boundInterface = null;
|
|
1779
1976
|
}
|
|
1780
1977
|
|
|
1781
1978
|
// Browse the LAN for services of a given type. Collects results for timeoutMs
|
|
@@ -1784,7 +1981,7 @@ export async function destroyDiscovery() {
|
|
|
1784
1981
|
export async function browse({ type = "http", timeoutMs = 2500 } = {}) {
|
|
1785
1982
|
const Ctor = await loadBonjour();
|
|
1786
1983
|
if (!Ctor) return { available: false, services: [] };
|
|
1787
|
-
if (!bonjour)
|
|
1984
|
+
if (!bonjour) ensureController(Ctor, resolveInterface().iface);
|
|
1788
1985
|
|
|
1789
1986
|
return new Promise((resolve) => {
|
|
1790
1987
|
const found = new Map();
|
|
@@ -1894,3 +2091,1212 @@ export function wireDiscoveryIntoIndex(content) {
|
|
|
1894
2091
|
return out;
|
|
1895
2092
|
}
|
|
1896
2093
|
|
|
2094
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2095
|
+
// App-wide API key gate
|
|
2096
|
+
//
|
|
2097
|
+
// Every API route (login and registration included) requires a valid x-api-key
|
|
2098
|
+
// header. skeleton/src/middleware/apiKey.js is generated from
|
|
2099
|
+
// buildApiKeyMiddleware() (a test asserts they're identical), and the dashboard
|
|
2100
|
+
// writes the same file into older projects when it wires the gate in. Keys are
|
|
2101
|
+
// stored as SHA-256 hashes in src/api-keys.json; the plaintext is shown once.
|
|
2102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2103
|
+
|
|
2104
|
+
export function buildApiKeyMiddleware() {
|
|
2105
|
+
return `// API key authentication.
|
|
2106
|
+
//
|
|
2107
|
+
// Every request to this app's API must carry a valid key in the x-api-key
|
|
2108
|
+
// header, including login and registration, unless the gate is switched off
|
|
2109
|
+
// with API_KEY_REQUIRED=off. Keys are created and revoked in the 4bnode
|
|
2110
|
+
// dashboard (Security -> API keys) and stored HASHED in src/api-keys.json, so
|
|
2111
|
+
// that file never holds a usable key. Revoking a key takes effect immediately.
|
|
2112
|
+
//
|
|
2113
|
+
// index.js (whole app, built in): app.use(apiKeyGate);
|
|
2114
|
+
// a single router only: app.use('/api/private', apiKey);
|
|
2115
|
+
//
|
|
2116
|
+
// Never needs a key: the /docs page, CORS preflight (OPTIONS) requests, the
|
|
2117
|
+
// /_dev dashboard, and any path prefix listed in API_KEY_PUBLIC_PATHS
|
|
2118
|
+
// (comma-separated, e.g. /health,/webhooks/stripe).
|
|
2119
|
+
//
|
|
2120
|
+
// Routes that need a signed-in user still check the JWT (auth middleware) on
|
|
2121
|
+
// top of this: the API key identifies the calling app, the JWT the user.
|
|
2122
|
+
import fs from 'fs';
|
|
2123
|
+
import path from 'path';
|
|
2124
|
+
import crypto from 'crypto';
|
|
2125
|
+
import { fileURLToPath } from 'url';
|
|
2126
|
+
|
|
2127
|
+
const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
|
|
2128
|
+
let _cache = { mtimeMs: -1, hashes: [] };
|
|
2129
|
+
|
|
2130
|
+
export function hashApiKey(key) {
|
|
2131
|
+
return crypto.createHash('sha256').update(String(key)).digest('hex');
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Read the keys file fresh when it changes (mtime-cached), so keys created or
|
|
2135
|
+
// revoked in the dashboard apply without a restart. Older files stored the
|
|
2136
|
+
// plaintext key; those entries are hashed on read.
|
|
2137
|
+
function loadKeyHashes() {
|
|
2138
|
+
try {
|
|
2139
|
+
const { mtimeMs } = fs.statSync(KEYS_FILE);
|
|
2140
|
+
if (mtimeMs !== _cache.mtimeMs) {
|
|
2141
|
+
const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
|
|
2142
|
+
const hashes = (Array.isArray(list) ? list : [])
|
|
2143
|
+
.map((k) => k && (k.hash || (k.key ? hashApiKey(k.key) : null)))
|
|
2144
|
+
.filter(Boolean);
|
|
2145
|
+
_cache = { mtimeMs, hashes };
|
|
2146
|
+
}
|
|
2147
|
+
} catch {
|
|
2148
|
+
_cache = { mtimeMs: -1, hashes: [] };
|
|
2149
|
+
}
|
|
2150
|
+
return _cache.hashes;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// Constant-time membership test over fixed-length digests: no early return,
|
|
2154
|
+
// so timing reveals neither which key matched nor the key length.
|
|
2155
|
+
function keyMatches(provided) {
|
|
2156
|
+
const p = Buffer.from(hashApiKey(provided), 'hex');
|
|
2157
|
+
let ok = false;
|
|
2158
|
+
for (const h of loadKeyHashes()) {
|
|
2159
|
+
const v = Buffer.from(h, 'hex');
|
|
2160
|
+
if (v.length === p.length && crypto.timingSafeEqual(p, v)) ok = true;
|
|
2161
|
+
}
|
|
2162
|
+
return ok;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const apiKey = (req, res, next) => {
|
|
2166
|
+
const provided = req.header('x-api-key');
|
|
2167
|
+
if (!provided || !keyMatches(provided)) {
|
|
2168
|
+
return res.status(401).json({ message: 'Invalid or missing API key' });
|
|
2169
|
+
}
|
|
2170
|
+
next();
|
|
2171
|
+
};
|
|
2172
|
+
|
|
2173
|
+
function publicPaths() {
|
|
2174
|
+
const extra = String(process.env.API_KEY_PUBLIC_PATHS || '')
|
|
2175
|
+
.split(',')
|
|
2176
|
+
.map((p) => p.trim())
|
|
2177
|
+
.filter((p) => p.startsWith('/'));
|
|
2178
|
+
return ['/_dev', '/docs'].concat(extra);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
function isPublicPath(p) {
|
|
2182
|
+
return publicPaths().some((pre) => {
|
|
2183
|
+
const base = pre.length > 1 && pre.endsWith('/') ? pre.slice(0, -1) : pre;
|
|
2184
|
+
return p === base || p.startsWith(base + '/');
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// App-wide gate. Mount after static files and before any routes.
|
|
2189
|
+
export function apiKeyGate(req, res, next) {
|
|
2190
|
+
if (process.env.API_KEY_REQUIRED === 'off') return next();
|
|
2191
|
+
if (req.method === 'OPTIONS') return next();
|
|
2192
|
+
if (isPublicPath(req.path)) return next();
|
|
2193
|
+
return apiKey(req, res, next);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
export default apiKey;
|
|
2197
|
+
`;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
export const API_KEY_GATE_IMPORT = 'import { apiKeyGate } from "./src/middleware/apiKey.js";';
|
|
2201
|
+
export const API_KEY_GATE_USE = "app.use(apiKeyGate);";
|
|
2202
|
+
|
|
2203
|
+
// Idempotently mount the gate in an index.js: right after static files (so
|
|
2204
|
+
// public assets stay public) and therefore before every route registration.
|
|
2205
|
+
export function wireApiKeyGateIntoIndex(content) {
|
|
2206
|
+
let out = content;
|
|
2207
|
+
if (!out.includes("apiKeyGate")) {
|
|
2208
|
+
out = addImportAfterLastImport(out, API_KEY_GATE_IMPORT);
|
|
2209
|
+
}
|
|
2210
|
+
if (!out.includes(API_KEY_GATE_USE)) {
|
|
2211
|
+
const block =
|
|
2212
|
+
"// ── API key gate ────────────────────────────────────\n" +
|
|
2213
|
+
"// Every API route (login/register included) needs a valid x-api-key header.\n" +
|
|
2214
|
+
"// Manage keys in the /_dev dashboard → Security. API_KEY_REQUIRED=off disables.\n" +
|
|
2215
|
+
API_KEY_GATE_USE;
|
|
2216
|
+
const m = out.match(/^app\.use\(express\.static\(.*\);[ \t]*$/m);
|
|
2217
|
+
if (m) {
|
|
2218
|
+
const at = m.index + m[0].length;
|
|
2219
|
+
out = out.slice(0, at) + "\n\n" + block + out.slice(at);
|
|
2220
|
+
} else {
|
|
2221
|
+
out = insertBeforeRoutesContent(out, block);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
return out;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// New API key: "4b_" + 32 random bytes, base64url. Returned once to the user;
|
|
2228
|
+
// only hashApiKeyValue(key) is stored.
|
|
2229
|
+
export function generateApiKey(randomBytes) {
|
|
2230
|
+
return "4b_" + Buffer.from(randomBytes(32)).toString("base64url");
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
export function hashApiKeyValue(key, createHash) {
|
|
2234
|
+
return createHash("sha256").update(String(key)).digest("hex");
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2238
|
+
// Serial devices (Arduino / ESP32 over USB serial)
|
|
2239
|
+
//
|
|
2240
|
+
// Generated projects never hard-code a COM path. Devices are listed in
|
|
2241
|
+
// src/serial/devices.json and matched at connect time by USB serial number,
|
|
2242
|
+
// USB vendor/product id, or a firmware identity handshake; the manager
|
|
2243
|
+
// auto-reconnects, queues command -> reply with timeouts, and closes ports on
|
|
2244
|
+
// shutdown. Both the CLI (add-serialport) and the dashboard write these same
|
|
2245
|
+
// files and validate config with normalizeSerialDevice().
|
|
2246
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2247
|
+
|
|
2248
|
+
export const SERIAL_DEPS = ["serialport", "@serialport/parser-readline"];
|
|
2249
|
+
export const SERIAL_BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
|
2250
|
+
export const SERIAL_ROUTE_PREFIX = "/api/serial";
|
|
2251
|
+
// Identity command presets (also tried in this order when auto-adding a board).
|
|
2252
|
+
export const SERIAL_ID_PRESETS = [
|
|
2253
|
+
{ command: "ID?", label: "ID? (your own firmware)" },
|
|
2254
|
+
{ command: "M115", label: "M115 (Marlin / RepRap 3D printers)" },
|
|
2255
|
+
{ command: "$I", label: "$I (GRBL CNC)" },
|
|
2256
|
+
{ command: "ATI", label: "ATI (AT-command modems)" },
|
|
2257
|
+
];
|
|
2258
|
+
export const SERIAL_RULE_WHEN = ["contains", "equals", "startsWith", "regex"];
|
|
2259
|
+
export const SERIAL_RULE_ACTIONS = ["webhook", "send"];
|
|
2260
|
+
|
|
2261
|
+
export function buildSerialDeviceModule() {
|
|
2262
|
+
return `// Serial device manager for Arduino / ESP32 boards over USB serial.
|
|
2263
|
+
//
|
|
2264
|
+
// Generated by 4bnode. It follows the rules that keep serial hardware working
|
|
2265
|
+
// on an unattended machine:
|
|
2266
|
+
// - Never hard-code a COM path. A device is MATCHED every time it connects,
|
|
2267
|
+
// by USB serial number, USB vendor/product id, or a firmware identity
|
|
2268
|
+
// handshake (send "ID?", read the reply). COM numbers and tty names change
|
|
2269
|
+
// between reboots and USB ports; these do not.
|
|
2270
|
+
// - Always read through a parser, so each "data" event is one complete line.
|
|
2271
|
+
// - Every write ends with the delimiter the firmware expects.
|
|
2272
|
+
// - send() is command -> reply with a timeout. Commands are queued, so only
|
|
2273
|
+
// one is in flight and a reply can't be matched to the wrong command.
|
|
2274
|
+
// Lines that can't be the reply are skipped: an echo of the command, the
|
|
2275
|
+
// device's ignoreLines, and heartbeats it prints on its own (any line seen
|
|
2276
|
+
// 3+ times while idle, e.g. Marlin's "wait").
|
|
2277
|
+
// - Wait out the board's auto-reset after opening before the first command.
|
|
2278
|
+
// - Auto-reconnect: an unexpected close (board reset, cable pulled) re-runs
|
|
2279
|
+
// find + open until the board is back, on whatever port it returns as.
|
|
2280
|
+
// - Ports are probed one at a time, never in parallel, and a port this
|
|
2281
|
+
// process already holds open is never probed (opening resets most boards).
|
|
2282
|
+
// - Ports are closed on shutdown so the next launch can open them.
|
|
2283
|
+
// - Hotplug: portWatcher notices boards being plugged in or removed, so an
|
|
2284
|
+
// offline device reconnects the moment its board is attached.
|
|
2285
|
+
|
|
2286
|
+
import { EventEmitter } from 'events';
|
|
2287
|
+
|
|
2288
|
+
let SerialPortCtor = null;
|
|
2289
|
+
let ReadlineParserCtor = null;
|
|
2290
|
+
|
|
2291
|
+
// Imported lazily: if the native serialport module is missing or fails to
|
|
2292
|
+
// load on this machine, the app still boots and devices just stay offline.
|
|
2293
|
+
async function loadSerial() {
|
|
2294
|
+
if (SerialPortCtor) return true;
|
|
2295
|
+
try {
|
|
2296
|
+
SerialPortCtor = (await import('serialport')).SerialPort;
|
|
2297
|
+
ReadlineParserCtor = (await import('@serialport/parser-readline')).ReadlineParser;
|
|
2298
|
+
return true;
|
|
2299
|
+
} catch {
|
|
2300
|
+
return false;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// USB bridge chips by vendor id. A hint, not proof: an ESP32 and an ESP8266
|
|
2305
|
+
// can both report 10c4 or 1a86, so confirm with the identity handshake.
|
|
2306
|
+
export const USB_VENDORS = {
|
|
2307
|
+
'2341': 'Arduino',
|
|
2308
|
+
'2a03': 'Arduino (.org)',
|
|
2309
|
+
'303a': 'Espressif (ESP32 native USB)',
|
|
2310
|
+
'10c4': 'Silicon Labs CP210x',
|
|
2311
|
+
'1a86': 'WCH CH340 / CH9102',
|
|
2312
|
+
'0403': 'FTDI',
|
|
2313
|
+
'239a': 'Adafruit',
|
|
2314
|
+
'2e8a': 'Raspberry Pi (RP2040)',
|
|
2315
|
+
'0483': 'STMicroelectronics (STM32)',
|
|
2316
|
+
};
|
|
2317
|
+
|
|
2318
|
+
// Replies that are clearly not an identity: firmware errors and bare acks
|
|
2319
|
+
// (e.g. Marlin answers an unknown command with "echo:Unknown command" + "ok").
|
|
2320
|
+
const NOT_AN_IDENTITY = /unknown command|invalid|not supported|error|^ok$|^echo:/i;
|
|
2321
|
+
|
|
2322
|
+
// Identity commands tried in order when a board's command is unknown:
|
|
2323
|
+
// ID? (your own firmware), M115 (Marlin / RepRap 3D printers), $I (GRBL CNC),
|
|
2324
|
+
// ATI (AT-command modems). Override with SERIAL_ID_COMMANDS=ID?,M115
|
|
2325
|
+
export const ID_COMMANDS = ['ID?', 'M115', '$I', 'ATI'];
|
|
2326
|
+
|
|
2327
|
+
export function idCommands() {
|
|
2328
|
+
const env = String(process.env.SERIAL_ID_COMMANDS || '').split(',').map((c) => c.trim()).filter(Boolean);
|
|
2329
|
+
return env.length ? env : ID_COMMANDS;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Short, stable key for an identity reply, used to match and name a board.
|
|
2333
|
+
// "LED-CTRL:2.1" -> "LED-CTRL"
|
|
2334
|
+
// "FIRMWARE_NAME:Marlin 2.0.5.4 (GitHub)" -> "Marlin"
|
|
2335
|
+
// "[VER:1.1h.20190825:]" -> "GRBL"
|
|
2336
|
+
export function identityKey(reply) {
|
|
2337
|
+
const v = String(reply || '').trim();
|
|
2338
|
+
if (!v) return null;
|
|
2339
|
+
const fw = v.match(/FIRMWARE_NAME: *([A-Za-z0-9._-]+)/);
|
|
2340
|
+
if (fw) return fw[1];
|
|
2341
|
+
if (v.startsWith('[VER:')) return 'GRBL';
|
|
2342
|
+
const head = v.split(/[: ]/)[0].replace(/[^A-Za-z0-9._-]/g, '');
|
|
2343
|
+
return head ? head.slice(0, 40) : null;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
const CR = String.fromCharCode(13);
|
|
2347
|
+
const LF = String.fromCharCode(10);
|
|
2348
|
+
const LINE_BREAKS = new RegExp('[' + CR + LF + ']', 'g');
|
|
2349
|
+
|
|
2350
|
+
export function delimiterFor(name) {
|
|
2351
|
+
return name === 'crlf' ? CR + LF : LF;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
const openPaths = new Set(); // ports this process currently holds open
|
|
2355
|
+
// Last identity each port answered with (path -> "LED-CTRL:2.1"). Probing
|
|
2356
|
+
// resets most boards, so a reply is remembered until the board is unplugged.
|
|
2357
|
+
const identities = new Map();
|
|
2358
|
+
let probeChain = Promise.resolve(); // serializes probing across all devices
|
|
2359
|
+
|
|
2360
|
+
function normId(v) {
|
|
2361
|
+
return String(v || '').toLowerCase().replace(/^0x/, '');
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2365
|
+
|
|
2366
|
+
// Every serial port with its USB metadata.
|
|
2367
|
+
export async function listPorts() {
|
|
2368
|
+
if (!(await loadSerial())) {
|
|
2369
|
+
throw new Error('serialport is not installed. Run: npm install serialport @serialport/parser-readline');
|
|
2370
|
+
}
|
|
2371
|
+
const ports = await SerialPortCtor.list();
|
|
2372
|
+
return ports.map((p) => {
|
|
2373
|
+
const vendorId = normId(p.vendorId) || null;
|
|
2374
|
+
return {
|
|
2375
|
+
path: p.path,
|
|
2376
|
+
vendorId,
|
|
2377
|
+
productId: normId(p.productId) || null,
|
|
2378
|
+
manufacturer: p.manufacturer || null,
|
|
2379
|
+
serialNumber: p.serialNumber || null,
|
|
2380
|
+
vendor: (vendorId && USB_VENDORS[vendorId]) || null,
|
|
2381
|
+
usb: !!vendorId,
|
|
2382
|
+
inUse: openPaths.has(p.path),
|
|
2383
|
+
identity: identities.get(p.path) || null,
|
|
2384
|
+
};
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// Open a port, wait for the board's auto-reset, send the identity command and
|
|
2389
|
+
// return the first line it answers with, or null. Never touches an open port.
|
|
2390
|
+
export function probeIdentity(path, opts = {}) {
|
|
2391
|
+
const baudRate = Number(opts.baudRate) || 115200;
|
|
2392
|
+
const command = opts.command || 'ID?';
|
|
2393
|
+
const delimiter = delimiterFor(opts.delimiter);
|
|
2394
|
+
const timeout = Number(opts.timeout) || 1500;
|
|
2395
|
+
const resetDelayMs = opts.resetDelayMs == null ? 1200 : Number(opts.resetDelayMs);
|
|
2396
|
+
if (openPaths.has(path)) return Promise.resolve(null);
|
|
2397
|
+
return loadSerial().then((ok) => {
|
|
2398
|
+
if (!ok) return null;
|
|
2399
|
+
return new Promise((resolve) => {
|
|
2400
|
+
let done = false;
|
|
2401
|
+
const port = new SerialPortCtor({ path, baudRate, autoOpen: false });
|
|
2402
|
+
const parser = port.pipe(new ReadlineParserCtor({ delimiter }));
|
|
2403
|
+
const finish = (result) => {
|
|
2404
|
+
if (done) return;
|
|
2405
|
+
done = true;
|
|
2406
|
+
clearTimeout(guard);
|
|
2407
|
+
openPaths.delete(path);
|
|
2408
|
+
if (port.isOpen) port.close(() => resolve(result));
|
|
2409
|
+
else resolve(result);
|
|
2410
|
+
};
|
|
2411
|
+
const guard = setTimeout(() => finish(null), resetDelayMs + timeout + 1500);
|
|
2412
|
+
// Only a line that arrives AFTER the command counts. Lines the board was
|
|
2413
|
+
// already sending on its own (boot banners, "wait" heartbeats) are noise,
|
|
2414
|
+
// and so is a repeat of one of them after the command.
|
|
2415
|
+
let sent = false;
|
|
2416
|
+
const before = new Set();
|
|
2417
|
+
parser.on('data', (line) => {
|
|
2418
|
+
const v = String(line).trim();
|
|
2419
|
+
if (!v) return;
|
|
2420
|
+
if (!sent) {
|
|
2421
|
+
before.add(v);
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (before.has(v) || v === command || NOT_AN_IDENTITY.test(v)) return; // ignore echoes too
|
|
2425
|
+
identities.set(path, v);
|
|
2426
|
+
finish(v); // first real reply wins
|
|
2427
|
+
});
|
|
2428
|
+
port.on('error', () => finish(null));
|
|
2429
|
+
port.open((err) => {
|
|
2430
|
+
if (err) return finish(null);
|
|
2431
|
+
openPaths.add(path);
|
|
2432
|
+
setTimeout(() => {
|
|
2433
|
+
if (done) return;
|
|
2434
|
+
sent = true;
|
|
2435
|
+
port.write(command + delimiter, (e) => { if (e) finish(null); });
|
|
2436
|
+
}, resetDelayMs);
|
|
2437
|
+
});
|
|
2438
|
+
});
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// ── Hotplug detection ──
|
|
2443
|
+
// Polls the port list (cheap: it does not open any port) and emits
|
|
2444
|
+
// "attach" / "detach" with the port's USB metadata. Boards already present
|
|
2445
|
+
// when watching starts are reported once as "attach" with initial: true.
|
|
2446
|
+
// Start it with watchPorts().
|
|
2447
|
+
export const portWatcher = new EventEmitter();
|
|
2448
|
+
portWatcher.setMaxListeners(100);
|
|
2449
|
+
let watchTimer = null;
|
|
2450
|
+
let knownPorts = null; // path -> port info from the last poll
|
|
2451
|
+
|
|
2452
|
+
export function watchPorts(intervalMs = 2000) {
|
|
2453
|
+
if (watchTimer) return portWatcher;
|
|
2454
|
+
const poll = async () => {
|
|
2455
|
+
let ports;
|
|
2456
|
+
try {
|
|
2457
|
+
ports = await listPorts();
|
|
2458
|
+
} catch {
|
|
2459
|
+
return; // serialport missing: nothing to watch
|
|
2460
|
+
}
|
|
2461
|
+
const now = new Map(ports.map((p) => [p.path, p]));
|
|
2462
|
+
// The first poll reports boards already plugged in at startup
|
|
2463
|
+
// (initial: true), so they are handled exactly like a fresh plug-in.
|
|
2464
|
+
const initial = !knownPorts;
|
|
2465
|
+
for (const [path, info] of now) {
|
|
2466
|
+
if (initial || !knownPorts.has(path)) portWatcher.emit('attach', Object.assign({}, info, { initial }));
|
|
2467
|
+
}
|
|
2468
|
+
if (!initial) {
|
|
2469
|
+
for (const [path, info] of knownPorts) {
|
|
2470
|
+
if (now.has(path)) continue;
|
|
2471
|
+
identities.delete(path); // a different board may come back on this path
|
|
2472
|
+
portWatcher.emit('detach', info);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
knownPorts = now;
|
|
2476
|
+
};
|
|
2477
|
+
poll();
|
|
2478
|
+
watchTimer = setInterval(poll, intervalMs);
|
|
2479
|
+
if (watchTimer.unref) watchTimer.unref();
|
|
2480
|
+
return portWatcher;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
export function unwatchPorts() {
|
|
2484
|
+
if (watchTimer) clearInterval(watchTimer);
|
|
2485
|
+
watchTimer = null;
|
|
2486
|
+
knownPorts = null;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// Sleep for ms, but wake early when any board is plugged in.
|
|
2490
|
+
function waitOrAttach(ms) {
|
|
2491
|
+
return new Promise((resolve) => {
|
|
2492
|
+
const done = () => {
|
|
2493
|
+
clearTimeout(t);
|
|
2494
|
+
portWatcher.off('attach', done);
|
|
2495
|
+
resolve();
|
|
2496
|
+
};
|
|
2497
|
+
const t = setTimeout(done, ms);
|
|
2498
|
+
portWatcher.on('attach', done);
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// Every USB serial port with both identification layers: USB metadata
|
|
2503
|
+
// (vendor id, serial number) and the firmware identity (reply to idCommand).
|
|
2504
|
+
// Probes one port at a time, skips ports this process holds open, and reuses
|
|
2505
|
+
// remembered identities unless refresh is set.
|
|
2506
|
+
export async function listDevices(opts = {}) {
|
|
2507
|
+
const ports = (await listPorts()).filter((p) => p.usb);
|
|
2508
|
+
return withProbeLock(async () => {
|
|
2509
|
+
const out = [];
|
|
2510
|
+
for (const p of ports) {
|
|
2511
|
+
let identity = p.identity;
|
|
2512
|
+
if ((!identity || opts.refresh) && !p.inUse) identity = await probeIdentity(p.path, opts);
|
|
2513
|
+
out.push(Object.assign({}, p, { identity: identity || null }));
|
|
2514
|
+
}
|
|
2515
|
+
return out;
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// Try each identity command until one gets a real reply. Returns
|
|
2520
|
+
// { identity, command } or null. Each try opens the port (resets the board).
|
|
2521
|
+
export async function identifyBoard(path, opts = {}) {
|
|
2522
|
+
const commands = opts.commands || idCommands();
|
|
2523
|
+
for (const command of commands) {
|
|
2524
|
+
const identity = await probeIdentity(path, Object.assign({}, opts, { command }));
|
|
2525
|
+
if (identity) return { identity, command };
|
|
2526
|
+
}
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Run fn with the global probe lock, so ports are never probed in parallel.
|
|
2531
|
+
function withProbeLock(fn) {
|
|
2532
|
+
const run = probeChain.then(fn, fn);
|
|
2533
|
+
probeChain = run.catch(() => {});
|
|
2534
|
+
return run;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// Find a device's CURRENT path. Cheapest, non-intrusive checks first:
|
|
2538
|
+
// serialNumber, then vendorId/productId, then the identity handshake (which
|
|
2539
|
+
// opens ports, so it only runs on USB ports), then a fixed path.
|
|
2540
|
+
export async function resolvePort(match = {}, opts = {}) {
|
|
2541
|
+
const ports = (await listPorts()).filter((p) => !p.inUse);
|
|
2542
|
+
if (match.serialNumber) {
|
|
2543
|
+
const hit = ports.find((p) => p.serialNumber === match.serialNumber);
|
|
2544
|
+
return hit ? hit.path : null;
|
|
2545
|
+
}
|
|
2546
|
+
let candidates = ports;
|
|
2547
|
+
if (match.vendorId) {
|
|
2548
|
+
const vid = normId(match.vendorId);
|
|
2549
|
+
const pid = normId(match.productId);
|
|
2550
|
+
candidates = candidates.filter((p) => p.vendorId === vid && (!pid || p.productId === pid));
|
|
2551
|
+
if (!match.identity) return candidates[0] ? candidates[0].path : null;
|
|
2552
|
+
}
|
|
2553
|
+
if (match.identity) {
|
|
2554
|
+
const usb = candidates.filter((p) => p.usb);
|
|
2555
|
+
return withProbeLock(async () => {
|
|
2556
|
+
for (const p of usb) {
|
|
2557
|
+
const id = await probeIdentity(p.path, opts);
|
|
2558
|
+
if (id && id.includes(match.identity)) return p.path;
|
|
2559
|
+
}
|
|
2560
|
+
return null;
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
if (match.path) return ports.some((p) => p.path === match.path) ? match.path : null;
|
|
2564
|
+
return null;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
export class SerialDevice extends EventEmitter {
|
|
2568
|
+
constructor(name, config = {}) {
|
|
2569
|
+
super();
|
|
2570
|
+
this.name = name;
|
|
2571
|
+
this.match = config.match || {};
|
|
2572
|
+
this.baudRate = Number(config.baudRate) || 115200;
|
|
2573
|
+
this.delimiter = config.delimiter === 'crlf' ? 'crlf' : 'lf';
|
|
2574
|
+
this.idCommand = config.idCommand || 'ID?';
|
|
2575
|
+
this.timeout = Number(config.timeout) || 2000;
|
|
2576
|
+
this.retryMs = Number(config.retryMs) || 3000;
|
|
2577
|
+
this.openDelayMs = config.openDelayMs == null ? 1200 : Number(config.openDelayMs);
|
|
2578
|
+
this.port = null;
|
|
2579
|
+
this.parser = null;
|
|
2580
|
+
this.path = null;
|
|
2581
|
+
this.connected = false;
|
|
2582
|
+
this.status = 'idle'; // idle | searching | connected | reconnecting | offline | error | stopped
|
|
2583
|
+
this.error = null;
|
|
2584
|
+
this.connectedAt = null;
|
|
2585
|
+
this.ignoreLines = Array.isArray(config.ignoreLines) ? config.ignoreLines : [];
|
|
2586
|
+
this._idle = new Map(); // line -> times seen while no command was in flight
|
|
2587
|
+
this._inFlight = 0;
|
|
2588
|
+
this.recent = []; // last lines sent (tx) and received (rx)
|
|
2589
|
+
this.stats = {
|
|
2590
|
+
connects: 0,
|
|
2591
|
+
reconnects: 0,
|
|
2592
|
+
bytesIn: 0,
|
|
2593
|
+
bytesOut: 0,
|
|
2594
|
+
linesIn: 0,
|
|
2595
|
+
linesOut: 0,
|
|
2596
|
+
lastRx: null,
|
|
2597
|
+
lastTx: null,
|
|
2598
|
+
replies: 0,
|
|
2599
|
+
replyMsTotal: 0,
|
|
2600
|
+
timeouts: 0,
|
|
2601
|
+
};
|
|
2602
|
+
this._stopped = true;
|
|
2603
|
+
this._looping = false;
|
|
2604
|
+
this._readyAt = 0;
|
|
2605
|
+
this._queue = Promise.resolve();
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
start() {
|
|
2609
|
+
if (!this._stopped) return;
|
|
2610
|
+
this._stopped = false;
|
|
2611
|
+
this._connectLoop();
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
async stop() {
|
|
2615
|
+
this._stopped = true;
|
|
2616
|
+
const port = this.port;
|
|
2617
|
+
if (port && port.isOpen) await new Promise((r) => port.close(() => r()));
|
|
2618
|
+
this._setStatus('stopped');
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
state() {
|
|
2622
|
+
return {
|
|
2623
|
+
name: this.name,
|
|
2624
|
+
status: this.status,
|
|
2625
|
+
connected: this.connected,
|
|
2626
|
+
path: this.path,
|
|
2627
|
+
error: this.error,
|
|
2628
|
+
connectedAt: this.connectedAt,
|
|
2629
|
+
match: this.match,
|
|
2630
|
+
baudRate: this.baudRate,
|
|
2631
|
+
delimiter: this.delimiter,
|
|
2632
|
+
stats: Object.assign({}, this.stats, {
|
|
2633
|
+
avgReplyMs: this.stats.replies ? Math.round(this.stats.replyMsTotal / this.stats.replies) : null,
|
|
2634
|
+
uptimeMs: this.connected && this.connectedAt ? Date.now() - this.connectedAt : 0,
|
|
2635
|
+
}),
|
|
2636
|
+
recent: this.recent.slice(-50),
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
_setStatus(status, error) {
|
|
2641
|
+
this.status = status;
|
|
2642
|
+
this.error = error || null;
|
|
2643
|
+
this.emit('status', status);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
_log(dir, line) {
|
|
2647
|
+
this.recent.push({ dir, line, ts: Date.now() });
|
|
2648
|
+
if (this.recent.length > 1000) this.recent.splice(0, this.recent.length - 1000);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
async _connectLoop() {
|
|
2652
|
+
if (this._looping) return;
|
|
2653
|
+
this._looping = true;
|
|
2654
|
+
try {
|
|
2655
|
+
while (!this._stopped && !this.connected) {
|
|
2656
|
+
this._setStatus(this.status === 'reconnecting' ? 'reconnecting' : 'searching');
|
|
2657
|
+
let path = null;
|
|
2658
|
+
try {
|
|
2659
|
+
path = await resolvePort(this.match, {
|
|
2660
|
+
baudRate: this.baudRate,
|
|
2661
|
+
command: this.idCommand,
|
|
2662
|
+
delimiter: this.delimiter,
|
|
2663
|
+
});
|
|
2664
|
+
} catch (err) {
|
|
2665
|
+
this._setStatus('error', err.message);
|
|
2666
|
+
}
|
|
2667
|
+
if (path && !this._stopped) {
|
|
2668
|
+
try {
|
|
2669
|
+
await this._open(path);
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
this._setStatus('error', err.message);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
if (!this.connected && !this._stopped) {
|
|
2675
|
+
if (this.status !== 'error') this._setStatus('offline', 'Device not found. Retrying.');
|
|
2676
|
+
await waitOrAttach(this.retryMs);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
} finally {
|
|
2680
|
+
this._looping = false;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
_open(path) {
|
|
2685
|
+
return new Promise((resolve, reject) => {
|
|
2686
|
+
const port = new SerialPortCtor({ path, baudRate: this.baudRate, autoOpen: false });
|
|
2687
|
+
const parser = port.pipe(new ReadlineParserCtor({ delimiter: delimiterFor(this.delimiter) }));
|
|
2688
|
+
port.open((err) => {
|
|
2689
|
+
if (err) return reject(err);
|
|
2690
|
+
if (this._stopped) {
|
|
2691
|
+
port.close(() => {});
|
|
2692
|
+
return reject(new Error('stopped'));
|
|
2693
|
+
}
|
|
2694
|
+
openPaths.add(path);
|
|
2695
|
+
this.port = port;
|
|
2696
|
+
this.parser = parser;
|
|
2697
|
+
this.path = path;
|
|
2698
|
+
this.connected = true;
|
|
2699
|
+
this.connectedAt = Date.now();
|
|
2700
|
+
this.stats.connects++;
|
|
2701
|
+
if (this.stats.connects > 1) this.stats.reconnects++;
|
|
2702
|
+
this._readyAt = Date.now() + this.openDelayMs; // board auto-resets on open
|
|
2703
|
+
this._setStatus('connected');
|
|
2704
|
+
this.emit('connect', path);
|
|
2705
|
+
|
|
2706
|
+
parser.on('data', (line) => {
|
|
2707
|
+
const v = String(line).trim();
|
|
2708
|
+
if (!v) return;
|
|
2709
|
+
this.stats.linesIn++;
|
|
2710
|
+
this.stats.bytesIn += Buffer.byteLength(String(line)) + delimiterFor(this.delimiter).length;
|
|
2711
|
+
this.stats.lastRx = Date.now();
|
|
2712
|
+
if (!this._inFlight) {
|
|
2713
|
+
if (this._idle.size > 200) this._idle.clear();
|
|
2714
|
+
this._idle.set(v, (this._idle.get(v) || 0) + 1);
|
|
2715
|
+
}
|
|
2716
|
+
this._log('rx', v);
|
|
2717
|
+
this.emit('data', v);
|
|
2718
|
+
});
|
|
2719
|
+
port.on('close', () => {
|
|
2720
|
+
openPaths.delete(path);
|
|
2721
|
+
this.connected = false;
|
|
2722
|
+
this.port = null;
|
|
2723
|
+
this.parser = null;
|
|
2724
|
+
if (!this._stopped) {
|
|
2725
|
+
this.emit('disconnect');
|
|
2726
|
+
this._setStatus('reconnecting');
|
|
2727
|
+
this._connectLoop();
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
port.on('error', (e) => {
|
|
2731
|
+
this.error = e.message;
|
|
2732
|
+
if (this.listenerCount('error')) this.emit('error', e);
|
|
2733
|
+
});
|
|
2734
|
+
resolve();
|
|
2735
|
+
});
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
async _waitReady() {
|
|
2740
|
+
const ms = this._readyAt - Date.now();
|
|
2741
|
+
if (ms > 0) await wait(ms);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
_write(data) {
|
|
2745
|
+
return new Promise((resolve, reject) => {
|
|
2746
|
+
const port = this.port;
|
|
2747
|
+
if (!this.connected || !port) return reject(new Error(this.name + ' is not connected'));
|
|
2748
|
+
const line = String(data).replace(LINE_BREAKS, ''); // one command per write
|
|
2749
|
+
this._log('tx', line);
|
|
2750
|
+
this.stats.linesOut++;
|
|
2751
|
+
this.stats.bytesOut += Buffer.byteLength(line) + delimiterFor(this.delimiter).length;
|
|
2752
|
+
this.stats.lastTx = Date.now();
|
|
2753
|
+
this.emit('tx', line);
|
|
2754
|
+
port.write(line + delimiterFor(this.delimiter), (err) => {
|
|
2755
|
+
if (err) return reject(err);
|
|
2756
|
+
port.drain((e) => (e ? reject(e) : resolve())); // flush now: low-latency triggers
|
|
2757
|
+
});
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
_enqueue(task) {
|
|
2762
|
+
const run = this._queue.then(() => this._waitReady()).then(task);
|
|
2763
|
+
this._queue = run.catch(() => {});
|
|
2764
|
+
return run;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// Is this line noise rather than a reply to command?
|
|
2768
|
+
_isNoise(line, command) {
|
|
2769
|
+
return line === command || this.ignoreLines.includes(line) || (this._idle.get(line) || 0) >= 3;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// Send a command and resolve with the board's reply: the next line that is
|
|
2773
|
+
// not noise, or (with opts.expect) the next line containing that text /
|
|
2774
|
+
// matching that RegExp.
|
|
2775
|
+
send(command, opts = {}) {
|
|
2776
|
+
const ms = Number(opts.timeout) || this.timeout;
|
|
2777
|
+
const expect = opts.expect;
|
|
2778
|
+
const cmd = String(command);
|
|
2779
|
+
return this._enqueue(() => new Promise((resolve, reject) => {
|
|
2780
|
+
if (!this.connected) return reject(new Error(this.name + ' is not connected'));
|
|
2781
|
+
let timer = null;
|
|
2782
|
+
const started = Date.now();
|
|
2783
|
+
this._inFlight++;
|
|
2784
|
+
const onData = (line) => {
|
|
2785
|
+
if (expect) {
|
|
2786
|
+
if (expect instanceof RegExp ? !expect.test(line) : !line.includes(String(expect))) return;
|
|
2787
|
+
} else if (this._isNoise(line, cmd)) {
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
cleanup();
|
|
2791
|
+
this.stats.replies++;
|
|
2792
|
+
this.stats.replyMsTotal += Date.now() - started;
|
|
2793
|
+
resolve(line);
|
|
2794
|
+
};
|
|
2795
|
+
let cleaned = false;
|
|
2796
|
+
const cleanup = () => {
|
|
2797
|
+
if (cleaned) return;
|
|
2798
|
+
cleaned = true;
|
|
2799
|
+
this._inFlight--;
|
|
2800
|
+
clearTimeout(timer);
|
|
2801
|
+
this.off('data', onData);
|
|
2802
|
+
};
|
|
2803
|
+
timer = setTimeout(() => {
|
|
2804
|
+
cleanup();
|
|
2805
|
+
this.stats.timeouts++;
|
|
2806
|
+
reject(new Error('Timeout waiting for reply to ' + JSON.stringify(String(command))));
|
|
2807
|
+
}, ms);
|
|
2808
|
+
this.on('data', onData);
|
|
2809
|
+
this._write(command).catch((err) => {
|
|
2810
|
+
cleanup();
|
|
2811
|
+
reject(err);
|
|
2812
|
+
});
|
|
2813
|
+
}));
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// Fire-and-forget write (no reply expected).
|
|
2817
|
+
write(data) {
|
|
2818
|
+
return this._enqueue(() => this._write(data));
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
`;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
export function buildSerialDevicesModule() {
|
|
2825
|
+
return `// Serial devices for this app, configured in src/serial/devices.json, started
|
|
2826
|
+
// at boot and kept connected. Manage them in the /_dev dashboard
|
|
2827
|
+
// (Features -> SerialPort) or edit the JSON by hand; reloadSerialDevices()
|
|
2828
|
+
// applies changes without a restart.
|
|
2829
|
+
//
|
|
2830
|
+
// Plug and play: boards are detected as they are plugged in. A configured
|
|
2831
|
+
// device reconnects immediately, and an unknown board is added automatically
|
|
2832
|
+
// (matched by USB serial number, firmware identity, or USB vendor/product id)
|
|
2833
|
+
// unless SERIAL_AUTO_ADD=off.
|
|
2834
|
+
//
|
|
2835
|
+
// Use a device anywhere in the app:
|
|
2836
|
+
// import { getDevice, onSerialLine } from './src/serial/devices.js';
|
|
2837
|
+
// const reply = await getDevice('led').send('LED:ON'); // waits for the reply
|
|
2838
|
+
// const stop = onSerialLine((e) => console.log(e.device, e.dir, e.line));
|
|
2839
|
+
//
|
|
2840
|
+
// Rules (per device, in devices.json): when a received line matches, call a
|
|
2841
|
+
// webhook or send a command to a device. See runRules() below.
|
|
2842
|
+
import fs from 'fs';
|
|
2843
|
+
import path from 'path';
|
|
2844
|
+
import { EventEmitter } from 'events';
|
|
2845
|
+
import { fileURLToPath } from 'url';
|
|
2846
|
+
import { SerialDevice, watchPorts, portWatcher, identifyBoard, identityKey, listPorts, listDevices } from './SerialDevice.js';
|
|
2847
|
+
|
|
2848
|
+
const CONFIG_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'devices.json');
|
|
2849
|
+
const devices = new Map(); // name -> SerialDevice
|
|
2850
|
+
const events = []; // recent hotplug / auto-add / rule events, newest last
|
|
2851
|
+
let eventSeq = 0;
|
|
2852
|
+
let watching = false;
|
|
2853
|
+
|
|
2854
|
+
// Every line sent or received by any device, plus status changes:
|
|
2855
|
+
// { type: 'line', device, dir: 'rx' | 'tx', line, ts }
|
|
2856
|
+
// { type: 'status', device, status, path, ts }
|
|
2857
|
+
export const serialBus = new EventEmitter();
|
|
2858
|
+
serialBus.setMaxListeners(200);
|
|
2859
|
+
|
|
2860
|
+
export function onSerialLine(fn) {
|
|
2861
|
+
const h = (e) => { if (e.type === 'line') fn(e); };
|
|
2862
|
+
serialBus.on('event', h);
|
|
2863
|
+
return () => serialBus.off('event', h);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
export function readSerialConfig() {
|
|
2867
|
+
try {
|
|
2868
|
+
const list = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
2869
|
+
return Array.isArray(list) ? list : [];
|
|
2870
|
+
} catch {
|
|
2871
|
+
return [];
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
function writeSerialConfig(list) {
|
|
2876
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(list, null, 2) + String.fromCharCode(10), 'utf8');
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
function logEvent(type, port, extra) {
|
|
2880
|
+
const e = Object.assign({ id: ++eventSeq, type, ts: Date.now(), path: port.path || null, vendor: port.vendor || port.manufacturer || null, serialNumber: port.serialNumber || null }, extra || {});
|
|
2881
|
+
events.push(e);
|
|
2882
|
+
if (events.length > 100) events.splice(0, events.length - 100);
|
|
2883
|
+
return e;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// Events after a given id (the dashboard polls with the last id it saw).
|
|
2887
|
+
export function getSerialEvents(sinceId = 0) {
|
|
2888
|
+
return events.filter((e) => e.id > sinceId);
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
export function autoAddEnabled() {
|
|
2892
|
+
return process.env.SERIAL_AUTO_ADD !== 'off';
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// ── Rules ──
|
|
2896
|
+
// { id, enabled, when: 'contains'|'equals'|'startsWith'|'regex', pattern,
|
|
2897
|
+
// action: 'webhook'|'send', url, target, command, cooldownMs }
|
|
2898
|
+
const ruleLastFired = new Map(); // "device|ruleId" -> ts
|
|
2899
|
+
const ruleStats = new Map(); // "device|ruleId" -> { fired, lastFired, lastError }
|
|
2900
|
+
|
|
2901
|
+
function ruleMatches(rule, line) {
|
|
2902
|
+
const p = String(rule.pattern || '');
|
|
2903
|
+
if (!p) return false;
|
|
2904
|
+
if (rule.when === 'equals') return line === p;
|
|
2905
|
+
if (rule.when === 'startsWith') return line.startsWith(p);
|
|
2906
|
+
if (rule.when === 'regex') {
|
|
2907
|
+
try { return new RegExp(p).test(line); } catch { return false; }
|
|
2908
|
+
}
|
|
2909
|
+
return line.includes(p);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
async function runRules(deviceName, line, config) {
|
|
2913
|
+
const rules = (config && Array.isArray(config.rules)) ? config.rules : [];
|
|
2914
|
+
for (const rule of rules) {
|
|
2915
|
+
if (!rule || rule.enabled === false || !ruleMatches(rule, line)) continue;
|
|
2916
|
+
const key = deviceName + '|' + rule.id;
|
|
2917
|
+
const cooldown = Number(rule.cooldownMs) >= 0 ? Number(rule.cooldownMs) : 2000;
|
|
2918
|
+
const last = ruleLastFired.get(key) || 0;
|
|
2919
|
+
if (Date.now() - last < cooldown) continue;
|
|
2920
|
+
ruleLastFired.set(key, Date.now());
|
|
2921
|
+
const st = ruleStats.get(key) || { fired: 0, lastFired: null, lastError: null };
|
|
2922
|
+
st.fired++;
|
|
2923
|
+
st.lastFired = Date.now();
|
|
2924
|
+
ruleStats.set(key, st);
|
|
2925
|
+
try {
|
|
2926
|
+
if (rule.action === 'webhook' && rule.url) {
|
|
2927
|
+
const res = await fetch(rule.url, {
|
|
2928
|
+
method: 'POST',
|
|
2929
|
+
headers: { 'content-type': 'application/json' },
|
|
2930
|
+
body: JSON.stringify({ device: deviceName, line, rule: rule.id, ts: Date.now() }),
|
|
2931
|
+
signal: AbortSignal.timeout(5000),
|
|
2932
|
+
});
|
|
2933
|
+
if (!res.ok) throw new Error('webhook answered ' + res.status);
|
|
2934
|
+
} else if (rule.action === 'send' && rule.target && rule.command) {
|
|
2935
|
+
const target = devices.get(rule.target);
|
|
2936
|
+
if (!target || !target.connected) throw new Error('device "' + rule.target + '" is offline');
|
|
2937
|
+
await target.write(rule.command);
|
|
2938
|
+
}
|
|
2939
|
+
st.lastError = null;
|
|
2940
|
+
logEvent('rule', {}, { name: deviceName, rule: rule.id, line });
|
|
2941
|
+
} catch (err) {
|
|
2942
|
+
st.lastError = err.message;
|
|
2943
|
+
console.error('serial: rule ' + rule.id + ' on ' + deviceName + ' failed: ' + err.message);
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
export function getRuleStats(deviceName) {
|
|
2949
|
+
const out = {};
|
|
2950
|
+
for (const [key, st] of ruleStats) {
|
|
2951
|
+
const [dev, id] = key.split('|');
|
|
2952
|
+
if (dev === deviceName) out[id] = st;
|
|
2953
|
+
}
|
|
2954
|
+
return out;
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// ── Auto-add ──
|
|
2958
|
+
// Does a configured device already claim this port?
|
|
2959
|
+
function claimedBy(port, config) {
|
|
2960
|
+
return config.find((c) => {
|
|
2961
|
+
const m = c.match || {};
|
|
2962
|
+
if (m.serialNumber) return m.serialNumber === port.serialNumber;
|
|
2963
|
+
if (m.path) return m.path === port.path;
|
|
2964
|
+
if (m.vendorId && !m.identity) return m.vendorId === port.vendorId && (!m.productId || m.productId === port.productId);
|
|
2965
|
+
return false; // identity matches can't be known without probing
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function uniqueName(base, config) {
|
|
2970
|
+
const clean = String(base || 'board').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '') || 'board';
|
|
2971
|
+
let name = clean.slice(0, 32);
|
|
2972
|
+
let n = 2;
|
|
2973
|
+
while (config.some((c) => c.name === name)) name = clean.slice(0, 30) + '-' + n++;
|
|
2974
|
+
return name;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// Register a newly attached, unknown USB board as a device and connect it.
|
|
2978
|
+
async function autoAdd(port) {
|
|
2979
|
+
if (claimedBy(port, readSerialConfig())) return;
|
|
2980
|
+
// Let configured identity-matched devices try this port first.
|
|
2981
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
2982
|
+
const fresh = (await listPorts().catch(() => [])).find((p) => p.path === port.path);
|
|
2983
|
+
if (!fresh || fresh.inUse) return;
|
|
2984
|
+
if (claimedBy(fresh, readSerialConfig())) return;
|
|
2985
|
+
|
|
2986
|
+
const baudRate = Number(process.env.SERIAL_DEFAULT_BAUD) || 115200;
|
|
2987
|
+
// Try the identity commands in turn (ID?, M115, $I, ATI) for a name and a
|
|
2988
|
+
// firmware match; remember which command worked.
|
|
2989
|
+
const found = fresh.identity ? { identity: fresh.identity, command: 'ID?' } : await identifyBoard(fresh.path, { baudRate });
|
|
2990
|
+
const key = found ? identityKey(found.identity) : null;
|
|
2991
|
+
// A configured device already matches this firmware: it will connect to the
|
|
2992
|
+
// board itself (e.g. after a replug into another USB port).
|
|
2993
|
+
if (key && !fresh.serialNumber && readSerialConfig().some((c) => c.match && c.match.identity && found.identity.includes(c.match.identity))) return;
|
|
2994
|
+
|
|
2995
|
+
let match;
|
|
2996
|
+
if (fresh.serialNumber) match = { serialNumber: fresh.serialNumber };
|
|
2997
|
+
else if (key) match = { identity: key, vendorId: fresh.vendorId };
|
|
2998
|
+
else {
|
|
2999
|
+
const sameModel = readSerialConfig().some((c) => c.match && c.match.vendorId === fresh.vendorId && c.match.productId === fresh.productId);
|
|
3000
|
+
match = sameModel ? { path: fresh.path } : { vendorId: fresh.vendorId, productId: fresh.productId };
|
|
3001
|
+
}
|
|
3002
|
+
const name = uniqueName(key || (fresh.vendor || fresh.manufacturer || 'board').split(' ')[0], readSerialConfig());
|
|
3003
|
+
const entry = { name, match, baudRate, delimiter: 'lf', idCommand: found ? found.command : 'ID?', timeout: 2000, enabled: true, auto: true };
|
|
3004
|
+
const list = readSerialConfig();
|
|
3005
|
+
if (list.some((c) => c.name === name) || claimedBy(fresh, list)) return;
|
|
3006
|
+
list.push(entry);
|
|
3007
|
+
writeSerialConfig(list);
|
|
3008
|
+
logEvent('added', fresh, { name, identity: found ? found.identity : null });
|
|
3009
|
+
console.log('serial: new board on ' + fresh.path + ' added as "' + name + '"');
|
|
3010
|
+
await reloadSerialDevices();
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
function startWatching() {
|
|
3014
|
+
if (watching) return;
|
|
3015
|
+
watching = true;
|
|
3016
|
+
watchPorts(2000);
|
|
3017
|
+
portWatcher.on('attach', (port) => {
|
|
3018
|
+
if (!port.usb) return;
|
|
3019
|
+
logEvent(port.initial ? 'found' : 'attach', port);
|
|
3020
|
+
console.log('serial: board ' + (port.initial ? 'found' : 'attached') + ' on ' + port.path + (port.vendor ? ' (' + port.vendor + ')' : ''));
|
|
3021
|
+
if (autoAddEnabled()) autoAdd(port).catch((err) => console.error('serial: auto-add failed: ' + err.message));
|
|
3022
|
+
});
|
|
3023
|
+
portWatcher.on('detach', (port) => {
|
|
3024
|
+
if (!port.usb) return;
|
|
3025
|
+
logEvent('detach', port);
|
|
3026
|
+
console.log('serial: board removed from ' + port.path);
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
// Start new devices, restart changed ones, stop removed ones.
|
|
3031
|
+
export async function reloadSerialDevices() {
|
|
3032
|
+
startWatching();
|
|
3033
|
+
const config = readSerialConfig().filter((c) => c && c.name && c.enabled !== false);
|
|
3034
|
+
const wanted = new Map(config.map((c) => [c.name, c]));
|
|
3035
|
+
for (const [name, dev] of devices) {
|
|
3036
|
+
const next = wanted.get(name);
|
|
3037
|
+
if (!next || dev.configKey !== JSON.stringify(next)) {
|
|
3038
|
+
await dev.stop();
|
|
3039
|
+
devices.delete(name);
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
for (const c of config) {
|
|
3043
|
+
if (devices.has(c.name)) continue;
|
|
3044
|
+
const dev = new SerialDevice(c.name, c);
|
|
3045
|
+
dev.configKey = JSON.stringify(c);
|
|
3046
|
+
dev.on('connect', (p) => console.log('serial: ' + c.name + ' connected on ' + p));
|
|
3047
|
+
dev.on('disconnect', () => console.warn('serial: ' + c.name + ' disconnected, reconnecting...'));
|
|
3048
|
+
dev.on('error', (e) => console.error('serial: ' + c.name + ' error: ' + e.message));
|
|
3049
|
+
dev.on('status', (status) => serialBus.emit('event', { type: 'status', device: c.name, status, path: dev.path, ts: Date.now() }));
|
|
3050
|
+
dev.on('tx', (line) => serialBus.emit('event', { type: 'line', device: c.name, dir: 'tx', line, ts: Date.now() }));
|
|
3051
|
+
dev.on('data', (line) => {
|
|
3052
|
+
serialBus.emit('event', { type: 'line', device: c.name, dir: 'rx', line, ts: Date.now() });
|
|
3053
|
+
runRules(c.name, line, c);
|
|
3054
|
+
});
|
|
3055
|
+
devices.set(c.name, dev);
|
|
3056
|
+
dev.start();
|
|
3057
|
+
}
|
|
3058
|
+
return listSerialDevices();
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
export const startSerialDevices = reloadSerialDevices;
|
|
3062
|
+
|
|
3063
|
+
// Close every port (call on shutdown so the ports are free next launch).
|
|
3064
|
+
export async function stopSerialDevices() {
|
|
3065
|
+
await Promise.all([...devices.values()].map((d) => d.stop().catch(() => {})));
|
|
3066
|
+
devices.clear();
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
export function getDevice(name) {
|
|
3070
|
+
return devices.get(name) || null;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// Re-exported for the dashboard / CLI: every USB port with USB metadata and
|
|
3074
|
+
// firmware identity (point 7 of the serial guide).
|
|
3075
|
+
export { listDevices };
|
|
3076
|
+
|
|
3077
|
+
export function listSerialDevices() {
|
|
3078
|
+
return [...devices.values()].map((d) => Object.assign(d.state(), { rules: getRuleStats(d.name) }));
|
|
3079
|
+
}
|
|
3080
|
+
`;
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
export function buildSerialRouter() {
|
|
3084
|
+
return `// HTTP API for the serial devices. The backend owns the hardware; browsers and
|
|
3085
|
+
// other clients talk to it through these routes (covered by the API key gate).
|
|
3086
|
+
// GET /api/serial/devices status + health of every device
|
|
3087
|
+
// POST /api/serial/devices/:name/send { "command": "ID?", "timeout": 2000 } -> { "reply": "..." }
|
|
3088
|
+
// POST /api/serial/devices/:name/write { "data": "LED:ON" } (no reply expected)
|
|
3089
|
+
// GET /api/serial/stream live lines from every device (Server-Sent Events)
|
|
3090
|
+
// GET /api/serial/devices/:name/stream live lines from one device
|
|
3091
|
+
//
|
|
3092
|
+
// Streams send the x-api-key header like any other request, so read them with
|
|
3093
|
+
// fetch() and a stream reader (EventSource cannot set headers):
|
|
3094
|
+
// const res = await fetch('/api/serial/stream', { headers: { 'x-api-key': KEY } });
|
|
3095
|
+
// const reader = res.body.getReader(); // each event: "data: {json}" + blank line
|
|
3096
|
+
import express from 'express';
|
|
3097
|
+
import { getDevice, listSerialDevices, serialBus } from '../serial/devices.js';
|
|
3098
|
+
|
|
3099
|
+
const router = express.Router();
|
|
3100
|
+
|
|
3101
|
+
router.get('/devices', (req, res) => {
|
|
3102
|
+
res.json(listSerialDevices().map(({ recent, ...d }) => d));
|
|
3103
|
+
});
|
|
3104
|
+
|
|
3105
|
+
router.post('/devices/:name/send', async (req, res) => {
|
|
3106
|
+
const device = getDevice(req.params.name);
|
|
3107
|
+
if (!device) return res.status(404).json({ message: 'Unknown device' });
|
|
3108
|
+
if (!device.connected) return res.status(503).json({ message: 'Device offline' });
|
|
3109
|
+
const command = req.body.command;
|
|
3110
|
+
const timeout = req.body.timeout;
|
|
3111
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
3112
|
+
return res.status(400).json({ message: 'command is required' });
|
|
3113
|
+
}
|
|
3114
|
+
try {
|
|
3115
|
+
const reply = await device.send(command, { timeout });
|
|
3116
|
+
res.json({ reply });
|
|
3117
|
+
} catch (err) {
|
|
3118
|
+
res.status(504).json({ message: err.message });
|
|
3119
|
+
}
|
|
3120
|
+
});
|
|
3121
|
+
|
|
3122
|
+
router.post('/devices/:name/write', async (req, res) => {
|
|
3123
|
+
const device = getDevice(req.params.name);
|
|
3124
|
+
if (!device) return res.status(404).json({ message: 'Unknown device' });
|
|
3125
|
+
if (!device.connected) return res.status(503).json({ message: 'Device offline' });
|
|
3126
|
+
const data = req.body.data;
|
|
3127
|
+
if (typeof data !== 'string' || !data.trim()) {
|
|
3128
|
+
return res.status(400).json({ message: 'data is required' });
|
|
3129
|
+
}
|
|
3130
|
+
try {
|
|
3131
|
+
await device.write(data);
|
|
3132
|
+
res.json({ ok: true });
|
|
3133
|
+
} catch (err) {
|
|
3134
|
+
res.status(500).json({ message: err.message });
|
|
3135
|
+
}
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
// Server-Sent Events: one "data: {json}" message per line / status change.
|
|
3139
|
+
function stream(req, res, only) {
|
|
3140
|
+
res.writeHead(200, {
|
|
3141
|
+
'Content-Type': 'text/event-stream',
|
|
3142
|
+
'Cache-Control': 'no-cache',
|
|
3143
|
+
Connection: 'keep-alive',
|
|
3144
|
+
'X-Accel-Buffering': 'no',
|
|
3145
|
+
});
|
|
3146
|
+
res.write(': connected' + String.fromCharCode(10, 10));
|
|
3147
|
+
const onEvent = (e) => {
|
|
3148
|
+
if (only && e.device !== only) return;
|
|
3149
|
+
res.write('data: ' + JSON.stringify(e) + String.fromCharCode(10, 10));
|
|
3150
|
+
};
|
|
3151
|
+
serialBus.on('event', onEvent);
|
|
3152
|
+
const ping = setInterval(() => res.write(': ping' + String.fromCharCode(10, 10)), 25000);
|
|
3153
|
+
req.on('close', () => {
|
|
3154
|
+
clearInterval(ping);
|
|
3155
|
+
serialBus.off('event', onEvent);
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
router.get('/stream', (req, res) => stream(req, res, null));
|
|
3160
|
+
|
|
3161
|
+
router.get('/devices/:name/stream', (req, res) => {
|
|
3162
|
+
if (!getDevice(req.params.name)) return res.status(404).json({ message: 'Unknown device' });
|
|
3163
|
+
stream(req, res, req.params.name);
|
|
3164
|
+
});
|
|
3165
|
+
|
|
3166
|
+
export default router;
|
|
3167
|
+
`;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
// Validate + normalize one device entry for devices.json. Exactly one match
|
|
3171
|
+
// strategy is kept (vendorId may pair with identity to narrow the probe).
|
|
3172
|
+
// Throws a readable Error on invalid input.
|
|
3173
|
+
export function normalizeSerialDevice(input = {}) {
|
|
3174
|
+
const name = String(input.name || "").trim();
|
|
3175
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(name)) {
|
|
3176
|
+
throw new Error("Device name must start with a letter and use only letters, numbers, - or _ (max 40).");
|
|
3177
|
+
}
|
|
3178
|
+
const m = input.match || {};
|
|
3179
|
+
const clean = (v, max = 120) => String(v == null ? "" : v).replace(/[\r\n]/g, "").trim().slice(0, max);
|
|
3180
|
+
const hex = (v) => clean(v, 8).toLowerCase().replace(/^0x/, "");
|
|
3181
|
+
let match;
|
|
3182
|
+
if (clean(m.serialNumber)) match = { serialNumber: clean(m.serialNumber) };
|
|
3183
|
+
else if (clean(m.identity)) {
|
|
3184
|
+
match = { identity: clean(m.identity, 60) };
|
|
3185
|
+
if (hex(m.vendorId)) match.vendorId = hex(m.vendorId);
|
|
3186
|
+
} else if (hex(m.vendorId)) {
|
|
3187
|
+
match = { vendorId: hex(m.vendorId) };
|
|
3188
|
+
if (hex(m.productId)) match.productId = hex(m.productId);
|
|
3189
|
+
} else if (clean(m.path)) match = { path: clean(m.path, 200) };
|
|
3190
|
+
else throw new Error("Choose how to find the device: USB serial number, firmware identity, USB vendor id, or a fixed port path.");
|
|
3191
|
+
if (match.vendorId && !/^[0-9a-f]{4}$/.test(match.vendorId)) throw new Error("USB vendor id must be 4 hex digits, e.g. 10c4.");
|
|
3192
|
+
if (match.productId && !/^[0-9a-f]{4}$/.test(match.productId)) throw new Error("USB product id must be 4 hex digits.");
|
|
3193
|
+
|
|
3194
|
+
const baudRate = Number(input.baudRate) || 115200;
|
|
3195
|
+
if (!Number.isInteger(baudRate) || baudRate < 300 || baudRate > 4000000) throw new Error("Baud rate must match the firmware's Serial.begin(), e.g. 115200.");
|
|
3196
|
+
const timeout = Math.min(60000, Math.max(100, Number(input.timeout) || 2000));
|
|
3197
|
+
|
|
3198
|
+
// Saved commands: one-click buttons in the dashboard.
|
|
3199
|
+
const commands = (Array.isArray(input.commands) ? input.commands : []).slice(0, 50)
|
|
3200
|
+
.map((x) => ({ label: clean(x && x.label, 40), command: clean(x && x.command, 200), expectReply: !(x && x.expectReply === false) }))
|
|
3201
|
+
.filter((x) => x.command)
|
|
3202
|
+
.map((x) => ({ ...x, label: x.label || x.command }));
|
|
3203
|
+
|
|
3204
|
+
// Rules: when a received line matches, call a webhook or send a command.
|
|
3205
|
+
const rules = (Array.isArray(input.rules) ? input.rules : []).slice(0, 50).map((r, i) => {
|
|
3206
|
+
r = r || {};
|
|
3207
|
+
const when = SERIAL_RULE_WHEN.includes(r.when) ? r.when : "contains";
|
|
3208
|
+
const pattern = clean(r.pattern, 200);
|
|
3209
|
+
if (!pattern) throw new Error("Rule " + (i + 1) + ": a pattern is required.");
|
|
3210
|
+
if (when === "regex") {
|
|
3211
|
+
try { new RegExp(pattern); } catch { throw new Error("Rule " + (i + 1) + ": invalid regular expression."); }
|
|
3212
|
+
}
|
|
3213
|
+
const action = SERIAL_RULE_ACTIONS.includes(r.action) ? r.action : "webhook";
|
|
3214
|
+
const rule = {
|
|
3215
|
+
id: clean(r.id, 40).replace(/[^a-zA-Z0-9_-]/g, "") || "rule-" + (i + 1),
|
|
3216
|
+
enabled: r.enabled !== false,
|
|
3217
|
+
when,
|
|
3218
|
+
pattern,
|
|
3219
|
+
action,
|
|
3220
|
+
cooldownMs: Math.min(3600000, Math.max(0, Number(r.cooldownMs) >= 0 ? Number(r.cooldownMs) : 2000)),
|
|
3221
|
+
};
|
|
3222
|
+
if (action === "webhook") {
|
|
3223
|
+
const url = clean(r.url, 500);
|
|
3224
|
+
if (!/^https?:[/][/][^ ]+$/i.test(url)) throw new Error("Rule " + (i + 1) + ": webhook URL must start with http:// or https://.");
|
|
3225
|
+
rule.url = url;
|
|
3226
|
+
} else {
|
|
3227
|
+
rule.target = clean(r.target, 40);
|
|
3228
|
+
rule.command = clean(r.command, 200);
|
|
3229
|
+
if (!rule.target || !rule.command) throw new Error("Rule " + (i + 1) + ": choose a device and a command to send.");
|
|
3230
|
+
}
|
|
3231
|
+
return rule;
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
const out = {
|
|
3235
|
+
name,
|
|
3236
|
+
match,
|
|
3237
|
+
baudRate,
|
|
3238
|
+
delimiter: input.delimiter === "crlf" ? "crlf" : "lf",
|
|
3239
|
+
idCommand: clean(input.idCommand, 40) || "ID?",
|
|
3240
|
+
timeout,
|
|
3241
|
+
enabled: input.enabled !== false,
|
|
3242
|
+
};
|
|
3243
|
+
// Lines that are never a reply (heartbeats, banners). Heartbeats are also learned automatically.
|
|
3244
|
+
const ignoreLines = (Array.isArray(input.ignoreLines) ? input.ignoreLines : String(input.ignoreLines || "").split(","))
|
|
3245
|
+
.map((x) => clean(x, 80)).filter(Boolean).slice(0, 20);
|
|
3246
|
+
if (ignoreLines.length) out.ignoreLines = ignoreLines;
|
|
3247
|
+
if (commands.length) out.commands = commands;
|
|
3248
|
+
if (rules.length) out.rules = rules;
|
|
3249
|
+
if (input.auto === true) out.auto = true;
|
|
3250
|
+
return out;
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
export const SERIAL_IMPORTS = [
|
|
3254
|
+
'import { startSerialDevices, stopSerialDevices } from "./src/serial/devices.js";',
|
|
3255
|
+
'import serialRouter from "./src/routes/serial.js";',
|
|
3256
|
+
];
|
|
3257
|
+
|
|
3258
|
+
// Idempotently wire the serial devices into index.js: mount the HTTP routes
|
|
3259
|
+
// (after the API key gate, so hardware control is protected), start the
|
|
3260
|
+
// devices at boot, and close their ports on shutdown / nodemon restart.
|
|
3261
|
+
export function wireSerialIntoIndex(content) {
|
|
3262
|
+
let out = content;
|
|
3263
|
+
for (const line of SERIAL_IMPORTS) out = addImportAfterLastImport(out, line);
|
|
3264
|
+
if (!out.includes("serialRouter)")) {
|
|
3265
|
+
out = insertBeforeListenContent(out, [
|
|
3266
|
+
"// ── Serial devices (src/serial/devices.json) ──",
|
|
3267
|
+
'app.use("' + SERIAL_ROUTE_PREFIX + '", serialRouter);',
|
|
3268
|
+
'startSerialDevices().catch((err) => console.error("serial:", err.message));',
|
|
3269
|
+
].join("\n"));
|
|
3270
|
+
}
|
|
3271
|
+
if (!out.includes("stopSerialDevices()")) {
|
|
3272
|
+
if (out.includes("await destroyDiscovery();")) {
|
|
3273
|
+
// Reuse the existing shutdown handlers (SIGINT/SIGTERM + nodemon SIGUSR2).
|
|
3274
|
+
out = out.split("await destroyDiscovery();").join("await stopSerialDevices().catch(() => {}); await destroyDiscovery();");
|
|
3275
|
+
} else {
|
|
3276
|
+
out = out.trimEnd() + "\n\n" + [
|
|
3277
|
+
"// Close serial ports on shutdown so they're free on the next launch.",
|
|
3278
|
+
"let _serialShuttingDown = false;",
|
|
3279
|
+
"async function _serialShutdown() {",
|
|
3280
|
+
" if (_serialShuttingDown) return;",
|
|
3281
|
+
" _serialShuttingDown = true;",
|
|
3282
|
+
" await stopSerialDevices().catch(() => {});",
|
|
3283
|
+
" process.exit(0);",
|
|
3284
|
+
"}",
|
|
3285
|
+
'process.on("SIGINT", _serialShutdown);',
|
|
3286
|
+
'process.on("SIGTERM", _serialShutdown);',
|
|
3287
|
+
'process.once("SIGUSR2", async () => {',
|
|
3288
|
+
" await stopSerialDevices().catch(() => {});",
|
|
3289
|
+
' process.kill(process.pid, "SIGUSR2");',
|
|
3290
|
+
"});",
|
|
3291
|
+
].join("\n") + "\n";
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
return out;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
// Legacy (pre-device-manager) ports were inlined in index.js as
|
|
3298
|
+
// "const x = new SerialPort({ path: ..., baudRate: ... })". Detect them so the
|
|
3299
|
+
// dashboard can show and remove them.
|
|
3300
|
+
export function hasLegacySerialPorts(content) {
|
|
3301
|
+
return /new\s+SerialPort\(/.test(content);
|
|
3302
|
+
}
|