QuietCool whole-house fan — closed-loop RF control in ESPHome

I’ve been running my QuietCool whole-house fans from Home Assistant over their
native 433.92 MHz radio for a while, and I’ve cleaned the firmware up into a
public ESPHome project:

GitHub - joyfulhouse/esphome-quietcool: ESPHome firmware to control QuietCool whole-house/attic fans over their native 433.92 MHz 2-FSK RF link from Home Assistant. Reverse-engineered from the OEM remote firmware. · GitHub (MIT)

There’s already a great thread and repo from Thadd Selden that reverse-engineered
this protocol with URH and a CC1101 — that’s what proved it was doable. This is
an independent implementation built from a firmware teardown of the OEM remote,
with a few things I wanted for my own install: a proper Semtech LoRa transceiver
instead of a CC1101, full bi-directional state, and self-service pairing so you
never have to sniff packets or hard-code a controller ID.

What it does

  • A real Home Assistant fan entity — Off / Low / Medium / High plus the
    OEM 1/2/4-hour timers, transmitted as the exact frames the glass remote sends.
  • Bi-directional. This was the big one for me. The controller doesn’t just
    transmit — it listens on the same channel. Press the physical glass remote
    and the HA entity updates to match within a second. It strictly validates
    received frames and mirrors them into the entity without ever
    re-transmitting
    , so there’s no RF echo or feedback loop. HA and the wall
    remote stay in sync no matter which one you use.
  • Learn-mode pairing. Every QuietCool remote has a unique 4-byte sender ID.
    Instead of sniffing it or dumping firmware, the controller learns it over the
    air: flash it, then press any button on your OEM remote twice (about a second
    apart). The OLED shows LEARNED / ID SAVED, the ID persists to flash, and
    you’re done. A two-burst guard means a neighbor’s remote on the same band
    can’t accidentally pair itself.
  • On-device OLED — animated fan icon, HH:MM:SS timer countdown, three
    HA-relayed temperatures (indoor / outdoor / attic, configurable from the HA
    UI without reflashing), and a Wi-Fi / API / battery status row.
  • Safety-first. It never transmits on its own — not at boot, after OTA, on
    reconnect, or from a received frame. Only an explicit press or HA command
    keys the radio.

Hardware

Two supported boards, both with an onboard OLED and a real Semtech FSK radio:

  • LilyGO TTGO LoRa32 V2.1 (433 MHz, SX1278) — the board I built and verified
    on real fans.
  • Heltec / HiLetgo ESP32 LoRa V3 (SX1262) — same radio profile, builds
    clean; I’m still bringing it up on hardware.

Attach a 433 MHz antenna before you transmit.

How it was figured out

The 433.92 MHz carrier and 2-FSK modulation came from SDR captures; the exact
register profile, frame format, per-unit sender-ID mechanism, and command-byte
structure came from dumping and disassembling the remote’s STM32 firmware. One
useful finding for anyone chasing the “turns on High when you press Off from
Low” quirk and the 80 vs 90 Off byte: the decoder treats speed as a 2-bit
field ((command >> 4) & 0x03) and masks the 0x80 bit away, so both 80 and
90 are valid “Off.” That’s all written up here:

Protocol details (radio profile, frame layout, command table) are in
docs/protocol.md,
and there’s a full setup walkthrough in
INSTALL.md.

I don’t redistribute the OEM firmware image (it’s QuietCool’s copyright) — only
the independently derived facts about the protocol.

Happy to answer questions, and PRs welcome if you’ve got a different QuietCool
variant.

Note: the repo’s hardware links are Amazon affiliate links.

1 Like

To clarify fan support, this works for the Trident/Stealth QC-CL**** series whole house fans?

Yes — compatibility follows the remote/receiver, not the fan model. This project emulates the QuietCool Wireless RF Control Kit remote protocol (433.92 MHz 2-FSK, FCC ID family 2APQIIT-RFHUB-01), reverse-engineered from firmware dumps of two OEM remotes plus over-the-air captures. I’ve confirmed it works with the IT-RFGLSSWITCH-01, so any fan whose speed control is driven by that remote family should work, whether it’s a Trident Pro, Stealth Pro, or Classic QC CL-series chassis. The motor line doesn’t matter; the RF receiver does.

Porting to your own fan doesn’t require any firmware dumps or sniffing: the built-in learn mode picks up your fan’s 4-byte sender ID from two presses of your existing OEM remote, and it’s persisted across reboot/OTA.

Two caveats: it’s been physically tested on two fans so far (Off/Low/Medium/High plus the 1H/2H/4H timers — fewer-speed fans should work since the receiver just ignores speeds it doesn’t have, but that’s inference). And it does NOT apply to BLE-app-only setups or Smart Attic Fans (IT-AF-SMT), which use a totally different JSON-over-BLE protocol. Hardware-wise you need a real FSK transceiver (SX1278/TTGO LoRa32 or CC1101) — a Sonoff RF Bridge can’t transmit this.

