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
|
@@ -38,8 +38,23 @@ import {
|
|
|
38
38
|
extractFileFields,
|
|
39
39
|
requiredFieldsFromZod,
|
|
40
40
|
buildOpenApiSpec,
|
|
41
|
+
buildApiKeyMiddleware,
|
|
42
|
+
buildSerialDeviceModule,
|
|
43
|
+
buildSerialDevicesModule,
|
|
44
|
+
buildSerialRouter,
|
|
45
|
+
normalizeSerialDevice,
|
|
46
|
+
wireSerialIntoIndex,
|
|
47
|
+
hasLegacySerialPorts,
|
|
48
|
+
SERIAL_DEPS,
|
|
49
|
+
SERIAL_BAUD_RATES,
|
|
50
|
+
SERIAL_ID_PRESETS,
|
|
51
|
+
wireApiKeyGateIntoIndex,
|
|
52
|
+
generateApiKey,
|
|
53
|
+
hashApiKeyValue,
|
|
41
54
|
buildDocsRouter,
|
|
42
55
|
MAIL_PROVIDERS,
|
|
56
|
+
SANCHARAK_API_URL,
|
|
57
|
+
sancharakKeyProblem,
|
|
43
58
|
buildMailerService,
|
|
44
59
|
isOfficialMailDomain,
|
|
45
60
|
OFFICIAL_MAIL_DOMAIN_PASSWORD,
|
|
@@ -166,6 +181,57 @@ process.on('unhandledRejection', (reason) => {
|
|
|
166
181
|
_origError('Unhandled Rejection:', reason);
|
|
167
182
|
});
|
|
168
183
|
|
|
184
|
+
// ── Request Metrics (feeds the live Dashboard overview) ─────────────────────
|
|
185
|
+
// index.js mounts `requestMetrics` app-wide (before /_dev) so it sees the
|
|
186
|
+
// project's own API traffic. Dashboard requests are skipped. Kept in memory
|
|
187
|
+
// only: a ring of recent requests and per-minute buckets for the last hour.
|
|
188
|
+
const _startedAt = Date.now();
|
|
189
|
+
const _recentRequests = [];
|
|
190
|
+
const _recentMax = 100;
|
|
191
|
+
const _minuteBuckets = new Map(); // minuteEpoch -> { count, errors }
|
|
192
|
+
const _durations = []; // last N durations (ms) for percentiles
|
|
193
|
+
const _durationsMax = 500;
|
|
194
|
+
const _metrics = { enabled: false, total: 0, errors: 0 };
|
|
195
|
+
const _lastMinute = []; // finish timestamps within the rolling last 60s
|
|
196
|
+
|
|
197
|
+
export function requestMetrics(req, res, next) {
|
|
198
|
+
_metrics.enabled = true;
|
|
199
|
+
if (req.originalUrl.startsWith('/_dev')) return next();
|
|
200
|
+
const start = process.hrtime.bigint();
|
|
201
|
+
res.on('finish', () => {
|
|
202
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
203
|
+
const isError = res.statusCode >= 500;
|
|
204
|
+
_metrics.total++;
|
|
205
|
+
if (isError) _metrics.errors++;
|
|
206
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
207
|
+
const bucket = _minuteBuckets.get(minute) || { count: 0, errors: 0 };
|
|
208
|
+
bucket.count++;
|
|
209
|
+
if (res.statusCode >= 400) bucket.errors++;
|
|
210
|
+
_minuteBuckets.set(minute, bucket);
|
|
211
|
+
for (const k of _minuteBuckets.keys()) if (k < minute - 60) _minuteBuckets.delete(k);
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
_lastMinute.push(now);
|
|
214
|
+
while (_lastMinute.length && _lastMinute[0] < now - 60000) _lastMinute.shift();
|
|
215
|
+
_durations.push(ms);
|
|
216
|
+
if (_durations.length > _durationsMax) _durations.shift();
|
|
217
|
+
_recentRequests.unshift({
|
|
218
|
+
method: req.method,
|
|
219
|
+
path: req.originalUrl.split('?')[0],
|
|
220
|
+
status: res.statusCode,
|
|
221
|
+
ms: Math.round(ms * 10) / 10,
|
|
222
|
+
ts: Date.now(),
|
|
223
|
+
});
|
|
224
|
+
if (_recentRequests.length > _recentMax) _recentRequests.length = _recentMax;
|
|
225
|
+
});
|
|
226
|
+
next();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function percentile(sorted, p) {
|
|
230
|
+
if (!sorted.length) return null;
|
|
231
|
+
const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
|
232
|
+
return Math.round(sorted[i] * 10) / 10;
|
|
233
|
+
}
|
|
234
|
+
|
|
169
235
|
// Serve dashboard UI and assets
|
|
170
236
|
const __4bnodeDir = path.join(process.cwd(), '.4bnode');
|
|
171
237
|
router.get('/', (req, res) => {
|
|
@@ -1257,7 +1323,7 @@ router.get('/api/status', (req, res) => {
|
|
|
1257
1323
|
const authMiddlewareExists = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
|
|
1258
1324
|
const socketConfigured = indexContent.includes('setupSocketIO') || indexContent.includes('socket.io') || indexContent.includes('Server(server');
|
|
1259
1325
|
const websocketConfigured = indexContent.includes('setupWebSocket') || indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
|
|
1260
|
-
const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
|
|
1326
|
+
const serialportConfigured = indexContent.includes('serialRouter)') || indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
|
|
1261
1327
|
|
|
1262
1328
|
const mwExists = (f) => fs.existsSync(path.join(process.cwd(), 'src', 'middleware', f));
|
|
1263
1329
|
const security = {
|
|
@@ -1284,13 +1350,181 @@ router.get('/api/status', (req, res) => {
|
|
|
1284
1350
|
security,
|
|
1285
1351
|
aiConfigured: !!getActiveAiKey(),
|
|
1286
1352
|
docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
|
|
1287
|
-
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js'))
|
|
1353
|
+
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js')),
|
|
1354
|
+
apiKeyGate: { wired: indexContent.includes('app.use(apiKeyGate)'), enabled: getEnvValue(path.join(process.cwd(), '.env'), 'API_KEY_REQUIRED') !== 'off', keys: readApiKeys().length },
|
|
1288
1355
|
});
|
|
1289
1356
|
} catch (err) {
|
|
1290
1357
|
res.status(500).json({ error: err.message });
|
|
1291
1358
|
}
|
|
1292
1359
|
});
|
|
1293
1360
|
|
|
1361
|
+
// ---------------------------------------------------------------------------
|
|
1362
|
+
// Health — process stats + request metrics for the live Dashboard
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
router.get('/api/health', (req, res) => {
|
|
1365
|
+
const mem = process.memoryUsage();
|
|
1366
|
+
const nowMinute = Math.floor(Date.now() / 60000);
|
|
1367
|
+
const series = [];
|
|
1368
|
+
for (let m = nowMinute - 29; m <= nowMinute; m++) {
|
|
1369
|
+
const b = _minuteBuckets.get(m) || { count: 0, errors: 0 };
|
|
1370
|
+
series.push({ t: m * 60000, count: b.count, errors: b.errors });
|
|
1371
|
+
}
|
|
1372
|
+
const sorted = [..._durations].sort((a, b) => a - b);
|
|
1373
|
+
while (_lastMinute.length && _lastMinute[0] < Date.now() - 60000) _lastMinute.shift();
|
|
1374
|
+
const recentErrors = _logBuffer.filter(l => l.type === 'error').slice(-5).reverse();
|
|
1375
|
+
res.json({
|
|
1376
|
+
uptime: Math.round((Date.now() - _startedAt) / 1000),
|
|
1377
|
+
startedAt: _startedAt,
|
|
1378
|
+
node: process.version,
|
|
1379
|
+
pid: process.pid,
|
|
1380
|
+
platform: `${os.platform()} ${os.arch()}`,
|
|
1381
|
+
env: process.env.NODE_ENV || 'development',
|
|
1382
|
+
port: Number(process.env.PORT) || 3000,
|
|
1383
|
+
memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal },
|
|
1384
|
+
load: os.loadavg()[0],
|
|
1385
|
+
cpus: os.cpus().length,
|
|
1386
|
+
metrics: {
|
|
1387
|
+
enabled: _metrics.enabled,
|
|
1388
|
+
total: _metrics.total,
|
|
1389
|
+
errors: _metrics.errors,
|
|
1390
|
+
rpm: _lastMinute.length,
|
|
1391
|
+
p50: percentile(sorted, 50),
|
|
1392
|
+
p95: percentile(sorted, 95),
|
|
1393
|
+
series,
|
|
1394
|
+
recent: _recentRequests.slice(0, 15),
|
|
1395
|
+
},
|
|
1396
|
+
recentErrors,
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
// ---------------------------------------------------------------------------
|
|
1401
|
+
// API keys + app-wide API key gate
|
|
1402
|
+
// ---------------------------------------------------------------------------
|
|
1403
|
+
// Keys live in src/api-keys.json as SHA-256 hashes; the plaintext is returned
|
|
1404
|
+
// exactly once, at creation. src/middleware/apiKey.js re-reads the file on
|
|
1405
|
+
// change, so creating / revoking a key applies without a restart.
|
|
1406
|
+
const apiKeysFile = () => path.join(process.cwd(), 'src', 'api-keys.json');
|
|
1407
|
+
|
|
1408
|
+
function readApiKeys() {
|
|
1409
|
+
try {
|
|
1410
|
+
const list = JSON.parse(fs.readFileSync(apiKeysFile(), 'utf8'));
|
|
1411
|
+
return Array.isArray(list) ? list : [];
|
|
1412
|
+
} catch { return []; }
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function writeApiKeys(list) {
|
|
1416
|
+
fs.mkdirSync(path.dirname(apiKeysFile()), { recursive: true });
|
|
1417
|
+
fs.writeFileSync(apiKeysFile(), JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function apiKeyGateWired() {
|
|
1421
|
+
return readIndexFile().includes('app.use(apiKeyGate)');
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function apiKeyGateActive() {
|
|
1425
|
+
return apiKeyGateWired() && getEnvValue(path.join(process.cwd(), '.env'), 'API_KEY_REQUIRED') !== 'off';
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Keep the hashed keys file out of git, like .env.
|
|
1429
|
+
function ensureGitignored(entry) {
|
|
1430
|
+
const gi = path.join(process.cwd(), '.gitignore');
|
|
1431
|
+
try {
|
|
1432
|
+
const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
1433
|
+
if (!cur.split(/\r?\n/).includes(entry)) fs.writeFileSync(gi, cur.trimEnd() + '\n' + entry + '\n', 'utf8');
|
|
1434
|
+
} catch {}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function publicKeyView(k) {
|
|
1438
|
+
return { id: k.id, name: k.name, prefix: k.prefix || (k.key ? k.key.slice(0, 7) : ''), createdAt: k.createdAt || null, legacy: !!k.key };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
router.get('/api/security/api-keys', (req, res) => {
|
|
1442
|
+
try {
|
|
1443
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1444
|
+
res.json({
|
|
1445
|
+
gate: {
|
|
1446
|
+
wired: apiKeyGateWired(),
|
|
1447
|
+
enabled: getEnvValue(envPath, 'API_KEY_REQUIRED') !== 'off',
|
|
1448
|
+
publicPaths: getEnvValue(envPath, 'API_KEY_PUBLIC_PATHS') || '',
|
|
1449
|
+
},
|
|
1450
|
+
keys: readApiKeys().map(publicKeyView),
|
|
1451
|
+
});
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
res.status(500).json({ error: err.message });
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
// Create a key. The plaintext is in this response only; it is never stored.
|
|
1458
|
+
router.post('/api/security/api-keys', (req, res) => {
|
|
1459
|
+
try {
|
|
1460
|
+
const name = String((req.body && req.body.name) || '').replace(/[\r\n]/g, '').trim().slice(0, 60) || 'Unnamed key';
|
|
1461
|
+
const key = generateApiKey(crypto.randomBytes);
|
|
1462
|
+
const entry = {
|
|
1463
|
+
id: crypto.randomUUID(),
|
|
1464
|
+
name,
|
|
1465
|
+
prefix: key.slice(0, 7),
|
|
1466
|
+
hash: hashApiKeyValue(key, crypto.createHash),
|
|
1467
|
+
createdAt: new Date().toISOString(),
|
|
1468
|
+
};
|
|
1469
|
+
writeApiKeys([...readApiKeys(), entry]);
|
|
1470
|
+
ensureGitignored('src/api-keys.json');
|
|
1471
|
+
res.json({ key, ...publicKeyView(entry) });
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
res.status(500).json({ error: err.message });
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
router.delete('/api/security/api-keys/:id', (req, res) => {
|
|
1478
|
+
try {
|
|
1479
|
+
const list = readApiKeys();
|
|
1480
|
+
const next = list.filter((k) => k.id !== req.params.id);
|
|
1481
|
+
if (next.length === list.length) return res.status(404).json({ error: 'Key not found' });
|
|
1482
|
+
writeApiKeys(next);
|
|
1483
|
+
res.json({ message: 'Key revoked' });
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
res.status(500).json({ error: err.message });
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
// Turn the gate on/off and set public path prefixes. Both are read per request
|
|
1490
|
+
// from process.env, so they apply live. If the project predates the built-in
|
|
1491
|
+
// gate, this also writes the middleware and mounts it in index.js (restart).
|
|
1492
|
+
router.post('/api/security/api-gate', (req, res) => {
|
|
1493
|
+
try {
|
|
1494
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1495
|
+
const b = req.body || {};
|
|
1496
|
+
if (typeof b.enabled === 'boolean') {
|
|
1497
|
+
const v = b.enabled ? 'on' : 'off';
|
|
1498
|
+
updateEnvFile(envPath, 'API_KEY_REQUIRED', v);
|
|
1499
|
+
process.env.API_KEY_REQUIRED = v;
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof b.publicPaths === 'string') {
|
|
1502
|
+
const clean = b.publicPaths.split(',').map((p) => p.trim()).filter((p) => /^\/[\w\-./]*$/.test(p)).join(',');
|
|
1503
|
+
updateEnvFile(envPath, 'API_KEY_PUBLIC_PATHS', clean);
|
|
1504
|
+
process.env.API_KEY_PUBLIC_PATHS = clean;
|
|
1505
|
+
}
|
|
1506
|
+
let wiredNow = false;
|
|
1507
|
+
if (b.enabled === true && !apiKeyGateWired()) {
|
|
1508
|
+
const mwPath = path.join(process.cwd(), 'src', 'middleware', 'apiKey.js');
|
|
1509
|
+
const existing = fs.existsSync(mwPath) ? fs.readFileSync(mwPath, 'utf8') : '';
|
|
1510
|
+
if (!existing.includes('export function apiKeyGate')) {
|
|
1511
|
+
fs.mkdirSync(path.dirname(mwPath), { recursive: true });
|
|
1512
|
+
fs.writeFileSync(mwPath, buildApiKeyMiddleware(), 'utf8');
|
|
1513
|
+
}
|
|
1514
|
+
ensureGitignored('src/api-keys.json');
|
|
1515
|
+
writeIndexFile(wireApiKeyGateIntoIndex(readIndexFile()));
|
|
1516
|
+
wiredNow = true;
|
|
1517
|
+
}
|
|
1518
|
+
// Keep the docs' code samples in step with the gate.
|
|
1519
|
+
if (fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'))) {
|
|
1520
|
+
try { generateSpecFile(); } catch {}
|
|
1521
|
+
}
|
|
1522
|
+
res.json({ message: wiredNow ? 'API key gate added to index.js. The app is restarting.' : 'Saved.', wiredNow });
|
|
1523
|
+
} catch (err) {
|
|
1524
|
+
res.status(500).json({ error: err.message });
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1294
1528
|
// ---------------------------------------------------------------------------
|
|
1295
1529
|
// Security configuration
|
|
1296
1530
|
// ---------------------------------------------------------------------------
|
|
@@ -1334,12 +1568,14 @@ const safeServiceType = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9-]
|
|
|
1334
1568
|
router.get('/api/discovery', async (req, res) => {
|
|
1335
1569
|
try {
|
|
1336
1570
|
const envPath = path.join(process.cwd(), '.env');
|
|
1337
|
-
const enabled = getEnvValue(envPath, 'BONJOUR_ENABLED')
|
|
1571
|
+
const enabled = getEnvValue(envPath, 'BONJOUR_ENABLED') === 'on';
|
|
1338
1572
|
const name = getEnvValue(envPath, 'BONJOUR_NAME');
|
|
1339
1573
|
const type = getEnvValue(envPath, 'BONJOUR_TYPE') || 'http';
|
|
1574
|
+
const iface = getEnvValue(envPath, 'BONJOUR_INTERFACE');
|
|
1340
1575
|
const mod = await loadDiscovery().catch(() => null);
|
|
1341
1576
|
const self = mod && mod.getSelfState ? mod.getSelfState() : null;
|
|
1342
|
-
|
|
1577
|
+
const interfaces = mod && mod.listNetworkInterfaces ? mod.listNetworkInterfaces() : [];
|
|
1578
|
+
res.json({ available: !!mod, enabled, config: { name, type, interface: iface }, self, interfaces });
|
|
1343
1579
|
} catch (err) {
|
|
1344
1580
|
res.status(500).json({ error: err.message });
|
|
1345
1581
|
}
|
|
@@ -1381,12 +1617,18 @@ router.post('/api/discovery/config', async (req, res) => {
|
|
|
1381
1617
|
updateEnvFile(envPath, 'BONJOUR_TYPE', t);
|
|
1382
1618
|
process.env.BONJOUR_TYPE = t;
|
|
1383
1619
|
}
|
|
1620
|
+
// Interface NAME ("en0"); "" = all networks.
|
|
1621
|
+
if (typeof b.interface === 'string') {
|
|
1622
|
+
const iface = b.interface.replace(/[^a-zA-Z0-9_.:-]/g, '').slice(0, 40);
|
|
1623
|
+
updateEnvFile(envPath, 'BONJOUR_INTERFACE', iface);
|
|
1624
|
+
process.env.BONJOUR_INTERFACE = iface;
|
|
1625
|
+
}
|
|
1384
1626
|
|
|
1385
1627
|
// Apply live in the running process.
|
|
1386
1628
|
let self = null;
|
|
1387
1629
|
const mod = await loadDiscovery().catch(() => null);
|
|
1388
1630
|
if (mod) {
|
|
1389
|
-
if (process.env.BONJOUR_ENABLED
|
|
1631
|
+
if (process.env.BONJOUR_ENABLED !== 'on') {
|
|
1390
1632
|
if (mod.stopAdvertising) await mod.stopAdvertising();
|
|
1391
1633
|
} else if (mod.startAdvertising) {
|
|
1392
1634
|
const st = mod.getSelfState ? mod.getSelfState() : {};
|
|
@@ -1436,11 +1678,8 @@ async function notifyOfficialDomain(envPath, provider, fromAddr) {
|
|
|
1436
1678
|
'Time: ' + new Date().toISOString(),
|
|
1437
1679
|
].join('\n');
|
|
1438
1680
|
|
|
1439
|
-
if (provider === '
|
|
1440
|
-
|
|
1441
|
-
const resend = new Resend(getEnvValue(envPath, 'RESEND_API_KEY'));
|
|
1442
|
-
const { error } = await resend.emails.send({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
|
|
1443
|
-
if (error) throw new Error(error.message || 'Resend notify failed');
|
|
1681
|
+
if (provider === 'sancharak') {
|
|
1682
|
+
await sancharakSendFromEnv(envPath, { from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
|
|
1444
1683
|
return;
|
|
1445
1684
|
}
|
|
1446
1685
|
const nodemailer = (await import('nodemailer')).default;
|
|
@@ -1453,18 +1692,47 @@ async function notifyOfficialDomain(envPath, provider, fromAddr) {
|
|
|
1453
1692
|
await transporter.sendMail({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
|
|
1454
1693
|
}
|
|
1455
1694
|
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1695
|
+
// Send one email through the Sancharak API using the settings in .env (read
|
|
1696
|
+
// fresh: process.env can be stale right after a save). Mirrors sancharakSend in
|
|
1697
|
+
// the generated src/services/mailer.js.
|
|
1698
|
+
async function sancharakSendFromEnv(envPath, { from, to, subject, text, html, replyTo }) {
|
|
1699
|
+
const key = String(getEnvValue(envPath, 'SANCHARAK_API_KEY') || '').trim();
|
|
1700
|
+
const problem = sancharakKeyProblem(key);
|
|
1701
|
+
if (problem) throw new Error(problem);
|
|
1702
|
+
const body = { from: from || getEnvValue(envPath, 'MAIL_FROM'), to: [].concat(to), subject, stream: 'tx' };
|
|
1703
|
+
const reply = replyTo || getEnvValue(envPath, 'MAIL_REPLY_TO');
|
|
1704
|
+
if (reply) body.reply_to = [].concat(reply);
|
|
1705
|
+
if (text) body.text = text;
|
|
1706
|
+
if (html) body.html = html;
|
|
1707
|
+
const res = await fetch(getEnvValue(envPath, 'SANCHARAK_API_URL') || SANCHARAK_API_URL, {
|
|
1708
|
+
method: 'POST',
|
|
1709
|
+
headers: {
|
|
1710
|
+
Authorization: 'Bearer ' + key,
|
|
1711
|
+
'Content-Type': 'application/json',
|
|
1712
|
+
'Idempotency-Key': crypto.randomUUID(),
|
|
1713
|
+
},
|
|
1714
|
+
body: JSON.stringify(body),
|
|
1715
|
+
signal: AbortSignal.timeout(15000),
|
|
1716
|
+
});
|
|
1717
|
+
const raw = await res.text();
|
|
1718
|
+
let data = null;
|
|
1719
|
+
try { data = raw ? JSON.parse(raw) : null; } catch { data = { raw }; }
|
|
1720
|
+
if (!res.ok) {
|
|
1721
|
+
const msg = (data && (data.message || data.error || (data.error && data.error.message))) || raw || res.statusText;
|
|
1722
|
+
throw new Error('Sancharak ' + res.status + ': ' + (typeof msg === 'string' ? msg : JSON.stringify(msg)));
|
|
1723
|
+
}
|
|
1724
|
+
return data;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// Verify a saved mail config: an SMTP handshake, or for Sancharak the key shape
|
|
1728
|
+
// and From address (sending is the only real check, so "Send test" confirms
|
|
1729
|
+
// delivery). Throws with a readable message on failure. A missing package
|
|
1730
|
+
// (mid-install) is treated as "not yet verifiable", not a failure.
|
|
1459
1731
|
async function verifyMailConfig(envPath, provider) {
|
|
1460
|
-
if (provider === '
|
|
1461
|
-
const
|
|
1462
|
-
if (
|
|
1463
|
-
|
|
1464
|
-
// rejects normal send-only keys, which would fail valid configs. So validate the
|
|
1465
|
-
// key shape + From here, and let the "Send test" button confirm deliverability.
|
|
1466
|
-
if (!/^re_/.test(key)) throw new Error('That does not look like a Resend API key — it should start with "re_".');
|
|
1467
|
-
if (!getEnvValue(envPath, 'MAIL_FROM')) throw new Error('A From address is required for Postmaster.');
|
|
1732
|
+
if (provider === 'sancharak') {
|
|
1733
|
+
const problem = sancharakKeyProblem(getEnvValue(envPath, 'SANCHARAK_API_KEY'));
|
|
1734
|
+
if (problem) throw new Error(problem);
|
|
1735
|
+
if (!getEnvValue(envPath, 'MAIL_FROM')) throw new Error('A From address is required for Sancharak.');
|
|
1468
1736
|
return;
|
|
1469
1737
|
}
|
|
1470
1738
|
const host = getEnvValue(envPath, 'SMTP_HOST');
|
|
@@ -1486,16 +1754,19 @@ async function verifyMailConfig(envPath, provider) {
|
|
|
1486
1754
|
router.get('/api/mail', (req, res) => {
|
|
1487
1755
|
try {
|
|
1488
1756
|
const envPath = path.join(process.cwd(), '.env');
|
|
1489
|
-
|
|
1757
|
+
// Old projects saved MAIL_PROVIDER=resend (Postmaster); Sancharak replaces it.
|
|
1758
|
+
const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'sancharak').toLowerCase();
|
|
1490
1759
|
res.json({
|
|
1491
1760
|
configured: fs.existsSync(mailerFilePath()),
|
|
1492
1761
|
// false only when the last save explicitly failed its connection test.
|
|
1493
|
-
verified: getEnvValue(envPath, 'MAIL_VERIFIED') !== 'false',
|
|
1494
|
-
provider: MAIL_PROVIDERS[provider] ? provider : '
|
|
1762
|
+
verified: getEnvValue(envPath, 'MAIL_VERIFIED') !== 'false' && provider !== 'resend',
|
|
1763
|
+
provider: MAIL_PROVIDERS[provider] ? provider : 'sancharak',
|
|
1764
|
+
legacyResend: provider === 'resend',
|
|
1495
1765
|
providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
|
|
1496
|
-
|
|
1766
|
+
sancharak: {
|
|
1497
1767
|
from: getEnvValue(envPath, 'MAIL_FROM'),
|
|
1498
|
-
|
|
1768
|
+
replyTo: getEnvValue(envPath, 'MAIL_REPLY_TO'),
|
|
1769
|
+
hasKey: !!getEnvValue(envPath, 'SANCHARAK_API_KEY'),
|
|
1499
1770
|
},
|
|
1500
1771
|
smtp: {
|
|
1501
1772
|
host: getEnvValue(envPath, 'SMTP_HOST'),
|
|
@@ -1512,11 +1783,11 @@ router.get('/api/mail', (req, res) => {
|
|
|
1512
1783
|
});
|
|
1513
1784
|
|
|
1514
1785
|
// POST /api/mail/setup — save config to .env, write mailer.js, install the backend.
|
|
1515
|
-
// Body: { provider: '
|
|
1786
|
+
// Body: { provider: 'sancharak'|'smtp', ... }. sancharak → { apiKey, from, replyTo }; smtp → { host, port, secure, user, pass, from }.
|
|
1516
1787
|
router.post('/api/mail/setup', async (req, res) => {
|
|
1517
1788
|
try {
|
|
1518
1789
|
const envPath = path.join(process.cwd(), '.env');
|
|
1519
|
-
const provider = String((req.body && req.body.provider) || '
|
|
1790
|
+
const provider = String((req.body && req.body.provider) || 'sancharak').toLowerCase();
|
|
1520
1791
|
if (!MAIL_PROVIDERS[provider]) return res.status(400).json({ error: 'Unknown mail provider.' });
|
|
1521
1792
|
|
|
1522
1793
|
// Gate the official 4brains.in domain: it may not be used directly. Require the
|
|
@@ -1539,13 +1810,19 @@ router.post('/api/mail/setup', async (req, res) => {
|
|
|
1539
1810
|
// guard allows sending from 4brains.in (direct/code use stays blocked).
|
|
1540
1811
|
if (official) updateEnvFile(envPath, 'OFFICIAL_MAIL_AUTHORIZED', '1');
|
|
1541
1812
|
|
|
1542
|
-
if (provider === '
|
|
1543
|
-
const { apiKey, from } = req.body || {};
|
|
1813
|
+
if (provider === 'sancharak') {
|
|
1814
|
+
const { apiKey, from, replyTo } = req.body || {};
|
|
1544
1815
|
if (from !== undefined) updateEnvFile(envPath, 'MAIL_FROM', String(from || '').trim());
|
|
1545
|
-
if (
|
|
1546
|
-
|
|
1816
|
+
if (replyTo !== undefined) updateEnvFile(envPath, 'MAIL_REPLY_TO', String(replyTo || '').trim());
|
|
1817
|
+
// Reject a bad key BEFORE saving, so it can't overwrite a working one.
|
|
1818
|
+
if (apiKey) {
|
|
1819
|
+
const problem = sancharakKeyProblem(apiKey);
|
|
1820
|
+
if (problem) return res.status(400).json({ error: problem });
|
|
1821
|
+
updateEnvFile(envPath, 'SANCHARAK_API_KEY', String(apiKey).trim());
|
|
1822
|
+
}
|
|
1823
|
+
if (!getEnvValue(envPath, 'SANCHARAK_API_KEY')) return res.status(400).json({ error: 'A Sancharak API key is required.' });
|
|
1547
1824
|
if (!getEnvValue(envPath, 'MAIL_FROM')) return res.status(400).json({ error: 'A From address is required.' });
|
|
1548
|
-
|
|
1825
|
+
// No package to install: Sancharak is called with fetch.
|
|
1549
1826
|
} else {
|
|
1550
1827
|
const { host, port, secure, user, pass, from } = req.body || {};
|
|
1551
1828
|
if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
|
|
@@ -1583,10 +1860,11 @@ router.post('/api/mail/setup', async (req, res) => {
|
|
|
1583
1860
|
res.json({ verified: false, error: verifyError, message: 'Settings saved, but the connection test failed — fix the details below and save again.' + note });
|
|
1584
1861
|
}
|
|
1585
1862
|
|
|
1586
|
-
// Write/upgrade the mailer service last. Upgrade old single-backend mailers
|
|
1587
|
-
// the
|
|
1863
|
+
// Write/upgrade the mailer service last. Upgrade old single-backend mailers and
|
|
1864
|
+
// the old Postmaster (Resend) mailer to the current Sancharak + SMTP version.
|
|
1588
1865
|
fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
|
|
1589
|
-
const
|
|
1866
|
+
const current = fs.existsSync(mailerFilePath()) ? fs.readFileSync(mailerFilePath(), 'utf8') : '';
|
|
1867
|
+
const needsWrite = !current || !current.includes('MAIL_PROVIDER') || current.includes("import('resend')");
|
|
1590
1868
|
if (needsWrite) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
|
|
1591
1869
|
} catch (err) {
|
|
1592
1870
|
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
@@ -1599,26 +1877,25 @@ router.post('/api/mail/test', async (req, res) => {
|
|
|
1599
1877
|
const to = String((req.body && req.body.to) || '').trim();
|
|
1600
1878
|
if (!to) return res.status(400).json({ error: 'Recipient is required.' });
|
|
1601
1879
|
const envPath = path.join(process.cwd(), '.env');
|
|
1602
|
-
const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || '
|
|
1880
|
+
const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'sancharak').toLowerCase();
|
|
1603
1881
|
|
|
1604
1882
|
if (provider === 'resend') {
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
return res.json({ message: `Test email sent to ${to}.` });
|
|
1883
|
+
return res.status(400).json({ error: 'Postmaster (Resend) has been replaced by Sancharak. Enter your Sancharak API key and save first.' });
|
|
1884
|
+
}
|
|
1885
|
+
if (provider === 'sancharak') {
|
|
1886
|
+
if (!getEnvValue(envPath, 'MAIL_FROM')) return res.status(400).json({ error: 'Set a From address (MAIL_FROM) before sending a test.' });
|
|
1887
|
+
try {
|
|
1888
|
+
const r = await sancharakSendFromEnv(envPath, {
|
|
1889
|
+
to,
|
|
1890
|
+
subject: 'Test email from your app',
|
|
1891
|
+
text: 'This is a test email confirming your Sancharak settings work.',
|
|
1892
|
+
html: '<p>This is a <b>test email</b> confirming your Sancharak settings work.</p>',
|
|
1893
|
+
});
|
|
1894
|
+
const id = r && (r.id || (r.data && r.data.id));
|
|
1895
|
+
return res.json({ message: `Test email sent to ${to}.` + (id ? ' Message id: ' + id : '') });
|
|
1896
|
+
} catch (e) {
|
|
1897
|
+
return res.status(502).json({ error: 'Send failed: ' + e.message });
|
|
1898
|
+
}
|
|
1622
1899
|
}
|
|
1623
1900
|
|
|
1624
1901
|
const host = getEnvValue(envPath, 'SMTP_HOST');
|
|
@@ -1974,7 +2251,7 @@ function collectEndpoints() {
|
|
|
1974
2251
|
}
|
|
1975
2252
|
|
|
1976
2253
|
function generateSpecFile() {
|
|
1977
|
-
const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels() });
|
|
2254
|
+
const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels(), apiKey: apiKeyGateActive() });
|
|
1978
2255
|
fs.writeFileSync(path.join(process.cwd(), 'src', 'openapi.json'), JSON.stringify(spec, null, 2), 'utf8');
|
|
1979
2256
|
return spec;
|
|
1980
2257
|
}
|
|
@@ -2976,69 +3253,265 @@ router.get('/api/serialports', (req, res) => {
|
|
|
2976
3253
|
});
|
|
2977
3254
|
|
|
2978
3255
|
// ---------------------------------------------------------------------------
|
|
2979
|
-
//
|
|
3256
|
+
// Serial devices (Arduino / ESP32). Devices live in src/serial/devices.json and
|
|
3257
|
+
// are matched at connect time (USB serial number / vendor id / firmware
|
|
3258
|
+
// identity), never by a hard-coded COM path. The device manager runs in THIS
|
|
3259
|
+
// process (started by index.js); we import the same module by absolute path so
|
|
3260
|
+
// the dashboard reads live state and can send commands.
|
|
2980
3261
|
// ---------------------------------------------------------------------------
|
|
2981
|
-
|
|
3262
|
+
const serialDir = () => path.join(process.cwd(), 'src', 'serial');
|
|
3263
|
+
const serialConfigFile = () => path.join(serialDir(), 'devices.json');
|
|
3264
|
+
const loadSerialModule = (file) => import(pathToFileURL(path.join(serialDir(), file)).href);
|
|
3265
|
+
|
|
3266
|
+
function readSerialConfigFile() {
|
|
2982
3267
|
try {
|
|
2983
|
-
const
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
const envPathKey = name.toUpperCase() + '_PATH';
|
|
2988
|
-
const envBaudKey = name.toUpperCase() + '_BAUD';
|
|
3268
|
+
const list = JSON.parse(fs.readFileSync(serialConfigFile(), 'utf8'));
|
|
3269
|
+
return Array.isArray(list) ? list : [];
|
|
3270
|
+
} catch { return []; }
|
|
3271
|
+
}
|
|
2989
3272
|
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
3273
|
+
function serialWired() {
|
|
3274
|
+
return readIndexFile().includes('serialRouter)');
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// Write the generated files that are missing (never overwrite edits).
|
|
3278
|
+
function writeSerialFiles() {
|
|
3279
|
+
fs.mkdirSync(serialDir(), { recursive: true });
|
|
3280
|
+
const files = [
|
|
3281
|
+
[path.join(serialDir(), 'SerialDevice.js'), buildSerialDeviceModule()],
|
|
3282
|
+
[path.join(serialDir(), 'devices.js'), buildSerialDevicesModule()],
|
|
3283
|
+
[path.join(process.cwd(), 'src', 'routes', 'serial.js'), buildSerialRouter()],
|
|
3284
|
+
[serialConfigFile(), '[]\n'],
|
|
3285
|
+
];
|
|
3286
|
+
const written = [];
|
|
3287
|
+
for (const [file, content] of files) {
|
|
3288
|
+
if (fs.existsSync(file)) continue;
|
|
3289
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3290
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
3291
|
+
written.push(path.relative(process.cwd(), file));
|
|
3292
|
+
}
|
|
3293
|
+
return written;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
async function serialPackageInstalled() {
|
|
3297
|
+
try { await import('serialport'); return true; } catch { return false; }
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// Apply devices.json to the running app without a restart.
|
|
3301
|
+
async function reloadLiveSerial() {
|
|
3302
|
+
if (!serialWired()) return null;
|
|
3303
|
+
try { return await (await loadSerialModule('devices.js')).reloadSerialDevices(); } catch { return null; }
|
|
3304
|
+
}
|
|
2996
3305
|
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3306
|
+
async function liveSerialDevice(name) {
|
|
3307
|
+
const mod = await loadSerialModule('devices.js');
|
|
3308
|
+
const dev = mod.getDevice(name);
|
|
3309
|
+
if (!dev) throw Object.assign(new Error('Device "' + name + '" is not running. Is it enabled?'), { status: 404 });
|
|
3310
|
+
return dev;
|
|
3311
|
+
}
|
|
3001
3312
|
|
|
3002
|
-
|
|
3313
|
+
router.get('/api/serial', async (req, res) => {
|
|
3314
|
+
try {
|
|
3315
|
+
const wired = serialWired();
|
|
3316
|
+
let live = [];
|
|
3317
|
+
if (wired) {
|
|
3318
|
+
try { live = (await loadSerialModule('devices.js')).listSerialDevices(); } catch {}
|
|
3319
|
+
}
|
|
3320
|
+
const index = readIndexFile();
|
|
3003
3321
|
res.json({
|
|
3004
|
-
|
|
3005
|
-
|
|
3322
|
+
installed: await serialPackageInstalled(),
|
|
3323
|
+
wired,
|
|
3324
|
+
config: readSerialConfigFile(),
|
|
3325
|
+
live,
|
|
3326
|
+
legacy: hasLegacySerialPorts(index),
|
|
3327
|
+
baudRates: SERIAL_BAUD_RATES,
|
|
3328
|
+
autoAdd: getEnvValue(path.join(process.cwd(), '.env'), 'SERIAL_AUTO_ADD') !== 'off',
|
|
3329
|
+
idPresets: SERIAL_ID_PRESETS,
|
|
3006
3330
|
});
|
|
3331
|
+
} catch (err) {
|
|
3332
|
+
res.status(500).json({ error: err.message });
|
|
3333
|
+
}
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
// One-time setup: install serialport, write the device manager + routes, wire
|
|
3337
|
+
// index.js (that last step restarts the app).
|
|
3338
|
+
router.post('/api/serial/setup', async (req, res) => {
|
|
3339
|
+
try {
|
|
3340
|
+
if (!(await serialPackageInstalled())) await installDeps(SERIAL_DEPS);
|
|
3341
|
+
if (!(await serialPackageInstalled())) {
|
|
3342
|
+
return res.status(500).json({ error: 'Could not install serialport. Run: npm install ' + SERIAL_DEPS.join(' ') });
|
|
3343
|
+
}
|
|
3344
|
+
const files = writeSerialFiles();
|
|
3345
|
+
const willRestart = !serialWired();
|
|
3346
|
+
res.json({ message: willRestart ? 'Serial support added. The app is restarting.' : 'Serial support is ready.', files });
|
|
3347
|
+
if (willRestart) writeIndexFile(wireSerialIntoIndex(readIndexFile()));
|
|
3348
|
+
} catch (err) {
|
|
3349
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
|
|
3353
|
+
// Hotplug events (board found / attached / removed / auto-added) after ?since=<id>.
|
|
3354
|
+
// Polled by the dashboard to toast "board connected" on any page.
|
|
3355
|
+
router.get('/api/serial/events', async (req, res) => {
|
|
3356
|
+
try {
|
|
3357
|
+
if (!serialWired()) return res.json([]);
|
|
3358
|
+
const mod = await loadSerialModule('devices.js');
|
|
3359
|
+
res.json(mod.getSerialEvents ? mod.getSerialEvents(Number(req.query.since) || 0) : []);
|
|
3360
|
+
} catch {
|
|
3361
|
+
res.json([]);
|
|
3362
|
+
}
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
// Auto-add new boards on/off (SERIAL_AUTO_ADD). Read per event, so it applies live.
|
|
3366
|
+
router.post('/api/serial/settings', (req, res) => {
|
|
3367
|
+
try {
|
|
3368
|
+
if (typeof (req.body && req.body.autoAdd) !== 'boolean') return res.status(400).json({ error: 'autoAdd must be true or false' });
|
|
3369
|
+
const v = req.body.autoAdd ? 'on' : 'off';
|
|
3370
|
+
updateEnvFile(path.join(process.cwd(), '.env'), 'SERIAL_AUTO_ADD', v);
|
|
3371
|
+
process.env.SERIAL_AUTO_ADD = v;
|
|
3372
|
+
res.json({ message: req.body.autoAdd ? 'New boards are added automatically.' : 'New boards are only listed, not added.' });
|
|
3373
|
+
} catch (err) {
|
|
3374
|
+
res.status(500).json({ error: err.message });
|
|
3375
|
+
}
|
|
3376
|
+
});
|
|
3377
|
+
|
|
3378
|
+
// Every serial port with its USB metadata (vendor id, serial number, ...).
|
|
3379
|
+
router.get('/api/serial/scan', async (req, res) => {
|
|
3380
|
+
try {
|
|
3381
|
+
if (!fs.existsSync(path.join(serialDir(), 'SerialDevice.js'))) writeSerialFiles();
|
|
3382
|
+
const mod = await loadSerialModule('SerialDevice.js');
|
|
3383
|
+
res.json(await mod.listPorts());
|
|
3384
|
+
} catch (err) {
|
|
3385
|
+
res.status(500).json({ error: err.message });
|
|
3386
|
+
}
|
|
3387
|
+
});
|
|
3007
3388
|
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3389
|
+
// Point 7 of the serial guide: every USB port with USB metadata AND firmware
|
|
3390
|
+
// identity, probing free ports one at a time (opening resets most boards).
|
|
3391
|
+
router.post('/api/serial/identify-all', async (req, res) => {
|
|
3392
|
+
try {
|
|
3393
|
+
const b = req.body || {};
|
|
3394
|
+
const opts = {
|
|
3395
|
+
baudRate: Number(b.baudRate) || 115200,
|
|
3396
|
+
command: String(b.command || 'ID?').slice(0, 40),
|
|
3397
|
+
refresh: b.refresh === true,
|
|
3398
|
+
};
|
|
3399
|
+
// Use the running device manager when wired, so its open ports are skipped.
|
|
3400
|
+
const mod = serialWired() ? await loadSerialModule('devices.js') : await loadSerialModule('SerialDevice.js');
|
|
3401
|
+
res.json(await mod.listDevices(opts));
|
|
3402
|
+
} catch (err) {
|
|
3403
|
+
res.status(500).json({ error: err.message });
|
|
3404
|
+
}
|
|
3405
|
+
});
|
|
3011
3406
|
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3407
|
+
// Ask a board who it is: open, wait out the reset, send the id command.
|
|
3408
|
+
router.post('/api/serial/probe', async (req, res) => {
|
|
3409
|
+
try {
|
|
3410
|
+
const b = req.body || {};
|
|
3411
|
+
const portPath = String(b.path || '');
|
|
3412
|
+
if (!portPath) return res.status(400).json({ error: 'path is required' });
|
|
3413
|
+
const mod = await loadSerialModule('SerialDevice.js');
|
|
3414
|
+
const known = (await mod.listPorts()).find((p) => p.path === portPath);
|
|
3415
|
+
if (!known) return res.status(404).json({ error: 'No such port' });
|
|
3416
|
+
if (known.inUse) return res.status(409).json({ error: 'A configured device is using this port.' });
|
|
3417
|
+
const opts = {
|
|
3418
|
+
baudRate: Number(b.baudRate) || 115200,
|
|
3419
|
+
command: String(b.command || 'ID?').slice(0, 40),
|
|
3420
|
+
delimiter: b.delimiter === 'crlf' ? 'crlf' : 'lf',
|
|
3421
|
+
};
|
|
3422
|
+
// tryAll: ID?, M115, $I, ATI in turn; reports which command answered.
|
|
3423
|
+
if (b.tryAll && mod.identifyBoard) {
|
|
3424
|
+
const found = await mod.identifyBoard(portPath, opts);
|
|
3425
|
+
return res.json({ identity: found ? found.identity : null, command: found ? found.command : null });
|
|
3426
|
+
}
|
|
3427
|
+
const identity = await mod.probeIdentity(portPath, opts);
|
|
3428
|
+
res.json({ identity, command: identity ? opts.command : null });
|
|
3429
|
+
} catch (err) {
|
|
3430
|
+
res.status(500).json({ error: err.message });
|
|
3431
|
+
}
|
|
3015
3432
|
});
|
|
3016
3433
|
|
|
3017
|
-
|
|
3434
|
+
// Create or update a device (body.originalName renames).
|
|
3435
|
+
router.post('/api/serial/devices', async (req, res) => {
|
|
3436
|
+
try {
|
|
3437
|
+
const device = normalizeSerialDevice(req.body || {});
|
|
3438
|
+
const original = String((req.body && req.body.originalName) || device.name);
|
|
3439
|
+
const list = readSerialConfigFile().filter((d) => d.name !== original);
|
|
3440
|
+
if (list.some((d) => d.name === device.name)) return res.status(409).json({ error: 'A device named "' + device.name + '" already exists.' });
|
|
3441
|
+
list.push(device);
|
|
3442
|
+
fs.mkdirSync(serialDir(), { recursive: true });
|
|
3443
|
+
fs.writeFileSync(serialConfigFile(), JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
3444
|
+
const live = await reloadLiveSerial();
|
|
3445
|
+
res.json({ message: 'Device "' + device.name + '" saved.', device, live });
|
|
3446
|
+
} catch (err) {
|
|
3447
|
+
res.status(400).json({ error: err.message });
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3018
3450
|
|
|
3019
|
-
|
|
3020
|
-
|
|
3451
|
+
router.delete('/api/serial/devices/:name', async (req, res) => {
|
|
3452
|
+
try {
|
|
3453
|
+
const list = readSerialConfigFile();
|
|
3454
|
+
const next = list.filter((d) => d.name !== req.params.name);
|
|
3455
|
+
if (next.length === list.length) return res.status(404).json({ error: 'Device not found' });
|
|
3456
|
+
fs.writeFileSync(serialConfigFile(), JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
3457
|
+
await reloadLiveSerial();
|
|
3458
|
+
res.json({ message: 'Device "' + req.params.name + '" removed.' });
|
|
3459
|
+
} catch (err) {
|
|
3460
|
+
res.status(500).json({ error: err.message });
|
|
3461
|
+
}
|
|
3021
3462
|
});
|
|
3022
3463
|
|
|
3023
|
-
|
|
3024
|
-
|
|
3464
|
+
// Monitor backfill: the last (up to 1000) lines a device sent and received.
|
|
3465
|
+
router.get('/api/serial/devices/:name/recent', async (req, res) => {
|
|
3466
|
+
try {
|
|
3467
|
+
const dev = await liveSerialDevice(req.params.name);
|
|
3468
|
+
res.json(dev.recent.slice(-1000));
|
|
3469
|
+
} catch (err) {
|
|
3470
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
3471
|
+
}
|
|
3025
3472
|
});
|
|
3026
3473
|
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3474
|
+
// Live serial monitor for the dashboard: Server-Sent Events of every line and
|
|
3475
|
+
// status change. The dashboard reads it with fetch() so the passkey travels in
|
|
3476
|
+
// the x-passkey header (checked by the /api middleware), never in the URL.
|
|
3477
|
+
router.get('/api/serial/stream', async (req, res) => {
|
|
3478
|
+
let mod;
|
|
3479
|
+
try {
|
|
3480
|
+
if (!serialWired()) return res.status(409).json({ error: 'Serial is not set up' });
|
|
3481
|
+
mod = await loadSerialModule('devices.js');
|
|
3482
|
+
if (!mod.serialBus) return res.status(409).json({ error: 'Update src/serial/devices.js to use the live monitor' });
|
|
3483
|
+
} catch (err) {
|
|
3484
|
+
return res.status(500).json({ error: err.message });
|
|
3485
|
+
}
|
|
3486
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
3487
|
+
res.write(':\n\n');
|
|
3488
|
+
const onEvent = (e) => res.write('data: ' + JSON.stringify(e) + '\n\n');
|
|
3489
|
+
mod.serialBus.on('event', onEvent);
|
|
3490
|
+
const ping = setInterval(() => res.write(':\n\n'), 25000);
|
|
3491
|
+
req.on('close', () => { clearInterval(ping); mod.serialBus.off('event', onEvent); });
|
|
3492
|
+
});
|
|
3030
3493
|
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3494
|
+
// Send a command and wait for the reply (or just write when expectReply=false).
|
|
3495
|
+
router.post('/api/serial/devices/:name/send', async (req, res) => {
|
|
3496
|
+
try {
|
|
3497
|
+
const dev = await liveSerialDevice(req.params.name);
|
|
3498
|
+
const command = String((req.body && req.body.command) || '');
|
|
3499
|
+
if (!command.trim()) return res.status(400).json({ error: 'command is required' });
|
|
3500
|
+
if (req.body.expectReply === false) {
|
|
3501
|
+
await dev.write(command);
|
|
3502
|
+
return res.json({ ok: true });
|
|
3034
3503
|
}
|
|
3504
|
+
const started = Date.now();
|
|
3505
|
+
const reply = await dev.send(command, { timeout: req.body.timeout });
|
|
3506
|
+
res.json({ reply, ms: Date.now() - started });
|
|
3035
3507
|
} catch (err) {
|
|
3036
|
-
|
|
3508
|
+
res.status(err.status || 504).json({ error: err.message });
|
|
3037
3509
|
}
|
|
3038
3510
|
});
|
|
3039
3511
|
|
|
3040
3512
|
// ---------------------------------------------------------------------------
|
|
3041
|
-
// DELETE /api/serialports/:name — remove a serial port
|
|
3513
|
+
// DELETE /api/serialports/:name — remove a LEGACY serial port (inlined in index.js
|
|
3514
|
+
// by older 4bnode versions, with a hard-coded path) from index.js
|
|
3042
3515
|
// ---------------------------------------------------------------------------
|
|
3043
3516
|
router.delete('/api/serialports/:name', (req, res) => {
|
|
3044
3517
|
try {
|