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