This is a repository for attempting to make my home ceiling fans smart like this. I have two of these Sofucor fans.
There is a plan for dealing with the fans.
- Gqrx -
brew install gqrx - rtl_fm -
brew install librtlsdr - sox -
brew install sox - Audacity (already installed)
- PlatformIO - extension to VSCode
- Nooelec NESDR Mini 2+ 0.5PPM TCXO RTL-SDR & ADS-B USB Receiver Set
- HiLetgo 1PC ESP8266 NodeMCU CP2102 ESP-12E Development Board
- HiLetgo 315Mhz RF Transmitter and Receiver Module
The original goal was a Home Assistant automation, not a CLI. The NodeMCU's HTTP endpoints make this straightforward — HA's rest_command integration just needs URLs.
Probably the cleanest config in configuration.yaml:
rest_command:
fan_main_light: {url: "http://ceilingfans.local/fan/1/light", method: GET}
fan_main_off: {url: "http://ceilingfans.local/fan/1/off", method: GET}
fan_main_speed1: {url: "http://ceilingfans.local/fan/1/speed1", method: GET}
fan_main_speed2: {url: "http://ceilingfans.local/fan/1/speed2", method: GET}
fan_main_speed3: {url: "http://ceilingfans.local/fan/1/speed3", method: GET}
fan_stairs_light: {url: "http://ceilingfans.local/fan/2/light", method: GET}
fan_stairs_off: {url: "http://ceilingfans.local/fan/2/off", method: GET}
fan_stairs_speed1: {url: "http://ceilingfans.local/fan/2/speed1", method: GET}
fan_stairs_speed2: {url: "http://ceilingfans.local/fan/2/speed2", method: GET}
fan_stairs_speed3: {url: "http://ceilingfans.local/fan/2/speed3", method: GET}Each one becomes a callable service (rest_command.fan_main_light), wireable into automations or a Lovelace card. The template integration can wrap pairs into a proper fan entity with on/off + speed if you want HA to model state correctly.
Open work for the actual integration:
- HA on the homelab needs LAN reach to the NodeMCU. Both should be on the same network, so this should "just work" — verify by
curl http://ceilingfans.local/fan/1/lightfrom the HA host. - Eventual nice-to-have: a
templatefan entity that wraps the rest_command pairs into a real HAfanentity with state, on/off, and speed levels — so Lovelace cards and voice assistants treat it as a first-class device instead of a stack of buttons.
Future-you forgets. This is the path from "open laptop" to "send a command at a fan" without re-deriving anything.
- Plug in the NodeMCU with a data-capable USB cable (not a charge-only one).
ls /dev/cu.usbserial-*should list a new device. - Plug in the RTL-SDR with the antenna attached, only if you intend to capture or run Gqrx this session. The two USB devices don't conflict — but Gqrx and
rtl_fmboth grab the RTL-SDR exclusively, so quit one before starting the other. - Activate the project venv (otherwise
pio,pytest, etc. aren't on PATH):Or usecd ~/Documents/work/radiofrequency source .venv/bin/activate
uv run <command>from the repo root for one-shots. - Try the mDNS hostname first — once the firmware has been flashed with mDNS support, the NodeMCU advertises as
ceilingfans.localand you can skip IP-hunting:curl -s -o /dev/null -w "%{http_code}\n" "http://ceilingfans.local/fan/1/light"
200means everything's up. If that times out, the chip might be off or on a different LAN segment — fall back to step 5. - Find the IP via serial if mDNS isn't resolving:
Press the board's RST button if needed. Look for
cd firmware pio device monitor -b 115200Connected! IP: 192.168.68.XXandmDNS: ceilingfans.local. Note the IP, then Ctrl+C — the chip keeps running. The first second of garbage is the ESP8266 ROM bootloader; ignore it. - Send a command via the CLI (defaults to
ceilingfans.local):uv run python cli.py send sofa_king_fan main light
Useful gotchas worth remembering:
- mDNS (
ceilingfans.local) is the canonical address. If it stops resolving, the chip rebooted without re-registering — power-cycle the NodeMCU, then re-curl. - For RTL-SDR captures, the bulletproof one-liner pattern is
(sleep 3; curl ...) & timeout 8 rtl_fm ... | sox ...— fire-and-forget, no zombies. Seedocs/fan-debugging-2026-04-19.mdfor the exact recipe. - The full debugging story (three weeks of "it doesn't work" before AM-demod recapture revealed the timing was off) is in
docs/fan-debugging-2026-04-19.md. Keep around as folklore for the next device.
Ghost-of-past-sessions present: this burned half an hour the last time. Two things to verify before pio run --target upload:
- Plug the NodeMCU in first. PlatformIO's upload step can't autodiscover a board that isn't on USB yet. If
pio device listreturns nothing, the board isn't connected (or isn't seen — see #2). - Use a data-capable USB cable. Most short/thin USB cables in the drawer are charge-only and will power the board without exposing the serial port. If macOS shows no new
/dev/cu.usbserial-*after plugging in, swap cables.
Use this to see whether the NodeMCU is actually putting RF into the air when you hit /transmit. If these settings give you a clear burst on the waterfall, the transmitter is alive — any remaining issue is bits/timing/range, not hardware.
Tune and demodulate:
- Frequency:
433.935 MHz - Input:
Realtek RTL2838UHIDIR SN: 00000001(auto-selected) - Input rate:
2.4 Msps(default is fine) - Mode: AM — OOK rides amplitude; AM makes bursts audible and visible
- Filter width:
Normal(~10 kHz) - Squelch:
-150 dB(i.e. off — you want to see everything)
Gain and AGC (right-hand panel):
- AGC: Off (AGC will chase noise and mask the bursts)
- LNA gain:
~38 dB(headroom without overload; nudge down if the waterfall looks saturated)
FFT / waterfall readability:
- FFT size:
32768— finer frequency resolution separates the fan signal from nearby WiFi/noise - FFT rate:
30 fps - Waterfall speed: leave at default (20–30 fps)
- dB range: drag the range slider so the noise floor is dark and bursts pop bright. If everything is one color, you're clipped — adjust.
What a good burst looks like: when you fire the curl, you should see a vertical bright stripe centered on 433.935 MHz lasting ~1 second (20 packet repeats × ~55 ms each). The signal meter jumps; in AM mode you'll hear a rapid chatter through speakers.