diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ba40981..eb4cd62 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -33,7 +33,7 @@ firmware/ # Single unified PlatformIO project │ ├── receiver/ # Static HTML/CSS/JS for receiver web UI │ └── transmitter/ # Static HTML/CSS/JS for transmitter web UI ├── include/ # lv_conf.h, User_Setup.h (TFT_eSPI pin config) -├── sim/ # Simulation shim headers + sources for sim_rx / sim_tx +├── sim/ # Simulation shim headers + sources for sim_rx / sim_tx / sim_tx_gui │ ├── include/ # Arduino.h, Wire.h, esp_now.h, Preferences.h, etc. │ ├── src/ # sim_arduino.cpp, sim_espnow.cpp, sim_freertos.cpp, etc. │ └── sim_build.py # PlatformIO extra_script @@ -51,7 +51,8 @@ hardware/ # Hardware design docs (transmitter, receiver, module | `transmitter` | `espressif32` | ESP32 transmitter firmware | `ODH_TRANSMITTER` | | `native` | `native` | Host unit tests (Unity) | `NATIVE_TEST` | | `sim_rx` | `native` | Receiver simulation (terminal) | `NATIVE_SIM`, `ODH_RECEIVER`, `SIM_RX` | -| `sim_tx` | `native` | Transmitter simulation (SDL2) | `NATIVE_SIM`, `ODH_TRANSMITTER`, `SIM_TX` | +| `sim_tx` | `native` | Transmitter simulation (headless terminal) | `NATIVE_SIM`, `ODH_TRANSMITTER`, `SIM_TX`, `ODH_HEADLESS` | +| `sim_tx_gui` | `native` | Transmitter simulation (SDL2) | `NATIVE_SIM`, `ODH_TRANSMITTER`, `SIM_TX` | ### Build Commands @@ -61,7 +62,8 @@ pio run -e receiver # Build receiver pio run -e transmitter # Build transmitter pio test -e native # Run 90 unit tests pio run -e sim_rx -t exec # Run receiver simulation -pio run -e sim_tx -t exec # Run transmitter simulation (SDL2 window) +pio run -e sim_tx -t exec # Run transmitter simulation (headless terminal) +pio run -e sim_tx_gui -t exec # Run transmitter simulation (SDL2 window) ``` Always run builds from the `firmware/` directory – `platformio.ini` is there. @@ -177,7 +179,7 @@ copyright block. - FreeRTOS → pthreads - I²C (Wire) → in-memory stubs - Preferences → in-memory key-value store -- LCD+LVGL → SDL2 (sim_tx only) +- LCD+LVGL → SDL2 (sim_tx_gui only) - `sim_build.py` adds shim includes and sources automatically ### Configuration (`odh-config`) @@ -197,7 +199,8 @@ The CI workflow (`.github/workflows/ci.yml`) runs on push/PR to main/master: | `build-transmitter`| `pio run -e transmitter` | | `build-receiver` | `pio run -e receiver` | | `build-sim-rx` | `pio run -e sim_rx` | -| `build-sim-tx` | `pio run -e sim_tx` (needs `libsdl2-dev`) | +| `build-sim-tx` | `pio run -e sim_tx` | +| `build-sim-tx-gui` | `pio run -e sim_tx_gui` (needs `libsdl2-dev`)| | `clang-format` | Formatting check (clang-format-19) | | `cppcheck` | Static analysis | | `license-headers` | Copyright header check (SPDX identifier) | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6ce5b0..22ada61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,11 +123,39 @@ jobs: working-directory: firmware run: pio run -e sim_rx - # ── Transmitter simulation build ─────────────────────────────────────── + # ── Transmitter simulation build (headless) ─────────────────────────── build-sim-tx: name: Build transmitter simulation runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install PlatformIO + run: pip install platformio + + - name: Cache PlatformIO packages + uses: actions/cache@v4 + with: + path: ~/.platformio + key: ${{ runner.os }}-pio-sim-${{ hashFiles('firmware/platformio.ini') }} + restore-keys: | + ${{ runner.os }}-pio-sim- + + - name: Build transmitter simulation + working-directory: firmware + run: pio run -e sim_tx + + # ── Transmitter simulation build (SDL2 GUI) ───────────────────────── + build-sim-tx-gui: + name: Build transmitter simulation (GUI) + runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 @@ -150,9 +178,45 @@ jobs: restore-keys: | ${{ runner.os }}-pio-sim- - - name: Build transmitter simulation + - name: Build transmitter simulation (GUI) working-directory: firmware - run: pio run -e sim_tx + run: pio run -e sim_tx_gui + + # ── Console integration tests (simulation) ────────────────────────────── + console-tests: + name: Console tests (${{ matrix.target }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + target: [sim_rx, sim_tx] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install PlatformIO + run: pip install platformio + + - name: Install test dependencies + run: pip install -r firmware/test/test_console/requirements.txt + + - name: Cache PlatformIO packages + uses: actions/cache@v4 + with: + path: ~/.platformio + key: ${{ runner.os }}-pio-sim-${{ hashFiles('firmware/platformio.ini') }} + restore-keys: | + ${{ runner.os }}-pio-sim- + + - name: Run console tests (${{ matrix.target }}) + working-directory: firmware/test/test_console + run: pytest --target=${{ matrix.target }} -v # ── clang-format style check ─────────────────────────────────────────── clang-format: diff --git a/README.md b/README.md index bc29f68..521b805 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ cd firmware # Terminal 1 – receiver pio run -e sim_rx -t exec -# Terminal 2 – transmitter (opens SDL2 window) +# Terminal 2 – transmitter (headless terminal simulation) pio run -e sim_tx -t exec ``` diff --git a/docs/architecture.md b/docs/architecture.md index c165540..7e3cca7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ firmware is built from a single PlatformIO project located in `firmware/`. ``` firmware/ -├── platformio.ini # Unified build: receiver, transmitter, native, sim_rx, sim_tx +├── platformio.ini # Unified build: receiver, transmitter, native, sim_rx, sim_tx, sim_tx_gui ├── lib/ # Shared libraries (used by both targets) │ ├── odh-protocol/ # Protocol.h, FunctionMap.h │ ├── odh-config/ # Config.h, NvsStore.h (compile-time + NVS) @@ -79,7 +79,7 @@ firmware/ │ ├── receiver/ # Static web UI (HTML/CSS/JS) for receiver │ └── transmitter/ # Static web UI for transmitter ├── include/ # Board-level overrides (lv_conf.h, User_Setup.h) -├── sim/ # Simulation shim headers for sim_rx / sim_tx +├── sim/ # Simulation shim headers for sim_rx / sim_tx / sim_tx_gui └── test/ └── test_native/ # Unity tests (90 cases) ``` @@ -216,8 +216,8 @@ bus that connects input modules to the transmitter. ## Simulation -Two simulation environments (`sim_rx`, `sim_tx`) build the full firmware as -a Linux executable. Hardware peripherals are replaced by software shims: +Three simulation environments (`sim_rx`, `sim_tx`, `sim_tx_gui`) build the full +firmware as a Linux executable. Hardware peripherals are replaced by software shims: | Hardware | Simulation Shim | |----------|----------------| @@ -225,14 +225,15 @@ a Linux executable. Hardware peripherals are replaced by software shims: | FreeRTOS | pthreads / BSD timers | | I²C (Wire) | In-memory virtual bus | | Preferences (NVS) | In-memory key-value store | -| ILI9341 LCD + LVGL | SDL2 window (sim_tx only) | +| ILI9341 LCD + LVGL | SDL2 window (sim_tx_gui only) | | PCA9685 / ADS1115 | Console logging | | WiFi / WebServer | Localhost HTTP | ```bash cd firmware -pio run -e sim_rx -t exec # receiver simulation -pio run -e sim_tx -t exec # transmitter simulation (opens SDL2 window) +pio run -e sim_rx -t exec # receiver simulation +pio run -e sim_tx -t exec # transmitter simulation (headless terminal) +pio run -e sim_tx_gui -t exec # transmitter simulation (SDL2 window) ``` See `firmware/sim/README.md` for keyboard shortcuts and known limitations. @@ -241,7 +242,7 @@ See `firmware/sim/README.md` for keyboard shortcuts and known limitations. ## Build Environments -The unified `firmwave/platformio.ini` defines five environments: +The unified `firmware/platformio.ini` defines six environments: | Environment | Target | Board | Framework | |-------------|--------|-------|-----------| @@ -250,16 +251,18 @@ The unified `firmwave/platformio.ini` defines five environments: | `native` | Host | native | — | | `sim_rx` | Host (Linux) | native | — | | `sim_tx` | Host (Linux) | native | — | +| `sim_tx_gui` | Host (Linux) | native | — | Build commands: ```bash cd firmware -pio run -e receiver # build receiver -pio run -e transmitter # build transmitter -pio test -e native # run 90 unit tests -pio run -e sim_rx -t exec # run receiver sim -pio run -e sim_tx -t exec # run transmitter sim +pio run -e receiver # build receiver +pio run -e transmitter # build transmitter +pio test -e native # run 90 unit tests +pio run -e sim_rx -t exec # run receiver sim +pio run -e sim_tx -t exec # run transmitter sim (headless) +pio run -e sim_tx_gui -t exec # run transmitter sim (SDL2 window) ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index c022143..2e1833b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -165,13 +165,17 @@ cd firmware # Terminal 1 – receiver pio run -e sim_rx -t exec -# Terminal 2 – transmitter (opens SDL2 window) +# Terminal 2 – transmitter (headless terminal simulation) pio run -e sim_tx -t exec + +# Optional: transmitter with SDL2 GUI (requires libsdl2-dev) +pio run -e sim_tx_gui -t exec ``` -The receiver starts announcing immediately. The transmitter opens an SDL2 -window showing the scan screen. After a few seconds the vehicle appears as a -button – click it to connect. +The receiver starts announcing immediately. The headless transmitter simulation +runs in the terminal. For a graphical display, use `sim_tx_gui` which opens an +SDL2 window showing the scan screen. After a few seconds the vehicle appears as +a button – click it to connect. The web config UI is also available in simulation: - Transmitter: `http://localhost:8080` diff --git a/docs/index.md b/docs/index.md index ea3c9b7..990e37c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -136,7 +136,7 @@ cd firmware # Terminal 1 – receiver pio run -e sim_rx -t exec -# Terminal 2 – transmitter (opens SDL2 window) +# Terminal 2 – transmitter (headless terminal simulation) pio run -e sim_tx -t exec ``` diff --git a/firmware/lib/odh-config/Config.h b/firmware/lib/odh-config/Config.h index fd70604..35fd699 100644 --- a/firmware/lib/odh-config/Config.h +++ b/firmware/lib/odh-config/Config.h @@ -65,6 +65,16 @@ inline constexpr float kBatteryDividerRatio = 4.03f; inline constexpr uint16_t kAdcVrefMv = 3300; inline constexpr uint8_t kAdcResolutionBits = 12; +// ── Shell console ─────────────────────────────────────────────────────── + +/// Shell poll interval (ms) – how often the shell task checks for input. +inline constexpr uint32_t kShellPollIntervalMs = 50; + +/// FreeRTOS task config for the shell task. +inline constexpr uint32_t kShellTaskStackWords = 4096; +inline constexpr uint8_t kShellTaskPriority = 1; +inline constexpr uint8_t kShellTaskCore = 0; + // ── Receiver-specific settings ────────────────────────────────────────── namespace rx { diff --git a/firmware/lib/odh-shell/Shell.cpp b/firmware/lib/odh-shell/Shell.cpp new file mode 100644 index 0000000..87602c3 --- /dev/null +++ b/firmware/lib/odh-shell/Shell.cpp @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "Shell.h" + +#include +#include + +// ── Tokeniser (always compiled – used by native unit tests) ───────────── + +namespace odh { + +int shellTokenize(char *line, const char **argv, int maxArgs) { + int argc = 0; + char *p = line; + + while (*p && argc < maxArgs) { + while (*p == ' ' || *p == '\t') + ++p; + if (*p == '\0') + break; + + if (*p == '"') { + ++p; + argv[argc++] = p; + while (*p && *p != '"') + ++p; + if (*p == '"') + *p++ = '\0'; + } else { + argv[argc++] = p; + while (*p && *p != ' ' && *p != '\t') + ++p; + if (*p) + *p++ = '\0'; + } + } + return argc; +} + +} // namespace odh + +// ── Full shell implementation (not for the native-test environment) ────── + +#ifndef NATIVE_TEST + +#include + +namespace odh { + +Shell::Shell() { + registerCommand("help", "List available commands", cmdHelp, this); +#ifdef NATIVE_SIM + registerCommand("exit", "Exit the simulation", cmdExit, nullptr); +#endif +} + +bool Shell::registerCommand(const char *name, const char *help, CommandHandler handler, void *ctx) { + if (_commandCount >= kMaxCommands) + return false; + _commands[_commandCount++] = {name, help, handler, ctx}; + return true; +} + +// ── Polling ───────────────────────────────────────────────────────────── + +void Shell::poll() { + if (!_prompted) { + showPrompt(); + _prompted = true; + } + + while (Serial.available() > 0) { + char c = static_cast(Serial.read()); + processChar(c); + } +} + +void Shell::processChar(char c) { + // ANSI escape sequence state machine (for arrow keys) + if (_escState == EscState::Esc) { + _escState = (c == '[') ? EscState::Bracket : EscState::None; + return; + } + if (_escState == EscState::Bracket) { + _escState = EscState::None; + processEscapeChar(c); + return; + } + if (c == '\x1B') { + _escState = EscState::Esc; + return; + } + + if (c == '\n' || c == '\r') { + Serial.println(); + if (_linePos > 0) { + _lineBuf[_linePos] = '\0'; + historyPush(_lineBuf); + execute(_lineBuf); + } + _linePos = 0; + _histBrowse = -1; + _prompted = false; + return; + } + + if (c == '\b' || c == 127) { + if (_linePos > 0) { + --_linePos; + Serial.printf("\b \b"); + } + return; + } + + if (c < ' ') + return; + + if (_linePos < kMaxLineLen - 1) { + _lineBuf[_linePos++] = c; + Serial.printf("%c", c); + } +} + +void Shell::processEscapeChar(char c) { + if (c == 'A') { + // Arrow Up – older history entry + if (_histCount == 0) + return; + if (_histBrowse < 0) { + // Save current line before browsing + _lineBuf[_linePos] = '\0'; + strncpy(_histSavedLine, _lineBuf, kMaxLineLen); + _histBrowse = 0; + } else if (_histBrowse < static_cast(_histCount) - 1) { + ++_histBrowse; + } else { + return; // already at oldest + } + replaceLine(historyAt(_histBrowse)); + } else if (c == 'B') { + // Arrow Down – newer history entry + if (_histBrowse < 0) + return; + --_histBrowse; + if (_histBrowse < 0) { + replaceLine(_histSavedLine); + } else { + replaceLine(historyAt(_histBrowse)); + } + } + // Ignore other escape sequences (C=right, D=left, etc.) +} + +void Shell::clearLine() { + while (_linePos > 0) { + Serial.printf("\b \b"); + --_linePos; + } +} + +void Shell::replaceLine(const char *newLine) { + clearLine(); + strncpy(_lineBuf, newLine, kMaxLineLen - 1); + _lineBuf[kMaxLineLen - 1] = '\0'; + _linePos = static_cast(strlen(_lineBuf)); + Serial.printf("%s", _lineBuf); +} + +void Shell::historyPush(const char *line) { + strncpy(_history[_histHead], line, kMaxLineLen - 1); + _history[_histHead][kMaxLineLen - 1] = '\0'; + _histHead = (_histHead + 1) % kHistorySize; + if (_histCount < kHistorySize) + ++_histCount; +} + +const char *Shell::historyAt(int16_t index) const { + // index 0 = most recent, 1 = one before, ... + int slot = (static_cast(_histHead) - 1 - index + kHistorySize * 2) % kHistorySize; + return _history[slot]; +} + +// ── Execute ───────────────────────────────────────────────────────────── + +int Shell::execute(const char *line) { + char buf[kMaxLineLen]; + strncpy(buf, line, sizeof(buf)); + buf[sizeof(buf) - 1] = '\0'; + + const char *argv[kMaxArgs]; + int argc = shellTokenize(buf, argv, kMaxArgs); + if (argc == 0) + return 0; + + return dispatch(argc, argv); +} + +int Shell::dispatch(int argc, const char *const *argv) { + for (uint8_t i = 0; i < _commandCount; ++i) { + if (strcmp(argv[0], _commands[i].name) == 0) { + return _commands[i].handler(*this, argc, argv, _commands[i].context); + } + } + println("Unknown command: %s (type 'help')", argv[0]); + return -1; +} + +// ── Output helpers ────────────────────────────────────────────────────── + +void Shell::print(const char *fmt, ...) { + char buf[256]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + Serial.print(buf); +} + +void Shell::println(const char *fmt, ...) { + char buf[256]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + Serial.println(buf); +} + +void Shell::println() { + Serial.println(); +} + +void Shell::showPrompt() { + Serial.print(kPrompt); +} + +// ── Built-in: help ────────────────────────────────────────────────────── + +int Shell::cmdHelp(Shell &shell, int, const char *const *, void *) { + shell.println("Available commands:"); + for (uint8_t i = 0; i < shell._commandCount; ++i) { + shell.println(" %-14s %s", shell._commands[i].name, shell._commands[i].help); + } + return 0; +} + +// ── Built-in: exit (simulation only) ──────────────────────────────────── + +#ifdef NATIVE_SIM +int Shell::cmdExit(Shell &shell, int, const char *const *, void *) { + shell.println("Bye."); + std::exit(0); + return 0; +} +#endif + +} // namespace odh + +#endif // NATIVE_TEST diff --git a/firmware/lib/odh-shell/Shell.h b/firmware/lib/odh-shell/Shell.h new file mode 100644 index 0000000..ba408e2 --- /dev/null +++ b/firmware/lib/odh-shell/Shell.h @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Shell.h – Lightweight interactive console inspired by the Zephyr shell. + * + * Provides command registration, line editing, tokenisation and dispatch. + * On ESP32 the shell reads from UART via Serial; in the Linux simulation it + * reads from stdin (see sim_arduino.cpp for the stdin reader thread). + */ + +#pragma once + +#include + +namespace odh { + +// ── Tokeniser (always available, including native unit tests) ─────────── + +/// Tokenise a command line into an argc/argv pair. +/// Supports double-quoted strings. Modifies @p line in-place by inserting +/// NUL terminators. Returns the number of tokens written to @p argv. +int shellTokenize(char *line, const char **argv, int maxArgs); + +// ── Shell class (not available in the pure native-test environment) ───── + +#ifndef NATIVE_TEST + +#ifdef __GNUC__ +#define ODH_PRINTF_FMT(fmtIdx, argIdx) __attribute__((format(printf, fmtIdx, argIdx))) +#else +#define ODH_PRINTF_FMT(fmtIdx, argIdx) +#endif + +/// Function pointer type for shell command handlers. +/// @return 0 on success, non-zero on error. +using CommandHandler = int (*)(class Shell &shell, int argc, const char *const *argv, void *ctx); + +/// A single registered command. +struct ShellCommand { + const char *name; + const char *help; + CommandHandler handler; + void *context; +}; + +class Shell { +public: + Shell(); + + /// Register a command. @p name and @p help must have static lifetime. + /// Returns false if the command table is full. + bool registerCommand(const char *name, const char *help, CommandHandler handler, void *ctx = nullptr); + + /// Non-blocking poll: read available Serial input and process complete + /// lines. Call this periodically from a FreeRTOS task. + void poll(); + + /// Execute a command line string directly (useful for programmatic + /// injection). Returns the handler return value or -1 if not found. + int execute(const char *line); + + /// Formatted output helpers (delegate to Serial). + void print(const char *fmt, ...) ODH_PRINTF_FMT(2, 3); + void println(const char *fmt, ...) ODH_PRINTF_FMT(2, 3); + void println(); + +private: + static constexpr uint8_t kMaxCommands = 32; + static constexpr uint16_t kMaxLineLen = 128; + static constexpr uint8_t kMaxArgs = 16; + static constexpr uint8_t kHistorySize = 16; + static constexpr const char *kPrompt = "odh> "; + + // Command table + ShellCommand _commands[kMaxCommands] = {}; + uint8_t _commandCount = 0; + + // Line editing + char _lineBuf[kMaxLineLen] = {}; + uint16_t _linePos = 0; + bool _prompted = false; + + // Command history (ring buffer) + char _history[kHistorySize][kMaxLineLen] = {}; + uint8_t _histHead = 0; // next write slot + uint8_t _histCount = 0; // entries stored + int16_t _histBrowse = -1; // browse index (-1 = not browsing) + char _histSavedLine[kMaxLineLen] = {}; // saved current line when browsing + + // ANSI escape sequence state machine + enum class EscState : uint8_t { None, Esc, Bracket }; + EscState _escState = EscState::None; + + void processChar(char c); + void processEscapeChar(char c); + void clearLine(); + void replaceLine(const char *newLine); + void historyPush(const char *line); + const char *historyAt(int16_t index) const; + int dispatch(int argc, const char *const *argv); + void showPrompt(); + + static int cmdHelp(Shell &shell, int argc, const char *const *argv, void *ctx); +#ifdef NATIVE_SIM + static int cmdExit(Shell &shell, int argc, const char *const *argv, void *ctx); +#endif +}; + +#undef ODH_PRINTF_FMT + +#endif // NATIVE_TEST + +} // namespace odh diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 879a022..222c3e9 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -6,12 +6,13 @@ ; transmitter – ESP32 transmitter firmware ; native – Host unit tests (no hardware) ; sim_rx – Linux receiver simulation (terminal) -; sim_tx – Linux transmitter simulation (SDL2 display) +; sim_tx – Linux transmitter simulation (terminal, headless) +; sim_tx_gui – Linux transmitter simulation (SDL2 display) ; ; Inheritance: ; esp32_base → receiver, transmitter ; native_base → native, sim_base -; sim_base → sim_rx, sim_tx +; sim_base → sim_rx, sim_tx, sim_tx_gui ; ═══════════════════════════════════════════════════════════════════════ [platformio] @@ -112,6 +113,7 @@ build_flags = -DNATIVE_TEST build_src_filter = -<*> test_build_src = no +test_filter = test_native ; ── Simulation: Receiver (terminal, no SDL2) ───────────────────────────── [env:sim_rx] @@ -123,9 +125,20 @@ build_flags = -DODH_RECEIVER -DSIM_RX -; ── Simulation: Transmitter (SDL2 display) ─────────────────────────────── +; ── Simulation: Transmitter headless (terminal, no SDL2/LVGL) ───────────── [env:sim_tx] extends = sim_base +build_src_filter = + - + +build_flags = + ${sim_base.build_flags} + -DODH_TRANSMITTER + -DSIM_TX + -DODH_HEADLESS + +; ── Simulation: Transmitter GUI (SDL2 display) ─────────────────────────── +[env:sim_tx_gui] +extends = sim_base build_src_filter = + build_flags = diff --git a/firmware/sim/README.md b/firmware/sim/README.md index 8f46301..9765581 100644 --- a/firmware/sim/README.md +++ b/firmware/sim/README.md @@ -1,6 +1,6 @@ # Simulation -OpenDriveHub includes two simulation environments that build the full +OpenDriveHub includes three simulation environments that build the full firmware as **Linux executables**. No ESP32 hardware is required. --- @@ -10,7 +10,7 @@ firmware as **Linux executables**. No ESP32 hardware is required. ### Prerequisites ```bash -# SDL2 is needed only for the transmitter simulation (display window) +# SDL2 is needed only for the sim_tx_gui environment (display window) sudo apt install pkg-config libsdl2-dev ``` @@ -22,8 +22,11 @@ cd firmware # Receiver simulation (terminal-only, no display) pio run -e sim_rx -t exec -# Transmitter simulation (opens an SDL2 window) +# Transmitter simulation (headless terminal, no display) pio run -e sim_tx -t exec + +# Transmitter simulation with SDL2 GUI (opens a window) +pio run -e sim_tx_gui -t exec ``` Open both in separate terminals to simulate a complete TX → RX link. @@ -43,12 +46,12 @@ Source files live in `firmware/sim/`. | ADS1115 | `Adafruit_ADS1X15.h` | (header-only stub) | Returns constant mid-value | | PCF8574 | `Adafruit_PCF8574.h` | (header-only stub) | Returns 0xFF (all high) | | Preferences (NVS) | `Preferences.h` | `sim_preferences.cpp` | In-memory key-value map | -| ILI9341 + LVGL | `TFT_eSPI.h` | (linked SDL2) | SDL2 window (sim_tx only) | +| ILI9341 + LVGL | `TFT_eSPI.h` | (linked SDL2) | SDL2 window (sim_tx_gui only) | | WiFi AP | `WiFi.h` | `sim_wifi.cpp` | No-op (AP name printed) | | ESPAsyncWebServer | `ESPAsyncWebServer.h` | `sim_webserver.cpp` | Localhost HTTP | | LittleFS | `LittleFS.h` | (header-only stub) | No-op | | Arduino core | `Arduino.h` | `sim_arduino.cpp` | `millis()`, `delay()`, `Serial` | -| Keyboard input | `sim_keyboard.h` | `sim_keyboard.cpp` | SDL2 key events (sim_tx) | +| Keyboard input | `sim_keyboard.h` | `sim_keyboard.cpp` | SDL2 key events (sim_tx_gui) | --- @@ -56,15 +59,17 @@ Source files live in `firmware/sim/`. 1. Start `sim_rx` — it immediately begins broadcasting `AnnouncePacket` via UDP. -2. Start `sim_tx` — an SDL2 window appears showing the scan screen. +2. Start `sim_tx` — the transmitter runs in headless mode in the terminal. + Alternatively, start `sim_tx_gui` — an SDL2 window appears showing the scan screen. 3. After a few seconds the receiver's vehicle name appears on screen. -4. Click the vehicle button in the SDL2 window to bind. +4. Click the vehicle button in the SDL2 window to bind (sim_tx_gui), or the + transmitter auto-connects in headless mode (sim_tx). 5. Both sides enter **Connected** state. Control packets flow TX → RX; telemetry flows RX → TX. --- -## Keyboard Shortcuts (sim_tx) +## Keyboard Shortcuts (sim_tx_gui) The transmitter simulation accepts keyboard input to control function values instead of physical modules. @@ -87,6 +92,7 @@ Both environments start a local HTTP server: | Environment | URL | Port | |-------------|-----|------| | sim_tx | http://localhost:8080 | 8080 | +| sim_tx_gui | http://localhost:8080 | 8080 | | sim_rx | http://localhost:8081 | 8081 | > **Note:** Static files (HTML/CSS/JS) are **not** served in simulation @@ -119,7 +125,7 @@ build script (`firmware/sim/sim_build.py`) adds: - `-I firmware/sim/include` (shim headers) - Linking of shim `.cpp` sources -- SDL2 flags via `pkg-config` (sim_tx only) +- SDL2 flags via `pkg-config` (sim_tx_gui only) - `-DNATIVE_SIM` preprocessor define --- diff --git a/firmware/sim/include/Arduino.h b/firmware/sim/include/Arduino.h index b33c6dd..e91cbe7 100644 --- a/firmware/sim/include/Arduino.h +++ b/firmware/sim/include/Arduino.h @@ -35,6 +35,7 @@ #include #include #include +#include #include /* ── Arduino-compatible types ───────────────────────────────────────────── */ @@ -90,71 +91,94 @@ typedef int esp_err_t; /* ── Serial ─────────────────────────────────────────────────────────────── */ /** - * Minimal Serial class that prints to stdout. + * Minimal Serial class that prints to stdout and (in simulation builds) + * reads from a ring buffer fed by a background stdin-reader thread. + * + * When stdout is not a terminal (e.g. PlatformIO pipes it through an + * async reader), output is written directly to /dev/tty so that shell + * prompts and character echo are visible immediately. */ class HardwareSerial { public: - void begin(unsigned long) {} + void begin(unsigned long) { + initOutput(); + } void end() {} - /* print / println for basic types */ - size_t print(const char *s) { - return printf("%s", s); - } - size_t print(int v) { - return printf("%d", v); - } - size_t print(unsigned v) { - return printf("%u", v); - } - size_t print(long v) { - return printf("%ld", v); - } - size_t print(unsigned long v) { - return printf("%lu", v); - } - size_t print(double v, int prec = 2) { - return printf("%.*f", prec, v); - } + /// Initialise output – call before first print if begin() is skipped. + void initOutput(); - size_t println() { - return printf("\n"); - } - size_t println(const char *s) { - return printf("%s\n", s); - } - size_t println(int v) { - return printf("%d\n", v); - } - size_t println(unsigned v) { - return printf("%u\n", v); - } - size_t println(long v) { - return printf("%ld\n", v); - } - size_t println(unsigned long v) { - return printf("%lu\n", v); + /* ── Input (ring-buffer backed by stdin reader thread) ──────────── */ + + int available() { + std::lock_guard lock(_rxMutex); + return static_cast((_rxHead - _rxTail + kRxBufSize) % kRxBufSize); } - size_t println(double v, int prec = 2) { - return printf("%.*f\n", prec, v); + + int read() { + std::lock_guard lock(_rxMutex); + if (_rxHead == _rxTail) + return -1; + uint8_t c = _rxBuf[_rxTail]; + _rxTail = (_rxTail + 1) % kRxBufSize; + return c; } - /* The F() macro just passes through the string on native. */ - size_t print(const std::string &s) { - return printf("%s", s.c_str()); + int peek() { + std::lock_guard lock(_rxMutex); + if (_rxHead == _rxTail) + return -1; + return _rxBuf[_rxTail]; } - size_t println(const std::string &s) { - return printf("%s\n", s.c_str()); + + /// Push a byte into the receive buffer (called by the stdin reader thread). + void pushRxByte(uint8_t c) { + std::lock_guard lock(_rxMutex); + uint16_t next = (_rxHead + 1) % kRxBufSize; + if (next != _rxTail) { + _rxBuf[_rxHead] = c; + _rxHead = next; + } } - /* ESP32-Arduino Serial.printf() */ - size_t printf(const char *fmt, ...) __attribute__((format(printf, 2, 3))) { - va_list args; - va_start(args, fmt); - int r = vprintf(fmt, args); - va_end(args); - return r > 0 ? static_cast(r) : 0; + /* ── Output ────────────────────────────────────────────────────── */ + + size_t print(const char *s); + size_t print(int v); + size_t print(unsigned v); + size_t print(long v); + size_t print(unsigned long v); + size_t print(double v, int prec = 2); + + size_t println(); + size_t println(const char *s); + size_t println(int v); + size_t println(unsigned v); + size_t println(long v); + size_t println(unsigned long v); + size_t println(double v, int prec = 2); + + size_t print(const std::string &s); + size_t println(const std::string &s); + + size_t printf(const char *fmt, ...) __attribute__((format(printf, 2, 3))); + + /// Return the output FILE* stream, initialising on first call. + FILE *outputStream() { + if (!_outReady) + initOutput(); + return _out; } + +private: + static constexpr uint16_t kRxBufSize = 256; + uint8_t _rxBuf[kRxBufSize] = {}; + uint16_t _rxHead = 0; + uint16_t _rxTail = 0; + std::mutex _rxMutex; + + FILE *_out = nullptr; // output stream (stdout or /dev/tty) + bool _outReady = false; }; extern HardwareSerial Serial; diff --git a/firmware/sim/src/sim_arduino.cpp b/firmware/sim/src/sim_arduino.cpp index a1ded52..00ce06d 100644 --- a/firmware/sim/src/sim_arduino.cpp +++ b/firmware/sim/src/sim_arduino.cpp @@ -29,11 +29,99 @@ #include #include #include +#include + +#ifdef __has_include +#if __has_include() +#include +#include +#define HAS_TERMIOS 1 +#endif +#endif /* ── Global instances ───────────────────────────────────────────────────── */ HardwareSerial Serial; +/* ── HardwareSerial output ──────────────────────────────────────────────── */ + +void HardwareSerial::initOutput() { + if (_outReady) + return; + _outReady = true; + + if (isatty(STDOUT_FILENO)) { + _out = stdout; + return; + } + + // stdout is a pipe (e.g. PlatformIO exec). PlatformIO's BuildAsyncPipe + // buffers output line-by-line, which breaks interactive shell prompts + // and character echo. Write directly to the controlling terminal. + _out = fopen("/dev/tty", "w"); + if (_out) { + setvbuf(_out, nullptr, _IONBF, 0); + } else { + _out = stdout; // no terminal – fall back to pipe + } +} + +size_t HardwareSerial::print(const char *s) { + return static_cast(fprintf(outputStream(), "%s", s)); +} +size_t HardwareSerial::print(int v) { + return static_cast(fprintf(outputStream(), "%d", v)); +} +size_t HardwareSerial::print(unsigned v) { + return static_cast(fprintf(outputStream(), "%u", v)); +} +size_t HardwareSerial::print(long v) { + return static_cast(fprintf(outputStream(), "%ld", v)); +} +size_t HardwareSerial::print(unsigned long v) { + return static_cast(fprintf(outputStream(), "%lu", v)); +} +size_t HardwareSerial::print(double v, int prec) { + return static_cast(fprintf(outputStream(), "%.*f", prec, v)); +} + +size_t HardwareSerial::println() { + return static_cast(fprintf(outputStream(), "\n")); +} +size_t HardwareSerial::println(const char *s) { + return static_cast(fprintf(outputStream(), "%s\n", s)); +} +size_t HardwareSerial::println(int v) { + return static_cast(fprintf(outputStream(), "%d\n", v)); +} +size_t HardwareSerial::println(unsigned v) { + return static_cast(fprintf(outputStream(), "%u\n", v)); +} +size_t HardwareSerial::println(long v) { + return static_cast(fprintf(outputStream(), "%ld\n", v)); +} +size_t HardwareSerial::println(unsigned long v) { + return static_cast(fprintf(outputStream(), "%lu\n", v)); +} +size_t HardwareSerial::println(double v, int prec) { + return static_cast(fprintf(outputStream(), "%.*f\n", prec, v)); +} + +size_t HardwareSerial::print(const std::string &s) { + return static_cast(fprintf(outputStream(), "%s", s.c_str())); +} +size_t HardwareSerial::println(const std::string &s) { + return static_cast(fprintf(outputStream(), "%s\n", s.c_str())); +} + +size_t HardwareSerial::printf(const char *fmt, ...) { + va_list args; + va_start(args, fmt); + int r = vfprintf(outputStream(), fmt, args); + va_end(args); + return r > 0 ? static_cast(r) : 0; +} + /* ── Time ───────────────────────────────────────────────────────────────── */ static auto s_startTime = std::chrono::steady_clock::now(); @@ -89,10 +177,72 @@ void esp_read_mac(uint8_t *mac, esp_mac_type_t) { /* ── Arduino main() bridge ──────────────────────────────────────────────── */ +#ifdef HAS_TERMIOS +static struct termios s_origTermios; +static int s_ttyFd = -1; + +/// Enable raw mode on the given file descriptor. +static void enableRawModeOnFd(int fd) { + tcgetattr(fd, &s_origTermios); + atexit([] { + int restoreFd = (s_ttyFd >= 0) ? s_ttyFd : STDIN_FILENO; + tcsetattr(restoreFd, TCSAFLUSH, &s_origTermios); + }); + + struct termios raw = s_origTermios; + raw.c_lflag &= ~(static_cast(ICANON) | static_cast(ECHO)); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + tcsetattr(fd, TCSAFLUSH, &raw); +} + +/// Open the controlling terminal for input. When stdin is a pipe (e.g. +/// PlatformIO exec), /dev/tty gives us direct access to the real terminal. +/// Returns the fd to read from (STDIN_FILENO or /dev/tty). +static int openTerminalInput() { + if (isatty(STDIN_FILENO)) { + enableRawModeOnFd(STDIN_FILENO); + return STDIN_FILENO; + } + // stdin is a pipe – try the controlling terminal directly. + int fd = open("/dev/tty", O_RDONLY); + if (fd >= 0) { + s_ttyFd = fd; + enableRawModeOnFd(fd); + return fd; + } + // No terminal at all (CI, headless server) – fall back to stdin pipe. + return STDIN_FILENO; +} +#endif + +/// Background thread that reads from the terminal (or stdin) and feeds +/// bytes into the Serial receive ring buffer. +static void stdinReaderThread(int fd) { + while (true) { + uint8_t c; + ssize_t n = ::read(fd, &c, 1); + if (n <= 0) + break; + Serial.pushRxByte(c); + } +} + int main(int, char **) { - /* Line-buffer stdout so Serial.print/println output is visible immediately - * even when piped through PlatformIO's exec runner. */ - setvbuf(stdout, nullptr, _IOLBF, 0); + /* Disable buffering on both stdout and stdin so the interactive shell + * prompt, character echo and input are immediate. */ + setvbuf(stdout, nullptr, _IONBF, 0); + setvbuf(stdin, nullptr, _IONBF, 0); + + int inputFd = STDIN_FILENO; +#ifdef HAS_TERMIOS + inputFd = openTerminalInput(); +#endif + + /* Start stdin reader as a background daemon thread. */ + std::thread reader(stdinReaderThread, inputFd); + reader.detach(); + setup(); for (;;) { loop(); diff --git a/firmware/src/receiver/ReceiverApp.cpp b/firmware/src/receiver/ReceiverApp.cpp index a230106..61c624f 100644 --- a/firmware/src/receiver/ReceiverApp.cpp +++ b/firmware/src/receiver/ReceiverApp.cpp @@ -23,6 +23,7 @@ #include +#include #include #ifndef NATIVE_SIM @@ -33,6 +34,8 @@ #include "output/LoggingOutput.h" #endif +#include "shell/ReceiverShellCommands.h" + namespace odh { ReceiverApp::ReceiverApp() @@ -124,6 +127,10 @@ void ReceiverApp::begin() { xTaskCreatePinnedToCore(taskTelemetryFn, "telemetry", config::rx::kTaskTelemetryStackWords, this, config::rx::kTaskTelemetryPriority, nullptr, config::rx::kTaskTelemetryCore); + // ── Shell console ─────────────────────────────────────────────── + registerReceiverShellCommands(_shell, *this); + xTaskCreatePinnedToCore(taskShellFn, "shell", config::kShellTaskStackWords, this, config::kShellTaskPriority, nullptr, config::kShellTaskCore); + Serial.println(F("[ODH-RX] Setup complete")); } @@ -151,6 +158,10 @@ void ReceiverApp::taskWebFn(void *param) { static_cast(param)->runWebLoop(); } +void ReceiverApp::taskShellFn(void *param) { + static_cast(param)->runShellLoop(); +} + // ── Output loop (50 Hz) ───────────────────────────────────────────────── void ReceiverApp::runOutputLoop() { @@ -201,8 +212,23 @@ void ReceiverApp::runWebLoop() { } } +// ── Shell loop ────────────────────────────────────────────────────────── + +void ReceiverApp::runShellLoop() { + for (;;) { + _shell.poll(); + vTaskDelay(pdMS_TO_TICKS(config::kShellPollIntervalMs)); + } +} + // ── NVS helpers ───────────────────────────────────────────────────────── +void ReceiverApp::setVehicleName(const char *name) { + std::strncpy(_vehicleName, name, kVehicleNameMax - 1); + _vehicleName[kVehicleNameMax - 1] = '\0'; + saveVehicleName(); +} + void ReceiverApp::loadVehicleName() { NvsStore store("odh", true); String name = store.getString("veh_name", config::rx::kVehicleName); diff --git a/firmware/src/receiver/ReceiverApp.h b/firmware/src/receiver/ReceiverApp.h index 5b06342..daac5a3 100644 --- a/firmware/src/receiver/ReceiverApp.h +++ b/firmware/src/receiver/ReceiverApp.h @@ -41,6 +41,7 @@ #include "OdhWebServer.h" #include "OutputManager.h" #include "ReceiverRadioLink.h" +#include "Shell.h" #include "web/ReceiverApi.h" #include @@ -57,12 +58,35 @@ class ReceiverApp { /// Initialise all subsystems and start FreeRTOS tasks. void begin(); + // ── Accessors for shell commands ──────────────────────────────── + + OutputManager &output() { + return _output; + } + const OutputManager &output() const { + return _output; + } + ReceiverRadioLink &radio() { + return _radio; + } + const ReceiverRadioLink &radio() const { + return _radio; + } + const BatteryMonitor &battery() const { + return _battery; + } + const char *vehicleName() const { + return _vehicleName; + } + void setVehicleName(const char *name); + private: OutputManager _output; ReceiverRadioLink _radio; BatteryMonitor _battery; OdhWebServer _webServer; ReceiverApi _api; + Shell _shell; SemaphoreHandle_t _channelsMux = nullptr; bool _failsafeActive = false; @@ -78,10 +102,12 @@ class ReceiverApp { static void taskOutputFn(void *param); static void taskTelemetryFn(void *param); static void taskWebFn(void *param); + static void taskShellFn(void *param); void runOutputLoop(); void runTelemetryLoop(); void runWebLoop(); + void runShellLoop(); /// Load vehicle name from NVS. void loadVehicleName(); diff --git a/firmware/src/receiver/shell/ReceiverShellCommands.cpp b/firmware/src/receiver/shell/ReceiverShellCommands.cpp new file mode 100644 index 0000000..220cc6f --- /dev/null +++ b/firmware/src/receiver/shell/ReceiverShellCommands.cpp @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "ReceiverShellCommands.h" + +#include "../ReceiverApp.h" + +#include +#include +#include +#include +#include +#include + +namespace odh { + +// ── status ────────────────────────────────────────────────────────────── + +static int cmdStatus(Shell &shell, int, const char *const *, void *ctx) { + auto &app = *static_cast(ctx); + + const char *linkStr = app.radio().isBound() ? "connected" : "announcing"; + shell.println("Link: %s", linkStr); + + if (app.radio().isBound()) { + shell.println("RSSI: %d dBm", app.radio().lastRssi()); + shell.println("Last pkt: %lu ms ago", static_cast(app.radio().msSinceLastControl())); + } + + shell.println("Battery: %u mV (%uS)", app.battery().voltageMv(), app.battery().cells()); + + auto mn = modelName(app.output().modelType()); +#ifdef ODH_HAS_STRING_VIEW + shell.println("Model: %.*s", static_cast(mn.size()), mn.data()); +#else + shell.println("Model: %s", mn); +#endif + + shell.println("Vehicle: %s", app.vehicleName()); + return 0; +} + +// ── channel ───────────────────────────────────────────────────────────── + +static int cmdChannel(Shell &shell, int argc, const char *const *argv, void *ctx) { + auto &app = *static_cast(ctx); + + if (argc < 2) { + shell.println("Usage: channel "); + return 1; + } + + if (strcmp(argv[1], "list") == 0) { + const auto &vals = app.output().channelValues(); + for (uint8_t i = 0; i < config::rx::kChannelCount; ++i) { + shell.println(" CH%u: %u us", i, vals[i]); + } + return 0; + } + + if (strcmp(argv[1], "set") == 0) { + if (argc < 4) { + shell.println("Usage: channel set "); + return 1; + } + int ch = atoi(argv[2]); + int us = atoi(argv[3]); + if (ch < 0 || ch >= config::rx::kChannelCount) { + shell.println("Channel %d out of range (0-%d)", ch, config::rx::kChannelCount - 1); + return 1; + } + if (us < kChannelMin || us > kChannelMax) { + shell.println("Value %d out of range (%d-%d)", us, kChannelMin, kChannelMax); + return 1; + } + app.output().setChannel(static_cast(ch), static_cast(us)); + shell.println("CH%d = %d us", ch, us); + return 0; + } + + shell.println("Unknown sub-command: %s", argv[1]); + return 1; +} + +// ── config ────────────────────────────────────────────────────────────── + +static int cmdConfig(Shell &shell, int argc, const char *const *argv, void *ctx) { + auto &app = *static_cast(ctx); + + if (argc < 2) { + shell.println("Usage: config "); + return 1; + } + + if (strcmp(argv[1], "get") == 0) { + NvsStore store("odh", true); + shell.println(" radio_ch: %u", store.getU8("radio_ch", config::kRadioWifiChannel)); + + auto mn = modelName(app.output().modelType()); +#ifdef ODH_HAS_STRING_VIEW + shell.println(" model: %.*s", static_cast(mn.size()), mn.data()); +#else + shell.println(" model: %s", mn); +#endif + + shell.println(" vehicle: %s", app.vehicleName()); + shell.println(" batt_cell: %u", app.battery().cells()); + return 0; + } + + if (strcmp(argv[1], "set") == 0) { + if (argc < 4) { + shell.println("Usage: config set "); + shell.println(" Keys: radio_ch, model, batt_cell"); + return 1; + } + + if (strcmp(argv[2], "radio_ch") == 0) { + int ch = atoi(argv[3]); + if (ch < 1 || ch > 13) { + shell.println("Channel must be 1-13"); + return 1; + } + NvsStore store("odh", false); + store.putU8("radio_ch", static_cast(ch)); + shell.println("radio_ch = %d (restart required)", ch); + return 0; + } + + if (strcmp(argv[2], "batt_cell") == 0) { + int cells = atoi(argv[3]); + if (cells < 0 || cells > 6) { + shell.println("Cell count must be 0-6 (0 = auto)"); + return 1; + } + NvsStore store("odh", false); + store.putU8("batt_cell", static_cast(cells)); + shell.println("batt_cell = %d", cells); + return 0; + } + + shell.println("Unknown key: %s", argv[2]); + return 1; + } + + shell.println("Unknown sub-command: %s", argv[1]); + return 1; +} + +// ── vehicle ───────────────────────────────────────────────────────────── + +static int cmdVehicle(Shell &shell, int argc, const char *const *argv, void *ctx) { + auto &app = *static_cast(ctx); + + if (argc < 2) { + shell.println("Vehicle: %s", app.vehicleName()); + return 0; + } + + app.setVehicleName(argv[1]); + shell.println("Vehicle name set to: %s", app.vehicleName()); + return 0; +} + +// ── Registration ──────────────────────────────────────────────────────── + +void registerReceiverShellCommands(Shell &shell, ReceiverApp &app) { + shell.registerCommand("status", "Show link, battery and vehicle info", cmdStatus, &app); + shell.registerCommand("channel", "Channel ops: list, set ", cmdChannel, &app); + shell.registerCommand("config", "Config ops: get, set ", cmdConfig, &app); + shell.registerCommand("vehicle", "Get/set vehicle name", cmdVehicle, &app); +} + +} // namespace odh diff --git a/firmware/src/receiver/shell/ReceiverShellCommands.h b/firmware/src/receiver/shell/ReceiverShellCommands.h new file mode 100644 index 0000000..7c4b0dd --- /dev/null +++ b/firmware/src/receiver/shell/ReceiverShellCommands.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * ReceiverShellCommands – shell command handlers for the receiver firmware. + */ + +#pragma once + +namespace odh { + +class Shell; +class ReceiverApp; + +/// Register all receiver-specific shell commands. +void registerReceiverShellCommands(Shell &shell, ReceiverApp &app); + +} // namespace odh diff --git a/firmware/src/transmitter/TransmitterApp.cpp b/firmware/src/transmitter/TransmitterApp.cpp index e32a4b0..d96ec20 100644 --- a/firmware/src/transmitter/TransmitterApp.cpp +++ b/firmware/src/transmitter/TransmitterApp.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #ifdef NATIVE_SIM #include @@ -41,7 +42,11 @@ #endif #include +#ifndef ODH_HEADLESS #include +#endif + +#include "shell/TransmitterShellCommands.h" namespace odh { @@ -103,10 +108,14 @@ void TransmitterApp::begin() { Wire.setClock(config::kI2cFreqHz); // Display +#ifndef ODH_HEADLESS if (_display.begin()) Serial.println(F("[ODH] Display + touch OK")); else Serial.println(F("[ODH] Display not found – continuing without it")); +#else + Serial.println(F("[ODH] Headless mode – display disabled")); +#endif // Backplane if (_backplane.begin()) @@ -172,10 +181,16 @@ void TransmitterApp::begin() { // Start FreeRTOS tasks xTaskCreatePinnedToCore(taskControl, "control", config::tx::kTaskControlStackWords, this, config::tx::kTaskControlPriority, nullptr, config::tx::kTaskControlCore); +#ifndef ODH_HEADLESS xTaskCreatePinnedToCore(taskDisplay, "display", config::tx::kTaskDisplayStackWords, this, config::tx::kTaskDisplayPriority, nullptr, config::tx::kTaskDisplayCore); +#endif xTaskCreatePinnedToCore(taskWeb, "webconfig", config::tx::kTaskWebConfigStackWords, this, config::tx::kTaskWebConfigPriority, nullptr, config::tx::kTaskWebConfigCore); + // Shell console + registerTransmitterShellCommands(_shell, *this); + xTaskCreatePinnedToCore(taskShell, "shell", config::kShellTaskStackWords, this, config::kShellTaskPriority, nullptr, config::kShellTaskCore); + Serial.println(F("[ODH] Setup complete")); } @@ -243,6 +258,8 @@ void TransmitterApp::taskControl(void *param) { /* ── taskDisplay – 4 Hz ──────────────────────────────────────────────────── */ +#ifndef ODH_HEADLESS + void TransmitterApp::taskDisplay(void *param) { auto &app = *static_cast(param); TickType_t lastTickTime = xTaskGetTickCount(); @@ -336,6 +353,82 @@ void TransmitterApp::taskDisplay(void *param) { } } +#endif // ODH_HEADLESS + +/* ── taskShell ───────────────────────────────────────────────────────────── */ + +void TransmitterApp::taskShell(void *param) { + auto &app = *static_cast(param); + +#ifdef ODH_HEADLESS + // In headless mode the shell task also handles telemetry timeout and + // RX cell auto-detection that normally live in taskDisplay. + bool rxCellsDetected = false; +#endif + + for (;;) { + app._shell.poll(); + +#ifdef ODH_HEADLESS + // Telemetry timeout → auto-disconnect + if (app._radio.isBound() && app._telemetry.hasData() && app._telemetry.msSinceLastPacket() > config::kRadioLinkTimeoutMs) { + Serial.println(F("[ODH] Telemetry timeout – disconnecting")); + app._radio.disconnect(); + } + + // Auto-detect RX cells + if (app._radio.isBound() && app._telemetry.hasData() && !rxCellsDetected) { + if (app._telemetry.rxCells() == 0) { + app._telemetry.autoDetectRxCells(); + } + rxCellsDetected = true; + } + if (!app._radio.isBound()) + rxCellsDetected = false; + + // Prune stale vehicles while scanning + if (app._radio.isScanning()) { + app._radio.pruneStaleVehicles(config::tx::kVehicleDiscoveryTimeoutMs); + } +#endif + + vTaskDelay(pdMS_TO_TICKS(config::kShellPollIntervalMs)); + } +} + +/* ── Accessors ───────────────────────────────────────────────────────────── */ + +std::pair TransmitterApp::snapshotFuncValues() { + uint8_t count = 0; + if (xSemaphoreTake(_funcMux, pdMS_TO_TICKS(10)) == pdTRUE) { + count = _funcValueCount; + memcpy(_snapBuf, _funcValues, count * sizeof(FunctionValue)); + xSemaphoreGive(_funcMux); + } + return {_snapBuf, count}; +} + +bool TransmitterApp::setTrim(uint8_t idx, int8_t value) { + if (idx >= _inputMapCount) + return false; + + if (xSemaphoreTake(_funcMux, pdMS_TO_TICKS(10)) == pdTRUE) { + _inputMap[idx].trim = value; + if (idx < _funcValueCount) + _funcValues[idx].trim = value; + xSemaphoreGive(_funcMux); + } + + // Persist + NvsStore nvs("odh", false); + NvsStore nvsr("odh", true); + uint8_t m = nvsr.getU8("model_type", static_cast(ModelType::Generic)); + char ek[16]; + snprintf(ek, sizeof(ek), "imape_%u", m); + nvs.putBytes(ek, _inputMap, _inputMapCount * sizeof(InputAssignment)); + return true; +} + /* ── taskWeb ─────────────────────────────────────────────────────────────── */ void TransmitterApp::taskWeb(void *param) { diff --git a/firmware/src/transmitter/TransmitterApp.h b/firmware/src/transmitter/TransmitterApp.h index baaf31c..6fedd89 100644 --- a/firmware/src/transmitter/TransmitterApp.h +++ b/firmware/src/transmitter/TransmitterApp.h @@ -23,18 +23,21 @@ * TransmitterApp – top-level application class for the transmitter. * * Owns all subsystems: Backplane, ModuleManager, RadioLink, BatteryMonitor, - * TelemetryData, Display, OdhWebServer, TransmitterApi. + * TelemetryData, Display, OdhWebServer, TransmitterApi, Shell. * - * Creates three FreeRTOS tasks: + * Creates FreeRTOS tasks: * taskControl – core 1, 50 Hz: read modules, send control packets - * taskDisplay – core 0, 4 Hz : LVGL refresh, touch events + * taskDisplay – core 0, 4 Hz : LVGL refresh, touch events (not in headless) + * taskShell – core 0 : interactive console * taskWeb – core 0, low : web config server */ #pragma once #include "backplane/Backplane.h" +#ifndef ODH_HEADLESS #include "display/Display.h" +#endif #include "modules/InputMap.h" #include "modules/ModuleManager.h" #include "web/TransmitterApi.h" @@ -42,10 +45,12 @@ #include #include #include +#include #include #include #include #include +#include namespace odh { @@ -53,6 +58,38 @@ class TransmitterApp { public: void begin(); + // ── Accessors for shell commands ──────────────────────────────── + + TransmitterRadioLink &radio() { + return _radio; + } + const TransmitterRadioLink &radio() const { + return _radio; + } + const BatteryMonitor &battery() const { + return _battery; + } + TelemetryData &telemetry() { + return _telemetry; + } + const TelemetryData &telemetry() const { + return _telemetry; + } + const ModuleManager &modules() const { + return _modules; + } + + /// Snapshot of current function values (thread-safe copy). + std::pair snapshotFuncValues(); + + /// Set trim for a function index. Returns false if out of range. + bool setTrim(uint8_t idx, int8_t value); + + /// Load input map for a model type (used by shell bind command). + void loadInputMapForModel(uint8_t model) { + loadInputMap(model); + } + private: // Subsystems Backplane _backplane{config::tx::kI2cMuxAddr, config::tx::kModuleSlotCount}; @@ -60,9 +97,12 @@ class TransmitterApp { TransmitterRadioLink _radio; BatteryMonitor _battery{config::kBatteryAdcPin, config::kBatteryDividerRatio, config::kAdcVrefMv, config::kAdcResolutionBits}; TelemetryData _telemetry; +#ifndef ODH_HEADLESS Display _display; +#endif OdhWebServer _webServer; TransmitterApi _api{_webServer, _radio, _battery, _telemetry, _modules}; + Shell _shell; // Input map (per current model) InputAssignment _inputMap[kMaxFunctions] = {}; @@ -71,6 +111,7 @@ class TransmitterApp { // Function values built each control cycle FunctionValue _funcValues[kMaxFunctions] = {}; uint8_t _funcValueCount = 0; + FunctionValue _snapBuf[kMaxFunctions] = {}; SemaphoreHandle_t _i2cMutex = nullptr; SemaphoreHandle_t _funcMux = nullptr; @@ -80,7 +121,10 @@ class TransmitterApp { // FreeRTOS task bodies static void taskControl(void *param); +#ifndef ODH_HEADLESS static void taskDisplay(void *param); +#endif + static void taskShell(void *param); static void taskWeb(void *param); }; diff --git a/firmware/src/transmitter/shell/TransmitterShellCommands.cpp b/firmware/src/transmitter/shell/TransmitterShellCommands.cpp new file mode 100644 index 0000000..c05a479 --- /dev/null +++ b/firmware/src/transmitter/shell/TransmitterShellCommands.cpp @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "TransmitterShellCommands.h" + +#include "../TransmitterApp.h" + +#include +#include +#include +#include +#include + +#ifdef NATIVE_SIM +#include +#endif + +#include +#include + +namespace odh { + +// ── status ────────────────────────────────────────────────────────────── + +static int cmdStatus(Shell &shell, int, const char *const *, void *ctx) { + auto &app = *static_cast(ctx); + + const char *linkStr = app.radio().isBound() ? "connected" : (app.radio().isScanning() ? "scanning" : "idle"); + shell.println("Link: %s", linkStr); + + if (app.radio().isBound()) { + shell.println("RSSI: %d dBm", app.radio().lastRssi()); + } + + shell.println("TX Batt: %u mV (%uS)", app.battery().voltageMv(), app.battery().cells()); + + if (app.telemetry().hasData()) { + shell.println("RX Batt: %u mV (%uS)", app.telemetry().rxBatteryMv(), app.telemetry().rxCells()); + shell.println("RX RSSI: %d dBm", app.telemetry().rssi()); + shell.println("Pkts/s: %.1f", static_cast(app.telemetry().packetsPerSecond())); + shell.println("Uptime: %lu ms", static_cast(app.telemetry().connectionUptimeMs())); + } + + return 0; +} + +// ── bind ───────────────────────────────────────────────────────────────── + +static int cmdBind(Shell &shell, int argc, const char *const *argv, void *ctx) { + auto &app = *static_cast(ctx); + + if (argc < 2) { + shell.println("Usage: bind >"); + return 1; + } + + if (strcmp(argv[1], "scan") == 0) { + if (app.radio().isBound()) { + shell.println("Already connected – disconnect first"); + return 1; + } + app.radio().startScanning(); + shell.println("Scanning for vehicles..."); + return 0; + } + + if (strcmp(argv[1], "list") == 0) { + uint8_t count = app.radio().discoveredCount(); + if (count == 0) { + shell.println("No vehicles discovered"); + return 0; + } + for (uint8_t i = 0; i < count; ++i) { + const auto *v = app.radio().discoveredVehicle(i); + if (v && v->valid) { + auto mn = modelName(v->modelType); +#ifdef ODH_HAS_STRING_VIEW + shell.println(" [%u] %-15s %.*s RSSI %d", i, v->name, static_cast(mn.size()), mn.data(), v->rssi); +#else + shell.println(" [%u] %-15s %s RSSI %d", i, v->name, mn, v->rssi); +#endif + } + } + return 0; + } + + if (strcmp(argv[1], "connect") == 0) { + if (argc < 3) { + shell.println("Usage: bind connect "); + return 1; + } + int idx = atoi(argv[2]); + if (idx < 0 || idx >= app.radio().discoveredCount()) { + shell.println("Index %d out of range (0-%d)", idx, app.radio().discoveredCount() - 1); + return 1; + } + const auto *veh = app.radio().discoveredVehicle(static_cast(idx)); + if (veh && veh->valid) { + app.loadInputMapForModel(veh->modelType); + } + if (app.radio().connectTo(static_cast(idx))) { + shell.println("Connected to vehicle %d", idx); + app.telemetry().reset(); + } else { + shell.println("Connection failed"); + } + return 0; + } + + shell.println("Unknown sub-command: %s", argv[1]); + return 1; +} + +// ── disconnect ────────────────────────────────────────────────────────── + +static int cmdDisconnect(Shell &shell, int, const char *const *, void *ctx) { + auto &app = *static_cast(ctx); + if (!app.radio().isBound()) { + shell.println("Not connected"); + return 1; + } + app.radio().disconnect(); + shell.println("Disconnected – scanning"); + return 0; +} + +// ── channel ───────────────────────────────────────────────────────────── + +static int cmdChannel(Shell &shell, int argc, const char *const *argv, void *ctx) { + auto &app = *static_cast(ctx); + + if (argc < 2) { + shell.println("Usage: channel >"); + return 1; + } + + if (strcmp(argv[1], "list") == 0) { + auto snap = app.snapshotFuncValues(); + for (uint8_t i = 0; i < snap.second; ++i) { + auto fn = functionName(snap.first[i].function); +#ifdef ODH_HAS_STRING_VIEW + shell.println(" [%u] %-10.*s %4u us trim %+d", i, static_cast(fn.size()), fn.data(), snap.first[i].value, snap.first[i].trim); +#else + shell.println(" [%u] %-10s %4u us trim %+d", i, fn, snap.first[i].value, snap.first[i].trim); +#endif + } + return 0; + } + + if (strcmp(argv[1], "set") == 0) { +#ifdef NATIVE_SIM + if (argc < 4) { + shell.println("Usage: channel set "); + return 1; + } + int idx = atoi(argv[2]); + int us = atoi(argv[3]); + if (idx < 0 || idx >= 16) { + shell.println("Index must be 0-15"); + return 1; + } + if (us < kChannelMin || us > kChannelMax) { + shell.println("Value must be %d-%d", kChannelMin, kChannelMax); + return 1; + } + { + std::lock_guard lock(g_simKeyboard.mtx); + g_simKeyboard.channels[idx] = static_cast(us); + } + shell.println("Channel %d = %d us", idx, us); + return 0; +#else + shell.println("channel set is only available in simulation"); + return 1; +#endif + } + + shell.println("Unknown sub-command: %s", argv[1]); + return 1; +} + +// ── trim ───────────────────────────────────────────────────────────────── + +static int cmdTrim(Shell &shell, int argc, const char *const *argv, void *ctx) { + auto &app = *static_cast(ctx); + + if (argc < 2) { + shell.println("Usage: trim >"); + return 1; + } + + if (strcmp(argv[1], "list") == 0) { + auto snap = app.snapshotFuncValues(); + for (uint8_t i = 0; i < snap.second; ++i) { + auto fn = functionName(snap.first[i].function); +#ifdef ODH_HAS_STRING_VIEW + shell.println(" [%u] %-10.*s trim %+d", i, static_cast(fn.size()), fn.data(), snap.first[i].trim); +#else + shell.println(" [%u] %-10s trim %+d", i, fn, snap.first[i].trim); +#endif + } + return 0; + } + + if (strcmp(argv[1], "set") == 0) { + if (argc < 4) { + shell.println("Usage: trim set "); + return 1; + } + int idx = atoi(argv[2]); + int val = atoi(argv[3]); + if (val < -100 || val > 100) { + shell.println("Trim must be -100 to +100"); + return 1; + } + if (!app.setTrim(static_cast(idx), static_cast(val))) { + shell.println("Index %d out of range", idx); + return 1; + } + shell.println("Trim[%d] = %+d", idx, val); + return 0; + } + + shell.println("Unknown sub-command: %s", argv[1]); + return 1; +} + +// ── module ────────────────────────────────────────────────────────────── + +// cppcheck-suppress constParameterCallback +static int cmdModule(Shell &shell, int, const char *const *, void *ctx) { + const auto &app = *static_cast(ctx); + + for (uint8_t s = 0; s < app.modules().slotCount(); ++s) { + const char *typeStr = "empty"; + switch (app.modules().typeAt(s)) { + case ModuleType::Switch: + typeStr = "Switch"; + break; + case ModuleType::Button: + typeStr = "Button"; + break; + case ModuleType::Potentiometer: + typeStr = "Pot"; + break; + case ModuleType::Encoder: + typeStr = "Encoder"; + break; + default: + break; + } + shell.println(" Slot %u: %s (%u inputs)", s, typeStr, app.modules().inputCount(s)); + } + return 0; +} + +// ── config ────────────────────────────────────────────────────────────── + +static int cmdConfig(Shell &shell, int argc, const char *const *argv, void *ctx) { + (void)ctx; + + if (argc < 2) { + shell.println("Usage: config "); + return 1; + } + + if (strcmp(argv[1], "get") == 0) { + NvsStore nvs("odh", true); + shell.println(" radio_ch: %u", nvs.getU8("radio_ch", config::kRadioWifiChannel)); + shell.println(" model: %u", nvs.getU8("model_type", static_cast(ModelType::Generic))); + shell.println(" tx_cells: %u", nvs.getU8("tx_cells", 0)); + shell.println(" rx_cells: %u", nvs.getU8("rx_cells", 0)); + String dn = nvs.getString("dev_name", "TX"); + shell.println(" dev_name: %s", dn.c_str()); + return 0; + } + + if (strcmp(argv[1], "set") == 0) { + if (argc < 4) { + shell.println("Usage: config set "); + shell.println(" Keys: radio_ch, tx_cells, rx_cells, dev_name"); + return 1; + } + + NvsStore nvs("odh", false); + + if (strcmp(argv[2], "radio_ch") == 0) { + int ch = atoi(argv[3]); + if (ch < 1 || ch > 13) { + shell.println("Channel must be 1-13"); + return 1; + } + nvs.putU8("radio_ch", static_cast(ch)); + shell.println("radio_ch = %d (restart required)", ch); + return 0; + } + + if (strcmp(argv[2], "tx_cells") == 0) { + int c = atoi(argv[3]); + if (c < 0 || c > 6) { + shell.println("Must be 0-6 (0 = auto)"); + return 1; + } + nvs.putU8("tx_cells", static_cast(c)); + shell.println("tx_cells = %d", c); + return 0; + } + + if (strcmp(argv[2], "rx_cells") == 0) { + int c = atoi(argv[3]); + if (c < 0 || c > 6) { + shell.println("Must be 0-6 (0 = auto)"); + return 1; + } + nvs.putU8("rx_cells", static_cast(c)); + shell.println("rx_cells = %d", c); + return 0; + } + + if (strcmp(argv[2], "dev_name") == 0) { + nvs.putString("dev_name", argv[3]); + shell.println("dev_name = %s", argv[3]); + return 0; + } + + shell.println("Unknown key: %s", argv[2]); + return 1; + } + + shell.println("Unknown sub-command: %s", argv[1]); + return 1; +} + +// ── Registration ──────────────────────────────────────────────────────── + +void registerTransmitterShellCommands(Shell &shell, TransmitterApp &app) { + shell.registerCommand("status", "Show link, battery and telemetry", cmdStatus, &app); + shell.registerCommand("bind", "Bind ops: scan, list, connect ", cmdBind, &app); + shell.registerCommand("disconnect", "Disconnect from vehicle", cmdDisconnect, &app); + shell.registerCommand("channel", "Channel ops: list, set ", cmdChannel, &app); + shell.registerCommand("trim", "Trim ops: list, set ", cmdTrim, &app); + shell.registerCommand("module", "List detected input modules", cmdModule, &app); + shell.registerCommand("config", "Config ops: get, set ", cmdConfig, &app); +} + +} // namespace odh diff --git a/firmware/src/transmitter/shell/TransmitterShellCommands.h b/firmware/src/transmitter/shell/TransmitterShellCommands.h new file mode 100644 index 0000000..f7d1824 --- /dev/null +++ b/firmware/src/transmitter/shell/TransmitterShellCommands.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * TransmitterShellCommands – shell command handlers for the transmitter. + */ + +#pragma once + +namespace odh { + +class Shell; +class TransmitterApp; + +/// Register all transmitter-specific shell commands. +void registerTransmitterShellCommands(Shell &shell, TransmitterApp &app); + +} // namespace odh diff --git a/firmware/test/test_console/README.md b/firmware/test/test_console/README.md new file mode 100644 index 0000000..c0d924b --- /dev/null +++ b/firmware/test/test_console/README.md @@ -0,0 +1,397 @@ +# OpenDriveHub Console Tests + +End-to-end integration tests for the ODH interactive shell (`odh-shell`). +The framework launches simulation binaries or connects to real hardware via +serial and sends commands through the same console interface a human operator +would use. + +--- + +## Prerequisites + +- **Python 3.10+** +- **PlatformIO Core CLI** (for building simulation binaries) + +Install the Python dependencies: + +```bash +cd firmware/test/test_console +pip install -r requirements.txt +``` + +The two packages are: + +| Package | Purpose | +|------------|----------------------------------------| +| `pytest` | Test runner and assertion framework | +| `pyserial` | Serial transport for real hardware | + +--- + +## Quick Start + +```bash +cd firmware/test/test_console + +# Run all tests against the receiver simulation (builds automatically) +pytest --target=sim_rx -v + +# Run all tests against the transmitter simulation +pytest --target=sim_tx -v +``` + +On first run, pytest triggers `pio run -e sim_rx` (or `sim_tx`) to build the +binary. Skip with `--no-build` if you already built it: + +```bash +pytest --target=sim_rx --no-build -v +``` + +--- + +## CLI Options + +| Option | Default | Description | +|--------------------|-----------|-------------------------------------------------| +| `--target=TARGET` | `sim_rx` | Which target to test (see table below) | +| `--port=PORT` | — | Serial port for hardware targets | +| `--build` | enabled | Build sim binary before tests (default) | +| `--no-build` | — | Skip the build step | + +### Available Targets + +| Target | Transport | Description | +|--------------|------------|---------------------------------------| +| `sim_rx` | PTY | Receiver simulation (subprocess) | +| `sim_tx` | PTY | Transmitter simulation (subprocess) | +| `serial_rx` | Serial | Receiver on real hardware via UART | +| `serial_tx` | Serial | Transmitter on real hardware via UART | + +### Examples + +```bash +# Receiver simulation – verbose output +pytest --target=sim_rx -v + +# Transmitter simulation – only startup tests +pytest --target=sim_tx -v test_startup.py + +# Real receiver hardware +pytest --target=serial_rx --port=/dev/ttyUSB0 -v + +# Run a single test +pytest --target=sim_rx -v test_help.py::TestHelp::test_help_lists_status + +# Show print output from tests +pytest --target=sim_rx -v -s +``` + +--- + +## Test Structure + +``` +firmware/test/test_console/ +├── conftest.py # Fixtures, CLI options, markers +├── console.py # Console helper (send_command, wait_for_prompt) +├── transport.py # SimTransport (PTY) + SerialTransport (pyserial) +├── requirements.txt # Python dependencies +├── README.md # This file +├── test_startup.py # Startup banner, prompt, exit/shutdown +├── test_help.py # help command lists all expected entries +├── test_commands_common.py # Commands shared by RX + TX +├── test_commands_rx.py # Receiver-only commands +└── test_commands_tx.py # Transmitter-only commands +``` + +### Test Files + +| File | Tests | Description | +|---------------------------|-------|-----------------------------------------------------| +| `test_startup.py` | 5 | Prompt appears, no errors, banner, `exit` (sim) | +| `test_help.py` | 10 | `help` lists all registered commands | +| `test_commands_common.py` | 13 | `status`, `config`, `channel`, unknown/empty input | +| `test_commands_rx.py` | 5 | `vehicle` get/set, `channel set` valid/invalid | +| `test_commands_tx.py` | 8 | `bind`, `trim`, `module`, `disconnect` | + +--- + +## Architecture + +### Transport Layer (`transport.py`) + +The abstract `Transport` class defines the I/O interface. Two implementations +exist: + +- **`SimTransport`** – Launches the simulation binary under a **PTY** + (pseudo-terminal). This is necessary because the ODH simulation detects + piped stdout via `isatty()` and redirects output to `/dev/tty`. Using a PTY + makes `isatty()` return `true`, so all output goes through the captured + stream. + +- **`SerialTransport`** – Opens a serial connection via `pyserial` at 115200 + baud. A background thread continuously reads incoming bytes into a buffer. + +Both transports provide: + +```python +transport.start() # Open connection / launch process +transport.stop() # Close connection / kill process +transport.write(b"help\n") # Send raw bytes +transport.read_until("odh> ", timeout=5.0) # Read until pattern +transport.read_available(timeout=0.5) # Read whatever is buffered +transport.exit_code # Process exit code (sim only) +``` + +### Console Helper (`console.py`) + +The `Console` class wraps a transport and provides command-level methods: + +```python +console.start(startup_timeout=10.0) # Start + wait for first prompt +console.startup_output # Everything printed before first prompt +console.send_command("status") # Send command, wait for prompt, return output +console.send_raw(b"\x1b[A") # Send raw bytes (e.g. arrow keys) +console.stop() # Shut down +``` + +`send_command()` automatically: +1. Appends `\n` and sends the command +2. Waits for the next `odh> ` prompt +3. Strips the echoed command line from the output +4. Returns only the command's response text + +### Fixtures (`conftest.py`) + +| Fixture | Scope | Description | +|---------------|----------|----------------------------------------------| +| `console` | module | Ready `Console` instance (prompt received) | +| `target` | session | Target string (e.g. `"sim_rx"`) | +| `target_type` | session | `"rx"` or `"tx"` | +| `is_sim` | session | `True` for simulation targets | + +The `console` fixture is **module-scoped**: one process per test file. This +keeps tests fast (~5 seconds for all 41 tests) while still isolating test +modules from each other. + +### Markers + +| Marker | Effect | +|--------------|---------------------------------------------| +| `@pytest.mark.rx` | Runs only on receiver targets | +| `@pytest.mark.tx` | Runs only on transmitter targets | +| `@pytest.mark.sim_only` | Runs only on simulation targets | + +Markers are applied automatically – tests with a wrong marker are skipped with +a descriptive reason. + +--- + +## Writing New Tests + +### 1. Basic Test + +Every test receives the `console` fixture and uses `send_command()`: + +```python +from console import Console + +class TestMyFeature: + def test_something(self, console: Console) -> None: + output = console.send_command("my_command arg1 arg2") + assert "expected text" in output +``` + +`send_command()` returns the shell output as a string (without echo and +prompt). Use standard `assert` statements to validate the response. + +### 2. Target-Specific Tests + +Use markers to restrict tests to receiver or transmitter: + +```python +import pytest + +pytestmark = pytest.mark.tx # All tests in this file are TX-only + +class TestTransmitterFeature: + def test_bind_status(self, console: Console) -> None: + output = console.send_command("bind list") + assert "error" not in output.lower() +``` + +Or mark individual tests: + +```python +class TestMixed: + @pytest.mark.rx + def test_vehicle_name(self, console: Console) -> None: + output = console.send_command("vehicle") + assert len(output.strip()) > 0 + + @pytest.mark.tx + def test_trim_value(self, console: Console) -> None: + output = console.send_command("trim list") + assert len(output.strip()) > 0 +``` + +### 3. Simulation-Only Tests + +For tests that can't run on real hardware (e.g. killing the process): + +```python +@pytest.mark.sim_only +def test_exit_terminates(self, console: Console) -> None: + console.send_raw(b"exit\n") + time.sleep(1.0) + assert console.exit_code == 0 +``` + +### 4. Testing Set/Get Roundtrips + +A common pattern is to set a value, read it back, then restore the original: + +```python +def test_config_roundtrip(self, console: Console) -> None: + # Save original + original = console.send_command("config get") + + # Set new value + console.send_command("config set batt_cell 3") + + # Verify + output = console.send_command("config get") + assert "3" in output + + # Restore + console.send_command("config set batt_cell 0") +``` + +### 5. Testing Error Responses + +Verify that invalid input produces an error message (not a crash): + +```python +def test_invalid_argument(self, console: Console) -> None: + output = console.send_command("channel set 0 99999") + out_lower = output.lower() + assert any(kw in out_lower for kw in ("error", "invalid", "must be", "range")), ( + f"Expected error message, got:\n{output}" + ) +``` + +### 6. Using the `startup_output` Property + +To verify what the firmware prints before the shell prompt: + +```python +def test_no_crash_on_startup(self, console: Console) -> None: + assert "PANIC" not in console.startup_output.upper() + assert "[ERROR]" not in console.startup_output.upper() +``` + +### 7. Fresh Process per Test (Shutdown Tests) + +The `console` fixture is module-scoped (shared across one file). If a test +needs its own process (e.g. to test `exit`), create a transport manually: + +```python +from conftest import TARGET_ENV_MAP, _sim_binary_path +from transport import SimTransport + +@pytest.mark.sim_only +def test_exit_code(self, target: str) -> None: + binary = _sim_binary_path(TARGET_ENV_MAP[target]) + transport = SimTransport(binary) + con = Console(transport) + con.start(startup_timeout=10.0) + try: + con.send_raw(b"exit\n") + time.sleep(1.0) + assert con.exit_code == 0 + finally: + con.stop() +``` + +--- + +## Adding a New Test File + +1. Create `test_my_feature.py` in `firmware/test/test_console/` +2. Add the GPL-3.0 copyright header (see existing files) +3. Import `Console` and use the `console` fixture +4. Add markers if tests are target-specific +5. Run: `pytest --target=sim_rx -v test_my_feature.py` + +### Template + +```python +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# ... (full GPL-3.0 header) +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for .""" + +from __future__ import annotations + +import pytest + +from console import Console + + +class TestMyFeature: + """Describe what this test class verifies.""" + + def test_basic_case(self, console: Console) -> None: + output = console.send_command("my_command") + assert "expected" in output.lower() + + @pytest.mark.rx + def test_rx_specific(self, console: Console) -> None: + output = console.send_command("vehicle") + assert len(output.strip()) > 0 +``` + +--- + +## Timeouts + +| Scenario | Default Timeout | Where to Change | +|------------------|-----------------|--------------------------| +| Startup | 10 s | `conftest.py` → `con.start(startup_timeout=…)` | +| Command response | 5 s | `console.send_command(cmd, timeout=…)` | +| Read available | 0.5 s | `console.read_available(timeout=…)` | + +For real hardware, you may need longer timeouts. Override per-command: + +```python +output = console.send_command("slow_command", timeout=15.0) +``` + +--- + +## Troubleshooting + +### `TimeoutError: Timed out waiting for pattern 'odh> '` + +- The simulation may not have started. Run `pio run -e sim_rx` manually to + check for build errors. +- On serial: verify the port and baud rate (`115200`). +- Increase the timeout: `console.send_command("cmd", timeout=15.0)`. + +### Tests fail with `FileNotFoundError: Simulation binary not found` + +- Build the simulation first: `cd firmware && pio run -e sim_rx` +- Or remove `--no-build` to let pytest build automatically. + +### Serial `--port` required error + +- Hardware targets need `--port`: `pytest --target=serial_rx --port=/dev/ttyUSB0` + +### PTY-related errors on macOS/Windows + +- The `SimTransport` uses Linux PTY (`pty.openpty()`). On macOS this should + work. Windows is not supported for simulation tests (use WSL). diff --git a/firmware/test/test_console/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/firmware/test/test_console/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..0168a34 Binary files /dev/null and b/firmware/test/test_console/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/firmware/test/test_console/__pycache__/console.cpython-313.pyc b/firmware/test/test_console/__pycache__/console.cpython-313.pyc new file mode 100644 index 0000000..6dae39c Binary files /dev/null and b/firmware/test/test_console/__pycache__/console.cpython-313.pyc differ diff --git a/firmware/test/test_console/__pycache__/test_commands_common.cpython-313-pytest-9.0.2.pyc b/firmware/test/test_console/__pycache__/test_commands_common.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..75fa3cf Binary files /dev/null and b/firmware/test/test_console/__pycache__/test_commands_common.cpython-313-pytest-9.0.2.pyc differ diff --git a/firmware/test/test_console/__pycache__/test_commands_rx.cpython-313-pytest-9.0.2.pyc b/firmware/test/test_console/__pycache__/test_commands_rx.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..6ab8cdd Binary files /dev/null and b/firmware/test/test_console/__pycache__/test_commands_rx.cpython-313-pytest-9.0.2.pyc differ diff --git a/firmware/test/test_console/__pycache__/test_commands_tx.cpython-313-pytest-9.0.2.pyc b/firmware/test/test_console/__pycache__/test_commands_tx.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..a5f0074 Binary files /dev/null and b/firmware/test/test_console/__pycache__/test_commands_tx.cpython-313-pytest-9.0.2.pyc differ diff --git a/firmware/test/test_console/__pycache__/test_help.cpython-313-pytest-9.0.2.pyc b/firmware/test/test_console/__pycache__/test_help.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..2e470b3 Binary files /dev/null and b/firmware/test/test_console/__pycache__/test_help.cpython-313-pytest-9.0.2.pyc differ diff --git a/firmware/test/test_console/__pycache__/test_startup.cpython-313-pytest-9.0.2.pyc b/firmware/test/test_console/__pycache__/test_startup.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..9214bbb Binary files /dev/null and b/firmware/test/test_console/__pycache__/test_startup.cpython-313-pytest-9.0.2.pyc differ diff --git a/firmware/test/test_console/__pycache__/transport.cpython-313.pyc b/firmware/test/test_console/__pycache__/transport.cpython-313.pyc new file mode 100644 index 0000000..3c29058 Binary files /dev/null and b/firmware/test/test_console/__pycache__/transport.cpython-313.pyc differ diff --git a/firmware/test/test_console/conftest.py b/firmware/test/test_console/conftest.py new file mode 100644 index 0000000..c7c448e --- /dev/null +++ b/firmware/test/test_console/conftest.py @@ -0,0 +1,181 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Pytest configuration and fixtures for ODH console tests.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from console import Console +from transport import SerialTransport, SimTransport + +FIRMWARE_DIR = os.path.normpath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) +) + +TARGET_ENV_MAP = { + "sim_rx": "sim_rx", + "sim_tx": "sim_tx", + "serial_rx": None, + "serial_tx": None, +} + +TARGET_ROLE = { + "sim_rx": "rx", + "sim_tx": "tx", + "serial_rx": "rx", + "serial_tx": "tx", +} + + +# -- CLI options ------------------------------------------------------------- + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--target", + choices=list(TARGET_ENV_MAP.keys()), + default="sim_rx", + help="Target to test against (default: sim_rx)", + ) + parser.addoption( + "--port", + default=None, + help="Serial port for hardware targets (e.g. /dev/ttyUSB0)", + ) + parser.addoption( + "--build", + action="store_true", + default=True, + dest="build", + help="Build the simulation binary before running tests (default)", + ) + parser.addoption( + "--no-build", + action="store_false", + dest="build", + help="Skip building the simulation binary", + ) + + +# -- markers ----------------------------------------------------------------- + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "rx: test only runs on receiver targets") + config.addinivalue_line( + "markers", "tx: test only runs on transmitter targets" + ) + config.addinivalue_line( + "markers", "sim_only: test only runs on simulation targets" + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + target = config.getoption("--target") + role = TARGET_ROLE[target] + is_sim = target.startswith("sim_") + + for item in items: + if "rx" in item.keywords and role != "rx": + item.add_marker( + pytest.mark.skip(reason=f"RX-only test, target is {target}") + ) + if "tx" in item.keywords and role != "tx": + item.add_marker( + pytest.mark.skip(reason=f"TX-only test, target is {target}") + ) + if "sim_only" in item.keywords and not is_sim: + item.add_marker( + pytest.mark.skip( + reason=f"Sim-only test, target is {target}" + ) + ) + + +# -- fixtures ---------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def target(request: pytest.FixtureRequest) -> str: + return request.config.getoption("--target") + + +@pytest.fixture(scope="session") +def target_type(target: str) -> str: + """Return ``'rx'`` or ``'tx'``.""" + return TARGET_ROLE[target] + + +@pytest.fixture(scope="session") +def is_sim(target: str) -> bool: + return target.startswith("sim_") + + +def _build_sim(env_name: str) -> None: + """Run ``pio run -e `` to build the simulation binary.""" + print(f"\n>>> Building {env_name} …") + result = subprocess.run( + ["pio", "run", "-e", env_name, "--silent"], + cwd=FIRMWARE_DIR, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stdout) + print(result.stderr, file=sys.stderr) + pytest.fail(f"Build of {env_name} failed (exit {result.returncode})") + + +def _sim_binary_path(env_name: str) -> str: + return os.path.join(FIRMWARE_DIR, ".pio", "build", env_name, "program") + + +@pytest.fixture(scope="module") +def console(request: pytest.FixtureRequest, target: str) -> Console: + """Yield a ready :class:`Console` with the first prompt already received.""" + + is_sim_target = target.startswith("sim_") + + if is_sim_target: + env_name = TARGET_ENV_MAP[target] + if request.config.getoption("build"): + _build_sim(env_name) + + binary = _sim_binary_path(env_name) + transport = SimTransport(binary) + else: + port = request.config.getoption("--port") + if port is None: + pytest.fail( + f"--port is required for serial target {target!r}" + ) + transport = SerialTransport(port) + + con = Console(transport) + con.start(startup_timeout=10.0) + yield con + con.stop() diff --git a/firmware/test/test_console/console.py b/firmware/test/test_console/console.py new file mode 100644 index 0000000..9780642 --- /dev/null +++ b/firmware/test/test_console/console.py @@ -0,0 +1,104 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""High-level console helper for interacting with the ODH shell.""" + +from __future__ import annotations + +from typing import Optional + +from transport import Transport + +PROMPT = "odh> " + + +class Console: + """Wraps a :class:`Transport` and provides command-level helpers.""" + + def __init__(self, transport: Transport, prompt: str = PROMPT) -> None: + self._transport = transport + self._prompt = prompt + self.startup_output: str = "" + + # -- lifecycle ----------------------------------------------------------- + + def start(self, startup_timeout: float = 10.0) -> None: + """Start the transport and wait for the first prompt. + + The output captured before the prompt is stored in + :attr:`startup_output`. + """ + self._transport.start() + self.startup_output = self.wait_for_prompt(timeout=startup_timeout) + + def stop(self) -> None: + """Stop the transport.""" + self._transport.stop() + + # -- command helpers ----------------------------------------------------- + + def wait_for_prompt(self, timeout: float = 5.0) -> str: + """Block until the shell prompt appears. + + Returns all output received *before* the prompt. + """ + raw = self._transport.read_until(self._prompt, timeout=timeout) + # Strip the trailing prompt from the returned text + if raw.endswith(self._prompt): + return raw[: -len(self._prompt)] + return raw + + def send_command(self, command: str, timeout: float = 5.0) -> str: + """Send *command*, wait for the next prompt, return the output. + + The returned string does **not** include the typed command echo + or the trailing prompt. + """ + self._transport.write((command + "\n").encode("utf-8")) + raw = self._transport.read_until(self._prompt, timeout=timeout) + + # The shell echoes the command back; strip it from the output. + if raw.endswith(self._prompt): + raw = raw[: -len(self._prompt)] + + lines = raw.split("\n") + # Remove echo line(s) that match the sent command + output_lines = [] + echo_skipped = False + for line in lines: + stripped = line.strip() + if not echo_skipped and stripped == command.strip(): + echo_skipped = True + continue + output_lines.append(line) + + return "\n".join(output_lines).strip() + + def send_raw(self, data: bytes) -> None: + """Send raw bytes without appending a newline.""" + self._transport.write(data) + + def read_available(self, timeout: float = 0.5) -> str: + """Read whatever output is available.""" + return self._transport.read_available(timeout=timeout) + + @property + def exit_code(self) -> Optional[int]: + """Return the process exit code (sim only), or None.""" + return self._transport.exit_code diff --git a/firmware/test/test_console/requirements.txt b/firmware/test/test_console/requirements.txt new file mode 100644 index 0000000..ca50f0f --- /dev/null +++ b/firmware/test/test_console/requirements.txt @@ -0,0 +1,2 @@ +pytest +pyserial diff --git a/firmware/test/test_console/test_commands_common.py b/firmware/test/test_console/test_commands_common.py new file mode 100644 index 0000000..dc6ae8f --- /dev/null +++ b/firmware/test/test_console/test_commands_common.py @@ -0,0 +1,159 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for commands shared by both receiver and transmitter.""" + +from __future__ import annotations + +import pytest + +from console import Console + + +class TestUnknownCommand: + """Verify behaviour when the user enters an unrecognised command.""" + + def test_unknown_command_error_message(self, console: Console) -> None: + output = console.send_command("foobar_nonexistent") + assert "unknown command" in output.lower(), ( + f"Expected 'Unknown command' error, got:\n{output}" + ) + + def test_unknown_command_suggests_help(self, console: Console) -> None: + output = console.send_command("notacommand") + assert "help" in output.lower(), ( + f"Expected suggestion to type 'help', got:\n{output}" + ) + + +class TestEmptyLine: + """Verify that pressing Enter without a command does nothing harmful.""" + + def test_empty_line_no_error(self, console: Console) -> None: + """An empty line should just produce a new prompt (no error).""" + output = console.send_command("") + # Empty line should produce no output or just whitespace + assert "error" not in output.lower(), ( + f"Empty line produced an error:\n{output}" + ) + assert "unknown" not in output.lower(), ( + f"Empty line triggered 'unknown command':\n{output}" + ) + + +class TestStatus: + """Verify the ``status`` command produces valid output.""" + + def test_status_returns_output(self, console: Console) -> None: + output = console.send_command("status") + assert len(output.strip()) > 0, "status returned empty output" + + def test_status_contains_link_info(self, console: Console) -> None: + """``status`` must mention some kind of link/connection state.""" + output = console.send_command("status").lower() + has_link_info = any( + kw in output + for kw in ("link", "connected", "disconnected", "scanning", + "announcing", "idle", "state") + ) + assert has_link_info, ( + f"status output missing link info:\n{output}" + ) + + def test_status_contains_battery(self, console: Console) -> None: + """``status`` must mention battery information.""" + output = console.send_command("status").lower() + has_battery = any( + kw in output for kw in ("battery", "batt", "voltage", "cell") + ) + assert has_battery, ( + f"status output missing battery info:\n{output}" + ) + + +class TestConfigGet: + """Verify ``config get`` returns key-value data.""" + + def test_config_get_returns_output(self, console: Console) -> None: + output = console.send_command("config get") + assert len(output.strip()) > 0, "config get returned empty output" + + def test_config_get_contains_radio_ch(self, console: Console) -> None: + output = console.send_command("config get") + assert "radio_ch" in output, ( + f"config get missing radio_ch:\n{output}" + ) + + def test_config_set_invalid_key(self, console: Console) -> None: + """Setting an invalid key must produce an error.""" + output = console.send_command("config set totally_invalid_key 42") + out_lower = output.lower() + assert "error" in out_lower or "unknown" in out_lower or "invalid" in out_lower, ( + f"Expected error for invalid key, got:\n{output}" + ) + + +class TestConfigRoundtrip: + """Verify ``config set`` followed by ``config get`` persists values.""" + + @pytest.mark.rx + def test_config_set_get_batt_cell_rx(self, console: Console) -> None: + """RX: set batt_cell, read it back.""" + console.send_command("config set batt_cell 3") + output = console.send_command("config get") + assert "3" in output, ( + f"batt_cell not set to 3 in config get:\n{output}" + ) + # Reset to auto + console.send_command("config set batt_cell 0") + + @pytest.mark.tx + def test_config_set_get_tx_cells(self, console: Console) -> None: + """TX: set tx_cells, read it back.""" + console.send_command("config set tx_cells 3") + output = console.send_command("config get") + assert "3" in output, ( + f"tx_cells not set to 3 in config get:\n{output}" + ) + # Reset to auto + console.send_command("config set tx_cells 0") + + +class TestChannelList: + """Verify ``channel list`` shows channel information.""" + + def test_channel_list_returns_output(self, console: Console) -> None: + output = console.send_command("channel list") + assert len(output.strip()) > 0, "channel list returned empty output" + + +class TestMultipleCommands: + """Verify that running several commands in sequence works.""" + + def test_sequential_commands(self, console: Console) -> None: + """Run 5 different commands and ensure all return valid output.""" + commands = ["help", "status", "config get", "channel list", "help"] + for cmd in commands: + output = console.send_command(cmd) + assert len(output.strip()) > 0, ( + f"Command {cmd!r} returned empty output" + ) + assert "error" not in output.lower() or cmd == "config set", ( + f"Command {cmd!r} produced error:\n{output}" + ) diff --git a/firmware/test/test_console/test_commands_rx.py b/firmware/test/test_console/test_commands_rx.py new file mode 100644 index 0000000..e800ddb --- /dev/null +++ b/firmware/test/test_console/test_commands_rx.py @@ -0,0 +1,80 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Receiver-specific console command tests.""" + +from __future__ import annotations + +import pytest + +from console import Console + +pytestmark = pytest.mark.rx + + +class TestVehicle: + """Verify the ``vehicle`` command for getting/setting the vehicle name.""" + + def test_vehicle_get(self, console: Console) -> None: + """``vehicle`` (no args) must return a name string.""" + output = console.send_command("vehicle") + assert len(output.strip()) > 0, "vehicle returned empty output" + + def test_vehicle_set_get_roundtrip(self, console: Console) -> None: + """Set vehicle name, then read it back.""" + # Save original + original = console.send_command("vehicle").strip() + + console.send_command('vehicle "TestCar"') + output = console.send_command("vehicle") + assert "TestCar" in output, ( + f"Vehicle name not updated to TestCar:\n{output}" + ) + + # Restore original + if original: + console.send_command(f'vehicle "{original}"') + + +class TestChannelSetRx: + """Verify ``channel set`` on the receiver.""" + + def test_channel_set_valid(self, console: Console) -> None: + """Setting channel 0 to 1500µs must succeed.""" + output = console.send_command("channel set 0 1500") + out_lower = output.lower() + assert "error" not in out_lower and "invalid" not in out_lower, ( + f"Valid channel set failed:\n{output}" + ) + + def test_channel_set_invalid_index(self, console: Console) -> None: + """Setting an out-of-range channel index must fail.""" + output = console.send_command("channel set 99 1500") + out_lower = output.lower() + assert "error" in out_lower or "invalid" in out_lower or "range" in out_lower or "must be" in out_lower, ( + f"Expected error for invalid channel index:\n{output}" + ) + + def test_channel_set_invalid_value(self, console: Console) -> None: + """Setting an out-of-range PWM value must fail.""" + output = console.send_command("channel set 0 9999") + out_lower = output.lower() + assert "error" in out_lower or "invalid" in out_lower or "range" in out_lower or "must be" in out_lower, ( + f"Expected error for invalid PWM value:\n{output}" + ) diff --git a/firmware/test/test_console/test_commands_tx.py b/firmware/test/test_console/test_commands_tx.py new file mode 100644 index 0000000..193dc09 --- /dev/null +++ b/firmware/test/test_console/test_commands_tx.py @@ -0,0 +1,98 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Transmitter-specific console command tests.""" + +from __future__ import annotations + +import pytest + +from console import Console + +pytestmark = pytest.mark.tx + + +class TestBind: + """Verify ``bind`` subcommands.""" + + def test_bind_scan(self, console: Console) -> None: + """``bind scan`` must not crash.""" + output = console.send_command("bind scan") + out_lower = output.lower() + assert "error" not in out_lower and "unknown" not in out_lower, ( + f"bind scan failed:\n{output}" + ) + + def test_bind_list(self, console: Console) -> None: + """``bind list`` must return output (may be empty list).""" + output = console.send_command("bind list") + # Might say "no vehicles" or list entries – both are fine + assert "error" not in output.lower(), ( + f"bind list returned error:\n{output}" + ) + + +class TestDisconnect: + """Verify ``disconnect`` when not connected.""" + + def test_disconnect_when_idle(self, console: Console) -> None: + """``disconnect`` when not connected must not crash.""" + output = console.send_command("disconnect") + # May say "not connected" or similar – that's fine + assert "error" not in output.lower() or "not connected" in output.lower(), ( + f"disconnect produced unexpected error:\n{output}" + ) + + +class TestTrim: + """Verify ``trim`` subcommands.""" + + def test_trim_list(self, console: Console) -> None: + """``trim list`` must return formatted output.""" + output = console.send_command("trim list") + assert len(output.strip()) > 0, "trim list returned empty output" + + def test_trim_set_get_roundtrip(self, console: Console) -> None: + """Set trim, verify it appears in ``trim list``.""" + console.send_command("trim set 0 50") + output = console.send_command("trim list") + assert "50" in output, ( + f"trim value 50 not found in trim list:\n{output}" + ) + # Reset trim + console.send_command("trim set 0 0") + + def test_trim_set_invalid_range(self, console: Console) -> None: + """Setting trim outside -100..+100 must fail.""" + output = console.send_command("trim set 0 200") + out_lower = output.lower() + assert "error" in out_lower or "invalid" in out_lower or "range" in out_lower or "must be" in out_lower, ( + f"Expected error for out-of-range trim:\n{output}" + ) + + +class TestModule: + """Verify ``module`` command.""" + + def test_module_list(self, console: Console) -> None: + """``module`` must return output (may report no modules in sim).""" + output = console.send_command("module") + assert "error" not in output.lower(), ( + f"module returned error:\n{output}" + ) diff --git a/firmware/test/test_console/test_help.py b/firmware/test/test_console/test_help.py new file mode 100644 index 0000000..190dd47 --- /dev/null +++ b/firmware/test/test_console/test_help.py @@ -0,0 +1,81 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for the ``help`` command.""" + +from __future__ import annotations + +import pytest + +from console import Console + + +class TestHelp: + """Verify that ``help`` lists all expected commands.""" + + def test_help_returns_output(self, console: Console) -> None: + """``help`` must produce non-empty output.""" + output = console.send_command("help") + assert len(output.strip()) > 0, "help returned empty output" + + def test_help_lists_help(self, console: Console) -> None: + output = console.send_command("help") + assert "help" in output.lower() + + def test_help_lists_status(self, console: Console) -> None: + output = console.send_command("help") + assert "status" in output.lower() + + def test_help_lists_config(self, console: Console) -> None: + output = console.send_command("help") + assert "config" in output.lower() + + def test_help_lists_channel(self, console: Console) -> None: + output = console.send_command("help") + assert "channel" in output.lower() + + @pytest.mark.rx + def test_help_lists_vehicle(self, console: Console) -> None: + """RX ``help`` must list the ``vehicle`` command.""" + output = console.send_command("help") + assert "vehicle" in output.lower() + + @pytest.mark.tx + def test_help_lists_bind(self, console: Console) -> None: + """TX ``help`` must list the ``bind`` command.""" + output = console.send_command("help") + assert "bind" in output.lower() + + @pytest.mark.tx + def test_help_lists_trim(self, console: Console) -> None: + """TX ``help`` must list the ``trim`` command.""" + output = console.send_command("help") + assert "trim" in output.lower() + + @pytest.mark.tx + def test_help_lists_module(self, console: Console) -> None: + """TX ``help`` must list the ``module`` command.""" + output = console.send_command("help") + assert "module" in output.lower() + + @pytest.mark.tx + def test_help_lists_disconnect(self, console: Console) -> None: + """TX ``help`` must list the ``disconnect`` command.""" + output = console.send_command("help") + assert "disconnect" in output.lower() diff --git a/firmware/test/test_console/test_startup.py b/firmware/test/test_console/test_startup.py new file mode 100644 index 0000000..1e4d853 --- /dev/null +++ b/firmware/test/test_console/test_startup.py @@ -0,0 +1,112 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Startup and shutdown tests for the ODH console.""" + +from __future__ import annotations + +import time + +import pytest + +from console import Console +from transport import SimTransport + + +class TestStartup: + """Verify that the shell starts correctly.""" + + def test_prompt_appears(self, console: Console) -> None: + """The shell must print the ``odh> `` prompt on startup.""" + # If we got here, conftest already waited for the prompt successfully. + assert console.startup_output is not None + + def test_no_error_on_startup(self, console: Console) -> None: + """Startup output must not contain error indicators.""" + out = console.startup_output.upper() + assert "[ERROR]" not in out, ( + f"Startup output contains [ERROR]:\n{console.startup_output}" + ) + assert "[FATAL]" not in out, ( + f"Startup output contains [FATAL]:\n{console.startup_output}" + ) + assert "PANIC" not in out, ( + f"Startup output contains PANIC:\n{console.startup_output}" + ) + + @pytest.mark.rx + def test_startup_banner_rx(self, console: Console) -> None: + """Receiver startup output must contain the RX banner.""" + assert "[ODH-RX]" in console.startup_output or "[ODH]" in console.startup_output, ( + f"Missing ODH banner in:\n{console.startup_output}" + ) + + @pytest.mark.tx + def test_startup_banner_tx(self, console: Console) -> None: + """Transmitter startup output must contain the TX banner.""" + assert "[ODH-TX]" in console.startup_output or "[ODH]" in console.startup_output, ( + f"Missing ODH banner in:\n{console.startup_output}" + ) + + +class TestShutdown: + """Verify the exit command (simulation only).""" + + @pytest.mark.sim_only + def test_exit_prints_bye(self, target: str, request: pytest.FixtureRequest) -> None: + """The ``exit`` command must print 'Bye.' before terminating.""" + from conftest import FIRMWARE_DIR, TARGET_ENV_MAP, _sim_binary_path + + env_name = TARGET_ENV_MAP[target] + binary = _sim_binary_path(env_name) + transport = SimTransport(binary) + con = Console(transport) + con.start(startup_timeout=10.0) + + try: + con.send_raw(b"exit\n") + # Give the process time to print and exit + time.sleep(1.0) + remaining = con.read_available(timeout=0.5) + assert "Bye." in remaining or con.exit_code is not None + finally: + con.stop() + + @pytest.mark.sim_only + def test_exit_code_clean(self, target: str) -> None: + """After ``exit``, the process must terminate with code 0.""" + from conftest import TARGET_ENV_MAP, _sim_binary_path + + env_name = TARGET_ENV_MAP[target] + binary = _sim_binary_path(env_name) + transport = SimTransport(binary) + con = Console(transport) + con.start(startup_timeout=10.0) + + try: + con.send_raw(b"exit\n") + # Wait for process to terminate + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline and con.exit_code is None: + time.sleep(0.1) + assert con.exit_code == 0, ( + f"Expected exit code 0, got {con.exit_code}" + ) + finally: + con.stop() diff --git a/firmware/test/test_console/transport.py b/firmware/test/test_console/transport.py new file mode 100644 index 0000000..4ac38bf --- /dev/null +++ b/firmware/test/test_console/transport.py @@ -0,0 +1,285 @@ +# Copyright (C) 2026 Peter Buchegger +# +# This file is part of OpenDriveHub. +# +# OpenDriveHub is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenDriveHub is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenDriveHub. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Transport layer for console tests. + +Provides an abstract Transport interface with two implementations: +- SimTransport: launches a simulation binary as a subprocess +- SerialTransport: connects to real hardware via pyserial +""" + +import abc +import os +import pty +import subprocess +import threading +import time +from typing import Optional + +import serial + + +class Transport(abc.ABC): + """Abstract base for console I/O transports.""" + + @abc.abstractmethod + def start(self) -> None: + """Open the connection / launch the process.""" + + @abc.abstractmethod + def stop(self) -> None: + """Close the connection / kill the process.""" + + @abc.abstractmethod + def write(self, data: bytes) -> None: + """Send raw bytes to the console.""" + + @abc.abstractmethod + def read_until(self, pattern: str, timeout: float = 5.0) -> str: + """Read output until *pattern* is found or *timeout* expires. + + Returns all captured output (including the pattern). + Raises ``TimeoutError`` with partial output on timeout. + """ + + @abc.abstractmethod + def read_available(self, timeout: float = 0.5) -> str: + """Read whatever output is available within *timeout* seconds.""" + + @property + @abc.abstractmethod + def exit_code(self) -> Optional[int]: + """Return the process exit code, or ``None`` if still running / N/A.""" + + +class SimTransport(Transport): + """Launch a PlatformIO simulation binary via a PTY. + + The simulation binary uses ``isatty()`` to decide whether to write to + stdout or ``/dev/tty``. By running it under a pseudo-terminal, stdout + is a TTY and all output goes through the PTY – which we can capture. + """ + + def __init__(self, binary_path: str) -> None: + self._binary_path = binary_path + self._proc: Optional[subprocess.Popen] = None + self._master_fd: Optional[int] = None + self._output_buf = "" + self._buf_lock = threading.Lock() + self._reader_thread: Optional[threading.Thread] = None + self._running = False + + # -- lifecycle ----------------------------------------------------------- + + def start(self) -> None: + if not os.path.isfile(self._binary_path): + raise FileNotFoundError( + f"Simulation binary not found: {self._binary_path}" + ) + + # Create a PTY so the child sees a real terminal (isatty → true). + master_fd, slave_fd = pty.openpty() + self._master_fd = master_fd + + self._proc = subprocess.Popen( + [self._binary_path], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + ) + os.close(slave_fd) + + self._running = True + self._reader_thread = threading.Thread( + target=self._reader_loop, daemon=True + ) + self._reader_thread.start() + + def stop(self) -> None: + self._running = False + if self._proc is not None: + try: + self._proc.terminate() + self._proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait(timeout=2) + if self._master_fd is not None: + try: + os.close(self._master_fd) + except OSError: + pass + self._master_fd = None + + # -- I/O ----------------------------------------------------------------- + + def write(self, data: bytes) -> None: + assert self._master_fd is not None + os.write(self._master_fd, data) + + def read_until(self, pattern: str, timeout: float = 5.0) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with self._buf_lock: + idx = self._output_buf.find(pattern) + if idx != -1: + end = idx + len(pattern) + result = self._output_buf[:end] + self._output_buf = self._output_buf[end:] + return result + if self._proc is not None and self._proc.poll() is not None: + with self._buf_lock: + remaining = self._output_buf + self._output_buf = "" + raise TimeoutError( + f"Process exited (code {self._proc.returncode}) before " + f"pattern {pattern!r} was found. Output so far:\n{remaining}" + ) + time.sleep(0.02) + + with self._buf_lock: + partial = self._output_buf + self._output_buf = "" + raise TimeoutError( + f"Timed out ({timeout}s) waiting for pattern {pattern!r}. " + f"Output so far:\n{partial}" + ) + + def read_available(self, timeout: float = 0.5) -> str: + time.sleep(timeout) + with self._buf_lock: + result = self._output_buf + self._output_buf = "" + return result + + @property + def exit_code(self) -> Optional[int]: + if self._proc is None: + return None + return self._proc.poll() + + # -- internals ----------------------------------------------------------- + + def _reader_loop(self) -> None: + assert self._master_fd is not None + try: + while self._running: + try: + data = os.read(self._master_fd, 4096) + except OSError: + break + if not data: + break + text = data.decode("utf-8", errors="replace") + with self._buf_lock: + self._output_buf += text + except (OSError, ValueError): + pass + + +class SerialTransport(Transport): + """Communicate with real hardware over a serial (UART) connection.""" + + DEFAULT_BAUD = 115200 + + def __init__(self, port: str, baud: int = DEFAULT_BAUD) -> None: + self._port = port + self._baud = baud + self._ser: Optional[serial.Serial] = None + self._output_buf = "" + self._buf_lock = threading.Lock() + self._reader_thread: Optional[threading.Thread] = None + self._running = False + + # -- lifecycle ----------------------------------------------------------- + + def start(self) -> None: + self._ser = serial.Serial( + self._port, + self._baud, + timeout=0.1, + write_timeout=2, + ) + self._ser.reset_input_buffer() + self._running = True + self._reader_thread = threading.Thread( + target=self._reader_loop, daemon=True + ) + self._reader_thread.start() + + def stop(self) -> None: + self._running = False + if self._ser is not None: + try: + self._ser.close() + except OSError: + pass + + # -- I/O ----------------------------------------------------------------- + + def write(self, data: bytes) -> None: + assert self._ser is not None + self._ser.write(data) + self._ser.flush() + + def read_until(self, pattern: str, timeout: float = 5.0) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with self._buf_lock: + idx = self._output_buf.find(pattern) + if idx != -1: + end = idx + len(pattern) + result = self._output_buf[:end] + self._output_buf = self._output_buf[end:] + return result + time.sleep(0.02) + + with self._buf_lock: + partial = self._output_buf + self._output_buf = "" + raise TimeoutError( + f"Timed out ({timeout}s) waiting for pattern {pattern!r}. " + f"Output so far:\n{partial}" + ) + + def read_available(self, timeout: float = 0.5) -> str: + time.sleep(timeout) + with self._buf_lock: + result = self._output_buf + self._output_buf = "" + return result + + @property + def exit_code(self) -> Optional[int]: + return None + + # -- internals ----------------------------------------------------------- + + def _reader_loop(self) -> None: + assert self._ser is not None + try: + while self._running: + data = self._ser.read(256) + if data: + text = data.decode("utf-8", errors="replace") + with self._buf_lock: + self._output_buf += text + except (OSError, serial.SerialException): + pass diff --git a/firmware/test/test_native/test_protocol.cpp b/firmware/test/test_native/test_protocol.cpp index 4653194..472c65e 100644 --- a/firmware/test/test_native/test_protocol.cpp +++ b/firmware/test/test_native/test_protocol.cpp @@ -330,6 +330,21 @@ extern void test_model_name_excavator(void); extern void test_model_name_unknown(void); extern void test_channel_assignments_unique(void); +/* Shell tokenizer tests (test_shell.cpp) */ +extern void test_tokenize_single_word(void); +extern void test_tokenize_two_words(void); +extern void test_tokenize_three_args(void); +extern void test_tokenize_leading_spaces(void); +extern void test_tokenize_trailing_spaces(void); +extern void test_tokenize_multiple_spaces(void); +extern void test_tokenize_tabs(void); +extern void test_tokenize_empty_string(void); +extern void test_tokenize_only_spaces(void); +extern void test_tokenize_max_args_limit(void); +extern void test_tokenize_quoted_string(void); +extern void test_tokenize_quoted_with_extra_args(void); +extern void test_tokenize_empty_quotes(void); + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -445,5 +460,20 @@ int main(int argc, char **argv) { RUN_TEST(test_model_name_unknown); RUN_TEST(test_channel_assignments_unique); + /* Shell tokenizer tests */ + RUN_TEST(test_tokenize_single_word); + RUN_TEST(test_tokenize_two_words); + RUN_TEST(test_tokenize_three_args); + RUN_TEST(test_tokenize_leading_spaces); + RUN_TEST(test_tokenize_trailing_spaces); + RUN_TEST(test_tokenize_multiple_spaces); + RUN_TEST(test_tokenize_tabs); + RUN_TEST(test_tokenize_empty_string); + RUN_TEST(test_tokenize_only_spaces); + RUN_TEST(test_tokenize_max_args_limit); + RUN_TEST(test_tokenize_quoted_string); + RUN_TEST(test_tokenize_quoted_with_extra_args); + RUN_TEST(test_tokenize_empty_quotes); + return UNITY_END(); } diff --git a/firmware/test/test_native/test_shell.cpp b/firmware/test/test_native/test_shell.cpp new file mode 100644 index 0000000..885884d --- /dev/null +++ b/firmware/test/test_native/test_shell.cpp @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2026 Peter Buchegger + * + * This file is part of OpenDriveHub. + * + * OpenDriveHub is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenDriveHub is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenDriveHub. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Native unit tests for the shell tokeniser. + * + * Only the free function shellTokenize() is tested here because the full + * Shell class depends on Arduino Serial which is not available in the + * native-test environment. + */ + +#include +#include +#include + +using namespace odh; + +/* ── Basic tokenisation ──────────────────────────────────────────────────── */ + +void test_tokenize_single_word(void) { + char line[] = "status"; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(1, argc); + TEST_ASSERT_EQUAL_STRING("status", argv[0]); +} + +void test_tokenize_two_words(void) { + char line[] = "bind scan"; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(2, argc); + TEST_ASSERT_EQUAL_STRING("bind", argv[0]); + TEST_ASSERT_EQUAL_STRING("scan", argv[1]); +} + +void test_tokenize_three_args(void) { + char line[] = "channel set 1500"; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(3, argc); + TEST_ASSERT_EQUAL_STRING("channel", argv[0]); + TEST_ASSERT_EQUAL_STRING("set", argv[1]); + TEST_ASSERT_EQUAL_STRING("1500", argv[2]); +} + +/* ── Whitespace handling ─────────────────────────────────────────────────── */ + +void test_tokenize_leading_spaces(void) { + char line[] = " status"; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(1, argc); + TEST_ASSERT_EQUAL_STRING("status", argv[0]); +} + +void test_tokenize_trailing_spaces(void) { + char line[] = "status "; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(1, argc); + TEST_ASSERT_EQUAL_STRING("status", argv[0]); +} + +void test_tokenize_multiple_spaces(void) { + char line[] = "channel set 0 1800"; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(4, argc); + TEST_ASSERT_EQUAL_STRING("channel", argv[0]); + TEST_ASSERT_EQUAL_STRING("set", argv[1]); + TEST_ASSERT_EQUAL_STRING("0", argv[2]); + TEST_ASSERT_EQUAL_STRING("1800", argv[3]); +} + +void test_tokenize_tabs(void) { + char line[] = "config\tget"; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(2, argc); + TEST_ASSERT_EQUAL_STRING("config", argv[0]); + TEST_ASSERT_EQUAL_STRING("get", argv[1]); +} + +/* ── Edge cases ──────────────────────────────────────────────────────────── */ + +void test_tokenize_empty_string(void) { + char line[] = ""; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(0, argc); +} + +void test_tokenize_only_spaces(void) { + char line[] = " "; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(0, argc); +} + +void test_tokenize_max_args_limit(void) { + char line[] = "a b c d e f g h i j"; + const char *argv[4]; + int argc = shellTokenize(line, argv, 4); + TEST_ASSERT_EQUAL_INT(4, argc); + TEST_ASSERT_EQUAL_STRING("a", argv[0]); + TEST_ASSERT_EQUAL_STRING("d", argv[3]); +} + +/* ── Quoted strings ──────────────────────────────────────────────────────── */ + +void test_tokenize_quoted_string(void) { + char line[] = "vehicle \"My Vehicle\""; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(2, argc); + TEST_ASSERT_EQUAL_STRING("vehicle", argv[0]); + TEST_ASSERT_EQUAL_STRING("My Vehicle", argv[1]); +} + +void test_tokenize_quoted_with_extra_args(void) { + char line[] = "config set dev_name \"Dump Truck 1\""; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(4, argc); + TEST_ASSERT_EQUAL_STRING("config", argv[0]); + TEST_ASSERT_EQUAL_STRING("set", argv[1]); + TEST_ASSERT_EQUAL_STRING("dev_name", argv[2]); + TEST_ASSERT_EQUAL_STRING("Dump Truck 1", argv[3]); +} + +void test_tokenize_empty_quotes(void) { + char line[] = "vehicle \"\""; + const char *argv[8]; + int argc = shellTokenize(line, argv, 8); + TEST_ASSERT_EQUAL_INT(2, argc); + TEST_ASSERT_EQUAL_STRING("vehicle", argv[0]); + TEST_ASSERT_EQUAL_STRING("", argv[1]); +} + +/* ── Test runner ─────────────────────────────────────────────────────────── */ + +// Tests are registered in the central main() in test_protocol.cpp. +// No main() here – just test functions exported for RUN_TEST().