From a1ffcb08cac1e1950c242ada2ee17b77aff2505c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Fiedler?= Date: Tue, 24 Feb 2026 22:00:26 +0100 Subject: [PATCH] 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. --- docs/eggbot-ble-serial-protocol.md | 46 +++++ docs/eggbot-serial-protocol.md | 53 +++++ docs/eggbot-wifi-serial-protocol.md | 45 +++++ docs/examples/eggbot-ble-client.mjs | 185 ++++++++++++++++++ docs/examples/eggbot-wifi-client.mjs | 183 ++++++++++++++++++ include/EggDuino.h | 37 ++++ lib/SerialCommand/src/SerialCommand.cpp | 91 +++++---- lib/SerialCommand/src/SerialCommand.h | 2 + platformio.ini | 7 +- src/BLE_Interface.cpp | 244 ++++++++++++++++++++++++ src/Config_Web.cpp | 1 + src/Functions.cpp | 72 +++---- src/Helper_Functions.cpp | 16 +- src/WiFi_Protocol.cpp | 156 +++++++++++++++ src/main.cpp | 60 ++++++ 15 files changed, 1115 insertions(+), 83 deletions(-) create mode 100644 docs/eggbot-ble-serial-protocol.md create mode 100644 docs/eggbot-serial-protocol.md create mode 100644 docs/eggbot-wifi-serial-protocol.md create mode 100644 docs/examples/eggbot-ble-client.mjs create mode 100644 docs/examples/eggbot-wifi-client.mjs create mode 100644 src/BLE_Interface.cpp create mode 100644 src/WiFi_Protocol.cpp diff --git a/docs/eggbot-ble-serial-protocol.md b/docs/eggbot-ble-serial-protocol.md new file mode 100644 index 0000000..c34f998 --- /dev/null +++ b/docs/eggbot-ble-serial-protocol.md @@ -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: `\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. diff --git a/docs/eggbot-serial-protocol.md b/docs/eggbot-serial-protocol.md new file mode 100644 index 0000000..5224874 --- /dev/null +++ b/docs/eggbot-serial-protocol.md @@ -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,[,]` +- `SC,,` +- `SP,[,]` +- `SM,,,` +- `SE` (ack-only placeholder) +- `TP[,]` +- `PO` (ack-only placeholder) +- `NI` +- `ND` +- `SN,` +- `QN` +- `SL,` +- `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. diff --git a/docs/eggbot-wifi-serial-protocol.md b/docs/eggbot-wifi-serial-protocol.md new file mode 100644 index 0000000..0b0ef76 --- /dev/null +++ b/docs/eggbot-wifi-serial-protocol.md @@ -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://: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: `\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. diff --git a/docs/examples/eggbot-ble-client.mjs b/docs/examples/eggbot-ble-client.mjs new file mode 100644 index 0000000..079dd81 --- /dev/null +++ b/docs/examples/eggbot-ble-client.mjs @@ -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); +} diff --git a/docs/examples/eggbot-wifi-client.mjs b/docs/examples/eggbot-wifi-client.mjs new file mode 100644 index 0000000..9f5dd43 --- /dev/null +++ b/docs/examples/eggbot-wifi-client.mjs @@ -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); +} diff --git a/include/EggDuino.h b/include/EggDuino.h index 61b57d3..ab089ad 100644 --- a/include/EggDuino.h +++ b/include/EggDuino.h @@ -67,6 +67,10 @@ extern FastAccelStepper *g_pStepperPen; extern Servo penServo; extern SerialCommand SCmd; +#ifdef ESP32 +extern SerialCommand g_BLECmd; +extern SerialCommand g_WifiCmd; +#endif extern int g_iPenUpPos; extern int g_iPenDownPos; @@ -82,10 +86,20 @@ extern float fROT_STEP_CORRECTION; extern float fPEN_STEP_CORRECTION; extern boolean g_bMotorsEnabled; +enum ProtocolTransport { + PROTOCOL_TRANSPORT_SERIAL = 0, + PROTOCOL_TRANSPORT_BLE = 1, + PROTOCOL_TRANSPORT_WIFI = 2, +}; + extern ConfigParameter configParameters[]; extern const size_t configParameterCount; void makeComInterface(); +void setActiveProtocolContext(SerialCommand *parser, ProtocolTransport transport); +char *nextCommandArg(); +void protocolWrite(const char *message); +void protocolWrite(const String &message); void initHardware(); void moveOneStep(); void moveToDestination(); @@ -108,6 +122,29 @@ String buildConfigJson(); bool applyConfigJson(const String &payload, String &errorMessage); void startWebInterface(); void handleWebInterface(); +#ifdef ESP32 +void startBleInterface(); +void handleBleInterface(); +bool bleProtocolWrite(const char *message); +void startWifiProtocolInterface(); +void handleWifiProtocolInterface(); +bool wifiProtocolWrite(const char *message); +#else +inline void startBleInterface() {} +inline void handleBleInterface() {} +inline bool bleProtocolWrite(const char *message) +{ + (void)message; + return false; +} +inline void startWifiProtocolInterface() {} +inline void handleWifiProtocolInterface() {} +inline bool wifiProtocolWrite(const char *message) +{ + (void)message; + return false; +} +#endif void Log(const String &message); void Log(const char *message); String buildLogsJson(uint32_t sinceSeq); diff --git a/lib/SerialCommand/src/SerialCommand.cpp b/lib/SerialCommand/src/SerialCommand.cpp index 9d0e7af..a14c1a7 100644 --- a/lib/SerialCommand/src/SerialCommand.cpp +++ b/lib/SerialCommand/src/SerialCommand.cpp @@ -71,58 +71,65 @@ void SerialCommand::setDefaultHandler(void (*function)(const char *)) { * buffer for a prefix command, and calls handlers setup by addCommand() member */ void SerialCommand::readSerial() { - while (Serial.available() > 0) { - char inChar = Serial.read(); // Read single available character, there may be more waiting + readSerial(Serial); +} + +void SerialCommand::readSerial(Stream &stream) { + while (stream.available() > 0) { + char inChar = stream.read(); // Read single available character, there may be more waiting + readChar(inChar); + } +} + +void SerialCommand::readChar(char inChar) { + #ifdef SERIALCOMMAND_DEBUG + Serial.print(inChar); // Echo back to serial stream + #endif + + if (inChar == term) { // Check for the terminator (default '\r') meaning end of command #ifdef SERIALCOMMAND_DEBUG - Serial.print(inChar); // Echo back to serial stream + Serial.print("Received: "); + Serial.println(buffer); #endif - if (inChar == term) { // Check for the terminator (default '\r') meaning end of command - #ifdef SERIALCOMMAND_DEBUG - Serial.print("Received: "); - Serial.println(buffer); - #endif + char *command = strtok_r(buffer, delim, &last); // Search for command at start of buffer + if (command != NULL) { + boolean matched = false; + for (int i = 0; i < commandCount; i++) { + #ifdef SERIALCOMMAND_DEBUG + Serial.print("Comparing ["); + Serial.print(command); + Serial.print("] to ["); + Serial.print(commandList[i].command); + Serial.println("]"); + #endif - char *command = strtok_r(buffer, delim, &last); // Search for command at start of buffer - if (command != NULL) { - boolean matched = false; - for (int i = 0; i < commandCount; i++) { + // Compare the found command against the list of known commands for a match + if (strncmp(command, commandList[i].command, SERIALCOMMAND_MAXCOMMANDLENGTH) == 0) { #ifdef SERIALCOMMAND_DEBUG - Serial.print("Comparing ["); - Serial.print(command); - Serial.print("] to ["); - Serial.print(commandList[i].command); - Serial.println("]"); + Serial.print("Matched Command: "); + Serial.println(command); #endif - // Compare the found command against the list of known commands for a match - if (strncmp(command, commandList[i].command, SERIALCOMMAND_MAXCOMMANDLENGTH) == 0) { - #ifdef SERIALCOMMAND_DEBUG - Serial.print("Matched Command: "); - Serial.println(command); - #endif - - // Execute the stored handler function for the command - (*commandList[i].function)(); - matched = true; - break; - } - } - if (!matched && (defaultHandler != NULL)) { - (*defaultHandler)(command); + // Execute the stored handler function for the command + (*commandList[i].function)(); + matched = true; + break; } } - clearBuffer(); + if (!matched && (defaultHandler != NULL)) { + (*defaultHandler)(command); + } } - else if (isprint(inChar)) { // Only printable characters into the buffer - if (bufPos < SERIALCOMMAND_BUFFER) { - buffer[bufPos++] = inChar; // Put character into buffer - buffer[bufPos] = '\0'; // Null terminate - } else { - #ifdef SERIALCOMMAND_DEBUG - Serial.println("Line buffer is full - increase SERIALCOMMAND_BUFFER"); - #endif - } + clearBuffer(); + } else if (isprint(inChar)) { // Only printable characters into the buffer + if (bufPos < SERIALCOMMAND_BUFFER) { + buffer[bufPos++] = inChar; // Put character into buffer + buffer[bufPos] = '\0'; // Null terminate + } else { + #ifdef SERIALCOMMAND_DEBUG + Serial.println("Line buffer is full - increase SERIALCOMMAND_BUFFER"); + #endif } } } diff --git a/lib/SerialCommand/src/SerialCommand.h b/lib/SerialCommand/src/SerialCommand.h index 9f98c38..d52308e 100644 --- a/lib/SerialCommand/src/SerialCommand.h +++ b/lib/SerialCommand/src/SerialCommand.h @@ -49,6 +49,8 @@ class SerialCommand { void setDefaultHandler(void (*function)(const char *)); // A handler to call when no valid command received. void readSerial(); // Main entry point. + void readSerial(Stream &stream); // Reads commands from any Stream-compatible transport. + void readChar(char inChar); // Feeds one incoming character into the parser. void clearBuffer(); // Clears the input buffer. char *next(); // Returns pointer to next token found in command buffer (for getting arguments to commands). diff --git a/platformio.ini b/platformio.ini index e3cfe35..16dfa7b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,15 +19,20 @@ lib_deps = madhephaestus/ESP32Servo@^3.0.6 bblanchon/ArduinoJson@^6.21.5 gin66/FastAccelStepper@^0.33.13 + h2zero/NimBLE-Arduino@^2.3.6 + links2004/WebSockets@^2.6.1 [env:uno_macos] platform = platformio/espressif32 board = esp32dev framework = arduino monitor_speed = 115200 +monitor_port = /dev/cu.usb* upload_speed = 115200 -upload_port = /dev/tty.usbserial-110 +upload_port = /dev/cu.usb* lib_deps = madhephaestus/ESP32Servo@^3.0.6 bblanchon/ArduinoJson@^6.21.5 gin66/FastAccelStepper@^0.33.13 + h2zero/NimBLE-Arduino@^2.3.6 + links2004/WebSockets@^2.6.1 diff --git a/src/BLE_Interface.cpp b/src/BLE_Interface.cpp new file mode 100644 index 0000000..762668c --- /dev/null +++ b/src/BLE_Interface.cpp @@ -0,0 +1,244 @@ +#include "EggDuino.h" + +#ifdef ESP32 +#include +#include + +namespace +{ +constexpr char kBleDeviceNamePrefix[] = "EggDuino_"; +constexpr char kBleServiceUuid[] = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; +constexpr char kBleRxCharUuid[] = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; +constexpr char kBleTxCharUuid[] = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; + +constexpr size_t kBleRxQueueSize = 512; +constexpr size_t kBleNotifyChunkSize = 20; + +NimBLEServer *g_pBleServer = NULL; +NimBLECharacteristic *g_pBleTxCharacteristic = NULL; + +uint8_t g_bleRxQueue[kBleRxQueueSize]; +size_t g_bleRxHead = 0; +size_t g_bleRxTail = 0; +bool g_bleRxQueueOverflow = false; +bool g_bleClientConnected = false; + +portMUX_TYPE g_bleQueueMux = portMUX_INITIALIZER_UNLOCKED; + +void logBleDiag(const String &message) +{ + Log(message); + Serial.println(message); +} + +void buildBleDeviceName(char *nameBuffer, size_t bufferSize) +{ + if ((nameBuffer == NULL) || (bufferSize == 0)) + { + return; + } + + // Tasmota-style chip ID uses the lower 24 bits of the ESP32 efuse MAC. + const uint32_t chipId = static_cast(ESP.getEfuseMac() & 0xFFFFFFULL); + snprintf(nameBuffer, bufferSize, "%s%06X", kBleDeviceNamePrefix, chipId); +} + +bool queueBleByte(uint8_t value) +{ + bool queued = false; + portENTER_CRITICAL(&g_bleQueueMux); + const size_t nextHead = (g_bleRxHead + 1) % kBleRxQueueSize; + if (nextHead == g_bleRxTail) + { + g_bleRxQueueOverflow = true; + } + else + { + g_bleRxQueue[g_bleRxHead] = value; + g_bleRxHead = nextHead; + queued = true; + } + portEXIT_CRITICAL(&g_bleQueueMux); + return queued; +} + +bool dequeueBleByte(uint8_t *value) +{ + bool hasData = false; + portENTER_CRITICAL(&g_bleQueueMux); + if (g_bleRxHead != g_bleRxTail) + { + *value = g_bleRxQueue[g_bleRxTail]; + g_bleRxTail = (g_bleRxTail + 1) % kBleRxQueueSize; + hasData = true; + } + portEXIT_CRITICAL(&g_bleQueueMux); + return hasData; +} + +class EggDuinoBleServerCallbacks : public NimBLEServerCallbacks +{ + void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) + { + (void)pServer; + (void)connInfo; + g_bleClientConnected = true; + Log("BLE client connected"); + } + + void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo, int reason) + { + (void)connInfo; + (void)reason; + g_bleClientConnected = false; + const bool restartedAdvertising = pServer->startAdvertising(); + logBleDiag(String("BLE client disconnected; advertising restart: ") + (restartedAdvertising ? "ok" : "failed")); + } +}; + +class EggDuinoBleRxCallbacks : public NimBLECharacteristicCallbacks +{ + void onWrite(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) + { + (void)connInfo; + const std::string value = pCharacteristic->getValue(); + for (size_t i = 0; i < value.size(); ++i) + { + queueBleByte(static_cast(value[i])); + } + } +}; +} // namespace + +void startBleInterface() +{ + char bleDeviceName[32] = {0}; + buildBleDeviceName(bleDeviceName, sizeof(bleDeviceName)); + if (bleDeviceName[0] == '\0') + { + snprintf(bleDeviceName, sizeof(bleDeviceName), "%sUNKNOWN", kBleDeviceNamePrefix); + } + + logBleDiag("BLE init begin"); + logBleDiag(String("BLE device name: ") + bleDeviceName); + logBleDiag(String("BLE service UUID: ") + kBleServiceUuid); + logBleDiag(String("BLE RX UUID: ") + kBleRxCharUuid); + logBleDiag(String("BLE TX UUID: ") + kBleTxCharUuid); + + NimBLEDevice::init(bleDeviceName); + const bool blePowerSet = NimBLEDevice::setPower(ESP_PWR_LVL_P6); + const std::string bleAddress = NimBLEDevice::getAddress().toString(); + logBleDiag(String("BLE radio address: ") + bleAddress.c_str()); + logBleDiag(String("BLE TX power set: ") + (blePowerSet ? "ok" : "failed")); + + g_pBleServer = NimBLEDevice::createServer(); + if (g_pBleServer == NULL) + { + logBleDiag("BLE init failed: createServer returned null"); + return; + } + g_pBleServer->setCallbacks(new EggDuinoBleServerCallbacks()); + logBleDiag("BLE server created"); + + NimBLEService *pService = g_pBleServer->createService(kBleServiceUuid); + if (pService == NULL) + { + logBleDiag("BLE init failed: createService returned null"); + return; + } + g_pBleTxCharacteristic = pService->createCharacteristic( + kBleTxCharUuid, + NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::READ); + if (g_pBleTxCharacteristic == NULL) + { + logBleDiag("BLE init failed: TX characteristic creation failed"); + return; + } + + NimBLECharacteristic *pRxCharacteristic = pService->createCharacteristic( + kBleRxCharUuid, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR); + if (pRxCharacteristic == NULL) + { + logBleDiag("BLE init failed: RX characteristic creation failed"); + return; + } + pRxCharacteristic->setCallbacks(new EggDuinoBleRxCallbacks()); + logBleDiag("BLE characteristics created"); + + const bool serviceStarted = pService->start(); + logBleDiag(String("BLE service start: ") + (serviceStarted ? "ok" : "failed")); + if (!serviceStarted) + { + return; + } + + NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + if (pAdvertising == NULL) + { + logBleDiag("BLE init failed: getAdvertising returned null"); + return; + } + pAdvertising->enableScanResponse(true); + const bool localNameSet = pAdvertising->setName(bleDeviceName); + const bool serviceUuidAdded = pAdvertising->addServiceUUID(kBleServiceUuid); + const bool advertisingStarted = pAdvertising->start(); + logBleDiag(String("BLE advertising set local name: ") + (localNameSet ? "ok" : "failed")); + logBleDiag(String("BLE advertising add service UUID: ") + (serviceUuidAdded ? "ok" : "failed")); + logBleDiag(String("BLE advertising start: ") + (advertisingStarted ? "ok" : "failed")); + if (serviceUuidAdded && advertisingStarted) + { + logBleDiag("BLE service started"); + } +} + +void handleBleInterface() +{ + if (g_bleRxQueueOverflow) + { + g_bleRxQueueOverflow = false; + Log("BLE RX queue overflow"); + } + + uint8_t value = 0; + while (dequeueBleByte(&value)) + { + setActiveProtocolContext(&g_BLECmd, PROTOCOL_TRANSPORT_BLE); + g_BLECmd.readChar(static_cast(value)); + } +} + +bool bleProtocolWrite(const char *message) +{ + if ((message == NULL) || !g_bleClientConnected || (g_pBleTxCharacteristic == NULL)) + { + return false; + } + + const uint8_t *payload = reinterpret_cast(message); + size_t remaining = strlen(message); + if (remaining == 0) + { + return true; + } + + while (remaining > 0) + { + size_t chunkLen = remaining; + if (chunkLen > kBleNotifyChunkSize) + { + chunkLen = kBleNotifyChunkSize; + } + + g_pBleTxCharacteristic->setValue(payload, chunkLen); + g_pBleTxCharacteristic->notify(); + + payload += chunkLen; + remaining -= chunkLen; + delay(3); + } + + return true; +} + +#endif diff --git a/src/Config_Web.cpp b/src/Config_Web.cpp index a53a81f..c8e7b1d 100644 --- a/src/Config_Web.cpp +++ b/src/Config_Web.cpp @@ -397,6 +397,7 @@ void startWebInterface() server.on("/api/logs", HTTP_GET, handleGetLogs); server.onNotFound(handleNotFound); server.begin(); + startWifiProtocolInterface(); } else { diff --git a/src/Functions.cpp b/src/Functions.cpp index a37aeea..828dcee 100644 --- a/src/Functions.cpp +++ b/src/Functions.cpp @@ -8,13 +8,13 @@ void queryPen() state = '1'; else state = '0'; - Serial.print(String(state) + "\r\n"); + protocolWrite(String(state) + "\r\n"); sendAck(); } void queryButton() { - Serial.print(String(g_bPrgButtonState) + "\r\n"); + protocolWrite(String(g_bPrgButtonState) + "\r\n"); sendAck(); g_bPrgButtonState = 0; } @@ -22,7 +22,7 @@ void queryButton() void queryLayer() { Log(__FUNCTION__); - Serial.print(String(g_uiLayer) + "\r\n"); + protocolWrite(String(g_uiLayer) + "\r\n"); sendAck(); } @@ -31,7 +31,7 @@ void setLayer() Log(__FUNCTION__); uint32_t value = 0; char *arg1; - arg1 = SCmd.next(); + arg1 = nextCommandArg(); if (arg1 != NULL) { value = atoi(arg1); @@ -44,7 +44,7 @@ void setLayer() void queryNodeCount() { - Serial.print(String(g_uiNodeCount) + "\r\n"); + protocolWrite(String(g_uiNodeCount) + "\r\n"); sendAck(); } @@ -53,7 +53,7 @@ void setNodeCount() Log(__FUNCTION__); uint32_t value = 0; char *arg1; - arg1 = SCmd.next(); + arg1 = nextCommandArg(); if (arg1 != NULL) { value = atoi(arg1); @@ -112,7 +112,7 @@ void setPen() moveToDestination(); - arg = SCmd.next(); + arg = nextCommandArg(); if (arg != NULL) { cmd = atoi(arg); @@ -131,7 +131,7 @@ void setPen() } } char *val; - val = SCmd.next(); + val = nextCommandArg(); if (val != NULL) { value = atoi(val); @@ -156,7 +156,7 @@ void togglePen() moveToDestination(); - arg = SCmd.next(); + arg = nextCommandArg(); if (arg != NULL) value = atoi(arg); else @@ -189,10 +189,10 @@ void enableMotors() int value; char *arg; char *val; - arg = SCmd.next(); + arg = nextCommandArg(); if (arg != NULL) cmd = atoi(arg); - val = SCmd.next(); + val = nextCommandArg(); if (val != NULL) value = atoi(val); // values parsed @@ -237,11 +237,11 @@ void stepperModeConfigure() int cmd; int value; char *arg; - arg = SCmd.next(); + arg = nextCommandArg(); if (arg != NULL) cmd = atoi(arg); char *val; - val = SCmd.next(); + val = nextCommandArg(); if (val != NULL) value = atoi(val); if ((arg != NULL) && (val != NULL)) @@ -281,8 +281,8 @@ void stepperModeConfigure() void sendVersion() { Log(__FUNCTION__); - Serial.print(initSting); - Serial.print("\r\n"); + protocolWrite(initSting); + protocolWrite("\r\n"); } void unrecognized(const char *command) @@ -299,21 +299,29 @@ void ignore() void makeComInterface() { - SCmd.addCommand("v", sendVersion); - SCmd.addCommand("EM", enableMotors); - SCmd.addCommand("SC", stepperModeConfigure); - SCmd.addCommand("SP", setPen); - SCmd.addCommand("SM", stepperMove); - SCmd.addCommand("SE", ignore); - SCmd.addCommand("TP", togglePen); - SCmd.addCommand("PO", ignore); // Engraver command, not implemented, gives fake answer - SCmd.addCommand("NI", nodeCountIncrement); - SCmd.addCommand("ND", nodeCountDecrement); - SCmd.addCommand("SN", setNodeCount); - SCmd.addCommand("QN", queryNodeCount); - SCmd.addCommand("SL", setLayer); - SCmd.addCommand("QL", queryLayer); - SCmd.addCommand("QP", queryPen); - SCmd.addCommand("QB", queryButton); //"PRG" Button, - SCmd.setDefaultHandler(unrecognized); // Handler for command that isn't matched (says "What?") + auto registerCommands = [](SerialCommand &cmd) { + cmd.addCommand("v", sendVersion); + cmd.addCommand("EM", enableMotors); + cmd.addCommand("SC", stepperModeConfigure); + cmd.addCommand("SP", setPen); + cmd.addCommand("SM", stepperMove); + cmd.addCommand("SE", ignore); + cmd.addCommand("TP", togglePen); + cmd.addCommand("PO", ignore); // Engraver command, not implemented, gives fake answer + cmd.addCommand("NI", nodeCountIncrement); + cmd.addCommand("ND", nodeCountDecrement); + cmd.addCommand("SN", setNodeCount); + cmd.addCommand("QN", queryNodeCount); + cmd.addCommand("SL", setLayer); + cmd.addCommand("QL", queryLayer); + cmd.addCommand("QP", queryPen); + cmd.addCommand("QB", queryButton); //"PRG" Button, + cmd.setDefaultHandler(unrecognized); // Handler for command that isn't matched (says "What?") + }; + + registerCommands(SCmd); +#ifdef ESP32 + registerCommands(g_BLECmd); + registerCommands(g_WifiCmd); +#endif } diff --git a/src/Helper_Functions.cpp b/src/Helper_Functions.cpp index e2b316a..89671c5 100644 --- a/src/Helper_Functions.cpp +++ b/src/Helper_Functions.cpp @@ -52,13 +52,13 @@ void storePenDownPosInEE() void sendAck() { Log(__FUNCTION__); - Serial.print("OK\r\n"); + protocolWrite("OK\r\n"); } void sendError() { Log(__FUNCTION__); - Serial.print("unknown CMD\r\n"); + protocolWrite("unknown CMD\r\n"); } void motorsOff() @@ -92,19 +92,19 @@ void toggleMotors() bool parseSMArgs(uint16_t *duration, int *penStepsEBB, int *rotStepsEBB) { - char *arg1; - char *arg2; - char *arg3; - arg1 = SCmd.next(); + char *arg1 = NULL; + char *arg2 = NULL; + char *arg3 = NULL; + arg1 = nextCommandArg(); if (arg1 != NULL) { *duration = atoi(arg1); - arg2 = SCmd.next(); + arg2 = nextCommandArg(); } if (arg2 != NULL) { *penStepsEBB = atoi(arg2); - arg3 = SCmd.next(); + arg3 = nextCommandArg(); } if (arg3 != NULL) { diff --git a/src/WiFi_Protocol.cpp b/src/WiFi_Protocol.cpp new file mode 100644 index 0000000..822d500 --- /dev/null +++ b/src/WiFi_Protocol.cpp @@ -0,0 +1,156 @@ +#include "EggDuino.h" + +#ifdef ESP32 +#include + +namespace +{ +constexpr uint16_t kWifiProtocolPort = 1337; +constexpr uint8_t kInvalidWifiClientId = 0xFF; +constexpr size_t kWifiRxQueueSize = 1024; + +struct WifiRxByte +{ + uint8_t value; +}; + +WebSocketsServer g_wifiProtocolSocket(kWifiProtocolPort); +WifiRxByte g_wifiRxQueue[kWifiRxQueueSize]; +size_t g_wifiRxHead = 0; +size_t g_wifiRxTail = 0; +bool g_wifiRxOverflow = false; +bool g_wifiProtocolStarted = false; +uint8_t g_wifiProtocolClientId = kInvalidWifiClientId; + +bool queueWifiByte(uint8_t value) +{ + const size_t nextHead = (g_wifiRxHead + 1) % kWifiRxQueueSize; + if (nextHead == g_wifiRxTail) + { + g_wifiRxOverflow = true; + return false; + } + + g_wifiRxQueue[g_wifiRxHead].value = value; + g_wifiRxHead = nextHead; + return true; +} + +bool dequeueWifiByte(WifiRxByte *byte) +{ + if ((byte == NULL) || (g_wifiRxHead == g_wifiRxTail)) + { + return false; + } + + *byte = g_wifiRxQueue[g_wifiRxTail]; + g_wifiRxTail = (g_wifiRxTail + 1) % kWifiRxQueueSize; + return true; +} + +void handleWifiSocketEvent(uint8_t clientId, WStype_t type, uint8_t *payload, size_t length) +{ + (void)payload; + + switch (type) + { + case WStype_CONNECTED: + if (g_wifiProtocolClientId == kInvalidWifiClientId) + { + g_wifiProtocolClientId = clientId; + } + Log(String("WiFi protocol client connected: ") + clientId); + break; + + case WStype_DISCONNECTED: + if (g_wifiProtocolClientId == clientId) + { + g_wifiProtocolClientId = kInvalidWifiClientId; + } + Log(String("WiFi protocol client disconnected: ") + clientId); + break; + + case WStype_TEXT: + case WStype_BIN: + if (g_wifiProtocolClientId == kInvalidWifiClientId) + { + g_wifiProtocolClientId = clientId; + } + if (clientId != g_wifiProtocolClientId) + { + break; + } + for (size_t i = 0; i < length; ++i) + { + queueWifiByte(payload[i]); + } + break; + + default: + break; + } +} +} // namespace + +void startWifiProtocolInterface() +{ + if (g_wifiProtocolStarted) + { + return; + } + + if (WiFi.status() != WL_CONNECTED) + { + return; + } + + g_wifiProtocolSocket.begin(); + g_wifiProtocolSocket.onEvent(handleWifiSocketEvent); + g_wifiProtocolStarted = true; + + Log(String("WiFi EggBot protocol ws://") + WiFi.localIP().toString() + ":" + kWifiProtocolPort); +} + +void handleWifiProtocolInterface() +{ + if (!g_wifiProtocolStarted) + { + if (WiFi.status() == WL_CONNECTED) + { + startWifiProtocolInterface(); + } + return; + } + + g_wifiProtocolSocket.loop(); + + if (g_wifiRxOverflow) + { + g_wifiRxOverflow = false; + Log("WiFi protocol RX queue overflow"); + } + + WifiRxByte byte = {0}; + while (dequeueWifiByte(&byte)) + { + setActiveProtocolContext(&g_WifiCmd, PROTOCOL_TRANSPORT_WIFI); + g_WifiCmd.readChar(static_cast(byte.value)); + } +} + +bool wifiProtocolWrite(const char *message) +{ + if (!g_wifiProtocolStarted || (message == NULL)) + { + return false; + } + + if ((g_wifiProtocolClientId == kInvalidWifiClientId) || !g_wifiProtocolSocket.clientIsConnected(g_wifiProtocolClientId)) + { + return false; + } + + return g_wifiProtocolSocket.sendTXT(g_wifiProtocolClientId, message); +} + +#endif diff --git a/src/main.cpp b/src/main.cpp index ce6d084..affb55b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,6 +36,16 @@ FastAccelStepper *g_pStepperPen = NULL; // make Objects Servo penServo; SerialCommand SCmd; +#ifdef ESP32 +SerialCommand g_BLECmd; +SerialCommand g_WifiCmd; +#endif + +namespace +{ +SerialCommand *g_pActiveParser = &SCmd; +ProtocolTransport g_activeTransport = PROTOCOL_TRANSPORT_SERIAL; +} // create Buttons #ifdef prgButton @@ -63,6 +73,52 @@ float fROT_STEP_CORRECTION = 16.0 / rotMicrostep; // devide EBB-Coordinates by t float fPEN_STEP_CORRECTION = 16.0 / penMicrostep; // devide EBB-Coordinates by this factor to get EGGduino-Steps boolean g_bMotorsEnabled = 0; +void setActiveProtocolContext(SerialCommand *parser, ProtocolTransport transport) +{ + if (parser != NULL) + { + g_pActiveParser = parser; + } + g_activeTransport = transport; +} + +char *nextCommandArg() +{ + return g_pActiveParser->next(); +} + +void protocolWrite(const char *message) +{ + if (message == NULL) + { + return; + } + +#ifdef ESP32 + if (g_activeTransport == PROTOCOL_TRANSPORT_BLE) + { + if (bleProtocolWrite(message)) + { + return; + } + } + if (g_activeTransport == PROTOCOL_TRANSPORT_WIFI) + { + if (wifiProtocolWrite(message)) + { + return; + } + } +#endif + + Serial.print(message); +} + +void protocolWrite(const String &message) +{ + protocolWrite(message.c_str()); +} + // Stepper Test #ifdef TEST // #define dirPinStepper 16 @@ -77,6 +133,7 @@ void setup() Log("Starting..."); makeComInterface(); initHardware(); + startBleInterface(); startWebInterface(); } @@ -134,8 +191,11 @@ void loop() } #else // moveOneStep(); + setActiveProtocolContext(&SCmd, PROTOCOL_TRANSPORT_SERIAL); SCmd.readSerial(); + handleBleInterface(); handleWebInterface(); + handleWifiProtocolInterface(); #endif #ifdef penToggleButton