glove_visualizer / src /serial.js
nepyope's picture
Homunculus glove viewer
af93715 verified
Raw History Blame Contribute Delete
3.86 kB
// Homunculus / LeRobot PR #1713: newline-delimited, 16 whitespace-separated
// integers at 115200 baud. No writes to the device are needed.
export const CHANNEL_NAMES = ["thumb_cmc", "thumb_mcp", "thumb_pip", "thumb_dip",
...["index", "middle", "ring", "pinky"].flatMap(f => [`${f}_mcp_abduction`, `${f}_mcp_flexion`, `${f}_dip`])];
export class FrameParser {
constructor(onFrame, onInvalid = () => {}) { this.onFrame = onFrame; this.onInvalid = onInvalid; this.reset(); }
reset() { this.buffer = ""; this.discarding = false; this.decoder = new TextDecoder(); }
push(chunk) {
const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true });
for (const character of text) {
if (character === "\n") {
if (!this.discarding && this.buffer.trim()) {
const fields = this.buffer.trim().split(/\s+/);
if (fields.length === 16 && fields.every(s => /^\d{1,4}$/.test(s) && Number(s) <= 4096)) this.onFrame(fields.map(Number));
else this.onInvalid();
}
this.buffer = ""; this.discarding = false;
} else if (!this.discarding) {
if (this.buffer.length >= 1024) { this.buffer = ""; this.discarding = true; this.onInvalid(); }
else this.buffer += character;
}
}
}
}
export class GloveSerial {
constructor({ onFrame, onState = () => {}, onInvalid, serial = globalThis.navigator?.serial }) {
this.serial = serial; this.onState = onState; this.parser = new FrameParser(onFrame, onInvalid);
this.state = "disconnected"; this.port = null; this.reader = null; this.task = null; this.generation = 0;
}
emit(state, message = state) { this.state = state; this.onState(state, message); }
async connect(baudRate = 115200) {
if (this.state !== "disconnected") throw new Error("Serial connection is already active");
if (!this.serial) throw new Error("Web Serial is unavailable. Open localhost or HTTPS in desktop Chrome/Edge.");
if (!Number.isInteger(baudRate) || baudRate <= 0) throw new Error("Invalid baud rate");
const generation = ++this.generation;
this.emit("connecting", "Select the glove USB port…");
let candidate;
try {
candidate = await this.serial.requestPort();
if (generation !== this.generation) return;
await candidate.open({ baudRate });
if (generation !== this.generation) { await candidate.close(); return; }
if (!candidate.readable) throw new Error("The selected port is not readable");
this.port = candidate; this.parser.reset();
this.emit("connected", "Connected — waiting for a complete frame");
this.task = this.pump(candidate, generation);
} catch (error) {
if (candidate && !this.port) { try { await candidate.close(); } catch {} }
if (generation === this.generation) this.emit("disconnected", error.name === "NotFoundError" ? "Connection cancelled" : error.message);
}
}
async pump(port, generation) {
let message = "Disconnected";
try {
this.reader = port.readable.getReader();
while (generation === this.generation) {
const { value, done } = await this.reader.read();
if (done) break;
if (value && generation === this.generation) this.parser.push(value);
}
} catch (error) { message = `Serial stopped: ${error.message}`; }
finally {
this.reader?.releaseLock(); this.reader = null;
try { await port.close(); } catch (error) { message += ` (${error.message})`; }
this.port = null; this.parser.reset(); this.task = null;
this.emit("disconnected", message);
}
}
async disconnect() {
++this.generation;
if (!this.task) { this.emit("disconnected", "Disconnected"); return; }
this.emit("disconnecting", "Disconnecting…");
try { await this.reader?.cancel(); } catch {}
await this.task;
}
}