DIR << back
Decoding Dooya: 40 Bits Between You And Your Curtains
=====================================================
I have three motorised rollerblinds in my flat. They came with cheap 433 MHz
remotes and no documentation. Like any reasonable person I wanted them in Home
Assistant; like any unreasonable person I decided to do it the hard way.
A few years ago I got it working: raw RF captures replayed through a CC1101
transceiver and a Wemos D1 Mini. The blinds moved. The config was 370 lines of
YAML, a C++ header file, two Arduino libraries, and zero understanding of what
protocol I was actually speaking.
IMG ./PXL_20260531_211111005.jpg
"Recently" ESPHome 2025.12 shipped native support for both the CC1101 chip and
the Dooya protocol. Which raised two questions worth answering:
[a] Are my blinds actually Dooya?
[b] How much of the existing config can I delete?
The short answers turned out to be "yes" and "two thirds of it".
This post documents the reverse engineering, the protocol identification done by
hand from the raw timings, and the migration to a config that human beings can
read too.
## The captures
Initial captures were done with a Flipper Zero and the Sub-GHz app in "Read RAW"
mode - tuned to 433,92 MHz, AM650. Hold each remote next to the Flipper, press a
button. save the .sub file. Repeat for each button on every remote.
Good now I have captures and I can successfully replay them from the Flipper.
But the Flipper isn't a stationary device, and I wanted my Home Assistant to
drive the blinds without me sitting on the couch with a hacking tool. My first
attempts at a permanent setup used cheap Chinese 433 MHz transmitter/receiver
module packs with a Raspberry Pi, then an ESP8266. The Pi failed because
general-purpose computers are terrible at microsecond-accurate GPIO timing; the
ESP8266 with a dumb fixed-frequency TX module failed for less interesting
reasons. Then it hit me - I could just use the same chip the Flipper uses. The
CC1101.
The CC1101 is nice in part because it can do TX and RX over a single data pin
called GDO0 - the chip handles the direction switching internally. That means
four wires for SPI (clock, MOSI, MISO, CS) and one more for the data line.
Wiring is almost trivial:
Wemos D1 Mini (ESP8266)
|
+-- SPI --> CC1101 433 MHz transceiver module
|
+-- GPIO5 (D1) --> GDO0 on the CC1101 (TX/RX data line)
In ESPHome land that one GDO0 pin gets handed to both `remote_receiver` and
`remote_transmitter`, with `allow_other_uses: true` on every reference. More on
that landmine later.
Worth noting: the native cc1101 docs actually recommend a *dual*-pin wiring -
GDO0 for TX, GDO2 for RX - which sidesteps the allow_other_uses juggling
entirely. My module only breaks out GDO0, so I'm on the single-pin path; if
you're wiring from scratch, run both pins.
A Flipper .sub file for a raw capture looks like this (timings rounded for
illustration; real captures jitter):
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433920000
Preset: FuriHalSubGhzPresetOok650Async
Protocol: RAW
RAW_Data: 4900 -1480 350 -700 720 -350 350 -700 720 -350 ...
The RAW_Data line is what we want. Same convention as ESPHome: positive number =
microseconds high, negative = microseconds low.
Strip the metadata, comma-separate the numbers, wrap them in square brackets,
and you have a working ESPHome transmit_raw payload:
[4900, -1480, 350, -700, 720, -350, 350, -700, ...
720, -9700]
I did the conversion with a one-line awk:
awk '/^RAW_Data:/ {
sub(/^RAW_Data: /, "");
gsub(/ /, ", ");
print "[" $0 "]";
}' livingroom_up.sub
Paste the result into a remote_transmitter.transmit_raw action, add a transmit
GPIO and a CC1101 to drive it, and the blinds move. Done. Except we still don't
know what we just replayed. The whole point of the rest of this post is reading
those numbers instead of just shoveling them around.
## Reading the timing array
### The preamble is always the giveaway
First three numbers of every capture:
[4900, -1480, 350, ...]
A ~4,9 ms high pulse followed by a ~1,5 ms low. This is too long to be a data
bit and it's the same in every capture from every remote. It's a preamble: a
sync burst whose only job is to give the receiver's AGC (Automatic Gain Control)
time to lock onto the signal before any data arrives. Almost every cheap 433 MHz
protocol has one.
Length of the preamble alone is a fingerprint. ~4,9 ms high + ~1,5 ms low is in
the Dooya/RAEX family. It's not the ~9 ms lead pulse of NEC IR. And it's the
wrong shape for EV1527, whose sync is a short pulse trailed by a long (~10 ms)
gap rather than a long high burst.
### Bits, pairs, and PWM
After the preamble, the rest of the capture is a long sequence of short-ish
pulses.
Bin the durations into two buckets:
Short pulse: ~350us
Long pulse: ~700us
(Real captures jitter by 20-50us either way; treat those numbers as midpoints,
not constants.) Pair them up and you get two kinds of pulse-pair:
[350, -700] (short high, long low) <-- bit "0"
[700, -350] (long high, short low) <-- bit "1"
This is textbook PWM encoding. Each bit takes ~1 ms wall-clock time. The high
portion encodes the value: short = 0, long = 1.
(I'll read short-high as "0" here — the naive PWM reading. Dooya as it turns out
defines the opposite polarity, so we'll complement the whole 40-bit string once
at the end to land the fields in the right place. rtl_433 encodes exactly this
with its `invert` decoder flag.)
### The terminator
Last pair of every capture:
[..., 720, -9700]
A long high followed by ~10 ms of silence. That gap is giant compared to a data
bit. It's an inter-frame terminator that says "frame done, start the next one
whenever". The remote actually transmits the same frame several times in a row
separated by these gaps, presumably for reliability.
### Counting to forty
Between preamble and terminator, count the bit-pairs:
(total_numbers - 2 preamble - 2 terminator) / 2 = bits
For my captures that lands consistently on 40 bits per frame. Forty -
hold that thought.
## Pattern matching against the protocol zoo
### 433,92 MHz, ASK/OOK, 40 bits
Search the prior art for "433 MHz blinds 40 bits PWM 4,9 ms preamble" and exactly
one family of protocols comes up: Dooya. Also sold as RAEX, Rollease Acmeda, and
a stack of OEM rebrands. The FCC filing for the DC90 transmitter confirms
433,92 MHz / ASK modulation. rtl_433 has a "Dooya-Curtain" decoder. Flipper Zero
has been able to capture (but not always decode) them since 2022. esphome has
shipped a Dooya codec in remote_base for a while.
The fingerprint matched. So now I had to actually decode my frames.
### The Dooya frame structure
As per rtl_433 source and various reverse-engineering writeups, the 40 bits
split like following:
[ 24 bits | 8 bits | 4 bits | 4 bits ]
ID channel button check
ID: paired to the motor when you programmed it
channel: which "group" on a multi-channel remote (mine: 0x05)
button: 1 = up, 3 = down, 5 = stop (short press)
check: 4-bit field that includes a held-down indicator. On my three
remotes a short press happened to echo the button (1/1, 3/3, 5/5),
but that's not universal - many Dooya remotes read 14 for up, 12
for down, 5 for stop on a short press. Treat "mirrors the button"
as true for my units, not the protocol. Long press changes the
value to flag the hold.
### Applying it to my three remotes
Take the Livingroom "up" capture. Strip the preamble (first two
numbers) and the terminator (last two numbers). What's left is 80
numbers - exactly 40 pulse-pairs.
Walk through the first few by hand:
[350, -700] short high, long low -> 0
[700, -350] long high, short low -> 1
[350, -700] -> 0
[700, -350] -> 1
[350, -700] -> 0
[700, -350] -> 1
[350, -700] -> 0
[350, -700] -> 0
...
Do that for all 40 pairs and concatenate. With the naive "short = 0" reading you
get:
0101 0100 1110 1101 0011 1100 | 1111 1010 | 1110 | 1110
That doesn't split into anything clean. Complement every bit (Dooya's inverted
polarity - rtl_433's `invert` flag):
1010 1011 0001 0010 1100 0011 | 0000 0101 | 0001 | 0001
\_______ ID = 0xAB12C3 _______/ Ch = 5 btn=1 chk=1
That matches the Dooya frame layout exactly. The motor sees this
sequence, recognises ID 0xAB12C3 as a remote it's paired with,
channel 5 as the channel it's listening on, button 1 as "up", and
check 1 as "short press". The blind goes up.
Repeat for all three remotes. Tabulate:
Room | ID | Ch | Up | Down | Stop
------------+------------+------+-------+-------+-------
Livingroom | 0xAB12C3 | 0x05 | 1/1 | 3/3 | 5/5
Kitchen | 0xAB12CD | 0x05 | 1/1 | 3/3 | 5/5
Bedroom | 0xDE34F5 | 0x05 | 1/1 | 3/3 | 5/5
(The X/Y columns are button/check. All three of mine echoed the button on a
short press; trust your own `dump: dooya` output rather than assuming the same.)
All three remotes share channel 0x05 because that's the channel they were paired
to the motors on. Two of the three IDs differ by exactly the low byte, which
suggests they came off the same production line on the same day. Cool.
NOTE: the remote IDs in this post have been changed. Dooya uses static codes
with no rolling; publishing real IDs is publishing a key. The walkthrough is
otherwise unmodified -- substitute your own captures.
## Burning it all down: native components
Knowing what protocol I was speaking meant I could finally throw out the
magic-numbers config. Two things in the ESPHome ecosystem made this much cleaner
than it was two years ago.
First, native CC1101 support. PR #11849 landed a real `cc1101:` component in
ESPHome 2025.12 - no external libraries, no C++ header includes, no manual TX/RX
state machine. The chip is a first-class citizen with proper config for
frequency, modulation, AGC, output power, the lot.
Second, native Dooya encode/decode. ESPHome's `remote_base` has had a Dooya
codec for a while, exposed as `transmit_dooya` for sending and `dump: dooya` /
`on_dooya` for receiving. I just hadn't known my blinds were Dooya, so I'd never
reached for it.
Before / after:
OLD NEW
Lines ~370 ~150
C++ files 1 (cc1101.h) 0
External libs 2 0
Lambdas per cmd 2 0
Receives remote? no yes
Old action (excerpt):
open_action:
- lambda: get_cc1101(transciver).beginTransmission();
- remote_transmitter.transmit_raw:
code: [4900, -1480, 350, -700, 720, -350, ...] # truncated
repeat: { times: 3, wait_time: 99ms }
- lambda: get_cc1101(transciver).endTransmission();
Three cover entities, three actions each, raw arrays for every action. Roughly
370 lines. Reading it taught me nothing about my blinds.
Native cc1101 block (an `spi:` bus block with the clk/mosi/miso pins is assumed
above it and omitted here for brevity):
cc1101:
id: transceiver
cs_pin: D8
gdo0_pin:
number: D1
allow_other_uses: true
frequency: 433.92MHz
modulation_type: ASK/OOK
output_power: 10
symbol_rate: 5000
filter_bandwidth: 203kHz
New action:
open_action:
- remote_transmitter.transmit_dooya:
id: 0xAB12C3
channel: 0x05
button: 1
check: 1
repeat: { times: 2, wait_time: 20ms }
And the receive side gets dump: dooya, which means the ESP can now also *react*
to the physical remote, not just impersonate it.
## Wrapping it up
Three takeaways:
1) Cheap 433 MHz protocols are not magic. The data is in the
timing array. Look at it.
2) If you can identify the preamble length, symbol rate, and bit
count, you can identify the protocol family in five minutes.
rtl_433's protocol list is the lookup table.
3) ESPHome's component ecosystem improves fast. If you wrote
something with custom_component or external lambdas more than
two years ago, it's probably native now. Go check.
The old config is dead. The new config controls the same blinds with 1/3 the
lines, without external libraries or custom_components, bidirectional operation,
and code I can read out loud and explain. All packaged up in a nice 3D-printed case.
IMG ./PXL_20260531_211201386.jpg
Room for future optimization: the ESP8266 is at the edge of its capabilities
here and quite old in the meanwhile.
A move to ESP32 + ESP-IDF would give me hardware-timed RMT and dual-core
scheduling - and if I rewire to the recommended dual-pin GDO0/GDO2 layout at the
same time, a TX/RX setup that drops the allow_other_uses hack altogether.
## Gotchas
Things I wish I'd known before starting. In rough order of how much time each
one cost me:
**The ESPHome Dooya decoder has a known off-by-one** versus Flipper and rtl_433
(esphome/issues#5721). My hand decode matched the protocol exactly, but the
values ESPHome's encoder expects are the ones its *own* decoder produces. Always
trust `dump: dooya` log values when writing transmit actions, not what you
derived from a raw capture.
A related symptom: the Flipper Zero captured my remotes cleanly in raw mode but
never auto-decoded them as Dooya, despite having a Dooya decoder. Same family of
disagreement - the raw signal is fine, the decoders just don't agree on it.
**`on_complete` must call `cc1101.begin_rx`**, not `cc1101.set_idle`, or the
chip stops listening to the physical remote after the first transmit. One-line
mistake, took me an hour to find.
**Cut your repeat count on ESP8266.** My old config sent every command 3x with
99 ms gaps - about 360 ms of blocking main-loop time, which trips the API
watchdog with "took a long time" warnings. 2x with 20 ms gaps lands at ~130 ms
and the blinds still respond reliably.
## References
1. [CC1101 datasheet, Texas Instruments](https://www.ti.com/lit/ds/symlink/cc1101.pdf)
2. [ESPHome cc1101 component](https://esphome.io/components/cc1101/) (added in 2025.12)
3. ESPHome [remote_transmitter](https://esphome.io/components/remote_transmitter/) / [remote_receiver](https://esphome.io/components/remote_receiver/)
4. [rtl_433 Dooya-Curtain decoder](https://github.com/merbanan/rtl_433)
5. [FCC filing, DOOYA DC90 RF Transmitter](https://fcc.report/FCC-ID/VYY-DC90)
6. [esphome/issues#5721](https://github.com/esphome/issues/issues/5721) (Dooya decoder off-by-one)
7. [nickw444's RAEX reverse-engineering writeup](https://github.com/nickw444/home/tree/master/blindkit)
8. [Flipper Zero Sub-GHz documentation](https://docs.flipper.net/sub-ghz)
9. [Flipper Zero .sub file format](https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/file_formats/SubGhzFileFormats.md)
---------------------------------------------------------------=|
powered by openbsd & geomyidae(8) | accessible via tls & tor
(c) 2016-2026 drkhsh. CC BY-NC-SA 4.0