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:
2026-02-24 22:00:26 +01:00
parent 3487d7c263
commit a1ffcb08ca
15 changed files with 1115 additions and 83 deletions

View 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.

View 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.

View 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.

View 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);
}

View 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);
}