const SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; const RX_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; const TX_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); let device = null; let server = null; let rxCharacteristic = null; let txCharacteristic = null; let lineBuffer = ''; let commandQueue = Promise.resolve(); const lineListeners = new Set(); function emitLine(line) { for (const listener of lineListeners) { listener(line); } } function handleNotification(event) { const dataView = event.target.value; const chunk = decoder.decode(dataView, { stream: true }); lineBuffer += chunk; while (true) { const delimiterIndex = lineBuffer.indexOf('\r\n'); if (delimiterIndex < 0) { break; } const line = lineBuffer.slice(0, delimiterIndex); lineBuffer = lineBuffer.slice(delimiterIndex + 2); emitLine(line); } } function handleDisconnected() { server = null; rxCharacteristic = null; if (txCharacteristic) { txCharacteristic.removeEventListener('characteristicvaluechanged', handleNotification); } txCharacteristic = null; } async function writeToRxCharacteristic(payloadBytes) { if (!rxCharacteristic) { throw new Error('EggDuino is not connected'); } if (typeof rxCharacteristic.writeValueWithoutResponse === 'function') { await rxCharacteristic.writeValueWithoutResponse(payloadBytes); return; } if (typeof rxCharacteristic.writeValueWithResponse === 'function') { await rxCharacteristic.writeValueWithResponse(payloadBytes); return; } await rxCharacteristic.writeValue(payloadBytes); } function normalizeCommand(command) { return command.endsWith('\r') ? command : `${command}\r`; } function enqueueCommand(fn) { const run = commandQueue.then(fn, fn); commandQueue = run.catch(() => {}); return run; } export async function connectEggDuino() { if (server?.connected && rxCharacteristic && txCharacteristic) { return; } try { device = await navigator.bluetooth.requestDevice({ filters: [{ services: [SERVICE_UUID] }], optionalServices: [SERVICE_UUID], }); } catch (error) { throw new Error(`BLE request failed: ${error.message}`); } device.addEventListener('gattserverdisconnected', handleDisconnected); try { server = await device.gatt.connect(); } catch (error) { throw new Error(`BLE gatt connect failed: ${error.message}`); } let service; try { service = await server.getPrimaryService(SERVICE_UUID); } catch (error) { throw new Error(`BLE service lookup failed: ${error.message}`); } try { rxCharacteristic = await service.getCharacteristic(RX_UUID); txCharacteristic = await service.getCharacteristic(TX_UUID); } catch (error) { throw new Error(`BLE characteristic lookup failed: ${error.message}`); } try { await txCharacteristic.startNotifications(); txCharacteristic.addEventListener('characteristicvaluechanged', handleNotification); } catch (error) { throw new Error(`BLE notifications setup failed: ${error.message}`); } } export function disconnectEggDuino() { if (!device) { return; } if (txCharacteristic) { txCharacteristic.removeEventListener('characteristicvaluechanged', handleNotification); } if (device.gatt?.connected) { device.gatt.disconnect(); } handleDisconnected(); } export async function sendEggBotCommand(command) { const normalized = normalizeCommand(command); await writeToRxCharacteristic(encoder.encode(normalized)); } export async function sendEggBotCommandExpectOk(command, timeoutMs = 2000) { return enqueueCommand(() => new Promise(async (resolve, reject) => { const collectedLines = []; const timeout = setTimeout(() => { lineListeners.delete(onResponseLine); reject(new Error('EggBot response timeout')); }, timeoutMs); const onResponseLine = (line) => { collectedLines.push(line); if (line === 'OK') { clearTimeout(timeout); lineListeners.delete(onResponseLine); resolve(collectedLines.slice(0, -1)); } if (line === 'unknown CMD') { clearTimeout(timeout); lineListeners.delete(onResponseLine); reject(new Error(`EggBot error: ${collectedLines.join(' | ')}`)); } }; lineListeners.add(onResponseLine); try { await sendEggBotCommand(command); } catch (error) { clearTimeout(timeout); lineListeners.delete(onResponseLine); reject(error); } })); } export function onLine(callback) { lineListeners.add(callback); } export function offLine(callback) { lineListeners.delete(callback); }