ESPHome external component that exposes a Fiido ebike to Home Assistant over BLE. One component instance ("hub") per bike. Multiple hubs run on a single ESP32 with the burst poll of each hub offset in time so the radio is not contended.
The component reads the bike state out of its BMS, parses the proprietary frames the official app uses, and writes back to flip a small set of physical controls: power, light, gear, gear count, speed limit, speed unit, horn, key sound, throttle, slow mode on boot.
The document is split in two:
- Part 1 - Integrator (yaml only): how to wire a bike into an ESPHome device and what entities you get.
- Part 2 - Extender (C++ + python): how the component is structured, how it polls, how it writes, and how to add a new sensor, binary sensor, select, or switch.
The component speaks the BLE protocol of the official Fiido app (com.fiido.meter).
That protocol is model-agnostic: the same frame format and register map are used
across the adult Fiido ebike line, so the component is not tied to one model. It
was developed and verified empirically on a Fiido C11 Pro and a Fiido M1 Pro 2025;
everything here was confirmed on that hardware unless noted. Other adult Fiido
ebikes exposing the same Fiido_* BLE service are likely compatible but untested -
reports and PRs welcome. The K1 / Kidz children's line uses a different register
layout and is not covered.
| Bike | BLE name | MAC (example) | Spec |
|---|---|---|---|
| Fiido C11 Pro | Fiido_C11Pro |
XX:XX:XX:XX:XX:XX |
48V/11.6Ah, 350W, 28 inch |
| Fiido M1 Pro 2025 | Fiido_M1PRO |
XX:XX:XX:XX:XX:XX |
48V/11.6Ah, 500W, 22 inch |
MACs above are placeholders; scan your bike with any BLE tool to get the real
address and substitute it in ble_client.mac_address.
Both bikes use the same BLE topology and the same wire protocol. C11 is locked to 3-gear mode at the firmware level; M1 supports both 3-gear and 5-gear.
ESP32 side: any board capable of esp32_ble_tracker + ble_client plus enough
RAM/Flash headroom (component baseline: ~16% RAM, ~47% Flash on ESP32 with
esp-idf framework). The reference deployment uses an ESP32 with LAN8720 Ethernet
but Wi-Fi works too.
| Service | Characteristic | Use |
|---|---|---|
00010203-0405-0607-0809-0A0B0C0DFFE0 |
...FFE1 handle 0x12 (NOTIFY) |
Rx |
00010203-0405-0607-0809-0A0B0C0DFFE0 |
...FFE2 handle 0x10 (WRITE + WRITE_NR) |
Tx |
0xFE59 |
Nordic Secure DFU | not used |
Negotiated MTU: 247. The component does no DFU and never writes to 0xFE59.
Numbers are after the default expose_dev_sensors: false. With expose_dev_sensors: true you also get raw diagnostic readouts (HW/SW versions, controller upper/lower
voltage, motor magnetic/wire/steel/ratio, crank torque/rpm, this-trip / total energy,
meter mode data). Those are created with disabled_by_default: true so they stay
hidden in HA until you enable them per entity.
- 10 sensors always on: battery voltage, battery capacity, motor wheel diameter, motor temperature, motor capacity (W), startup time, speed, trip distance, total distance, battery SOC.
- 26 dev sensors gated by
expose_dev_sensors(HW/SW versions, controller upper / lower voltage / current / temperature, motor magnetic / wire / steel / reduction ratio, battery current and trip / total energy, crank RPM / torque, meter diagnostics, gear start). - 1 binary sensor always on:
connected(BLE link state). - 1 dev binary sensor gated by
expose_dev_sensors:brake(STATS 0x2A bit 5, hardware behaviour not user-verified). - 4 selects:
gear(3 or 5 options depending on mode),mode(3 / 5, hidden on bikes pinned to 3-gear),speed_limit(6 km/h / 25 km/h / No limit),speed_unit(km/h / mph). - 8 switches:
motor(Power),light,auto_shutdown,speaker(Horn),key_sound,throttle,slow_mode_on_boot,bluetooth(BLE link master switch).
The component in Home Assistant, shown as the device page split into its four entity-category cards:
This component uses the ESPHome sub-device API, so it needs ESPHome 2025.7.0 or
newer. Pin it with esphome: { min_version: 2025.7.0 } so an older install fails
fast instead of erroring deep in code generation.
Two declarations per bike. Replace the MAC with the bike's MAC.
external_components:
- source: github://dzikus/esphome-fiido-bms
components: [fiido_bms]
esp32_ble_tracker:
ble_client:
- id: ble_c11
mac_address: XX:XX:XX:XX:XX:XX
fiido_bms:
- id: hub_c11
ble_client_id: ble_c11
sensor:
- platform: fiido_bms
fiido_bms_id: hub_c11
binary_sensor:
- platform: fiido_bms
fiido_bms_id: hub_c11
select:
- platform: fiido_bms
fiido_bms_id: hub_c11
switch:
- platform: fiido_bms
fiido_bms_id: hub_c11That gives you all default entities, named in English, with default icons and restore modes. Every individual entity can be customised; see Override per-entity below.
Set on the fiido_bms: entry, not on the platforms.
| Option | Type | Default | Effect |
|---|---|---|---|
ble_client_id |
id | - | Required. Points to the ble_client entry for this bike's MAC. |
startup_delay |
time | 0s |
Hub waits this long after boot before its first poll. Auto-derived from hub_index if omitted. |
update_interval_on |
time | 3s |
Burst rotation period while motor controller is ON (bit 7 ADDR 0x27 set). |
update_interval_off |
time | 15s |
Burst rotation period while motor controller is OFF. Fast enough to catch a physical power-on. |
idle_disconnect |
time | 15min |
After motor has been OFF this long with no pending writes, the BLE link is dropped. |
expose_dev_sensors |
bool | false |
When true, dev sensors and dev binary sensors are created (disabled in HA). |
ui_gear_mode_3 |
bool | false |
HA UI only: hides the mode select and shrinks gear to 4 options. Does not change BMS state. |
enforce_gear_mode_3 |
bool | false |
Runtime: writes mode 3 to BMS when STATS reports 5-gear while the motor controller is ON (60s cooldown, ble_user_enabled). |
update_interval |
time | 1s |
PollingComponent baseline tick. The component runs an adaptive gate on top. |
The auto-offset behaviour spreads multiple hubs evenly: hub N out of M starts
(N * update_interval_on) / M after boot, so two bikes on one ESP32 do not poll the
radio at the exact same millisecond.
All sensors are scalar floats. Sensors marked dev are gated by
expose_dev_sensors. ADDR is the BMS register the value lives in; offset (when given)
is the byte offset inside the STATS poll payload [0..52].
Always on (created regardless of expose_dev_sensors):
| Key | Default name | Unit | Source | Notes |
|---|---|---|---|---|
battery_voltage |
Battery Voltage | V | BATTERY 0x80 | 2B BE / 10 |
battery_capacity |
Battery Capacity | Ah | BATTERY 0x7E | 2B BE / 10 |
motor_wheel_diameter |
Motor Wheel Diameter | in | MOTOR 0x9C | |
motor_temperature |
Motor Temperature | C | MOTOR | |
motor_capacity |
Motor Capacity | W | MOTOR | |
startup_time |
Uptime | s | ENERGY 0xD3 | seconds since BMS power-up |
bicycle_speed |
Speed | km/h | STATS 0x23 | 2B BE / 10 |
current_kilometers |
Trip Distance | km | STATS 0x21 | 2B BE / 10 |
total_kilometers |
Total Distance | km | STATS 0x1F | 4B BE / 10 |
battery_soc |
Battery SOC | % | STATS 0x24 | 1B, matches the bike display bars |
Dev (created only with expose_dev_sensors: true, then disabled_by_default):
| Key | Default name | Unit | Source |
|---|---|---|---|
battery_current |
Battery Current | A | BATTERY 0x85 |
battery_current_voltage |
Battery Current Voltage | V | BATTERY |
battery_manufacturer |
Battery Manufacturer | - | BATTERY |
battery_hw_version |
Battery HW Version | - | BATTERY |
battery_sw_version |
Battery SW Version | - | BATTERY |
ctrl_upper_voltage |
Controller Upper Voltage | V | CTRL |
ctrl_lower_voltage |
Controller Lower Voltage | V | CTRL |
ctrl_current |
Controller Current | A | CTRL |
ctrl_temperature |
Controller Temperature | C | CTRL |
ctrl_hw_version |
Controller HW Version | - | CTRL |
ctrl_sw_version |
Controller SW Version | - | CTRL |
ctrl_version |
Controller Version | - | CTRL |
ctrl_manufacturer |
Controller Manufacturer | - | CTRL |
motor_version |
Motor Version | - | MOTOR |
motor_magnetic |
Motor Magnetic | - | MOTOR |
motor_wire_count |
Motor Wire Count | - | MOTOR |
motor_steel_count |
Motor Steel Count | - | MOTOR |
motor_reduction_ratio |
Motor Reduction Ratio | - | MOTOR |
crank_torque |
Crank Torque | Nm | ENERGY |
crank_rpm |
Crank RPM | rpm | ENERGY |
this_take_energy |
Trip Energy | Wh | ENERGY |
total_take_energy |
Total Energy | Wh | ENERGY |
bicycle_gear_start |
Gear Start | - | STATS |
meter_hw_version |
Meter HW Version | - | METER |
meter_sw_version |
Meter SW Version | - | METER |
meter_mode_data |
Meter Mode Data | - | METER |
Same expose_dev_sensors gate as the sensor platform: dev entries are skipped
entirely when the flag is false, and created with disabled_by_default: true when
it is true.
Always on (created regardless of expose_dev_sensors):
| Key | Default name | Source | Notes |
|---|---|---|---|
connected |
BLE Connected | BLE link state | diagnostic category |
Dev (created only with expose_dev_sensors: true, then disabled_by_default):
| Key | Default name | Source | Notes |
|---|---|---|---|
brake |
Brake | STATS 0x2A bit 5 | hardware behaviour not user-verified on C11 / M1; do not rely on it |
| Key | Default name | Options | Source / write |
|---|---|---|---|
gear |
Gear | OFF / eco / sport / turbo (3-gear) | STATS 0x26, WRITE L0 ADDR 0x26 (1B raw) |
| OFF / eco / normal / sport / turbo / turbo+ (5-gear) | |||
mode |
Gear Count | 3 / 5 | STATS 0x25 nibble, WRITE J0 ADDR 0x25 (upper nibble = max_gear, lower preserved) |
speed_limit |
Speed Limit | 6 km/h / 25 km/h / No limit | STATS 0x27 bit 5 + ADDR 0x3C value (separate poll, two WRITE frames in order) |
speed_unit |
Speed Unit | km/h / mph | STATS 0x28 bit 7, WRITE L0 ADDR 0x28 (read-modify-write) |
Note on mode: bikes with ui_gear_mode_3: true do not expose this entity. The
gear select shrinks to a 4-option list in that case. The mode UI is purely
cosmetic; physical BMS state can still be flipped to 5 by other apps. Use
enforce_gear_mode_3: true to also pin the BMS itself.
All switches default to RESTORE_DEFAULT_ON except motor and light, which default
to RESTORE_DEFAULT_OFF. Restore mode is overridable per entity.
| Key | Default name | Source | Write |
|---|---|---|---|
motor |
Power | STATS 0x27 bit 7 | WRITE L0 ADDR 0x27 (R-M-W, bit 7) |
light |
Light | STATS 0x27 bit 3 | WRITE L0 ADDR 0x27 (R-M-W, bit 3, rejected if motor is OFF) |
auto_shutdown |
Auto Shutdown | local (HA-restored) | enables / disables 15-min idle-disconnect |
speaker |
Horn | STATS 0x38 bits 3:2 | WRITE L0 ADDR 0x38 (R-M-W, ON = 00, OFF = 01) |
key_sound |
Key Sound | STATS 0x2C bit 4 | WRITE L0 ADDR 0x2C (R-M-W, inverted: bit 4 = 0 means ON) |
throttle |
Throttle | STATS 0x2B bit 1 | WRITE L0 ADDR 0x2B (R-M-W, inverted: bit 1 = 0 means active) |
slow_mode_on_boot |
Slow Mode on Boot | STATS 0x2C bit 6 | WRITE L0 ADDR 0x2C (R-M-W, bit 6 = 1 forces 6 km/h limit on next power-up) |
bluetooth |
Bluetooth | local | master switch for the BLE link itself |
Every key on every platform accepts the normal ESPHome entity config. Override the name, icon, category, restore mode, or any other entity field directly under the key:
sensor:
- platform: fiido_bms
fiido_bms_id: hub_c11
device_id: dev_c11
battery_voltage:
name: "C11 Pack Voltage"
icon: "mdi:battery-charging-high"
battery_soc:
name: "C11 Charge"
switch:
- platform: fiido_bms
fiido_bms_id: hub_c11
device_id: dev_c11
motor:
name: "C11 Bike Power"
restore_mode: ALWAYS_OFF
light:
icon: "mdi:lightbulb-on"Schema defaults are injected before validation, so omitted fields keep their
defaults. If you do not set name, the default in the tables above is used.
Two ble_client entries and two fiido_bms hubs is all that is needed. The hub
auto-offset spreads the polls, and ESP32 supports up to 3 concurrent BLE client
connections out of the box (max 9 with max_connections raised in esp32_ble_tracker).
ble_client:
- id: ble_c11
mac_address: XX:XX:XX:XX:XX:XX
- id: ble_m1
mac_address: XX:XX:XX:XX:XX:XX
fiido_bms:
- id: hub_c11
ble_client_id: ble_c11
ui_gear_mode_3: true # HA UI 3-gear only
enforce_gear_mode_3: true # also force BMS to 3-gear (revert external 5-gear change)
- id: hub_m1
ble_client_id: ble_m1Each platform then needs one entry per hub. Use device_id to put entities under a
separate sub-device in HA:
esphome:
devices:
- id: dev_c11
name: "Fiido C11 Pro"
- id: dev_m1
name: "Fiido M1 PRO 2025"
sensor:
- platform: fiido_bms
fiido_bms_id: hub_c11
device_id: dev_c11
- platform: fiido_bms
fiido_bms_id: hub_m1
device_id: dev_m1- C11 Pro: BMS shuts the BLE radio off completely while the charger is plugged
in. The hub will fail to connect; the entity
connectedgoes to OFF. Unplug the charger to bring the link back. - M1 Pro 2025: BMS stays on BLE while charging, but no register reports the
charge state.
battery_voltagereads nominal 48.0 V,battery_currentreads 0.0 A regardless. Do not use these to detect charging.
The bike's BMS only accepts one BLE central at a time. While ESPHome is connected, the official app cannot pair. To use the app:
- Disable the
bluetoothswitch on the bike's HA device, or power the ESP32 off. - Connect with the app, make your changes, disconnect from the app.
- Re-enable the
bluetoothswitch (or power the ESP32 back on).
ESPHome will reconnect and start polling again on the next tick.
components/fiido_bms/
__init__.py hub config + schema, auto-offset, dev gating
sensor.py sensor platform: 36 keys (10 always on, 26 dev), schema + to_code
binary_sensor.py binary_sensor platform: 2 keys (1 always on, 1 dev)
select.py 4 select classes + platform
switch.py 8 switch classes + platform
fiido_protocol.{h,cpp} pure C++: CRC XOR, frame builders, validate, POLL_TABLE
fiido_bms.{h,cpp} FiidoBMSHub: BLE client + PollingComponent + state machine
fiido_bool_switch.h all 8 switches via two templates + one-line subclasses:
FiidoBoolSwitch<Setter> write_state only
FiidoBoolSwitchWithRestore<Setter> setup() restore + defer
motor / light / speaker / key_sound / throttle / slow_mode
are write-only; the bit + ADDR live in the hub setter
bluetooth / auto_shutdown use the with-restore template
(local state, re-applied on boot)
fiido_gear_select.{h,cpp} ADDR 0x26, count-aware (3 vs 5)
fiido_mode_select.{h,cpp} ADDR 0x25 nibble-packed
fiido_speed_limit_select.{h,cpp} ADDR 0x3C + bit 5 ADDR 0x27 pair
fiido_speed_unit_select.{h,cpp} bit 7 ADDR 0x28
fiido_protocol.{h,cpp} is pure C++ with no ESPHome dependencies and is what the
PlatformIO unit tests link against. Everything else needs the ESPHome runtime.
FiidoBMSHub inherits PollingComponent with a fixed 1-second baseline. On every
tick update() checks a gate:
if (now - last_burst_ms_) < desired_interval_ms_:
return
last_burst_ms_ = now
send_burst_poll_()
desired_interval_ms_ flips between update_interval_on_ms_ (default 3s, motor on)
and update_interval_off_ms_ (default 15s, motor off) inside parse_stats_, based
on bit 7 of ADDR 0x27. The flip is one-way per STATS frame; the gate decides when
the next burst actually fires.
A burst is six polls scheduled 5 ms apart in time via set_timeout("burst", 5ms):
BATTERY -> CTRL -> MOTOR -> ENERGY -> STATS -> METER
5 ms is the empirically established sweet spot; anything below ~3 ms makes the BMS
drop frames. Polls whose group is disabled (no consumer sensor, gated by
expose_dev_sensors) are skipped at burst time; a safety counter bounds the skip
loop so a fully disabled rotation cannot spin forever.
After every successful WRITE the hub sets force_poll_stats_ = true, cancels the
in-flight burst, and re-enters burst rotation starting with STATS so the WRITE's
visible effect (a flipped bit) shows up in HA within one burst step.
motor_off_since >= idle_disconnect
AND pending_writes empty
[CONNECTED] -------------------------------------------------> [DISCONNECTED]
^ |
| |
| STATS, motor on |
| |
[PROBING] <-----------------------------------------------------------/
| disconnected_since >= PERIODIC_PROBE
| (or HA-driven WRITE)
|
|--- STATS, motor off, no pending --> set_enabled(false) --> [DISCONNECTED]
|
\--- PROBE_WINDOW expired, not connected, no pending --> [DISCONNECTED]
| Transition | Trigger | Effect |
|---|---|---|
CONNECTED -> DISCONNECTED |
now - motor_off_since_ms_ >= idle_disconnect_ms_ and no pending WRITE |
set_enabled(false), BLE link dropped |
DISCONNECTED -> PROBING |
now - disconnected_since_ms_ >= PERIODIC_PROBE_MS (5 min) |
set_enabled(true), scan + connect |
PROBING -> CONNECTED |
STATS arrives with bit 7 ADDR 0x27 set | stay connected, run burst polls |
PROBING -> DISCONNECTED |
STATS arrives with bit 7 ADDR 0x27 clear and no pending | set_enabled(false) |
PROBING -> DISCONNECTED (timeout) |
PROBE_WINDOW_MS (60s) elapsed, still not linked, no pending |
set_enabled(false) |
any -> PROBING |
HA writes a control while link is down | enqueue_pending_write_(fn) + ensure_enabled_for_write_() + set_enabled(true) |
| WRITE drained | STATS valid after re-connect | dispatch_pending_writes_() runs every enqueued lambda |
auto_shutdown switch disables the first transition; with it OFF the link stays up
until something else cuts it. The bluetooth switch is a hard kill: turning it OFF
clears the pending-writes queue, cancels the burst timeout, calls
parent_->set_enabled(false), and any subsequent WRITE setter rejects the change
(motor/light/speaker re-publish the inverted state, others log + return).
The bike's WRITE protocol replaces an entire byte, so toggling a single bit needs the latest copy of that byte first. The hub keeps a one-byte cache per relevant address, populated from the STATS poll:
| ADDR | Offset in STATS payload | What lives there | Used by |
|---|---|---|---|
| 0x25 | payload[32] | gear range, nibble-packed | set_gear_mode |
| 0x27 | payload[34] | bit 7 = motor, bit 5 = speed_limit_en, bit 3 = light, plus cruise/start/insens bits not used | set_motor_enable, set_light_enable, set_speed_limit |
| 0x28 | payload[35] | bit 7 = speed unit (1 = mph), other UI flags | set_speed_unit |
| 0x2B | payload[38] | bit 1 = throttle (inverted) | set_throttle_enable |
| 0x2C | payload[39] | bits 3:2 = gear way, bit 4 = key_sound (inverted), bit 6 = slow_mode_on_boot | set_key_sound_enable, set_slow_mode_enable |
| 0x38 | payload[51] | bits 3:2 = speaker (binary on these bikes), other flags | set_speaker_enable |
| 0x3C | separate poll | speed limit value in km/h | set_speed_limit (paired with bit 5 ADDR 0x27) |
Each cache has a _valid flag. Setters reject writes (and re-publish the old state
to HA) when their cache byte is not yet valid; the next STATS frame populates it.
This is also why the first action after boot is sometimes deferred by one or two
seconds: the cache must be primed first.
If a WRITE arrives while disconnected, the setter wraps itself in a lambda and
pushes it to pending_writes_. The lifecycle state machine forces a re-connect,
and dispatch_pending_writes_ runs the queue once STATS has come back valid.
POLL (read): [0x46][0x64][0x55][len ][addr][CRC] total: 6 bytes
WRITE J0: [0x46][0x64][0xFF][plen][addr][...p ][CRC]
WRITE L0 (most): [0x46][0x64][0xAA][plen][addr][...p ][CRC]
NOTIFY: [0x46][0x64][0xAA][plen][addr][...p ][CRC]
- Byte 0..1:
'F' 'd', the Fiido signature. - Byte 2: frame type.
0x55= poll,0xFF= WRITE J0,0xAA= WRITE L0 / NOTIFY. - Byte 3:
len. For poll frames this is how many bytes the BMS should send back; for WRITE / NOTIFY it is the payload length (frame length minus 6). - Byte 4: address (register).
- Bytes 5..n-2: payload (WRITE / NOTIFY only).
- Last byte: XOR of all preceding bytes.
compute_crc(buf, len-1)infiido_protocol.cpp.
WRITE frames are fire-and-forget. The BMS does not return a NOTIFY for a WRITE
(the only exception is ADDR 0x25 mode change, which echoes back). Verification is
empirical: queue a STATS poll afterwards (force_poll_stats_) and read the bit
back from the next NOTIFY.
The full poll rotation:
| Name | Addr | Len | Tx | Provides |
|---|---|---|---|---|
| HANDSHAKE | 0x0D | 13 | 46 64 55 0D 0D 77 |
memory test pattern (sent once after connect) |
| BATTERY | 0x7B | 13 | 46 64 55 0D 7B 01 |
HW/SW/capacity/voltage/current/manufacturer |
| CTRL | 0xAF | 12 | 46 64 55 0C AF D4 |
controller HW/SW/upper/lower/current/temp |
| MOTOR | 0x96 | 12 | 46 64 55 0C 96 ED |
motor version/wheel/temp/capacity |
| ENERGY | 0xC8 | 12 | 46 64 55 0C C8 B3 |
torque/RPM/trip/total energy/uptime |
| STATS | 0x05 | 53 | 46 64 55 35 05 47 |
speed/km/gear/SOC + every flag byte 0x05..0x39 |
| METER | 0x60 | 13 | 46 64 55 0D 60 1A |
meter HW/SW/mode |
| SPEEDLIM | 0x3C | 1 | 46 64 55 01 3C 4A |
current speed-limit value in km/h |
STATS is the only poll that is always issued; CTRL and METER are skipped from the
rotation when expose_dev_sensors is false.
The component is built so that each new register-backed control follows the same shape. Walking through a new switch on, say, bit 0 ADDR 0x39:
-
Declare the cache (if the byte is not already cached). In
fiido_bms.hadd auint8_t addr_39_cache_{0};andbool addr_39_valid_{false};to the protected section. Direct field access is used inside the class; no public accessors are needed. -
Populate the cache from
parse_stats_infiido_bms.cpp. Find the byte in the STATS payload (payload[off]whereoffisADDR - 0x05), assign toaddr_39_cache_, setaddr_39_valid_ = true, andpublish_stateto the switch entity using the relevant bit. -
Write the setter in
fiido_bms.cppalongside the otherset_X_enablemethods. Pattern:void FiidoBMSHub::set_my_thing_enable(bool on) { if (!ble_user_enabled_) { if (my_thing_switch_) my_thing_switch_->publish_state(!on); return; } if (!addr_39_valid_) { enqueue_pending_write_([this, on]() { set_my_thing_enable(on); }); ensure_enabled_for_write_(); return; } uint8_t b = addr_39_cache_; b = on ? (b | 0x01) : (b & ~0x01); if (send_raw_write(FRAME_TYPE_WRITE_L0, 0x39, std::vector<uint8_t>{b})) { addr_39_cache_ = b; force_poll_stats_ = true; } }Invert the polarity (
on ? & ~0x01 : | 0x01) when the bit is inverted at the BMS (key_sound, throttle). -
Add a switch class: add one line to
fiido_bool_switch.h:class FiidoMyThingSwitch : public FiidoBoolSwitch<&FiidoBMSHub::set_my_thing_enable> {};. UseFiidoBoolSwitchWithRestore<...>instead only for local state that must re-apply its restored value on boot (the bluetooth and auto_shutdown switches). -
Register in
switch.py: import the class asFiidoMyThingSwitch, add it to theSWITCHEStable with a config key, hub setter name, restore mode, icon, entity category, and default name. -
Hub wiring in
fiido_bms.h: forward-declareclass FiidoMyThingSwitch;, addvoid set_my_thing_switch(FiidoMyThingSwitch *sw) { my_thing_switch_ = sw; }andFiidoMyThingSwitch *my_thing_switch_{nullptr};in the protected section.fiido_bms.cppalready includesfiido_bool_switch.h, which defines the class. -
Unit test the parse path in
tests/test_protocol/test_main.cpp: extend the STATS fixture to set bit 0 of ADDR 0x39, build a frame, decode it, assert the cache value.
For a new sensor the shape is the same minus steps 3-6: add it to SENSORS in
sensor.py, add set_my_sensor and the member pointer in fiido_bms.h, publish
from the corresponding parse_* in fiido_bms.cpp. Add it to SENSOR_POLL_GROUP
so the burst rotation enables the right poll when the sensor is declared, and add
to DEV_SENSOR_KEYS if it should be off by default.
Unit tests under tests/test_protocol/ build with PlatformIO + Unity. They link
only fiido_protocol.{h,cpp} and run on the host (no ESP32 required). 39 tests
cover CRC, the poll and write frame builders, validate, and the decode of every
poll's payload via static fixtures in fixtures.h.
pio test -d tests -e native
| Constraint | Effect / workaround |
|---|---|
| C11 charging cuts BLE entirely | connected goes OFF while the charger is plugged in. Unplug to restore the link. |
| M1 charging is invisible on BLE | No register reports charge current / voltage delta. Do not try to detect charging from BMS state. |
| Official app and the component share the BLE link | Only one central at a time. Use the bluetooth switch to release the link before pairing with the app. |
slow_mode_on_boot (bit 6 ADDR 0x2C) has an instant side-effect on ADDR 0x3C |
BMS rewrites the speed-limit value on the same WRITE: ON forces 6 km/h, OFF restores the user choice. The component only writes bit 6; do not also write 0x3C in the same burst. |
| Speaker (bits 3:2 ADDR 0x38) is binary in firmware | Only bits = 00 (audible) and bits = 01 (silent) have a physical effect. Other values collapse to silent. |
| Key sound (bit 4 ADDR 0x2C) is inverted | bit = 0 means audible, bit = 1 means silent. Setter applies the inversion; the entity reads "ON" when the bike beeps. |
| Throttle (bit 1 ADDR 0x2B) is inverted | bit = 0 means handle is active, bit = 1 means disabled. Same shape as key sound. |
| WRITE is fire-and-forget | No NOTIFY confirms a WRITE (except ADDR 0x25 mode change). Verify by force-polling STATS afterwards and checking the bit. |
| Bike will not sleep while the BLE link is held | The BMS only enters low-power state after the central disconnects. With auto_shutdown OFF the component never drops the link, so the bike keeps draining standby current indefinitely. Leave auto_shutdown ON unless you have an external reason to keep the link up. |




