Skip to main content
zerologs
Back to Learn Hub
Protocol reference

How a Zero talks over Bluetooth

A Zero answers a small set of four-letter commands over Bluetooth: who it is, every cell voltage, what the motor and the dash are doing, and its whole log, block by block. This page lists every command we know, what each reply holds and in which unit, and draws real replies from 4 recorded sessions with a 2016 FXS. Every row says how we know it: measured on those captures, taken from the open-source ZeroBLE library, or known but not yet captured.

Last updated: October 17, 2026
~14 min read7,987 captured frames · 4 sessions · every CRC checked

TL;DR

  • Gen2 motorcycles speak Bluetooth Classic, Serial Port Profile on RFCOMM channel 1. Gen3 motorcycles speak Bluetooth LE through a serial-over-BLE service, 0xFEFB. The 2026 XB, XE and LS1 have no known interface.
  • There is no login. Once the phone is bonded the link is encrypted and the first frame can already ask for data — and any bonded device could also send the commands that change the motorcycle. ZeroLogs sends reads only, from a fixed allowlist.
  • A Gen2 frame is F1 F2 F4 F8, a count of 32-bit payload words, a four-letter ASCII tag, the payload, F8 F4 F2 F1 and a CRC-32. A request is 20 bytes; the reply repeats its tag. In our captures all 3,995 requests and 3,992 replies frame and check correctly.
  • Live data comes from five reads in a round robin — MbbR, PwPk, DSt1, DSt2, DSt3 — one request in flight at a time, about 85 ms per round trip, so each value refreshes every ~0.5 s.
  • Speed arrives in whole mph and the odometer in whole miles, while the trip meters and range arrive in hundredths of a kilometre. Both units are checked: the speed integrated as mph reproduces the trip meter, and the odometer matches the bike’s own log to the kilometre.
  • A log download is one CmCh (read address 0) and then MbbD, or BmsD for the battery, repeated: a 4-byte flag and 256 bytes per answer, ~11 s stalls, and an end flag. On the FXS, 1,048 answers in 334 s gave the whole 262,144-byte log.
  • Some fields are not settled — DSt3’s three consumption words, two BtSt words, the sign of the currents — and the tables say so rather than guess.
On this page

Which motorcycle speaks which radio

Zero has shipped two Bluetooth protocols, one per electronics generation, and they have less in common than their shared frame magic suggests. A Gen2 motorcycle — the FX and FXS, and the S, SR, DS and DSR up to 2023 — is a Bluetooth Classic serial device. A Gen3 motorcycle (the Cypher III platform: the SR/F and SR/S from 2020, the DSR/X from 2023, and the S, SR, DS and DSR from 2024) is a Bluetooth Low Energy peripheral that tunnels a serial stream through GATT. "Gen2" and "Gen3" are the electronics and log generations used across these Learn pages; Zero’s own manuals file the FX and FXS as product generation 1.

The practical difference for a rider is the phone. Android opens a Classic serial link to any bonded device; iOS does not let an app do that unless the accessory is in Apple’s MFi programme, so a third-party iPhone app can reach a Gen3 bike over BLE but not a Gen2 one.

Everything measured on this page comes from one Gen2 motorcycle, a 2016 FXS whose main board reports part number 40-08290C. The Gen3 side rests on ZeroBLE, an MIT-licensed library; its author’s ZeroNavBridge app, built on it, is reported working on the DSR/X (2023), SR/F (2023), SR/S (2021–2023) and the Gen3 S. We have not captured a Gen3 bike ourselves yet, and every Gen3 row says so.

Which motorcycle speaks which radioGen2FX, FXS; S, SR, DS, DSRup to model year 2023Bluetooth ClassicSerial Port Profile (SPP)RFCOMM channel 1UUID 0x1101Android only (no SPP on iOSwithout Apple’s MFi programme)Measured on a 2016 FXSGen3SR/F, SR/S (2020 on), DSR/X (2023on), S, SR, DS, DSR (2024 on)Bluetooth Low EnergyGATT service 0xFEFB(“Terminal I/O” serial)credit-based flow controlasks for a 512-byte MTUone paired phone per bikeFrom open source, not yet tested by us2026 XB, XE, LS1the 2026 lightmotorcyclesNone knowntheir owner’s manualsnever mention Bluetooth,a phone or an appFrom the owner’s manuals
Two radios, two protocols that share a frame magic and a CRC but little else. Everything measured on this page comes from one Gen2 motorcycle; the Gen3 column rests on the MIT-licensed ZeroBLE library, and its author’s ZeroNavBridge app, built on it, is reported working on the DSR/X, SR/F, SR/S and Gen3 S. The one-phone limit is quoted from every Gen3 owner’s manual (2020–2026). The light 2026 models are listed so their absence is a finding rather than an omission: neither their owner’s nor their service manuals mention Bluetooth.
The two links side by side
Gen2Gen3
RadioBluetooth Classic (BR/EDR)Bluetooth Low Energy
ProfileSerial Port Profile over RFCOMMGATT service 0xFEFB, a serial-over-BLE profile
WhereRFCOMM server channel 1, SPP UUID 00001101-0000-1000-8000-00805f9b34fbService 0000FEFB-0000-1000-8000-00805F9B34FB
SecurityAn OS-level bond; the link is encrypted with its keyPairing as the bike’s manual describes; one phone per bike
Login in the protocolNoneNone known
Message namesFour ASCII letters (PrVr, MbbR …)Numbers (1, 3, 22 …) plus a counter
PhonesAndroid (iOS needs MFi)Android and iOS
StatusMeasuredZeroBLE (open source)

Connecting: the bond, the channel, and no login

On Gen2 the motorcycle is paired once in the phone’s own Bluetooth settings. That bond stores a link key; every later connection authenticates with it and turns on encryption before any data moves. The serial link itself is RFCOMM server channel 1. In our captures the phone opens channel 1 directly, with no service-discovery lookup first, and then the first frame on the link is already a request for data. Nothing in the protocol asks who is calling.

Treat the link as single-user: close one app before connecting with another. We have not tested what a Gen2 bike does with a second connection.

After connecting, the session in our captures asks for the protocol version, the power pack, the battery status, one dash read and the bike’s identity, then settles into the live round robin described further down. In both rides and the download, every reply arrived before the next request went out.

The first half-second after connecting2016 FXS, Bluetooth Classic SPP. Before frame 1: the OS bond (link key) and RFCOMM channel 1, no loginPhoneMotorcyclems0 ms — request PrVr0PrVr protocol version90 ms — reply PrVr, 4 bytes of payload90PrVr 4 B95 ms — request PwPk95PwPk power pack: cells, SoC154 ms — reply PwPk, 100 bytes of payload154PwPk 100 B157 ms — request BtSt157BtSt battery status, odometer249 ms — reply BtSt, 44 bytes of payload249BtSt 44 B253 ms — request DSt3253DSt3 dash: minutes to full383 ms — reply DSt3, 12 bytes of payload383DSt3 12 B393 ms — request Gbki393Gbki bike info: VIN, model506 ms — reply Gbki, 104 bytes of payload506Gbki 104 B514 ms — request PrVr514PrVr protocol version564 ms — reply PrVr, 4 bytes of payload564PrVr 4 B
The first 12 frames of a real session, times in milliseconds from the first one. No frame carries a password: once the operating system has a bond (a stored link key) the link is encrypted, and the very first frame is already a request for data. Every request is 20 bytes with no payload; each reply repeats the request’s tag. In both rides and the download the phone waited for each reply before sending the next request, never more than one in flight.

No login means no permission check either

The protocol has commands that change ride-mode limits, the clock, charging and firmware (see "Commands that change the motorcycle" below), and the bike does not ask who sends them: any device holding a bond can. That is why the ZeroLogs app sends nothing that is not on a short allowlist of reads, and refuses everything else in code. On the security side, CISA advisory ICSA-26-111-06 (CVE-2026-1354) describes forced Bluetooth pairing on Zero firmware 44 and earlier; its June 2026 update says a fixed firmware is available through Zero’s over-the-air (FOTA) platform.

The frame

Every Gen2 message, in both directions, has the same shape: a 4-byte start marker F1 F2 F4 F8; N, the payload length counted in 32-bit words, as a little-endian u32; a four-character ASCII tag; 4 × N bytes of payload; the end marker F8 F4 F2 F1; and a CRC-32 of everything from the start marker to the end marker, stored little-endian. The CRC is the ordinary zlib / IEEE 802.3 one, so any standard library computes it.

Requests carry no payload (N = 0), which makes every request exactly 20 bytes; the one exception is CmCh, below. The reply repeats the request’s tag. Multi-byte numbers inside a payload are little-endian too.

In our captures every one of the 3,995 requests and 3,992 replies frames at 12 + 4N and passes its CRC. One thing to get right first: a reply can arrive split across several Bluetooth packets, so reassemble the stream (on Android the socket does this for you) before looking for the markers.

Gen3 keeps the start marker and the CRC-32 but changes the rest: a u32 block count, a u16 message type and a u16 counter replace N and the tag, the payload is padded to whole 4-byte blocks, and the CRC comes before the end marker instead of after it.

