183 lines
5.1 KiB
JavaScript
183 lines
5.1 KiB
JavaScript
|
|
/* 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,
|
||
|
|
};
|
||
|
|
})();
|