Refactor SerialCommand to support Stream interface and improve command handling
- Added readSerial(Stream &stream) method to allow reading from any Stream-compatible transport. - Introduced readChar(char inChar) method for processing individual characters. - Updated command matching logic to enhance debugging output. - Improved buffer handling and command execution flow. Enhance platformio.ini for better compatibility and added libraries - Added NimBLE-Arduino and WebSockets libraries for BLE and WiFi support. - Updated upload and monitor ports for better compatibility with macOS. Integrate WiFi and BLE protocol interfaces - Implemented startWebInterface() to initialize WiFi protocol alongside existing web server. - Added BLE support with a new EggBot BLE Serial Protocol for command handling over BLE. - Created WebSocket server for WiFi communication, maintaining compatibility with existing command protocols. Refactor command handling in Functions.cpp - Replaced direct Serial.print calls with protocolWrite for consistent output handling. - Updated command registration to use a lambda function for better readability and maintainability. Add documentation for EggBot protocols - Created separate markdown files for BLE, WiFi, and Serial protocols detailing command structures and usage. - Provided examples of command transactions for better developer guidance. Implement BLE and WiFi protocol handling in respective source files - Developed BLE_Interface.cpp for managing BLE connections and data transmission. - Created WiFi_Protocol.cpp for handling WebSocket communication and data reception.
This commit is contained in:
46
docs/eggbot-ble-serial-protocol.md
Normal file
46
docs/eggbot-ble-serial-protocol.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# EggBot BLE Serial Protocol
|
||||
|
||||
## Scope
|
||||
This transport exposes the same EggBot command protocol as serial, but over BLE GATT.
|
||||
|
||||
- Protocol commands and responses are unchanged.
|
||||
- Framing is unchanged (`\r` command terminator, `\r\n` response lines).
|
||||
|
||||
## BLE GATT Profile
|
||||
- Device name: `EggDuino`
|
||||
- Service UUID: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- RX characteristic (host -> EggDuino): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- Properties: `Write`, `Write Without Response`
|
||||
- TX characteristic (EggDuino -> host): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- Properties: `Notify`, `Read`
|
||||
|
||||
## Data Model
|
||||
- Host writes plain ASCII command bytes to RX.
|
||||
- Firmware parses bytes with the same EggBot command parser used for USB serial.
|
||||
- Firmware sends responses via TX notifications.
|
||||
- Long responses are segmented into BLE-sized notification chunks; host must reassemble by bytes and parse lines by `\r\n`.
|
||||
|
||||
## Compatibility Rules
|
||||
- A BLE client must send commands exactly as serial hosts do.
|
||||
- Each command must end with `\r`.
|
||||
- Data-returning commands (`QP`, `QB`, `QN`, `QL`) return a value line before final status (`OK\r\n`).
|
||||
- Unknown/invalid command format returns `unknown CMD\r\n`.
|
||||
|
||||
## Example BLE Transactions
|
||||
Version query:
|
||||
- Write RX: `v\r`
|
||||
- Notify TX: `EBBv13_and_above Protocol emulated by Eggduino-Firmware V1.6a\r\n`
|
||||
|
||||
Move command:
|
||||
- Write RX: `SM,100,0,200\r`
|
||||
- Notify TX: `OK\r\n`
|
||||
|
||||
Query node count:
|
||||
- Write RX: `QN\r`
|
||||
- Notify TX: `<number>\r\n`
|
||||
- Notify TX: `OK\r\n`
|
||||
|
||||
## Operational Notes
|
||||
- BLE receive bytes are queued and parsed in the main firmware loop.
|
||||
- If the BLE RX queue overruns, excess bytes are dropped and a log entry is generated.
|
||||
- BLE and USB serial can coexist; each command response is routed to the transport that received that command.
|
||||
53
docs/eggbot-serial-protocol.md
Normal file
53
docs/eggbot-serial-protocol.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# EggBot Serial Protocol (USB/UART)
|
||||
|
||||
## Scope
|
||||
This firmware emulates the EggBot/EBB command protocol over serial transport.
|
||||
|
||||
- Transport: USB CDC/UART (`115200 8N1`)
|
||||
- Command separator: `,`
|
||||
- Command terminator: carriage return (`\r`, ASCII 0x0D)
|
||||
- Response terminator: carriage return + newline (`\r\n`)
|
||||
|
||||
## Handshake and Generic Responses
|
||||
- Version query: `v\r`
|
||||
- Version response: `EBBv13_and_above Protocol emulated by Eggduino-Firmware V1.6a\r\n`
|
||||
- Success response: `OK\r\n`
|
||||
- Error response: `unknown CMD\r\n`
|
||||
|
||||
## Implemented Commands
|
||||
All commands are case-sensitive and comma-delimited.
|
||||
|
||||
- `EM,<mode>[,<ignored>]`
|
||||
- `SC,<id>,<value>`
|
||||
- `SP,<penState>[,<delayMs>]`
|
||||
- `SM,<durationMs>,<penSteps>,<rotSteps>`
|
||||
- `SE` (ack-only placeholder)
|
||||
- `TP[,<delayMs>]`
|
||||
- `PO` (ack-only placeholder)
|
||||
- `NI`
|
||||
- `ND`
|
||||
- `SN,<nodeCount>`
|
||||
- `QN`
|
||||
- `SL,<layer>`
|
||||
- `QL`
|
||||
- `QP`
|
||||
- `QB`
|
||||
|
||||
## Command Behavior Notes
|
||||
- `SM`: firmware blocks until previous move completes, sends `OK`, then runs the requested move.
|
||||
- `SP` and `TP`: support optional delay argument in milliseconds.
|
||||
- `QP`: returns pen state line (`1` for pen up, `0` for pen down), then `OK`.
|
||||
- `QB`: returns button state (`0` or `1`), then `OK`; internal button latch is reset after query.
|
||||
- `QN`, `QL`: return numeric line first, then `OK`.
|
||||
- `SC` supports these IDs:
|
||||
- `4`: set pen-down servo position (EBB value to servo-mapped value)
|
||||
- `5`: set pen-up servo position
|
||||
- `6`, `7`: accepted, ignored, and acknowledged
|
||||
- `11`: set servo rate up
|
||||
- `12`: set servo rate down
|
||||
|
||||
## Timing/Parsing Requirements for Hosts
|
||||
- Always terminate each command with `\r`.
|
||||
- Do not rely on `\n` as a command terminator.
|
||||
- Read until `OK\r\n` (or `unknown CMD\r\n`) to complete command transactions.
|
||||
- For commands that return data (`QP`, `QB`, `QN`, `QL`), read one data line plus the final status line.
|
||||
45
docs/eggbot-wifi-serial-protocol.md
Normal file
45
docs/eggbot-wifi-serial-protocol.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# EggBot WiFi Serial Protocol
|
||||
|
||||
## Scope
|
||||
This transport exposes the same EggBot command protocol as serial, but over WiFi/WebSocket.
|
||||
|
||||
- Protocol commands and responses are unchanged.
|
||||
- Framing is unchanged (`\r` command terminator, `\r\n` response lines).
|
||||
|
||||
## Transport Profile
|
||||
- Protocol endpoint: `ws://<eggs-esp32-ip>:1337/`
|
||||
- WebSocket message type: text or binary frames accepted
|
||||
- Payload: ASCII command bytes
|
||||
|
||||
## Data Model
|
||||
- Host writes command bytes to the WebSocket.
|
||||
- Firmware parses incoming bytes with the same EggBot parser used for USB serial and BLE.
|
||||
- Firmware sends responses as WebSocket text messages.
|
||||
- Hosts must treat inbound data as a stream and parse lines by `\r\n`.
|
||||
|
||||
## Compatibility Rules
|
||||
- A WiFi client must send commands exactly as serial hosts do.
|
||||
- Each command must end with `\r`.
|
||||
- Data-returning commands (`QP`, `QB`, `QN`, `QL`) return a value line before final status (`OK\r\n`).
|
||||
- Unknown/invalid command format returns `unknown CMD\r\n`.
|
||||
|
||||
## Example WiFi Transactions
|
||||
Version query:
|
||||
- Send: `v\r`
|
||||
- Receive: `EBBv13_and_above Protocol emulated by Eggduino-Firmware V1.6a\r\n`
|
||||
|
||||
Move command:
|
||||
- Send: `SM,100,0,200\r`
|
||||
- Receive: `OK\r\n`
|
||||
|
||||
Query layer:
|
||||
- Send: `QL\r`
|
||||
- Receive: `<layer>\r\n`
|
||||
- Receive: `OK\r\n`
|
||||
|
||||
## Operational Notes
|
||||
- The WiFi protocol endpoint is started only when ESP32 station WiFi is connected.
|
||||
- Incoming WiFi bytes are queued and parsed in the main loop.
|
||||
- If the WiFi RX queue overruns, excess bytes are dropped and a log entry is generated.
|
||||
- WiFi transport is single active client: first connected sender is accepted until disconnect.
|
||||
- USB serial, BLE, and WiFi can coexist; responses are routed to the transport that received each command.
|
||||
185
docs/examples/eggbot-ble-client.mjs
Normal file
185
docs/examples/eggbot-ble-client.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
}
|
||||
183
docs/examples/eggbot-wifi-client.mjs
Normal file
183
docs/examples/eggbot-wifi-client.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let socket = null;
|
||||
let lineBuffer = '';
|
||||
let commandQueue = Promise.resolve();
|
||||
|
||||
const lineListeners = new Set();
|
||||
|
||||
function emitLine(line) {
|
||||
for (const listener of lineListeners) {
|
||||
listener(line);
|
||||
}
|
||||
}
|
||||
|
||||
function parseIncomingChunk(chunk) {
|
||||
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 handleSocketMessage(event) {
|
||||
if (typeof event.data === 'string') {
|
||||
parseIncomingChunk(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
parseIncomingChunk(decoder.decode(event.data));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(command) {
|
||||
return command.endsWith('\r') ? command : `${command}\r`;
|
||||
}
|
||||
|
||||
function enqueueCommand(fn) {
|
||||
const run = commandQueue.then(fn, fn);
|
||||
commandQueue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function ensureSocketReady() {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('EggDuino WiFi socket is not connected');
|
||||
}
|
||||
}
|
||||
|
||||
function buildSocketUrl(options) {
|
||||
if (options.url) {
|
||||
return options.url;
|
||||
}
|
||||
|
||||
const host = options.host;
|
||||
if (!host) {
|
||||
throw new Error('Missing EggDuino host');
|
||||
}
|
||||
|
||||
const secure = options.secure === true;
|
||||
const protocol = secure ? 'wss' : 'ws';
|
||||
const port = Number(options.port ?? 1337);
|
||||
const path = options.path ?? '/';
|
||||
|
||||
return `${protocol}://${host}:${port}${path}`;
|
||||
}
|
||||
|
||||
export async function connectEggDuinoWifi(options = {}) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket && socket.readyState === WebSocket.CONNECTING) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
socket.removeEventListener('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
socket.removeEventListener('open', onOpen);
|
||||
reject(new Error('EggDuino WiFi socket failed while connecting'));
|
||||
};
|
||||
socket.addEventListener('open', onOpen, { once: true });
|
||||
socket.addEventListener('error', onError, { once: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildSocketUrl(options);
|
||||
socket = new WebSocket(url);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.addEventListener('message', handleSocketMessage);
|
||||
socket.addEventListener('close', () => {
|
||||
socket = null;
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
socket.removeEventListener('error', onError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
socket.removeEventListener('open', onOpen);
|
||||
socket?.close();
|
||||
socket = null;
|
||||
reject(new Error(`EggDuino WiFi socket connection failed: ${url}`));
|
||||
};
|
||||
|
||||
socket.addEventListener('open', onOpen, { once: true });
|
||||
socket.addEventListener('error', onError, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectEggDuinoWifi() {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.removeEventListener('message', handleSocketMessage);
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close();
|
||||
}
|
||||
socket = null;
|
||||
}
|
||||
|
||||
export async function sendEggBotCommand(command) {
|
||||
ensureSocketReady();
|
||||
socket.send(normalizeCommand(command));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user