Skip to content

Firmware

FilipRaic edited this page Aug 31, 2026 · 1 revision

Firmware

The firmware is written entirely in C++ (C++17), with identifiers and comments in English. The development environment is PlatformIO with the Arduino core for ESP32, with the low-level functions of the ESP-IDF framework available (FreeRTOS tasks, message queues, interrupts).

1. Setting up the development environment

  1. Install Visual Studio Code and the PlatformIO IDE extension (or the standalone platformio CLI).
  2. Clone the repository:
    git clone https://github.com/<user>/obd2-simulator.git
    cd obd2-simulator/firmware
  3. There is a single hardware environment in platformio.ini:
    • env:custom-board - the ESP32-S3-WROOM-1 module, the same for the adapter on a breadboard and for the dedicated printed board
  4. Compile and upload:
    pio run -e custom-board -t upload
  5. Serial monitor (115200 baud) for diagnostic output:
    pio device monitor

How the upload works

The firmware is uploaded over the USB-C port, through the USB-Serial-JTAG interface built into the ROM of the ESP32-S3 microcontroller, so no external USB-UART converter is needed. The upload requires no button pressing: resistor R15 holds the USB multiplexer on the USB-C side during reset, the application leaves it there, and USB.begin() is never called in normal operation, so the USB-Serial-JTAG interface stays available to the computer and esptool enters download mode on its own.

Entering download mode by hand is needed in only two cases, when that interface really is absent:

  • when the board is in USB disk (MSC) mode, because the OTG controller is then taken,
  • when the firmware hangs before it takes over the multiplexer.

The procedure is then:

  1. hold the BOOT button (SW4),
  2. briefly press RESET (SW5),
  3. release BOOT and repeat pio run -e custom-board -t upload.

Serial output goes to UART0 (header J6: TX0 on GPIO43, RX0 on GPIO44, ground). Connect any USB-UART converter and run pio device monitor. The monitor is on UART0 because the firmware is compiled with ARDUINO_USB_CDC_ON_BOOT=0, so Serial is the serial port and not USB. That header carries neither DTR nor RTS, so download mode cannot be entered through it without the buttons.

Compilation uses GCC for the Xtensa architecture with -Os optimisation. The footprint of the current version, measured by the compiler on 21.08.2026.: 470,341 bytes of flash, that is 14.1 % of the 3.34 MB the program partition provides on an 8 MB module, and 53,868 bytes of statically allocated RAM, that is 16.4 % of 320 KB. There is plenty of room for extensions.

The bring-up build

Next to the regular environment there is env:custom-board-bringup, which inherits everything from custom-board and changes only what a freshly assembled board needs while it is being brought to life:

  • ARDUINO_USB_MODE=1 and ARDUINO_USB_CDC_ON_BOOT=1 move Serial onto the USB-Serial-JTAG, so the boot log comes out over USB-C and no USB-UART adapter is needed. The cost is that USB disk mode does not work in that build, because MSC needs the OTG controller,
  • the display clock is halved to 20 MHz,
  • the hal/crash_report diagnostics are enabled.

That last item answers a trap that cost a full day during the board's first power-up. The Arduino SDK sets CONFIG_ESP_CONSOLE_UART_DEFAULT=y and leaves the USB-Serial-JTAG as the secondary console only. The panic handler writes to the primary one, that is UART0 on header J6, so over USB-C only the odd byte of a crash message ever arrives. A board that crashed cleanly looks exactly like a dead one.

The way out needs no adapter. The partition table already carries a coredump partition and CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y is on, so the whole panic is written to flash at the moment it happens. crash_report_print() reads it back on the next boot and prints it slowly: reset reason, task, exception cause, faulting address and backtrace, with a line ready to paste into addr2line.

The helper tools live in firmware/tools/, and their README.md covers the J6 pinout, entering download mode and both upload paths.

2. Code organisation

The code is organised into modules with clearly defined interfaces:

Module Contents
can The MCP2515 controller driver and the ISO-TP transport protocol (ISO 15765-2)
obd The OBD-II protocol stack: mode dispatcher, PID table, DTC bank
sim Sensor simulation models (manual, idle and driving profile)
ui The graphical interface (ILI9341) and input handling independent of the input device (switch, buttons or encoder)
storage The FAT filesystem on the external S25FL128L flash (scenarios, logs), the USB-C MSC link to a computer, import and export with a USB stick
hal Abstraction of the SPI bus (mutex), of the pins and of the timing functions

The physical layout in the repository: the obd and sim modules form the portable protocol core at the repository root (/include, /src), free of any hardware dependency, so they are tested on a development computer, while hal, can, ui and storage live in firmware/src/, and the inter-task contracts (commands and a state snapshot) in firmware/src/app/. The core is pulled into the firmware by the firmware/import_core.py script, without copying any code.

The pin assignment lives in a configuration file (firmware/include/pins_custom_s3.h), so the same source code is shared by the development and the final build.

3. Architecture

The firmware is layered: hardware drivers → HAL (shared management of the SPI bus, because three peripherals share it - MCP2515, ILI9341 and S25FL128L - from two tasks) → the protocol and UI layer → the application layer (which ties scenarios to the simulation state).

Execution is split into two FreeRTOS tasks:

  • The high priority task handles CAN traffic and guarantees the response time (the ISO 15765-4 standard prescribes a P2 limit of 50 ms, and the measured response always arrives within 8 ms).
  • The low priority task draws the interface and handles user input.

The tasks communicate through message queues, so there is no sharing of mutable state without synchronisation. If you are adding a new feature, stick to this pattern.

4. The path of a diagnostic request

  1. The tool sends a CAN frame (identifier 0x7DF functional or 0x7E0 physical) to the J1962 connector.
  2. The transceiver converts the differential signal, and the MCP2515 decodes the frame and notifies the MCU with an interrupt (INT).
  3. The ESP32 fetches the frame over SPI, and the mode dispatcher finds the handler (unsupported modes → negative response 0x7F <mode> 0x11).
  4. For mode 0x01 the value from the sensor table is encoded by the inverse formula of ISO 15031-5 (for example 90 °C → 90 + 40 = 0x82).
  5. The response is sent with identifier 0x7E8, with unused bytes padded with the value 0x55 per ISO 15765-4.
  6. Responses longer than 7 bytes (for example the VIN in mode 0x09) are segmented into ISO-TP frames (FF → FC → CF).

5. Unit tests

The ISO 15031-5 codec (the formulas of all 21 PIDs in both directions), DTC encoding and ISO-TP segmentation are covered by 98 unit tests that run in the PlatformIO native environment, on a development computer and without any hardware:

pio test -e native

The tests cover the edge cases: range minima and maxima (-40 °C = 0x00), the single frame to multi-frame ISO-TP boundary (7 / 8 bytes) and the cyclic wrap of the CF frame sequence number (15 → 0). Every pull request that touches the can or obd modules has to pass all the tests and add new ones where needed (see Contributing).

6. Code conventions

  • Language: C++17, and identifiers, comments and commit messages are written in English.
  • No dynamic allocation in the hot path (CAN traffic handling).
  • All accesses to the SPI bus go through the HAL mutex.
  • Conversion formulas and protocol constants belong exclusively to the obd module, never "magic numbers" scattered through the code.
  • A new PID: add an entry to the sensor table, update the supported-PID bit mask (PID 0x00/0x20/0x40...) and add a unit test of the encoding in both directions.

OBD-II Simulator


ESP32-S3 · MCP2515 · SAE J1979 · ISO 15031-5

Clone this wiki locally