Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
run: uv python install
- name: Install dependencies
run: uv sync --all-groups
- name: Lint (ruff)
run: uv run ruff check .
- name: Format check (ruff)
run: uv run ruff format --check .
- name: Run tests
run: uv run pytest
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: pytest
name: pytest (pre-push)
language: system
entry: uv run pytest
pass_filenames: false
stages: [pre-push]
43 changes: 26 additions & 17 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@

| Parameter | Value |
|-----------|-------|
| Frequency | 315.4 MHz |
| Frequency | 433.935 MHz |
| Modulation | OOK (On-Off Keying), Pulse Distance |
| Packet length | 32 bits |
| Repetitions | 36–41 per button press (remote); 20 in firmware |
| Sync pulse | ~8 ms HIGH (between/before each repetition) |
| Bit pulse (HIGH) | ~400 µs (fixed for all bits) |
| Bit 0 gap (LOW) | ~670 µs |
| Bit 1 gap (LOW) | ~1800 µs |
| Sync pulse (HIGH) | ~8200 µs (carrier on at start of each repetition) |
| Sync gap (LOW) | ~4500 µs (silence between sync and first data bit) |
| Bit pulse (HIGH) | ~560 µs (fixed for all bits) |
| Bit 0 gap (LOW) | ~570 µs |
| Bit 1 gap (LOW) | ~1700 µs |

Original WBFM-decoded values (pulse 400, sync_gap 670, bit gaps 670/1800)
were systematically off; the values above came from a clean AM-demod
RTL-SDR capture on 2026-05-09 and are what actually drives the fans.

## Encoding

