In a stereotypical midlife crisis fashion, I bought my Zero DSR/X in 2023. If you don’t already know, it is a fully electric motorcycle that comes with all of the fancy things you’d expect from a fully electric motorcycle, including its own app. But two parts of the app have never worked well since the beginning: scheduled charging and ride capture.

So I worked out how Zero’s app talks to the bike, and wrote my own. It’s called FST ReGen, and on November 13th, 2026, it was published as a private alpha on Google Play. Because I’m an overachiever, the app has its very own “marketing” page that covers what it does. This post covers how: the bytes on the wire, and why Zero’s app disconnects from the bike partway through a ride.

Unofficial

FST ReGen isn’t affiliated with, or endorsed by, Zero Motorcycles. Zero, Zero Motorcycles, NextGen, DSR/X and Cypher are trademarks of their owners, used here only to say what the app works with.

What the bike hears

Before anything touched a $25,000 motorcycle, I studied Zero’s app for how it frames, sends and reads every setting over Bluetooth LE. The bike talks through Telit’s Terminal I/O service, a serial port over GATT with credit-based flow control, and every packet, in both directions, is framed the same way. Here’s the smallest one, Zero’s app asking the bike which protocol version it speaks:

1f1 f2 f4 f8   start marker
200 00 00 00   body length 0: a request
301 00         packet ID 1, VER
400 00         sequence, always 0
5bd 90 67 f9   CRC-32 of all the above
6f8 f4 f2 f1   end marker

A request has no body, and the bike replies with the same packet ID, filled in. Body lengths count 4-byte words, and Zero’s app never sets the sequence number.

That frame was worked out from Zero’s app before the bike was connected. Then Android’s Bluetooth snoop log recorded Zero’s app connecting, and the frame was there, byte for byte. The CRC is plain zlib, little-endian, so you can check it yourself:

1import zlib
2before_crc = bytes.fromhex("f1f2f4f8 00000000 0100 0000")
3zlib.crc32(before_crc).to_bytes(4, "little").hex(" ")
4# 'bd 90 67 f9'

A write is the same frame with a body. One of the first was where all this started: the scheduled charging screen, and the Update button in Zero’s app. Its body was predicted from the app’s code before I pressed it, and the capture matched:

101               schedule on
22e 0e 2a 04 ×7   Monday to Sunday
300 00 00         padding

Each day is four bytes counted in half-hours. Plug in between 21:00 (slot 2a, 42) and 23:00 (04, four half-hours later), and charging waits until 23:00 (2e, 46), then runs for 7 hours (0e, 14). The bike answered with an empty ACK about 390 ms later.

To see what the bike did with it, I downloaded its event log and decoded it with the community’s zero-log-parser. The bike had logged:

1Setting "sunday_schedule" changed to 772680196

772680196 is 0x2E0E2A04, the four bytes sent for Sunday.

Every write FST ReGen makes was predicted byte for byte the same way before I first pressed the button, and it reads every setting back from the bike to check that it took. The alpha’s send rules allow five writes, the bike’s clock, charge schedule, charge target, dash gauges and ride modes, and refuse everything else.

Just as important is what the alpha leaves alone. It talks to the bike directly, over that same Bluetooth LE link, and never to Zero’s servers, the bike’s cellular modem or the Cypher Store. It changes no unauthorized settings, and makes no attempt to enable any restricted functionality.

Why the connection drops

Ride capture in Zero’s app polls the bike for its live data, and stops for good the first time the Bluetooth link drops. On October 16th I rode with it running and Android’s snoop log recording.

Android connects with a 5-second supervision timeout, which is how long the link can go silent before either end gives up on it. Then, a few seconds in, the bike sends an L2CAP Connection Parameter Update Request on the LE signaling channel. In the spec’s units:

1interval min   12 × 1.25 ms = 15 ms
2interval max   24 × 1.25 ms = 30 ms
3latency         0
4timeout        54 × 10 ms   = 540 ms

Android’s stack grants it within a couple of milliseconds, and the app plays no part. From then on, any radio gap longer than about half a second ends the connection. Counted from the moment that ride connected:

ElapsedWhat happened
0:06The bike’s 540 ms timeout takes effect
15:46Dropped, HCI reason 0x08: supervision timeout. Ride capture stops for good.
30:31Reconnected, 15 minutes later
30:48Dropped again
41:55Reconnected, 11 minutes later
56:25Dropped, and still gone 46 minutes later, when the log ended

The reconnects took that long because, in the background, Zero’s app doesn’t connect to the bike directly. It waits for a scan to find it, and Android throttles scans for apps in the background. The log has a single connection attempt for each reconnect, among 162 scan starts and stops.

Even while connected, Zero’s app asked for ride data every 12 to 41 seconds instead of every second: 291 samples in 15 minutes. The likely cause is the phone going to sleep with nothing holding it awake. FST ReGen records from a foreground service that holds a wake lock.

Asking for a longer timeout

An app gets one control over these parameters, requestConnectionPriority(), and it doesn’t take numbers. It takes a profile, and Android’s Bluetooth stack fills in three parameters for it:

  • the connection interval, how often the phone and the bike meet to exchange packets
  • the latency, how many of those meetings the bike may skip when it has nothing to send
  • the supervision timeout, which is 5 seconds in every profile

Besides one specifically added in Android 14 for digital car keys, Android offers three profiles:

  • Balanced is the default. Android applies it by itself just after connecting, and its documentation describes it as the parameters the Bluetooth SIG recommends.
  • High is meant for moving a lot of data quickly. Its shorter interval has the radios meet more often, which costs power, so Android’s documentation asks apps to drop back to Balanced once the transfer is done.
  • Low power goes the other way, with a longer interval for apps that rarely have anything to send.

Since every profile asks for 5 seconds, the question was which one the bike would accept. Only the first two were tried:

PriorityIntervalLatencyWhat the bike did
Balanced30–50 ms5asked for 540 ms again 5.24 s later
High11.25–15 ms1left it alone

Balanced’s interval sits at or above the top of the bike’s 15–30 ms window, and High’s reaches its bottom. From two changes, the bike seems to check the parameters about 5.2 seconds after any change, and to ask for its own if the interval is outside that window. Ask too early and the bike’s request replaces yours. So FST ReGen waits until the bike’s request has been granted, then asks for High, and stays there for the whole connection, against Android’s advice. Dropping back to Balanced would hand the link back to the bike’s 540 ms.

At 540 ms, the October 16th ride dropped after 16 minutes. At 5 seconds, on October 20th, the link held for a whole 77-minute ride, and didn’t drop until I switched the bike off. Of the 3,811 times FST ReGen asked for the bike’s live data on that ride, 3,783 got a complete reply. On the first four rides at 5 seconds, the link only ever dropped at key-off.

If it does drop, FST ReGen treats it as a pause. It reconnects straight to the bike, with no scan for Android to throttle, and carries on with the same ride.

Tangent

The advice in the Android documentation is about power consumption, so I measured it, and High priority barely registers on the phone. (I assume it’s recommended to save power on a wearable, not a motorcycle with a 17.3 kWh battery.) I rode for 57 minutes with my Pixel 9a’s own power monitor metering each of its supply rails. Wi-Fi was off, so the rail feeding the Wi-Fi and Bluetooth chip carried Bluetooth alone. While riding, the whole Bluetooth link, High priority included, drew 6.1 mW more than with the bike switched off: about 0.03% of the battery per hour, and about 2% of what the phone drew. The real cost is the wake lock that keeps the phone awake to poll the bike, at about 0.6% of the battery per hour. All told, recording a ride with 1.2-second sample intervals, the phone used about 1.3% of its battery per hour of riding.

How it got built

This wouldn’t be a 2026 Cesspool of Knowledge post if we didn’t talk about AI. So, I’ll confess that I didn’t do this alone. Claude Opus 5.5 did most of the tedious work. (I do have a day job, after all.) However, using my real-world debugging skills, I set the goals, iterated on the design and acted as a meat-proxy when it involved touching the bike. Claude did the analysis, the tooling and the decoding, and kept the protocol notes this post comes from.

Every new piece of code ran against both a mock bike and the real one, in the garage and on actual rides. When I finally considered it “feature complete”, I manually reviewed every line of code to make sure it met my engineering standards, polished it lightly, and published it to Google Play.

Join the alpha

FST ReGen’s private alpha release is now available on Google Play. If you ride a Zero with Cypher III and carry a phone running Android 13 or newer, I’m inviting you to test it. It writes settings to your motorcycle, so start with Safety, liability and legal, then follow the four steps under Join the alpha on its page. I personally review and approve every request.

Please send bugs, ideas and things that went wrong to apps@banasiak.com. And Zero, if you’re reading, let me know if you need help with the new MyZero app. I’m happy to discuss a partnership or some other type of collaboration. ❤️