// Balance Board Hardware Bridge // Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events. // Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js. const { spawn } = require('child_process'); const EventEmitter = require('events'); const path = require('path'); const WORKER_PATH = process.env.BALANCE_BOARD_WORKER || path.join(__dirname, 'native', 'balance_board_worker'); const RESTART_DELAY_MS = 2000; const STDERR_LOG_INTERVAL_MS = 5000; function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) { const events = new EventEmitter(); let worker = null; let stdoutBuffer = ''; let stopped = false; let restarting = false; let restartTimer = null; let lastStderrLogAt = 0; let suppressedStderrLines = 0; let currentAddress = address; function emitProtocolError(message) { events.emit('message', { type: 'status', state: 'error', error: message, }); } function processStdout(chunk) { stdoutBuffer += chunk.toString('utf8'); let newline = stdoutBuffer.indexOf('\n'); while (newline !== -1) { const line = stdoutBuffer.slice(0, newline).trim(); stdoutBuffer = stdoutBuffer.slice(newline + 1); if (line) { try { const message = JSON.parse(line); if (!message || typeof message !== 'object' || typeof message.type !== 'string') { throw new Error('message needs a type'); } events.emit('message', message); } catch (err) { // A corrupted stdout line means measurement framing can no longer be // trusted. Surface the exact line rather than silently discarding a // potential hardware failure that would otherwise look like zero kg. emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`); logger?.warn?.('Balance Board worker protocol error', { line, error: err.message }); } } newline = stdoutBuffer.indexOf('\n'); } } function scheduleRestart() { if (stopped || restartTimer) return; restartTimer = setTimeout(() => { restartTimer = null; start(); }, RESTART_DELAY_MS); } function start() { if (stopped || (worker && !worker.killed)) return; stdoutBuffer = ''; const child = spawn(WORKER_PATH, [], { env: { ...process.env, BALANCE_BOARD_ADDRESS: currentAddress || '', BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '', }, stdio: ['pipe', 'pipe', 'pipe'], }); worker = child; child.stdout.on('data', processStdout); child.stderr.on('data', (chunk) => { const text = chunk.toString('utf8').trim(); if (!text) return; const now = Date.now(); if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) { const suffix = suppressedStderrLines ? ` (${suppressedStderrLines} worker stderr lines suppressed)` : ''; logger?.warn?.(`Balance Board worker: ${text}${suffix}`); lastStderrLogAt = now; suppressedStderrLines = 0; } else { suppressedStderrLines += 1; } }); child.on('error', (err) => { if (worker === child) worker = null; emitProtocolError(`balance board worker failed to start: ${err.message}`); scheduleRestart(); }); child.on('close', (code, signal) => { if (worker === child) worker = null; if (!stopped) { // Admin unpair deliberately replaces the worker with an empty address. // Do not turn that expected exit into a red hardware-error state while // still using the normal restart scheduler for the replacement. if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`); restarting = false; scheduleRestart(); } }); } function stop() { stopped = true; restarting = false; if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } if (!worker) return; const child = worker; worker = null; try { child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`); } catch (_err) { // The worker may have already closed stdin while its exit event is still // queued. SIGTERM below remains the reliable cleanup path. } child.kill('SIGTERM'); setTimeout(() => { // bluetoothctl may still be finishing a bounded pairing command inside a // worker thread. Do not let that delay server shutdown indefinitely. if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); }, 1500).unref(); } function restart() { if (stopped) return; if (!worker) { start(); return; } const child = worker; restarting = true; try { // An admin forget changes the address used in the child environment. A // controlled restart lets the replacement worker start with that new // value, while the existing close handler remains the single owner of // delayed respawn and avoids overlapping Bluetooth listeners. child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`); } catch (_err) { // The child may have already closed stdin; SIGTERM below still guarantees // that it cannot keep listening for the address that was just forgotten. } child.kill('SIGTERM'); setTimeout(() => { if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); }, 1500).unref(); } return { events, start, stop, restart, setAddress(nextAddress) { // The factory can be created before first commissioning. Preserve the // newly paired address for later bridge restarts in the same Node process // instead of reverting the replacement worker to discovery mode. currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : ''; }, }; } module.exports = { createBalanceBoardHardware, };