Companion to wheelie-vesc-comms-api.md. Everything here is the vendor-specific
layer that sits on top of COMM_CUSTOM_APP_DATA.
1. Frame shape
Every bike command is one VESC packet:
0x02 len 36 102 <sub> <payload…> crc16 0x03
│ │ │ │
│ │ │ └── FloatPacketTypes sub-command
│ │ └──────── AppPacketTypes.WFB = 102
│ └──────────── COMM_CUSTOM_APP_DATA = 36
└───────────────── length byte
getVescAppData reads bytes 0 and 1 of the payload and returns null unless
byte 0 is 102. Stock Float package boards advertise 101, so this app is a
closed loop with its own firmware package — it won't read a Float or Refloat
board even though it understands the config format.
Two send helpers exist:
writeData(...bytes)— fire and forget, used by the three configure commands.getData(...bytes)— writes and awaits a matching response, used for polling.
Both prepend [COMM_FORWARD_CAN, canId] automatically when
_primaryCANDeviceId is set.
2. Sub-command 100 — BIKE_RIDE_CONFIGURE
Sets the four motor-response parameters as a 3-byte packed payload. Full frame is 6 bytes of payload:
36 102 100 b0 b1 b2
Built by createRideModeCommand(maxCurrent, maxCurrentBrake, rampAlphaPos, rampAlphaNeg):
| Byte | Content |
|---|---|
b0 low nibble |
clamp(round(maxCurrent / 10), 0, 15) |
b0 high nibble |
clamp(round(abs(maxCurrentBrake) / 10), 0, 15) |
b1 |
rampAlphaToByte(rampAlphaPos) |
b2 |
rampAlphaToByte(rampAlphaNeg) |
So currents are transmitted in 10 A units, 4 bits each, brake current as an
absolute value in the high nibble. Range on the wire is 0–150 A in 10 A steps —
which is exactly why max_current in the config table tops out at 150.
rampAlphaToByte(a) — piecewise non-linear quantisation
A single byte covering four orders of magnitude, with finer resolution at the low (smoother) end:
| Input range | Byte value | Resolution |
|---|---|---|
a <= 0.0001 |
0 |
— |
0.0001 < a <= 0.001 |
round((a − 0.0001) / 0.0001) → 0–9 |
0.0001 |
0.001 < a <= 0.01 |
round(10 + (a − 0.001) / 0.0005) → 10–28 |
0.0005 |
0.01 < a <= 0.1 |
round(28 + (a − 0.01) / 0.001) → 28–118 |
0.001 |
0.1 < a <= 0.5 |
round(118 + (a − 0.1) / 0.005) → 118–198 |
0.005 |
0.5 < a < 1 |
round(198 + (a − 0.5) / 0.01) → 198–248 |
0.01 |
a >= 1 |
248 |
— |
The bands join cleanly (0.001→10, 0.01→28, 0.1→118, 0.5→198, 1.0→248), so this is a deliberate companding curve, not accumulated drift. Byte values 249–255 are unreachable.
Ride mode presets
RIDE_MODE_PARAMS:
| id | maxCurrent | maxCurrentBrake | rampAlphaPos | rampAlphaNeg |
|---|---|---|---|---|
sport |
150 | −90 | 0.015 | 0.008 |
cruise |
150 | −90 | 0.003 | 0.008 |
eco |
90 | −90 | 0.002 | 0.005 |
custom |
150 | −90 | 0.003 | 0.008 |
(The 4294967206 in the decompiled output is −90 rendered unsigned.) Note
sport and cruise differ only in throttle ramp; braking is identical across
all four presets. custom is seeded from cruise.
UI metadata: Sport #AC0E0E "High performance mode with near-instant throttle
response", Cruise #0E77AC "Smooth, predictable power delivery suitable for
most riders", Eco #198E13 "Limited acceleration and speed for a more relaxed
and efficient ride", Custom #777777, plus an unknown pseudo-mode
("Configuration doesn't match any known mode").
Custom mode: slider → parameter mapping
When the user picks Custom, the four values come from sliders:
maxCurrent = 60 + throttleCurrent * 10
maxCurrentBrake = −(60 + brakeCurrent * 10)
rampAlphaPos = throttleRamp === 10 ? 1 : RAMP_VALUES[throttleRamp − 1]
rampAlphaNeg = brakeRamp === 10 ? 1 : RAMP_VALUES[brakeRamp − 1]
RAMP_VALUES = [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008, 0.015, 0.03, 0.06, 0.1]
Slider position 10 short-circuits to rampAlpha = 1 (instant response,
byte 248) rather than reading RAMP_VALUES[9] = 0.1. Index 9 of the table is
therefore dead — position 9 gives 0.06 and position 10 jumps straight to 1.0.
Whether that's intentional ("10 = instant") or an off-by-one is unclear from
the bytecode; the effect is a large discontinuity at the top of the slider.
Reverse mapping — determineInitialRideMode
On connect, the app reads the live config and tries to match it back to a
preset using isCloseEnough(a, b, epsilon = 0.001) on each of the four values.
No match → the unknown pseudo-mode. A match on custom's values →
'custom'. This is how the UI shows which mode a board is currently in without
the firmware reporting a mode ID.
Logging is verbose here: "Sending ride mode command for <name>:" with the
resolved values and commandBytes, then "Successfully sent ride mode
configuration for <name>". "Cannot select unknown mode" throws if the
unknown pseudo-mode is somehow selected.
3. Sub-command 101 — BIKE_WHEELIE_CONFIGURE
Defined but never sent. The identifier appears only in the enum declaration; no call site anywhere in the bundle constructs a packet with it.
The associated UI exists but is largely disabled. WHEELIE_MODES:
| id | name | icon | colour | disabled |
|---|---|---|---|---|
agro |
Agro | 3 |
#AC0E0E |
true |
standard |
Standard | 2 |
#0E77AC |
false |
float |
Float | 1 |
#198E13 |
true |
Descriptions: Agro — "Stiff, locked in response; sticks to set wheelie angle at all costs"; Standard — "Secure feeling wheelie with some give for a smooth, fun ride"; Float — "Soft, loose wheelie for a more floaty feel".
Handlers handleWheelieModePress / handleWheelieModeSelect /
handleWheelieModeCancel and state selectedWheelieMode,
isWheelieModeModalVisible are all wired up, but with two of three options
disabled and no transmit path, this reads as shipped-but-inert scaffolding
for a feature that wasn't finished in 1.0.0. There is also a separate
WHEELIE_CONFIG_SIGNATURES array and an isWheelieConfig predicate alongside
the WFB ones, suggesting a second config profile was planned.
Wheelie tuning in this build goes through the ordinary
COMM_SET_CUSTOM_CONFIG path instead (the WHEELIE_KP* / MAHONY_KP params in
§5), not through sub-command 101.
4. Sub-command 102 — BIKE_FEATURE_CONFIGURE
The big one: 13 payload bytes, all scalar, no length prefix. Full frame payload is 16 bytes.
36 102 102 b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12
| # | Field | Encoding | Wire range |
|---|---|---|---|
| 0 | selectedFunction |
{wheelie:0, turbo:1, horn:2, disabled:3}, unknown → 0, then min(x, 3) |
0–3 |
| 1 | wheelieAngle |
clamp(round(deg × 2), 10, 110) |
10–110 (5–55°) |
| 2 | throttleBrakeTilt |
bool ? 1 : 0 |
0/1 |
| 3 | throttleTiltAngle |
clamp(round((deg + 20) × 2), 0, 80) |
0–80 (−20…+20°) |
| 4 | brakeTiltAngle |
clamp(round((deg + 20) × 2), 0, 80) |
0–80 (−20…+20°) |
| 5 | persistWheelieThrottle |
bool ? 1 : 0 |
0/1 |
| 6 | persistWheelieBrake |
bool ? 1 : 0 |
0/1 |
| 7 | antiLoopEnabled |
bool ? 1 : 0 |
0/1 |
| 8 | sameAngleAsButton |
bool ? 1 : 0 |
0/1 |
| 9 | antiLoopAngle |
clamp(round(deg × 2), 10, 110) |
10–110 (5–55°) |
| 10 | wheelLiftResistance |
clamp(round(x × 10), 0, 30) |
0–30 (0–3.0) |
| 11 | hornStrength |
hornStrengthUiToWireByte(ui), clamped 0–250 |
10–100 |
| 12 | hornNoteIndex |
clamp(round(i), 0, 27) |
0–27 |
Two encoding families worth noting: angles are ×2 with a +20 bias where they can go negative (tilt angles), and plain ×2 where they can't (wheelie and anti-loop angles, both 5–55°). The +20/×2 bias is what lets a signed −20…+20° range fit an unsigned byte.
hornStrengthUiToWireByte(ui) = max(10, min(100, round(ui × 10))) — UI 1–10
maps to wire 10–100. The inverse used when reading firmware state is
hornStrengthFirmwareToUi(fw) = max(1, min(10, round(fw))), which does not
divide by 10. The two paths disagree by a factor of 10; the horn_strength
config field (§5) is a 0–100 double with default 4, and the UI default is also
4, so the round-trip only stays consistent because the config path and the
sub-command path are used in different places. Flagging as a likely bug.
Horn note table
HORN_NOTE_LABELS / HORN_NOTE_HZ_TABLE, 28 entries, G#3 through B5:
G#3 208 A3 220 A#3 233 B3 247 C4 262 C#4 277 D4 294
D#4 311 E4 330 F4 349 F#4 370 G4 392 G#4 415 A4 440
A#4 466 B4 494 C5 523 C#5 554 D5 587 D#5 622 E5 659
F5 698 F#5 740 G5 784 G#5 831 A5 880 A#5 932 B5 988
DEFAULT_HORN_NOTE_INDEX_B4 = 15 → 494 Hz, matching the horn_frequency
config default exactly. Helpers: hornNoteLabel(i) and
nearestHornNoteIndexFromHz(hz) for converting a firmware-reported frequency
back to a table index. The horn is produced by motor haptics, not a speaker —
per the button_function description, "Trigger a horn sound through motor
haptics."
Not transmitted
coastingBrakeStrength is held in component state and written back to local
config after a successful save, but is not one of the 13 bytes. Either
it's carried by a different path or the feature is incomplete. Its default,
DEFAULT_COASTING_BRAKE_STRENGTH, is 0.
Defaults
| Constant | Value |
|---|---|
DEFAULT_WHEELIE_ANGLE |
30 |
DEFAULT_SELECTED_FUNCTION |
'wheelie' |
DEFAULT_THROTTLE_BRAKE_TILT |
false |
DEFAULT_THROTTLE_TILT_ANGLE |
5 |
DEFAULT_BRAKE_TILT_ANGLE |
−5 |
DEFAULT_PERSIST_WHEELIE_THROTTLE |
false |
DEFAULT_PERSIST_WHEELIE_BRAKE |
false |
DEFAULT_ANTI_LOOP_ENABLED |
true |
DEFAULT_ANTI_LOOP_ANGLE |
30 |
DEFAULT_SAME_ANGLE_AS_BUTTON |
true |
DEFAULT_WHEEL_LIFT_RESISTANCE |
1.5 |
WHEEL_LIFT_RESISTANCE_STEP |
0.3 |
WHEEL_LIFT_RESISTANCE_MAX |
3 |
WHEEL_LIFT_DISPLAY_SCALE |
3.3333333333333335 |
DEFAULT_COASTING_BRAKE_STRENGTH |
0 |
DEFAULT_HORN_STRENGTH_UI |
4 |
OVERHEAT_THRESHOLD |
75 |
wheelLiftResistanceToDisplay(x) = round(x × 3.3333…) turns the 0–3.0
internal value into a 0–10 display scale; snapWheelLiftResistance quantises
user input to the 0.3 step.
5. Sub-command 103 — BIKE_SPEED_LIMIT_CONFIGURE
Single-byte payload:
36 102 103 <mph>
speedLimitMphToWireByte(mph):
n = round(mph)
return (n <= 0 || n > SPEED_LIMIT_MAX) ? 0 : n
So the value is transmitted as whole mph, unscaled, and anything out of
range collapses to 0. SPEED_LIMIT_MIN = 5, SPEED_LIMIT_MAX = 35,
DEFAULT_SPEED_LIMIT = 20. Note the guard is n <= 0, not n < SPEED_LIMIT_MIN
— values 1–4 pass through to the wire even though the UI won't produce them.
0 evidently means "no limit" rather than "limit of zero": the sibling helper
speedLimitIconKey uses the identical guard and returns the string 'none'
for the same inputs, which is what the UI renders.
The send path is a save button with an optimistic-UI wrapper: on failure it
logs "Failed to execute BIKE_SPEED_LIMIT_CONFIGURE command:" and shows a
"Save Failed" / "Failed to save speed limit settings." alert; there's a
300 ms debounce branch and an "Unsaved Changes" discard confirmation on close.
The config table's speed_limit field has default 19, while
DEFAULT_SPEED_LIMIT is 20 — the firmware default and the app's UI default
disagree by 1.
6. bike_config — the full custom-config profile
Read with COMM_GET_CUSTOM_CONFIG (93), written with COMM_SET_CUSTOM_CONFIG
(95). This is a separate channel from the 100–103 sub-commands: the sub-commands
are live/volatile pushes, the custom config is the persisted profile.
Write payload — getFloatConfigPayload(version, config)
u8 COMM_SET_CUSTOM_CONFIG (95)
u8 0 // config slot index
u32 floatSignatures[version]
... floatConfigEncoders[version](config)
Buffer is pre-allocated at 250 bytes. An unknown version throws
"Unsupported Package Version: <v>".
Read parse — getFloatConf(buffer)
u8 (discarded)
u32 signature → getWfbVersionForSignature() → parser table
Only version 1 is registered. Unrecognised signature → null → the user
sees "Package Version Unsupported or failed to get config". The signature is
computed at runtime from the parameter table rather than stored as a literal,
so it can't be read straight out of the bundle.
Wire types
vTx follows VESC Tool's VESC_TX_* enum (inferred from the observed values
and the accompanying scales):
| vTx | Type | Used by |
|---|---|---|
| 1 | uint8 | speed_limit |
| 3 | uint16 | horn_frequency |
| 4 | int16 | max_current, max_current_brake |
| 7 | double16 with vTxDoubleScale |
all the tuning doubles |
type is the editor type, not the wire type: 0 label, 1 double, 2 int,
3 string, 4 enum, 5 bool.
The 27 parameters
Grouped by function rather than declaration order.
Identity / control
| Name | cDefine | Wire | Default |
|---|---|---|---|
config_name |
— | string | bike_config |
hw_name |
— | label | "WFB Cfg" — "This is the VESC Logging and Communication Module." |
package_disable |
PACKAGE_DISABLE |
bool | 1 |
Wheelie PID — the tune the disabled sub-command 101 would have driven
| Name | cDefine | Scale | Range | Default |
|---|---|---|---|---|
kp (Angle P) |
WHEELIE_KP |
10 | 0–40 | 30 |
kp2 (Rate P) |
WHEELIE_KP2 |
100 | 0–3 | 1 |
mahony_kp |
WHEELIE_MAHONY_KP |
100 | 0–3 | 2 |
kp_brake |
WHEELIE_KP_BRAKE |
10 | 0.2–2 | 1.5 |
kp2_brake |
WHEELIE_KP2_BRAKE |
10 | 0–2 | 1.5 |
lift_resistance_kp2 |
BIKE_LIFT_RESISTANCE_KP2 |
100 | 0–3 | 1.5 |
The lift_resistance_kp2 description is unusually candid about the design:
Rate P responds to the nose's angular velocity independent of setpoint, and
although it's nominally a P term they use it "practically as the D (Derivative)
component for our PID loop, working as a damper for the Angle P component."
Recommended 0.1–0.6, with 0.7–1.2+ possible but flagged for side effects, and
advice to scale Angle P and Rate P together to preserve ride feel.
Motor limits and ramps — same quantities sub-command 100 pushes live
| Name | cDefine | Wire | Range | Default |
|---|---|---|---|---|
max_current |
BIKE_MAX_CURRENT |
int16 | 0–150 | 150 |
max_current_brake |
BIKE_MAX_CURRENT_BRAKE |
int16 | −150–0 | −90 |
ramp_alpha_pos |
BIKE_RAMP_ALPHA_POS |
×10000 | 0.0001–1 | 0.003 |
ramp_alpha_neg |
BIKE_RAMP_ALPHA_NEG |
×10000 | 0.0001–1 | 0.008 |
ramp_alpha_return |
BIKE_RAMP_ALPHA_RETURN |
×10000 | 0.0001–1 | 0.008 |
balance_pitch |
BIKE_BALANCE_PITCH |
×100 | 5–55 | 30 |
ramp_alpha_return has no counterpart in createRideModeCommand — the live
command carries only pos and neg. The persisted config has a third ramp the
live path can't set.
Button and tilt
| Name | cDefine | Wire | Range | Default |
|---|---|---|---|---|
button_function |
BIKE_BUTTON_FUNCTION |
enum | Wheelie / Turbo / Horn / Disabled | 2 (Horn) |
throttle_tilt_enabled |
BIKE_THROTTLE_TILT_ENABLED |
bool | — | 1 |
throttle_tilt_angle_limit |
BIKE_THROTTLE_TILT_ANGLE_LIMIT |
×100 | −20–20 | 5 |
brake_tilt_angle_limit |
BIKE_BRAKE_TILT_ANGLE_LIMIT |
×100 | −20–20 | −5 |
persist_wheelie_throttle |
BIKE_PERSIST_WHEELIE_THROTTLE |
bool | — | 0 |
persist_wheelie_brake |
BIKE_PERSIST_WHEELIE_BRAKE |
bool | — | 0 |
button_function enum labels are ['Wheelie', 'Turbo', 'Horn', 'Disabled'],
matching the {wheelie:0, turbo:1, horn:2, disabled:3} map in sub-command 102.
Its description: Wheelie triggers a wheelie at any time; Turbo accelerates as
fast as possible while maintaining current angle; Horn triggers motor haptics;
Disabled assigns nothing.
throttle_tilt_enabled description: while the button is held in Wheelie mode,
the bike's angle can be modulated with throttle and regen brake within the
configured tilt angles.
Note the config default for button_function is 2 (Horn) while
DEFAULT_SELECTED_FUNCTION in the app is 'wheelie' (0) — another
app/firmware default mismatch.
Anti-loop
| Name | cDefine | Wire | Range | Default |
|---|---|---|---|---|
anti_loop_enabled |
BIKE_ANTI_LOOP_ENABLED |
bool | — | 1 |
anti_loop_use_button_angle |
BIKE_ANTI_LOOP_USE_BUTTON_ANGLE |
bool | — | 1 |
anti_loop_angle |
BIKE_ANTI_LOOP_ANGLE |
×100 | 5–55 | 30 |
When anti_loop_use_button_angle is set, anti-loop references the wheelie
button's configured angle instead of anti_loop_angle.
Horn and limit
| Name | cDefine | Wire | Range | Default |
|---|---|---|---|---|
horn_strength |
BIKE_HORN_STRENGTH |
×100 | 0–100 | 4 |
horn_frequency |
BIKE_HORN_FREQUENCY |
uint16 | 200–1000 | 494 |
speed_limit |
BIKE_SPEED_LIMIT |
uint8 | 0–35 | 19 |
horn_frequency's 200–1000 Hz range brackets the note table's 208–988 Hz
exactly.
7. Telemetry for the bike screen
Two loops, both driven off loopTime reported by COMM_GET_VALUES_SETUP:
bikeRTDataInterval = loopTime
bikeSlowDataInterval = max(loopTime, 1000)
getRTFloatData(ctx, mode = 1) sends:
36 102 10 <mode>
i.e. GET_ALL_DATA with a mode selector. All call sites in this build pass
only ctx, so mode is always 1. The response echoes the mode back as its
first byte, and the parser adds tachAbs, tempMosfet, tempMotor,
tempBattery only when mode >= 2 — meaning the temperature fields exist in
the protocol but this build never requests them through this path. It gets
temperatures from plain COMM_GET_VALUES instead.
getAllFloatData additionally issues a plain COMM_GET_VALUES_SETUP and
derives batteryPercent (rounded to 2 dp), batteryVolts (1 dp), and
wattHoursRemaining for the dashboard.
Field layout of the GET_ALL_DATA response is in the main document, §6.
8. Bike-layer issues
| # | Issue |
|---|---|
| 1 | BIKE_WHEELIE_CONFIGURE (101) defined but never sent; 2 of 3 wheelie modes disabled: true |
| 2 | Ramp slider position 10 short-circuits to alpha = 1 (byte 248); RAMP_VALUES[9] = 0.1 is unreachable, giving a 0.06 → 1.0 jump |
| 3 | hornStrengthUiToWireByte multiplies by 10, hornStrengthFirmwareToUi doesn't divide — asymmetric round-trip |
| 4 | coastingBrakeStrength tracked in UI state but absent from the 13-byte BIKE_FEATURE_CONFIGURE payload |
| 5 | speed_limit firmware default 19 vs app DEFAULT_SPEED_LIMIT 20 |
| 6 | button_function firmware default 2 (Horn) vs app DEFAULT_SELECTED_FUNCTION 'wheelie' (0) |
| 7 | speedLimitMphToWireByte guards on n <= 0, not n < SPEED_LIMIT_MIN; 1–4 mph reach the wire |
| 8 | ramp_alpha_return exists in the persisted config but has no live-command equivalent |
| 9 | Ride-mode current resolution is 10 A per nibble — the UI can present finer values than the wire can carry |