Initial commit

This commit is contained in:
2026-08-19 21:35:27 +03:00
commit d719b34e03
40 changed files with 35252 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
release/
site/
+43
View File
@@ -0,0 +1,43 @@
appId: org.openbatteryinformation.desktop
productName: Open Battery Information
copyright: "Copyright © 2026 Open Battery Information — GPL-3.0"
directories:
output: release
files:
- main.js
- preload.js
- preload-picker.js
- site/**/*
asar: true
asarUnpack:
- "**/*.node"
win:
target:
- nsis
artifactName: "Open-Battery-Information-${version}-setup-${arch}.${ext}"
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
shortcutName: Open Battery Information
mac:
target:
- dmg
- zip
category: public.app-category.utilities
artifactName: "Open-Battery-Information-${version}-mac-${arch}.${ext}"
linux:
target:
- AppImage
- deb
category: Utility
maintainer: Open Battery Information
artifactName: "Open-Battery-Information-${version}-linux-${arch}.${ext}"
+285
View File
@@ -0,0 +1,285 @@
/* 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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
);
const PICKER_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: #1e1f22; color: #e6e6e6; padding: 20px; }
h1 { font-size: 16px; margin: 0 0 4px; }
p.sub { margin: 0 0 16px; color: #9b9b9b; font-size: 13px; }
.port { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; margin-bottom: 8px; cursor: pointer; }
.port:hover { border-color: #5f9bff; }
.port input { margin: 0; }
.path { font-weight: 600; font-family: ui-monospace, monospace; }
.meta { margin-left: auto; color: #9b9b9b; font-size: 12px; }
.actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
button { padding: 8px 16px; border-radius: 8px; border: 1px solid #333; background: #2a2b2e; color: #e6e6e6; font: inherit; cursor: pointer; }
button.primary { background: #3d7eff; border-color: #3d7eff; }
button.primary:disabled { opacity: 0.5; cursor: default; }
.empty { color: #9b9b9b; padding: 20px 0; text-align: center; }
</style>
</head>
<body>
<h1>Select a serial port</h1>
<p class="sub">Choose the Arduino running the OBI firmware.</p>
<form id="form">__ROWS__</form>
<div class="actions">
<button id="cancel" type="button">Cancel</button>
<button id="connect" class="primary" type="button" disabled>Connect</button>
</div>
<script>
const radios = document.querySelectorAll('input[name="port"]');
const connect = document.getElementById('connect');
const cancel = document.getElementById('cancel');
const form = document.getElementById('form');
form.addEventListener('change', () => { connect.disabled = !document.querySelector('input[name="port"]:checked'); });
connect.addEventListener('click', () => {
const sel = document.querySelector('input[name="port"]:checked');
if (sel) window.picker.select(sel.value);
});
cancel.addEventListener('click', () => window.picker.cancel());
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') window.picker.cancel(); });
</script>
</body>
</html>`;
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) =>
'<label class="port"><input type="radio" name="port" value="' +
escapeHtml(p.path) +
'"><span class="path">' +
escapeHtml(p.path) +
'</span><span class="meta">' +
escapeHtml(
[p.manufacturer, p.serialNumber ? 'S/N ' + p.serialNumber : '']
.filter(Boolean)
.join(' · ')
) +
'</span></label>'
)
.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();
});
+4085
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "open-battery-information-desktop",
"productName": "Open Battery Information",
"version": "1.0.7",
"description": "Desktop build of the Open Battery Information OBI-1 tool — native serial battery diagnosis for Windows, macOS and Linux.",
"main": "main.js",
"license": "GPL-3.0",
"author": "Open Battery Information",
"homepage": "https://openbatteryinformation.github.io/",
"dependencies": {
"serialport": "^12.0.0"
},
"scripts": {
"prepare:site": "node scripts/prepare-site.mjs",
"start": "node scripts/prepare-site.mjs && electron .",
"dist": "node scripts/prepare-site.mjs && electron-builder",
"dist:win": "node scripts/prepare-site.mjs && electron-builder --win",
"dist:mac": "node scripts/prepare-site.mjs && electron-builder --mac",
"dist:linux": "node scripts/prepare-site.mjs && electron-builder --linux"
},
"devDependencies": {
"electron": "^30.0.0",
"electron-builder": "^24.13.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
/* Preload for the serial port picker window. */
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('picker', {
select: (portPath) => ipcRenderer.send('picker:select', portPath),
cancel: () => ipcRenderer.send('picker:cancel'),
});
+27
View File
@@ -0,0 +1,27 @@
/* Electron preload — bridges native serial access to the renderer.
The page's serial-shim.js surfaces this through the navigator.serial API
the OBI code already uses. */
const { contextBridge, ipcRenderer } = require('electron');
function toInfo(info) {
return {
path: info.path,
vendorId: info.vendorId || 0,
productId: info.productId || 0,
manufacturer: info.manufacturer || '',
serialNumber: info.serialNumber || '',
};
}
contextBridge.exposeInMainWorld('serialAPI', {
requestPort: () => ipcRenderer.invoke('serial:requestPort').then(toInfo),
open: (info, baudRate) => ipcRenderer.invoke('serial:open', toInfo(info), baudRate),
close: (info) => ipcRenderer.invoke('serial:close', toInfo(info)),
setSignals: (info, dtr) => ipcRenderer.invoke('serial:setSignals', toInfo(info), dtr),
write: (info, bytes) => ipcRenderer.invoke('serial:write', toInfo(info), bytes),
onData: (cb) => {
const listener = (_e, portPath, data) => cb(portPath, data);
ipcRenderer.on('serial:data', listener);
return () => ipcRenderer.removeListener('serial:data', listener);
},
});
+49
View File
@@ -0,0 +1,49 @@
/* Builds desktop/site: a self-contained copy of just the OBI-1 page.
Only the files obi.html references are copied. The nav is trimmed to the
theme toggle (no links to pages that aren't bundled) and the Web Serial
shim is injected so native serialport works through the OBI code. */
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
const SITE = path.join(__dirname, '..', 'site');
const CSS_FILES = ['style.css', 'tool.css'];
const JS_FILES = ['theme.js', 'reveal.js', 'common.js', 'obi.js'];
await rm(SITE, { recursive: true, force: true });
await mkdir(path.join(SITE, 'css'), { recursive: true });
await mkdir(path.join(SITE, 'js', 'modules'), { recursive: true });
await mkdir(path.join(SITE, 'js', 'interfaces'), { recursive: true });
/* ---- obi.html, transformed for the desktop bundle ---- */
const source = await readFile(path.join(ROOT, 'obi.html'), 'utf8');
let html = source;
html = html.replace(
'<script src="js/theme.js"></script>',
'<script src="js/theme.js"></script>\n <script src="js/serial-shim.js"></script>'
);
html = html.replace(
'<a class="brand" href="index.html">',
'<a class="brand" href="obi.html">'
);
html = html.replace(
/(<div class="nav-links">)([\s\S]*?)(<\/div>)/,
(_m, open, inner, close) => open + inner.replace(/<a [\s\S]*?<\/a>/g, '') + close
);
await writeFile(path.join(SITE, 'obi.html'), html);
/* ---- shared assets ---- */
for (const file of CSS_FILES) {
await cp(path.join(ROOT, 'css', file), path.join(SITE, 'css', file));
}
for (const file of JS_FILES) {
await cp(path.join(ROOT, 'js', file), path.join(SITE, 'js', file));
}
await cp(path.join(ROOT, 'js', 'modules', 'makita_lxt.js'), path.join(SITE, 'js', 'modules', 'makita_lxt.js'));
await cp(path.join(ROOT, 'js', 'interfaces', 'arduino_obi.js'), path.join(SITE, 'js', 'interfaces', 'arduino_obi.js'));
await cp(path.resolve(__dirname, '..', 'serial-shim.js'), path.join(SITE, 'js', 'serial-shim.js'));
console.log(`OBI-only site prepared in ${SITE}`);
+99
View File
@@ -0,0 +1,99 @@
/* Desktop-only Web Serial shim.
In the Electron app, the main process owns the serial port (via the
`serialport` package) and streams bytes over IPC. This file re-exposes
that transport through the same navigator.serial API the OBI web code
uses, so js/common.js and js/interfaces/arduino_obi.js run unchanged. */
(function () {
"use strict";
if (!window.serialAPI) return;
const api = window.serialAPI;
class DesktopSerialPort {
constructor(info) {
this._info = info;
this._open = false;
this._stream = null;
this._controller = null;
this._queue = [];
this._unsubscribe = null;
}
getInfo() {
return {
usbVendorId: this._info.vendorId || 0,
usbProductId: this._info.productId || 0,
};
}
isOpen() {
return this._open;
}
async open(options) {
if (this._open) return;
await api.open(this._info, (options && options.baudRate) || 9600);
this._open = true;
this._unsubscribe = api.onData((portPath, buf) => {
if (portPath !== this._info.path) return;
const data = new Uint8Array(buf);
if (this._controller) this._controller.enqueue(data);
else this._queue.push(data);
});
}
get readable() {
if (!this._open) throw new Error('InvalidStateError: port not open');
if (!this._stream) {
this._stream = new ReadableStream({
start: (controller) => {
this._controller = controller;
for (const chunk of this._queue) controller.enqueue(chunk);
this._queue = [];
},
cancel: () => {
this._controller = null;
},
});
}
return this._stream;
}
get writable() {
if (!this._open) throw new Error('InvalidStateError: port not open');
return new WritableStream({
write: (chunk) =>
api.write(
this._info,
chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
),
});
}
async setSignals(signals) {
await api.setSignals(this._info, !!(signals && signals.dataTerminalReady));
}
async close() {
await api.close(this._info);
if (this._unsubscribe) this._unsubscribe();
this._unsubscribe = null;
this._open = false;
}
async forget() {}
}
Object.defineProperty(navigator, 'serial', {
configurable: true,
enumerable: true,
value: {
getPorts: async () => [],
requestPort: async () => {
const info = await api.requestPort();
return new DesktopSerialPort(info);
},
},
});
})();