One frame, two generationsGen2start4 bytesf1 f2 f4 f8Nu32 LE, words00 00 00 00tag4 ASCII50 72 56 72payload4 × N bytes(none: N = 0)end4 bytesf8 f4 f2 f1CRC-32u32 LE9d 88 03 7brequest PrVr = 20 bytes. Reply: N = 1, payload 00 17 00 00, CRC c0 10 3d edCRC-32 covers start … end, inclusiveGen3startF1 F2 F4 F8blocksu32typeu16counteru16payload4 × blocks, paddedCRC-32u32endF8 F4 F2 F1CRC-32 covers start … payload, and comes BEFORE the end markerA number replaces the ASCII tag, and a counter is added. Layout from ZeroBLE (MIT); not yet captured by us.
Gen2 bytes are the real PrVr request and reply from the captures; the page’s own frame builder reproduces every request in them byte for byte, CRC included. The CRC is the ordinary zlib / IEEE CRC-32, stored little-endian. The Gen3 row is the layout the open-source ZeroBLE library sends; we have no Gen3 capture of our own yet.
Messages the bike can send on its own
TagMeaningWhat to doStatus
ReSdThe bike did not take the last requestSend the same request againKnown, not yet captured
Worked example: the protocol-version exchange, byte for byte
request  f1 f2 f4 f8  00 00 00 00  50 72 56 72  f8 f4 f2 f1  9d 88 03 7b
         start        N = 0        "PrVr"       end          CRC-32, LE

reply    f1 f2 f4 f8  01 00 00 00  50 72 56 72  00 17 00 00  f8 f4 f2 f1  c0 10 3d ed
         start        N = 1        "PrVr"       payload      end          CRC-32, LE

The reply’s payload, 00 17 00 00, is the protocol version the 2016 FXS reports. Both frames are exactly the bytes in the capture, and this page’s own frame builder reproduces every request in the captures, CRC included.

Every Gen2 command, and what comes back

These are the reads. Each is a 20-byte request; the reply size is the one the 2016 FXS sends. Offsets in the sections below count from the first payload byte, after the 12-byte header (start marker, N, tag).

The Gen2 reads
TagAsks forReply payloadStatus
PrVrThe protocol version1 wordMeasured
GbkiWho the bike is: VIN, make, model, main-board part number26 words (104 B)Measured
PwPkPower pack: pack voltage, every cell, SoC, Ah, temperature probes, current25 words (100 B) on a 28-cell packMeasured
BtStBattery status: voltage, Ah, SoC, pack temperatures, status bits, odometer11 words (44 B)Measured
MbbRMain board: torque, rpm, motor and controller temperature, currents, speed, switches7 words (28 B)Measured
DSt1Dash: trip A, motor rpm, the dash’s code2 wordsMeasured
DSt2Dash: trip B, range estimate, motor temperature2 wordsMeasured
DSt3Dash: minutes until charged, three consumption words3 wordsMeasured; scales not settled
EcusEvery control unit: index, model year, hardware and firmware part numbers and revisionsa count, then 30 B per unitKnown, not yet captured
ErrsThe active fault codesa count, then a u16 per codeKnown, not yet captured
MbbDThe main board’s log, one block per request65 words: 4-byte flag + 256 BMeasured
BmsDThe battery’s log, the same way65 wordsAnswered on the 2016 FXS
CmChThe command channel; a download sets the read address to 0 with it11 words, echoedMeasured (read address only)

A bike that does not support a read may simply not answer it; treat silence after a few seconds as "not supported" rather than retrying forever.

PrVr and Gbki: who is on the line

PrVr answers one word, 00 17 00 00 on this bike. Gbki answers 104 bytes of mostly NUL-terminated ASCII. The offsets below are measured on the one bike captured; read the strings up to their first NUL rather than trusting fixed lengths.

Gbki, as the 2016 FXS answers it
Starts at byteFieldExample
0VIN, 17 ASCII characters + NUL[VIN] (redacted here)
18MakeZero Motorcycles
38ModelFXS
70Main-board part number40-08290C
96u32, meaning not known41

Status: Measured, one bike. Where each string starts is measured; how long each field is allowed to be is not known.

PwPk: every cell, twice a second

PwPk is the richest live read. Its cell list has no length field: cell voltages, as u16 millivolts, run until the first value under 128, and that value is the state of charge. This 28-cell pack read 3.673 V to 3.764 V at connect, a spread of 91 mV.

After the state of charge come the pack’s capacity and remaining charge in amp-hours, then thirteen temperature slots in °C, where −100 means "no sensor fitted". On this bike slots 0–3 and 8–9 track the pack temperature the main board logs at the same moments, slots 4–7 are absent, and slots 10–12 sit with the electronics: slot 10 follows MbbR’s motor temperature (equal in 541 of 566, and never more than 1 °C apart) and slot 12 its controller temperature (545 of 566, and never more than 1 °C apart), pairing each reply with the MbbR read less than a second before it. The pack current sits 8 bytes before the end; its sign convention is not settled, and the last 6 bytes are not decoded.