Big update: the fan now confirms every command. Closed-loop control is
live on main.

Until now (like every other QuietCool integration I’m aware of), commands were
sent blind — transmit the frame, hope the fan heard it. But the OEM protocol
turns out to have a real query/response exchange, recovered from the remote’s
firmware and verified against live fan replies: send the remote’s 66 66
status query and the fan answers with a six-byte report of its actual state.

So after every command, the controller now:

  1. sends the OEM status query about 200 ms after the command burst,
  2. listens in a bounded window and requires consensus — repeated agreeing
    replies, validated with the exact rules the OEM remote’s own parser uses
    (exact ID match, duplicated state byte; there’s no CRC on this protocol,
    so single-frame trust isn’t good enough),
  3. compares the fan’s reported state with what was requested (on the lower six
    bits, with the OEM’s own “any zero-duration report confirms Off” wildcard),
  4. and either confirms — cancelling the remaining spaced retries — or lets
    the bounded, one-second-spaced re-fire backstop keep going. If the fan
    never confirms, you get an explicit FAILED status in Home Assistant
    instead of a silent wrong guess.

A physical press of the glass remote still always wins: the controller hears
the remote’s own query/command exchange and immediately cancels its pending
automatic work.

Three new diagnostic entities:

Entity What it shows
Last Confirmed Fan State The fan’s own reported state, e.g. OFF (raw 90)
Command Confirmation Status confirmed OFF after 1 command(s), 1 query(s), mismatch, or bounded failure
Fan Speed Capability The fan self-reports its model class — mine answered 2-speed, which is exactly why its OEM remote skips Medium

That last one was a fun protocol discovery: bits 7:6 of the fan’s state byte
carry speed-capability metadata (1/2/3-speed), which is also why earlier
attempts to read those bits as a “packet direction” flag kept contradicting
themselves.

Also in this release: the SX1278 receiver now uses an explicit 50 kHz RX
bandwidth (the signal only occupies ~22 kHz; the old 125 kHz default admitted
~4 dB more noise), the SX1262/V3 config carries the identical closed loop at
the nearest legal bandwidth (58.6 kHz, still awaiting hardware bring-up), and
the protocol/firmware-analysis docs are updated with the full query/response
write-up, including why the “corrupt” long frames people see are length-byte
bit errors rather than a richer reply format.

The entity set also got a cleanup: the redundant Off/Low/Medium/High and
timer buttons are gone (speed control lives on the fan entity itself), and
the timer is now a single Fan Timer select covering the fan’s full
1/2/4/8/12-hour range — more durations than the OEM remote’s three buttons
expose. It syncs both ways: start a timer from the physical glass remote and
the select snaps to the matching duration in HA within a second.

Repo: GitHub - joyfulhouse/esphome-quietcool: ESPHome firmware to control QuietCool whole-house/attic fans over their native 433.92 MHz 2-FSK RF link from Home Assistant. Reverse-engineered from the OEM remote firmware. · GitHub — protocol details in
docs/protocol.md,
teardown in
docs/firmware-analysis.md.

1 Like

Would you believe I was just trying to wire up an open loop solution today? My only regret is my current hardware doesn’t work. Thank you for your work on this!

1 Like

I mean it is that time of year, or really too hot these last few days in Los Angeles. At least my automations will be ready in the fall. There’s a small glitch that gpt-5.6-sol and fable are working through right now… Really fun watching them argue :slight_smile:

v0.2.0 — Fan Timer, RF diagnostics, and a much easier install

@blink_la — good news before fall: the glitch the models were arguing about is long dead, and they’ve since argued through two full review campaigns (16 rounds + 3 rounds, four models, all-clean at the end). Arguing between gpt-5.6-sol, claude-fable-5, grok-4.5, and gemini-3.1-pro, realistically gpt and fable producing the most useful results but entertaining to watch them ignore grok :grinning_face_with_smiling_eyes:

What’s new in v0.2.0:

:stopwatch: Fan Timer select — the full OEM range: None / 1 / 2 / 4 / 8 / 12 hours. One thing to internalize before wiring automations, because the OEM protocol is genuinely counter-intuitive: every option runs the fan, including None. On the wire, a duration on a stopped fan starts it (at Low, same as the OEM remote), and None means “no timer — run until stopped,” which will restart a fan whose timer just expired. Off deliberately doesn’t exist in the timer vocabulary — stopping is the fan entity’s job. Open a window first; makeup air matters.

:bar_chart: Eight RF diagnostics, disabled by default (enable per-entity in HA): Last TX Command, Last Valid RX Frame, Last Confirmed Fan State, Speed Capability, Remote Sender ID, and TX / RX Valid / RX Rejected counters. That last one earned its keep immediately: it’s a live RF-interference meter, and it caught a real household interferer degrading one of my bridges — walk around unplugging suspects while watching the counter climb (or stop).

:wrench: Under the hood: an OEM-set timer that expires unobserved now clears its stale display and re-queries instead of claiming “1 hour, fan ON” forever; Controller Fault reads off from boot instead of unknown; assorted hardening from the review rounds.

