/* Open Battery Information — Electron main process. Loads the OBI-1 page and provides native serial access through the `serialport` package (no browser Web Serial needed). The renderer talks to the serial port over IPC via the preload bridge and a small navigator.serial shim. Everything stays local; no telemetry. */ const { app, BrowserWindow, Menu, ipcMain, shell } = require('electron'); const path = require('path'); process.on('uncaughtException', (err) => { console.error('[main] uncaughtException:', err); }); process.on('unhandledRejection', (reason) => { console.error('[main] unhandledRejection:', reason); }); let SerialPort = null; try { ({ SerialPort } = require('serialport')); console.log('[main] serialport loaded'); } catch (err) { console.error('[main] serialport failed to load:', err.message); } const SITE_DIR = path.join(__dirname, 'site'); const openPorts = new Map(); // port path -> { port, sender } let pickerPromise = null; /* ---------- serialport helpers ---------- */ function toPortInfo(p) { return { path: p.path, vendorId: p.vendorId ? parseInt(p.vendorId, 16) : 0, productId: p.productId ? parseInt(p.productId, 16) : 0, manufacturer: p.manufacturer || '', serialNumber: p.serialNumber || '', }; } function openPort(info, baudRate) { return new Promise((resolve, reject) => { const port = new SerialPort( { path: info.path, baudRate: baudRate || 9600 }, (err) => (err ? reject(err) : resolve(port)) ); port.on('error', () => {}); }); } /* ---------- IPC: serial ---------- */ ipcMain.handle('serial:list', async () => { const list = await SerialPort.list(); return list.map(toPortInfo); }); ipcMain.handle('serial:requestPort', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); const info = await showPortPicker(win); if (!info) throw new Error('NotFoundError: No port selected'); return info; }); ipcMain.handle('serial:open', async (event, info, baudRate) => { const existing = openPorts.get(info.path); if (existing && existing.port.isOpen) return { ok: true }; const port = await openPort(info, baudRate); port.on('data', (buf) => { const entry = openPorts.get(info.path); if (entry && entry.sender && !entry.sender.isDestroyed()) { entry.sender.send('serial:data', info.path, buf); } }); port.on('close', () => openPorts.delete(info.path)); openPorts.set(info.path, { port, sender: event.sender }); return { ok: true }; }); ipcMain.handle('serial:setSignals', async (_event, info, dtr) => { const entry = openPorts.get(info.path); if (!entry) throw new Error('Serial port is not open'); await new Promise((resolve, reject) => entry.port.set({ dtr: !!dtr }, (err) => (err ? reject(err) : resolve())) ); return { ok: true }; }); ipcMain.handle('serial:write', async (_event, info, data) => { const entry = openPorts.get(info.path); if (!entry) throw new Error('Serial port is not open'); await new Promise((resolve, reject) => entry.port.write(Buffer.from(data), (err) => (err ? reject(err) : resolve())) ); return { ok: true }; }); ipcMain.handle('serial:close', async (_event, info) => { const entry = openPorts.get(info.path); if (!entry) return { ok: true }; openPorts.delete(info.path); await new Promise((resolve) => entry.port.close(() => resolve())); return { ok: true }; }); /* ---------- serial port picker ---------- */ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) ); const PICKER_HTML = `

Select a serial port

Choose the Arduino running the OBI firmware.

__ROWS__
`; function showPortPicker(parent) { if (pickerPromise) return pickerPromise; pickerPromise = (async () => { let list = []; try { list = await SerialPort.list(); } catch (e) { list = []; } if (!list.length) return null; return await new Promise((resolve) => { const picker = new BrowserWindow({ parent, modal: true, width: 540, height: 440, resizable: false, autoHideMenuBar: true, title: 'Select a serial port', backgroundColor: '#1e1f22', webPreferences: { preload: path.join(__dirname, 'preload-picker.js'), contextIsolation: true, nodeIntegration: false, }, }); const rows = list .map( (p) => '' ) .join(''); ipcMain.once('picker:select', (_e, pickedPath) => { const info = list.find((p) => p.path === pickedPath); if (picker && !picker.isDestroyed()) picker.close(); resolve(info ? toPortInfo(info) : null); }); ipcMain.once('picker:cancel', () => { if (picker && !picker.isDestroyed()) picker.close(); resolve(null); }); picker.on('closed', () => resolve(null)); picker.loadURL( 'data:text/html;charset=utf-8,' + encodeURIComponent(PICKER_HTML.replace('__ROWS__', rows)) ); picker.once('ready-to-show', () => picker.show()); }); })().finally(() => { pickerPromise = null; }); return pickerPromise; } /* ---------- window ---------- */ function createWindow() { const win = new BrowserWindow({ width: 1200, height: 820, minWidth: 900, minHeight: 600, autoHideMenuBar: true, backgroundColor: '#1e1f22', title: 'OBI-1 · Open Battery Information', show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }); win.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http')) shell.openExternal(url); return { action: 'deny' }; }); win.webContents.on('did-fail-load', (_e, code, desc, url) => { console.error('[main] did-fail-load', code, desc, url); }); const target = path.join(SITE_DIR, 'obi.html'); console.log('[main] loading', target); win.loadFile(target).then( () => console.log('[main] page loaded'), (err) => console.error('[main] loadFile failed:', err) ); win.once('ready-to-show', () => { console.log('[main] window ready-to-show'); win.show(); }); return win; } app.whenReady().then(() => { console.log('[main] app ready'); if (process.platform !== 'darwin') { Menu.setApplicationMenu(null); } try { createWindow(); console.log('[main] window created'); } catch (err) { console.error('[main] createWindow failed:', err); } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });