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
+182
View File
@@ -0,0 +1,182 @@
/* Shared helpers for the Open Battery Information web app. */
(function () {
"use strict";
const OBI = (window.OBI = window.OBI || {});
OBI.modules = {};
OBI.interfaces = {};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const hexByte = (b) => (b & 0xff).toString(16).toUpperCase().padStart(2, "0");
const hexDump = (bytes) => Array.from(bytes, hexByte).join(" ");
function debugLine(text, cls) {
const el = document.getElementById("debug-text");
if (!el) return;
const span = document.createElement("span");
if (cls) span.className = cls;
span.textContent = text + "\n";
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
const debug = {
send: (text) => debugLine(text, "dbg-send"),
recv: (text) => debugLine(text, "dbg-recv"),
info: (text) => debugLine(text, "dbg-info"),
error: (text) => debugLine(text, "dbg-error"),
};
/* ---- Simple modal ---- */
function modal(title, message, kind, okLabel) {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML =
'<div class="modal" role="dialog" aria-modal="true">' +
"<h3></h3><p></p><div class='modal-actions'>" +
"<button class='primary'>" + (okLabel || "OK") + "</button>" +
"</div></div>";
overlay.querySelector("h3").textContent = title;
overlay.querySelector("p").textContent = message;
const close = () => {
overlay.remove();
resolve();
};
overlay.querySelector("button").addEventListener("click", close);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
document.body.appendChild(overlay);
});
}
/* ---- ConnectionError ---- */
class ConnectionError extends Error {}
/* ---- Web Serial connection wrapper ----
Maintains a continuous read loop and a byte queue so that
"read exactly n bytes" works reliably despite chunking. */
class SerialConnection {
constructor(port) {
this.port = port;
this.queue = [];
this.queueLength = 0;
this.reader = null;
this.closed = false;
this.portName = port.getInfo
? port.getInfo().usbVendorId + ":" + port.getInfo().usbProductId
: "unknown";
}
async start() {
this.reader = this.port.readable.getReader();
this.readLoop = (async () => {
while (!this.closed) {
try {
const { value, done } = await this.reader.read();
if (done) break;
this.queue.push(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
this.queueLength += value.length;
} catch (err) {
if (!this.closed) throw err;
break;
}
}
})().catch((err) => {
if (!this.closed) OBI.debug && OBI.debug.error("Serial read loop error: " + err.message);
});
}
async read(n, timeoutMs) {
const start = Date.now();
while (this.queueLength < n) {
if (Date.now() - start > timeoutMs) {
const partial = this.take(Math.min(this.queueLength, n));
return partial;
}
await sleep(10);
}
return this.take(n);
}
take(n) {
const out = new Uint8Array(n);
let offset = 0;
while (offset < n) {
const chunk = this.queue[0];
const take = Math.min(chunk.length, n - offset);
out.set(chunk.subarray(0, take), offset);
offset += take;
if (take === chunk.length) {
this.queue.shift();
} else {
this.queue[0] = chunk.subarray(take);
}
}
this.queueLength -= n;
return out;
}
clear() {
this.queue = [];
this.queueLength = 0;
}
async write(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const writer = this.port.writable.getWriter();
try {
await writer.write(data);
} finally {
writer.releaseLock();
}
}
async close() {
this.closed = true;
try {
if (this.reader) {
await this.reader.cancel();
this.reader.releaseLock();
}
await this.port.close();
} catch (err) {
/* already closed */
}
}
}
/* ---- Toast notification ---- */
let toastContainer = null;
function toast(message, kind) {
if (!toastContainer) {
toastContainer = document.createElement("div");
toastContainer.className = "toast-container";
document.body.appendChild(toastContainer);
}
const el = document.createElement("div");
el.className = "toast" + (kind ? " toast-" + kind : "");
el.textContent = message;
toastContainer.appendChild(el);
setTimeout(() => {
el.classList.add("toast-out");
el.addEventListener("transitionend", () => el.remove(), { once: true });
}, 3000);
setTimeout(() => el.remove(), 3800);
}
OBI.util = {
sleep,
hexByte,
hexDump,
debug,
modal,
toast,
ConnectionError,
SerialConnection,
};
})();
+194
View File
@@ -0,0 +1,194 @@
/* Arduino OBI interface, ported from the Python desktop app (interfaces/arduino_obi.py).
Uses the Web Serial API instead of pyserial. The widget renders the connection
details (port + firmware version); the host page owns the Connect button and
calls connect()/disconnect(). */
(function () {
"use strict";
const {
debug,
hexDump,
modal,
toast,
sleep,
ConnectionError,
SerialConnection,
} = OBI.util;
const INTERFACE_VERSION_CMD = [0x01, 0x00, 0x03, 0x01];
function createWidget(frame, obi, callbacks) {
const onConnect = (callbacks && callbacks.onConnect) || function () {};
frame.innerHTML =
'<div class="iface-details">' +
'<div class="iface-detail"><span class="label">Serial port</span>' +
'<div class="value" id="port-name">No port connected</div></div>' +
"</div>";
const portNameEl = frame.querySelector("#port-name");
const versionLabel = document.getElementById("interface-version");
let conn = null; /* SerialConnection */
let connected = false;
const setConnected = (v) => {
connected = v;
onConnect(v);
};
function portName(port) {
const info = port.getInfo ? port.getInfo() : {};
const parts = [];
if (info.usbVendorId) parts.push("VID " + info.usbVendorId.toString(16));
if (info.usbProductId) parts.push("PID " + info.usbProductId.toString(16));
return parts.length ? parts.join(" ") : "serial device";
}
async function connect(resetBoard) {
if (!("serial" in navigator)) {
modal(
"Unsupported Browser",
"Web Serial is not available in this browser.\n\n" +
"Use Chrome, Edge, Opera or Firefox 151+ on desktop over HTTPS (or localhost)."
);
return;
}
let port;
try {
port = await navigator.serial.requestPort();
} catch (e) {
/* user cancelled */
return;
}
try {
await port.open({ baudRate: 9600, bufferSize: 255 });
if (resetBoard) {
/* Classic Arduino reset: pulse DTR to reboot the board. */
await port.setSignals({ dataTerminalReady: false });
await sleep(100);
await port.setSignals({ dataTerminalReady: true });
}
/* Wait for the Uno to boot out of the (opti)bootloader and for
the OBI firmware to initialise its serial port. */
await sleep(2000);
conn = new SerialConnection(port);
await conn.start();
portNameEl.textContent = portName(port) + " (9600 baud)";
debug.info("Opened serial port: " + portNameEl.textContent);
connected = true; /* allow request() while we read the version */
const version = await getVersion();
versionLabel.textContent = "Firmware version: " + version;
debug.info("Arduino OBI interface version " + version);
onConnect(true);
toast("Connected to Arduino OBI (firmware " + version + ")");
} catch (e) {
try {
await port.close();
} catch (err) {
/* ignore */
}
setConnected(false);
portNameEl.textContent = "No port connected";
versionLabel.textContent = "Firmware version: -";
debug.error("Error opening serial port: " + e.message);
modal(
"Connection Error",
"Could not open the serial port:\n\n" +
e.message +
"\n\nCheck that the port is not in use by another application."
);
}
}
async function disconnect() {
if (conn) {
await conn.close();
conn = null;
}
setConnected(false);
versionLabel.textContent = "Firmware version: -";
portNameEl.textContent = "No port connected";
debug.info("Closed serial port");
}
function isConnected() {
return connected;
}
async function getVersion() {
const response = await request(INTERFACE_VERSION_CMD, 5);
return Array.from(response.slice(2)).join(".");
}
async function request(requestBytes, maxAttempts = 5) {
if (!connected || !conn) {
throw new ConnectionError(
"Serial port is not open. Please connect to the Arduino first."
);
}
const expectedLength = requestBytes[2] + 2;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
debug.send(">> " + hexDump(requestBytes.slice(3)));
try {
conn.clear();
await conn.write(requestBytes);
if (requestBytes[2] === 0) {
return undefined;
}
const response = await conn.read(expectedLength, 3000);
debug.recv("<< " + hexDump(response.slice(2)));
if (response.length === 0) {
throw new Error(
"No response received from Arduino (expected " +
expectedLength +
" bytes). Check that a battery is connected."
);
}
if (response.length !== expectedLength) {
throw new Error(
"Incomplete response: received " +
response.length +
" bytes, expected " +
expectedLength +
". The battery may not be seated correctly."
);
}
if (Array.from(response.slice(2)).every((b) => b === 0xff)) {
throw new Error(
"Invalid response: all bytes are 0xFF. The battery may not be communicating correctly."
);
}
return response;
} catch (e) {
if (e instanceof ConnectionError) throw e;
debug.error(
"Attempt " + attempt + "/" + maxAttempts + " failed: " + e.message
);
}
}
throw new ConnectionError(
"Failed to get a valid response after " +
maxAttempts +
" attempts. Ensure the Arduino is connected and a battery is inserted."
);
}
return {
connect,
disconnect,
isConnected,
request,
};
}
OBI.interfaces["arduino_obi"] = {
getDisplayName: () => "Arduino OBI",
createWidget,
};
})();
+423
View File
@@ -0,0 +1,423 @@
/* Makita LXT module, ported from the Python desktop app (modules/makita_lxt.py).
The widget renders the results table and function-test buttons; the host
page drives the reading via readBattery(). */
(function () {
"use strict";
const { debug, hexDump, modal, toast, ConnectionError } = OBI.util;
/* Command definitions (start, len, rsp_len, cmd, data...) */
const MODEL_CMD = [0x01, 0x02, 0x10, 0xcc, 0xdc, 0x0c];
const READ_DATA_REQUEST = [0x01, 0x04, 0x1d, 0xcc, 0xd7, 0x00, 0x00, 0xff];
const TESTMODE_CMD = [0x01, 0x03, 0x09, 0x33, 0xd9, 0x96, 0xa5];
const LEDS_ON_CMD = [0x01, 0x02, 0x09, 0x33, 0xda, 0x31];
const LEDS_OFF_CMD = [0x01, 0x02, 0x09, 0x33, 0xda, 0x34];
const RESET_ERROR_CMD = [0x01, 0x02, 0x09, 0x33, 0xda, 0x04];
const READ_MSG_CMD = [0x01, 0x02, 0x28, 0x33, 0xaa, 0x00];
const CLEAR_CMD = [0x01, 0x02, 0x00, 0xcc, 0xf0, 0x00];
/* Commands specific to the F0513 version */
const F0513_VCELL_1_CMD = [0x01, 0x01, 0x02, 0xcc, 0x31];
const F0513_VCELL_2_CMD = [0x01, 0x01, 0x02, 0xcc, 0x32];
const F0513_VCELL_3_CMD = [0x01, 0x01, 0x02, 0xcc, 0x33];
const F0513_VCELL_4_CMD = [0x01, 0x01, 0x02, 0xcc, 0x34];
const F0513_VCELL_5_CMD = [0x01, 0x01, 0x02, 0xcc, 0x35];
const F0513_TEMP_CMD = [0x01, 0x01, 0x02, 0xcc, 0x52];
const F0513_MODEL_CMD = [0x01, 0x00, 0x02, 0x31];
const F0513_VERSION_CMD = [0x01, 0x00, 0x02, 0x32];
const F0513_TESTMODE_CMD = [0x01, 0x01, 0x00, 0xcc, 0x99];
const initialData = {
"Model": "",
"Charge count*": "",
"State": "",
"Status code": "",
"Pack Voltage": "",
"Cell 1 Voltage": "",
"Cell 2 Voltage": "",
"Cell 3 Voltage": "",
"Cell 4 Voltage": "",
"Cell 5 Voltage": "",
"Cell Voltage Difference": "",
"Temperature Sensor 1": "",
"Temperature Sensor 2": "",
"ROM ID": "",
"Manufacturing date": "",
"Battery message": "",
"Capacity": "",
"Battery type": "",
};
const nibbleSwap = (byte) =>
(((byte & 0xf0) >> 4) | ((byte & 0x0f) << 4)) & 0xff;
const u16le = (bytes, offset) =>
(bytes[offset] | (bytes[offset + 1] << 8)) & 0xffff;
function createWidget(container) {
container.innerHTML =
'<div class="test-grid">' +
"<fieldset class='test-group'><legend>Function test</legend>" +
'<button data-action="led-on" disabled>LED test ON</button>' +
'<button data-action="led-off" disabled>LED test OFF</button>' +
"</fieldset>" +
"<fieldset class='test-group'><legend>Reset battery</legend>" +
'<button data-action="reset-errors" disabled>Clear errors</button>' +
'<button data-action="reset-message" disabled>Reset battery message</button>' +
"</fieldset>" +
"</div>" +
'<div class="table-wrap"><table id="data-table">' +
"<thead><tr><th>Parameter</th><th>Value</th></tr></thead>" +
"<tbody></tbody></table></div>" +
'<div class="table-actions">' +
'<button data-action="copy">Copy</button>' +
'<button data-action="clear">Clear</button>' +
"</div>";
const state = {
interface: null,
commandVersion: null,
batteryPresent: false,
busy: false,
};
const buttons = {
"led-on": container.querySelector('[data-action="led-on"]'),
"led-off": container.querySelector('[data-action="led-off"]'),
"reset-errors": container.querySelector('[data-action="reset-errors"]'),
"reset-message": container.querySelector('[data-action="reset-message"]'),
};
const enableAllButtons = () => {
for (const key in buttons) buttons[key].disabled = false;
};
const tbody = container.querySelector("tbody");
const rows = {}; /* parameter -> <tr> */
function insertBatteryData(data) {
for (const [parameter, value] of Object.entries(data)) {
let tr = rows[parameter];
if (tr) {
tr.querySelector("td.value").textContent = String(value);
} else {
tr = document.createElement("tr");
const tdParam = document.createElement("td");
tdParam.textContent = parameter;
const tdValue = document.createElement("td");
tdValue.className = "value";
tdValue.textContent = String(value);
tr.appendChild(tdParam);
tr.appendChild(tdValue);
tr.addEventListener("click", () => tr.classList.toggle("selected"));
tbody.appendChild(tr);
rows[parameter] = tr;
}
}
}
const getInterface = () => {
if (!state.interface) {
modal(
"Error",
"No interface selected. Please select and connect an interface from the sidebar."
);
return null;
}
return state.interface;
};
/* ---- model identification ---- */
async function getModel() {
const response = await state.interface.request(MODEL_CMD);
return String.fromCharCode(...response.slice(2, 9));
}
async function getF0513Model() {
const response = await state.interface.request(F0513_MODEL_CMD);
await state.interface.request(CLEAR_CMD);
state.commandVersion = "F0513";
modal("Limited", "This model only supports diagnostics");
return (
"BL" +
response[2].toString(16).toUpperCase() +
response[3].toString(16).toUpperCase()
);
}
/* ---- dynamic data ---- */
async function readDynamicData() {
let batteryData;
if (state.commandVersion === "F0513") {
await state.interface.request(CLEAR_CMD);
await state.interface.request(CLEAR_CMD);
const cellCmds = [
F0513_VCELL_1_CMD,
F0513_VCELL_2_CMD,
F0513_VCELL_3_CMD,
F0513_VCELL_4_CMD,
F0513_VCELL_5_CMD,
];
const responses = [];
for (const cmd of cellCmds) {
responses.push(await state.interface.request(cmd));
}
const temp = await state.interface.request(F0513_TEMP_CMD);
const voltages = responses.map((r) => u16le(r, 2) / 1000);
const vPack = voltages.reduce((a, b) => a + b, 0);
const vDiff = Math.round((Math.max(...voltages) - Math.min(...voltages)) * 100) / 100;
const tCell = u16le(temp, 2) / 100;
batteryData = {
"Pack Voltage": vPack,
"Cell 1 Voltage": voltages[0],
"Cell 2 Voltage": voltages[1],
"Cell 3 Voltage": voltages[2],
"Cell 4 Voltage": voltages[3],
"Cell 5 Voltage": voltages[4],
"Cell Voltage Difference": vDiff,
"Temperature Sensor 1": tCell,
"Temperature Sensor 2": "",
};
} else {
const response = await state.interface.request(READ_DATA_REQUEST);
const vPack = u16le(response, 2) / 1000;
const voltages = [4, 6, 8, 10, 12].map((o) => u16le(response, o) / 1000);
const vDiff = Math.round((Math.max(...voltages) - Math.min(...voltages)) * 100) / 100;
const tCell = u16le(response, 16) / 100;
const tMosfet = u16le(response, 18) / 100;
batteryData = {
"Pack Voltage": vPack,
"Cell 1 Voltage": voltages[0],
"Cell 2 Voltage": voltages[1],
"Cell 3 Voltage": voltages[2],
"Cell 4 Voltage": voltages[3],
"Cell 5 Voltage": voltages[4],
"Cell Voltage Difference": vDiff,
"Temperature Sensor 1": tCell,
"Temperature Sensor 2": tMosfet,
};
}
return batteryData;
}
/* ---- combined read: static info + model + dynamic data ---- */
async function readBattery() {
const iface = getInterface();
if (!iface) return false;
if (state.busy) return false;
state.busy = true;
try {
/* static info */
let response;
try {
response = await iface.request(READ_MSG_CMD);
} catch (e) {
if (e instanceof ConnectionError) {
modal(
"Connection Error",
"Could not communicate with the battery:\n\n" + e.message
);
} else {
modal(
"Data Error",
"Received an unexpected response while reading battery info:\n\n" +
e.name + ": " + e.message
);
}
return false;
}
const romId = hexDump(response.slice(2, 10));
const rawMsg = hexDump(response.slice(10, 42));
const swapped = [nibbleSwap(response[36]), nibbleSwap(response[37])];
const chargeCount = (((swapped[0] << 8) | swapped[1]) & 0x0fff) >>> 0;
const lockNibble = response[30] & 0x0f;
const errorByte = response[29];
const lockStatus = lockNibble > 0 ? "LOCKED" : "UNLOCKED";
const pad2 = (b) => (b & 0xff).toString(10).padStart(2, "0");
const hex2 = (b) => (b & 0xff).toString(16).toUpperCase().padStart(2, "0");
insertBatteryData({
"ROM ID": romId,
"Battery message": rawMsg,
"Charge count*": chargeCount,
"State": lockStatus,
"Status code": hex2(errorByte),
"Manufacturing date":
pad2(response[4]) + "/" + pad2(response[3]) + "/20" + pad2(response[2]),
"Capacity": (nibbleSwap(response[26]) / 10).toFixed(1) + "Ah",
"Battery type": nibbleSwap(response[21]),
});
state.batteryPresent = true;
/* model identification */
state.commandVersion = null;
let model = null;
let lastException = null;
for (const command of [getModel, getF0513Model]) {
try {
model = await command();
break;
} catch (e) {
lastException = e;
}
}
if (model === null) {
modal(
"Unsupported Battery",
"Battery is present but the model is not supported.\n\nLast error: " +
(lastException && lastException.message)
);
return false;
}
insertBatteryData({ Model: model });
/* dynamic data */
try {
const batteryData = await readDynamicData();
insertBatteryData(batteryData);
} catch (e) {
if (e instanceof ConnectionError) {
modal(
"Connection Error",
"Lost communication while reading battery data:\n\n" + e.message
);
} else {
modal(
"Data Error",
"Received an unexpected response while reading battery data:\n\n" +
e.name + ": " + e.message
);
}
return false;
}
enableAllButtons();
return true;
} finally {
state.busy = false;
}
}
/* ---- function test ---- */
async function onAllLedsOnClick() {
const iface = getInterface();
if (!iface) return;
try {
await iface.request(TESTMODE_CMD);
await iface.request(LEDS_ON_CMD);
toast("LED test on — check the battery LEDs");
} catch (e) {
modal(
"Connection Error",
"Lost communication while turning LEDs on:\n\n" + e.message
);
}
}
async function onAllLedsOffClick() {
const iface = getInterface();
if (!iface) return;
try {
if (state.commandVersion === "F0513") {
await iface.request(F0513_TESTMODE_CMD);
} else {
await iface.request(TESTMODE_CMD);
}
await iface.request(LEDS_OFF_CMD);
toast("LED test off");
} catch (e) {
modal(
"Connection Error",
"Lost communication while turning LEDs off:\n\n" + e.message
);
}
}
/* ---- reset ---- */
async function onResetErrorsClick() {
const iface = getInterface();
if (!iface) return;
try {
await iface.request(TESTMODE_CMD);
await iface.request(RESET_ERROR_CMD);
toast("Battery errors cleared");
} catch (e) {
modal(
"Connection Error",
"Lost communication while resetting errors:\n\n" + e.message
);
}
}
async function onResetMessageClick() {
const iface = getInterface();
if (!iface) return;
await modal(
"Not Implemented",
"This feature is currently under development."
);
}
/* ---- table actions ---- */
function copyToClipboard() {
const selected = Array.from(tbody.querySelectorAll("tr.selected"));
if (!selected.length) {
modal("No Selection", "No rows selected to copy!");
return;
}
const rowsText = selected
.map((tr) => {
const cells = tr.querySelectorAll("td");
return Array.from(cells)
.map((td) => td.textContent)
.join("\t");
})
.join("\n");
navigator.clipboard
.writeText(rowsText)
.then(() => toast("Rows copied to clipboard"))
.catch(() => modal("Error", "Could not access the clipboard."));
}
function clearData() {
insertBatteryData(initialData);
}
container
.querySelector('[data-action="led-on"]')
.addEventListener("click", onAllLedsOnClick);
container
.querySelector('[data-action="led-off"]')
.addEventListener("click", onAllLedsOffClick);
container
.querySelector('[data-action="reset-errors"]')
.addEventListener("click", onResetErrorsClick);
container
.querySelector('[data-action="reset-message"]')
.addEventListener("click", onResetMessageClick);
container
.querySelector('[data-action="copy"]')
.addEventListener("click", copyToClipboard);
container
.querySelector('[data-action="clear"]')
.addEventListener("click", clearData);
insertBatteryData(initialData);
return {
setInterface(iface) {
state.interface = iface;
},
readBattery,
destroy() {
container.innerHTML = "";
},
};
}
OBI.modules["makita_lxt"] = {
getDisplayName: () => "Makita LXT",
createWidget,
};
})();
+191
View File
@@ -0,0 +1,191 @@
/* Open Battery Information web app shell — wizard flow.
Guides the user through: 1. Connect 2. Read battery 3. Results. */
(function () {
"use strict";
const { debug, toast } = OBI.util;
const $ = (id) => document.getElementById(id);
const els = {
moduleSelect: $("module-select"),
interfaceSelect: $("interface-select"),
resetCheck: $("reset-check"),
connectBtn: $("connect-btn"),
ifaceState: $("iface-state"),
ifaceWidget: $("iface-widget"),
serialWarning: $("serial-warning"),
readBtn: $("read-btn"),
readHint: $("read-hint"),
resultsSummary: $("results-summary"),
readAgainBtn: $("read-again-btn"),
moduleContent: $("module-content"),
};
const stepItems = { 1: $("step-1"), 2: $("step-2"), 3: $("step-3") };
const panels = { 1: $("panel-connect"), 2: $("panel-read"), 3: $("panel-results") };
let iface = null; /* interface controller */
let moduleWidget = null; /* module controller */
let connected = false;
/* ---------- stepper ---------- */
function goToStep(n) {
for (const [k, el] of Object.entries(panels)) {
el.classList.toggle("active", Number(k) === n);
}
for (const [k, el] of Object.entries(stepItems)) {
el.classList.toggle("active", Number(k) === n);
el.classList.toggle("done", Number(k) < n);
}
}
function markStepDone(n) {
const el = stepItems[n];
if (el) {
el.classList.remove("active");
el.classList.add("done");
}
}
function resetSteps() {
for (const el of Object.values(stepItems)) {
el.classList.remove("active", "done");
}
for (const el of Object.values(panels)) {
el.classList.remove("active");
}
stepItems[1].classList.add("active");
panels[1].classList.add("active");
}
/* ---------- interface ---------- */
function handleConnectChange(v) {
connected = v;
updateConnectUi();
if (v) {
markStepDone(1);
goToStep(2);
} else {
resetSteps();
}
}
function updateConnectUi() {
els.connectBtn.textContent = connected ? "Disconnect" : "Connect";
els.connectBtn.classList.toggle("primary", !connected);
els.ifaceState.textContent = connected ? "Connected" : "Not connected";
els.ifaceState.className = "badge " + (connected ? "ok" : "");
els.readBtn.disabled = !connected;
}
function setupInterface() {
const key = els.interfaceSelect.value;
const ifaceDef = OBI.interfaces[key];
if (!ifaceDef) return;
if (iface) {
iface.disconnect && iface.disconnect();
iface = null;
}
els.ifaceWidget.innerHTML = "";
iface = ifaceDef.createWidget(els.ifaceWidget, OBI, {
onConnect: handleConnectChange,
});
connected = iface.isConnected();
updateConnectUi();
}
/* ---------- module ---------- */
function setupModule() {
const key = els.moduleSelect.value;
const moduleDef = OBI.modules[key];
if (!moduleDef) return;
if (moduleWidget) {
moduleWidget.destroy();
moduleWidget = null;
}
els.moduleContent.innerHTML = "";
moduleWidget = moduleDef.createWidget(els.moduleContent);
if (iface) moduleWidget.setInterface(iface);
}
/* ---------- reading ---------- */
async function doRead() {
if (!connected || !iface || !moduleWidget) return;
els.readBtn.disabled = true;
els.readBtn.textContent = "Reading…";
els.readHint.textContent = "Talking to the battery — this takes a few seconds…";
const ok = await moduleWidget.readBattery();
if (ok) {
toast("Battery read successfully");
els.readHint.textContent =
"Battery read successfully. Open the Results step or press Read again to refresh.";
markStepDone(2);
els.resultsSummary.textContent =
"Battery read — details below. Select rows and use Copy, or Read again to refresh.";
goToStep(3);
} else {
els.readHint.textContent =
"Could not read the battery. Check it is seated correctly and connected, then try again.";
}
els.readBtn.textContent = "Read battery";
els.readBtn.disabled = !connected;
}
/* ---------- wiring ---------- */
els.connectBtn.addEventListener("click", async () => {
if (!iface) return;
if (connected) {
await iface.disconnect();
} else {
await iface.connect(els.resetCheck.checked);
}
});
els.readBtn.addEventListener("click", doRead);
els.readAgainBtn.addEventListener("click", doRead);
els.moduleSelect.addEventListener("change", () => {
setupModule();
resetSteps();
});
els.interfaceSelect.addEventListener("change", () => {
setupInterface();
setupModule();
resetSteps();
});
/* ---------- init ---------- */
function init() {
if (!("serial" in navigator)) {
debug.error(
"Web Serial is not available in this browser. Use Chrome, Edge, Opera or Firefox 151+ on desktop over HTTPS (or localhost), or the desktop app."
);
if (els.serialWarning) els.serialWarning.hidden = false;
els.connectBtn.disabled = true;
}
const names = Object.keys(OBI.modules).sort();
for (const name of names) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = OBI.modules[name].getDisplayName();
els.moduleSelect.appendChild(opt);
}
if (names.length) els.moduleSelect.value = names[0];
const ifaceNames = Object.keys(OBI.interfaces).sort();
for (const name of ifaceNames) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = OBI.interfaces[name].getDisplayName();
els.interfaceSelect.appendChild(opt);
}
if (ifaceNames.length) els.interfaceSelect.value = ifaceNames[0];
setupInterface();
setupModule();
resetSteps();
}
document.addEventListener("DOMContentLoaded", init);
})();
+49
View File
@@ -0,0 +1,49 @@
/* Reveal-on-scroll entrance animations (ui-flashy branch, exploratory).
Adds a staggered fade/slide-up to page blocks. Everything stays visible
if IntersectionObserver is missing or the user prefers reduced motion. */
(function () {
var SELECTORS = [
'.hero',
'.feature-card',
'.step',
'.card',
'.warning',
'.faq',
'.wizard-card',
'.flash-card',
'.test-group',
'.tool-header',
'.uploader-header'
].join(', ');
if (!('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var els = document.querySelectorAll(SELECTORS);
if (!els.length) return;
document.documentElement.classList.add('js-reveal');
els.forEach(function (el) { el.classList.add('reveal'); });
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
var el = entry.target;
var delay = 0;
if (el.dataset.revealDelay) {
delay = parseInt(el.dataset.revealDelay, 10) || 0;
} else {
var parent = el.parentElement;
if (parent) {
var idx = Array.prototype.indexOf.call(parent.children, el);
delay = Math.min(idx * 70, 490);
}
}
if (delay) el.style.transitionDelay = delay + 'ms';
el.classList.add('revealed');
io.unobserve(el);
});
}, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' });
els.forEach(function (el) { io.observe(el); });
})();
+69
View File
@@ -0,0 +1,69 @@
/* Light/dark theme. Applies <html data-theme="dark|light">, persists the
choice in localStorage and falls back to the OS preference. */
(function () {
var KEY = "obi-theme";
function initialTheme() {
try {
var saved = localStorage.getItem(KEY);
if (saved === "dark" || saved === "light") return saved;
} catch (e) {
/* storage unavailable */
}
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) {
return "light";
}
return "dark";
}
function apply(theme) {
document.documentElement.setAttribute("data-theme", theme);
var btn = document.getElementById("theme-toggle");
if (btn) {
var next = theme === "light" ? "dark" : "light";
btn.setAttribute("aria-label", "Switch to " + next + " mode");
btn.setAttribute("title", "Switch to " + next + " mode");
}
}
apply(initialTheme());
document.addEventListener("DOMContentLoaded", function () {
var theme = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
apply(theme);
var btn = document.getElementById("theme-toggle");
if (!btn) return;
btn.addEventListener("click", function () {
var next = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
try {
localStorage.setItem(KEY, next);
} catch (e) {
/* ignore */
}
apply(next);
});
});
})();
/* Responsive navbar: toggle the burger menu on small screens. */
(function () {
document.addEventListener("DOMContentLoaded", function () {
var navbar = document.querySelector(".navbar");
var toggle = document.getElementById("nav-toggle");
if (!navbar || !toggle) return;
toggle.addEventListener("click", function () {
var open = navbar.classList.toggle("nav-open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? "Close navigation menu" : "Open navigation menu");
});
navbar.addEventListener("click", function (e) {
if (!navbar.classList.contains("nav-open")) return;
var link = e.target.closest && e.target.closest("a");
if (link) {
navbar.classList.remove("nav-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Open navigation menu");
}
});
});
})();