Gazebo Fans — Cloning the FanLamp Remote

June 2026

Two Bluetooth ceiling fans (fan + dimmable light combos) hang in the campsite gazebo, and I wanted them in Home Assistant. They came with little RF-looking remotes and a phone app called FanLamp Pro. Simple enough, I figured — find the GATT service, poke the characteristics, done.

Except these fans don’t have a GATT service to connect to. They don’t accept connections at all.


They’re controlled by advertisements, not connections

I pulled the FanLamp Pro APK apart to see how the app talks to them. It’s a Flutter app, so the business logic is AOT-compiled into a libapp.so, but the actual BLE packet codec is a separate native library, libencodeV3.so. Its exported symbols told the whole story: ble_v2_encode, ble_v3_encoder, AES_ECB_encrypt, v2_crc16_ccitt. There’s a clean Java-to-native bridge class (ToolV3) with one method per command — blevbFanSpeed, blevbLampOnOff, blevbDimming, blevbTemperature, and so on — and each one returns two byte arrays: a v2 advertisement and a v3 advertisement.

That’s the key insight. The app doesn’t connect to a fan and send it a command. It broadcasts an AES-encrypted BLE advertisement — a beacon packet — and any fan in range that decrypts it and likes the contents acts on it. No connection, no pairing, no bond. Control is fire-and-forget broadcast.

This is a known protocol family (FanLamp Pro / “FLP” combos, with a color-temperature white channel), and it’s already fully reverse-engineered and maintained upstream by NicoIIT. So the right move wasn’t to reimplement the AES codec — it was to stand on that work:

  • an ESP32 at the gazebo runs ESPHome with NicoIIT’s esphome-ble_adv_proxy — a thin bridge that just transmits and receives raw BLE advertisements on command;
  • the campsite HA Pi runs the ha-ble-adv integration, which holds the actual codec, computes the encrypted adverts, and exposes native fan.* / light.* entities.

The Pi is inside the camper, out of BLE range of the gazebo, so the ESP32 is the transmitter that reaches the fans. It’s an active proxy — it broadcasts crafted adverts — not a passive Bluetooth proxy that just relays what it hears.


The “pair” flow doesn’t work, and why

The integration has a Pairing flow: it invents a fresh device ID, broadcasts a “bind to me” advert, and you power-cycle the fan so it comes up in a learn window and latches onto the new ID. This is the clean, obvious path.

It failed on every codec variant. Twice.

These fans don’t have a learn-on-power-up window. They’re not blank devices waiting to be adopted — they’re factory-bound to the specific remotes they shipped with. Broadcasting a new ID and rebooting the fan does nothing, because the fan was never listening for a new owner.

What works is the opposite approach: don’t make the fan learn me, clone the remote it already trusts. The integration’s wait_config flow listens on the ESP32 proxy for about ten seconds while you press a button on the fan’s own remote. It captures the remote’s real device ID off the air, decodes it into a few candidate (codec, ID) interpretations, and replays each as a blink so you can confirm which one the fan actually obeys.

Press the remote, watch the fan blink, confirm. That’s it. And because each fan came with its own remote — its own distinct ID — cloning them gives me two fully independent devices:

FanCodecentities
Gazebo Fan 1remote_v3light (warm/cool white), fan (6-speed)
Gazebo Fan 2fanlamp_pro_v3light (warm/cool white), fan (6-speed)

(An amusing footnote: my APK analysis had pointed me at the fanlamp_pro_v2 codec, and the real on-air format turned out to be the *_v3 / remote_* family. The app emits both v2 and v3 for every command, and the fans happen to honor v3. The confirm-by-blink step is what actually pins it down — the static analysis just narrows the field.)


The takeaway

When a device ships with its own remote, clone the remote instead of trying to make the device learn Home Assistant. These fans were never going to enter a pairing mode, because from the factory’s point of view they were already paired — to the remote in the box. Once I stopped fighting that and started impersonating the remote, both fans came up as native HA entities in minutes.

They’re bridged over MQTT to home HA as well (device “Campsite Gazebo”), so I can spin them up before we even get to the campsite. The same ESP32 doing the broadcasting later picked up a second job entirely — polling the golf cart battery — but that’s a different story.

Wesley Ray · blog · git · resume