/* 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 = '
' + "
Function test" + '' + '' + "
" + "
Reset battery" + '' + '' + "
" + "
" + '
' + "" + "
ParameterValue
" + '
' + '' + '' + "
"; 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 -> */ 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, }; })();