Uncategorized

hunterfan-blog-post

Reverse-engineering a Hunter ceiling fan remote

My ceiling fan came with a hand-held 433 MHz remote that — like most
of them — was starting to die. Hunter sells replacements, but they
won’t show up for a week, and the OEM remote isn’t smart-home friendly
anyway. I wanted the fan in my LCARS smart-home dashboard, which means
an ESP8266 needs to speak the same protocol the OEM remote does.

A few hours with a cheap 433 MHz receiver, and an
oscilloscope was enough to figure out the protocol and build a small
Arduino library that can both learn captured packets and replay
them. The library is up on GitHub: breandan/HunterFan.

This post is a walk-through of what the protocol actually looks like
on the air, why it’s shaped the way it is, and how the library
implements it.

Hardware

  • A cheap 433 MHz SYN115 OOK transmitter module on a GPIO.
  • A matching SYN480R OOK receiver on an interrupt-capable GPIO.
    (Optional — you only need this if you want to capture packets from
    the OEM remote.)
  • Any Arduino-flavored MCU. I’m using an ESP8266, but the library
    builds for ESP32 and AVR too.

OOK (“on-off keying”) is the simplest possible RF modulation: you turn
the carrier on for a HIGH bit and off for a LOW bit. The receiver
module hands you a clean digital signal — no demodulation work needed.
What’s left is figuring out the framing on top of that digital
bitstream, which is the interesting part.

The bit clock

Every other timing in this protocol is in multiples of a single bit
clock period. I’ll call it T. By measuring the shortest pulses on
a capture, T comes out at 400 µs — close enough to “400” that the
firmware almost certainly uses it as the configured value.

The library exposes T as a public field, so you can adapt it to other
similar OOK remotes:

HunterFan fan(TX_PIN);
fan.clkUs = 400;   // T, the bit clock
fan.begin();

Bit encoding — PWM in a 3T window

Each data bit takes one 3T window — 1.2 ms total — and the ratio
of HIGH-to-LOW within that window encodes the value:

Bit Waveform
1 2T HIGH, then 1T LOW
0 1T HIGH, then 2T LOW

Bit encoding

This is pulse-width modulation, but with a twist: the transition
between any two bits is at a known place (the 3T boundary), so you can
recover the clock from the signal itself. Decode is dead simple — for
each 3T window, look at how long the line stayed HIGH.  A fun thing about
this is that it means you don’t actually need to listen to the levels at all. 
All the information is encoded in the time between transitions, and
whether the edges are falling or rising is irrelevant.

Frame structure

A full transmission has four parts: preamble, anchor gap, data, and
inter-frame gap.

Frame structure

Preamble. About 60 alternating HIGH/LOW pulses, each 1T wide. This
is purely for the receiver’s automatic gain control to settle. Cheap
OOK receivers have a slow AGC loop and need a steady stream of edges
to find their bias point before they can reliably distinguish 0s from
1s — the preamble is the OEM’s way of making sure that’s stable before
the actual data starts.

Anchor gap. A single LOW period of 13T (~5.2 ms) that marks
the boundary between preamble and data. Because the preamble is at 1T
and the longest in-band pulse during data is 2T, this 13T gap is
unmistakable — a perfect framing token.

Data. 65 or 66 PWM-encoded bits.
That’s enough to pack a device-ID, a button code, and a checksum.

Inter-frame gap. ~26 ms of silence between frames.

Repeats — fault tolerance the easy way

RF in the 433 MHz ISM band is noisy. Every garage door opener,
weather sensor, doorbell, and tire-pressure monitor in your
neighborhood is also splattering OOK in this range. To deal with that,
the OEM remote transmits every packet three times with a 26 ms gap
between repeats:

Repeat structure

A receiver that catches any one of the three frames cleanly is
enough. The library transmits with repeats = 3 to match the OEM,
but you can crank it up if your reception is dodgy.