:package: Install got much simpler — no repo checkout, no CLI. Straight from your ESPHome Device Builder, full config below. Guide: INSTALL.md.

Full config for a TTGO LoRa32 V2.1 (SX1278) — paste into a new Builder device, fill in your secrets, install:

substitutions:
  device_name: quietcool-fan
  friendly_name: "QuietCool Fan"

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}
  min_version: 2026.7.0

esp32:
  board: ttgo-lora32-v21
  # REQUIRED, not a preference: the component rejects Arduino or a smaller
  # stack at codegen time. The default 8 KB stack crash-looped a production
  # controller mid-RF-exchange before this was found.
  framework:
    type: esp-idf
    advanced:
      loop_task_stack_size: 16384

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "QuietCool Fallback"
    password: !secret fallback_ap_password

logger:
api:
  encryption:
    key: !secret api_key
ota:
  - platform: esphome
    password: !secret ota_password

external_components:
  - source: github://joyfulhouse/[email protected]
    components: [quietcool]

spi:
  clk_pin: GPIO5
  miso_pin: GPIO19
  mosi_pin: GPIO27

sx127x:
  id: fan_radio
  cs_pin: GPIO18
  rst_pin: GPIO23
  dio0_pin: GPIO26
  frequency: 433920000
  modulation: FSK
  bandwidth: 50_0kHz
  packet_mode: true
  bitsync: true
  bitrate: 2400
  deviation: 10000
  shaping: NONE
  sync_value: [0x2D, 0xD4]
  payload_length: 0
  crc_enable: false
  preamble_size: 8
  preamble_polarity: 0xAA
  preamble_errors: 0
  pa_pin: BOOST
  pa_power: 17
  rx_start: true
  on_packet:
    - quietcool.on_packet:
        id: quietcool_controller
        packet: received_packet

quietcool:
  id: quietcool_controller
  sender_seed: 0x00000000   # 0 = unprovisioned; pair via the Learn button
  radio:
    type: sx127x
    id: fan_radio

fan:
  - platform: quietcool
    id: whole_house_fan
    controller_id: quietcool_controller
    name: "Whole House Fan"

# Every option transmits and every option RUNS the fan: a duration on a
# stopped fan starts it (at Low), and "None" = no timer / run continuously —
# it restarts a fan whose timer already expired. There is no Off here.
select:
  - platform: quietcool
    id: fan_timer
    controller_id: quietcool_controller
    name: "Fan Timer"

binary_sensor:
  - platform: quietcool
    controller_id: quietcool_controller
    kind: state_known
    name: "Fan State Known"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: timer_program_known
    name: "Fan Timer Program Known"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: timer_remaining_known
    name: "Fan Timer Remaining Known"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: confirmed_off
    name: "Fan Confirmed Off"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: controller_fault
    device_class: problem
    name: "Controller Fault"

sensor:
  - platform: quietcool
    controller_id: quietcool_controller
    kind: timer_remaining
    name: "Fan Timer Remaining"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: tx_count
    name: "TX Count"
    disabled_by_default: true
  - platform: quietcool
    controller_id: quietcool_controller
    kind: rx_valid_count
    name: "RX Valid Count"
    disabled_by_default: true
  - platform: quietcool
    controller_id: quietcool_controller
    kind: rx_rejected_count
    name: "RX Rejected Count"
    disabled_by_default: true

text_sensor:
  - platform: quietcool
    controller_id: quietcool_controller
    kind: command_status
    name: "Fan Command Confirmation"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: evidence_source
    name: "Fan Evidence Source"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: last_tx_command
    name: "Last TX Command"
    disabled_by_default: true
  - platform: quietcool
    controller_id: quietcool_controller
    kind: last_rx_frame
    name: "Last Valid RX Frame"
    disabled_by_default: true
  - platform: quietcool
    controller_id: quietcool_controller
    kind: last_confirmed_state
    name: "Last Confirmed Fan State"
    disabled_by_default: true
  - platform: quietcool
    controller_id: quietcool_controller
    kind: speed_capability
    name: "Fan Speed Capability"
    disabled_by_default: true
  - platform: quietcool
    controller_id: quietcool_controller
    kind: remote_sender_id
    name: "Remote Sender ID"
    disabled_by_default: true

button:
  - platform: quietcool
    controller_id: quietcool_controller
    kind: refresh
    name: "Query Fan State"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: learn
    name: "Learn Remote ID"
  - platform: quietcool
    controller_id: quietcool_controller
    kind: forget
    name: "Forget Remote ID"

Pair it with the Learn Remote ID button and three presses on your OEM remote — no sniffing needed. SX1262 boards (Heltec V3): same config, swap the radio block per the example config’s SX1262 section.

As always: the firmware never sends a fan command unbidden — only your explicit action plus the confirmation queries it authorizes. Boot sends a single non-energizing status query. Antenna on before power.