Expand Down Expand Up @@ -39,10 +44,13 @@ before the first data bit uses the zero_gap duration as a default (may need tuni

## Decoded Addresses

| Fan | Location | Address (16 bits) |
|-----|----------|-------------------|
| 1 | Bedroom | `1000110011110110` |
| 2 | Living room | `1111000100111011` |
Both fans physically live in the living room; names distinguish which one
is nearer the main room vs the staircase landing.

| Fan | Name | Address (16 bits) |
|-----|------|-------------------|
| 1 | main | `1000110011110110` |
| 2 | stairs | `1111000100111011` |

## Decoded Commands

Expand All @@ -56,7 +64,7 @@ before the first data bit uses the zero_gap duration as a default (may need tuni

## Full 32-Bit Codes

### Remote 1 — Bedroom Fan
### Remote 1 — Main Fan

| Button | Full code | Verified |
|--------|-----------|----------|
Expand All @@ -66,7 +74,7 @@ before the first data bit uses the zero_gap duration as a default (may need tuni
| speed2 | `10001100111101101001000001101111` | ✓ |
| speed3 | `10001100111101100100100010110111` | ✓ |

### Remote 2 — Living Room Fan
### Remote 2 — Stairs Fan

| Button | Full code | Verified |
|--------|-----------|----------|
Expand All @@ -76,12 +84,13 @@ before the first data bit uses the zero_gap duration as a default (may need tuni
| speed2 | `11110001001110111001000001101111` | derived |
| speed3 | `11110001001110110100100010110111` | derived |

"Derived" = remote 2 address + remote 1 command bits. Should work; not yet verified
against physical hardware.
"Derived" = remote 2 address + remote 1 command bits. **Verified working
against the stairs fan on 2026-05-09** — the address/command split was
correct.

## Notes on Timing Tolerances

The firmware uses the measured values. If the fan doesn't respond, the most likely
cause is the sync gap. Try adjusting `SYNC_GAP_US` in firmware/src/main.cpp:
- Increase to 4000–10000 µs if the fan ignores all commands
- Reduce to 0 if the fan responds erratically
The firmware reads timing from the JSON payload sent to `/transmit`, so all
tuning is in `devices/sofa_king_fan.yaml` — no reflash needed. The values
above (especially `sync_gap_us: 4500`) are what got the fans responding;
deviating much from them stops working.
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,98 @@ There is a [plan for dealing with the fans](docs/plans/2026-03-08-fan-control-ph
- [Nooelec NESDR Mini 2+ 0.5PPM TCXO RTL-SDR & ADS-B USB Receiver Set](https://www.nooelec.com/store/sdr/sdr-receivers/nesdr-mini-2-plus.html)
- [HiLetgo 1PC ESP8266 NodeMCU CP2102 ESP-12E Development Board](http://www.hiletgo.com/ProductDetail/1906570.html)
- [HiLetgo 315Mhz RF Transmitter and Receiver Module](http://hiletgo.com/ProductDetail/2157209.html)

## Next: roll into Home Assistant

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`:

```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/light` from the HA host.
- Eventual nice-to-have: a `template` fan entity that wraps the rest_command pairs into a real HA `fan` entity 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.

## Tom's setup checklist — run through this every cold start

Future-you forgets. This is the path from "open laptop" to "send a command at a fan" without re-deriving anything.

1. **Plug in the NodeMCU** with a *data-capable* USB cable (not a charge-only one). `ls /dev/cu.usbserial-*` should list a new device.
2. **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_fm` both grab the RTL-SDR exclusively, so quit one before starting the other.
3. **Activate the project venv** (otherwise `pio`, `pytest`, etc. aren't on PATH):
```bash
cd ~/Documents/work/radiofrequency
source .venv/bin/activate
```
Or use `uv run <command>` from the repo root for one-shots.
4. **Try the mDNS hostname first** — once the firmware has been flashed with mDNS support, the NodeMCU advertises as `ceilingfans.local` and you can skip IP-hunting:
```bash
curl -s -o /dev/null -w "%{http_code}\n" "http://ceilingfans.local/fan/1/light"
```
`200` means everything's up. If that times out, the chip might be off or on a different LAN segment — fall back to step 5.
5. **Find the IP via serial** if mDNS isn't resolving:
```bash
cd firmware
pio device monitor -b 115200
```
Press the board's RST button if needed. Look for `Connected! IP: 192.168.68.XX` and `mDNS: ceilingfans.local`. Note the IP, then Ctrl+C — the chip keeps running. The first second of garbage is the ESP8266 ROM bootloader; ignore it.
6. **Send a command via the CLI** (defaults to `ceilingfans.local`):
```bash
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. See `docs/fan-debugging-2026-04-19.md` for 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.

## Flashing the NodeMCU — future-you checklist

Ghost-of-past-sessions present: this burned half an hour the last time. Two things to verify before `pio run --target upload`:

1. **Plug the NodeMCU in first.** PlatformIO's upload step can't autodiscover a board that isn't on USB yet. If `pio device list` returns nothing, the board isn't connected (or isn't seen — see #2).
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.

## Verifying transmission with Gqrx

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.
Binary file added captures/nodemcu_main_light.wav
Binary file not shown.
Binary file added captures/sofucor_remote1_light_am.wav
Binary file not shown.
88 changes: 59 additions & 29 deletions cli.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,56 @@
#!/usr/bin/env python3
"""Control RF devices (e.g. Sofucor fans) via NodeMCU HTTP API.
"""Control RF devices via NodeMCU HTTP API.

Usage:
python cli.py sofucor_fan bedroom speed1 --host 192.168.1.42
python cli.py sofucor_fan living_room off --host nodemcu.local
python cli.py send sofa_king_fan main light --host 192.168.1.42
python cli.py raw 10001100111101101100000000111111 \\
--device sofa_king_fan --host 192.168.1.42
"""

import sys

import click
import httpx

from src.device import DeviceProfile, build_packet
from src.device import DeviceProfile, build_packet, build_transmit_payload

DEVICES_DIR = "devices"


@click.command()
def _load_profile(device: str) -> DeviceProfile:
return DeviceProfile.load(f"{DEVICES_DIR}/{device}.yaml")


def _post_transmit(host: str, port: int, payload: dict) -> None:
url = f"http://{host}:{port}/transmit"
try:
resp = httpx.post(url, json=payload, timeout=10.0)
resp.raise_for_status()
except httpx.ConnectError:
click.echo(f"Error: could not connect to {host}:{port}", err=True)
sys.exit(1)
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
click.echo(
f"Error: NodeMCU returned {exc.response.status_code} — {body}",
err=True,
)
sys.exit(1)


@click.group()
def cli() -> None:
"""Control RF devices via NodeMCU HTTP API."""


@cli.command()
@click.argument("device")
@click.argument("unit")
@click.argument("command")
@click.option("--host", default="nodemcu.local", show_default=True, help="NodeMCU hostname or IP")
@click.option("--port", default=80, show_default=True, help="NodeMCU HTTP port")
def cli(device: str, unit: str, command: str, host: str, port: int) -> None:
"""Send an RF command to a device via NodeMCU.

\b
DEVICE: profile name (e.g. sofucor_fan)
UNIT: unit name (e.g. bedroom, living_room)
COMMAND: button name (e.g. speed1, speed2, speed3, off, light)
"""
profile = DeviceProfile.load(f"{DEVICES_DIR}/{device}.yaml")
@click.option("--host", default="ceilingfans.local", show_default=True)
@click.option("--port", default=80, show_default=True)
def send(device: str, unit: str, command: str, host: str, port: int) -> None:
"""Send a named command from a device profile (e.g. main light)."""
profile = _load_profile(device)

if unit not in profile.units:
available = ", ".join(sorted(profile.units))
Expand All @@ -38,24 +59,33 @@ def cli(device: str, unit: str, command: str, host: str, port: int) -> None:

if command not in profile.commands:
available = ", ".join(sorted(profile.commands))
click.echo(f"Error: unknown command '{command}'. Available: {available}", err=True)
click.echo(
f"Error: unknown command '{command}'. Available: {available}", err=True
)
sys.exit(1)

fan_number = profile.units[unit]["fan_number"]
url = f"http://{host}:{port}/fan/{fan_number}/{command}"
bits = build_packet(profile, unit=unit, command=command)
payload = build_transmit_payload(profile, bits=bits)
_post_transmit(host, port, payload)
click.echo(f"OK {command} → {device}/{unit} [{bits}]")


@cli.command()
@click.argument("bits")
@click.option("--device", required=True, help="Device profile whose timing to use.")
@click.option("--host", default="ceilingfans.local", show_default=True)
@click.option("--port", default=80, show_default=True)
def raw(bits: str, device: str, host: str, port: int) -> None:
"""Transmit an arbitrary bit string using a device profile's timing."""
profile = _load_profile(device)
try:
resp = httpx.get(url, timeout=10.0)
resp.raise_for_status()
except httpx.ConnectError:
click.echo(f"Error: could not connect to {host}:{port}", err=True)
sys.exit(1)
except httpx.HTTPStatusError as exc:
click.echo(f"Error: NodeMCU returned {exc.response.status_code} for {url}", err=True)
payload = build_transmit_payload(profile, bits=bits)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)

packet = build_packet(profile, unit=unit, command=command)
click.echo(f"OK {command} → {device}/{unit} [{packet}]")
_post_transmit(host, port, payload)
click.echo(f"OK raw [{bits}] ({len(bits)} bits) via {device}")


if __name__ == "__main__":
Expand Down
25 changes: 16 additions & 9 deletions devices/sofa_king_fan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,33 @@
# Encoding: OOK Pulse Distance (fixed HIGH pulse, variable LOW gap)
# See PROTOCOL.md for full decoding notes

frequency_mhz: 315.4
frequency_mhz: 433.935
encoding: OOK_PDM # Pulse Distance Modulation

timing:
sync_us: 8000 # sync HIGH pulse at start of each repetition
sync_gap_us: 670 # LOW gap between sync and first data bit (tune if fan doesn't respond)
pulse_us: 400 # carrier-ON duration, same for all bits
zero_gap_us: 670 # LOW gap after pulse = bit 0
one_gap_us: 1800 # LOW gap after pulse = bit 1
repeat_count: 20 # firmware sends 20×; real remote sends 36–41×
# Values re-measured from a clean AM-demod RTL-SDR capture of remote 1
# pressing light (captures/sofucor_remote1_light_am.wav, 2026-05-09).
# Original WBFM-decoded values were systematically off, especially
# sync_gap_us (was 670, real ≈ 4500). The receiver needed that gap to
# let AGC settle after the long sync burst before decoding bits.
sync_us: 8200 # sync HIGH pulse at start of each repetition
sync_gap_us: 4500 # LOW gap between sync and first data bit (was 670)
pulse_us: 560 # carrier-ON duration, same for all bits (was 400)
zero_gap_us: 570 # LOW gap after pulse = bit 0 (was 670)
one_gap_us: 1700 # LOW gap after pulse = bit 1 (was 1800)
repeat_count: 20 # firmware sends 20×; real remote sends 36–41×

# Packet: [address 16 bits][command 16 bits] = 32 bits total
# address = bits 0–15, unique per remote/fan pair
# command = bits 16–31, same across all units of this type

units:
bedroom:
# Both fans live in the living room; names reflect which is nearer the
# main room vs the staircase landing.
main:
address: "1000110011110110" # remote 1 — verified
fan_number: 1
living_room:
stairs:
address: "1111000100111011" # remote 2 — verified
fan_number: 2

Expand Down
Loading
Loading