Modernizing the rear lighting of an older car is one of those projects that seems simple at first, but quickly turns into a much bigger electrical, software, and packaging challenge. This project is my custom taillight controller for a 1989 Fox Body Mustang, built around an ESP32-S3 and fully custom WS2812B LED assemblies. The goal is to preserve the function of the factory lighting signals while replacing the original rear lighting behavior with a programmable, modular system that is cleaner, more flexible, and easier to expand in the future.
Unlike a basic LED retrofit, this system is designed more like a rear body control module. Each taillight is treated as its own independently controlled lighting assembly, with stock 12V vehicle signals read through optocouplers for electrical isolation, and CAN bus support included so other modules can eventually monitor system state or issue commands. The project is built in PlatformIO using FastLED and the autowp-mcp2515 library.
Project Goals
The main idea behind this build is to create a smarter rear lighting system that still works with the factory car wiring. I wanted something that could read the stock brake, running light, turn signal, and reverse inputs, resolve those into proper light states, and then drive custom addressable LED sections with more modern behavior. That includes things like independent left and right state handling, segmented animation control, thermal protection, and CAN-based monitoring and overrides.
This also serves as the foundation for a broader electronics modernization plan for the car. The taillight controller is one of the first major modules in that larger system and is intended to be robust enough for real automotive installation rather than just bench testing.
Core Hardware
The system is centered around an ESP32-S3 DevKitC-1. Each side of the car uses a custom WS2812B LED assembly with 380 LEDs per side, and each side is electrically isolated from the stock vehicle lighting inputs by its own 4-channel optocoupler module. CAN functionality is handled through an MCP2515 SPI CAN interface. Power is split between 5V for the LED system and 3.3V for the ESP32 logic.
Main Parts Used
- ESP32-S3 DevKitC-1
- WS2812B addressable LEDs
- Two 4-channel optocoupler modules, one per side
- MCP2515 CAN bus interface
- 5V LED power supply
- 3.3V logic rail for the ESP32
- Custom wiring and segment interconnects
- PlatformIO development environment
- FastLED
- autowp-mcp2515 library
LED Layout Per Side
Each taillight is driven from a single data pin and is made of three chained serpentine LED segments. This gives each side a total of 380 LEDs. The segments are arranged as follows: a top strip with 105 LEDs, a bottom strip with 105 LEDs, and a main section with 170 LEDs. The top strip sits behind a clear diffuser and can display true color, while the bottom strip and main section sit behind red diffusers and are filtered to red-channel output only. Pixel offsets are handled in software so each segment can still be addressed correctly as part of one side.
Segment Layout Per Side
| Segment | Size | LED Count | Diffuser | Pixel Offset |
|---|---|---|---|---|
| SEG_TOP_STRIP | 21 × 5 | 105 | Clear, true color | 0 to 104 |
| SEG_BOT_STRIP | 21 × 5 | 105 | Red only | 105 to 209 |
| SEG_MAIN | 17 × 10 | 170 | Red only | 210 to 379 |
The software includes an applySegDiffuser() helper in config.h so animations can write the intended color directly without having to manually account for which diffuser is covering each segment. That keeps the animation layer cleaner and makes segment-specific behavior easier to manage.
GPIO Assignments
All pin assignments are centralized in src/config.h. The left taillight data line is assigned to GPIO 20, and the right taillight data line is assigned to GPIO 19. Stock signal inputs are split by side and routed through the two optocoupler modules. On the left side, brake is on GPIO 5, running or park is on GPIO 6, turn is on GPIO 4, and reverse is on GPIO 7. On the right side, brake is on GPIO 46, running or park is on GPIO 9, turn is on GPIO 3, and reverse is on GPIO 10. CAN SPI is wired using GPIO 12 for SCK, 13 for MOSI, 14 for MISO, 15 for chip select, and 16 for interrupt.
Current Pin Map
| Signal | GPIO |
|---|---|
| Left panel DIN | 20 |
| Right panel DIN | 19 |
| Left brake | 5 |
| Left running or park | 6 |
| Left turn | 4 |
| Left reverse | 7 |
| Right brake | 46 |
| Right running or park | 9 |
| Right turn | 3 |
| Right reverse | 10 |
| CAN SCK | 12 |
| CAN MOSI | 13 |
| CAN MISO | 14 |
| CAN CS | 15 |
| CAN INT | 16 |
The optocoupler outputs are active low, which means the GPIO reads LOW when the original 12V signal is active. Because of that, all of the input pins are configured with INPUT_PULLUP.
How the Signal Processing Works
The taillight controller reads the original rear vehicle lighting signals and debounces them through dedicated input code. Each side then resolves its own state independently based on those four input channels. This means the left and right sides are not forced to mirror each other unless the current condition actually calls for it, such as hazards. That is important for handling mixed states correctly, especially brake plus turn on one side.
The state priority order is:
HAZARD > BRAKE_TURN > BRAKE > TURN > REVERSE > RUNNING > OFF
Light States
| State | Description |
|---|---|
| OFF | No active signals, all LEDs off |
| RUNNING | Parking or running light active, dim red |
| BRAKE | Brake input active, bright red |
| TURN | This side’s turn signal active, amber sweep |
| REVERSE | Reverse gear active, white output |
| BRAKE_TURN | Brake and turn active on the same side |
| HAZARD | Both turn signals active at once, amber flash |
This state-based design is one of the most important parts of the project. It separates raw electrical inputs from final light behavior, which makes the animation system much easier to extend and debug.
CAN Bus Support
An MCP2515 CAN interface is included so that this rear lighting controller can eventually function as part of a larger vehicle electronics network. The CAN bus is configured to run at 500 kbps by default. Three CAN IDs are currently used: 0x100 for state broadcasts, 0x101 for command input, and 0x102 for fault reporting.
The state broadcast frame reports left and right resolved light state, raw input flags, die temperature, and derate amount. The command frame supports brightness override, animation override, and clearing of overrides. The fault frame is used for diagnostics and can report things like thermal faults, CAN bus-off condition, stuck inputs at boot, watchdog resets, panic resets, and brownout resets.
CAN Frames
| ID | Direction | Purpose |
|---|---|---|
| 0x100 | TX | State broadcast |
| 0x101 | RX | Commands |
| 0x102 | TX | Fault reporting |
Command Frame Functions
| Command | Payload | Effect |
|---|---|---|
| 0x01 | Byte 1 = brightness 0 to 255 | Set global LED brightness |
| 0x02 | Byte 1 = left state, Byte 2 = right state | Force animation override |
| 0x03 | None | Clear animation override |
Fault Codes
| Code | Name | Meaning |
|---|---|---|
| 0x01 | FAULT_THERMAL_WARN | Temperature at or above derate start |
| 0x02 | FAULT_THERMAL_CRITICAL | Temperature at or above thermal critical |
| 0x03 | FAULT_CAN_BUS_OFF | MCP2515 bus-off condition |
| 0x04 | FAULT_INPUT_STUCK_BOOT | Input active at boot |
| 0x05 | FAULT_WDT_RESET | Watchdog reset on previous boot |
| 0x06 | FAULT_PANIC_RESET | Panic or exception reset on previous boot |
| 0x07 | FAULT_BROWNOUT_RESET | Brownout or power-loss reset on previous boot |
That CAN support is a big part of what makes this more than just an LED controller. It turns the module into something other ECUs or future controllers can observe and interact with.
Thermal Protection
Automotive electronics do not live in a friendly environment, especially once they are mounted in a trunk or body cavity. To help protect the controller, the ESP32-S3 die temperature sensor is monitored continuously in the main loop. Brightness is linearly derated between 65°C and 80°C, and above 85°C the system clamps to a minimum safe brightness of 30 rather than going fully dark. That means the taillights remain visible even under thermal stress, which is much safer than simply shutting the LEDs off.
Software Structure
The project is organized into separate files for configuration, inputs, states, per-side taillight control, animations, CAN handling, thermal management, fault handling, and a small bitmap font for matrix text rendering. This keeps the codebase modular and makes it easier to change or expand one part without turning the whole project into one giant file.
Current Project Layout
CustomTaillights/
├── platformio.ini
└── src/
├── main.cpp
├── config.h
├── states.h
├── inputs.h / .cpp
├── taillight.h / .cpp
├── animations.h / .cpp
├── canbus.h / .cpp
├── thermal.h / .cpp
├── faults.h
└── font5x.h / .cpp
More specifically, inputs.cpp handles the debounced dual optocoupler input snapshots, taillight.cpp handles per-side pixel writing, animations.cpp holds the animation framework and built-in effects, and canbus.cpp manages MCP2515 transmit and receive behavior.
Animation System
The animation layer is designed so new effects can be added by subclassing the base Animation class and registering the new effect in the animation registry for the desired LightState. Because the TailLight API already understands segment layout and diffuser behavior, new animations can focus on drawing intended colors and movement rather than worrying about the physical color filtering or raw LED mapping every time.
That setup should make it much easier to keep refining the visual side of the project over time, whether that means improving turn sweeps, changing how brake and running lights layer together, or adding future state-specific effects.
Installation Notes
This system is designed around stock 12V rear lighting signals being preserved and sensed, not replaced upstream. Each side has its own optocoupler module for isolation, each taillight has its own dedicated LED data line, and CAN support is already built in for future expansion. The architecture is intentionally modular so the rear lighting system can stand alone now and still integrate with future electronics later.
This approach also makes troubleshooting cleaner. Electrical inputs, resolved software state, LED output behavior, fault handling, and CAN diagnostics are all separated into their own layers. That should make the final installed system easier to maintain than a one-off lighting retrofit that hides all of the logic in a single file or a nest of ad hoc wiring.
Why I Built It This Way
Older cars leave a lot of room for improvement, but I did not want to just bolt in generic LED strips and call it done. I wanted the rear of the car to behave more like a modern electronically controlled system while still respecting the original wiring and the way the car is used. Using the ESP32-S3 gives me enough performance to drive the LED assemblies properly, the optocouplers keep the stock 12V signals electrically isolated from the controller, and the MCP2515 gives me a real path toward turning this into one module of a larger networked electronics system.
At this stage, the taillight controller is not just a cosmetic project. It is a foundation for a broader modular electronics architecture for the car, starting at the rear and expanding forward over time.