Source: https://oliviercouthaud.com/gaugecluster · Status: Prototype · 2026 · Tags: ESP32, CAN bus, Arduino, BMW E39 # BMW E39 Gauge Cluster Three ESP32 programs I wrote to talk CAN bus to an old BMW 5 Series instrument cluster, aimed at driving its rev counter with no engine attached. The cluster ran its own built-in test; moving the needle from my own code is the remaining step. [Image: A BMW E39 instrument cluster lit up on a desk, with the speedometer, rev counter and fuel gauge visible, some warning lights on and a breadboard with jumper wires above it.] Figure: The cluster on my desk, lit up, with the breadboard and wires I used above it. ## What I was after I started this about a year and a half ago because I liked the way an old BMW dash looks, with its lights, small screen and dials, and I wanted one on my desk as a living piece of art that could react to games or just do its own thing. I set it aside, came back a year later, fixed some issues and worked out the pins for the CAN bus. The three programs speak CAN on those pins. I saw the cluster light up and move its needles when I ran its own built-in test program; driving them from my own code is the remaining step. Then I had to move and threw the cluster away, so the project is paused. The code below is what I had at that point. Figure: The cluster running its own built-in self-test: the needles sweep and the warning lights come on. My hand comes into the frame a couple of times. This is the cluster's own test, not my code controlling it. The technical goal was to move the needles of an E39 instrument cluster from an ESP32. Most of what the cluster shows arrives over CAN from other control units. The rev counter is fed by the engine computer, the DME, so the job is to impersonate the DME. The rev counter comes first because it is the only value in my code that actually changes. Everything below describes what the programs are written to do; none of it has been seen to move a needle. Before any of that could be tested, the cluster had to be powered and wired correctly, and that took a good part of the effort. It had three different connector plugs on the back, white, black and blue, each with its own cable. I identified every wire by its colour and pin, worked out which ones were ground and which were power, and used the backlight and the warning symbols lighting up as the signal that a power or ground guess was right. I searched online and went through a lot of forums along the way. I kept a written table of every wire colour and its meaning. The remaining step is still moving the needle from my own code. ## How it works All three programs use the ESP32's built-in CAN controller through the ESP-IDF twai driver, at 500 kbit/s, with TX on GPIO21 and RX on GPIO22. Those pins go to an MCP2551 transceiver, which is what sits on the bus. **What is CAN bus?** A two-wire network that cars use to let their computers talk. Nothing is addressed to a particular device. Each message carries an ID that says what it is about (engine speed, say) and up to 8 bytes of data, and anyone who cares about that ID reads it. A cluster is simply a listener for the IDs it displays. **What does the MCP2551 do?** The ESP32 has a CAN controller, but its pins are ordinary logic signals. A transceiver converts them to and from the differential voltage the two bus wires actually carry. Without one, the controller cannot talk to a real bus. Figure: Diagram. The hardware chain and the three programs that run on it, one at a time. The sniffer is the simplest. It runs in listen-only mode, prints every frame with its ID and bytes, and reports the driver's error counters every five seconds. Its start-up message says that if nothing shows up after ten seconds, the wiring is wrong. The diagnostic, covered further down, is a longer version of the same idea. The sweep is the real attempt. It takes its frame format from an existing project for the E46 cluster, which is not mine, and assumes that E39, E46 and E38 clusters share the protocol. It sends four frames and steps the engine speed from 0 to 6000 rpm and back in steps of 100, one step every 100 ms. That is a 12-second round trip. ## What the sweep sends **Frames sent by E39_RPM_Sweep_Test.** | Frame | Every | Content | |---|---|---| | **0x316** DME1 | 10 ms | Engine speed in bytes 2 and 3, plus fixed status and torque bytes | | **0x329** DME2 | 100 ms | Fixed coolant temperature, throttle and engine-running bytes | | **0x545** DME4 | 100 ms | No warning lights, fixed oil temperature byte | | **0x153** ASC1 | 100 ms | All zeros, to clear the ABS and traction lights | Figure: Diagram. One 100 ms window of the sweep's schedule, using the periods in the loop. The engine speed frame goes out ten times for every one of the others. That is 130 frames a second. A standard frame with 8 data bytes is about 111 bits on the wire, so the sweep fills roughly 3 percent of a 500 kbit/s bus. That is counted from the frame length, before bit stuffing, not measured on the bus. Each send waits at most 5 ms to be queued, and if the controller has gone bus-off the sketch restarts it. The engine speed is encoded as rpm × 6.4, as a 16-bit value, low byte first. The diagnostic sketch's test frame carries 0x4B00, which is 19200 / 6.4, so 3000 rpm. **What do 16-bit and low byte first mean?** A byte holds 0 to 255, which is too small for an engine speed, so the number is split across two bytes. Low byte first means the least significant half goes out in byte 2 and the most significant half in byte 3. For 19200, which is 0x4B00, byte 2 is 0x00 and byte 3 is 0x4B. The largest 16-bit value, 65535, would be about 10,000 rpm, so there is plenty of room above 6000. uint16_t rpmVal = (uint16_t)(rpm * 6.4); uint8_t rpmLSB = rpmVal & 0xFF; uint8_t rpmMSB = (rpmVal >> 8) & 0xFF; Figure: Diagram. The engine speed frame. Only bytes 2 and 3 change during the sweep; the rest are constants. Slide the speed below to see the two bytes and the whole frame it produces. Where motion is allowed, a sweep button plays the loop from the code: 100 rpm every 100 ms, up to 6000 and back. The dial is a toy, not the cluster. The numbers underneath are computed with the same formula as the code. ### Encode an engine speed 3000 rpm 3000 × 6.4 = 19200 = 0x4B00, low byte 00, high byte 4B 05 62 00 4B 65 12 00 3E The last line is the full 0x316 frame, byte 0 to byte 7. The ticks are every 1000 rpm. The dial is illustrative and does not copy the real cluster's scale. ## Checking the link The diagnostic runs four tests in a single boot. Tests 2 to 4 are each paired with a printed reading of what the result would mean, so the output tells you where to look. Each box in the diagram below matches one step in the list. **What do listen-only and no-ACK mean?** On CAN, a frame is only counted as delivered if at least one other device on the bus acknowledges it, so a lone transmitter keeps retrying the same frame and never sees it delivered. Listen-only mode never transmits, which makes it safe for looking. No-ACK mode transmits without needing anyone to acknowledge, which lets a sender run on an empty bus. Both are modes of the ESP32's driver, and the sweep uses the second. ### Four tests in one boot - **Listen only.** The ESP32 listens for five seconds and prints every frame it hears, with ID and bytes, then the error counters. It cannot disturb the bus. If anything is sending, this is where it shows up. - **Normal mode.** It queues one 0x316 frame carrying 3000 rpm with a 100 ms wait for queue space. The sketch prints that the cluster acknowledged if the call returns, and a timeout otherwise. That return only means the frame was queued, so "acknowledged" on the serial monitor proves nothing by itself. If it times out, the sketch prints that the cluster is not on this bus. - **No-ACK mode.** The same frame, sent without needing an acknowledgement, followed by three seconds of listening for any response. This is the mode the sweep runs in. If the send goes through and nothing answers, the sketch prints that the cluster is ignoring the messages. - **Pin check.** The CAN driver is removed and both pins are read as plain inputs. RX should read 1 when the transceiver is connected and the bus is idle. A 0 means the transceiver is not powered or connected, or the bus has a fault, and the sketch prints exactly that. The interpretations in each step are the messages the sketch prints over serial. ## Limits It only drives the rev counter. The coolant byte in DME2 and the oil temperature byte in DME4 are both fixed at 0xAB, so treat both temperatures as placeholders rather than data. The other gauges, an input from a real engine or a simulator, and an enclosure are still to come. The sweep sends in no-ACK mode, so it runs whether or not a cluster is attached. The serial monitor printing RPM: 3000 proves nothing about the cluster, which is what the diagnostic is for. The frame layout is borrowed. The assumption that E39, E46 and E38 clusters share the protocol comes with it, and it is not yet confirmed on the E39: the only movement I saw came from the cluster's built-in test, not from my code. ## What's next The project is on hold: I moved, and the cluster is gone. I still think I would have worked it out and ended up using it for projects and games. What remains is getting my code to control the cluster, then the other needles, checked scaling for the temperature bytes, a live data source such as a game, and a box to put it in. If I picked it up again I would start with the first of those. - **Stack**: ESP32, Arduino framework, ESP-IDF twai CAN driver, MCP2551 transceiver - **Credit**: Frame format borrowed from an existing project for the BMW E46 cluster - **Timeline**: Started about a year and a half ago, put aside, picked up again a year later - **Status**: Prototype - **Now**: On hold; the cluster is gone - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects)