A professional FreeRTOS-based vehicle control unit for ESP32-S3 with a JSON/WebSocket API. LIVI is the user interface; the VCU serves no web page of its own.
- FreeRTOS Multi-tasking: Three dedicated tasks for CAN communication, control logic, and web server
- Advanced Pedal Control: Three-zone system (Regen/Coast/Accel) with smooth torque transitions
- CAN Bus Integration: Manages BMS, DMC, BSC, and NLG subsystems
- Persistent Configuration: JSON-based flash storage with validation
- Real-time Monitoring: Error logging, performance metrics, and system health checks
- Always-On Web Server: REST + WebSocket on port 80, used by LIVI
- WebSocket Updates: 10Hz live data streaming
- BMS cells and faults: see the end of this file
- ESP32-S3 DevKit C1
- Dual-core ARM @ 240 MHz
- 16 MB Flash, 8 MB SRAM
- MCP2515 CAN Controller (SPI)
- ADS1115 16-bit ADC (I2C)
- WiFi 802.11 b/g/n
- Dual throttle pedal inputs
- HV contactor control
- Cooling pump PWM
- Charger/inverter enable outputs
- Connector unlock interrupt
# Install PlatformIO
pip install platformio
# Build project
pio run
# Upload firmware
pio run --target upload- Power on the VCU
- Look for WiFi network:
VCU_XXXXX(XXXXX = chip ID) - Connect with password:
vcu12345 - Point LIVI at
192.168.4.1(Settings → Vehicle → Status)
All configuration is accessible via REST API:
GET /api/status - System status
GET /api/status/live - Real-time vehicle data
GET /api/status/errors - Error log
GET /api/config/driving - Get driving config
POST /api/config/driving - Set driving config
GET /api/config/charging - Get charging config
POST /api/config/charging - Set charging config
GET /api/config/pedal - Get pedal zones
POST /api/config/pedal - Set pedal zones
GET /api/config/transition - Get torque transitions
POST /api/config/transition - Set torque transitions
GET /api/config/limits - Get speed/power limits
POST /api/config/limits - Set limits
POST /api/system/save - Save config to flash
POST /api/system/reset - Factory reset
GET /api/system/export - Download config JSON
POST /api/system/import - Upload config JSON
GET /api/system/info - System information
POST /api/system/debug - Enable/disable debug mode
GET /api/can/messages - Get CAN message data
ws://[IP_ADDRESS]/ws - Real-time data stream (10Hz)
Debug mode prevents the VCU from entering deep sleep, useful for:
- Development and testing
- Keeping WiFi active during extended stops
- Debugging CAN communication issues
Enable via:
- API:
POST /api/system/debug {"enabled": true}
- CAN Task (Priority 1, 12KB stack)
- CAN message RX/TX
- Motor speed updates
- Torque calculation
- Temperature monitoring
- 1ms cycle time
-
Control Task (Priority 1, 24KB stack)
- State machine updates
- Interrupt handling
- Performance monitoring
- 50ms cycle time
-
Web Server Task (Priority 1, 16KB stack)
- WiFi management
- HTTP request handling
- WebSocket broadcasts
- 10ms cycle time
- Input validation on all configuration changes
- Automatic fallback to safe defaults
- Error monitoring with severity levels
- System health checks
- CAN timeout detection
- Temperature monitoring
- Voltage/current limits
- Emergency stop capability
GET /api/system/export returns the configuration as JSON; save it as vcu_config.json.
POST /api/system/import with the saved JSON, then POST /api/system/save.
- SSID:
VCU_XXXXX(XXXXX = chip ID) - Password:
vcu12345 - IP Address:
192.168.4.1
- The VCU has no API for station credentials; see
wifi_manager - Automatically falls back to AP if connection fails
- Reconnects automatically if WiFi drops
- Check that AP mode is active (look for
VCU_network) - Default password is
vcu12345 - Try factory reset if needed
- Check the VCU's IP address in the serial monitor
- Ensure LIVI's host is on the VCU's network
- Check
http://<ip>/api/system/infoanswers
- Writes are saved by the VCU itself; check the API response of
POST /api/system/save - Look at the error LIVI shows under Adjustments
- Verify flash storage isn't full
- Check that
/wsaccepts a connection (LIVI reconnects with backoff) - Confirm the link state under Settings → Vehicle → Status
- Ensure CAN bus is connected and operational
VCU/
├── src/
│ ├── main.cpp - Main application
│ ├── state_manager.cpp - State machine
│ ├── vehicle_control.cpp - Pedal control
│ ├── can_manager.cpp - CAN communication
│ ├── configuration.cpp - Config management
│ ├── wifi_manager.cpp - WiFi management
│ └── web_server.cpp - Web server
├── include/
│ └── [corresponding headers]
├── platformio.ini - Build configuration
└── min_spiffs.csv - Partition table
# Clone repository
git clone [your-repo-url]
cd VCU
# Install dependencies (automatic via PlatformIO)
pio lib install
# Build
pio run
# Upload firmware
pio run --target upload
# Monitor serial output
pio device monitor[Your License Here]
Built with:
- PlatformIO
- ESP32 Arduino Framework
- ESPAsyncWebServer
- ArduinoJson
- FreeRTOS
For issues, questions, or contributions, please open an issue on GitHub.
GET /api/bms/cells→{ "cellsMv": [...], "balancing": [idx...], "temps": [...], "ageMs": n }. Cells come from the Orion Battery Cell Broadcast frame (seeinclude/bms_cells.h).cellsMvis empty until a valid frame has been decoded.GET|POST /api/config/bms→{ cellId, tempId, bigEndian, checkChecksum, cellCount, tempCount, ignoreAllowFlags }.ignoreAllowFlags(override of the BMS charge/discharge allow flags, for breakdown, trailer loading or debugging) is not saved: every boot starts with the flags enforced. The BMS current limits (DCL/CCL) still apply while it is on, andBMS_FLAGS_IGNOREDstays active as a reminder.- Live JSON
battery:minCell,maxCell(V),tempHigh,tempLow(°C) from BMS 0x001, andallow { fresh, chargeFlag, dischargeFlag, ignored, charge, discharge }(charge/discharge= what the VCU acts on).cellId/tempIdare the CAN IDs set in the Orion BMS Utility;0turns decoding off (default). Stored in its own NVS namespace, applied immediately. GET /api/status/faults→{ "faults": [{ code, name, severity, active, count, lastSeenS }] }. Every fault code raised byFaultHandler, INFO included (e.g.BMS_CHARGE_NOT_ALLOWED,CHARGER_OVERTEMP).activeis edge-triggered: set on raise, cleared byFaultHandleronce the value is back inside the hysteresis band (no time-based expiry).countis the number of inactive→active transitions.
FaultHandler (include/fault_handler.h, src/fault_handler.cpp) runs every CAN-task cycle in main.cpp.
It is the only place that raises/clears the fault codes below and the only place that maps faults to reactions.
ErrorMonitor just stores them. Raising an already active fault does not log or count again.
Values marked existing come from include/vehicle_parameters.h and are unchanged (INV_HIGH/MOT_HIGH = 45 °C, INV_LOW/MOT_LOW = 30 °C and FAN_ON = 65 °C are the cooling thresholds in StateManager, the pump on above 45 and off below 30, the fan on above 65, and are not used for derating); the others are new and live in FaultLimits (fault_handler.h) so the safety header was not touched.
| Code | Severity | Raised when | Cleared when |
|---|---|---|---|
BMS_TIMEOUT |
ERROR | no BMS frame (0x010) for > 1500 ms (existing Timing::BMS_TIMEOUT_MS) |
a BMS frame is fresh again |
DMC_TIMEOUT |
CRITICAL | in RUN, no DMC frame (0x258/0x259/0x458) for > 500 ms (new) | frame fresh again, or leaving RUN |
BSC_TIMEOUT |
WARNING | no BSC status frame for > 1500 ms (new) | frame fresh again |
NLG_TIMEOUT |
WARNING | in CHARGING, no NLG frame for > 1500 ms (new) | frame fresh again, or leaving CHARGING |
CHARGER_OVERTEMP |
INFO | in CHARGING, charger cooling plate > 85 °C (new; the charger itself is good to 95 °C) | plate <= 80 °C, or leaving CHARGING |
BATTERY_UNDERVOLTAGE |
ERROR | pack < 300 V (Battery::MIN_VOLTAGE), BMS data fresh |
pack >= 305 V (+5 V, new) |
BMS_DISCHARGE_NOT_ALLOWED |
CRITICAL | in RUN, BMS discharge flag (0x001 byte 6 bit 1) off or no 0x001 frame for > 1600 ms, override off | flag on again, or leaving RUN |
BMS_CHARGE_NOT_ALLOWED |
INFO | in CHARGING, BMS charge flag (0x001 byte 6 bit 0) off or frame missing, override off | flag on again, or leaving CHARGING |
BMS_FLAGS_IGNORED |
WARNING | allow-flag override on (ignoreAllowFlags) |
override off (or restart) |
BATTERY_OVERVOLTAGE |
CRITICAL | pack > 422 V (Battery::OVERVOLTAGE), BMS data fresh |
pack <= 417 V (-5 V, new) |
INVERTER_OVERTEMP |
ERROR | inverter > 100 °C (new TEMP_DERATE_C); CRITICAL above 105 °C (new TEMP_CUTOFF_C) |
inverter < 95 °C (-5 °C, new) |
MOTOR_OVERTEMP |
ERROR | motor > 100 °C (new TEMP_DERATE_C); CRITICAL above 105 °C (new TEMP_CUTOFF_C) |
motor < 95 °C (-5 °C, new) |
Nodes that never sent anything are timed from boot (BMS, BSC) or from entering the state that needs them (DMC, NLG), so an unpowered node in standby does not raise a fault. Temperature faults are evaluated only after the first DMC frame, voltage faults only while BMS data is fresh.
Not wired yet (no source signal in the firmware): BATTERY_OVERCURRENT, DMC_ERROR, SOC_OUT_OF_RANGE, precharge and charger codes.
Applied in canTask (main.cpp) in RUN state; the fault reaction is the minimum over all active faults.
| Active fault | Reaction |
|---|---|
INVERTER_OVERTEMP / MOTOR_OVERTEMP |
torque request x 0.5 (drive and regen); x 0 above 105 °C |
BATTERY_UNDERVOLTAGE |
torque request x 0.5 |
BATTERY_OVERVOLTAGE |
torque request x 0 |
BMS_TIMEOUT |
torque request x 0 |
any active CRITICAL fault (DMC_TIMEOUT, BATTERY_OVERVOLTAGE, overtemp above 105 °C) |
torque x 0 and DMC enable bit forced off |
BSC_TIMEOUT, NLG_TIMEOUT, CHARGER_OVERTEMP |
log/report only (the charger reduces its own power) |
The allow flags also act directly in CANManager, in every state: discharge not allowed sets the DMC
drive current limit to 0; charge not allowed sets the charger DC current and the DMC regen current
limit to 0. Not latched: current flows again as soon as the BMS sets the flag again.
Regen torque itself is also not requested while charge is not allowed or the BMS charge limit
(CCL) is 0 A: the DMC's 0 A generator current limit alone still let some current into a full
pack (Orion P0A06). The pedal's regen zone then behaves like coasting.
With max SOC at 100 % the SOC is not checked at all: charging continues until the plug is pulled,
governed only by the BMS charge limit (CCL) and charge flag.
Reactions are not latched: they release automatically once the fault clears. isSystemHealthy() now reports unhealthy while a CRITICAL fault is active (instead of whenever any critical line was ever logged). /api/status/faults output shape is unchanged. CAN IDs and task timing are unchanged.
GET /api/system/limits→ the limits LIVI uses for gauges and slider ranges:maxRpm(VehicleParams::Motor::MAX_RPM, the one applied inCANManager),maxTorque,maxSoc,chargeCurrent(min/max),motorTemp/inverterTemp(derate,cutoff),packVoltage(min/max),chargerTempMax.