#!/usr/bin/env node /** * bridge.mjs — Arduino Serial → DataNet bridge (Node.js) * * Reads newline-delimited JSON from a USB serial port and publishes each * message to a DataNet channel. Works with the SerialSensor.ino sketch and * any other Arduino sketch that emits JSON lines. * * Requirements: * Node.js 18+ (native fetch + WebSocket built in) * npm install (installs serialport) * * Usage: * npm install * node bridge.mjs # auto-detect port * SERIAL_PORT=/dev/ttyUSB0 node bridge.mjs # Linux explicit port * SERIAL_PORT=/dev/tty.usbmodem* node bridge.mjs # macOS explicit port * SERIAL_PORT=COM3 node bridge.mjs # Windows explicit port * * Environment variables: * DATANET_API_KEY Your DataNet API key (required) * SERIAL_PORT Serial device path (auto-detected if not set) * BAUD_RATE Baud rate (default: 115200) * CHANNEL DataNet channel (default: demo.serial.arduino) * API_URL DataNet REST base URL (default: https://api.datanet.art) * WS_URL DataNet WebSocket URL (default: wss://ws.datanet.art/ws) * ROUTE_BY_SENSOR Set to "1" to publish to . sub-channels * DEBUG Set to "1" to log every message */ import { SerialPort } from 'serialport'; import { ReadlineParser } from '@serialport/parser-readline'; // ── Config ──────────────────────────────────────────────────────────────── const API_KEY = process.env.DATANET_API_KEY || ''; const SERIAL_PORT = process.env.SERIAL_PORT || ''; const BAUD_RATE = parseInt(process.env.BAUD_RATE || '115200', 10); const CHANNEL = process.env.CHANNEL || 'demo.serial.arduino'; const API_URL = (process.env.API_URL || 'https://api.datanet.art').replace(/\/$/, ''); const WS_URL = process.env.WS_URL || 'wss://ws.datanet.art/ws'; const ROUTE_BY_SENSOR = process.env.ROUTE_BY_SENSOR === '1'; const DEBUG = process.env.DEBUG === '1'; if (!API_KEY) { console.error('ERROR: DATANET_API_KEY is required.'); console.error(' export DATANET_API_KEY=ak_your_key_here'); process.exit(1); } // ── State ───────────────────────────────────────────────────────────────── let ws = null; let wsReady = false; let jwt = null; let reconnectTimer = null; let reconnectAttempts = 0; const MAX_RECONNECTS = 10; const HEARTBEAT_INTERVAL_MS = 30_000; let hbTimer = null; let msgCount = 0; // ── Serial port auto-detection ──────────────────────────────────────────── async function findArduinoPort() { const ports = await SerialPort.list(); if (DEBUG) console.log('[serial] available ports:', ports.map(p => p.path)); // Prefer ports that look like Arduino / CH340 / FTDI / CP210x const arduino = ports.find(p => /arduino|ch340|ch341|ftdi|cp210|usbmodem|usbserial/i.test( [p.manufacturer, p.vendorId, p.productId, p.path].join(' ') ) ); if (arduino) return arduino.path; // Fall back to first USB serial port const usb = ports.find(p => /usb|ttyusb|ttyacm|cu\.usb/i.test(p.path) ); if (usb) return usb.path; return null; } // ── DataNet auth + WebSocket ─────────────────────────────────────────────── async function fetchToken() { const res = await fetch(`${API_URL}/auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: API_KEY }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Auth failed — HTTP ${res.status}: ${text}`); } const { token } = await res.json(); if (!token) throw new Error('Auth response missing token'); return token; } function connectWebSocket(token) { console.log('[datanet] connecting WebSocket…'); ws = new WebSocket(WS_URL, ['bearer', token]); ws.onopen = () => { wsReady = true; reconnectAttempts = 0; console.log('[datanet] WebSocket open ✓'); startHeartbeat(); }; ws.onclose = (event) => { wsReady = false; stopHeartbeat(); console.log(`[datanet] WebSocket closed (code ${event.code})`); scheduleReconnect(); }; ws.onerror = () => { // The close event will fire right after with the actual code. wsReady = false; }; ws.onmessage = (event) => { // Log server acks and errors when in debug mode. if (DEBUG) { try { const msg = JSON.parse(event.data); if (msg.error) console.warn('[datanet] server error:', msg); else console.log('[datanet] recv:', msg); } catch (_) {} } }; } function scheduleReconnect() { if (reconnectAttempts >= MAX_RECONNECTS) { console.error(`[datanet] max reconnect attempts (${MAX_RECONNECTS}) reached — exiting`); process.exit(1); } const delay = Math.min(1000 * 2 ** reconnectAttempts, 30_000); reconnectAttempts++; console.log(`[datanet] reconnecting in ${delay}ms (attempt ${reconnectAttempts})…`); reconnectTimer = setTimeout(async () => { try { jwt = await fetchToken(); connectWebSocket(jwt); } catch (err) { console.error('[datanet] reconnect auth failed:', err.message); scheduleReconnect(); } }, delay); } function startHeartbeat() { stopHeartbeat(); hbTimer = setInterval(() => { if (wsReady) ws.send('{"op":"hb"}'); }, HEARTBEAT_INTERVAL_MS); } function stopHeartbeat() { if (hbTimer) { clearInterval(hbTimer); hbTimer = null; } } function publish(channel, data) { if (!wsReady) { if (DEBUG) console.log('[bridge] dropped (not connected):', data); return; } const envelope = JSON.stringify({ op: 'pub', ch: channel, d: data }); ws.send(envelope); msgCount++; if (DEBUG) console.log(`[bridge] published to ${channel}:`, data); } // ── Serial reading ───────────────────────────────────────────────────────── function handleLine(raw) { const line = raw.trim(); if (!line || !line.startsWith('{')) return; let data; try { data = JSON.parse(line); } catch (_) { if (DEBUG) console.warn('[serial] parse error:', line); return; } // Status messages from the sketch (e.g. startup marker) — log, don't publish. if (data.status) { console.log('[serial] sketch says:', data.status); return; } // Route to sub-channel by sensor name, or publish everything to one channel. const target = ROUTE_BY_SENSOR && data.sensor ? `${CHANNEL}.${data.sensor}` : CHANNEL; publish(target, data); } // ── Main ─────────────────────────────────────────────────────────────────── async function main() { console.log('DataNet Arduino Serial Bridge'); console.log('─────────────────────────────'); console.log(`Channel : ${CHANNEL}${ROUTE_BY_SENSOR ? '.' : ''}`); console.log(`API URL : ${API_URL}`); console.log(`WS URL : ${WS_URL}`); console.log(''); // ── Authenticate ────────────────────────────────────────────────────── console.log('[datanet] authenticating…'); try { jwt = await fetchToken(); console.log('[datanet] auth ok ✓'); } catch (err) { console.error('[datanet] auth failed:', err.message); process.exit(1); } // ── Connect WebSocket ───────────────────────────────────────────────── connectWebSocket(jwt); // ── Resolve serial port ─────────────────────────────────────────────── let portPath = SERIAL_PORT; if (!portPath) { portPath = await findArduinoPort(); if (!portPath) { console.error('ERROR: No serial port found. Set SERIAL_PORT explicitly.'); const all = await SerialPort.list(); if (all.length) console.error('Available ports:', all.map(p => p.path).join(', ')); process.exit(1); } console.log(`[serial] auto-detected port: ${portPath}`); } // ── Open serial port ────────────────────────────────────────────────── const port = new SerialPort({ path: portPath, baudRate: BAUD_RATE }); const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); port.on('open', () => { console.log(`[serial] opened ${portPath} @ ${BAUD_RATE} baud ✓`); console.log('[bridge] forwarding serial JSON → DataNet\n'); }); port.on('error', (err) => { console.error('[serial] port error:', err.message); process.exit(1); }); parser.on('data', handleLine); // ── Status ticker ───────────────────────────────────────────────────── setInterval(() => { const connStr = wsReady ? 'connected' : 'disconnected'; process.stdout.write(`\r[bridge] msgs published: ${msgCount} DataNet: ${connStr} `); }, 2000); // ── Graceful shutdown ───────────────────────────────────────────────── for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => { console.log(`\n[bridge] ${sig} received — shutting down`); if (ws) ws.close(); port.close(); process.exit(0); }); } } main().catch(err => { console.error('[bridge] fatal:', err); process.exit(1); });