Decoding the CAN output of a Bosch iBooster Gen1 — Tesla PN 1037123-00-B,
listing fitment 2016–2020 Model S — so a 1987 Porsche 944S restomod can read
brake pedal position. Nothing in this repo ever transmits to the booster.
Bench bring-up 2026-08-06, fault signalling 2026-08-10, in service on the car's Pi since 2026-08-11. Everything below is measured on this unit, not inherited from other iBooster variants.
It assists standalone. 12 V, ground and ignition, with nothing ever transmitted to it — no vehicle CAN, no wake frame, no keep-alive. A purely passive monitor is viable, so commanding a brake actuator never has to enter the picture.
Both CAN buses found and decoded:
| Bus | Pins | Rate | Carries |
|---|---|---|---|
| Vehicle | 25 = CAN-H, 16 = CAN-L | 500 kbps, 36 fps | 0x39D — 16-bit stroke |
| YAW | 18 = CAN-H, 10 = CAN-L | 500 kbps, 143 fps | 0x38E @ 99.7 Hz, 0x38F @ 49.8 Hz |
Power: pin 1 = 12 V, pin 9 = GND, pin 20 = ignition. Neither bus is internally terminated.
b0 = checksum, (b1 + b2 + b3 + 0xA0) & 0xFF ← holds on 100% of 2250 frames
b1 = alive counter, +1 mod 16
b2:b3 = stroke, uint16 LITTLE-endian
mm = (counts − 3.3) / 320.68 rest ≈ 264 · end stop 13606 = 43.4 mm
b1 = alive counter, 0x20..0x2F (low nibble counts)
position = b3 | ((b4 & 0x0F) << 8) 12-bit, rest 320, full ~3052
status = b4 >> 4 0 = initialising, 1 = healthy, 2 = fault
mm = 0.015207 * position - 4.072 rest 320 -> 0.79 · full 3052 -> 42.34
+1.94 here once cost a downstream consumer
a display reading 6.8 mm at rest against 0x39D's 0.8 mm.
b3 alone wraps — it is the low byte, not the whole field. Correlates with
0x39D at r = 0.9999 over 15,296 matched samples.
Confirmed by disconnecting the travel sensor:
| Signal | Healthy | Fault |
|---|---|---|
0x38E b4 >> 4 |
1 |
2 |
0x39D stroke |
live | pinned to 16354, checksum still valid |
0x38F b2 |
0xE2 |
0xCC |
There is no status message — status is a field inside the position messages.
That is why looking for a dedicated fault frame found nothing. Use 0x38E b4 >> 4:
a two-value enum at 99.7 Hz, in the same byte as the position it qualifies.
The fault latches. Reconnecting the sensor does not clear it — the booster
stays in no-assist until a power cycle. And status == 2 means assist
unavailable, not position invalid: after a reconnect, 0x38E reports live
position again while status stays 2 and assist stays off. 0x39D, meanwhile, stays
pinned at its sentinel, so 0x38E is the only live position source during a latched
fault.
Full signal definitions: docs/DECODE.md. Plotted data: report/index.html (self-contained, opens offline). Correlation analysis: report/correlations.html — what the other eleven message IDs carry, and what tracks what.
1 · Contact at the pins, not the pinout. Six rounds of debugging — bitrate sweeps, polarity swaps, host power, ACK modes — turned out to be a bad connection at the booster end. Verify continuity from the adapter's screw terminal through to the pin. A clip resting on an exposed pin looks connected and often is not.
2 · Listen-only is unusable on a 2-node bus. With no ACK the booster's error counter climbs 8 per attempt, hits bus-off at ~32 ms, auto-recovers, and repeats. Same 12-second window:
| Mode | Frames | Unique IDs |
|---|---|---|
| listen-only | 1006 fps | 1 |
| ACK | 37 fps | 4 |
The 1006 fps is a retransmission storm, not a signal — and it hides three of the four IDs. ACK is a link-layer bit, not a command, so read-only capture still acknowledges. Always capture in ACK mode. This applies in the car too, wherever the booster and your monitor are the only two nodes.
3 · A powered transceiver idles at 2.5 V even when the ECU is dead. That reading proves the transceivers have power. It proves nothing about the ECU running.
4 · "The bus is silent" is not a diagnosis. Which kind of silent is, and the controller already knows — it is in the error counters, which is the one thing nobody reads. This table turned a recurring hour of meter work into one glance:
| Signature | Means |
|---|---|
| silent, error counters not moving | nothing is driving the pair at all — wiring |
| error counters climbing | bitrate, polarity, or termination |
ERROR-PASSIVE / BUS-OFF |
nothing is ACKing — the 2-node trap above |
| all buses silent together | power or ignition; one loose lead cannot silence two |
interface DOWN / STOPPED |
never brought up — see hotplug below |
ip -details -statistics link show can-veh # state + bus-errors + restartsPython, against a CANable-clone USB-CAN adapter (candleLight/gs_usb, 1d50:606f).
Two backends, picked automatically:
- SocketCAN (Linux/Pi) —
--channel can-veh. No dependencies; Python speaksAF_CANnatively. Preferred. - gs_usb over libusb (macOS) —
--index 0. Needed only because macOS has no SocketCAN and the adapter exposes no serial port.
pip install gs_usb pyusb # macOS only; plus brew install libusb
python3 tools/sniff.py --mode ack --seconds 30 --log logs/capture.log
python3 tools/analyze.py logs/capture.log --id 39D
python3 tools/plateaus.py logs/capture.log --id 39D --field le23| Tool | Does |
|---|---|
tools/sniff.py |
Capture, SocketCAN or gs_usb. candump-format logs, per-byte min/max |
tools/analyze.py |
Finds physical signals by smoothness × activity, not by range — a checksum spans 00..FF but jumps randomly; a real signal moves smoothly |
tools/plateaus.py |
Finds held positions for calibration, by spread rather than min/max |
tools/selftest.py |
Positive control between two adapters. The only file that transmits — adapter-to-adapter only |
macOS gotcha, load-bearing: sniff.py stubs is_kernel_driver_active to False
and Device.reset to a no-op. Without them the first capture in a process works and
every later one fails with "No such device", which reads convincingly as flaky
hardware.
ibooster_sniffer/ is an ESP32-S3 slcan sniffer, written and compiling but never
needed — the USB adapter path won. Kept as a fallback.
The Pi's kernel binds CANable adapters natively, so SocketCAN is available and
sniff.py needs no dependencies at all there — Python speaks AF_CAN directly.
sudo apt install can-utils
git clone https://github.com/pelosim/tesla-ibooster-can.git ~/ibooster
cd ~/ibooster && ./deploy/install.sh # persistent names + boot bring-up
./deploy/verify-buses.sh # confirm names match buses
python3 tools/sniff.py --channel can-veh --seconds 30 --log logs/veh.log
cansniffer can-veh # live changing-byte highlightingdeploy/ pins the two adapters to can-veh and can-yaw by USB serial.
Without that, can0/can1 are assigned in enumeration order and can swap on
reboot, silently mislabelling which bus a capture came from.
verify-buses.sh, which
checks by content (whichever bus carries 0x39D is the vehicle bus).
Hotplug is handled, and the mechanism is not obvious. A replugged adapter comes
back DOWN/STOPPED with no bitrate, and the boot unit is Type=oneshot RemainAfterExit=yes so systemd never re-runs it. Renaming a netdev emits
ACTION=="add" under the old name and ACTION=="move"** under the new one, so a SYSTEMD_WANTSon the add rule attaches to the pre-rename device and never reachescan-veh`. The rules bind it to move.
udevadm trigger --action=add nor a gs_usb module reload is faithful — the first
synthesises an event on a device already present under its final name, the second
happens too fast for systemd's device units to go inactive. Both reported success on
a fix that did nothing.
echo 1-1.3.1:1.0 | sudo tee /sys/bus/usb/drivers/gs_usb/unbind
sleep 4
echo 1-1.3.1:1.0 | sudo tee /sys/bus/usb/drivers/gs_usb/bindlogs/ holds the actual bench captures behind every claim in docs/DECODE.md, so
the decode can be re-derived rather than taken on trust. Bench frames only — no
vehicle-identifying data.
| File | What |
|---|---|
| docs/DECODE.md | Confirmed signal definitions and calibration |
| docs/BENCH_LOG.md | Dated findings, plus a table of the reasoning errors made along the way |
| docs/PINOUT.md | Measured pinout, and the unverified remainder |
| VERIFY_FIRST.md | Gate list — what is confirmed vs still hypothesis |
| BENCH_PLAN.md | The phased procedure, Phase −1 → 7 |
- Whether faults other than a lost travel sensor produce different status codes.
Only
1and2have ever been observed. 0x33D— a post-brake event message, all-FF99.8% of the time, fires ~0.8 s after release. Three samples is not enough to decode the payload.0x31D0x34D0x36D0x37D0x38D— fire together within ~100 ms, event driven, trigger unknown.0x38F(49.8 Hz, YAW) —b2andb3move; undecoded.- Calibration is good to roughly ±2 mm, limited by the ruler rather than the data — the signal repeats to 0.06 mm within a hold. Better would need a dial indicator on a fixed datum and a single run across the whole range.
This is a brake actuator. Everything here reads; nothing commands. On the bench, clamp it by the mounting studs, keep the pushrod path clear, and use a fused battery rather than a current-limited supply once ignition is live — the motor draws enough to sag a bench PSU into a brownout that looks like a fault.
Prior art that got the buses and idle values right, on other variants: EVcreate · openinverter wiki
MIT licensed. No affiliation with Tesla or Bosch.