diff --git a/.github/workflows/multi-platform-ci.yaml b/.github/workflows/multi-platform-ci.yaml new file mode 100644 index 0000000..a35c6d5 --- /dev/null +++ b/.github/workflows/multi-platform-ci.yaml @@ -0,0 +1,121 @@ +name: Multi-Platform CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # --- STAGE 1: COMPILATION --- + compile_test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: "Arduino-Uno" + board: "arduino:avr:uno" + platform: "arduino:avr" + board_type: "uno" + - name: "Arduino-Mega-2560" + board: "arduino:avr:mega" + platform: "arduino:avr" + board_type: "mega" + - name: "ESP32-Dev-Module" + board: "esp32:esp32:esp32" + platform: "esp32:esp32" + index_url: "https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json" + board_type: "esp32" + - name: "Arduino-Zero" + board: "arduino:samd:arduino_zero_native" + platform: "arduino:samd" + board_type: "samd21" + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Arduino CLI + uses: arduino/setup-arduino-cli@v2 + + - name: Cache Arduino Platforms + uses: actions/cache@v4 + with: + path: | + ~/.arduino15/packages + ~/.arduino15/staging + # The cache key hashes the YAML file. + # If the matrix changes or a new board is added, the cache automatically resets. + key: ${{ runner.os }}-arduino-${{ hashFiles('.github/workflows/multi-platform-ci.yml') }} + restore-keys: | + ${{ runner.os }}-arduino- + + - name: Install Platforms + run: | + if [ ! -z "${{ matrix.index_url }}" ]; then + arduino-cli core update-index --additional-urls ${{ matrix.index_url }} + arduino-cli core install ${{ matrix.platform }} --additional-urls ${{ matrix.index_url }} + else + arduino-cli core install ${{ matrix.platform }} + fi + + - name: Compile and Stage Binaries + run: | + mkdir -p ./artifacts/${{ matrix.name }} + + # Target the integration tests instead of the example + arduino-cli compile --library . \ + -b ${{ matrix.board }} \ + tests/integration/integration.ino \ + --output-dir ./artifacts/${{ matrix.name }} \ + --export-binaries # This is critical for ESP32/SAMD bootability + + - name: Upload Binaries + uses: actions/upload-artifact@v4 + with: + # Naming the artifact clearly as an integration test binary + name: test-binaries-${{ matrix.name }}-${{ github.run_id }} + path: ./artifacts/${{ matrix.name }}/ + retention-days: 1 + + # --- STAGE 2: NATIVE LOGIC TEST (Zero-Heap & Pointer Validation) --- + logic_test: + needs: compile_test + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Dependencies + run: | + python3 -m pip install pyyaml + # Clone EpoxyDuino to compile Arduino code natively on Linux + git clone https://github.com/bxparks/EpoxyDuino.git + + - name: Compile Native Executable + run: | + # 1. Generate the Makefile on the fly + cat << 'EOF' > tests/integration/Makefile + APP_NAME := integration + CXXFLAGS += -I../../ + include $(EPOXY_DUINO_DIR)/EpoxyDuino.mk + EOF + + # 2. Compile it + export EPOXY_DUINO_DIR=$PWD/EpoxyDuino + make -C tests/integration + + - name: Run Native Simulation + run: | + # Run the Python test harness against the native executable + python3 tests/integration/yaml_runner.py --yaml tests/integration/universal_test.yaml --binary tests/integration/integration.out \ No newline at end of file diff --git a/README.md b/README.md index 810993a..bc1f6bf 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # SerialCommandCoordinator -## Origin -I was working on a control system prototype and wanted a more interacve way to jump between diagnostic modes when working with an Arduino board, and its connected peripherals, than maintaining multiple sketch versions and flashing the board each time I needed to run it in a different mode. The solution had to have a compact enough memory footprint to be useful on the ATmega328P, yet verbose enough that someone could connect to it via a serial port to run these diagnostics. I couldn't find an open source library I felt was both efficient and simple enough so I wrote my own. - ## How it Works -Given an initialized serial stream in the Arduino ecosystem that inherits from Stream (e.g. HardwareSerial or SoftwareSerial), the SerialCommandCoordinator maps a registered serial command, in the form of null terminated character array, received from said stream to functions via a function's memory address. These values are stored and accessed via two, parallel, unsorted arrays allocated on the heap. Since the number of commands will likely be small, performing a search of an unsorted array for mode switching is sufficient as memory footprint is valued over latency. +The **SerialCommandCoordinator** maps serial string commands to function addresses without using the heap, making it ideal for memory-constrained environments like the ATmega328P. +Unlike previous versions that relied on dynamic memory, this revamped library uses **C++ Templates** to allocate command lists and buffers statically at compile-time. Command strings are stored directly in **Program Memory (Flash)** using the `F()` macro, ensuring that SRAM is preserved for your application logic. The library operates using a non-blocking state machine, allowing your main loop to continue running while serial data is being gathered. ## How to Use It ### Main Workflow -The main workflow for using the SerialCommandCoordinator is as follows: -1. Initialize the serial stream and SerialCommandCoordinator object -2. Register a command with function -3. Wait for input and run the command if matching input arrives -``` C++ +1. **Initialize**: Declare the object as a template. You can specify the maximum number of commands and the buffer size, or use the optimized defaults. +2. **Register**: Map string literals (in Flash) to `void` functions. +3. **Update**: Call `update()` in your main loop to automatically handle input and execution. + +```cpp +#include -SerialCommandCoordinator scc(Serial); +// Initialize with defaults: 8 commands, 32-byte buffer +SerialCommandCoordinator<> scc(Serial); void performLampTest() { // code to turn on and off lamp @@ -27,84 +27,130 @@ void scaleTest() { void setup() { Serial.begin(9600); - scc.registerCommand("lampTest", &performLampTest); - scc.registerCommand("scaleTest", &performScaleTest); + + // Register commands using the F() macro to save SRAM + scc.registerCommand(F("lampTest"), &performLampTest); + scc.registerCommand(F("scaleTest"), &scaleTest); } -void loop { - if (scc.receiveCommandInput()) { - scc.runSelectedCommand(); - } +void loop() { + // Non-blocking update: checks serial and runs commands automatically + scc.update(); + + // Your other application code runs freely here } ``` -Now, (e.g. using the Arduino IDE and Serial Monitor), when you enter the text **lampTest** in the Serial Monitor, the function ```performLampTest()``` will execute each time the member function ```runSelectedCommand()``` is called. In the example above this will only occur once each time the registered command **lampTest** is entered. -### Setting an Exit Case -There may be instances where it would be advantageous to return to the **receiveCommandInput** loop defined in the previous section. This is just one example of how to exit an operation back to a SerialCommandCoordinator loop, or even exit a SerialCommandCoordinator mode: -``` C++ -bool inDiagnosticMode = true; +### Breaking Out of Loops +When a registered function initiates a persistent execution loop (e.g., a continuous sensor polling routine), it occupies the processor's execution context, preventing the primary `SerialCommandCoordinator::update()` cycle from monitoring the serial stream. To maintain interactivity without exiting the local scope, the `checkForBreak()` method enables the function to perform a non-blocking poll of the serial buffer for a specific termination signal. +``` void scaleTest() { - bool exec = true; - while (exec) { - printFormattedScaleValues(); - - if (Serial.available()) { - char temp = Serial.read(); - if(temp == 'q') { - clearSerialBuffer(); - exec = false; - } + Serial.println(F("Scale Test active. Press '!' to stop.")); + + while (true) { + // Perform diagnostic work + printScaleData(); + + // Check for the break character (default is '!') + if (scc.checkForBreak()) { + Serial.println(F("Exiting Test...")); + return; // Jumps back to scc.update() in the main loop } } } +``` -void diagnosticMode() { - SerialCommandCoordinator scc(Serial); - scc.registerCommand("scaleTest", &scaleTest); - scc.registerCommand("buttonTest", &buttonTest); - scc.printCommandList(); +### Passing Parameters +To maintain a minimal memory footprint, the library provides a command-length agnostic method for retrieving arguments. The getParam() method scans the internal buffer for the first space delimiter and returns a zero-copy pointer to the start of the parameter payload. - while (inDiagnosticMode) { - if (scc.receiveCommandInput()) { - scc.runSelectedCommand(); - scc.printCommandList(); - } +``` +void setMotorSpeed() { + // Automatically locates the data following the command + const char* param = scc.getParam(); + + if (param != nullptr) { + int speed = atoi(param); + analogWrite(MOTOR_PIN, speed); + + Serial.print(F("Motor speed set to: ")); + Serial.println(speed); + } else { + Serial.println(F("Error: No parameter provided.")); } +} +void setup() { + Serial.begin(9600); + scc.registerCommand(F("speed"), &setMotorSpeed); } ``` -Note: There are plans to internalize this inside the class to allow for more flexability in use case. - -### Considerations -#### Initialization and Destruction -There is not a way to remove or change registered commands as the intent is to initialize once and use either throughout the duration of a program, or until the object is destroyed. This design choice was made in an attempt to reduce the chance of memory fragmentation on the heap, as well as keep the memory footprint of the SerialCommandCoordinator object small. - -Note: There are plans to change the allocation of the buffers containing only references to non-managed memory to be allocated on the stack instead of the heap in additional overloaded constructors. - -#### Baud Rate -The initial baud rate is set to 9600. If set different in Serial.begin(), the baud rate should also be set for the SerialCommandCoordinator using ```setBaudRate()```. This is to prevent a race condition where the entirety of the serial buffer is read such that the stream.available() shows no new data is present, but no ending character is reached. This can cause data that fills the serial buffer to treated as new input again when it is, instead, intended to be part of the previous input string. The delay equates for the minimum theoretical delay it should take to fill the Arduino's predefined 64 byte serial buffer based on the baud rate, between reads of said buffer. - -Note: There are plans to add this to an overload of the class constructor. - -#### Limit on Commands -The command list size is currently set to 8 commands. The functions referenced do not support parameters and should be of void return type. - -Note: There are plans to add a command list size to an overload of the class constructor. - - ### Public Members - This is a quick reference. Detailed descriptions of these members can be found in the SerialCommandCoordinator.h file - ``` C++ -SerialCommandCoordinator(Stream &device); -SerialCommandCoordinator(Stream *device); -virtual ~SerialCommandCoordinator(); -bool receiveInput(); -bool receiveCommandInput(); -bool registerCommand(const char *command, const void (*function)(void)); -void runSelectedCommand(); -void printCommandList(); -void printInputBuffer(); -void setBaudRate(long baudRate); -const char* getSerialBuffer(); -void testStream(); - ``` + +### Interactive Sub-Modules +For complex diagnostics like sensor calibration or manual motor stepping, you can enter a dedicated execution loop. This allows the system to process single-character instructions instantly. + +> Note: Do not use getParam() inside a persistent while loop. Because getParam() relies on the internal buffer populated by the main update() cycle, calling it within a local loop will result in a logic spinlock, where the function reads stale data indefinitely. For real-time interactivity, use readChar(). + +``` +void manualStepMode() { + Serial.println(F("Manual Mode: [+] Forward, [-] Backward, [!] Exit")); + + while (true) { + // 1. Check for the template-defined break character ('!') + if (scc.checkForBreak()) { + Serial.println(F("Exiting...")); + return; + } + + // 2. Use readChar() for real-time stream interaction + char input = scc.readChar(); + + if (input == '+') { + stepMotor(1); + } else if (input == '-') { + stepMotor(-1); + } + } +} +``` + +### Advanced Initialization +Because this is a template-based library, you can customize the memory footprint based on your specific hardware needs without editing the library source as well as the loop break character and serial end marker: +``` +// Default: 8 commands, 32-byte buffer, '!' break, '\n' end +SerialCommandCoordinator<> scc(Serial); + +// Custom sizing: 12 commands, 64-byte buffer +SerialCommandCoordinator<12, 64> scc(Serial); + +// Full protocol override: 10 commands, 32-byte buffer, q break, CR end marker +SerialCommandCoordinator<10, 32, 'q', '\r'> scc(Serial); +``` + +## Considerations +### Zero-Heap Allocation +This library has been re-designed to eliminate malloc, free, and calloc. All arrays are fixed-size and allocated in the Static Data section of the RAM. This prevents runtime crashes due to heap fragmentation and allows the compiler to provide accurate memory usage reports during the build process. + +### Non-Blocking & Overflow Protection +The library no longer uses delay() or timing-based reads. It features a Discarding State: if an incoming command exceeds the defined BUFFER_SIZE, the utility enters a non-blocking "ignore" mode until the next newline is reached. This protects the system from processing "garbage" data without halting your program. + +### Flash Memory (PROGMEM) +To maximize SRAM efficiency on 8-bit AVR boards, all registered command strings are stored in Flash. This is why the F() macro is required during registration. On 32-bit boards (ESP32, ARM), the library automatically aliases to compatible types, maintaining a unified codebase. + +## CI Testing +This library uses a two-stage CI/CD pipeline to verify logic across multiple architectures. + +### Compilation Matrix +The code is compiled against the following to verify the "Zero-SRAM" footprint and handle 16-bit vs 32-bit word size differences: + +* AVR (8-bit): Uno (ATmega328P), Mega 2560. +* ESP32 (32-bit): Xtensa and RISC-V cores. +* ARM (32-bit): SAMD21 (Cortex-M0+). + +### Functional Simulation ([EpoxyDuino](https://github.com/bxparks/EpoxyDuino)) +We leverage Host-Side Testing (compiling natively for Linux via EpoxyDuino) to validate the core library logic at maximum velocity. This stage bypasses hardware emulation to focus on: + +* **Command Parsing**: Callback execution and parameter extraction. +* **Sub-mode Logic**: Real-time polling via readChar() in sub-routines. +* **Buffer Safety**: Automatic recovery after input exceeds the 64-byte limit. +* **Line-Endings**: Compatibility with both Unix (\n) and Windows (\r\n) terminators. diff --git a/SerialCommandCoordinator.cpp b/SerialCommandCoordinator.cpp deleted file mode 100644 index 097807b..0000000 --- a/SerialCommandCoordinator.cpp +++ /dev/null @@ -1,185 +0,0 @@ -/** - * - * SerialCommandCoordinator library for Arduino - * v0.0.1 - * https://github.com/mattrussmill/SerialCommandCoordinator - * - * MIT License - * (c) 2023 Matthew Miller - * -**/ -#include -#include -#include "SerialCommandCoordinator.h" - -SerialCommandCoordinator::SerialCommandCoordinator(Stream &device) : _device(&device) { - init(); -} - -SerialCommandCoordinator::SerialCommandCoordinator(Stream *device) : _device(device) { - init(); -} - -SerialCommandCoordinator::~SerialCommandCoordinator() { - if (_inputBuffer) { - free(_inputBuffer); - } - - // currently manages all command string memory - if (_commandList) { - for (int i = 0; i < _commandListSize; i++) { - if (_commandList[i]) { - free(_commandList[i]); - } - } - free(_commandList); - } - - if (_functionList) { - free(_functionList); - } -} - -bool SerialCommandCoordinator::receiveInput() { - uint8_t ndx = 0; - bool newInput = false; - char rc; - if (_device->available() > 0){ - delay(_inputDelay); - while (_device->available() > 0 && newInput == false) { - rc = _device->read(); - - if (rc != _endMarker && ndx < _inputBufferSize - 1) { - _inputBuffer[ndx] = rc; - ndx++; - - } else { - _inputBuffer[ndx] = '\0'; // terminate the string - newInput = true; - - if (rc != _endMarker && ndx >= _inputBufferSize - 1) { - _inputValid = false; // input string too large for buffer - - while (_device->available() > 0 ) { - while (_device->available() > 0 ) { - _device->read(); // clear the remaining serial buffer - } - delay(_deviceDelay); - } - - } else { - _inputValid = true; - } - } - } - } - - return newInput && _inputValid; -} - -bool SerialCommandCoordinator::receiveCommandInput() { - if (receiveInput()) { - return setSelectedFunction(); - } - return false; -} - -void SerialCommandCoordinator::printInputBuffer() { - _device->println(_inputBuffer); -} - -void SerialCommandCoordinator::setBaudRate(long baudRate) { - if (baudRate <= 0) { - return; - } - _inputDelay = ceil(1000.0 / ((double)(baudRate / 10) / (double)_inputBufferSize)) ; // 1000 for ms conversion - _deviceDelay = ceil(1000.0 / ((double)(baudRate / 10) / 64.0)); // 64 is Arduino standard serial buffer size -} - -bool SerialCommandCoordinator::registerCommand(const char *command, const void (*function)(void)) { - if (command == nullptr || function == nullptr) { - return false; - } - - // find next empty spot in list - int ndx = 0; - while (ndx < _commandListSize) { - // find next empty spot in list - if (_commandList[ndx] == nullptr) { - break; - - // command already in list - } else if (strcmp(_commandList[ndx], command) == 0) { - return false; - } - ndx++; - } - - // Command buffer full, cannot register command; - if (ndx >= _commandListSize) { - return false; - } - - // Store command and function address - int size = strlen(command) + 1; - _commandList[ndx] = (char*) malloc(size * sizeof(char)); - if (_commandList[ndx] == nullptr) { - return false; - } - strcpy(_commandList[ndx], command); - _functionList[ndx] = function; - return true; - -} - -void SerialCommandCoordinator::runSelectedCommand() { - if (!_inputValid || _functionSelected == nullptr) { - return; - } - (*_functionSelected)(); -} - -void SerialCommandCoordinator::printCommandList() { - for (int i = 0; i < _commandListSize; i++) { - if (_commandList[i] != nullptr) { - _device->println(_commandList[i]); - } - } -} - -void SerialCommandCoordinator::testStream() { - _device->println("Hello World!"); -} - -void SerialCommandCoordinator::init() { - _inputBuffer = (char*) calloc(_inputBufferSize, sizeof(char)); - _commandList = (char**) calloc(_commandListSize, sizeof(char*)); - _functionList = (func_ptr_t*) calloc(_commandListSize, sizeof(func_ptr_t*)); - - if (_inputBuffer == nullptr || _commandList == nullptr || _functionList == nullptr) { - abort(); - } -} - -bool SerialCommandCoordinator::setSelectedFunction() { - int ndx = 0; - _functionSelected = nullptr; - - while (ndx < _commandListSize && _inputValid) { - - // registered functions can't be removed, nullptr is end of list = not found - if (_commandList[ndx] == nullptr) { - return false; - } - - // found function - if (strcmp(_inputBuffer, _commandList[ndx]) == 0) { - _functionSelected = _functionList[ndx]; - return true; - } - ndx++; - } - - // not found in list - return false; -} diff --git a/SerialCommandCoordinator.h b/SerialCommandCoordinator.h index c5a708a..d0de94c 100644 --- a/SerialCommandCoordinator.h +++ b/SerialCommandCoordinator.h @@ -1,98 +1,307 @@ /** - * - * SerialCommandCoordinator library for Arduino - * v0.0.1 - * https://github.com/mattrussmill/SerialCommandCoordinator - * - * MIT License + * @file SerialCommandCoordinator.h + * @author Matthew Miller + * @brief A memory-efficient, non-blocking serial command dispatcher for Arduino. + * @version 0.1.0 + * * MIT License * (c) 2023 Matthew Miller - * **/ + #ifndef SERIALCOMMANDCOORDINATOR_h #define SERIALCOMMANDCOORDINATOR_h +#ifndef SERIAL_RX_BUFFER_SIZE + #define SERIAL_RX_BUFFER_SIZE 64 +#endif + #include "Arduino.h" +#if defined(__AVR__) + #include +#else + /** * @brief Unified memory (32-bit) compatibility layer. + * 'const' ensures the compiler keeps data in Flash to save SRAM. + * Macros alias AVR Flash-functions to standard C for portability. + */ + #ifndef PGM_P + #define PGM_P const char* + #endif + + #ifndef strcmp_P + #define strcmp_P strcmp + #endif +#endif + +/** + * @class SerialCommandCoordinator + * @brief Maps serial string inputs to function addresses without using the heap. + * * @tparam MAX_COMMANDS Maximum number of commands that can be registered. + * @tparam BUFFER_SIZE Size of the internal RX buffer (defaults to half of hardware buffer). + */ +template< + size_t MAX_COMMANDS = 8, + uint8_t BUFFER_SIZE = (SERIAL_RX_BUFFER_SIZE / 2), + char DEFAULT_BREAK = '!', + char END_MARKER = '\n' +> class SerialCommandCoordinator { public: - - SerialCommandCoordinator(Stream &device); - SerialCommandCoordinator(Stream *device); - virtual ~SerialCommandCoordinator(); - - // Checks if there is data available in the serial receive buffer. If - // there is, it copies the data byte by byte into _inputBuffer until the - // predefined _endMarker is reached (preset to \n) and sets _inputValid to - // true. If the serial stream would overflow _inputBufferSize, the - // data is truncated (to _inputBufferSize - 2), and a null terminator is - // appended at the end of the _inputBuffer (_inputBufferSize - 1) instead - // of where _endMarker defines. The remaining data in the serial receive - // buffer is emptied and _inputValid is set to false. Returns _inputValid - // if new input data is available, else returns false. - bool receiveInput(); - - // First calls receiveInput. If receiveInput is successful, _inputBuffer - // contains a valid string, attempts to set the selected command with - // setSelectedFunction(). If successful, returns true and the selected - // command can be run via runSelectedCommand(). If the command is not - // recognized, no command will be pre-selected and returns false. - bool receiveCommandInput(); - - // Given a null terminated string and function address, attempts to register - // a command with its intended routine. It fails and returns false if the - // command is already in the list, the list is full, nullptr is an argument, - // or allocation of memory to store command fails. Returns true on success. - bool registerCommand(const char *command, const void (*function)(void)); - - // If the last call to receiveCommandInput() is successful, will run the most - // recently selected function matching a valid command from the _commandList. - void runSelectedCommand(); - - // Prints all commands currently registered in the _commandList. - void printCommandList(); - - // Prints the current value stored in the _inputBuffer. - void printInputBuffer(); - - // Sets the appropriate delay time for reading long strings of text from the input _device. - void setBaudRate(long baudRate); - - // Returns a pointer to the _inputBuffer for use outside of the class. - const char* getSerialBuffer(); + /** @brief Construct using a reference to a Stream (e.g., Serial). */ + SerialCommandCoordinator(Stream &device) : _device(&device) {} + + /** @brief Construct using a pointer to a Stream. */ + SerialCommandCoordinator(Stream *device) : _device(device) {} - // Stream test function. Prints a single line to the stream reference for testing purposes. - void testStream(); + virtual ~SerialCommandCoordinator() {} + + /** + * @brief Given a null terminated string and function address, attempts to register + * a command with its intended routine. + * * @param command A string literal wrapped in the F() macro. + * @param function Pointer to a void function with no parameters. + * @return Fails and returns false if the command is already in the list, + * the list is full, or nullptr is an argument. Returns true on success. + */ +bool registerCommand(const __FlashStringHelper *command, void (*function)(void)) { if (command == nullptr || function == nullptr) { + return false; + } + + // find next empty spot in list + int ndx = 0; + while (ndx < MAX_COMMANDS) { + if (_commandList[ndx] == nullptr) break; + + // command already in list - architecture-aware comparison + if (strcmp_P((const char*)command, (PGM_P)_commandList[ndx]) == 0) return false; + ndx++; + } + + // Command buffer full, cannot register command + if (ndx >= MAX_COMMANDS) return false; + + _commandList[ndx] = command; + _functionList[ndx] = (func_ptr_t)function; + return true; + } + + /** + * @brief The primary non-blocking execution entry point. + * * Checks for new serial data and executes a matching command if a full line + * (ending in END_MARKER) is received. Should be called once per loop(). + */ + void update() { + if (receiveCommandInput()) { + runSelectedCommand(); + } + } + + /** + * @brief If the last call to receiveCommandInput() is successful, will run the + * most recently selected function matching a valid command from the _commandList. + */ + void runSelectedCommand() { + if (!_inputValid || _functionSelected == nullptr) { + return; + } + (*_functionSelected)(); + } + + /** @brief Prints all commands currently registered in the _commandList. */ + void printCommandList() { + for (int i = 0; i < MAX_COMMANDS; i++) { + if (_commandList[i] != nullptr) { + _device->println(_commandList[i]); + } + } + } + + /** + * @brief Checks the serial stream for the designated break character to exit a local loop. + * * This allows a function that is executing its own internal loop to poll the stream + * for a termination signal. It uses peek() to check the next available character + * without consuming it unless it matches the DEFAULT_BREAK. + * * @return true if the break character was detected and consumed. + */ + bool checkForBreak() { + if (_device->available() > 0) { + if (_device->peek() == DEFAULT_BREAK) { + _device->read(); // Consume the break character + return true; + } + } + return false; + } + + /** + * @brief Returns a pointer to the start of the parameters trailing the command. + * * Scans for the first space delimiter and increments past any whitespace + * to find the actual payload. + * * @return A pointer to the parameter payload, or nullptr if no parameters exist. + */ + const char* getParam() { + const char* buf = getSerialBuffer(); + size_t i = 0; + + // 1. Scan until we hit a space or the end of the null-terminated string + while (buf[i] != ' ' && buf[i] != '\0') { + i++; + } + + // 2. If we found a space, skip over ALL consecutive spaces + // to find the start of the actual data. + while (buf[i] == ' ') { + i++; + } + + // 3. If we aren't at the end of the string, this is our parameter start. + if (buf[i] != '\0') { + return &buf[i]; + } + + return nullptr; // No valid parameters found + } + + /** + * @brief Polls the stream for the next valid character, instantly + * flushing any terminators. + * @return The character read, or 0 if no valid data is available. + */ + char readChar() { + while (_device->available() > 0) { + // If we see the break character, let checkForBreak() + // handle it on the next loop. + if (_device->peek() == DEFAULT_BREAK) { + return 0; + } + + // Otherwise, safely consume the character + char c = _device->read(); + + // Ignore the end marker so it doesn't interfere with logic loops + if (c != END_MARKER && c != '\r') { + return c; + } + } + return 0; // Buffer is empty, or only contained terminators + } + + /** @brief Prints the current value stored in the _inputBuffer. */ + void printInputBuffer() { + _device->println(_inputBuffer); + } + + /** @brief Returns a pointer to the _inputBuffer for use outside of the class. */ + const char* getSerialBuffer() { return _inputBuffer; } private: - typedef void(*func_ptr_t)(void); // Type definition for function pointer (for readability). + typedef void(*func_ptr_t)(void); + + /** + * @brief Checks the serial stream for available data without blocking. + * * If bytes are present, they are appended to _inputBuffer at _bufferIndex. + * Returns true only when the END_MARKER is detected (completing a command) + * or the buffer overflows. Returns false if the command is still incomplete + * or no data is available, allowing the main loop to continue. + */ + bool receiveInput() { + char rc; + + while (_device->available() > 0) { + rc = _device->read(); + + // Intercept and ignore Windows carriage returns immediately + if (rc == '\r') continue; + + // Normal processing for all other characters + if (rc != END_MARKER) { + if (_discarding) continue; + + // check for buffer overflow + if (_bufferIndex < BUFFER_SIZE - 1) { + _inputBuffer[_bufferIndex] = rc; + _bufferIndex++; + } else { + // buffer is full: enter discard state to protect next command + _inputBuffer[_bufferIndex] = '\0'; // terminate the string + _inputValid = false; // input string too large for buffer + _discarding = true; + } + } else { + // end marker reached + bool wasDiscarding = _discarding; + _inputBuffer[_bufferIndex] = '\0'; + _bufferIndex = 0; // reset index for the next command + _discarding = false; // Reset state for next command + + if (wasDiscarding) { + _inputValid = false; + return false; // Silently drop over-sized command + } + + _inputValid = true; + return true; + } + } + return false; // full command not received yet + } + + /** + * @brief First calls receiveInput. + * * If receiveInput is successful, _inputBuffer contains a valid string, + * attempts to set the selected command with setSelectedFunction(). + * @return true if successful; the selected command can be run via runSelectedCommand(). + * If the command is not recognized, no command will be pre-selected and returns false. + */ + bool receiveCommandInput() { + if (receiveInput()) { + return setSelectedFunction(); + } + return false; + } + + /** + * @brief Sets the function to be selected. + * * registered functions can't be removed, nullptr is end of list = not found. + * @return true if a function was found and selected. + */ + bool setSelectedFunction() { + _functionSelected = nullptr; - // Initializing code to be shared between all constructors - void init(); + // Temporarily null-terminate at the first space + char* spacePos = strchr(_inputBuffer, ' '); + if (spacePos != nullptr) *spacePos = '\0'; - // Sets the function to be selected - bool setSelectedFunction(); + int ndx = 0; + while (ndx < MAX_COMMANDS && _inputValid) { + if (_commandList[ndx] == nullptr) break; - Stream *_device = nullptr; // Address to input stream. - uint8_t _deviceDelay = 67; // *Minimum delay in ms, at the provided baud rate, to fill the _device buffer - char _endMarker = '\n'; // Designated end marker for input stream. + // Exact match check + if (strcmp_P(_inputBuffer, (PGM_P)_commandList[ndx]) == 0) { + _functionSelected = _functionList[ndx]; + + // Restore the space so getParam() still works + if (spacePos != nullptr) *spacePos = ' '; + return true; + } + ndx++; + } - bool _inputValid = false; // State of input buffer fitting entirely within the _inputBuffer. - uint8_t _inputBufferSize = 32; // Size of the _inputBuffer to be allocated. - uint8_t _inputDelay = 34; // *Minimum delay in ms, at the provided baud rate, to fill the _inputBuffer - char *_inputBuffer = nullptr; // Input buffer address for stream input. + // Restore the space if the command was invalid + if (spacePos != nullptr) *spacePos = ' '; + return false; + } - uint8_t _commandListSize = 8; // Shared index for commands and functions. - char **_commandList = nullptr; // List of addresses for registered commands. - func_ptr_t *_functionList = nullptr; // List of addresses for registered functions. - func_ptr_t _functionSelected = nullptr; // Selected function to be run with runSelectedCommand(). + Stream *_device = nullptr; ///< Address to input stream. + uint8_t _bufferIndex = 0; ///< Current position in _inputBuffer; persists between calls for non-blocking reads. + bool _discarding = false; ///< State used to ignore characters after an overflow until END_MARKER. - // * Initial delay value is based on 9600 baud rate using the following conversion: - // => 1 / ( (baud rate bytes / sec) / buffer-size bytes) - // => assuming 1 byte is 10 bits (1 start & 1 stop bit) - // => _inputDelay = 1000 / ( (960 bytes / sec) / _inputBufferSize() bytes ) (e.g. based on 32 byte buffer size) - // => _deviceDelay = 1000 / ( (960 bytes / sec) / SERIAL_BUFFER_SIZE bytes ) (e.g. hardcoded as 64 in HardwareSerial.cpp for Arduino ecosystem) + bool _inputValid = false; ///< State of input buffer fitting entirely within the _inputBuffer. + char _inputBuffer[BUFFER_SIZE] = {0}; ///< Input buffer address for stream input. Stored statically (Zero-Heap). + const __FlashStringHelper *_commandList[MAX_COMMANDS] = {nullptr}; ///< List of addresses for registered command strings stored in flash. + func_ptr_t _functionList[MAX_COMMANDS] = {nullptr}; ///< List of addresses for registered functions. + func_ptr_t _functionSelected = nullptr; ///< Selected function to be run with runSelectedCommand(). }; -#endif /* SERIALCOMMANDCOORDINATOR_h */ +#endif /* SERIALCOMMANDCOORDINATOR_h */ \ No newline at end of file diff --git a/tests/integration/Make b/tests/integration/Make new file mode 100644 index 0000000..79807a9 --- /dev/null +++ b/tests/integration/Make @@ -0,0 +1,7 @@ +# tests/integration/Makefile +APP_NAME := integration.out +ARDUINO_LIBS := SerialCommandCoordinator + +# Point to where your library source files live relative to this directory +USER_LIB_DIRS := ../../ +include $(EPOXY_CORE_PATH)/EpoxyCore.mk diff --git a/tests/integration/integration.ino b/tests/integration/integration.ino new file mode 100644 index 0000000..7c81fb0 --- /dev/null +++ b/tests/integration/integration.ino @@ -0,0 +1,64 @@ +#include + +// 10 commands, 64-byte buffer, '!' break, '\n' end marker +SerialCommandCoordinator<10, 64, '!', '\n'> scc(Serial); + +// Wrappers to convert member calls to void pointers for registerCommand +void handlePing() { + Serial.println(F("PONG")); +} + +void handleSetLimit() { + const char* val = scc.getParam(); // Access global scc + if (val) { + Serial.print(F("LIMIT_SET:")); + Serial.println(val); + } else { + Serial.println(F("ERROR:MISSING_VAL")); + } +} + +void handleStatus() { + Serial.println(F("STATUS:OK")); +} + +void runManualJog() { + Serial.println(F("MODE:JOG")); + while (true) { + // TEST 7: Exit back to main loop via Break Character + if (scc.checkForBreak()) { + Serial.println(F("MODE:MAIN")); + break; + } + + char c = scc.readChar(); + if (c == '+') Serial.println(F("UP")); + else if (c == '-') Serial.println(F("DOWN")); + } +} + +void handleJog() { + runManualJog(); +} + +void setup() { + Serial.begin(115200); + + // TEST 1 & 2: Wrap strings in F() to match __FlashStringHelper* + scc.registerCommand(F("ping"), handlePing); + + // TEST 3 & 4: Command WITH parameters + scc.registerCommand(F("set-limit"), handleSetLimit); + + // TEST 5: Status check + scc.registerCommand(F("status"), handleStatus); + + // TEST 6: Interactive Command + scc.registerCommand(F("jog"), handleJog); + + Serial.println(F("SYSTEM_READY")); +} + +void loop() { + scc.update(); +} \ No newline at end of file diff --git a/tests/integration/universal_test.yaml b/tests/integration/universal_test.yaml new file mode 100644 index 0000000..baf0bda --- /dev/null +++ b/tests/integration/universal_test.yaml @@ -0,0 +1,42 @@ +name: 'Comprehensive Workflow Test' +version: 1 +steps: + - wait-serial: 'SYSTEM_READY' + + # 1. Test Parameterless Command (Style: Trigger) + - write-serial: "ping\n" + - wait-serial: 'PONG' + + # 2. Test Windows-Style Line Endings (\r\n) + # Verifies the parser correctly ignores \r and triggers on \n + - write-serial: "ping\r\n" + - wait-serial: 'PONG' + + # 3. Test Parameterized Command (Style: Config) + - write-serial: "set-limit 450\n" + - wait-serial: 'LIMIT_SET:450' + + # 4. Test Parameterized Command - Missing Value Edge Case + - write-serial: "set-limit \n" + - wait-serial: 'ERROR:MISSING_VAL' + + # 5. Test Buffer Safety / Discarding State + # Sending > 64 chars forces the internal discarding state. + # We verify recovery by ensuring the 'status' command still works afterward. + - write-serial: "this_is_an_extremely_long_string_designed_to_force_the_buffer_into_a_discarding_state_immediately\n" + - write-serial: "status\n" + - wait-serial: 'STATUS:OK' + + # 6. Test Interactive Command (Style: Sub-mode) + - write-serial: "jog\n" + - wait-serial: 'MODE:JOG' + - write-serial: "++-" + - wait-serial: 'UP' + - wait-serial: 'UP' + - wait-serial: 'DOWN' + + # 7. Exit back to main and verify parameterless command again + - write-serial: "!" + - wait-serial: 'MODE:MAIN' + - write-serial: "ping\n" + - wait-serial: 'PONG' \ No newline at end of file diff --git a/tests/integration/yaml_runner.py b/tests/integration/yaml_runner.py new file mode 100644 index 0000000..240f709 --- /dev/null +++ b/tests/integration/yaml_runner.py @@ -0,0 +1,90 @@ +import yaml +import subprocess +import time +import sys +import argparse +import os +import fcntl + +def run_scenario(yaml_path, binary_path): + try: + with open(yaml_path, 'r') as f: + scenario = yaml.safe_load(f) + except Exception as e: + print(f"Error loading YAML: {e}") + sys.exit(1) + + print(f"--- Executing Scenario: {scenario.get('name', 'Unknown')} ---") + + # Spawn the native Linux executable to handle bytes natively + process = subprocess.Popen( + [binary_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT + ) + + # Make stdout non-blocking so our timeout logic works + flags = fcntl.fcntl(process.stdout, fcntl.F_GETFL) + fcntl.fcntl(process.stdout, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + buffer = "" + + for step in scenario.get('steps', []): + + # --- COMMAND: wait-serial --- + if 'wait-serial' in step: + target = step['wait-serial'] + print(f"Waiting for: '{target}'...") + + start_time = time.time() + while target not in buffer: + if time.time() - start_time > 10.0: + print(f"\n[TIMEOUT] Expected '{target}' but buffer contained: {repr(buffer)}") + process.kill() + sys.exit(1) + + try: + # Read raw bytes directly from the OS file descriptor + raw_data = os.read(process.stdout.fileno(), 1024) + if raw_data: + buffer += raw_data.decode('utf-8', errors='ignore') + except BlockingIOError: + pass # Normal non-blocking behavior (no data right now) + + time.sleep(0.01) # Yield to CPU + + print(f" -> Found '{target}'") + buffer = buffer[buffer.find(target) + len(target):] + + # --- COMMAND: write-serial --- + elif 'write-serial' in step: + payload = step['write-serial'] + payload = payload.encode('utf-8').decode('unicode_escape') + print(f"Writing: {repr(payload)}") + + # Write bytes to stdin + process.stdin.write(payload.encode('utf-8')) + process.stdin.flush() + + # --- COMMAND: delay --- + elif 'delay' in step: + delay_str = str(step['delay']) + print(f"Delaying for {delay_str}...") + if delay_str.endswith('ms'): + time.sleep(int(delay_str[:-2]) / 1000.0) + elif delay_str.endswith('s'): + time.sleep(float(delay_str[:-1])) + else: + time.sleep(float(delay_str)) + + print("--- Scenario Completed Successfully! ---") + process.terminate() + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('--yaml', required=True, help="Path to universal_test.yaml") + parser.add_argument('--binary', required=True, help="Path to the compiled executable") + args = parser.parse_args() + + run_scenario(args.yaml, args.binary) \ No newline at end of file