#!/usr/bin/env python3 """Cycle fan speeds on the dryer for wiring / PWM verification. Uses the firmware `fan test ` command (heater stays off). Sends `target 0` first so the thermal loop is idle. Example: ./fan_test.py ./fan_test.py --pct 30 50 100 --interval 3 --loop """ from __future__ import annotations import argparse import re import sys import time from capture_csv import decode_line, open_serial, resolve_port OK_RE = re.compile(r"^OK ") ERR_RE = re.compile(r"^ERR ") FAN_STATUS_RE = re.compile(r"fan=(\d+)/255\((\d+)%\)") def pct_to_pwm(pct: int) -> int: if pct < 0 or pct > 100: raise ValueError(f"fan percent must be 0-100, got {pct}") return round(pct * 255 / 100) def send_command(ser, command: str, timeout: float = 2.0) -> list[str]: ser.write((command.strip() + "\n").encode("utf-8")) ser.flush() lines: list[str] = [] deadline = time.monotonic() + timeout while time.monotonic() < deadline: raw = ser.readline() if not raw: continue line = decode_line(raw) if not line: continue lines.append(line) if OK_RE.match(line) or ERR_RE.match(line): break return lines def drain_status(ser, duration: float) -> str | None: """Read serial for `duration` seconds; return last status fan field if seen.""" fan_field: str | None = None deadline = time.monotonic() + duration while time.monotonic() < deadline: raw = ser.readline() if not raw: continue line = decode_line(raw) if not line or line.startswith("csv"): continue match = FAN_STATUS_RE.search(line) if match: fan_field = f"{match.group(1)}/255 ({match.group(2)}%)" elif line.startswith("target="): print(f" status: {line}", flush=True) return fan_field def main() -> int: parser = argparse.ArgumentParser(description="Cycle fan PWM to verify fan control") parser.add_argument( "-p", "--port", help="Serial port (default: auto-detect)", ) parser.add_argument("-b", "--baud", type=int, default=115200) parser.add_argument( "--pct", type=int, nargs="+", default=[0, 30, 100, 200, 255], metavar="PCT", help="Fan speeds in percent (default: 0 30 100 200 255)", ) parser.add_argument( "--interval", type=float, default=5.0, metavar="SEC", help="Seconds to hold each step (default: 5)", ) parser.add_argument( "--loop", action="store_true", help="Repeat the sequence until Ctrl+C", ) args = parser.parse_args() port = resolve_port(args.port) sequence = [(pct, pct_to_pwm(pct)) for pct in args.pct] print(f"Port: {port}", file=sys.stderr) print( f"Sequence: {' -> '.join(str(p) + '%' for p, _ in sequence)} " f"every {args.interval:g}s (heater off)", file=sys.stderr, ) print("Ctrl+C to stop\n", file=sys.stderr) interrupted = False with open_serial(port, args.baud) as ser: ser.reset_input_buffer() lines = send_command(ser, "target 0") for line in lines: print(line, flush=True) if any(ERR_RE.match(line) for line in lines): return 1 try: while True: for pct, pwm in sequence: print(f">>> fan test {pwm} ({pct}%)", flush=True) lines = send_command(ser, f"fan test {pwm}") for line in lines: print(f" {line}", flush=True) if any(ERR_RE.match(line) for line in lines): return 1 reported = drain_status(ser, args.interval) if reported: print(f" reported fan={reported}", flush=True) if not args.loop: break except KeyboardInterrupt: interrupted = True print("\nInterrupted", file=sys.stderr) finally: print(">>> fan test 0 (stop)", flush=True) send_command(ser, "fan test 0") return 130 if interrupted else 0 if __name__ == "__main__": raise SystemExit(main())