2026 · personal · private
myfan
Reverse-engineering a BLE circulator protocol
Why
The LZEF-DC290 BLDC circulator can only be controlled from the vendor app, which is heavy and has no widgets. For interoperability with my own device I decoded the protocol and built a replacement. The vendor app is neither copied nor distributed.
Findings
- Broadcast, not connection: The vendor app never connects or scans. It puts the command in the manufacturer data (0xFFF0) of an advertising packet, broadcasts for about 200 ms, and stops. The fan only listens, so a BLE scanner will never find it.
- Frame layout: Of a 33-byte plaintext only the last 18 bytes are sent: sync 3 · address 5 · header 2 · command 1 · device id 4 · checksum 1 · CRC16 2.
- Two-stage whitening: The standard BLE LFSR (x⁷+x⁴+1) is applied twice with seeds 63 and 37. Both seeds are constant, so the whole thing collapses to a single XOR with a fixed mask. Encoding and decoding are the same operation.
- Non-standard CRC16: poly 0x1021, init 0xFFFF, but the address goes in reversed and the payload bit-inverted. That is why 560 parameter combinations in the first analysis all missed.
- The “session nonce” was not a nonce: The 4 bytes that changed between app launches were a device id the app generates at random; they changed because another device had been added and removed in between. No randomness, no session: the same (command, device id) is always the same 18 bytes.
- Delete is a broadcast: cmdType 15 ignores the device id and reaches every device in range. The replacement app leaves it out.
Verification
All 63 captured frames re-encode to identical bytes. The encoder is Python (tools/rf297l.py); the Android side has unit tests that compare against real captured frames.
Replacement app
Kotlin, three dependencies, a single BLUETOOTH_ADVERTISE permission. One BluetoothLeAdvertiser.startAdvertising() is all it takes: no connecting, pairing, reconnecting or GATT cache. Five home-screen widgets (single command, power toggle, fan speed, power + speed, and more). R8 is off because it can strip methods the widget provider calls by name.