diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..095e970 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..26c55d9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/PROTOCOL.md b/PROTOCOL.md index 92d3891..d121f88 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -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 @@ -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 @@ -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 | |--------|-----------|----------| @@ -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 | |--------|-----------|----------| @@ -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. diff --git a/README.md b/README.md index 829cbaa..3443f2e 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/captures/nodemcu_main_light.wav b/captures/nodemcu_main_light.wav new file mode 100644 index 0000000..00a2fd1 Binary files /dev/null and b/captures/nodemcu_main_light.wav differ diff --git a/captures/sofucor_remote1_light_am.wav b/captures/sofucor_remote1_light_am.wav new file mode 100644 index 0000000..d0231fd Binary files /dev/null and b/captures/sofucor_remote1_light_am.wav differ diff --git a/cli.py b/cli.py index a892b58..152e420 100644 --- a/cli.py +++ b/cli.py @@ -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)) @@ -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__": diff --git a/devices/sofa_king_fan.yaml b/devices/sofa_king_fan.yaml index 6607cf5..598eec4 100644 --- a/devices/sofa_king_fan.yaml +++ b/devices/sofa_king_fan.yaml @@ -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 diff --git a/docs/fan-debugging-2026-04-19.md b/docs/fan-debugging-2026-04-19.md new file mode 100644 index 0000000..e649496 --- /dev/null +++ b/docs/fan-debugging-2026-04-19.md @@ -0,0 +1,132 @@ +# Fan Control Debugging Session — 2026-04-19 + +## RESOLVED 2026-05-09 + +The fix was hypothesis #2: our hand-decoded pulse/gap timings were +systematically off, especially `sync_gap_us` (we had 670 µs, the real +remote uses ~4500 µs). Recapturing the remote with `rtl_fm -M am` +gave a clean envelope, measurement was unambiguous, YAML updated, +fans now respond to all five commands on both units. The "derived" +stairs commands turned out to be correct — the address/command split +was right all along. + +Original hypotheses #1 (TX wiring), #3 (ESP8266 jitter), #4 (bit +pattern errors) were all wrong; nothing was fundamentally broken, +just that decoding from a WBFM-demod recording loses too much +precision to read pulse widths off by eye. AM demod for the win. + +The notes below preserve the debugging history. + +--- + + +Checkpoint for next-session-Tom. Context: we built the generic `/transmit` +firmware + CLI today, tried to drive the fans, and discovered that while +transmission happens, the fans don't respond. Investigation narrowed the +problem to pulse-envelope shape, not bit values. + +## What works now + +- Branch `claude/generic-firmware` has 10 commits: + - ArduinoJson dep + generic `POST /transmit` firmware endpoint + - `build_transmit_payload` helper in `src/device.py` + - CLI refactored into `click.group` with `send` and `raw` subcommands + - Rename: units `bedroom`/`living_room` → `main`/`stairs` (both fans live + in the living room; names distinguish which is nearer main room vs stairs) + - Frequency docs corrected from stale 315.4 → 433.935 MHz across YAML, + PROTOCOL.md, tests + - README flash-reminders + Gqrx setup notes +- 22/22 tests green +- Firmware flashed, NodeMCU at `192.168.68.66` +- Both endpoints work at the HTTP layer: legacy `GET /fan/{N}/{cmd}` and + generic `POST /transmit` +- Gqrx shows a clear RF burst at 433.935 MHz when curl fires — **TX is alive** + +## What doesn't work + +- Neither fan responds to commands from the NodeMCU. Close-range and + `repeat_count=40` made no difference. Light on the "stairs" fan was never + verified (no capture for remote 2 light in `captures/`). + +## Key diagnostic finding + +Captured the NodeMCU's own transmission with RTL-SDR +(`captures/nodemcu_main_light.wav`, using the same rtl_fm + sox pipeline +that produced `captures/sofucor_remote1_light.wav`) and compared. + +- Both show valid OOK bursts at 433.935 MHz +- NodeMCU burst is ~1.3 s (20 reps via old `/fan/1/light` endpoint); + remote burst is ~3.4 s (36–41 reps) +- **Critical difference:** in the overlaid envelope at 10 ms zoom + (`/tmp/compare_overlay.png`), the remote's pulses drop almost to zero + between each pulse with visibly bimodal gap widths (short `0`-gaps + vs wide `1`-gaps). The NodeMCU's envelope stays in the middle of the + range between pulses — never fully silencing the carrier — and gap + widths look more uniform. + +This means the fan's receiver can't discriminate `0` from `1` in our output. + +## Hypotheses, ranked by likelihood + +1. **TX module on 3.3V instead of 5V.** HiLetgo 433 MHz ASK modules have + notoriously dirty on/off response, especially under-powered. Carrier + decay can take hundreds of microseconds at 3.3V, which would exactly + match the smeared envelope we see. + - **First thing to check next session:** verify TX module `VCC` is + wired to NodeMCU `VIN` (5V from USB), not `3V3`. +2. **Our Audacity-decoded pulse/gap timings are slightly off.** Hand + measurement of WBFM-demod data has ±50–100 µs error. Recapture the + real remote with `rtl_fm -M am` for cleaner envelopes, then re-measure. +3. **ESP8266 WiFi ISR jitter during packet transmission.** Less likely + to cause systematic smearing, more likely occasional bad packets. +4. **Bit pattern errors (MSB/LSB order, missing parity).** Unlikely given + the pulse *smearing* we see — wrong bits would give clean pulses at + wrong positions, not smear. + +## Concrete next-session plan + +**Step 1 (30 s):** Inspect TX module wiring. If on 3V3, move to VIN, retry +`uv run python cli.py send sofa_king_fan main light --host 192.168.68.66`. +If the fan responds, we're done. + +**Step 2 (10 min):** If wiring was already correct, recapture the real +remote with clean AM demod: + + timeout 8 rtl_fm -f 433935000 -M am -s 250000 -r 250000 -g 38 - | \ + sox -t raw -r 250000 -e signed -b 16 -c 1 -V1 - \ + captures/sofucor_remote1_light_am.wav + +Press the real remote light button while it runs. Open the AM capture +and re-measure pulse/gap widths precisely. Update `devices/sofa_king_fan.yaml` +timing with corrected values. + +**Step 3 (if still no response):** Script a sweep. Using the `raw` +subcommand, fire the same bit string with timing variations and note which +reach the fan. Candidates to try: pulse_us in {300,400,500,600}; +zero_gap_us in {500,670,800}; one_gap_us in {1500,1800,2100}. + +**Step 4 (wild card):** Try with an actual antenna. The HiLetgo module +has an `ANT` pad — soldering ~17 cm of solid wire as a quarter-wave +improves radiated power by 10× and sometimes fixes receivers that are +marginal on noise. + +## Artifacts left on disk + +- `/tmp/comparison.png` — overview of both captures +- `/tmp/comparison_zoom.png` — 100 ms windows aligned to burst starts +- `/tmp/compare_bits.png` — 20 ms after-sync detail with 200 µs smoothing +- `/tmp/compare_overlay.png` — **the money shot**, normalized envelopes overlaid +- `/tmp/compare_captures.py`, `compare_zoom.py`, `compare_bits.py`, + `compare_overlay.py` — scripts to regenerate the above +- `/tmp/measure_pulses.py` — attempt at auto-measuring pulse widths; noisy + because it runs on WBFM-demod data. Revisit once we have AM captures. + +If these stay useful past one session, move them into `tools/` in the repo. + +## Things to decide before merging branch to main + +1. Do we drop the legacy `/fan/{N}/{cmd}` endpoints (plan Task 7)? +2. Rename `sofa_king_fan.yaml` → something less punny now that we're + settling? (Tom's call.) +3. The `visualizations/ook-signal-explorer/sofucor_fan.yaml` is a stale + copy — keep in sync with the real device profile or delete? diff --git a/docs/plans/2026-04-19-generic-firmware.md b/docs/plans/2026-04-19-generic-firmware.md new file mode 100644 index 0000000..aa96596 --- /dev/null +++ b/docs/plans/2026-04-19-generic-firmware.md @@ -0,0 +1,782 @@ +# Generic-Bits Firmware + Fan-2 Verification Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the per-button hardcoded HTTP endpoints on the NodeMCU with a single generic `POST /transmit` endpoint that accepts raw bits + timing, update the CLI to drive it, then empirically map which of the five Sofucor commands actually work on each of the two fans. + +**Architecture:** The Mac becomes the brain — Python builds the 32-bit packet from the YAML profile (or from a user-supplied literal via `raw` subcommand) and POSTs `{bits, timing}` JSON to the NodeMCU. The firmware becomes dumb — it validates the payload, toggles `TX_PIN` per the timing dict, returns `200 OK`. Existing hardcoded `/fan/{1,2}/{cmd}` endpoints stay in place during cutover so we can A/B if generic behaviour looks wrong, then get removed once verified. + +**Tech Stack:** Python 3.13 (click, httpx, pytest, pyyaml), PlatformIO/Arduino (ESP8266, ArduinoJson) + +--- + +## File Structure + +**Modify:** +- [firmware/platformio.ini](firmware/platformio.ini) — add `ArduinoJson` to `lib_deps` +- [firmware/src/main.cpp](firmware/src/main.cpp) — add `handleTransmit()` + `/transmit` route; keep existing routes +- [cli.py](cli.py) — restructure as `click.group` with `send` and `raw` subcommands; POST to `/transmit` +- [src/device.py](src/device.py) — add `build_transmit_payload()` helper + +**Create:** +- [tests/test_cli.py](tests/test_cli.py) — click `CliRunner` tests with mocked `httpx` +- [docs/fan-test-matrix.md](docs/fan-test-matrix.md) — empirical results table + +**Leave alone:** `devices/sofa_king_fan.yaml` (timing already matches), `PROTOCOL.md`, `signal_explorer.py`. + +--- + +## Task 0: Branch setup + +**Files:** none yet + +- [ ] **Step 1: Create and check out working branch** + +```bash +git checkout -b claude/generic-firmware +git status # should show clean tree on new branch +``` + +--- + +## Task 1: Add ArduinoJson dependency to PlatformIO + +**Files:** +- Modify: `firmware/platformio.ini` + +- [ ] **Step 1: Add ArduinoJson to lib_deps** + +Replace the `lib_deps` block so the file reads: + +```ini +[env:nodemcuv2] +platform = espressif8266 +board = nodemcuv2 +framework = arduino +lib_deps = + ESP8266WiFi + ESP8266WebServer + bblanchon/ArduinoJson@^7.0.0 +monitor_speed = 115200 +upload_speed = 115200 +build_flags = -DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS +``` + +- [ ] **Step 2: Verify PlatformIO picks up the dependency (compile only, no upload)** + +```bash +cd firmware && pio run +``` + +Expected: `SUCCESS`. The first run will download ArduinoJson — that's normal. If compile fails mentioning `ArduinoJson.h`, confirm internet access and re-run. + +- [ ] **Step 3: Commit** + +```bash +cd .. +git add firmware/platformio.ini +git commit -m "chore: add ArduinoJson dependency for generic /transmit endpoint" +``` + +--- + +## Task 2: Firmware — add generic /transmit endpoint + +**Files:** +- Modify: `firmware/src/main.cpp` + +**Why this shape:** The endpoint accepts a bit string up to 128 chars (well over 32 for our current protocol, but leaves headroom for other devices later) and a timing object with exactly the six keys the old hardcoded constants represented. Anything missing or out of range returns `400` with a plain-text error so `curl` output is readable. The existing hardcoded endpoints stay in place — do NOT touch lines 79-91 or the route registrations at 124-135. We delete those in a later task only after the generic path is proven. + +- [ ] **Step 1: Add the ArduinoJson include at the top** + +At line 3 (after the existing `ESP8266WiFi.h` include), add: + +```cpp +#include +``` + +- [ ] **Step 2: Add a generic transmit function** + +Insert this function immediately after the existing `transmit()` function (after its closing brace around line 73), before the `// ─── HTTP HANDLERS ───` block: + +```cpp +// Generic transmit: toggle TX_PIN per an explicit timing spec. +// Used by POST /transmit so the Mac can drive arbitrary bit patterns +// without the firmware knowing anything about the device protocol. +void transmitGeneric(const char *bits, + uint32_t sync_us, uint32_t sync_gap_us, + uint32_t pulse_us, uint32_t zero_gap_us, + uint32_t one_gap_us, int repeat_count) { + for (int r = 0; r < repeat_count; r++) { + digitalWrite(TX_PIN, HIGH); + delayMicroseconds(sync_us); + digitalWrite(TX_PIN, LOW); + delayMicroseconds(sync_gap_us); + + for (const char *p = bits; *p; p++) { + digitalWrite(TX_PIN, HIGH); + delayMicroseconds(pulse_us); + digitalWrite(TX_PIN, LOW); + delayMicroseconds(*p == '1' ? one_gap_us : zero_gap_us); + } + + ESP.wdtFeed(); + } +} +``` + +- [ ] **Step 3: Add the HTTP handler** + +Immediately after the new `transmitGeneric` function, before `void sendOK()`: + +```cpp +// POST /transmit — body is JSON: +// {"bits": "010...", "timing": {"sync_us":N, "sync_gap_us":N, +// "pulse_us":N, "zero_gap_us":N, "one_gap_us":N, "repeat_count":N}} +void handleTransmit() { + if (server.method() != HTTP_POST) { + server.send(405, "text/plain", "method not allowed\n"); + return; + } + if (!server.hasArg("plain")) { + server.send(400, "text/plain", "missing body\n"); + return; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + server.send(400, "text/plain", String("bad json: ") + err.c_str() + "\n"); + return; + } + + const char *bits = doc["bits"] | (const char *)nullptr; + if (!bits) { + server.send(400, "text/plain", "missing 'bits'\n"); + return; + } + size_t bitlen = strlen(bits); + if (bitlen == 0 || bitlen > 128) { + server.send(400, "text/plain", "bits must be 1..128 chars\n"); + return; + } + for (size_t i = 0; i < bitlen; i++) { + if (bits[i] != '0' && bits[i] != '1') { + server.send(400, "text/plain", "bits must contain only 0 and 1\n"); + return; + } + } + + JsonObject t = doc["timing"].as(); + if (t.isNull()) { + server.send(400, "text/plain", "missing 'timing' object\n"); + return; + } + + const char *required[] = {"sync_us", "sync_gap_us", "pulse_us", + "zero_gap_us", "one_gap_us", "repeat_count"}; + for (const char *k : required) { + if (!t.containsKey(k)) { + String msg = String("missing timing.") + k + "\n"; + server.send(400, "text/plain", msg); + return; + } + } + + uint32_t sync_us = t["sync_us"]; + uint32_t sync_gap_us = t["sync_gap_us"]; + uint32_t pulse_us = t["pulse_us"]; + uint32_t zero_gap_us = t["zero_gap_us"]; + uint32_t one_gap_us = t["one_gap_us"]; + int repeat_count = t["repeat_count"]; + + // Sanity clamps. Anything outside these is almost certainly a typo/bug + // and we'd rather fail loudly than sit in delayMicroseconds() forever. + auto badUs = [](uint32_t v) { return v == 0 || v > 100000; }; + if (badUs(sync_us) || badUs(sync_gap_us) || badUs(pulse_us) || + badUs(zero_gap_us) || badUs(one_gap_us)) { + server.send(400, "text/plain", "timing microsecond values must be 1..100000\n"); + return; + } + if (repeat_count < 1 || repeat_count > 100) { + server.send(400, "text/plain", "repeat_count must be 1..100\n"); + return; + } + + transmitGeneric(bits, sync_us, sync_gap_us, + pulse_us, zero_gap_us, one_gap_us, repeat_count); + + String reply = String("OK ") + bitlen + " bits x " + repeat_count + " reps\n"; + server.send(200, "text/plain", reply); +} +``` + +- [ ] **Step 4: Register the route** + +Inside `setup()`, after the existing `server.on("/fan/2/speed3"...)` line (around line 135), before `server.onNotFound(send404)`, add: + +```cpp + // Generic endpoint — preferred path, used by cli.py send & raw + server.on("/transmit", HTTP_POST, handleTransmit); +``` + +Also update the `Serial.println` at the bottom of `setup()` so the bootup banner reflects the new route. Replace: + +```cpp + Serial.println("Endpoints: /fan/{1,2}/{light,off,speed1,speed2,speed3}"); +``` + +with: + +```cpp + Serial.println("Endpoints:"); + Serial.println(" POST /transmit (generic bits + timing)"); + Serial.println(" GET /fan/{1,2}/{light,off,speed1,speed2,speed3} (legacy hardcoded)"); +``` + +- [ ] **Step 5: Compile-check** + +```bash +cd firmware && pio run +``` + +Expected: `SUCCESS`. If the linker complains about `transmitGeneric` mismatch, check that the function prototype matches the call. + +- [ ] **Step 6: Commit** + +```bash +cd .. +git add firmware/src/main.cpp +git commit -m "feat(firmware): add generic POST /transmit endpoint" +``` + +--- + +## Task 3: Flash and smoke-test firmware + +**Files:** none (manual hardware test) + +**Why:** Before writing any CLI code, confirm the firmware works end-to-end with `curl`. If we can drive fan 1's light with a hand-rolled JSON payload, the transport layer is proved and every subsequent failure is a Python bug. + +- [ ] **Step 1: Confirm WIFI_PASS is set to the real password** + +Check `firmware/src/main.cpp:8`. The literal `"password"` is a placeholder — if it's still there, replace it with the real value before flashing. Do **not** commit the real password. + +- [ ] **Step 2: Flash and watch serial** + +```bash +cd firmware +pio run --target upload +pio device monitor +``` + +Wait for the serial output to show `Connected! IP: 192.168.x.x` and `HTTP server ready`. Note the IP. If it times out, check SSID/password. + +- [ ] **Step 3: Smoke test — hit the OLD endpoint as a baseline** + +In another terminal, replace `` with the NodeMCU IP: + +```bash +curl -v "http:///fan/1/light" +``` + +Expected: `HTTP/1.1 200 OK`, body `OK`. Bedroom fan's light should toggle. If this fails, the regression is our firmware change — stop and debug before going further. + +- [ ] **Step 4: Smoke test the NEW endpoint with the same bits** + +The fan-1 light packet is `10001100111101101100000000111111`: + +```bash +curl -v -X POST "http:///transmit" \ + -H 'Content-Type: application/json' \ + -d '{ + "bits":"10001100111101101100000000111111", + "timing":{"sync_us":8000,"sync_gap_us":670,"pulse_us":400, + "zero_gap_us":670,"one_gap_us":1800,"repeat_count":20} + }' +``` + +Expected: `HTTP/1.1 200 OK`, body `OK 32 bits x 20 reps`. Bedroom light should toggle. + +- [ ] **Step 5: Smoke test validation** + +```bash +curl -v -X POST "http:///transmit" -H 'Content-Type: application/json' -d '{"bits":""}' +curl -v -X POST "http:///transmit" -H 'Content-Type: application/json' -d '{"bits":"01x0","timing":{}}' +``` + +Expected: both return `400 Bad Request` with a plain-text error message. Confirms validation fires. + +- [ ] **Step 6: Record the IP for the CLI tests** + +Note the IP in your scratch notes (or set an env var): + +```bash +export FAN_HOST= +``` + +No commit in this task — it's verification, not code. + +--- + +## Task 4: CLI — refactor to click group with `send` and `raw` + +**Files:** +- Modify: `cli.py` +- Create: `tests/test_cli.py` +- Modify: `src/device.py` (add helper) + +**Why this shape:** Single-command CLI doesn't fit once we need two usage modes. `click.group` with subcommands is idiomatic. A `build_transmit_payload` helper in `src/device.py` keeps payload shape in one place so `send` and `raw` can't drift from each other. + +### Task 4a: Add the payload helper + +- [ ] **Step 1: Write the failing test** + +Create or append to `tests/test_device.py` (add to the end): + +```python +from src.device import build_transmit_payload + + +def test_build_transmit_payload_shape(profile): + payload = build_transmit_payload(profile, bits="01" * 16) + assert payload["bits"] == "01" * 16 + assert set(payload["timing"].keys()) == { + "sync_us", "sync_gap_us", "pulse_us", + "zero_gap_us", "one_gap_us", "repeat_count", + } + assert payload["timing"]["pulse_us"] == 400 + assert payload["timing"]["repeat_count"] == 20 + + +def test_build_transmit_payload_rejects_bad_bits(profile): + with pytest.raises(ValueError): + build_transmit_payload(profile, bits="") + with pytest.raises(ValueError): + build_transmit_payload(profile, bits="0102") +``` + +- [ ] **Step 2: Run the tests to confirm failure** + +```bash +uv run pytest tests/test_device.py::test_build_transmit_payload_shape -v +``` + +Expected: `ImportError: cannot import name 'build_transmit_payload'`. + +- [ ] **Step 3: Implement the helper** + +Append to `src/device.py`: + +```python +_TIMING_KEYS = ( + "sync_us", "sync_gap_us", "pulse_us", + "zero_gap_us", "one_gap_us", "repeat_count", +) + + +def build_transmit_payload(profile: DeviceProfile, bits: str) -> dict: + """Return the JSON-ready payload for POST /transmit. + + Raises ValueError if bits is empty or contains non-binary characters. + """ + if not bits: + raise ValueError("bits must be non-empty") + if not set(bits).issubset({"0", "1"}): + raise ValueError("bits must contain only '0' and '1'") + timing = {k: profile.timing[k] for k in _TIMING_KEYS} + return {"bits": bits, "timing": timing} +``` + +- [ ] **Step 4: Run the tests to confirm pass** + +```bash +uv run pytest tests/test_device.py -v +``` + +Expected: all green, including the two new ones. + +- [ ] **Step 5: Commit** + +```bash +git add src/device.py tests/test_device.py +git commit -m "feat: add build_transmit_payload helper" +``` + +### Task 4b: Refactor CLI into click group + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_cli.py`: + +```python +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + +from cli import cli + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_help_lists_subcommands(runner): + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "send" in result.output + assert "raw" in result.output + + +def test_send_posts_to_transmit_endpoint(runner): + with patch("cli.httpx.post") as mock_post: + mock_post.return_value = MagicMock(status_code=200, text="OK") + mock_post.return_value.raise_for_status = MagicMock() + + result = runner.invoke( + cli, + ["send", "sofa_king_fan", "bedroom", "light", "--host", "1.2.3.4"], + ) + + assert result.exit_code == 0, result.output + assert mock_post.call_count == 1 + url, = mock_post.call_args.args + assert url == "http://1.2.3.4:80/transmit" + payload = mock_post.call_args.kwargs["json"] + assert payload["bits"] == "10001100111101101100000000111111" + assert payload["timing"]["pulse_us"] == 400 + + +def test_send_rejects_unknown_unit(runner): + with patch("cli.httpx.post") as mock_post: + result = runner.invoke( + cli, + ["send", "sofa_king_fan", "garage", "light", "--host", "1.2.3.4"], + ) + assert result.exit_code != 0 + assert "unknown unit" in result.output.lower() + assert mock_post.call_count == 0 + + +def test_raw_posts_arbitrary_bits(runner): + with patch("cli.httpx.post") as mock_post: + mock_post.return_value = MagicMock(status_code=200, text="OK") + mock_post.return_value.raise_for_status = MagicMock() + + result = runner.invoke( + cli, + [ + "raw", "01" * 16, + "--device", "sofa_king_fan", + "--host", "1.2.3.4", + ], + ) + + assert result.exit_code == 0, result.output + assert mock_post.call_count == 1 + payload = mock_post.call_args.kwargs["json"] + assert payload["bits"] == "01" * 16 + assert payload["timing"]["pulse_us"] == 400 + + +def test_raw_rejects_non_binary(runner): + with patch("cli.httpx.post") as mock_post: + result = runner.invoke( + cli, + ["raw", "01x0", "--device", "sofa_king_fan", "--host", "1.2.3.4"], + ) + assert result.exit_code != 0 + assert mock_post.call_count == 0 +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +uv run pytest tests/test_cli.py -v +``` + +Expected: all fail. The current `cli.py` is a single command, not a group — `send` and `raw` don't exist. + +- [ ] **Step 3: Rewrite `cli.py` as a group** + +Replace the entire contents of `cli.py` with: + +```python +#!/usr/bin/env python3 +"""Control RF devices via NodeMCU HTTP API. + + python cli.py send sofa_king_fan bedroom 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, build_transmit_payload + +DEVICES_DIR = "devices" + + +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) +@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. bedroom light).""" + profile = _load_profile(device) + + if unit not in profile.units: + available = ", ".join(sorted(profile.units)) + click.echo(f"Error: unknown unit '{unit}'. Available: {available}", err=True) + sys.exit(1) + + if command not in profile.commands: + available = ", ".join(sorted(profile.commands)) + click.echo(f"Error: unknown command '{command}'. Available: {available}", err=True) + sys.exit(1) + + 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="nodemcu.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: + payload = build_transmit_payload(profile, bits=bits) + except ValueError as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + _post_transmit(host, port, payload) + click.echo(f"OK raw [{bits}] ({len(bits)} bits) via {device}") + + +if __name__ == "__main__": + cli() +``` + +- [ ] **Step 4: Run tests to confirm pass** + +```bash +uv run pytest tests/test_cli.py tests/test_device.py -v +``` + +Expected: all green. + +- [ ] **Step 5: Hit the live NodeMCU as a sanity check** + +```bash +uv run python cli.py send sofa_king_fan bedroom light --host $FAN_HOST +uv run python cli.py raw 10001100111101101100000000111111 --device sofa_king_fan --host $FAN_HOST +``` + +Both should return `OK ...` and the bedroom light should toggle twice. + +- [ ] **Step 6: Commit** + +```bash +git add cli.py tests/test_cli.py +git commit -m "feat(cli): split into 'send' and 'raw' subcommands over /transmit" +``` + +--- + +## Task 5: Empirical fan test matrix + +**Files:** +- Create: `docs/fan-test-matrix.md` + +**Why:** Before we reason about what's broken, get the raw data. Tom's recollection is that the only confirmed-working combination was "light on fan 2." Verify all ten cells against physical hardware, one at a time, using the new CLI. + +- [ ] **Step 1: Scaffold the results doc** + +Create `docs/fan-test-matrix.md` with: + +```markdown +# Sofucor Fan Test Matrix + +Date: 2026-04-19 +Firmware: claude/generic-firmware branch +CLI: `uv run python cli.py send ...` + +Legend: ✓ = fan responded as expected. ✗ = no response. ? = partial / intermittent. +Leave a cell blank until tested. + +## Results + +| Unit | light | off | speed1 | speed2 | speed3 | +|--------------|-------|-----|--------|--------|--------| +| bedroom | | | | | | +| living_room | | | | | | + +## Notes + +- Test each cell by running: `uv run python cli.py send sofa_king_fan --host $FAN_HOST` +- Wait ~2 seconds between commands so the fan's receiver re-arms. +- For light, confirm it toggles (state-change), not absolute on/off. +- Record the distance from TX antenna to fan if coverage seems position-dependent. +``` + +- [ ] **Step 2: Run the matrix** + +Walk every cell. For each command: + +```bash +uv run python cli.py send sofa_king_fan bedroom light --host $FAN_HOST +# wait ~2s, observe fan, mark cell +``` + +- [ ] **Step 3: Fill in the results doc** + +Edit `docs/fan-test-matrix.md` with observations. Add prose notes for anything surprising (intermittent responses, position-sensitive, speeds that work from one remote but not the other, etc.). + +- [ ] **Step 4: Commit** + +```bash +git add docs/fan-test-matrix.md +git commit -m "docs: record empirical fan-command test matrix" +``` + +--- + +## Task 6: (Conditional) Fan-2 failure investigation + +**Run this task only if Task 5 shows failures on fan 2 that aren't explainable by signal strength.** + +**Files:** append findings to `docs/fan-test-matrix.md`. + +**Hypotheses to test with `cli.py raw`:** + +- [ ] **H1: address decoded wrong.** Swap remote-1 and remote-2 addresses in front of a known-working command (e.g. speed1). If fan 2 responds to `remote1_address + speed1_command`, our fan-2 address decoding is wrong. + +```bash +# remote 1 addr + speed1 cmd — should drive fan 1 (baseline) +uv run python cli.py raw 10001100111101100001000011101111 --device sofa_king_fan --host $FAN_HOST +# remote 2 addr + speed1 cmd — should drive fan 2 +uv run python cli.py raw 11110001001110110001000011101111 --device sofa_king_fan --host $FAN_HOST +``` + +- [ ] **H2: command bits leak into address.** Compare fan-2 captures across two known-working commands. Identify bits that differ between `off` and `speed1` in remote 2 and check whether any of those bits are in the "address" range. + +Capture two fresh WAVs for remote 2 with `rtl_fm` and re-decode in `signal_explorer.py`. Diff the decoded 32-bit strings. + +- [ ] **H3: signal strength.** Repeat the failing command(s) at short range (TX 30 cm from fan) vs. install location. If short range succeeds and install range fails, it's an antenna/power issue, not a bits issue. + +- [ ] **H4: repeat_count too low.** Real remote sends 36–41 reps; firmware sends 20. Retry failing commands with: + +```bash +# Temporarily bump repeats by editing devices/sofa_king_fan.yaml repeat_count: 40 +# (or pass via a future --repeat flag if we add one — not in scope for now). +``` + +- [ ] **Step: Write findings back into `docs/fan-test-matrix.md`** + +Append a `## Investigation` section with one subsection per hypothesis, noting which were ruled in / out. + +- [ ] **Step: Commit** + +```bash +git add docs/fan-test-matrix.md devices/sofa_king_fan.yaml +git commit -m "docs: fan-2 failure investigation" +``` + +--- + +## Task 7: (Optional) Retire legacy hardcoded endpoints + +**Only do this after Tom confirms the generic path has been reliable for at least one full session.** + +**Files:** +- Modify: `firmware/src/main.cpp` + +- [ ] **Step 1: Delete the hardcoded handlers and route registrations** + +Remove lines 79-91 (the `h1*` / `h2*` handler functions) and lines 124-135 (their `server.on(...)` registrations). Also remove the constants `ADDR_FAN1`, `ADDR_FAN2`, `CMD_LIGHT`, `CMD_OFF`, `CMD_SPEED1`, `CMD_SPEED2`, `CMD_SPEED3` if nothing else references them. + +Update the boot banner at the bottom of `setup()`: + +```cpp + Serial.println("Endpoint: POST /transmit (generic bits + timing)"); +``` + +- [ ] **Step 2: Flash and confirm only the generic endpoint remains** + +```bash +cd firmware && pio run --target upload +curl -v "http://$FAN_HOST/fan/1/light" # expect 404 +curl -v -X POST "http://$FAN_HOST/transmit" \ + -H 'Content-Type: application/json' \ + -d '{"bits":"10001100111101101100000000111111", + "timing":{"sync_us":8000,"sync_gap_us":670,"pulse_us":400, + "zero_gap_us":670,"one_gap_us":1800,"repeat_count":20}}' +# expect 200 + bedroom light toggle +``` + +- [ ] **Step 3: Commit and merge** + +```bash +cd .. +git add firmware/src/main.cpp +git commit -m "refactor(firmware): remove legacy hardcoded endpoints" +git checkout main +git merge --no-ff claude/generic-firmware +``` + +--- + +## Out of Scope (explicitly) + +- Multiple firmware folders with a `/firmware` symlink (rejected in planning discussion — use branches). +- A `test-matrix` CLI subcommand that prompts for Y/N (manual is fine at 10 cells). +- `--repeat` / `--timing-override` CLI flags (add later if H4 looks promising). +- Non-Sofucor device profiles (TPMS, other remotes) — handled in a separate project phase. +- mDNS / `nodemcu.local` debugging — if DNS fails during Task 3, fall back to the IP. + +--- + +## What "done" looks like + +- Firmware exposes `POST /transmit` and the legacy routes (legacy stays until Task 7 if ever). +- `cli.py send` and `cli.py raw` both hit `/transmit`, both pass their tests. +- `docs/fan-test-matrix.md` has a filled-in results table and (if needed) an investigation section. +- Tom has physical evidence of which fan-2 commands actually work. diff --git a/firmware/platformio.ini b/firmware/platformio.ini index b38e9ad..7829911 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -5,6 +5,7 @@ framework = arduino lib_deps = ESP8266WiFi ESP8266WebServer + bblanchon/ArduinoJson@^7.0.0 monitor_speed = 115200 upload_speed = 115200 build_flags = -DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 7655461..abf1c93 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,6 +1,8 @@ #include +#include #include #include +#include // ─── USER CONFIG ────────────────────────────────────────────────────────────── const char *WIFI_SSID = "22 Parsons"; @@ -14,20 +16,22 @@ const int TX_PIN = D1; // DATA pin → HiLetgo MX-FS-03V TX m // gap LOW: ZERO_GAP (bit 0) or ONE_GAP (bit 1) // sync = SYNC_US HIGH burst before each code repetition // -// If the fan doesn't respond, try increasing SYNC_GAP_US (e.g. 4000–10000 µs) -// or reducing REPEAT_N to see if timing is the issue. +// Values measured from a clean AM-demod RTL-SDR capture on 2026-05-09. +// Keep these in sync with devices/sofa_king_fan.yaml; legacy /fan/{N}/{cmd} +// endpoints use these constants, while POST /transmit reads from the +// JSON payload (which the Python CLI loads from the YAML). // -const uint32_t SYNC_US = 8000; // sync HIGH pulse before each repetition -const uint32_t SYNC_GAP = 670; // LOW gap between sync and first data bit -const uint32_t PULSE_US = 400; // carrier-ON pulse (same for all bits) -const uint32_t ZERO_GAP = 670; // LOW gap = bit 0 -const uint32_t ONE_GAP = 1800; // LOW gap = bit 1 +const uint32_t SYNC_US = 8200; // sync HIGH pulse before each repetition +const uint32_t SYNC_GAP = 4500; // LOW gap between sync and first data bit +const uint32_t PULSE_US = 560; // carrier-ON pulse (same for all bits) +const uint32_t ZERO_GAP = 570; // LOW gap = bit 0 +const uint32_t ONE_GAP = 1700; // LOW gap = bit 1 const int REPEAT_N = 20; // repetitions per button press (remote uses 36–41) // ────────────────────────────────────────────────────────────────────────────── // ─── FAN ADDRESSES (bits 0–15, unique per remote/fan pair) ─────────────────── -const char *ADDR_FAN1 = "1000110011110110"; // bedroom -const char *ADDR_FAN2 = "1111000100111011"; // living room +const char *ADDR_FAN1 = "1000110011110110"; // main (remote 1) +const char *ADDR_FAN2 = "1111000100111011"; // stairs (remote 2) // ─── COMMAND CODES (bits 16–31, shared across all fan units) ───────────────── const char *CMD_LIGHT = "1100000000111111"; // verified: fan 1 @@ -72,18 +76,122 @@ void transmit(const char *addr, const char *cmd) { } } +// Generic transmit: toggle TX_PIN per an explicit timing spec. +// Used by POST /transmit so the Mac can drive arbitrary bit patterns +// without the firmware knowing anything about the device protocol. +void transmitGeneric(const char *bits, + uint32_t sync_us, uint32_t sync_gap_us, + uint32_t pulse_us, uint32_t zero_gap_us, + uint32_t one_gap_us, int repeat_count) { + for (int r = 0; r < repeat_count; r++) { + digitalWrite(TX_PIN, HIGH); + delayMicroseconds(sync_us); + digitalWrite(TX_PIN, LOW); + delayMicroseconds(sync_gap_us); + + for (const char *p = bits; *p; p++) { + digitalWrite(TX_PIN, HIGH); + delayMicroseconds(pulse_us); + digitalWrite(TX_PIN, LOW); + delayMicroseconds(*p == '1' ? one_gap_us : zero_gap_us); + } + + ESP.wdtFeed(); + } +} + // ─── HTTP HANDLERS ─────────────────────────────────────────────────────────── +// POST /transmit — body is JSON: +// {"bits": "010...", "timing": {"sync_us":N, "sync_gap_us":N, +// "pulse_us":N, "zero_gap_us":N, "one_gap_us":N, "repeat_count":N}} +void handleTransmit() { + if (server.method() != HTTP_POST) { + server.send(405, "text/plain", "method not allowed\n"); + return; + } + if (!server.hasArg("plain")) { + server.send(400, "text/plain", "missing body\n"); + return; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + server.send(400, "text/plain", String("bad json: ") + err.c_str() + "\n"); + return; + } + + const char *bits = doc["bits"] | (const char *)nullptr; + if (!bits) { + server.send(400, "text/plain", "missing 'bits'\n"); + return; + } + size_t bitlen = strlen(bits); + if (bitlen == 0 || bitlen > 128) { + server.send(400, "text/plain", "bits must be 1..128 chars\n"); + return; + } + for (size_t i = 0; i < bitlen; i++) { + if (bits[i] != '0' && bits[i] != '1') { + server.send(400, "text/plain", "bits must contain only 0 and 1\n"); + return; + } + } + + JsonObject t = doc["timing"].as(); + if (t.isNull()) { + server.send(400, "text/plain", "missing 'timing' object\n"); + return; + } + + const char *required[] = {"sync_us", "sync_gap_us", "pulse_us", + "zero_gap_us", "one_gap_us", "repeat_count"}; + for (const char *k : required) { + if (!t.containsKey(k)) { + String msg = String("missing timing.") + k + "\n"; + server.send(400, "text/plain", msg); + return; + } + } + + uint32_t sync_us = t["sync_us"]; + uint32_t sync_gap_us = t["sync_gap_us"]; + uint32_t pulse_us = t["pulse_us"]; + uint32_t zero_gap_us = t["zero_gap_us"]; + uint32_t one_gap_us = t["one_gap_us"]; + int repeat_count = t["repeat_count"]; + + // Sanity clamps. Anything outside these is almost certainly a typo/bug + // and we'd rather fail loudly than sit in delayMicroseconds() forever. + auto badUs = [](uint32_t v) { return v == 0 || v > 100000; }; + if (badUs(sync_us) || badUs(sync_gap_us) || badUs(pulse_us) || + badUs(zero_gap_us) || badUs(one_gap_us)) { + server.send(400, "text/plain", "timing microsecond values must be 1..100000\n"); + return; + } + if (repeat_count < 1 || repeat_count > 100) { + server.send(400, "text/plain", "repeat_count must be 1..100\n"); + return; + } + + transmitGeneric(bits, sync_us, sync_gap_us, + pulse_us, zero_gap_us, one_gap_us, repeat_count); + + String reply = String("OK ") + bitlen + " bits x " + repeat_count + " reps\n"; + server.send(200, "text/plain", reply); +} + void sendOK() { server.send(200, "text/plain", "OK\n"); } void send404() { server.send(404, "text/plain", "Not Found\n"); } -// Fan 1 — bedroom +// Fan 1 — main void h1Light() { transmit(ADDR_FAN1, CMD_LIGHT); sendOK(); } void h1Off() { transmit(ADDR_FAN1, CMD_OFF); sendOK(); } void h1Speed1() { transmit(ADDR_FAN1, CMD_SPEED1); sendOK(); } void h1Speed2() { transmit(ADDR_FAN1, CMD_SPEED2); sendOK(); } void h1Speed3() { transmit(ADDR_FAN1, CMD_SPEED3); sendOK(); } -// Fan 2 — living room +// Fan 2 — stairs void h2Light() { transmit(ADDR_FAN2, CMD_LIGHT); sendOK(); } void h2Off() { transmit(ADDR_FAN2, CMD_OFF); sendOK(); } void h2Speed1() { transmit(ADDR_FAN2, CMD_SPEED1); sendOK(); } @@ -120,27 +228,46 @@ void setup() { } Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str()); - // Fan 1 (bedroom) + // Advertise as ceilingfans.local on the LAN. Home Assistant's + // rest_command can hit URLs at that hostname instead of the IP, + // which means the config doesn't break on DHCP lease changes. + // addService("http", "tcp", 80) also makes the chip visible in + // service browsers (`dns-sd -B _services._dns-sd._udp local.`) + // and to Home Assistant's auto-discovery. + if (MDNS.begin("ceilingfans")) { + MDNS.addService("http", "tcp", 80); + Serial.println("mDNS: ceilingfans.local (advertising _http._tcp on :80)"); + } else { + Serial.println("mDNS: setup FAILED — falling back to IP only"); + } + + // Fan 1 (main) server.on("/fan/1/light", HTTP_GET, h1Light); server.on("/fan/1/off", HTTP_GET, h1Off); server.on("/fan/1/speed1", HTTP_GET, h1Speed1); server.on("/fan/1/speed2", HTTP_GET, h1Speed2); server.on("/fan/1/speed3", HTTP_GET, h1Speed3); - // Fan 2 (living room) + // Fan 2 (stairs) server.on("/fan/2/light", HTTP_GET, h2Light); server.on("/fan/2/off", HTTP_GET, h2Off); server.on("/fan/2/speed1", HTTP_GET, h2Speed1); server.on("/fan/2/speed2", HTTP_GET, h2Speed2); server.on("/fan/2/speed3", HTTP_GET, h2Speed3); + // Generic endpoint — preferred path, used by cli.py send & raw + server.on("/transmit", HTTP_POST, handleTransmit); + server.onNotFound(send404); server.begin(); Serial.println("HTTP server ready"); - Serial.println("Endpoints: /fan/{1,2}/{light,off,speed1,speed2,speed3}"); + Serial.println("Endpoints:"); + Serial.println(" POST http://ceilingfans.local/transmit (generic bits + timing)"); + Serial.println(" GET http://ceilingfans.local/fan/{1,2}/{light,off,speed1,speed2,speed3}"); } // ─── LOOP ──────────────────────────────────────────────────────────────────── void loop() { + MDNS.update(); server.handleClient(); } diff --git a/pyproject.toml b/pyproject.toml index a48a864..ed82217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,13 @@ dependencies = [ [dependency-groups] dev = [ + "pre-commit>=4.6.0", "pytest>=8.0", + "ruff>=0.15.13", ] +[tool.pytest.ini_options] +testpaths = ["tests"] + [project.scripts] fanctl = "cli:cli" diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..1f4d343 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,2 @@ +rf-nudge.log +rf-nudge.err diff --git a/scripts/com.tomclancy.rf-nudge.plist b/scripts/com.tomclancy.rf-nudge.plist new file mode 100644 index 0000000..e9e90e5 --- /dev/null +++ b/scripts/com.tomclancy.rf-nudge.plist @@ -0,0 +1,29 @@ + + + + + + Label + com.tomclancy.rf-nudge + ProgramArguments + + /bin/bash + /Users/tom/Documents/work/radiofrequency/scripts/rf-nudge.sh + + StartCalendarInterval + + Hour + 9 + Minute + 0 + + RunAtLoad + + StandardOutPath + /Users/tom/Documents/work/radiofrequency/scripts/rf-nudge.log + StandardErrorPath + /Users/tom/Documents/work/radiofrequency/scripts/rf-nudge.err + + diff --git a/scripts/rf-nudge.sh b/scripts/rf-nudge.sh new file mode 100755 index 0000000..103b070 --- /dev/null +++ b/scripts/rf-nudge.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Morning nudge for the Sofucor fan RF project. Fired by +# ~/Library/LaunchAgents/com.tomclancy.rf-nudge.plist at 09:00 local time. +# +# Intentionally simple: a fixed reminder pointing at the findings doc plus +# the current highest-priority action from it. Update the MSG as the +# project's bottleneck shifts. + +set -euo pipefail + +NTFY_URL="https://notifications.tomclancy.info/claude" +NTFY_TOKEN="tk_97t85yxi0lj8gtu1kupx2f7pnd53n" + +MSG='RF fans: first move is TX module VCC wiring — VIN (5V) or 3V3? See docs/fan-debugging-2026-04-19.md on claude/generic-firmware branch.' + +curl -fsS -u ":${NTFY_TOKEN}" -d "$MSG" "$NTFY_URL" >/dev/null +echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] nudge sent" diff --git a/signal_explorer.py b/signal_explorer.py index dd25879..4166ed6 100644 --- a/signal_explorer.py +++ b/signal_explorer.py @@ -59,7 +59,9 @@ def style_ax(ax, title="", xlabel="Time (µs)", ylabel_left="HIGH", ylabel_right ax.set_ylim(-0.15, 1.45) -def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_gap_us=0): +def build_ook_waveform( + bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_gap_us=0 +): """ Build time and level arrays for an OOK pulse-distance waveform. Returns (times, levels, bit_centers, bit_boundaries) in microseconds. @@ -105,9 +107,15 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ st.sidebar.title("⚡ Signal Parameters") st.sidebar.markdown("### Timing (µs)") -pulse_us = st.sidebar.slider("Pulse width (HIGH)", 100, 1000, timing["pulse_us"], step=50) -zero_gap_us = st.sidebar.slider("Zero gap (LOW)", 100, 2000, timing["zero_gap_us"], step=50) -one_gap_us = st.sidebar.slider("One gap (LOW)", 500, 4000, timing["one_gap_us"], step=50) +pulse_us = st.sidebar.slider( + "Pulse width (HIGH)", 100, 1000, timing["pulse_us"], step=50 +) +zero_gap_us = st.sidebar.slider( + "Zero gap (LOW)", 100, 2000, timing["zero_gap_us"], step=50 +) +one_gap_us = st.sidebar.slider( + "One gap (LOW)", 500, 4000, timing["one_gap_us"], step=50 +) sync_us = st.sidebar.slider("Sync pulse", 0, 15000, timing["sync_us"], step=500) sync_gap_us = st.sidebar.slider("Sync gap", 0, 2000, timing["sync_gap_us"], step=50) @@ -122,7 +130,9 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ full_code = address + command st.sidebar.markdown("---") -st.sidebar.code(f"Address: {address}\nCommand: {command}\nFull: {full_code}", language="text") +st.sidebar.code( + f"Address: {address}\nCommand: {command}\nFull: {full_code}", language="text" +) # =========================================================================== # MAIN CONTENT @@ -136,15 +146,18 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # --------------------------------------------------------------------------- # TAB 1: What the remote actually transmits # --------------------------------------------------------------------------- -tab1, tab2, tab3, tab4 = st.tabs([ - "1️⃣ What the Remote Sends", - "2️⃣ What You See in Audacity", - "3️⃣ How to Read Bits", - "4️⃣ Full Packet Explorer", -]) +tab1, tab2, tab3, tab4 = st.tabs( + [ + "1️⃣ What the Remote Sends", + "2️⃣ What You See in Audacity", + "3️⃣ How to Read Bits", + "4️⃣ Full Packet Explorer", + ] +) with tab1: - st.markdown(""" + st.markdown( + """ ### The ideal OOK signal — what the remote's TX chip actually outputs **On-Off Keying** is the simplest possible digital radio modulation. The transmitter @@ -158,7 +171,8 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ - **Long gap → bit 1** (currently {one_gap} µs) Play with the sliders in the sidebar to see how changing the timing affects the waveform. - """.format(zero_gap=zero_gap_us, one_gap=one_gap_us)) + """.format(zero_gap=zero_gap_us, one_gap=one_gap_us) + ) # Show first 8 bits with sync first_8 = full_code[:8] @@ -169,19 +183,36 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ fig1, ax1 = plt.subplots(figsize=(14, 3), facecolor=DARK_BG) ax1.fill_between(t, lvl, step="post", alpha=0.35, color=CYAN) ax1.step(t, lvl, where="post", color=CYAN, linewidth=2) - style_ax(ax1, title=f"Sync + first 8 data bits [{first_8}]", - ylabel_left="Carrier ON", ylabel_right="Carrier OFF") + style_ax( + ax1, + title=f"Sync + first 8 data bits [{first_8}]", + ylabel_left="Carrier ON", + ylabel_right="Carrier OFF", + ) # Annotate sync if sync_us > 0: - ax1.annotate("← 8 ms sync pulse (carrier on, no data) →", - xy=(sync_us / 2, 1.15), color=ORANGE, fontsize=9, - ha="center", va="bottom") + ax1.annotate( + "← 8 ms sync pulse (carrier on, no data) →", + xy=(sync_us / 2, 1.15), + color=ORANGE, + fontsize=9, + ha="center", + va="bottom", + ) # Annotate each bit for i, (c, b) in enumerate(zip(centers, first_8)): - ax1.text(c, 1.25, b, ha="center", va="bottom", color=YELLOW, - fontsize=11, fontweight="bold") + ax1.text( + c, + 1.25, + b, + ha="center", + va="bottom", + color=YELLOW, + fontsize=11, + fontweight="bold", + ) ax1.set_xlabel("Time (µs)", color=MUTED) st.pyplot(fig1, width="stretch") @@ -214,7 +245,9 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Build a short example: 3 bits [0, 1, 1] demo_bits = "011" - t_real, lvl_real, _, _ = build_ook_waveform(demo_bits, pulse_us, zero_gap_us, one_gap_us) + t_real, lvl_real, _, _ = build_ook_waveform( + demo_bits, pulse_us, zero_gap_us, one_gap_us + ) # Simulated FM demod: invert + add noise during "gaps" np.random.seed(42) @@ -226,14 +259,19 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ noise = np.random.normal(0, 0.35, len(t_cont)) fm_demod = np.where(ideal > 0.5, 0.0, noise) - fig2, (ax_real, ax_fm) = plt.subplots(2, 1, figsize=(14, 5), facecolor=DARK_BG, - gridspec_kw={"hspace": 0.5}) + fig2, (ax_real, ax_fm) = plt.subplots( + 2, 1, figsize=(14, 5), facecolor=DARK_BG, gridspec_kw={"hspace": 0.5} + ) # Top: real signal ax_real.fill_between(t_real, lvl_real, step="post", alpha=0.35, color=CYAN) ax_real.step(t_real, lvl_real, where="post", color=CYAN, linewidth=2) - style_ax(ax_real, title='What the remote transmits (bits "0, 1, 1")', - ylabel_left="Carrier ON", ylabel_right="Carrier OFF") + style_ax( + ax_real, + title='What the remote transmits (bits "0, 1, 1")', + ylabel_left="Carrier ON", + ylabel_right="Carrier OFF", + ) # Annotate bits bit_labels_x = [] pos = 0 @@ -243,13 +281,26 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ bit_labels_x.append((center, b)) pos += pulse_us + gap for cx, bl in bit_labels_x: - ax_real.text(cx, 1.25, f"bit {bl}", ha="center", color=YELLOW, fontsize=10, fontweight="bold") + ax_real.text( + cx, + 1.25, + f"bit {bl}", + ha="center", + color=YELLOW, + fontsize=10, + fontweight="bold", + ) # Bottom: FM demod ax_fm.plot(t_cont, fm_demod, color=ORANGE, linewidth=0.6, alpha=0.9) ax_fm.set_facecolor(PANEL_BG) - ax_fm.set_title('What you see in Audacity (rtl_fm output)', color=WHITE, - fontsize=13, fontweight="bold", pad=12) + ax_fm.set_title( + "What you see in Audacity (rtl_fm output)", + color=WHITE, + fontsize=13, + fontweight="bold", + pad=12, + ) ax_fm.set_xlabel("Time (µs)", color=MUTED, fontsize=10) ax_fm.set_ylabel("Amplitude", color=MUTED, fontsize=10) ax_fm.tick_params(colors=MUTED, labelsize=9) @@ -263,14 +314,31 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ pos = 0 for b in demo_bits: ax_fm.axvspan(pos, pos + pulse_us, alpha=0.15, color=CYAN) - ax_fm.text(pos + pulse_us / 2, 1.0, "quiet\n(carrier ON)", ha="center", - va="bottom", color=CYAN, fontsize=7) + ax_fm.text( + pos + pulse_us / 2, + 1.0, + "quiet\n(carrier ON)", + ha="center", + va="bottom", + color=CYAN, + fontsize=7, + ) pos += pulse_us gap = one_gap_us if b == "1" else zero_gap_us - ax_fm.annotate("", xy=(pos, -0.9), xytext=(pos + gap, -0.9), - arrowprops=dict(arrowstyle="<->", color=YELLOW, lw=1.5)) - ax_fm.text(pos + gap / 2, -1.05, f"{'long' if b == '1' else 'short'} noise burst = bit {b}", - ha="center", color=YELLOW, fontsize=8) + ax_fm.annotate( + "", + xy=(pos, -0.9), + xytext=(pos + gap, -0.9), + arrowprops=dict(arrowstyle="<->", color=YELLOW, lw=1.5), + ) + ax_fm.text( + pos + gap / 2, + -1.05, + f"{'long' if b == '1' else 'short'} noise burst = bit {b}", + ha="center", + color=YELLOW, + fontsize=8, + ) pos += gap st.pyplot(fig2, width="stretch") @@ -280,7 +348,7 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ > **The rule:** In Audacity, you're measuring the **noisy bursts** (the gaps), not the > quiet parts. Short burst → 0. Long burst → 1. The quiet flat sections *between* > the bursts are the actual carrier pulses — that's the remote yelling "I'M HERE" at - > 315.4 MHz. + > 433.935 MHz. Here's the text version from last session: ``` @@ -324,8 +392,12 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ fig3, ax3 = plt.subplots(figsize=(14, 4), facecolor=DARK_BG) ax3.fill_between(t_demo, lvl_demo, step="post", alpha=0.3, color=CYAN) ax3.step(t_demo, lvl_demo, where="post", color=CYAN, linewidth=2) - style_ax(ax3, title=f"Reading bits from the waveform: [{demo_pattern}]", - ylabel_left="Carrier ON", ylabel_right="Carrier OFF") + style_ax( + ax3, + title=f"Reading bits from the waveform: [{demo_pattern}]", + ylabel_left="Carrier ON", + ylabel_right="Carrier OFF", + ) # Color-code the gaps pos = 0 @@ -337,17 +409,43 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Gap region — colored by bit value color = RED if b == "1" else YELLOW ax3.axvspan(pos, pos + gap, alpha=0.15, color=color) - ax3.text(pos + gap / 2, -0.08, f"{'LONG' if b == '1' else 'short'}", - ha="center", va="top", color=color, fontsize=8, fontweight="bold") - ax3.text(pos + gap / 2, 1.30, b, ha="center", va="bottom", - color=color, fontsize=14, fontweight="bold") + ax3.text( + pos + gap / 2, + -0.08, + f"{'LONG' if b == '1' else 'short'}", + ha="center", + va="top", + color=color, + fontsize=8, + fontweight="bold", + ) + ax3.text( + pos + gap / 2, + 1.30, + b, + ha="center", + va="bottom", + color=color, + fontsize=14, + fontweight="bold", + ) pos += gap # Legend - short_patch = mpatches.Patch(color=YELLOW, alpha=0.4, label=f"Short gap ({zero_gap_us} µs) = 0") - long_patch = mpatches.Patch(color=RED, alpha=0.4, label=f"Long gap ({one_gap_us} µs) = 1") - ax3.legend(handles=[short_patch, long_patch], loc="upper right", - facecolor=PANEL_BG, edgecolor=MUTED, labelcolor=WHITE, fontsize=9) + short_patch = mpatches.Patch( + color=YELLOW, alpha=0.4, label=f"Short gap ({zero_gap_us} µs) = 0" + ) + long_patch = mpatches.Patch( + color=RED, alpha=0.4, label=f"Long gap ({one_gap_us} µs) = 1" + ) + ax3.legend( + handles=[short_patch, long_patch], + loc="upper right", + facecolor=PANEL_BG, + edgecolor=MUTED, + labelcolor=WHITE, + fontsize=9, + ) st.pyplot(fig3, width="stretch") plt.close(fig3) @@ -386,7 +484,10 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ show_sync = st.checkbox("Show sync pulse", value=False) t_full, lvl_full, centers_full, bounds_full = build_ook_waveform( - full_code, pulse_us, zero_gap_us, one_gap_us, + full_code, + pulse_us, + zero_gap_us, + one_gap_us, sync_us=sync_us if show_sync else 0, sync_gap_us=sync_gap_us if show_sync else 0, ) @@ -405,10 +506,24 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Labels addr_mid = (offset + addr_end) / 2 cmd_mid = (addr_end + bounds_full[-1]) / 2 - ax4.text(addr_mid, 1.35, "ADDRESS (bits 0–15)", ha="center", color=ORANGE, - fontsize=10, fontweight="bold") - ax4.text(cmd_mid, 1.35, "COMMAND (bits 16–31)", ha="center", color=CYAN, - fontsize=10, fontweight="bold") + ax4.text( + addr_mid, + 1.35, + "ADDRESS (bits 0–15)", + ha="center", + color=ORANGE, + fontsize=10, + fontweight="bold", + ) + ax4.text( + cmd_mid, + 1.35, + "COMMAND (bits 16–31)", + ha="center", + color=CYAN, + fontsize=10, + fontweight="bold", + ) # Bit labels for i, (c, b) in enumerate(zip(centers_full, full_code)): @@ -421,12 +536,17 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Show all commands as a comparison st.markdown("---") st.markdown("### All commands compared") - st.markdown("Notice how the left half (address) stays the same and only the right half (command) changes:") + st.markdown( + "Notice how the left half (address) stays the same and only the right half (command) changes:" + ) - fig5, axes = plt.subplots(len(dev["commands"]), 1, - figsize=(16, 2.2 * len(dev["commands"])), - facecolor=DARK_BG, - gridspec_kw={"hspace": 0.6}) + fig5, axes = plt.subplots( + len(dev["commands"]), + 1, + figsize=(16, 2.2 * len(dev["commands"])), + facecolor=DARK_BG, + gridspec_kw={"hspace": 0.6}, + ) for idx, (cname, cbits) in enumerate(dev["commands"].items()): ax = axes[idx] diff --git a/src/device.py b/src/device.py index 3ad4e61..13c6403 100644 --- a/src/device.py +++ b/src/device.py @@ -29,6 +29,29 @@ def build_packet(profile: DeviceProfile, unit: str, command: str) -> str: Raises KeyError if unit or command is not in the profile. """ - address = profile.units[unit]["address"] # KeyError on unknown unit - command_bits = profile.commands[command] # KeyError on unknown command + address = profile.units[unit]["address"] # KeyError on unknown unit + command_bits = profile.commands[command] # KeyError on unknown command return address + command_bits + + +_TIMING_KEYS = ( + "sync_us", + "sync_gap_us", + "pulse_us", + "zero_gap_us", + "one_gap_us", + "repeat_count", +) + + +def build_transmit_payload(profile: DeviceProfile, bits: str) -> dict: + """Return the JSON-ready payload for POST /transmit. + + Raises ValueError if bits is empty or contains non-binary characters. + """ + if not bits: + raise ValueError("bits must be non-empty") + if not set(bits).issubset({"0", "1"}): + raise ValueError("bits must contain only '0' and '1'") + timing = {k: profile.timing[k] for k in _TIMING_KEYS} + return {"bits": bits, "timing": timing} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a77cc2d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,82 @@ +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + +from cli import cli + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_help_lists_subcommands(runner): + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "send" in result.output + assert "raw" in result.output + + +def test_send_posts_to_transmit_endpoint(runner): + with patch("cli.httpx.post") as mock_post: + mock_post.return_value = MagicMock(status_code=200, text="OK") + mock_post.return_value.raise_for_status = MagicMock() + + result = runner.invoke( + cli, + ["send", "sofa_king_fan", "main", "light", "--host", "1.2.3.4"], + ) + + assert result.exit_code == 0, result.output + assert mock_post.call_count == 1 + (url,) = mock_post.call_args.args + assert url == "http://1.2.3.4:80/transmit" + payload = mock_post.call_args.kwargs["json"] + assert payload["bits"] == "10001100111101101100000000111111" + assert payload["timing"]["pulse_us"] == 560 + + +def test_send_rejects_unknown_unit(runner): + with patch("cli.httpx.post") as mock_post: + result = runner.invoke( + cli, + ["send", "sofa_king_fan", "garage", "light", "--host", "1.2.3.4"], + ) + assert result.exit_code != 0 + assert "unknown unit" in result.output.lower() + assert mock_post.call_count == 0 + + +def test_raw_posts_arbitrary_bits(runner): + with patch("cli.httpx.post") as mock_post: + mock_post.return_value = MagicMock(status_code=200, text="OK") + mock_post.return_value.raise_for_status = MagicMock() + + result = runner.invoke( + cli, + [ + "raw", + "01" * 16, + "--device", + "sofa_king_fan", + "--host", + "1.2.3.4", + ], + ) + + assert result.exit_code == 0, result.output + assert mock_post.call_count == 1 + payload = mock_post.call_args.kwargs["json"] + assert payload["bits"] == "01" * 16 + assert payload["timing"]["pulse_us"] == 560 + + +def test_raw_rejects_non_binary(runner): + with patch("cli.httpx.post") as mock_post: + result = runner.invoke( + cli, + ["raw", "01x0", "--device", "sofa_king_fan", "--host", "1.2.3.4"], + ) + assert result.exit_code != 0 + assert mock_post.call_count == 0 diff --git a/tests/test_device.py b/tests/test_device.py index 951a50d..484d67a 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,6 +1,6 @@ import pytest -from src.device import DeviceProfile, build_packet +from src.device import DeviceProfile, build_packet, build_transmit_payload PROFILE_PATH = "devices/sofa_king_fan.yaml" @@ -12,8 +12,9 @@ def profile(): # --- packet structure --- + def test_packet_is_32_bits(profile): - bits = build_packet(profile, unit="bedroom", command="speed1") + bits = build_packet(profile, unit="main", command="speed1") assert len(bits) == 32 @@ -25,55 +26,58 @@ def test_all_packets_are_32_bits(profile): def test_packet_contains_only_01(profile): - bits = build_packet(profile, unit="bedroom", command="off") + bits = build_packet(profile, unit="main", command="off") assert set(bits).issubset({"0", "1"}) # --- address + command concatenation --- -def test_bedroom_speed1_address_prefix(profile): - bits = build_packet(profile, unit="bedroom", command="speed1") - assert bits.startswith("1000110011110110"), "first 16 bits should be bedroom address" + +def test_main_speed1_address_prefix(profile): + bits = build_packet(profile, unit="main", command="speed1") + assert bits.startswith("1000110011110110"), "first 16 bits should be main address" assert bits[16:] == "0001000011101111", "last 16 bits should be speed1 command" -def test_living_room_off(profile): - bits = build_packet(profile, unit="living_room", command="off") +def test_stairs_off(profile): + bits = build_packet(profile, unit="stairs", command="off") assert bits == "1111000100111011" + "0100000010111111" # --- known full codes from captures --- -def test_bedroom_light_full_code(profile): - bits = build_packet(profile, unit="bedroom", command="light") + +def test_main_light_full_code(profile): + bits = build_packet(profile, unit="main", command="light") assert bits == "10001100111101101100000000111111" -def test_bedroom_off_full_code(profile): - bits = build_packet(profile, unit="bedroom", command="off") +def test_main_off_full_code(profile): + bits = build_packet(profile, unit="main", command="off") assert bits == "10001100111101100100000010111111" -def test_bedroom_speed2_full_code(profile): - bits = build_packet(profile, unit="bedroom", command="speed2") +def test_main_speed2_full_code(profile): + bits = build_packet(profile, unit="main", command="speed2") assert bits == "10001100111101101001000001101111" -def test_bedroom_speed3_full_code(profile): - bits = build_packet(profile, unit="bedroom", command="speed3") +def test_main_speed3_full_code(profile): + bits = build_packet(profile, unit="main", command="speed3") assert bits == "10001100111101100100100010110111" -def test_living_room_speed1_full_code(profile): - bits = build_packet(profile, unit="living_room", command="speed1") +def test_stairs_speed1_full_code(profile): + bits = build_packet(profile, unit="stairs", command="speed1") assert bits == "11110001001110110001000011101111" # --- error handling --- + def test_unknown_command_raises(profile): with pytest.raises(KeyError): - build_packet(profile, unit="bedroom", command="turbo") + build_packet(profile, unit="main", command="turbo") def test_unknown_unit_raises(profile): @@ -83,8 +87,9 @@ def test_unknown_unit_raises(profile): # --- profile metadata --- + def test_frequency(profile): - assert profile.frequency_mhz == 315.4 + assert profile.frequency_mhz == 433.935 def test_timing_keys_present(profile): @@ -96,3 +101,28 @@ def test_units_have_fan_number(profile): for name, unit in profile.units.items(): assert "fan_number" in unit, f"unit '{name}' missing fan_number" assert isinstance(unit["fan_number"], int) + + +# --- build_transmit_payload --- + + +def test_build_transmit_payload_shape(profile): + payload = build_transmit_payload(profile, bits="01" * 16) + assert payload["bits"] == "01" * 16 + assert set(payload["timing"].keys()) == { + "sync_us", + "sync_gap_us", + "pulse_us", + "zero_gap_us", + "one_gap_us", + "repeat_count", + } + assert payload["timing"]["pulse_us"] == 560 + assert payload["timing"]["repeat_count"] == 20 + + +def test_build_transmit_payload_rejects_bad_bits(profile): + with pytest.raises(ValueError): + build_transmit_payload(profile, bits="") + with pytest.raises(ValueError): + build_transmit_payload(profile, bits="0102") diff --git a/uv.lock b/uv.lock index 2ae19c3..8a75f78 100644 --- a/uv.lock +++ b/uv.lock @@ -74,6 +74,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -216,6 +225,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "fonttools" version = "4.62.1" @@ -310,6 +337,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -542,6 +578,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952, upload-time = "2026-03-24T15:11:23.801Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numpy" version = "2.4.4" @@ -703,6 +748,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -712,6 +766,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "protobuf" version = "7.34.1" @@ -822,6 +892,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -873,7 +956,9 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pre-commit" }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -887,7 +972,11 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] +dev = [ + { name = "pre-commit", specifier = ">=4.6.0" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.15.13" }, +] [[package]] name = "referencing" @@ -983,6 +1072,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1092,6 +1206,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" diff --git a/visualizations/ook-signal-explorer/signal_explorer.py b/visualizations/ook-signal-explorer/signal_explorer.py index 4765980..821a23e 100644 --- a/visualizations/ook-signal-explorer/signal_explorer.py +++ b/visualizations/ook-signal-explorer/signal_explorer.py @@ -59,7 +59,9 @@ def style_ax(ax, title="", xlabel="Time (µs)", ylabel_left="HIGH", ylabel_right ax.set_ylim(-0.15, 1.45) -def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_gap_us=0): +def build_ook_waveform( + bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_gap_us=0 +): """ Build time and level arrays for an OOK pulse-distance waveform. Returns (times, levels, bit_centers, bit_boundaries) in microseconds. @@ -105,9 +107,15 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ st.sidebar.title("⚡ Signal Parameters") st.sidebar.markdown("### Timing (µs)") -pulse_us = st.sidebar.slider("Pulse width (HIGH)", 100, 1000, timing["pulse_us"], step=50) -zero_gap_us = st.sidebar.slider("Zero gap (LOW)", 100, 2000, timing["zero_gap_us"], step=50) -one_gap_us = st.sidebar.slider("One gap (LOW)", 500, 4000, timing["one_gap_us"], step=50) +pulse_us = st.sidebar.slider( + "Pulse width (HIGH)", 100, 1000, timing["pulse_us"], step=50 +) +zero_gap_us = st.sidebar.slider( + "Zero gap (LOW)", 100, 2000, timing["zero_gap_us"], step=50 +) +one_gap_us = st.sidebar.slider( + "One gap (LOW)", 500, 4000, timing["one_gap_us"], step=50 +) sync_us = st.sidebar.slider("Sync pulse", 0, 15000, timing["sync_us"], step=500) sync_gap_us = st.sidebar.slider("Sync gap", 0, 2000, timing["sync_gap_us"], step=50) @@ -122,7 +130,9 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ full_code = address + command st.sidebar.markdown("---") -st.sidebar.code(f"Address: {address}\nCommand: {command}\nFull: {full_code}", language="text") +st.sidebar.code( + f"Address: {address}\nCommand: {command}\nFull: {full_code}", language="text" +) # =========================================================================== # MAIN CONTENT @@ -136,15 +146,18 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # --------------------------------------------------------------------------- # TAB 1: What the remote actually transmits # --------------------------------------------------------------------------- -tab1, tab2, tab3, tab4 = st.tabs([ - "1️⃣ What the Remote Sends", - "2️⃣ What You See in Audacity", - "3️⃣ How to Read Bits", - "4️⃣ Full Packet Explorer", -]) +tab1, tab2, tab3, tab4 = st.tabs( + [ + "1️⃣ What the Remote Sends", + "2️⃣ What You See in Audacity", + "3️⃣ How to Read Bits", + "4️⃣ Full Packet Explorer", + ] +) with tab1: - st.markdown(""" + st.markdown( + """ ### The ideal OOK signal — what the remote's TX chip actually outputs **On-Off Keying** is the simplest possible digital radio modulation. The transmitter @@ -158,7 +171,8 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ - **Long gap → bit 1** (currently {one_gap} µs) Play with the sliders in the sidebar to see how changing the timing affects the waveform. - """.format(zero_gap=zero_gap_us, one_gap=one_gap_us)) + """.format(zero_gap=zero_gap_us, one_gap=one_gap_us) + ) # Show first 8 bits with sync first_8 = full_code[:8] @@ -169,19 +183,36 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ fig1, ax1 = plt.subplots(figsize=(14, 3), facecolor=DARK_BG) ax1.fill_between(t, lvl, step="post", alpha=0.35, color=CYAN) ax1.step(t, lvl, where="post", color=CYAN, linewidth=2) - style_ax(ax1, title=f"Sync + first 8 data bits [{first_8}]", - ylabel_left="Carrier ON", ylabel_right="Carrier OFF") + style_ax( + ax1, + title=f"Sync + first 8 data bits [{first_8}]", + ylabel_left="Carrier ON", + ylabel_right="Carrier OFF", + ) # Annotate sync if sync_us > 0: - ax1.annotate("← 8 ms sync pulse (carrier on, no data) →", - xy=(sync_us / 2, 1.15), color=ORANGE, fontsize=9, - ha="center", va="bottom") + ax1.annotate( + "← 8 ms sync pulse (carrier on, no data) →", + xy=(sync_us / 2, 1.15), + color=ORANGE, + fontsize=9, + ha="center", + va="bottom", + ) # Annotate each bit for i, (c, b) in enumerate(zip(centers, first_8)): - ax1.text(c, 1.25, b, ha="center", va="bottom", color=YELLOW, - fontsize=11, fontweight="bold") + ax1.text( + c, + 1.25, + b, + ha="center", + va="bottom", + color=YELLOW, + fontsize=11, + fontweight="bold", + ) ax1.set_xlabel("Time (µs)", color=MUTED) st.pyplot(fig1, width="stretch") @@ -214,7 +245,9 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Build a short example: 3 bits [0, 1, 1] demo_bits = "011" - t_real, lvl_real, _, _ = build_ook_waveform(demo_bits, pulse_us, zero_gap_us, one_gap_us) + t_real, lvl_real, _, _ = build_ook_waveform( + demo_bits, pulse_us, zero_gap_us, one_gap_us + ) # Simulated FM demod: invert + add noise during "gaps" np.random.seed(42) @@ -226,14 +259,19 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ noise = np.random.normal(0, 0.35, len(t_cont)) fm_demod = np.where(ideal > 0.5, 0.0, noise) - fig2, (ax_real, ax_fm) = plt.subplots(2, 1, figsize=(14, 5), facecolor=DARK_BG, - gridspec_kw={"hspace": 0.5}) + fig2, (ax_real, ax_fm) = plt.subplots( + 2, 1, figsize=(14, 5), facecolor=DARK_BG, gridspec_kw={"hspace": 0.5} + ) # Top: real signal ax_real.fill_between(t_real, lvl_real, step="post", alpha=0.35, color=CYAN) ax_real.step(t_real, lvl_real, where="post", color=CYAN, linewidth=2) - style_ax(ax_real, title='What the remote transmits (bits "0, 1, 1")', - ylabel_left="Carrier ON", ylabel_right="Carrier OFF") + style_ax( + ax_real, + title='What the remote transmits (bits "0, 1, 1")', + ylabel_left="Carrier ON", + ylabel_right="Carrier OFF", + ) # Annotate bits bit_labels_x = [] pos = 0 @@ -243,13 +281,26 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ bit_labels_x.append((center, b)) pos += pulse_us + gap for cx, bl in bit_labels_x: - ax_real.text(cx, 1.25, f"bit {bl}", ha="center", color=YELLOW, fontsize=10, fontweight="bold") + ax_real.text( + cx, + 1.25, + f"bit {bl}", + ha="center", + color=YELLOW, + fontsize=10, + fontweight="bold", + ) # Bottom: FM demod ax_fm.plot(t_cont, fm_demod, color=ORANGE, linewidth=0.6, alpha=0.9) ax_fm.set_facecolor(PANEL_BG) - ax_fm.set_title('What you see in Audacity (rtl_fm output)', color=WHITE, - fontsize=13, fontweight="bold", pad=12) + ax_fm.set_title( + "What you see in Audacity (rtl_fm output)", + color=WHITE, + fontsize=13, + fontweight="bold", + pad=12, + ) ax_fm.set_xlabel("Time (µs)", color=MUTED, fontsize=10) ax_fm.set_ylabel("Amplitude", color=MUTED, fontsize=10) ax_fm.tick_params(colors=MUTED, labelsize=9) @@ -263,14 +314,31 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ pos = 0 for b in demo_bits: ax_fm.axvspan(pos, pos + pulse_us, alpha=0.15, color=CYAN) - ax_fm.text(pos + pulse_us / 2, 1.0, "quiet\n(carrier ON)", ha="center", - va="bottom", color=CYAN, fontsize=7) + ax_fm.text( + pos + pulse_us / 2, + 1.0, + "quiet\n(carrier ON)", + ha="center", + va="bottom", + color=CYAN, + fontsize=7, + ) pos += pulse_us gap = one_gap_us if b == "1" else zero_gap_us - ax_fm.annotate("", xy=(pos, -0.9), xytext=(pos + gap, -0.9), - arrowprops=dict(arrowstyle="<->", color=YELLOW, lw=1.5)) - ax_fm.text(pos + gap / 2, -1.05, f"{'long' if b == '1' else 'short'} noise burst = bit {b}", - ha="center", color=YELLOW, fontsize=8) + ax_fm.annotate( + "", + xy=(pos, -0.9), + xytext=(pos + gap, -0.9), + arrowprops=dict(arrowstyle="<->", color=YELLOW, lw=1.5), + ) + ax_fm.text( + pos + gap / 2, + -1.05, + f"{'long' if b == '1' else 'short'} noise burst = bit {b}", + ha="center", + color=YELLOW, + fontsize=8, + ) pos += gap st.pyplot(fig2, width="stretch") @@ -324,8 +392,12 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ fig3, ax3 = plt.subplots(figsize=(14, 4), facecolor=DARK_BG) ax3.fill_between(t_demo, lvl_demo, step="post", alpha=0.3, color=CYAN) ax3.step(t_demo, lvl_demo, where="post", color=CYAN, linewidth=2) - style_ax(ax3, title=f"Reading bits from the waveform: [{demo_pattern}]", - ylabel_left="Carrier ON", ylabel_right="Carrier OFF") + style_ax( + ax3, + title=f"Reading bits from the waveform: [{demo_pattern}]", + ylabel_left="Carrier ON", + ylabel_right="Carrier OFF", + ) # Color-code the gaps pos = 0 @@ -337,17 +409,43 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Gap region — colored by bit value color = RED if b == "1" else YELLOW ax3.axvspan(pos, pos + gap, alpha=0.15, color=color) - ax3.text(pos + gap / 2, -0.08, f"{'LONG' if b == '1' else 'short'}", - ha="center", va="top", color=color, fontsize=8, fontweight="bold") - ax3.text(pos + gap / 2, 1.30, b, ha="center", va="bottom", - color=color, fontsize=14, fontweight="bold") + ax3.text( + pos + gap / 2, + -0.08, + f"{'LONG' if b == '1' else 'short'}", + ha="center", + va="top", + color=color, + fontsize=8, + fontweight="bold", + ) + ax3.text( + pos + gap / 2, + 1.30, + b, + ha="center", + va="bottom", + color=color, + fontsize=14, + fontweight="bold", + ) pos += gap # Legend - short_patch = mpatches.Patch(color=YELLOW, alpha=0.4, label=f"Short gap ({zero_gap_us} µs) = 0") - long_patch = mpatches.Patch(color=RED, alpha=0.4, label=f"Long gap ({one_gap_us} µs) = 1") - ax3.legend(handles=[short_patch, long_patch], loc="upper right", - facecolor=PANEL_BG, edgecolor=MUTED, labelcolor=WHITE, fontsize=9) + short_patch = mpatches.Patch( + color=YELLOW, alpha=0.4, label=f"Short gap ({zero_gap_us} µs) = 0" + ) + long_patch = mpatches.Patch( + color=RED, alpha=0.4, label=f"Long gap ({one_gap_us} µs) = 1" + ) + ax3.legend( + handles=[short_patch, long_patch], + loc="upper right", + facecolor=PANEL_BG, + edgecolor=MUTED, + labelcolor=WHITE, + fontsize=9, + ) st.pyplot(fig3, width="stretch") plt.close(fig3) @@ -386,7 +484,10 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ show_sync = st.checkbox("Show sync pulse", value=False) t_full, lvl_full, centers_full, bounds_full = build_ook_waveform( - full_code, pulse_us, zero_gap_us, one_gap_us, + full_code, + pulse_us, + zero_gap_us, + one_gap_us, sync_us=sync_us if show_sync else 0, sync_gap_us=sync_gap_us if show_sync else 0, ) @@ -405,10 +506,24 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Labels addr_mid = (offset + addr_end) / 2 cmd_mid = (addr_end + bounds_full[-1]) / 2 - ax4.text(addr_mid, 1.35, "ADDRESS (bits 0–15)", ha="center", color=ORANGE, - fontsize=10, fontweight="bold") - ax4.text(cmd_mid, 1.35, "COMMAND (bits 16–31)", ha="center", color=CYAN, - fontsize=10, fontweight="bold") + ax4.text( + addr_mid, + 1.35, + "ADDRESS (bits 0–15)", + ha="center", + color=ORANGE, + fontsize=10, + fontweight="bold", + ) + ax4.text( + cmd_mid, + 1.35, + "COMMAND (bits 16–31)", + ha="center", + color=CYAN, + fontsize=10, + fontweight="bold", + ) # Bit labels for i, (c, b) in enumerate(zip(centers_full, full_code)): @@ -421,12 +536,17 @@ def build_ook_waveform(bits, pulse_us, zero_gap_us, one_gap_us, sync_us=0, sync_ # Show all commands as a comparison st.markdown("---") st.markdown("### All commands compared") - st.markdown("Notice how the left half (address) stays the same and only the right half (command) changes:") + st.markdown( + "Notice how the left half (address) stays the same and only the right half (command) changes:" + ) - fig5, axes = plt.subplots(len(dev["commands"]), 1, - figsize=(16, 2.2 * len(dev["commands"])), - facecolor=DARK_BG, - gridspec_kw={"hspace": 0.6}) + fig5, axes = plt.subplots( + len(dev["commands"]), + 1, + figsize=(16, 2.2 * len(dev["commands"])), + facecolor=DARK_BG, + gridspec_kw={"hspace": 0.6}, + ) for idx, (cname, cbits) in enumerate(dev["commands"].items()): ax = axes[idx]