From f92c22b91931b871264bc1b647722dfce0b09c5d Mon Sep 17 00:00:00 2001 From: Peter Buchegger Date: Wed, 11 Mar 2026 20:12:44 +0100 Subject: [PATCH 1/5] feat(shell): add interactive shell and console testing framework --- .github/copilot-instructions.md | 13 +- .github/workflows/ci.yml | 34 +- README.md | 2 +- docs/architecture.md | 29 +- docs/getting-started.md | 12 +- docs/index.md | 2 +- firmware/lib/odh-config/Config.h | 10 + firmware/lib/odh-shell/Shell.cpp | 277 ++++++++++++ firmware/lib/odh-shell/Shell.h | 132 ++++++ firmware/platformio.ini | 18 +- firmware/sim/README.md | 24 +- firmware/sim/include/Arduino.h | 128 +++--- firmware/sim/src/sim_arduino.cpp | 156 ++++++- firmware/src/receiver/ReceiverApp.cpp | 26 ++ firmware/src/receiver/ReceiverApp.h | 26 ++ .../receiver/shell/ReceiverShellCommands.cpp | 192 +++++++++ .../receiver/shell/ReceiverShellCommands.h | 36 ++ firmware/src/transmitter/TransmitterApp.cpp | 93 ++++ firmware/src/transmitter/TransmitterApp.h | 50 ++- .../shell/TransmitterShellCommands.cpp | 363 ++++++++++++++++ .../shell/TransmitterShellCommands.h | 36 ++ firmware/test/test_console/README.md | 397 ++++++++++++++++++ .../conftest.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6641 bytes .../__pycache__/console.cpython-313.pyc | Bin 0 -> 4316 bytes ...mmands_common.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 22900 bytes ...t_commands_rx.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 12050 bytes ...t_commands_tx.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 14255 bytes .../test_help.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 10748 bytes .../test_startup.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 12886 bytes .../__pycache__/transport.cpython-313.pyc | Bin 0 -> 13692 bytes firmware/test/test_console/conftest.py | 181 ++++++++ firmware/test/test_console/console.py | 104 +++++ firmware/test/test_console/requirements.txt | 2 + .../test/test_console/test_commands_common.py | 159 +++++++ .../test/test_console/test_commands_rx.py | 80 ++++ .../test/test_console/test_commands_tx.py | 98 +++++ firmware/test/test_console/test_help.py | 81 ++++ firmware/test/test_console/test_startup.py | 112 +++++ firmware/test/test_console/transport.py | 285 +++++++++++++ firmware/test/test_native/test_protocol.cpp | 30 ++ firmware/test/test_native/test_shell.cpp | 162 +++++++ 41 files changed, 3253 insertions(+), 97 deletions(-) create mode 100644 firmware/lib/odh-shell/Shell.cpp create mode 100644 firmware/lib/odh-shell/Shell.h create mode 100644 firmware/src/receiver/shell/ReceiverShellCommands.cpp create mode 100644 firmware/src/receiver/shell/ReceiverShellCommands.h create mode 100644 firmware/src/transmitter/shell/TransmitterShellCommands.cpp create mode 100644 firmware/src/transmitter/shell/TransmitterShellCommands.h create mode 100644 firmware/test/test_console/README.md create mode 100644 firmware/test/test_console/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc create mode 100644 firmware/test/test_console/__pycache__/console.cpython-313.pyc create mode 100644 firmware/test/test_console/__pycache__/test_commands_common.cpython-313-pytest-9.0.2.pyc create mode 100644 firmware/test/test_console/__pycache__/test_commands_rx.cpython-313-pytest-9.0.2.pyc create mode 100644 firmware/test/test_console/__pycache__/test_commands_tx.cpython-313-pytest-9.0.2.pyc create mode 100644 firmware/test/test_console/__pycache__/test_help.cpython-313-pytest-9.0.2.pyc create mode 100644 firmware/test/test_console/__pycache__/test_startup.cpython-313-pytest-9.0.2.pyc create mode 100644 firmware/test/test_console/__pycache__/transport.cpython-313.pyc create mode 100644 firmware/test/test_console/conftest.py create mode 100644 firmware/test/test_console/console.py create mode 100644 firmware/test/test_console/requirements.txt create mode 100644 firmware/test/test_console/test_commands_common.py create mode 100644 firmware/test/test_console/test_commands_rx.py create mode 100644 firmware/test/test_console/test_commands_tx.py create mode 100644 firmware/test/test_console/test_help.py create mode 100644 firmware/test/test_console/test_startup.py create mode 100644 firmware/test/test_console/transport.py create mode 100644 firmware/test/test_native/test_shell.cpp 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..8b9f580 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,9 @@ 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 # ── 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..3671fa8 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 `firmwave/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..d84bd06 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] @@ -123,9 +124,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..bd3c3a5 --- /dev/null +++ b/firmware/src/transmitter/shell/TransmitterShellCommands.cpp @@ -0,0 +1,363 @@ +/* + * 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 ────────────────────────────────────────────────────────────── + +static int cmdModule(Shell &shell, int, const char *const *, void *ctx) { + 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 0000000000000000000000000000000000000000..0168a348e6f31bd88d8c74f54ce4488b0fabd5b6 GIT binary patch literal 6641 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 QCg58*KQhX9u*J6YKW*2ILjV8( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6dae39cd082afe5e3cc45da62760a7e886e27110 GIT binary patch literal 4316 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6ab8cdd7ef107e458596afac2a06a8cfa8988b34 GIT binary patch literal 12050 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 TjNK4G+)l0cM7|V2(6jzO%eHf8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a5f0074130dbd96f7a3569040cc7f16fb8db3fcc GIT binary patch literal 14255 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2e470b35f497c881c167716565bb036c5ba44df6 GIT binary patch literal 10748 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 z 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. +# +# 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(). From 90c500f466b7387d36d17053cf58e9c3e48c2ff5 Mon Sep 17 00:00:00 2001 From: Peter Buchegger Date: Wed, 11 Mar 2026 20:22:04 +0100 Subject: [PATCH 2/5] feat(ci): add console integration tests for simulation --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b9f580..22ada61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,42 @@ jobs: working-directory: firmware 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: name: clang-format check From e06d5b24dfa80f9b47ef67a640412871b444956e Mon Sep 17 00:00:00 2001 From: Peter Buchegger Date: Wed, 11 Mar 2026 20:30:34 +0100 Subject: [PATCH 3/5] fix(shell): change ctx parameter to const in cmdModule function --- firmware/src/transmitter/shell/TransmitterShellCommands.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firmware/src/transmitter/shell/TransmitterShellCommands.cpp b/firmware/src/transmitter/shell/TransmitterShellCommands.cpp index bd3c3a5..c05a479 100644 --- a/firmware/src/transmitter/shell/TransmitterShellCommands.cpp +++ b/firmware/src/transmitter/shell/TransmitterShellCommands.cpp @@ -245,8 +245,9 @@ static int cmdTrim(Shell &shell, int argc, const char *const *argv, void *ctx) { // ── module ────────────────────────────────────────────────────────────── +// cppcheck-suppress constParameterCallback static int cmdModule(Shell &shell, int, const char *const *, void *ctx) { - auto &app = *static_cast(ctx); + const auto &app = *static_cast(ctx); for (uint8_t s = 0; s < app.modules().slotCount(); ++s) { const char *typeStr = "empty"; From ece36cf51e8e7e7629dd7c3901dbbdadc59b02b5 Mon Sep 17 00:00:00 2001 From: Peter Buchegger Date: Wed, 11 Mar 2026 20:33:23 +0100 Subject: [PATCH 4/5] feat(tests): add test_filter for native unit tests --- firmware/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/firmware/platformio.ini b/firmware/platformio.ini index d84bd06..222c3e9 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -113,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] From bd14d1343e0b7ef7feb666ee686585ce503bf9e1 Mon Sep 17 00:00:00 2001 From: Peter Buchegger Date: Wed, 11 Mar 2026 20:35:46 +0100 Subject: [PATCH 5/5] fix(docs): correct path to platformio.ini in architecture.md --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3671fa8..7e3cca7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -242,7 +242,7 @@ See `firmware/sim/README.md` for keyboard shortcuts and known limitations. ## Build Environments -The unified `firmwave/platformio.ini` defines six environments: +The unified `firmware/platformio.ini` defines six environments: | Environment | Target | Board | Framework | |-------------|--------|-------|-----------|