← Home

2026 · personal · private

myfan

Reverse-engineering a BLE circulator protocol

Role
Analysis and implementation
Period
2026.08
Status
Decoded · replacement app in use
Stack
Kotlin, Android, BLE, Reverse Engineering, Python
Links

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.