Gen2 vs Gen3: Log Differences
A technical comparison of Zero Motorcycles log formats across generations, based on reverse-engineering and real-world analysis.
TL;DR
"Gen2" and "Gen3" on this page name a log format, not a motorcycle. Zero's own product generations do not line up with them model-for-model, and a third numbering — the one our parser records — differs again; the section below separates all three.
Gen2-format logs are 262,144-byte files whose entries are periodic text snapshots carrying every channel at once. Every file we hold from an S, SR, DS or DSR (2014-2023) is one, and so is every file from an FX (2014-2025), FXS (2016-2021) or FXE (2022-2025). Gen3-format logs are ring buffers of length-prefixed binary records — 131,200 bytes on the MBB, 131,328 on the BMS — and come from the SR/F and SR/S (2020 onward) and the DSR/X (2023 onward). The consequential difference is not the encoding, it is what a decoder gets out. A Gen2 riding entry hands you volts, amps, RPM and an odometer on one line. A native Gen3 MBB record does carry pack voltage and battery current, at record[34:38] and record[38:42] — but no shipping decoder reads them, and they are empty in the hibernation and power-up states that make up three quarters of a real log. There is no odometer and no road speed anywhere in the record. So a Gen3 upload still needs its BMS companion before any electrical chart fills in; the reason is sparseness and missing tooling, not absence.
File Format
The most visible difference is the file itself: size, naming convention, and internal structure.
Gen2 format (text snapshots)
Full SupportGen3 (FST / Cypher III)
Full SupportNote: Gen2 newer firmware uses VIN_MbbD_date.bin naming (lowercase "bb" + "D"). The format remains the same, only the filename convention changed.
Why the record counts on this page say "length-chain framing"
There is more than one way to count records in a Gen3 file, and they do not agree, so every population on this page names the one it used. Length-chain framing is the one our MBB decoder uses: at each position the byte after the 0xB2 delimiter gives the record's on-disk extent, and the next record starts exactly that many bytes later. Simply counting 0xB2 bytes, or splitting on every one of them, gives different totals — and it is not safe, because 0xB2 is a legal data byte that the writer escapes as FE 4D (with FE 01 for a literal 0xFE). Frame by the declared length first, then unescape the framed record. In the eight full-size MBB dumps measured here, 468 of the 19,089 records carry an escape, 477 escape bytes in all — small, but they land inside timestamps and state tokens, so ignoring them corrupts records rather than losing them. The byte-level rules are on the format specification.
Three things are called a "generation" here, and they disagree
1 · Product generation — what Zero built, as its own owner manuals state it. The manuals put the FX and FXS at generation 1 (XMX · modular) for their whole run; the S, SR, DS and DSR at generation 2 (SDS · Z-Force, Cypher II) from 2014 to 2023 and at generation 3 (FST · Cypher III) from 2024; the SR/F and SR/S at generation 3 from 2020 and the DSR/X from 2023; the FXE at generation 2 (XMX · modular, Cypher II); and the 2026 LS1, XE and XB at generation 3 on the LS and X-line platforms. Our bike catalogue renders those sheets directly.
2 · Log-format generation — which binary layout the file uses. That is what the two cards above describe, and it is the sense this page uses everywhere unless it says otherwise. It cuts across the product generations: an FX (product gen 1) and a DSR (product gen 2) write the same Gen2-format file.
3 · Routing generation — the log_generation value our parser stores against your upload. It is a statement about which decoder ran, nothing more.
The FXE is where all three collide
The FXE is a Cypher II motorcycle (sense 1: generation 2), it is in the parser's GEN3_MODELS set so we store log_generation = 3 against it (sense 3), and its files are ordinary 262,144-byte MBB\0 / BMS\0 dumps that decode through the 538XX firmware path — a Gen2-lineage record format, not the native Gen3 one (sense 2). All three statements are true at once, and a sentence that picks one without saying which sense it means is wrong. No FXE file in this corpus is a native Gen3 ring buffer.
The mechanics: GEN3_MODELS is {SRF, SRS, SR/F, SR/S, DSRX, DSR/X, FXE} and the GEN2_ONLY_MODELS veto list is {S, SR, DS, DSR, FX, FXS, XU} — FXE appears in the first and not the second (backend/app/parser.py, lines 2443 and 2449). A hard veto fires first if the model is on the Gen2-only list or the tenth VIN character is a year code from A to J (2010-2018), and only then are the three positive signals — model, Gen3 VIN pattern, Gen3-only text patterns with no Gen2 telemetry — consulted.
One consequence we have not been able to test. The veto list holds bare model names with no year term, and the manuals put the 2024-onward S, SR, DS and DSR on the FST / Cypher III platform. So a 2025 DSR would be vetoed onto the Gen2 path on its name alone, whatever its file actually contains — and GEN3_MODELS lists no LS1, XE or XB, so the 2026 light line has no route at all. Whether either is a real defect we cannot say: our 352-file corpus holds no 2024-or-later S, SR, DS or DSR and no light-line log, so there is nothing to measure. This page states it as an open risk rather than guessing, and the veto stays as it is until such a file exists — the comment above it records a regression it was written to prevent. If you have one, send it.
Data Logging Approach
Gen2: Periodic Snapshots
Gen2 logs a complete telemetry snapshot once a minute while riding, and periodic updates during charging and idle. Each entry contains all available data, decoded into readable key/value pairs — the record itself is binary, and the English you read is the parser’s table rather than words the motorcycle stored.
Measured on the published extracts: 0 of 169 Gen2 lines hold text the bike itself wrote, against 21 of 237 on Gen3 — where the ring buffer’s event records really do carry strings. The readable platform is the newer one, which is the opposite of what the formats suggest.
// A real Gen2 MBB riding entry, as decoded — FXS 2016, MBBEntry
MBB #929 15:22:15 Riding
pack_temp_high_celsius: 34, pack_temp_low_celsius: 32, state_of_charge_percent: 75, pack_voltage_volts: 108.28, motor_temp_celsius: 44, controller_temp_celsius: 32, motor_rpm: 1349, battery_current_amps: 4, motor_current_amps: 5, ambient_temp_celsius: 28, odometer_km: 30800, mods: 11, mods_raw: 3
MBB #930 15:23:15 Riding
pack_temp_high_celsius: 34, pack_temp_low_celsius: 32, state_of_charge_percent: 74, pack_voltage_volts: 106.924, motor_temp_celsius: 46, controller_temp_celsius: 32, motor_rpm: 1744, battery_current_amps: 23, motor_current_amps: 48, ambient_temp_celsius: 28, odometer_km: 30800, mods: 11, mods_raw: 3
// 21 lines in this window · timestamps real · every line is our decode of the bytes
Every field is available in every riding entry. Dense, complete data — and the 60-second cadence is exact: it is the dominant inter-entry gap in every Gen2 file measured here.
See this window line by line — the same extract with every field named, beside the battery lane that was writing at the same moment.
Gen3: Event-Based Ring Buffer
Gen3 uses a ring buffer that stores events as they occur. Telemetry is embedded in 75-byte binary records, and ASCII records log state changes, faults, and system messages. Entries are not chronological in the file — the ring buffer wraps around.
// Real records, decoded field by field — the moment a parked Gen3 goes to sleep — DSR/X 2023, Gen3MBBEntry
MBB #248 08:24:18 State change
from: PWSU, to: HIB
MBB #249 08:24:18 MTC pending change by 3
from: 0, to: 0
MBB #250 08:24:18 Stopping 12v charge
DC-DC: 281776uV, Battery: 13153056uV, Combined: 12340647uV, Ambient: 33C
MBB #251 08:24:18 Unknown
// 25 lines in this window · timestamps real · mixed device text and our decode
See a parked Gen3 keep this up for an hour at a time — and the two clocks that make a Gen3 pair look as though the boards never write together.
Mixed binary + ASCII. Must sort by timestamp after parsing. Fewer entries per ride. The voltage and current words are real and they are populated on every RUN record — and on no hibernation record — but nothing in the pipeline reads them, so in practice volts and amps come from the BMS file. There is no km/h and no odometer to read at all; the third value above is the 12 V accessory rail, which stays alive while the pack is disconnected.
MBB Telemetry Comparison
What data is available in each generation's MBB (Main Bike Board) log. Gen3 offsets below are given relative to s, the position of the ASCII state string; on a type-0x51 telemetry record s = 42, and it is 42 on all 1,356 such records in the nine native Gen3 MBB files here — 1,347 of them if you only count the eight state tokens the parser recognised before 2026-08, and all 1,356 with REV and PARK, which both decoders now read. "Present but unread" below means the bytes are there and populated; no shipping decoder extracts them.
| Data Field | Gen2 | Gen3 |
|---|---|---|
| Pack Voltage | Every entry (Vpack, V) | MBB [s-8] ([34:38], uint32 LE mV) — present but unread; non-zero on 144/144 RUN and 0/417 HIB records. The value the app charts is the BMS one (payload[38:41], uint24 LE mV, measured) |
| Battery Current | Every entry (BattAmps, A) | MBB [s-4] ([38:42], int32 LE mA) — present but unread, populated with the voltage. The value the app charts is the BMS one (payload[20:22], int16 LE mA) |
| SOC (State of Charge) | Every entry (PackSOC, %) | Binary [s-9]: uint8 0-100% (dashboard value; BMS carries its own at payload[19]) |
| Motor RPM | Every entry (MotRPM) | Not recorded anywhere in a native Gen3 log |
| Speed | Derived from RPM | Not recorded, and there is no odometer to derive distance from either |
| Odometer | Every entry (Odo, km) | Does not exist in a native Gen3 record. [s+29] was published here as one — it is the 12 V accessory rail, see the retraction below |
| 12 V accessory rail | Text events only | Post-state field 5 ([s+29], uint32 LE microvolts) — decoded, emitted raw as aux_12v_uv. Alive even in hibernation, unlike everything else here |
| Motor Temperature | Every entry (MotTemp, °C) | Post-state field 2 ([s+17], uint32 LE °C). The parser calls this channel pack_temp_low and ingest persists it as motor_temp_c — one byte, two names |
| Controller Temp | Every entry (CtrlTemp, °C) | Post-state field 4 ([s+25], uint32 LE °C) |
| Ambient Temp | Every entry (AmbTemp, °C) | Post-state field 3 ([s+21], uint32 LE °C) |
| Pack Temperature | Every entry (hi/lo, °C) | Post-state field 1 ([s+13], uint32 LE °C) — the parser's pack-high. The pack-low channel is field 2, which ingest stores as motor temperature, so only one pack figure survives to the charts |
| Torque | Not available | Not recorded. Derived (kW × 9549 / rpm) only on 538XX firmware, which has an RPM channel |
| Motor Amps | Every entry (MotAmps) | Not recorded in a native Gen3 record. The 538XX firmware variant does carry it — int16 amps at record[26], right after the module bitmask — so an FXE or an FX writing 538XX has this channel and an SR/F does not |
| Power (kW) | Derived (V × BattAmps) | Derived from BMS voltage × BMS current (the MBB pair would give the same product, but is not read) |
| Discharge Limit | Not available | 0xFD ASCII records (watts, stored as kW) |
| Charge Limit | Not available | 0xFD ASCII records (watts, stored as kW) |
| Controller Active | Not available | Record byte 17: 0x00=active (144/144 RUN records), 0x6A=standby (408/408 PWSU, 397/417 HIB), 0x60=transition. The decoder indexes it as binary_data[11] because its loader hands over the record from byte 6 onwards |
| Bike State | Derived from event type | ASCII [s:s+9], null-padded. The parser knows ten tokens — RUN/STOP/HIB/STRT/PWSU/CHRG/WAIT/WAKE/REV/PARK. The last two were added in 2026-08; before that they were decoded as records with no state and their telemetry was dropped |
| Error Codes | Text events | Fault pending/cleared ASCII records |
| Writes while switched off | Main board writes nothing at all. The BMS wakes about once a day — 86,401 s between blocks in the parked window measured here | Every hour, for ever. The main board declares 3,600 s of sleep and keeps to it to the second; with the 91 s it spends awake the true period is 3,691 s, so about 23 entries a day in a locked garage. This is what fills the ring buffer in roughly three days |
| Ride Mode | Not recorded. No Gen2-format file in this corpus contains a ride-mode string of any spelling; `Mods` on a riding line is the battery-module presence bitmask, not a mode | Recorded, but only as an ASCII event when it changes: `Changing riding mode to 3, SPORT`. Sparse — 8 events across 2 of the 9 native Gen3 MBB files here (tokens seen: 1 ECO, 3 SPORT, 4 ARM, 4 Full). No decoder extracts it, so the app's riding-mode field stays owner-declared |
Retracted: [s+29] is not an odometer, and this page said it was
Post-state field 5 was published here as a lifetime odometer in metres, and its behaviour was explained away as jitter. It is not an odometer. Every one of the 1,121 complete 75-byte type-0x51 records in this corpus satisfies v = 5128k + 4864 — a 12-bit ADC with a 5.128 mV least-significant bit over about 21 V of full scale — and read as microvolts the field spans 0.143 V to 13.440 V, which is the band and the unit the firmware's own text events use for the accessory battery ("Stopping 12v charge. DC-DC 302288uV, Battery 13142800uV"). An exhaustive scan of every uint32 window in the record finds no monotone field anywhere except the timestamp itself. There is no odometer in a native Gen3 MBB record at all.
The decoder has been changed to match: it no longer writes odometer_km from this field, and emits it as aux_12v_uv instead. Native-Gen3-MBB-only motorcycles therefore now report no odometer and no per-ride distance rather than a fabricated one. A real odometer does exist on Gen2 and on the 538XX firmware variant — at a different offset, in a different record.
Also corrected: the MBB does carry pack voltage and current — we said it did not
An earlier revision of this page said the three uint32 slots before the state string were mislabelled as voltage, current and speed, and concluded that a Gen3 MBB record contains no electrical data. The first half was right; the conclusion was too strong. Pack voltage is a uint32 little-endian in millivolts at record[34:38] and battery current a signed int32 in milliamps at record[38:42] — thirteen bytes further into the record than the slot that was mislabelled, so these are different bytes, not a second reading of the same ones.
The evidence is a population gate and a physical curve, not a correlation. Over the 1,121 complete 75-byte records, [34:38] is non-zero on 144 of 144 RUN records, 5 of 5 REV and 4 of 4 PARK, and on zero of 417 HIB, zero of 408 PWSU and zero of 51 WAIT — populated exactly when the high-voltage system is live, with no exceptions in either direction. The curve is stronger. In a 2021 file — whose telemetry records use the older 71-byte form, so they are outside the 1,121 just counted — 32 consecutive CHRG records show the field climbing monotonically from 102.081 V to 114.075 V over 5.5 hours while the current word holds between −11.6 A and −15.3 A and SOC goes 41 to 100. On the last six records SOC is pinned at 100 while the voltage still rises, 111.756 V to 114.075 V. That is a constant-voltage absorption phase; no padding field and no SOC lookup table produces one. A second file, a different SR/F, repeats it over 15 records (106.605 V to 114.894 V, SOC 60 to 100). Integrating V·I over the SOC change gives about 13.6 kWh and 12.4 kWh of full-pack equivalent, against a pack that is 14.4 kWh maximum and 12.6 kWh nominal.
The scope caveat is severe. Every dynamic datapoint above comes from a 538ZF-prefix VIN, the 2020-2023 SR/F platform. The one true PAG-WMI motorcycle here, a DSR/X, contributes 278 type-0x51 records with 18 of them populated, one RUN record and no CHRG records at all, so not a single conservation test can be run on that hardware. The alternative — that the layout is shared but DSR/X-era firmware populates it only in states this corpus barely contains — is not excluded.
And the Pearson correlation this page used to cite is retired. It argued for a different word, record[21:25], from r = 0.913 against BMS pack voltage — one motorcycle, one file pair. Re-run on that same pair (129 RUN records matched to a BMS row within 60 seconds), seven uint32 windows in the record score |r| ≥ 0.90 against BMS pack voltage, and the winner is not record[21:25]: the word at offset 30 scores 0.925 against its 0.907, and that is four different bytes again. A correlation does not identify an offset, and treating it as though it does is precisely the mistake that put a 12 V rail on this page as an odometer.
What settles record[21:25] instead is arithmetic: it is a multiple of 100 mV on 281 of its 281 non-zero readings, where record[34:38] is on 2 of 302 — the same quantity at a hundredth of the resolution. And it is zero on all 54 CHRG records here while record[34:38] is populated on 47 of them. Prefer record[34:38].
None of this changes what the app shows you today: our reader still extracts no volts, amps or km/h from a native Gen3 MBB record, and the BMS file remains the electrical source. The full measurement is on the format specification.
Gen2 Advantage
Complete telemetry in every entry. SOC, motor RPM, motor amps and all temperatures are always available, from one file, and so is a real odometer. (Not the ride mode — no Gen2-format file in this corpus records one; see the field table above.) High data density: 6,713 to 9,229 entries in the eight MBB dumps measured for this page. Simple text parsing.
Gen3 Advantage
Event-driven, so idle time costs few entries. Exact state transitions are recorded rather than inferred. The BMS side is higher-resolution than Gen2: millivolt cell minimum and maximum, an IR-compensated open-circuit voltage, milliamp current, and a measured pack voltage. Genuinely Gen3-only: power limits in watts, cell OCV, the controller-active flag.
Record type bytes, the full offset map, the 538XX firmware variant and the calibration evidence behind each field live on the Gen3 log format specification.
State Machine & Session Detection
Gen2: Mode-Based
Every entry has a clear mode label. Session detection is straightforward:
Gen3: State Machine
Uses a state machine with transitions. Session detection requires pattern matching:
Those eight are the states that actually appear in quantity here; REV and PARK are read too, on the one DSR/X in this corpus. What the files actually contain is lopsided: of the 1,121 complete 75-byte telemetry records here, 417 are HIB and 408 PWSU, against 144 RUN, 51 WAIT, 44 WAKE, 41 STOP and 7 CHRG. STRT never appears on one. Nine more records carry two tokens the parser does not know — REV five times and PARK four, all on the same DSR/X — and it discards them, which is a small loss worth fixing: all nine carry a populated pack voltage (102.561 V to 102.630 V).
Gen3 Session Detection Signals
Since Gen3 doesn't have clear "Riding" labels, we reconstruct sessions from multiple signals:
BMS Log Differences
Gen2 BMS
Text-based entries with clear labels:
// A real Gen2 BMS entry, as decoded
Discharge level 2017-06-14 09:06:22
SOC 92%, current 8A, amp_hours 2,
cell low 3.994V unloaded 4.013V high 4.0V,
balance 6mV, pack_temp 26C, bms_temp 35C,
pack_voltage 111.923V, mode Bike On
- Cell voltages (high, low) in every entry
- SOC (State of Charge) always present
- Pack and board temperatures
- Cell balance delta (mV)
- Charging/Discharging/Balancing modes
Gen3 BMS decoded
Binary records delimited by 0xB2. The 52-byte type-0x4B record is the primary telemetry source for the whole bike:
Two sibling records carry the same fields at a shifted offset — the 70-byte 0x4C at +4 and the 78-byte 0x4D at +8. Reading them at the 0x4B offsets lands in a pad block and the cell-voltage sanity check drops the row, which is what used to happen: dispatching on the type byte instead took the decoder from 1,625 telemetry rows to 2,319 across these seven files, +42.7%.
0xB2 + 1B length + 1B type (0x4B) + 4B Unix timestamp (LE) + 1B + 44B payload
Payload[13-14]: CvLow uint16 LE (mV)
Payload[15-16]: Cell OCV low uint16 LE (mV)
Payload[17-18]: CvHigh uint16 LE (mV)
Payload[19]: SOC uint8 (0-100%)
Payload[20-21]: Current int16 LE (mA, neg=charge)
Payload[38-40]: Pack voltage uint24 LE (mV, measured)
Payload[43]: Temperature uint8 (C)
- Cell voltages min/max (mV) - CC/CV profile visible
- SOC 0-100% (0 = BMS sleeping, actual value when active)
- Current in mA: charging ~-15A CC, taper in CV phase
- Pack voltage is measured, not inferred from the cells
- Charging session detection with power calculation
The Ring Buffer Problem
Gen3 uses a ring buffer (circular buffer) for log storage. This has significant implications for data analysis:
Data Retention: A Parked Bike Fills It in About 3 Days
A ring-buffer bike (SR/F, SR/S, DSR/X — not the FXE, which writes the Gen2 format) that sits parked overwrites its whole MBB ring buffer in roughly 3 days. Here's why:
- 1.The ring buffer holds 2,238-2,535 records in 131,200 bytes of flash (length-chain framing, eight full-size MBB dumps, 19,089 pooled)
- 2.Even when parked, the bike wakes up every ~1 hour for a STRT→PWSU→HIB cycle — 444 of the 462 hibernation markers in those files declare a 3,600-second sleep
- 3.Each wake-up cycle writes a median of 31 records (1,680 on-disk bytes, 3,691-second median interval), over the 416 inter-marker cycles here whose gap really is about an hour — a pair of consecutive hibernation markers 3,000 to 4,200 seconds apart. Counting only the markers that declare 3,600 seconds gives 411 cycles and the same three medians. (An earlier revision of this page said 426, which reproduces under neither rule.)
- 4.That is ~744 records/day: 131,200 ÷ 1,680 = 78 hourly cycles, so the buffer wraps in ~3.25 days by byte count and 3.0-3.4 days by record count, overwriting all older data
- 5.Do not assume the BMS file reaches back as far. Across the six same-bike, same-export MBB/BMS pairs here the BMS window runs anywhere from 0.19× to 20.9× the MBB window (median 1.02×), so which buffer runs out first is not fixed — download both together. (There are seven same-day pairs in the corpus; the six that give these ratios are the ones downloaded in the same session, which is the comparison that means anything.)
To capture ride history, download your logs within 1-2 days of riding. If you wait longer, the ride data will likely be overwritten by hibernation cycles.
Three days is how fast the buffer turns over while that hourly cadence is running, not the span you will see in every file. The cadence does not run for ever: eighteen of those 462 markers declare a 31,536,000-second (one-year) sleep instead, and the eight MBB dumps measured here span anywhere from 3.1 to 336 days of wall-clock time. So a Gen3 log can reach back nearly a year and still contain almost nothing but wake-up cycles.
How it works
The log has a fixed capacity (2,238-2,535 records in the dumps here). When full, new records overwrite the oldest. This means the file contains records from different time periods, and they are NOT in chronological order.
// Ring buffer visualization (simplified)
File position: [oldest ... wrap point ... newest]
Timestamps: [Jan 15 ... Dec 3 ... Jan 14]
^ write pointer wraps here
Consequences
- Entries must be sorted by timestamp before analysis
- Old riding data gets overwritten by newer hibernation cycles
- A "stored" bike that keeps waking hourly fills the buffer in about 3 days, losing all ride history
- There is no odometer in a native Gen3 telemetry record to interpolate across the wrap point. The bike's real odometer reaches us from the summary block, from a Gen2 text entry or from the 538XX firmware variant — never from this record
- Some timestamps are corrupt. The Gen3 framers reject any record whose uint32 at offset 3 falls outside 2019-01-01 (Unix 1546300800, the SR/F launch) to today + 365 days
MBB vs BMS Data Sources
No shipping decoder gets voltage or current out of a Gen3 MBB record, and there is no speed or odometer in it to get. The words at record[34:38] and record[38:42] do hold pack voltage and battery current while the pack is live, but nothing reads them and they are zero in the states that fill most of a parked bike's buffer. In practice the BMS (Battery Management System) log is the electrical source, and that is why the app asks for both files: upload only the MBB from a Gen3 bike and every electrical chart is empty, however complete the ride list looks.
MBB provides:
- State (RUN, STOP, HIB, CHRG...)
- SOC% (dashboard value)
- Temperatures (all four in RUN, sparse elsewhere)
- 12 V accessory rail (microvolts)
- Text events & fault codes
- Pack V & current — present, not read
BMS provides:
- Cell voltages (min/max)
- Battery current (mA)
- Pack SOC%
- Load state & report mode
- Temperature
Real-World Example: a "stored" SR/S
One SR/S dump in this corpus, downloaded on 31 December 2025, frames 2,416 records whose timestamps reach back 336 days. It contains not one RUN record. Of its 154 complete telemetry records, 76 are HIB and 76 PWSU, with one WAIT and one STOP — nearly a year of file, and all of it is the bike waking up hourly to go back to sleep. Any ride taken in those 336 days was overwritten long before the log was pulled.
Retracted, and this is the vivid case. This box used to say the same bike showed "9 different odometer readings, 12,635 – 13,153 km", offered as snapshots from different ring-buffer positions. Those were not kilometres. That file's field 5 takes 11 distinct values from 12.635 V to 13.168 V — the 12 V accessory rail, in microvolts, read as though it were metres. A parked bike's 12 V battery drifting by half a volt is exactly what you would expect to see; a parked bike's odometer moving 518 km is not, and that should have been the tell.
Charging Detection
Gen2
Simple and reliable:
- Entry has mode = "Charging"
- SOC increases over time
- Charge current visible in BMS
- Session ends when SOC stops increasing
Gen3
Requires multi-signal detection:
- State = CHRG (explicit)
- CHARGER LSS events (charger node registration)
- Contactor events (Precharging from module)
- Voltage increase during non-RUN periods (fallback)
Signal 1 sounds decisive and is not: only 7 of the 1,121 complete telemetry records here are in state CHRG. That is why the other three signals exist.
Decode status: the seven rows that matter
The Gen3 format is not officially documented, and nothing on this page comes from Zero Motorcycles. Seven entries carry most of the practical consequences. Counts below are measured over eight full-size native-Gen3 MBB dumps from five motorcycles (three SR/F, one SR/S, one DSR/X) plus a ninth truncated dump from a sixth, and seven Gen3 BMS files from those same five, all decoded through the shipping parser for this page.
| Area | Status | Notes |
|---|---|---|
| MBB voltage / current | partial | Present in the record, read by nothing. uint32 LE mV at record[34:38] and int32 LE mA at record[38:42], non-zero on 144 of 144 RUN records and 0 of 417 HIB, 0 of 408 PWSU, 0 of 51 WAIT. A charge series read from them shows a textbook CV absorption phase. Every dynamic sample comes from SR/F-platform bikes, so it is unconfirmed on DSR/X-era firmware. Use the BMS file |
| MBB speed / RPM / odometer | missing | None of the three exists in a native Gen3 telemetry record. No uint32 window in the record is monotone except the timestamp. The field published here as an odometer is the 12 V rail |
| MBB state machine | decoded | 9-byte null-padded ASCII at record[42:51]: RUN/STOP/HIB/STRT/PWSU/CHRG/WAIT/WAKE, plus REV and PARK which the parser does not yet accept. Anchors every other offset — byte 42 on all 1,356 type-0x51 records here |
| MBB temperatures | partial | Four uint32 post-state slots at [s+13]/[s+17]/[s+21]/[s+25]. The parser names them pack-high, pack-low, ambient and controller; ingest persists the pack-low slot as motor temperature. RUN populates all four in 144 of 144 records, but "zero unless awake" is a tendency, not a rule: HIB fills the first pair on 20 of 417 records (4.8%) and PWSU the second pair on 34 of 408 (8.3%) |
| MBB 12 V rail | decoded | Post-state field 5 at [s+29], uint32 LE microvolts, emitted as aux_12v_uv. All 1,121 complete telemetry records land on a 5,128 uV lattice — a 12-bit ADC over ~21 V — spanning 0.143 V to 13.440 V. This is the field this page published as an odometer in metres |
| BMS pack voltage | decoded | Measured uint24 LE mV at payload[38:41]. 2,189 of the 2,319 emitted rows (94.4%, per file 92.1-96.5%) fall inside 28 x CvMin .. 28 x CvMax; the cell-average x28 estimate is only the fallback |
| BMS state code | partial | payload[24], and still opaque. 37 distinct values across those 2,319 rows; 0x00-0x23 covers 2,276 of them, plus 19 rows of 0xF0 and 24 of 0xFF, the flash sentinels. 0x0B skews unloaded (516 of 603 rows) and 0x0C loaded (256 of 566), but no enum survives testing: the four codes once published as idle/active/unlock/precharge cover only 58.6% of rows, and an unlabelled 0x10 (69 of 111 rows under load, 62.2%) ranks above 0x0C on the same load ordering |
The remaining rows — every MBB and BMS record type byte, the pre- and post-state header layout, the 29-byte 0x54 status record, the ASCII event taxonomy, the 78-byte 0x4D health record with its full-charge-capacity register, the 50-byte FST Mono variant and the 538XX firmware variant — are on the Gen3 log format specification, which carries the calibration evidence for each one.
Database Storage & Parsing Pipeline
When a log file is uploaded, a background worker parses it and stores entries in PostgreSQL (with TimescaleDB for time-series optimisation). The storage strategy differs by generation. Detected sessions are written to two VIN-keyed tables, unified_ride_sessions and unified_charging_sessions, each linked to its source files through a many-to-many table rather than a foreign key — one session can be reconstructed from several overlapping log files, which is exactly what a wrapping ring buffer produces.
Gen2 Parsing Flow
- Parse text-based entries (structured key:value pairs)
- Map event type (Riding/Charging/etc.) to bike_state
- Extract all metrics directly from text fields
- Calculate speed from RPM, power from V×I
- Store with log_generation = 2
- Detect ride/charge sessions from event patterns
Dedicated columns:
pack_voltage_v, battery_amps, motor_rpm,
speed_kmh, odometer_km, motor_temp_c,
controller_temp_c, ambient_temp_c, soc,
power_kw, bike_state
Gen3 Parsing Flow
- Frame 0xB2 records and validate the timestamp at offset 3 before trusting the record
- Anchor MBB offsets on the ASCII state string; read BMS payloads at fixed offsets
- Extract the state machine value as bike_state
- Convert units (mV→V, mA→A, metres→km, W→kW)
- Parse ASCII records for power limits, faults and state transitions
- Store with log_generation = 3
Gen3-exclusive columns:
bike_state, controller_active,
discharge_limit_kw, charge_limit_kw,
cell_voltage_ocv_min, is_under_load,
bms_state_code
Shared columns (same as Gen2):
pack_voltage_v, battery_amps, soc,
power_kw — from the BMS file
motor_temp_c — from the MBB
speed_kmh, motor_rpm, torque_nm,
odometer_km — never populated
from a native Gen3 MBB record
Gen3 Entry Types in Database
Gen3 MBB entries are heterogeneous: not every entry carries every field, and no MBB entry contributes electrical data to the database. Charts that look sparse on a Gen3 report are usually showing this, not a parsing failure:
MBB 0x51 telemetry (75 B)
bike_state, soc, four temperatures (all four in every RUN record, a few percent of HIB and PWSU ones), controller_active, and aux_12v_uv in the raw-fields blob. No speed, no RPM, no odometer; the voltage and current words are in the record but are not extracted
MBB 0xFD ASCII (variable)
discharge_limit_kw, charge_limit_kw (BMS capability reports), state changes, faults, key/kill switch, kickstand, contactor events
BMS 0x4B telemetry (52 B)
pack_voltage_v, battery_amps, power_kw, soc, cell min/max/OCV, temperature, is_under_load, bms_state_code
Where the byte-level detail went
This page used to end with three collapsed deep-dives. They have been removed rather than patched. Of the 22 rows in their MBB offset table, more than half named the wrong field, two pointed past the end of a 75-byte record, and one mistook a byte of the Unix timestamp (0x69, the high byte of every timestamp between late October 2025 and May 2026 — 14,207 of the 19,089 records framed here) for a record-type marker. A second, contradictory byte map on the same domain is worse than none. The full record-by-record Gen3 specification now lives at /learn/gen3-log-format.
- Gen3 MBB binary payload. Framing rules, the 0x51 / 0x54 / 0xFD record types, the pre- and post-state layout and what each slot actually holds: read the MBB record spec.
- Gen3 BMS binary. The 52-byte 0x4B telemetry record, the 78-byte 0x4D health record and its full-charge-capacity register, the 70-byte 0x4C and the 50-byte FST Mono variant: read the BMS record spec.
- Gen3 session detection. How rides and charges are reconstructed from state transitions, ASCII boundary events and time gaps once the records are sorted: read the session-detection section.
For how long a Gen3 ring buffer keeps your rides before overwriting them, see Why your Gen3 log shows no rides.
Sources:
- zero-log-parser (Python library) — the Gen2 JSON pipeline this app builds on
- zero-log-parser: LOG_STRUCTURE.md — the community record-layout notes
- zero-log-parser issue #16 — Gen3 (SR/F) log support
- ZeroManual: Interfacing
- Zero Motorcycles Specifications
- NHTSA vPIC — VIN decoding, including the 10th-character model-year code
How much of this is actually known. Nothing here comes from Zero Motorcycles. The Gen3 format is reverse-engineered from production log files, and the honest summary is uneven: the BMS record is largely decoded — cell minimum and maximum, open-circuit voltage, SOC, current, measured pack voltage, temperature, load and report mode all read out and cross-check against each other. The MBB record is not. It yields a state, an SOC byte, four temperature slots, a 12 V rail and a pack-voltage/current pair that nothing reads; the rest of its 75 bytes is either zero or unidentified, and it has no odometer, no speed and no RPM. Between February and July 2026 four fields on this page were found to be mislabelled, two were fictional, and one — the "odometer" — was both wrong and being written into the database; that claim has been retracted and the decoder changed. The figures that replaced them were measured on 1,121 MBB and 2,319 BMS telemetry records from five motorcycles (a sixth contributes a truncated dump), through the shipping parser rather than a re-implementation. If you can falsify any of it with your own logs, please say so — that is how every error above was found. Last verified: 30 July 2026 against backend/app/parser.py.