lerobot/glove_visualizer
5
1// Homunculus / LeRobot PR #1713: newline-delimited, 16 whitespace-separated2// integers at 115200 baud. No writes to the device are needed.3export const CHANNEL_NAMES = ["thumb_cmc", "thumb_mcp", "thumb_pip", "thumb_dip",4 ...["index", "middle", "ring", "pinky"].flatMap(f => [`${f}_mcp_abduction`, `${f}_mcp_flexion`, `${f}_dip`])];5 6export class FrameParser {7 constructor(onFrame, onInvalid = () => {}) { this.onFrame = onFrame; this.onInvalid = onInvalid; this.reset(); }8 reset() { this.buffer = ""; this.discarding = false; this.decoder = new TextDecoder(); }9 push(chunk) {10 const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true });11 for (const character of text) {12 if (character === "\n") {13 if (!this.discarding && this.buffer.trim()) {14 const fields = this.buffer.trim().split(/\s+/);15 if (fields.length === 16 && fields.every(s => /^\d{1,4}$/.test(s) && Number(s) <= 4096)) this.onFrame(fields.map(Number));16 else this.onInvalid();17 }18 this.buffer = ""; this.discarding = false;19 } else if (!this.discarding) {20 if (this.buffer.length >= 1024) { this.buffer = ""; this.discarding = true; this.onInvalid(); }21 else this.buffer += character;22 }23 }24 }25}26 27export class GloveSerial {28 constructor({ onFrame, onState = () => {}, onInvalid, serial = globalThis.navigator?.serial }) {29 this.serial = serial; this.onState = onState; this.parser = new FrameParser(onFrame, onInvalid);30 this.state = "disconnected"; this.port = null; this.reader = null; this.task = null; this.generation = 0;31 }32 emit(state, message = state) { this.state = state; this.onState(state, message); }33 async connect(baudRate = 115200) {34 if (this.state !== "disconnected") throw new Error("Serial connection is already active");35 if (!this.serial) throw new Error("Web Serial is unavailable. Open localhost or HTTPS in desktop Chrome/Edge.");36 if (!Number.isInteger(baudRate) || baudRate <= 0) throw new Error("Invalid baud rate");37 const generation = ++this.generation;38 this.emit("connecting", "Select the glove USB port…");39 let candidate;40 try {41 candidate = await this.serial.requestPort();42 if (generation !== this.generation) return;43 await candidate.open({ baudRate });44 if (generation !== this.generation) { await candidate.close(); return; }45 if (!candidate.readable) throw new Error("The selected port is not readable");46 this.port = candidate; this.parser.reset();47 this.emit("connected", "Connected — waiting for a complete frame");48 this.task = this.pump(candidate, generation);49 } catch (error) {50 if (candidate && !this.port) { try { await candidate.close(); } catch {} }51 if (generation === this.generation) this.emit("disconnected", error.name === "NotFoundError" ? "Connection cancelled" : error.message);52 }53 }54 async pump(port, generation) {55 let message = "Disconnected";56 try {57 this.reader = port.readable.getReader();58 while (generation === this.generation) {59 const { value, done } = await this.reader.read();60 if (done) break;61 if (value && generation === this.generation) this.parser.push(value);62 }63 } catch (error) { message = `Serial stopped: ${error.message}`; }64 finally {65 this.reader?.releaseLock(); this.reader = null;66 try { await port.close(); } catch (error) { message += ` (${error.message})`; }67 this.port = null; this.parser.reset(); this.task = null;68 this.emit("disconnected", message);69 }70 }71 async disconnect() {72 ++this.generation;73 if (!this.task) { this.emit("disconnected", "Disconnected"); return; }74 this.emit("disconnecting", "Disconnecting…");75 try { await this.reader?.cancel(); } catch {}76 await this.task;77 }78}79 