Every cell, twice a secondPwPk on the 2016 FXS at connect: pack 105.386 V, SoC 60 %, spread 91 mV3.65 V3.70 V3.75 V3.80 Vcell 1: 3.759 V1cell 2: 3.706 Vcell 3: 3.695 Vcell 4: 3.673 V3673cell 5: 3.694 V5cell 6: 3.688 Vcell 7: 3.687 Vcell 8: 3.689 Vcell 9: 3.695 V9cell 10: 3.703 Vcell 11: 3.689 Vcell 12: 3.695 Vcell 13: 3.707 V13cell 14: 3.718 Vcell 15: 3.706 Vcell 16: 3.698 Vcell 17: 3.713 V17cell 18: 3.719 Vcell 19: 3.732 Vcell 20: 3.724 Vcell 21: 3.737 V21cell 22: 3.737 Vcell 23: 3.752 Vcell 24: 3.754 Vcell 25: 3.760 V25cell 26: 3.764 V3764cell 27: 3.763 Vcell 28: 3.759 V28cell number, in the order the reply lists them
One PwPk reply, decoded: the pack’s 28 series cells as the bike reports them, in millivolts. The list has no length field — it runs until the first value under 128, which is the state of charge (60 %). The y-axis starts at 3.65 V so a 91 mV spread is visible; each dot is a reading, not a bar of magnitude.
PwPk on a 28-cell pack (offsets move with the cell count)
BytesFieldUnitExample at connect
0–3Pack voltageu32, mV105.386 V
4–5928 cell voltagesu16 each, mV, all ≥ 1283,673–3,764 mV
60–61State of chargeu16, %60 %
62–63Capacityu16, Ah52 Ah
64–65Remainingu16, Ah32 Ah
66–9113 temperature slotss16 each, °C; −100 = none34 33 34 34 — — — — 34 33 42 46 35
92–93Pack currents16, A; sign not settled-3 A
94–99Not decodeded ff ae 00 00 00

Status: Measured. The state of charge here (60 %) and BtSt’s (62 %) are two different readings and need not agree.

BtSt: the battery, and the odometer in miles

BtSt repeats the pack voltage and amp-hours and adds two things PwPk lacks: a word of status bits and the odometer, in whole miles: 20,776 mi (33,436 km) at the first ride and 20,818 mi (33,503 km) at the second. The log the same bike wrote, read by this site’s parser, shows the same two odometer values in kilometres at those two rides.

BtSt
BytesFieldUnitExample at connect
0–3Pack voltageu32, mV105.341 V
4–5Capacityu16, Ah52 Ah
6–7Remainingu16, Ah32 Ah
8State of chargeu8, %62 %
10–25Pack temperatures8 × s16, °C; −100 = none34 33 34 34 — — — —
28–31Status bitsu32, see belowbike on
32–35Odometeru32, whole miles20,776 mi
36–39Not settledu3228,472 riding, 0 parked
40–43A slow counter, unit not settledu32135 → 138 over 42 mi

Status: Measured.

BtSt status bits (bytes 28–31 as a u32)
BitMeaningSeen
0x10000000Charger 1 attachedyes, in 1 reply
0x08000000Charger 2 attached
0x04000000Bike onyes, in all 10 replies
0x02000000Pack hot
0x01000000Pack critically hot
0x00800000Pack cold
0x00400000Charge low
0x00200000Charge critically low
0x00100000Cells out of balance (the battery’s own judgement)

"Bike on" and "Charger 1 attached" are measured; the other names are known but not yet seen set.

MbbR: motor, speed and switches

MbbR is the main board’s live read, and its speed is a whole number of miles per hour. The check: integrate it over each ride and read it as mph, and it gives 4.71 km and 1.90 km, against 4.80 km and 2.00 km on the dash’s trip meter; read as km/h it would give only 2.93 and 1.18 km. The motor turns a steady 68.3 rpm per mph (median, both rides). Over the ride drawn below the reading peaked at 38 mph and 2,601 rpm.

Bytes 20–27 are four percentages that stayed the same in every reply, riding and parked. They are known as the limits of the Eco mode (top speed, torque, coast regen, brake regen) — the values are measured, the naming is not — and not those of whatever mode is selected: the active mode is not in any Gen2 read we know of.

MbbR
BytesFieldUnitRidingParked
0–1Motor torques16, N·m-30
2–3Motor speeds16, rpm8380
4–5Motor temperatures16, °C4344
6–7Controller temperatures16, °C3534
8–9Battery currents16, A; sign not settled00
10–11Motor currents16, A; sign not settled-150
12–15Speedu32, whole mph120
16–19Status bitsu32, see belowarmedkickstand down, brake
20–27Eco-mode limits4 × u16, %85 / 20 / 0 / 10085 / 20 / 0 / 100

Status: Measured.

MbbR status bits (bytes 16–19 as a u32)
BitMeaningSeen
0x10000000Kickstand downyes, parked
0x08000000Kill switch off
0x04000000Armed (ready to ride)yes, riding
0x02000000Temperature warning
0x01000000Brakeyes
0x00200000Charging

DSt1, DSt2, DSt3: what the dash shows

The three DSt reads are short and come back with the numbers on the dash. Trip meters and the range estimate are in hundredths of a kilometre, so the trip advances in steps of 10 — the dash’s 0.1 km. The dash’s code is a plain number: it read 0 riding and 45 parked, and what each value means is not published, so show it as a number, never as an alarm.

DSt3’s first word is a minutes-until-full figure: 185 in a short session with BtSt’s "charger 1 attached" bit set. It is 0 most of the time otherwise, but in 38 of the 564 replies during the two rides it read 75 to 148, for a few seconds at a time, around 20 mph with the brake bit set — perhaps while regenerating; that is not settled. The other three words look like consumption figures, but under the "Wh/km × 100" scale that suggests itself they read 19.4 and 2.31 Wh/km, which no Zero does. Keep them raw until a ride settles them.

DSt1, DSt2 and DSt3
ReadBytesFieldUnitAt connect
DSt10–3Trip Au32, km × 10038.60 km
DSt14–5Motor speeds16, rpm0
DSt16–7Dash codeu160 (45 parked)
DSt20–3Trip Bu32, km × 1001,374.10 km
DSt24–5Range estimateu16, km × 100119.06 km
DSt26–7Motor temperatures16, °C44 °C
DSt30–1Minutes until chargedu160 (185 on the charger)
DSt34–7Consumption, instant?u32, scale not settled0
DSt38–9Consumption, average?u16, scale not settled1,940
DSt310–11Consumption, lifetime?u16, scale not settled231

Status: Measured. DSt2’s motor temperature equals MbbR’s in 519 of 558, and never more than 1 °C apart, pairing each reply with the MbbR read less than a second before it.

Ecus and Errs: firmware and faults

Two more reads are known but not yet in our captures. Ecus lists the control units: a u32 count, then one 30-byte record per unit. Errs lists the active fault codes: a u32 count, then one u16 per code, with zero codes as padding. Not every Gen2 bike may answer Errs.

One Ecus record (30 bytes)
BytesField
0Unit index: 6 main board, 8 battery, 9 a second battery
2–3Model year, u16 (the main board sends it × 10)
4–15Hardware part number, ASCII
16Hardware revision
17–28Firmware part number, ASCII (empty = skip the record)
29Firmware revision

Status: Known, not yet captured.

A live session, drawn

Once connected, the session polls five reads in a fixed order — MbbR, PwPk, DSt1, DSt2, DSt3 — with one request in flight at a time. The median round trip is 85 ms, so each reading comes back about every 0.5 s. PrVr, Gbki and BtSt are asked only in the first second or so after connecting.

The second figure is 8 minutes of one ride, every MbbR and DSt2 reply decoded at the offsets above. Nothing in it is smoothed.

What a live connection looks likeStrict request → reply, five reads in a round robinPhoneBikeDSt2: request at 0 ms, reply at 103 ms (103 ms)DSt2DSt3: request at 109 ms, reply at 203 ms (94 ms)DSt3MbbR: request at 214 ms, reply at 301 ms (87 ms)MbbRPwPk: request at 312 ms, reply at 406 ms (94 ms)PwPkDSt1: request at 420 ms, reply at 514 ms (94 ms)DSt1DSt2: request at 523 ms, reply at 611 ms (88 ms)DSt2DSt3: request at 622 ms, reply at 710 ms (88 ms)DSt3MbbR: request at 724 ms, reply at 773 ms (49 ms)MbbRPwPk: request at 779 ms, reply at 879 ms (100 ms)PwPkDSt1: request at 884 ms, reply at 1001 ms (117 ms)DSt1DSt2: request at 1012 ms, reply at 1088 ms (76 ms)DSt2DSt3: request at 1098 ms, reply at 1191 ms (93 ms)DSt3MbbR: request at 1202 ms, reply at 1283 ms (81 ms)MbbRPwPk: request at 1290 ms, reply at 1360 ms (70 ms)PwPkDSt1: request at 1373 ms, reply at 1442 ms (69 ms)DSt1DSt2: request at 1453 ms, reply at 1499 ms (46 ms)DSt2DSt3: request at 1509 ms, reply at 1594 ms (85 ms)DSt3MbbR: request at 1606 ms, reply at 1672 ms (66 ms)MbbRPwPk: request at 1686 ms, reply at 1794 ms (108 ms)PwPkDSt1: request at 1805 ms, reply at 1925 ms (120 ms)DSt10 ms500 ms1000 ms1500 ms2000 msmedian round trip 85 ms · each read every ~0.5 s
Two seconds of a live session, drawn from the capture: each slanted line is one request and its reply. Five reads go round in a fixed order, one in flight at a time. Over 2,857 replies the median round trip is 85 ms (90th percentile 106 ms), which is what sets the refresh rate: each reading comes back about every 0.5 s, roughly twice a second.
Eight minutes of riding, as the bike reports itOne panel per measure, shared time axis (minutes since connecting)Speed mph, as sent (MbbR)0204038 mph at 2:1538 mph ≈ 61 km/hMotor rpm (MbbR)01,5003,000Temperatures °C (MbbR)304560motorcontrollerDash range estimate km (DSt2)8010012084.98 km0:001:002:003:004:005:006:007:008:009:00
Every MbbR and DSt2 reply of one 8:13 session on the 2016 FXS, decoded at the offsets in the tables below and drawn as sent: the speed really is in whole mph on the wire, whatever the dash shows. Each dot is one reply; the polling ran in bursts, and the faint line only joins readings across the pauses between them. The range estimate drops 34.1 km while trip B advances 4.8 km — the dash’s own arithmetic, not ours.

How a log download works

The same link carries the motorcycle’s log — the file a computer would otherwise read over the diagnostic port. On Gen2 it comes in 256-byte blocks, and the request has no block number: the bike keeps its own read pointer and serves the next block each time it is asked.

  1. 1Stop the live polling, so only download frames are in flight.
  2. 2Send CmCh with the read address set to 0. Its payload is 11 words: 04 00 00 00 and then zeros. The bike echoes it.
  3. 3Send MbbD (N = 0). The answer is a 4-byte flag and 256 bytes of the log.
  4. 4Flag 00 00 00 c0 is a block of data. If its 256 bytes are identical to the block you just kept, it is a resend: drop it. Otherwise append it.
  5. 5Repeat MbbD. When the bike pauses, wait for the answer. In the capture every one of the 1,048 requests got exactly one answer, with never more than one request in flight; the 17 slow ones came after about 11 s, and each was simply the next block.
  6. 6Flag 00 00 00 e0 is the end of the file: stop there. Asking again only brings the end flag again; in the capture the read started over from the first block only after the client sent CmCh a second time.
  7. 7Send CmCh again and resume polling. If the bike ever sends ReSd, repeat your last request.
The download loopGen2 · MbbD / BmsDGen3 · numbered blocks1. stop the pollingone request in flight, always2. CmCh: read address = 0payload 04 00 00 00 00 … (11 words)3. MbbD (N = 0)answer: 4-byte flag + 256 Bflag?c0 · datasame bytes as the blockjust kept? drop it : append itnext MbbDe0 · end of file4. stop: the file is complete1,024 × 256 B = 262,144 B here5. CmCh again, resume pollingReSd from the bike:send the last request againLOG_INIT (24)per file: id, bytes, block countLOG_BLOCK (25)file + block number → 128 BLOG_DONE (26)the bike answers LOG_DONE or ACKfor each file: blocks 0 … count − 1,main board first, then the battery.Ask for exactly count blocks: onemore returns 128 B that are not log.131,200 B per board = 1,025 × 128 B
Gen2 (left) is measured: the address command, 1,048 MbbD answers and the end flag are all in the capture, and the request carries no block number — the bike keeps its own read pointer. In the capture the client simply waited out each stall, and the next answer was the next block. BmsD runs the same loop for the battery’s log. Gen3 (right) addresses every block by number; that procedure is known but not yet captured by us.
Downloading a 256 KiB log, block by blockMbbD on a 2016 FXS: 1,048 answers in 334 s, 17 stalls of ~11 sstall: 11 s with no answer, from 1.2 sstall: 11 s with no answer, from 16.2 sstall: 11.02 s with no answer, from 47.1 sstall: 10.99 s with no answer, from 62.2 sstall: 11.1 s with no answer, from 108.4 sstall: 11.12 s with no answer, from 119.5 sstall: 11.13 s with no answer, from 138.5 sstall: 11.12 s with no answer, from 149.6 sstall: 11.12 s with no answer, from 177.6 sstall: 11.16 s with no answer, from 188.7 sstall: 11.11 s with no answer, from 225.5 sstall: 11.12 s with no answer, from 236.6 sstall: 11.01 s with no answer, from 255.7 sstall: 11 s with no answer, from 267.8 sstall: 10.97 s with no answer, from 295.9 sstall: 11.1 s with no answer, from 309.8 sstall: 11.16 s with no answer, from 320.9 s02565127681,024end-of-file flag at 308.2 s1,048 answers1,024 keptstall0:001:002:003:004:005:00minutes since the first block · y: blocks
The whole main-board download, every answer drawn. The 17 flat steps are the bike pausing for 10.97–11.16 s; nothing is lost if the client simply keeps waiting and keeps every block in the order it arrives. At 308.2 s the bike sends the end-of-file flag, and sends it again to each request (6× in a row) until the client resets the read address with CmCh; after that the log starts over from its first block. None of the 23 answers from the end flag on belong to the file. Result: 1,024 × 256 B = 262,144 B, which the project’s parser reads with no duplicated block and no step backwards in time.
The download on the 2016 FXS, measured
Value
Answers received1,048
Blocks kept1,024 × 256 B = 262,144 B
Duration334.3 s
Median gap between answers0.144 s
Stalls17, each 10.97–11.16 s
End flagfirst at 308.2 s, 6 in a row

Kept in arrival order, an exact resend dropped and stopped at the first end flag, the blocks make the whole log: the project’s parser reads it with no duplicated block and no step backwards in time.

What the capture did differently

In the recorded download the first MbbD went out a moment before the CmCh. Its block (the start of the log) came again right after the CmCh, and that is the one exact repeat the rule above drops. Polling also did not fully stop: one MbbR went out between the two.

The battery’s log: BmsD

BmsD runs the same loop for the battery management system: CmCh with read address 0, then BmsD repeated. On the 2016 FXS the battery kept answering past 1,024 blocks without ever sending the end flag (seen with the ZeroLogs app in October 2026), so a client needs a stop of its own. ZeroLogs stops at the main board’s block count, 1,024 on this bike; whether that is exactly the size of the battery’s log is not confirmed yet.

A bike with a second battery (a Power Tank) selects the second battery management system through CmCh before BmsD. That step is known but we have not tried it on a bike.

Gen3: numbered blocks

Gen3 addresses every block. LOG_INIT (type 24, empty body) answers with the number of files and, per file, its size in bytes (u32 LE) and its block count (u16 LE); from protocol major version 27 on, each file entry is also preceded by its unit id. LOG_BLOCK (type 25) asks for one block: a file number (u8) and a block number (u16 LE). The answer’s body is 128 bytes of raw flash. The main board’s file comes first, then the battery’s. LOG_DONE (type 26) ends the session; the bike answers LOG_DONE or ACK.

Ask for exactly the block count, 0 to count − 1. A request one past the end still returns 128 bytes, and they are not log data: in the Gen3 files we hold that carry such a block, it is almost always a blank page — the marker 00 F0 FF 00 and zeros. A full Gen3 board log is 131,200 bytes, 1,025 blocks of 128.

Status: Known, not yet captured. The 131,200-byte size is measured on Gen3 log files.

The Gen3 message types

Gen3 messages are numbered. The table lists the reads; the writes are in the next section. Types 16 to 21 carry two different names in the sources we have. The second set (dash gauges, scheduled charge, custom ride modes, ACK, timestamp, charge target) is used here: it agrees with ZeroBLE wherever ZeroBLE names the type (16, 18, 19, 20).

Gen3 reads
TypeNameWhat the reply carriesStatus
1Protocol versionmajor, minor, patch. Major ≥ 27 changes the LOG_INIT layoutZeroBLE (open source)
2Resendthe other side asks for the last message againZeroBLE (open source)
3Status (bike info)VIN, make, model, charger connected, charging, SoC, ready (armed), charge power (× 100 W), minutes to full. Also a passcode field: discard it at decode, never store or log itZeroBLE (open source)
4Power packlayout not publishedKnown, not yet captured
5Battery statuslayout not publishedKnown, not yet captured
6Main board readmotor torque, rpm, motor and controller temperature, battery and motor current, speedKnown, not yet captured
10Dash statisticslayout not publishedKnown, not yet captured
11Control unitsper unit: node id and type, firmware revision, active bank, firmware and hardware part numbersKnown, not yet captured
12Error codesa u32 count, then the active codes as u16Known, not yet captured
19ACKacknowledges a messageZeroBLE (open source)
22Live data (ride share)odometer (× 100 m), energy used (kWh), consumption, speed (km/h × 100), lean angle (° × 10, left negative), power, torque, regen and brake regen, latitude and longitude (radians × 10⁸), SoCZeroBLE (open source)
23Range estimationrange per route type (city, highway, mixed, freeway) and the model’s inputs: pack capacity new / mid-life / old, ride style, temperature, terrainKnown, not yet captured
24–26Log downloadLOG_INIT, LOG_BLOCK, LOG_DONE — see the download sectionKnown, not yet captured

Ride-mode settings (types 13 and 18) are both read and written with the same type, so a reader that allows the type cannot tell a read from a change. ZeroLogs leaves them out.

Commands that change the motorcycle

