Skip to content

alpauna/ESP-ECU

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ESP-ECU

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.

Features

  • 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

Architecture

Dual-Core Split

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.

Source Files

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

Hardware

Target board: Freenove ESP32-S3-WROOM

GPIO Pin Mapping

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

CJ125 Wideband O2 Controller

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.

I/O Expanders

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.

Shared CS with Hardware Addressing (HAEN)

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.

Device Allocation and Priority

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.

Shared Open-Drain Interrupt Line

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:

  1. 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)
  2. When any enabled pin changes state on any chip, that chip pulls the shared line LOW
  3. The ESP32 FALLING-edge ISR fires and sets a volatile flag
  4. 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)
  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.

SPI Performance

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.

Ghost Device Detection

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.

Runtime Health Monitoring

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.

Level Shifting (3.3V ESP32 to 5V MCP23S17)

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

I2C Bus (ADS1115 only)

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

Safe Mode

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

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.

Screenshots

Home Dashboard
Home Dashboard
Tune Config
Tune Tables Configuration
Pins Update
Pin Map OTA Update
Log Heap
Log Viewer System / Heap
WiFi
WiFi Setup

Web Pages

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

Getting Started

Prerequisites

  • PlatformIO (CLI or IDE extension)
  • USB cable connected to ESP32-S3 board

Secrets Setup

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 and Upload

# 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_wroom

Dependencies

Managed 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

Acknowledgments

This project builds on the work of many open-source projects and their authors. Thank you to everyone who made their code freely available.

CJ125 Wideband O2

  • 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

Core Libraries

Hardware Drivers

Infrastructure

License

This project is provided as-is for educational and personal use.

About

ESP32-S3 engine control unit — fuel injection, ignition timing, wideband O2 (CJ125/LSU 4.9), dashboard, and MQTT telemetry

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages