Reference for parser and command builder implementation. Covers the older
InMotion family that pre-dates the V14 / V13 / V12 / V11 / P6 wire format
(known here as "InMotion V2", in inmotion_v2.md).
In-scope models: V8, V8F, V8S, V10, V10F, V10S, V10SF, V10T, V10FT, V5 / V5F / V5PLUS / V5D, L6, Lively, Glide 3 (Solowheel rebrand of V8), plus the older R-series (R1, R10, R2, R0) and V3 generation that share the framing. Production wheels still seen on this protocol are V8 / V8F and the V10 line; everything else is legacy.
Original prose. No third-party GPL code is reproduced here. See
Attribution at the end. Status: research-grade - CAN-over-BLE framing
and the 0x0F550113 fast info ID are solid, but slow-info offsets
and per-model shifts are firmware-dependent. Flagged.
InMotion V1 advertises two proprietary services. Notifications come on one service, writes go to the other.
| Role | UUID (128-bit) | Short |
|---|---|---|
| Notify service | 0000ffe0-0000-1000-8000-00805f9b34fb |
0xFFE0 |
| Notify characteristic (wheel -> phone) | 0000ffe4-0000-1000-8000-00805f9b34fb |
0xFFE4 |
| Write service | 0000ffe5-0000-1000-8000-00805f9b34fb |
0xFFE5 |
| Write characteristic (phone -> wheel) | 0000ffe9-0000-1000-8000-00805f9b34fb |
0xFFE9 |
| CCCD descriptor | 00002902-0000-1000-8000-00805f9b34fb |
standard |
Notes:
- This is not the same shape as V2. V2 (V14 etc.) uses Nordic UART
6e400001/02/03, single service. V1 uses the older HM-10-style0xFFExset split across two services, with read and write on different characteristics. - KingSong / Begode / Veteran adapters also use UUIDs in the
0xFFE0family. Disambiguate by BLE name and by the magic header on the first frames received (see section 3). - Wheels also expose standard
0x180A(Device Info) and0x180F(Battery), plus several unused legacy services (0xFFA0,0xFFB0,0xFFC0,0xFFD0,0xFF90,0xFE00,0xFC60). A parser does not need to discover those.
Procedure on connect: discover services (confirm 0xFFE0 + 0xFFE5),
subscribe to 0xFFE4 (CCCD 0x01 0x00), send PIN if required (section
7), send GetSlowInfo to learn the model code, then run a GetFastInfo
keep-alive loop.
Advertised names observed:
| Pattern | Examples | Notes |
|---|---|---|
V<digits> or V<digits><suffix> |
V8, V8F, V10, V10F, V10F-AB12 |
Most common on V8 / V10 generation |
Inmotion-<...> |
Inmotion-V8, Inmotion-V10F |
Some firmwares prepend the brand |
IM<digits> |
IM01234, IM987654 |
Older R-series and some rebrands |
L6-<...> |
L6-XXXX |
L6 model |
Solowheel <...> or Glide<...> |
Glide 3, Solowheel Glide-3 |
Solowheel-branded V8 hardware |
Detection strategy:
- Probe for service
0xFFE0containing notify characteristic0xFFE4AND service0xFFE5containing write characteristic0xFFE9. - If only
0xFFE0+0xFFE1are present, the device is KingSong / Begode / Veteran, not InMotion V1. - The advertised name alone is not authoritative. After connect, send
GetSlowInfoand read back the model code from the response (section 5).
V1 wraps an 8-byte CAN-bus frame in a BLE envelope with byte stuffing.
offset 0 1 2 ... N-3 N-3 N-2 N-1
AA AA <escaped CAN frame> CK 55 55
| Field | Size | Meaning |
|---|---|---|
| Header | 2 bytes | 0xAA 0xAA literal start-of-frame |
| Body | variable | Escaped CAN frame, see below |
| Checksum | 1 byte | sum(unescaped CAN bytes) mod 256 |
| Trailer | 2 bytes | 0x55 0x55 literal end-of-frame |
The bytes 0xAA, 0x55 and 0xA5 cannot appear unescaped inside the
body or checksum. Each occurrence is prefixed with 0xA5.
| Raw byte | On the wire |
|---|---|
0xAA |
0xA5 0xAA |
0x55 |
0xA5 0x55 |
0xA5 |
0xA5 0xA5 |
| any other | unchanged |
The checksum byte itself is escaped on the same rules. The header and
trailer pairs are always literal 0xAA 0xAA and 0x55 0x55 and are
written without escaping.
Every V1 BLE packet carries a fixed 16-byte CAN prefix, optionally followed by an extended payload:
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | u32 LE | CAN ID | 29-bit extended CAN ID, see section 3.3 |
| 4 | 8 bytes | data | CAN payload; semantics depend on ID |
| 12 | u8 | len | 8 for normal frames, 0xFE for extended frames |
| 13 | u8 | ch | Channel; 5 for all phone-to-wheel commands |
| 14 | u8 | format | 0 = standard, 1 = extended (29-bit ID) |
| 15 | u8 | type | 0 = data frame, 1 = remote (request) frame |
When len == 0xFE, the frame is an extended packet: the 8 data
bytes at offset 4-11 are reinterpreted as a header, and additional
payload follows the 16-byte prefix.
For extended frames (len == 0xFE): the standard 16-byte CAN prefix is
followed by L payload bytes, where L is u32 LE written at offset
4-7 of the prefix (i.e. inside the data slot). Total wire length is
16 + L unescaped bytes plus framing.
Fast-info replies (0x0F550113) ship ~76 bytes (offsets up to 76
stable; some firmwares ship 80+). Slow-info replies (0x0F550114)
ship 132-144 bytes; offsets above 124 are optional and firmware-
dependent.
All IDs are 32-bit values written little-endian at offset 0 of the CAN
frame. The high byte distinguishes telemetry (0x0F) from alerts
(0x0F78...). Only the IDs below are exchanged in normal operation.
| ID | Direction | Meaning |
|---|---|---|
0x0F550113 |
both | Fast info: phone requests, wheel replies (extended) |
0x0F550114 |
both | Slow info / settings dump: phone requests, wheel replies (extended) |
0x0F550115 |
phone -> wheel, ack back | Ride-mode group: max speed, classic/comfort, pedal sensitivity, tilt |
0x0F550116 |
phone -> wheel, ack back | Remote control group: LED, beep, power off |
0x0F550119 |
phone -> wheel, ack back | Wheel calibration |
0x0F55010D |
phone -> wheel, ack back | Headlight on/off |
0x0F55012E |
phone -> wheel, ack back | Handle / lift sensor button |
0x0F550609 |
phone -> wheel, ack back | Play sound by index |
0x0F55060A |
phone -> wheel, ack back | Speaker volume |
0x0F550307 |
phone -> wheel, ack back | PIN / password |
0x0F780101 |
wheel -> phone | Asynchronous alert (tiltback, low battery, etc.) |
Acks for command IDs come back with the same ID; data[0] == 0x01 means
success, 0x00 means failure.
Sent by the wheel after the phone issues a GetFastInfo request
(CAN ID 0x0F550113, len 8, ch 5, data FF FF FF FF FF FF FF FF).
The reply is an extended frame; offsets below are into the extended
payload (i.e. starting at offset 16 of the inner CAN frame), not into
the BLE packet.
All multi-byte integers are little-endian.
| Offset | Size | Field | Units / scale |
|---|---|---|---|
| 0 | i32 LE | Pitch / lean angle | raw / 65536, in degrees |
| 4 | i32 LE | (Reserved / motion vector) | Unconfirmed |
| 8 | i32 LE | (Reserved / motion vector) | Unconfirmed |
| 12 | i32 LE | Speed sample A | see section 4.1 |
| 16 | i32 LE | Speed sample B | see section 4.1 |
| 20 | i32 LE | Current (signed) | raw / 1000, in amps |
| 24 | u32 LE | Voltage | raw / 100, in volts |
| 28 | (4 bytes) | Reserved | Unconfirmed |
| 32 | u8 | MOS / motor temperature | raw, in degrees C |
| 33 | u8 | Reserved | |
| 34 | u8 | IMU / board temperature | raw, in degrees C |
| 35 | u8 | Reserved | |
| 36 | (8 bytes) | Reserved | Unconfirmed |
| 44 | u32 LE | Total mileage (most models) | raw, in metres - see section 4.2 |
| 48 | u32 LE | Trip distance | raw, in metres |
| 52 | (8 bytes) | Reserved | Unconfirmed |
| 60 | u32 LE | Work mode / state | see section 4.3 |
| 64 | (8 bytes) | Reserved | Unconfirmed |
| 72 | i32 LE | Roll | raw / 90, in degrees (legacy models only) |
V1 reports two motor-speed samples and the host averages them:
speed_units = (sampleA + sampleB) / (2 * factor)
speed_kmh = abs(speed_units) * 3.6
factor depends on the model code returned in the slow-info reply:
| Models | factor |
|---|---|
| R1S, R1Sample, R0 | 1000.0 |
| Everything else (V5, V8, V10, L6, R1N, R10, R2, V3, etc.) | 3812.0 |
The encoding of byte 44 has drifted across firmware revisions. Pick by model code:
| Models | Field at offset 44 | Conversion |
|---|---|---|
| V5 family, V8, V8F, V8S, Glide3, V10 family, R1 family ("1x"), R2 family ("5x" type) | u32 LE | metres |
| R0 | u64 LE | metres |
| L6 | u64 LE | value * 100 metres (centimetres on the wire) |
| Others (legacy R / V3) | u64 LE | round(value / 5.711016379455429e7) km - then convert to metres |
The conversion constant for the legacy branch is the historically observed value; its derivation is undocumented. Treat older R-series mileage as "approximate; varies by firmware".
Offset 60 is a 32-bit field with two interpretation rules:
Modern wheels (V8F, V8S, V10, V10F, V10T, V10FT, V10S, V10SF):
The high nibble of the low byte ((value >> 4) & 0xF) is the macro state:
| High nibble | Meaning |
|---|---|
| 1 | Shutdown |
| 2 | Drive |
| 3 | Charging |
| other | Unknown |
If (value & 0x0F) == 1, append "Engine off".
Legacy wheels (V5, V8, R-series, V3, L6, etc.):
The low 4 bits map directly:
| Low nibble | Meaning |
|---|---|
| 0 | Idle |
| 1 | Drive |
| 2 | Zero |
| 3 | LargeAngle |
| 4 | Check |
| 5 | Lock |
| 6 | Error |
| 7 | Carry |
| 8 | RemoteControl |
| 9 | Shutdown |
| 10 | pomStop |
| 12 | Unlock |
L6 has a separate ride-mode and lock-state interpretation that does not overlap with the V8 / V10 work-mode field. From the legacy work-mode byte:
(byte & 0x0F) != 0: BLDC mode; otherwise FOC mode.(byte & 0xF0) != 0: locked; otherwise unlocked.
V1 wheels do not report battery percentage on the wire. The phone derives it from voltage. Per-model curves (input volts in V, output 0.0 to 1.0):
V8 / V8F / V8S / Glide3 (84 V pack):
>=82.5 V -> 1.0; >68.0 V -> (volts - 68.0) / 14.5; else 0.0.
V10 family (84 V pack, "better" curve):
>83.5 V -> 1.0; >68.0 V -> (volts - 66.5) / 17; >64.0 V ->
(volts - 64.0) / 45; else 0.0.
Default (V5, R-series, others): piecewise linear with breakpoints at 82.0, 77.8, 74.8, 71.8, 70.3, 68.0; exact slopes vary by 1-2 percent across the public references.
Sent by the wheel in response to GetSlowInfo (CAN ID 0x0F550114, len
8, ch 5, type 1 (remote frame), data all 0xFF). The reply is an
extended frame; offsets are into the extended payload.
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 8 bytes | Serial number | Big-endian byte order, hex-formatted |
| 24 | u16 LE | Firmware version field 2 | minor part |
| 26 | u8 | Firmware version field 1 | mid part |
| 27 | u8 | Firmware version field 0 | major part - displayed f0.f1.f2 |
| 56 | u32 LE | Pedal angle / horizon | round(value / 6553.6) -> tenths of a degree |
| 60 | u16 LE | Max speed setting | raw / 1000, in km/h |
| 80 | u8 | Headlight | 1 = on, 0 = off |
| 104 | u8 | Model code low digit | see section 5.1 |
| 107 | u8 | Model code high digit | see section 5.1 |
| 124 | u8 | Pedal sensitivity | (raw - 28) & 0xFF; 0x80=100% (max), 0x20=min |
| 125 | u16 LE | Speaker volume | raw / 100 |
| 129 | u8 | Handle button | 1 = disabled, != 1 = enabled (inverted) |
| 130 | u8 | LED (under-glow) | 1 = on |
| 132 | u8 | Ride mode | 1 = classic, 0 = comfort |
Offsets 125, 129, 130, 132 are only present on firmwares that ship a slow-info payload longer than 124 / 126 / 129 / 132 bytes respectively. Older V8 firmware tops out at ~120 bytes and omits all of these.
The wheel reports its model as ASCII digits at offsets 104 and 107.
Concatenate as [byte107 if > 0][byte104] and look up:
In-scope models (the rest is legacy R / V3 / sample hardware not seen in the wild on current firmware):
| Code | Model |
|---|---|
30 |
R0 |
50 |
V5 |
51 |
V5PLUS |
52 |
V5F |
53 |
V5D |
60 |
L6 |
61 |
Lively |
80 |
V8 |
85 |
Glide 3 (Solowheel) |
86 |
V8F |
87 |
V8S |
100 |
V10S |
101 |
V10SF |
140 |
V10 |
141 |
V10F |
142 |
V10T |
143 |
V10FT |
Legacy R-series codes 0 through 7 (R1N/R1S/R1CF/R1AP/R1EX/R1Sample/
R1T/R10), V3 codes 10-13 (V3/V3C/V3PRO/V3S), and R2 codes 20-24
also exist. If the wheel reports an unknown code, fall back to V8
telemetry layout.
All commands are CAN frames with len = 8, ch = 5, format = 0,
type = 0 (data frame) unless noted. The 8-byte data payload follows.
| Command | CAN ID | data[0..7] |
|---|---|---|
| Request fast info (poll) | 0x0F550113 |
FF FF FF FF FF FF FF FF |
| Request slow info (settings dump) | 0x0F550114 |
FF FF FF FF FF FF FF FF, type=1 (remote) |
| Request battery cell levels | 0x0F550114 |
00 00 00 0F 00 00 00 00, type=1 |
| Request firmware version | 0x0F550114 |
20 00 00 00 00 00 00 00, type=1 |
| Headlight on | 0x0F55010D |
01 00 00 00 00 00 00 00 |
| Headlight off | 0x0F55010D |
00 00 00 00 00 00 00 00 |
| Decorative LED on | 0x0F550116 |
B2 00 00 00 0F 00 00 00 |
| Decorative LED off | 0x0F550116 |
B2 00 00 00 10 00 00 00 |
| Beep / horn (V8F / V8S / V10x) | 0x0F550116 |
B2 00 00 00 11 00 00 00 |
| Beep / horn (V8 / V5F: play sound 4) | 0x0F550609 |
04 00 00 00 00 00 00 00 |
Play sound n |
0x0F550609 |
nn 00 00 00 00 00 00 00 |
| Power off | 0x0F550116 |
B2 00 00 00 05 00 00 00 |
| Wheel calibration | 0x0F550119 |
32 54 76 98 00 00 00 00 |
| Set max speed | 0x0F550115 |
01 00 00 00 hi lo 00 00 where lo,hi = (kmh * 1000) LE |
| Set ride mode (1=classic, 0=comfort) | 0x0F550115 |
0A 00 00 00 cc 00 00 00 |
| Set pedal sensitivity | 0x0F550115 |
06 00 00 00 hi lo 00 00 where lo,hi = ((s + 28) << 5) LE |
| Set pedal tilt (horizon) | 0x0F550115 |
00 00 00 00 b3 b2 b1 b0 where b0..b3 = (deg10 * 65536 / 10) LE |
| Set speaker volume | 0x0F55060A |
lo hi 00 00 00 00 00 00 where lo,hi = (vol * 100) LE |
| Handle / lift button enable | 0x0F55012E |
00 00 00 00 00 00 00 00 |
| Handle / lift button disable | 0x0F55012E |
01 00 00 00 00 00 00 00 |
| Send PIN / password | 0x0F550307 |
6 ASCII digits + 00 00, type=0 |
CAN frame (16 bytes, unescaped):
0D 01 55 0F 01 00 00 00 00 00 00 00 08 05 00 00
[--ID LE--] [-----data[0..7]-----] len ch fmt typ
Checksum: 0x0D + 0x01 + 0x55 + 0x0F + 0x01 + 0x08 + 0x05 = 0x80.
No bytes in this frame need escaping. Wire packet (21 bytes):
AA AA 0D 01 55 0F 01 00 00 00 00 00 00 00 08 05 00 00 80 55 55
30 * 1000 = 30000 = 0x7530 -> lo=0x30, hi=0x75. The adapter writes
value[1], value[0], i.e. hi lo order in the data slot. The 0x55
byte that lands at data offset 4 is escaped on the wire as A5 55.
Some firmwares (notably V10F retail builds and later V8F builds) require a 6-digit PIN before they will reply to control commands. The wheel still streams telemetry without the PIN, so basic read-only display works without authenticating; commands silently fail.
Procedure:
- The phone must know the 6-digit PIN out of band (printed on the wheel, set in the original InMotion app, or stored from a previous session).
- Send the password command up to six times until the wheel replies
with CAN ID
0x0F550307. The reply is a single ack frame; once received, treat the session as authenticated and stop sending. - If the wheel does not reply, commands will not take effect. There is no negative ack.
Wire format: PIN as 6 ASCII bytes ('1','2','3','4','5','6') followed
by two zero bytes, sent on CAN ID 0x0F550307.
Wheels without a PIN configured accept and ignore the password command, so it is safe to always send it on first connect when the PIN is known.
| Family | Work mode | Horn | DRL | Volume | Ride mode | Mileage offset 44 | Battery curve |
|---|---|---|---|---|---|---|---|
| V8 | modern | playSound(4) | yes | maybe | no | u32 LE m |
V8 |
| V8F, V8S | modern | dedicated | yes | yes | yes | u32 LE m |
V8 |
| Glide 3 | modern | dedicated | yes | yes | no | u32 LE m |
V8 |
| V10 family | modern (roll unreliable) | dedicated | yes | yes | yes | u32 LE m |
V10 |
| V5 family | legacy | playSound only | no | no | no | u32 LE m |
default |
| L6 | legacy + L6 lock/BLDC bits | playSound only | no | no | no | u64 LE * 100 m |
always 0 |
| R-series / V3 | legacy | playSound only | no | no | no | u64 LE / 5.711e7 km |
default |
Notes:
- V10 family roll field at offset 72 is unreliable; treat as
0. - Speed factor is
1000.0for R1S / R1Sample / R0;3812.0for everything else. - Using the V8 battery curve on a V10 over-reports battery by 5-10 percent at the top end.
- L6 odometer should be treated as approximate; the centimetre-as-u64 encoding holds for the firmwares we have captures for, but other L6 batches may differ.
WheelCapabilities defaults for a generic V1 wheel before model is known:
| Field | Default | Notes |
|---|---|---|
hasHorn |
true |
V8 / V5F / L6 / R-series / V3 use playSound(4) instead of the dedicated horn command |
hasLight |
true |
All V1 wheels expose the headlight command |
hasLock |
false |
No remote lock command. Lock state is observable in work mode but not commandable |
hasMaxSpeed |
true |
RideMode CAN ID, sub-command 1 |
hasAlarmSpeed |
false |
No settable alarm speed; alarms are firmware tilt-back triggers reported via the alert frame |
hasVolume |
model-dependent | Yes on V8F / V8S / V10 family / Glide3; no on older V5 / V8 / L6 / R-series / V3 |
hasDRL |
model-dependent | Yes on V8, V8F, V8S, Glide3 and full V10 family; no on V5, L6, R-series, V3 |
needsAuthForLock |
false |
No lock command. Note: if PIN is set, all setting commands need auth; track separately as needsAuthForCommands if needed |
Per-model overrides are summarised in section 8.
- Fast-info offsets 4-11 and 28-31 carry signed 32-bit values on some firmwares (additional motion / motor data). Semantics not documented; capture under controlled riding to label.
- Fast-info offset 76+: one observed branch reads a fourth temperature on V10FT firmware; not present on older builds.
- The
5.711016379455429e7mileage scale for legacy R / V3 wheels has no documented derivation. Roughly2^32 / 75.0, suggesting an internal fixed-point representation, but unconfirmed. Mode.intToMode(mode)(rookie / general / smoothly classification on bits 4-7 of work mode) is not observed to change on V8 / V10 firmware in practice; meaning on R / V3 unverified.- Slow-info offsets 125-132 vary by firmware even within the same model;
always range-check
ex_data.lengthbefore reading. - PIN handshake rate-limit / lockout behaviour on bad attempts is not documented.
Protocol reference (upstream, GPLv3): https://github.com/Wheellog/wheellog.android/blob/master/app/src/main/java/com/cooper/wheellog/utils/InMotionAdapter.java
Cross-checked against forum threads on electricunicycle.org and esk8.news. The CAN ID layout, framing, escape rules, fast / slow info offsets, battery curves and per-model quirks here are restated in this project's idiom; no third-party GPL source code is reproduced.
V14 / V13 / V12 / V11 / P6 use the separate V2 protocol family (Nordic
UART UUIDs, different framing); see inmotion_v2.md and
project_v14_protocol.md for those.