The protocol also carries commands that change how the motorcycle behaves, set its clock or load firmware. They are listed so that a capture can be recognised — never with a payload. The ZeroLogs app sends none of them: its Bluetooth code accepts only the reads in the tables above and the single CmCh that sets the read address to 0, and refuses anything else before it reaches the radio.

Writes
GenerationMessageWhat it changes
Gen2CmCh, drive parametersthe custom ride mode’s limits: top speed, torque, coast regen, brake regen
Gen2CmCh, test modea test mode; what it does is not documented
Gen2CmCh, unit index, boot bank, update status and image infothe firmware-update machinery
Gen2Upld, Hasha firmware image and its hash
Gen313 / 18 ride modes (written back)ride-mode settings
Gen316 dash gaugeswhat the dash displays
Gen317 scheduled charge, 21 charge targetwhen charging starts and where it stops
Gen320 timestampsets the motorcycle’s clock
Gen327charging or connectivity configuration (the sources disagree on its name)
Gen331 navigationturn-by-turn directions on the dash
Gen332 option status, 33 option setreport and turn on the optional features (33 with a key). 32 only reports, but its type number sits with the feature writes, so ZeroLogs sends neither
Gen39 upload, 14 hash, 28 firmware init, 29 firmware installfirmware

Do not experiment with writes on a motorcycle you ride

A wrong drive parameter or a half-written firmware image can leave a bike that does not behave as its rider expects, or does not start. Reading is harmless; a command you cannot name is not.

Capture your own session

Everything measured here started as an Android Bluetooth log, and the Gen3 rows will only become measured the same way. A capture holds the bike’s VIN, so it is sent to us privately, never published.

  1. 1Turn on Developer options: Settings → About phone → tap Build number seven times.
  2. 2In Developer options set "Enable Bluetooth HCI snoop log" to Enabled (not Filtered), then turn Bluetooth off and on.
  3. 3Connect to the motorcycle with any app that talks to it, and do one thing at a time. Write down what you did, in order.
  4. 4Developer options → Take bug report → Full report, and keep the .zip (the log is FS/data/misc/bluetooth/logs/btsnoop_hci.log inside it).
  5. 5Set the snoop log back to Disabled: it records every Bluetooth exchange while it is on.
  6. 6Upload the .zip or the .log on the Help decode page of your ZeroLogs dashboard.

Frequently asked questions

Do I need a password or PIN to read data from a Zero over Bluetooth?

No. The phone pairs with the motorcycle once in its Bluetooth settings; after that the link is encrypted, and the protocol itself has no login. The first frame after connecting is already a request for data.

Can an iPhone talk to a Gen2 Zero?

Not from a third-party app. Gen2 bikes use Bluetooth Classic serial (SPP), which iOS opens only for accessories in Apple’s MFi programme. Gen3 bikes use Bluetooth LE, which iOS apps can use.

Why does the bike send miles when its log is in kilometres?

That is how the protocol is laid out: MbbR’s speed is in whole mph and BtSt’s odometer in whole miles, while DSt1 and DSt2 give the trip meters and range in hundredths of a kilometre. On the FXS, BtSt’s 20,776 mi is the 33,436 km its own log recorded, and MbbR’s speed integrated as mph gives 4.71 km over a ride the trip meter puts at 4.80 km. We have only measured this one bike.

Why is a log download over Bluetooth so slow?

It moves 256 bytes per request, one request at a time, about 0.144 s each, and the bike pauses for about 11 s every so often (17 times in one download). A 256 KiB main-board log took 5.6 minutes on the FXS.

Can two phones or two apps connect at the same time?

On Gen3, every owner’s manual from 2020 to 2026 says only one phone can be paired at a time. On Gen2 we have not tested a second connection; close one app before opening another.

Does reading data over Bluetooth change anything on the bike?

The reads do not. The only command a download needs besides them sets the read pointer to the start of the log. The commands that do change the bike are listed on this page so they can be recognised and avoided.

How sure is the Gen3 part of this page?

Less than the Gen2 part. Gen2 is measured on our own captures. Gen3 comes from the open-source ZeroBLE library — its author’s ZeroNavBridge app, built on it, is reported working on the DSR/X, SR/F, SR/S and Gen3 S — and from what is known but not yet captured; each row says which. A Gen3 capture would settle it.

The Gen2 measurements come from 4 sessions recorded in August 2026 between an Android phone and a 2016 FXS (two rides, a log download and a short reconnect), read with this project’s own btsnoop tools: Bluetooth packets reassembled, every frame checked against its CRC, every reply decoded at the offsets on this page. Times are given from the start of each session; the VIN is redacted.

This is an independent interoperability reference, not affiliated with or endorsed by Zero Motorcycles, Inc. Offsets were measured on one 2016 FXS and may differ on other firmware. Read freely; never send a command you cannot name, and never one from the "change the motorcycle" table to a bike you ride.