What a real command looks like

Here’s the first byte of a captured “light toggle” command — 0xA6,
sent MSB-first on the wire so it reads as 1010 0110 left-to-right:

Real command, first byte

You can read each bit straight off the trace by the width of the HIGH
pulse — wide pulse = 1, narrow pulse = 0. The full command is 66 bits,
so this image is just the first 9.6 ms of it.

The bit-reversal quirk

One detail that’s easy to get wrong: the protocol transmits each byte
MSB-first, but most bit-manipulation code in C is naturally LSB-first
(bit 0 is the low bit of byte 0). The library handles this by
bit-reversing each byte on the way in and out, so the hex string
you pass to sendHex() matches what a logic analyzer would display
(left-to-right, MSB first), but the internal storage is in the
LSB-first order that _getBit/_setBit want:

out[n++] = _reverseBits((uint8_t)((hi << 4) | lo));

It’s a small thing, but it’s the difference between “transmit a
captured packet and see it work” and “transmit a packet and watch
nothing happen because the fan got 0x65 instead of 0xA6”.

A few of my captured commands

Button Hex (66 bits)
fan A6FF346CBB011FEE00
up A6FF346CBB100EFF00
down A6FF346CBB1016FE80
light A6FF346CBB18067F80

You can see the shared prefix A6FF346CBB is presumably the remote’s
device ID (paired to my fan), and the last few bytes are the command

  • checksum. Hunter doesn’t publish the checksum algorithm, but for
    my fan it doesn’t matter — I’m replaying captured packets verbatim,
    not synthesizing new ones, so I just need to faithfully reproduce the
    bits I recorded.

Using the library

Two examples — capture and replay:

#include <HunterFan.h>

HunterFan fan(/* txPin */ 12, /* rxPin */ 13);

void setup() {
    Serial.begin(115200);
    fan.begin();
}

void loop() {
    uint8_t buf[16];
    uint8_t bits;
    if (fan.receive(buf, sizeof(buf), bits, /* timeoutMs */ 5000)) {
        Serial.printf("Got %u bits: %sn",
                      bits, HunterFan::toHex(buf, (bits + 7) / 8));
    }
}
#include <HunterFan.h>

HunterFan fan(/* txPin */ 12);

void setup() {
    fan.begin();
    fan.sendHex("A6FF346CBB18067F80", 66);   // toggle the light
}

void loop() {}

The toHex output and sendHex input share a format, so a capture
round-trips verbatim.

Timing on the ESP8266 — one small surprise

The ESP8266’s WiFi stack runs in an interrupt context, and any ISR
during a transmit can shift a HIGH or LOW pulse by tens of µs. That’s
not enough to break the receiver — the library’s ±45 µs tolerance
absorbs it — but if you really want clean transmits you can set:

fan.disableInterruptsDuringTx = true;

…on ESP8266 and ESP32. Don’t set it on AVR though: disabling
interrupts there kills the Timer0 overflow that micros() depends on,
and you’ll hang inside _waitForNextClock() after about 2 ms.

The transmit routines and ISR handler are tagged with
ICACHE_RAM_ATTR / IRAM_ATTR so the code lives in IRAM and isn’t
subject to flash-cache stalls — another source of timing jitter if you
forget to do it.

What I’d do differently

  • Add a checksum solver. Right now I can only replay captured
    packets. If you wanted to synthesize a command you hadn’t recorded
    from a paired remote, you’d need to reverse Hunter’s checksum. Most
    of these protocols use a CRC-8 or XOR-fold of the device-ID +
    command — a weekend project for a future me.
  • Non-blocking receive. receive() blocks until it gets a packet
    or the timeout fires. For a one-shot “learn this button” UI that’s
    fine, but if you want a long-running listener you’d want a
    callback-driven API instead.

Source

Library + examples: https://github.com/breandan/HunterFan

MIT licensed — drop it into your libraries/ folder and have at it.