ESP32-S3 Engine Control Unit for gas engines. Controls coil-over-plug ignition (up to 12 cylinders), sequential/batch fuel injection, dual-bank CJ125 wideband O2 closed-loop (LSU 4.9 sensors), and alternator field PWM. I/O expanded via MCP23017 I2C GPIO expander. Provides a REST API, WebSocket, and MQTT interface for remote monitoring and tuning.
- Coil-over-plug ignition -- Dwell + spark timing for up to 8 coils (GPIO10-17), expandable to 12 via MCP23017
- Sequential fuel injection -- Injector pulse width and timing for up to 8 injectors (3 native GPIO + 5 via MCP23017 I2C expander)
- Dual-bank wideband O2 -- Bosch CJ125 SPI controller driving two LSU 4.9 wideband lambda sensors with PID heater control and 23-point piecewise-linear Ip-to-lambda lookup
- Closed-loop AFR correction -- O2-based fuel trim with configurable AFR targets per RPM/MAP cell
- 3D tune tables -- 16x16 RPM x MAP interpolated lookup tables for spark advance, volumetric efficiency, and AFR targets. Editable via web UI
- Alternator field control -- PID-regulated PWM output for alternator voltage regulation
- Crank/cam decoding -- 36-1 trigger wheel with cam phase detection for sequential mode
- Automatic transmission control -- Ford 4R70W and 4R100 shift solenoid control, TCC PWM lockup, EPC line pressure, TFT temp monitoring, and MLPS gear range detection via MCP23S17 SPI expander (5V via TXB0108 level shifter). OSS/TSS speed sensors when ADS1115@0x49 frees GPIO 5/6
- I/O expansion -- 6x SPI MCP23S17 (96 pins) on shared HSPI bus with single CS, hardware addressing (HAEN), unified virtual pin routing, ghost device detection, and runtime health monitoring
- Safe mode -- Automatic boot loop detection with peripheral isolation. Configurable per-device enable/disable for I2C and SPI expanders via web UI
- Limp mode -- Sensor fault protection (MAP, TPS, CLT, IAT, VBAT), expander health monitoring, and oil pressure sensor support. Reduces rev limit, caps ignition advance, locks transmission gear, and lights CEL
- Oil pressure monitoring -- Configurable as digital switch or analog sender (0-5V via MCP3204 or native GPIO), with engine-running guard and startup delay
- Remote access -- REST API, WebSocket, and MQTT for monitoring and tuning
- Live dashboard -- Real-time gauges and status at
/dashboard - Web-based tuning -- 16x16 table editor with live cursor at
/tune - SD card configuration -- WiFi, MQTT, engine, and tune table settings stored as JSON
- Multi-output logging -- Serial, MQTT, SD card with tar.gz compressed log rotation, and WebSocket streaming
- OTA updates -- Firmware upload via web interface
- FTP server -- File upload to SD card for web pages and config
- PSRAM support -- All heap allocations routed through PSRAM when available
- WiFi AP fallback -- Automatic AP mode for emergency recovery
- Dual-core architecture -- Real-time engine control on Core 1, application/networking on Core 0
Core 1 -- Real-Time Engine Control (dedicated FreeRTOS task via xTaskCreatePinnedToCore):
- Crank/cam ISR (hardware timer capture)
- RPM calculation
- Spark timing (dwell + fire)
- Injector timing (pulse width)
- No WiFi, no logging, no heap allocation on this core
Core 0 -- Application (Arduino loop + TaskScheduler):
- Sensor ADC reads (O2, MAP, TPS, CLT, IAT, battery voltage)
- Fuel/ignition table lookups and tuning calculations
- O2 closed-loop AFR correction
- CJ125 wideband heater state machine
- Alternator PID control
- Web server, MQTT, logging, config, OTA
Cores communicate via shared EngineState struct with volatile fields.
| File | Purpose |
|---|---|
src/main.cpp |
Entry point, setup/loop, WiFi, tasks, core pinning |
src/ECU.cpp |
Top-level engine controller, EngineState management |
src/CrankSensor.cpp |
Crank trigger wheel decoding, RPM calculation |
src/CamSensor.cpp |
Cam phase detection for sequential mode |
src/IgnitionManager.cpp |
Coil dwell + spark timing |
src/InjectionManager.cpp |
Injector pulse width + timing |
src/FuelManager.cpp |
AFR targets, O2 correction, MAP load calc |
src/AlternatorControl.cpp |
PID field control for alternator |
src/TuneTable.cpp |
2D/3D interpolated lookup tables |
src/SensorManager.cpp |
ADC reads: O2, MAP, TPS, CLT, IAT, VBAT |
src/CJ125Controller.cpp |
Dual-bank CJ125 wideband O2 controller (SPI + heater PID) |
src/ADS1115Reader.cpp |
ADS1115 I2C ADC wrapper (CJ125 Nernst @ 0x48, MAP/TPS @ 0x49) |
src/MCP3204Reader.cpp |
MCP3204 SPI 12-bit ADC for MAP/TPS (alternative to ADS1115 @ 0x49) |
src/TransmissionManager.cpp |
Ford 4R70W/4R100 automatic transmission controller |
src/PinExpander.cpp |
6x SPI MCP23S17 GPIO expander, shared CS + HAEN, interrupt support, health check |
src/Config.cpp |
SD card and JSON configuration |
src/Logger.cpp |
Multi-output logging with tar.gz rotation |
src/WebHandler.cpp |
Web server and REST API |
src/MQTTHandler.cpp |
MQTT client with ECU topics |
src/PSRAMAllocator.cpp |
PSRAM allocator override |
src/OtaUtils.cpp |
OTA firmware update from SD card |
Target board: Freenove ESP32-S3-WROOM
Inputs:
| Pin | GPIO | Description |
|---|---|---|
| Crank | 1 | Digital interrupt, 36-1 trigger wheel |
| Cam | 2 | Digital interrupt, phase detection |
| CJ125_UA Bank 1 | 3 | ADC -- wideband O2 lambda/pump current |
| CJ125_UA Bank 2 | 4 | ADC -- wideband O2 lambda/pump current |
| MAP | ADS1115@0x49 CH0 | I2C ADC -- Manifold Absolute Pressure (GAIN_TWOTHIRDS, 860SPS) |
| TPS | ADS1115@0x49 CH1 | I2C ADC -- Throttle Position Sensor (GAIN_TWOTHIRDS, 860SPS) |
| OSS | 5 | Digital ISR -- Output shaft speed (freed by ADS1115@0x49) |
| TSS | 6 | Digital ISR -- Turbine shaft speed (freed by ADS1115@0x49) |
| CLT | 7 | ADC -- Coolant Temperature (NTC thermistor) |
| IAT | 8 | ADC -- Intake Air Temperature (NTC thermistor) |
| VBAT | 9 | ADC -- Battery voltage (47k/10k divider, 5.7:1) |
Outputs:
| Pin | GPIO / Bus | Description |
|---|---|---|
| Coils 1-8 | MCP23S17 #4 P0-P7 | COP ignition via SPI (HSPI 10MHz), pins 264-271 |
| Injectors 1-8 | MCP23S17 #5 P0-P7 | High-Z injectors via SPI (HSPI 10MHz), pins 280-287 |
| HEATER_OUT_1 | 19 | LEDC ch1 100Hz -- CJ125 heater bank 1 via BTS3134 |
| HEATER_OUT_2 | 20 | LEDC ch2 100Hz -- CJ125 heater bank 2 via BTS3134 |
| Alternator field | 41 | LEDC 25kHz PWM |
| TCC_PWM | 45 | LEDC ch4 200Hz -- Torque converter clutch (strapping pin, OK after boot) |
| EPC_PWM | 46 | LEDC ch6 5kHz -- Electronic pressure control (strapping pin, OK after boot) |
| Fuel pump relay | MCP23S17 #0 P0 | SPI expander (pin 200) |
| Tachometer output | MCP23S17 #0 P1 | SPI expander (pin 201) |
| Check engine light | MCP23S17 #0 P2 | SPI expander (pin 202) |
| CJ125 SS1 | MCP23S17 #0 P8 | CJ125 Bank 1 chip select (pin 208) |
| CJ125 SS2 | MCP23S17 #0 P9 | CJ125 Bank 2 chip select (pin 209) |
| SS_A | MCP23S17 #1 P0 | Shift Solenoid A (5V via TXB0108, pin 216) |
| SS_B | MCP23S17 #1 P1 | Shift Solenoid B (5V via TXB0108, pin 217) |
| SS_C | MCP23S17 #1 P2 | Shift Solenoid C -- 4R100 only (5V via TXB0108, pin 218) |
| SS_D | MCP23S17 #1 P3 | Coast Clutch -- 4R100 only (5V via TXB0108, pin 219) |
SPI Bus — FSPI (shared SD + CJ125):
| Pin | GPIO | Description |
|---|---|---|
| CLK | 47 | SPI clock |
| MISO | 48 | SPI data in |
| MOSI | 38 | SPI data out |
| SD_CS | 39 | SD card chip select (SPI_MODE0 @ 50MHz) |
| SPI_SS_1 | MCP23S17 #0 P8 | CJ125 Bank 1 chip select (SPI_MODE1 @ 125kHz) |
| SPI_SS_2 | MCP23S17 #0 P9 | CJ125 Bank 2 chip select (SPI_MODE1 @ 125kHz) |
SPI Bus — HSPI (6x MCP23S17, shared CS with hardware addressing):
| Pin | GPIO | Description |
|---|---|---|
| HSPI_SCK | 10 | SPI clock (10 MHz) |
| HSPI_MOSI | 11 | SPI data out |
| HSPI_MISO | 12 | SPI data in (10kΩ pull-up to VCC recommended) |
| HSPI_CS | 13 | Shared chip select for all 6 MCP23S17 devices |
| INT | 15 | Shared open-drain interrupt line (10kΩ pull-up to 3.3V) |
All 6 MCP23S17 devices share the same CS line. Each device is addressed individually via a 3-bit hardware address (A2:A1:A0 pins) embedded in the SPI command byte. See I/O Expanders for details.
I2C Bus (SDA=GPIO0, SCL=GPIO42):
| Device | Address | Description |
|---|---|---|
| ADS1115 #0 | 0x48 | 16-bit ADC -- CJ125_UR (CH0/1), TFT temp (CH2), MLPS (CH3) |
| ADS1115 #1 | 0x49 | 16-bit ADC -- MAP (CH0), TPS (CH1). Frees GPIO 5/6 for OSS/TSS |
GPIO Allocation Summary:
All 6 MCP23S17 share a single CS (GPIO 13) and a single interrupt line (GPIO 15). I2C bus is used only for ADS1115 ADCs. GPIO 14, 16-18, 21, 40 are free. GPIO 22-25 do not exist on ESP32-S3. GPIO 26-32 are reserved for SPI flash. GPIO 33-37 are reserved for OPI PSRAM. ADS1115 at 0x49 reads MAP/TPS via I2C, freeing GPIO 5/6 for OSS/TSS speed sensor inputs.
| Range | Assignment |
|---|---|
| 0 | I2C SDA |
| 1-2 | Crank + Cam ISR inputs (locked, not configurable) |
| 3-4 | CJ125 wideband O2 ADC |
| 5-6 | OSS/TSS speed ISR (freed from MAP/TPS by ADS1115@0x49) |
| 7-9 | Sensor ADC (CLT, IAT, VBAT) |
| 10-13 | HSPI bus (SCK, MOSI, MISO, shared CS) — 6x MCP23S17 |
| 14, 16-18, 21, 40 | FREE — available for custom I/O |
| 15 | Shared interrupt line (MCP23S17 INTA, open-drain with pull-up) |
| 19-20 | CJ125 heater PWM |
| 38-39 | SD card SPI (MOSI, CS) |
| 41 | Alternator field PWM |
| 42 | I2C SCL |
| 43-44 | UART TX/RX (Serial) |
| 45-46 | TCC/EPC PWM (strapping pins, OK after boot) |
| 47-48 | SD card SPI (CLK, MISO) |
Virtual Pin Ranges:
| Range | Bus | HW Addr | Device | Priority |
|---|---|---|---|---|
| 200-215 | SPI | 0 (A=000) | MCP23S17 #0 — General I/O (inputs) | Highest |
| 216-231 | SPI | 1 (A=001) | MCP23S17 #1 — Trans solenoids + spare | High |
| 232-247 | SPI | 2 (A=010) | MCP23S17 #2 — Expansion / custom I/O | Medium |
| 248-263 | SPI | 3 (A=011) | MCP23S17 #3 — Expansion / custom I/O | Medium |
| 264-279 | SPI | 4 (A=100) | MCP23S17 #4 — Coils (outputs only) | Low |
| 280-295 | SPI | 5 (A=101) | MCP23S17 #5 — Injectors (outputs only) | Lowest |
Dual-bank Bosch CJ125 SPI controller driving two LSU 4.9 wideband lambda sensors. Disabled by default (cj125Enabled = false in config). When disabled, SensorManager falls back to linear 0-5V to AFR 10-20 mapping from GPIO3/4.
Heater state machine (non-blocking, per bank):
IDLE -> WAIT_POWER (battery > 11V) -> CALIBRATING -> CONDENSATION (2V, 5s)
-> RAMP_UP (8.5V -> 13V at +0.4V/s) -> PID (heater regulation, readings valid)
-> ERROR (diagnostic failure)
- SPI protocol: 16-bit frames at 125kHz SPI_MODE1, chip select via MCP23S17 #0 P8/P9
- PID heater control: P=120, I=0.8, D=10, integral clamped +/-250
- Lambda lookup: 23-point piecewise-linear interpolation from Bosch LSU 4.9 Ip characteristic curve
- Decimation:
update()called every 10ms from ECU, CJ125 logic executes every 100ms
The CJ125 SPI register constants and PID tuning values are derived from the Lambda Shield project by Bylund.
The ECU extends GPIO capacity through 6x Microchip MCP23S17 SPI GPIO expanders, all sharing a single HSPI bus and a single chip select (CS) line. Each device is addressed individually via 3-bit hardware addressing (HAEN). All pin operations route through xDigitalWrite() / xDigitalRead() / xPinMode() which automatically dispatch to native GPIO or SPI expander based on pin number.
All 6 MCP23S17 devices share the same 4 SPI wires (SCK, MOSI, MISO, CS). When CS goes LOW, all chips see the SPI traffic, but only the chip whose hardware address matches the address in the SPI command byte responds. The address is set by tying each chip's A0/A1/A2 pins to VCC (3.3V) or GND on the PCB — no additional logic or components required.
SPI command byte format:
Bit: 7 6 5 4 3 2 1 0
0 1 0 0 A2 A1 A0 R/W
───────────────── ────────────── ───
Fixed prefix Hardware addr 0=write, 1=read
Hardware address wiring:
| Device | A2 | A1 | A0 | Addr | Wiring |
|---|---|---|---|---|---|
| #0 | GND | GND | GND | 0 | All three to ground |
| #1 | GND | GND | VCC | 1 | A0 to 3.3V |
| #2 | GND | VCC | GND | 2 | A1 to 3.3V |
| #3 | GND | VCC | VCC | 3 | A0 + A1 to 3.3V |
| #4 | VCC | GND | GND | 4 | A2 to 3.3V |
| #5 | VCC | GND | VCC | 5 | A2 + A0 to 3.3V |
The A0/A1/A2 pins are dedicated address pins, separate from the 16 I/O pins (GPA0-7, GPB0-7). No I/O capacity is lost — each chip provides 16 full I/O pins regardless of address configuration.
Devices are numbered by priority — input-focused chips get the lowest addresses and are scanned first during interrupt handling. Output-only chips are scanned last since they rarely (if ever) generate interrupts.
| Device | Pins | Purpose | Interrupt Priority |
|---|---|---|---|
| #0 (addr 0) | 200-215 | General I/O — fuel pump, tach, CEL, CJ125 CS, custom inputs | Highest — scan first |
| #1 (addr 1) | 216-231 | Transmission solenoids (SS-A/B/C/D) + spare I/O | High |
| #2 (addr 2) | 232-247 | Expansion — custom I/O, user-defined pins | Medium |
| #3 (addr 3) | 248-263 | Expansion — custom I/O, user-defined pins | Medium |
| #4 (addr 4) | 264-279 | Coils 1-8 (P0-P7), 8 spare (P8-P15) | Low — output only |
| #5 (addr 5) | 280-295 | Injectors 1-8 (P0-P7), 8 spare (P8-P15) | Lowest — output only |
Recommendation: Configure input-responsive functions (custom pin ISR, digital switch inputs, feedback signals) on devices #0-#3. Reserve devices #4-#5 for outputs only (coils, injectors). This ensures interrupt scanning prioritizes chips that are most likely to have pending state changes.
All 6 MCP23S17 INTA pins are wired together to a single ESP32 GPIO (default GPIO 15) via an open-drain bus with a 10kΩ pull-up resistor to 3.3V. This provides pin-change interrupt detection across all 96 expander pins using just one ESP32 GPIO.
Wiring diagram:
10kΩ
3.3V ──┤├──┬── ESP32 GPIO 15 (INPUT_PULLUP, FALLING edge)
│
MCP#0 INTA (open-drain) ───────┤
MCP#1 INTA (open-drain) ───────┤
MCP#2 INTA (open-drain) ───────┤
MCP#3 INTA (open-drain) ───────┤
MCP#4 INTA (open-drain) ───────┤
MCP#5 INTA (open-drain) ───────┘
How it works:
- Each chip is configured with IOCON bits: MIRROR=1 (INTA covers both ports A and B), ODR=1 (open-drain output), INTPOL=0 (active-low)
- When any enabled pin changes state on any chip, that chip pulls the shared line LOW
- The ESP32 FALLING-edge ISR fires and sets a volatile flag
- In the main
update()loop, the firmware scans chips in priority order (#0 first, #5 last):- Read INTF register (2 bytes) — bitmask of which pins triggered
- Read INTCAP register (2 bytes) — captured pin values at time of interrupt (also clears the interrupt)
- Skip chips with GPINTEN = 0x0000 (no interrupts enabled, typically output-only chips #4/#5)
- Matched pin changes are dispatched to custom pin ISR handlers
Performance: Scanning all 6 chips takes ~25μs at 10 MHz SPI (6 x 2 register reads x 3 bytes each). In practice, only chips with GPINTEN > 0 are scanned, so output-only chips add zero overhead.
Why open-drain? Push-pull interrupt outputs cannot be wired together — if one chip drives HIGH while another drives LOW, you get a short circuit. Open-drain outputs only pull LOW or float, so any chip can assert the shared line without conflict. The 10kΩ pull-up returns the line to HIGH when no interrupts are pending.
The custom thin SPI driver operates at 10 MHz with shadow registers, achieving ~3-5μs per write vs ~30μs with the Adafruit library at 1 MHz. Shadow registers (local copy of OLAT) eliminate read-modify-write cycles — each pin change is a single 3-byte SPI write. The MCP3204 ADC (1 MHz, SPI_MODE0) coexists on the same HSPI bus; beginTransaction() sets the clock and mode per-device.
SPI expanders are probed during begin() by writing IOCON (with HAEN=1, MIRROR=1, ODR=1) and reading it back. A real device returns the written value; a missing/ghost device returns 0xFF or 0x00. Devices that fail probe are marked not-ready and all pin operations become no-ops.
PinExpander::healthCheck() probes all initialized devices every ~1 second by reading the IOCON register via SPI. Failed devices are reported as a bitmask in expanderFaults and trigger limp mode. The health check accepts the configured IOCON value (0x68 = HAEN+MIRROR+ODR) as valid.
The MCP23S17 devices operate at 5V for compatibility with automotive solenoid drivers (BTS3134, ULN2803) and 5V sensors. The ESP32-S3 operates at 3.3V. Level shifting is required because the MCP23S17's CMOS input threshold at 5V is VIH = 0.7 x VDD = 3.5V, which exceeds the ESP32's 3.3V output HIGH.
TXB0108 for data lines (SCK, MOSI, MISO):
The TI TXB0108 bidirectional level shifter handles the four SPI data lines (SCK, MOSI, MISO, and the shared interrupt line). The TXB0108 uses internal current sources (~4mA) to auto-detect data direction, which works well for signals without external pull-ups or with pull-ups >= 50k ohm.
MOSFET for CS line (cannot use TXB0108):
The shared CS line has a 10k ohm pull-up to 5V (required for reliable idle-HIGH when CS is not driven). This pull-up is too strong for the TXB0108 -- its ~4mA current source cannot overcome the pull-up current, causing the output to latch HIGH and never drive LOW. TI's datasheet specifies external pull-ups must be > 50k ohm.
Instead, a discrete N-channel MOSFET (L2N7002SLLT1G) in source-follower configuration provides reliable level shifting for the CS line:
Q4 — CS Level Shifter
3.3V 5V
| |
[R] R51 10k [R] R52 10k
| |
G ---+ D ---+--- MCP23S17_CS (all 6)
| | |
Q4 L2N7002SLLT1G Q4
| | |
S ---+--- ESP32 GPIO 13 (same FET)
- Gate (G): Tied to 3.3V rail
- Source (S): ESP32 GPIO 13 with R51 (10k) pull-up to 3.3V
- Drain (D): MCP23S17 CS (all 6 tied together) with R52 (10k) pull-up to 5V
How it works:
| ESP32 CS | Source (V) | Vgs (V) | FET | Drain (5V side) | MCP23S17 CS |
|---|---|---|---|---|---|
| LOW (0V) | 0V | 3.3V (on) | ON | ~0V (pulled low through FET) | LOW (selected) |
| HIGH (3.3V) | 3.3V | 0V (off) | OFF | 5V (pulled up by R52) | HIGH (deselected) |
The MOSFET adds < 5ns propagation delay and < 1 ohm on-resistance (Rds_on), which is negligible at 10 MHz SPI. The 10k pull-up on the drain provides the HIGH level and also serves as the idle pull-up for the shared CS bus.
Why not a simple resistor divider? CS is bidirectional in the sense that the idle state is maintained by the pull-up, not actively driven. A resistor divider would fight the pull-up and waste current. The MOSFET cleanly switches between driven-LOW and pulled-HIGH.
MOSFET for RESET line (active reset from ESP32 EN):
A second L2N7002SLLT1G drives all 6 MCP23S17 RESET pins from the ESP32 EN signal. Same source-follower topology as CS -- both EN and RESET are active-LOW, so the non-inverting level shifter gives correct polarity. A 100nF gate capacitor (C76) delays the gate charge at power-on, preventing a RESET glitch while the 3.3V rail ramps.
Q5 — RESET Level Shifter
3.3V 5V
| |
[R] R55 10k [R] R56 10k
| |
G ---+---||--- GND D ---+--- MCP23S17_RESET (all 6)
| C76 100nF |
Q5 L2N7002SLLT1G Q5
| | |
S ---+--- ESP32 EN (same FET)
- Gate (G): 3.3V rail with C76 (100nF) to GND
- Source (S): ESP32 EN pin with R55 (10k) pull-up to 3.3V
- Drain (D): MCP23S17 RESET (all 6 tied together) with R56 (10k) pull-up to 5V
| ESP32 EN | Source (V) | Vgs (V) | FET | Drain (5V side) | MCP23S17 RESET |
|---|---|---|---|---|---|
| LOW (reset) | 0V | 3.3V (on) | ON | ~0V | LOW (devices reset) |
| HIGH (running) | 3.3V | 0V (off) | OFF | 5V (pulled up by R56) | HIGH (devices run) |
Gate capacitor C76: At cold power-on, the 100nF cap delays the gate voltage rise, preventing a brief RESET glitch while the 3.3V rail ramps. On warm reset (EN cycles but 3.3V stays up), the gate is already charged so the cap has no effect -- RESET releases as soon as EN goes HIGH. If additional hold time is needed on warm reset, a small cap (100nF-1uF) can be added on the drain side (RESET to GND) to slow the R56 charge-up.
Why active RESET matters: Without active RESET, the MCP23S17s retain their last output state across an ESP32 software reset / WDT / panic. Coils and injectors could stay energized for the ~100-500ms it takes the ESP32 to reboot and call PinExpander::begin(). With EN driving RESET, all outputs go to high-Z (IODIR resets to 0xFFFF) the instant the ESP32 enters reset.
Bill of materials for level shifting:
| Part | Qty | Function |
|---|---|---|
| TXB0108PWR (TSSOP-20) | 1 | 8-bit bidirectional level shifter for SCK, MOSI, MISO, INT |
| L2N7002SLLT1G (SOT-23) | 2 | N-channel MOSFETs: Q4 for CS, Q5 for RESET |
| R51, R55 — 10k ohm 0402 | 2 | Source pull-ups to 3.3V (CS, RESET) |
| R52, R56 — 10k ohm 0402 | 2 | Drain pull-ups to 5V (CS, RESET) |
| C76 — 100nF 0402 | 1 | Gate RC on Q5 (RESET startup delay) |
| 100nF 0402 | 2 | Bypass caps: VCCA (3.3V) and VCCB (5V) on TXB0108 |
With all GPIO expanders on SPI, the I2C bus (SDA=GPIO0, SCL=GPIO42) is used exclusively for the ADS1115 ADCs. This eliminates bus contention between expander I/O and ADC reads, improving MAP/TPS sampling reliability at 860 SPS.
| Device | Address | Description |
|---|---|---|
| ADS1115 #0 | 0x48 | CJ125_UR (CH0/1), TFT temp (CH2), MLPS (CH3) |
| ADS1115 #1 | 0x49 | MAP (CH0), TPS (CH1) — frees GPIO 5/6 for OSS/TSS |
The ECU includes boot loop detection and per-peripheral enable/disable to recover from hardware faults without reflashing.
Boot loop detection: An RTC_NOINIT_ATTR counter persists across soft resets, watchdog timeouts, and panic reboots (reset on power-on). If the counter exceeds 3, the ECU enters safe mode automatically. A 30-second stability timer resets the counter after a successful boot.
Safe mode behavior: Skips ECU::configure(), ECU::begin(), tune table loading, MQTT, and state publishing. Retains WiFi, web server, FTP, logger, config save, and CPU load monitoring -- allowing remote configuration changes to fix the problem.
Peripheral control: Eight individual enable flags (persisted to peripherals.* in config JSON) allow disabling specific I2C or SPI devices:
| Flag | Controls | Default |
|---|---|---|
i2cEnabled |
I2C bus (ADS1115 ADCs) | true |
spiExpandersEnabled |
HSPI bus (all 6 MCP23S17) | true |
expander0Enabled - expander5Enabled |
Individual MCP23S17 #0-#5 | true |
Web interface: The config page has a "Peripherals & Safe Mode" fieldset with checkboxes (master bus disable greys out children). A red banner appears on the dashboard in safe mode with boot count, reset reason, and an "Exit Safe Mode" button. POST /safemode/clear clears the flag and reboots. proj.forceSafeMode is a one-shot flag to enter safe mode on next reboot.
Limp mode protects the engine when critical sensors fail, I/O expanders go offline, or oil pressure drops. When active, it reduces the rev limit (default 3000 RPM), caps ignition advance (default 10 deg), locks the current transmission gear, unlocks TCC, and turns on the check engine light.
Fault sources (bitmask in limpFaults):
| Bit | Constant | Trigger |
|---|---|---|
| 0x01 | FAULT_MAP |
MAP reading outside configurable min/max range |
| 0x02 | FAULT_TPS |
TPS reading outside configurable min/max range |
| 0x04 | FAULT_CLT |
Coolant temp exceeds configurable max |
| 0x08 | FAULT_IAT |
Intake air temp exceeds configurable max |
| 0x10 | FAULT_VBAT |
Battery voltage below configurable min |
| 0x20 | FAULT_EXPANDER |
Any initialized I2C/SPI expander fails health check |
| 0x40 | FAULT_OIL |
Low oil pressure while engine running |
Recovery: All faults must clear for a configurable recovery delay (default 5 seconds) before limp mode exits. This prevents rapid cycling from intermittent faults.
Oil pressure sensor: Configurable as disabled (default), digital switch, or analog sender:
- Digital mode: GPIO pin with internal pull-up. Configurable polarity (
oilPressureActiveLow, default true). LOW = low pressure fault. - Analog mode: Reads voltage from MCP3204 SPI ADC (priority) or native GPIO (fallback). Linear conversion: 0.5V = 0 PSI, 4.5V = max PSI (configurable, default 100). Faults when PSI drops below threshold (default 10 PSI).
- Engine-running guard: Oil pressure is only checked when RPM >= 400, with a configurable startup delay (default 3 seconds) to allow pressure to build after engine start.
MQTT: Limp state and fault bitmask published in ecu/state. Fault enter/exit events published to ecu/fault.
Dashboard: Red limp mode banner with per-fault pills (MAP, TPS, CLT, IAT, VBAT, EXP, OIL). Oil pressure card shows live PSI value and OK/LOW status (auto-hidden when oil pressure is disabled).
Config page: "Limp Mode" fieldset with rev limit, advance cap, recovery delay, and 7 sensor fault thresholds. "Oil Pressure" fieldset with mode dropdown and mode-dependent field visibility. All sensor thresholds are live (no reboot). Oil pressure mode and pin changes require reboot.
![]() |
![]() |
| Home | Dashboard |
![]() |
![]() |
| Tune Tables | Configuration |
![]() |
![]() |
| Pin Map | OTA Update |
![]() |
![]() |
| Log Viewer | System / Heap |
![]() |
|
| WiFi Setup |
All pages served from SD card /www/ directory.
| Page | Purpose |
|---|---|
/ |
Landing page with nav cards |
/dashboard |
Live ECU gauges and status |
/tune |
16x16 table editor with live cursor |
/pins |
GPIO pin map with live state |
/config |
WiFi/MQTT/engine/alternator/sensor settings |
/update |
OTA firmware upload |
/log/view |
Log viewer |
/heap/view |
Memory/CPU monitor |
/wifi/view |
WiFi scan and test |
/admin/setup |
Initial password setup |
- PlatformIO (CLI or IDE extension)
- USB cable connected to ESP32-S3 board
Create secrets.ini in the project root (gitignored):
[secrets]
build_flags =
-D AP_PASSWORD=\"your-ap-password\"
-D XOR_KEY=\"your-random-base64-key\"# Build
pio run -e freenove_esp32_s3_wroom
# Upload firmware
pio run -t upload -e freenove_esp32_s3_wroom
# Serial monitor
pio run -t monitor -e freenove_esp32_s3_wroomManaged automatically by PlatformIO (lib_deps in platformio.ini).
| Library | Author | Purpose |
|---|---|---|
| TaskScheduler | Anatoli Arkhipenko | Cooperative multitasking on Core 0 |
| ESPAsyncWebServer | ESP32Async | Async HTTP and WebSocket server |
| AsyncTCP | ESP32Async | TCP transport for async web server |
| AsyncMqttClient | Marvin Roger | MQTT client with auto-reconnect |
| ArduinoJson | Benoit Blanchon | JSON parsing and serialization |
| CircularBuffer | Roberto Lo Giacco | Lock-free circular buffer for ISR queues |
| ESP32-targz | tobozo | tar.gz compression for log rotation |
| SimpleFTPServer | Renzo Mischianti | FTP server for SD card file uploads |
| Adafruit MCP23017 | Adafruit | I2C GPIO expander driver |
| Adafruit ADS1X15 | Adafruit | 16-bit I2C ADC for CJ125 Nernst cell temp |
| StringStream | 0xtj | String-based stream wrapper |
This project builds on the work of many open-source projects and their authors. Thank you to everyone who made their code freely available.
- Lambda Shield by Bylund -- The CJ125 SPI register constants, PID tuning values, and heater control state machine are derived from this project. Lambda Shield provided the foundational reference for interfacing with the Bosch CJ125 wideband controller chip
- Bosch CJ125 and LSU 4.9 datasheets -- The Ip-to-lambda characteristic curve lookup table is derived from the official Bosch LSU 4.9 sensor datasheet
- TaskScheduler by Anatoli Arkhipenko -- Cooperative multitasking framework that manages all periodic tasks on Core 0
- ArduinoJson by Benoit Blanchon -- JSON engine used for all configuration, API responses, and MQTT payloads
- ESPAsyncWebServer and AsyncTCP by ESP32Async -- Non-blocking web server and WebSocket support
- AsyncMqttClient by Marvin Roger -- MQTT client for telemetry publishing
- Adafruit MCP23017 and Adafruit ADS1X15 by Adafruit -- I2C GPIO expander and 16-bit ADC drivers. Adafruit's open-source hardware libraries and documentation are invaluable for embedded projects
- CircularBuffer by Roberto Lo Giacco -- ISR-safe circular buffer used for interrupt-driven input queues
- ESP32-targz by tobozo -- tar.gz compression for log file rotation on SD card
- SimpleFTPServer by Renzo Mischianti -- FTP server for uploading web pages and config to the SD card
- Arduino-ESP32 by Espressif -- The Arduino framework for ESP32 that makes all of this possible
- PlatformIO -- Build system and dependency management
This project is provided as-is for educational and personal use.








