Initial commit
This commit is contained in:
+182
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user