I spent two days implementing Dreo's Bluetooth pairing protocol from decompiled Android source. I wrote unit tests for it. I felt pretty good about it. Then I pointed it at an actual fan, and the fan ignored every single byte I sent.
The code wasn't buggy. It was just for a completely different product.
This was my first time reverse-engineering anything. I'd never decompiled an app, never hooked a running process, never looked at a BLE capture in my life. I mention that partly as a disclaimer and partly as encouragement, because the thing I was most scared of turned out to be the easy part, and the thing that actually cost me days was ordinary stubbornness about a wrong assumption.
So below is the protocol that really works: the service UUIDs, the message types, and the ordering rule that ate an entire evening before I worked out what was going on. As far as I can tell nobody had written this down anywhere. If you have a Dreo fan and you'd like to get it onto your WiFi without using their phone app, this should be everything you need.
Where it went wrong
The whole thing started because I wanted to turn a fan on from my keyboard. That was it, that was the entire ambition. So I started building a little macOS menu bar app for it, called Windbar, and assumed the hard part would be the UI.
The cloud side turned out to be straightforward. You log in over REST, you get a token, you open a WebSocket, and you send commands down it. Fine. The interesting part was setting up a brand new fan, because that happens over Bluetooth, directly between your phone and the fan, and nobody had documented any of it.
So I did the obvious thing and pulled the Android APK apart to read what it does.
It told me a very clear story. GATT service FFB4, WiFi password RSA-encrypted into a fixed byte layout, every field neatly accounted for. I implemented the whole thing, wrote tests around the byte layout, and went to bed happy.
The real fan advertised service FFFF and had no interest in anything I sent it.
Here's the annoying part: the FFB4 code was real. It just belongs to a different Bluetooth SDK that the same app also ships, for other Dreo product lines. Nothing in the decompiled source says "by the way, this path is dead for the fan sitting on your desk." Static analysis will happily hand you a complete, internally consistent, and totally wrong picture of the world, and your unit tests will nod along, because all they're really checking is that your code matches your assumption.
That's what got me. Not that I was wrong, but that everything available to me said I was right.
What actually worked
Eventually I stopped reading and started watching. I patched the Android app with a Frida gadget, hooked its Bluetooth calls, and paired a fan for real while listening in.
Ten minutes of that was worth more than the two days of reading.
There's no RSA anywhere. There's no FFB4. The protocol is CBOR messages going over one write characteristic and one notify characteristic, and the WiFi password crosses in cleartext. Bluetooth's own link encryption is the only thing protecting it, which is worth sitting with for a second if you've ever typed your home WiFi password into a fan.
Worth being precise about how bad that is, since "cleartext WiFi password" sounds worse than it is. To capture it you'd need to be within Bluetooth range during the handful of seconds that a fan is actually being paired, on a channel-hopping link you'd have to be sniffing already. It isn't remotely exploitable and it isn't sitting there waiting for you. It's still the kind of thing you'd rather see wrapped in application-level encryption, which is presumably what the unused RSA code in the other SDK was for.
The tools, for anyone else doing this for the first time
There are really only two jobs here, and it took me a while to understand they're different jobs.
Reading the app is static analysis. You take the APK, decompile it, and read what the code says it does. This is the part that looks like real reverse engineering in films, and it's genuinely useful for orientation: it's how I found the class names, the message type strings, and the general shape of things. It is also, as covered above, perfectly capable of lying to you with total confidence.
Watching the app is dynamic analysis, and it's what actually solves the problem. I used Frida, which lets you inject a bit of JavaScript into a running process and intercept function calls as they happen. The important detail for a beginner: you don't need a rooted phone. Frida ships a "gadget" you can repack into the APK itself, so the app carries the instrumentation with it and runs on an ordinary device.
(I'd fought with Bluetooth once before, from completely the other end, getting it working inside a Proxmox VM. Different problem, same feeling of the stack quietly lying to you.)
Once it's in, the hook itself is almost disappointingly small. Android only has so many ways to push bytes over BLE, so you wrap the write call and the notification callback, log the payloads as hex, and then go pair a fan normally with your own phone. The log that comes out the other side is the conversation, in order, exactly as the real app has it. Every answer in this post came from reading that log.
Two things nobody told me:
- Capture the whole session from the very first byte. I initially hooked things partway through and got the messages but not the opening handshake, which is precisely the part I needed and precisely the part I later wasted an evening on.
- Log timestamps. Some of these exchanges are the fan taking its time rather than the fan refusing you, and without timing you can't tell the difference between "rejected" and "still thinking".
For reference, all of this was captured against a Pilot Max tower fan. Other models speak the same protocol as far as I can tell, but I've only watched that one do it.
The protocol
This is the part I actually wanted to publish, since nobody else has.
| Service | 0000ffff-0000-1000-8000-00805f9b34fb |
| Write | 00009b01-0000-1000-8000-00805f9b34fb |
| Notify | 00009b02-0000-1000-8000-00805f9b34fb |
| Advertises as |
DREO plus a suffix, like DREOpf08s8E
|
Every message has the same shape, {"t": <type>, "v": <version>, "d": <payload>}:
| Type | Who | Meaning |
|---|---|---|
st |
app | Set the clock |
rd / ri
|
both | Ask the fan who it is, and its answer |
pd |
app | Which account and which API host to phone home to |
rw / wl
|
both | Ask for a WiFi scan, results streamed back in batches |
cw |
both | Here are the credentials, then progress reports |
cf |
fan | Final verdict, including its own internet self-check |
ee |
fan | Something went wrong |
Try it in thirty seconds
If you own a Dreo fan, you can confirm half of this right now without writing anything. Plug the fan in, hold the Oscillation button for about five seconds until the WiFi light blinks, then open any BLE scanner (nRF Connect is fine). You'll see it advertising as DREO plus a suffix, offering service FFFF. That's the thing the decompiled source told me didn't exist.
And here's the message that actually carries your WiFi credentials, cw, with the field names as they really go over the wire:
a auth type
b 0
c channel
m { ca: 0, cm: 0, da: 0 }
p password, cleartext
s ssid
t { ca: 60000, cm: 60000, da: 30000 }
sc 1 if the fan should self-check its internet after joining
scc { dm: [ "http://www.google.com", ... ] } the URLs it probes
Two things about that block took me longer than they should have. The fan parses the map positionally rather than by key, so the field order above isn't cosmetic, it's part of the format. And the final cw write omits the v version field that every other message carries, which I only believed after diffing my bytes against the capture for the third time.
The ordering rule that cost me an evening
Knowing the messages isn't enough, and this is the bit I wish someone had told me. The order matters.
If you send st, then rw, then cw, the fan refuses to join the network. And I mean it refuses even when the cw bytes are byte-for-byte identical to a capture of a pairing that worked. I diffed them, more than once, convinced I'd miscounted something. They matched exactly. The fan still said no.
What it's waiting for is the same opening the real app uses:
st -> rd -> pd -> rw -> cw
Introduce yourself, mention which account you're acting on behalf of, and then ask it to join a network. Add those two messages and it works on the first try.
Looking back, that makes a kind of sense. A protocol isn't really a set of messages, it's a conversation with a state machine you can't see, and the state machine is almost never documented. Capturing one successful run gives you the messages. Capturing it in order is what gives you the protocol.
That's the whole thing, by the way. Everything above is what you need to pair a Dreo fan yourself, in whatever language you like. It's maybe 250 lines of CBOR encoding and a small state machine, and the encoder has one trap in it: map keys have to keep their insertion order, which Swift dictionaries don't do, and the fan will reject you if they come out shuffled. If you'd rather not write all that, my implementation is here and it's MIT.
Once the message ordering was right, the whole protocol collapsed into about four screens of wizard:
Everything in the tables above, hidden behind "The Light Is Blinking".
The bug I invented out of thin air
While I was stuck, I managed to convince myself the real problem was Bluetooth packet size.
The reasoning was lovely. Dreo's app calls requestMtu(512), and warns in its own source that large writes fail on peripherals that can't reassemble them. My message was 213 bytes. CoreBluetooth gives you no way to request an MTU at all. So: clearly a platform limitation, clearly a dead end, clearly not my fault.
Then I printed the number. macOS had already negotiated 515. My 213-byte message fit in a single write with room to spare, and had done all along.
The theory had been dead on arrival, and I'd have spent a day "fixing" it if I hadn't checked. Honestly, most of the hours I lost on this project went the same way: I believed something I could have verified in thirty seconds.
I did keep one piece of the dead theory. There's a flag that drops the scc internet-self-check block from the credentials message, taking it from 213 bytes down to about 78, and it's still in the code. It's off by default, because on the hardware I can actually test the full message is fine. But I only ever measured one Mac talking to one fan, and I'm not confident enough in that sample to delete the smaller path. Being wrong about the diagnosis doesn't mean the escape hatch was a bad idea, it just means it isn't the thing that was broken.
The device that hid all the others
One more, from the cloud side rather than the Bluetooth side, because it's the bug I'd most like to warn people about.
The nice thing about Dreo's API is that devices describe themselves. Each one returns a controlsConf blob saying "I have a Speed control that goes 1 to 12" and "here are my four modes", so the app renders whatever it's told rather than knowing anything about fans. Then I paired a newer model and it came back with this:
"controlsConf": { "template": "DR-HPF008S" }
No controls, just a pointer at a template that lives inside Dreo's own app binary. Fine, annoying, solvable.
What wasn't fine: that one device broke decoding for the entire list. The app showed no fans at all and confidently blamed a failed login, so I spent the first while debugging authentication that was working perfectly. One unfamiliar device had taken down every other device in the account.
Both the device list and the control sections now decode item by item and drop only what they can't parse. If you're deserialising a collection from someone else's API, that's the whole lesson: one weird element should cost you that element, not the collection.
What I'd tell myself at the start
- Treat decompiled code as evidence, not as truth. It shows you what the binary is capable of, never what your particular device actually does. Confirm against real hardware before you build on top of it.
- Instrument the real client first. Ten minutes of Frida on the official app replaced two days of reading, and it handed me the message ordering that static analysis could never have shown me.
- Measure before you theorise. If you can check a hypothesis in under a minute, checking it is always cheaper than reasoning about it, no matter how good the reasoning feels.
None of those are clever insights. They're the sort of thing every person who does this for a living presumably learned years ago. But I hadn't, and each one cost me real hours, so if you're also doing this for the first time, maybe they'll cost you fewer.
The app, if you want it
All of this ships in Windbar, a small macOS menu bar app for Dreo fans. You click the icon and your fans are there. There's a global hotkey, and a windbar://toggle URL so Shortcuts or a Stream Deck can drive it. And it pairs a brand new fan onto WiFi with no phone involved, which is the part this whole post has been about.
One thing you can't see in that screenshot: none of those controls are hardcoded. Dreo's API hands back a controlsConf blob describing what each device can do, right down to "I have a Speed control that goes 1 to 12" and "here are my four modes and their names", so the app just renders whatever it's told. A tower fan and an air circulator both work without a line of model-specific code. That part was a genuinely pleasant surprise after the Bluetooth ordeal.
It's Swift 6 with strict concurrency, a hand-rolled CBOR encoder, and 59 tests, none of which touch the network.
Free, open source, MIT. If the tables above saved you a weekend, the repo is the place to say so:
github.com/lucid-fabrics/windbar
Two honest caveats before anyone downloads it. It's an independent project, not made by or affiliated with Dreo in any way, it just talks to their fans. And it needs a Dreo account with an actual password: if you signed up with "Continue with Google" or "Sign in with Apple" it won't work at all, because the login endpoint only accepts grant_type=email-password. There's nothing I can do about that from the outside, and I'd rather you knew now than after installing.
Two loose ends I'd love help with, if you're the sort of person who reads to the end of posts like this:
- The
eeerror message comes back as four bytes,03 02 00 01, and the official app's own parser expects a completely different shape, so it appears to never actually receive one. I never worked out what it means. - Everything here was captured against one fan, a Pilot Max tower. If you have a different Dreo model and it speaks this protocol, or refuses to, I'd genuinely like to hear which.
Either one, drop it in the comments or open an issue.


Top comments (0)