safety commit
This commit is contained in:
@@ -43,7 +43,7 @@ STATUS_RE = re.compile(
|
||||
r"htop=(?P<htop>\S+)\s+"
|
||||
r"hblk=(?P<hblk>\S+)\s+"
|
||||
r"ssr=(?P<ssr>on|off)\s+"
|
||||
r"fan=(?P<fan>\S+)\s+"
|
||||
r"fan=(?P<fan>\d+/255\(\d+%\)(?:\([^)]+\))?(?:\s+TEST)?)\s+"
|
||||
r"cutoff=(?P<cutoff_active>\S+)\s+"
|
||||
r"failsafe=(?P<failsafe>\S+)\s+"
|
||||
r"mode=(?P<mode>.+?)\s+sensors=\[(?P<sensors>.*)\]"
|
||||
@@ -55,6 +55,10 @@ AUTOTUNE_MODE_RE = re.compile(
|
||||
r"autotune/(?P<phase>[\w-]+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C"
|
||||
)
|
||||
|
||||
STEPRESP_MODE_RE = re.compile(
|
||||
r"stepresp/(?P<phase>[\w-]+) (?P<elapsed>\d+)s step (?P<step>\d+/\d+) heat=(?P<heat>\d+)%"
|
||||
)
|
||||
|
||||
|
||||
def format_mode_line(mode: str) -> str:
|
||||
match = AUTOTUNE_MODE_RE.match(mode)
|
||||
@@ -64,6 +68,13 @@ def format_mode_line(mode: str) -> str:
|
||||
f"Autotune {d['phase']}: {d['elapsed']}s elapsed, "
|
||||
f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
|
||||
)
|
||||
match = STEPRESP_MODE_RE.match(mode)
|
||||
if match:
|
||||
d = match.groupdict()
|
||||
return (
|
||||
f"Step response {d['phase']}: {d['elapsed']}s, "
|
||||
f"step {d['step']}, heater {d['heat']}%"
|
||||
)
|
||||
return f"Mode: {mode}"
|
||||
|
||||
|
||||
@@ -141,7 +152,7 @@ def apply_status(state: DryerState, data: dict) -> None:
|
||||
fan_raw = data["fan"]
|
||||
state.fan = fan_raw
|
||||
state.fan_note = ""
|
||||
if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or " TEST" in fan_raw:
|
||||
if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or fan_raw.endswith("(cmd-off)") or " TEST" in fan_raw:
|
||||
state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
|
||||
state.cutoff_active = data["cutoff_active"]
|
||||
state.failsafe = data["failsafe"]
|
||||
@@ -409,7 +420,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
stdscr,
|
||||
help_y,
|
||||
1,
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | : cmd | q quit",
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | r stepresp | : cmd | q quit",
|
||||
curses.A_DIM,
|
||||
)
|
||||
stdscr.refresh()
|
||||
@@ -491,6 +502,21 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
if value is not None:
|
||||
cmd = "autotune" if value == "" else f"autotune {value}"
|
||||
worker.send(cmd)
|
||||
elif key == ord("r"):
|
||||
temp = _prompt(stdscr, "Stepresp temp °C (Enter = 45)")
|
||||
if temp is None:
|
||||
continue
|
||||
heater = _prompt(stdscr, "Heater % (Enter = 35)")
|
||||
if heater is None:
|
||||
continue
|
||||
if temp == "" and heater == "":
|
||||
worker.send("stepresp")
|
||||
elif heater == "":
|
||||
worker.send(f"stepresp {temp}")
|
||||
elif temp == "":
|
||||
worker.send(f"stepresp 45 {heater}")
|
||||
else:
|
||||
worker.send(f"stepresp {temp} {heater}")
|
||||
elif key == ord(":"):
|
||||
value = _prompt(stdscr, "Command")
|
||||
if value is not None and value != "":
|
||||
|
||||
147
scripts/fan_test.py
Normal file
147
scripts/fan_test.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cycle fan speeds on the dryer for wiring / PWM verification.
|
||||
|
||||
Uses the firmware `fan test <pwm>` 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())
|
||||
169
scripts/step_response.py
Normal file
169
scripts/step_response.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run fan step-response test and capture sr,... serial log lines to CSV.
|
||||
|
||||
The firmware holds heater duty fixed, steps fan PWM, and logs temperature
|
||||
every second. Use the output to see how chamber temp responds to fan changes.
|
||||
|
||||
Example:
|
||||
./step_response.py
|
||||
./step_response.py --temp 45 --heater 35 -o logs/stepresp.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from capture_csv import decode_line, open_serial, resolve_port
|
||||
|
||||
SR_RE = re.compile(
|
||||
r"^sr,(?P<ms>\d+),(?P<phase>\w+),(?P<step>\d+/\d+),"
|
||||
r"(?P<heater>\d+),(?P<fan>\d+),"
|
||||
r"(?P<avg>[\d.]+),(?P<min>[\d.]+),(?P<max>[\d.]+),(?P<spread>[\d.]+)"
|
||||
r"(?:,(?P<temps>.*))?$"
|
||||
)
|
||||
DONE_RE = re.compile(r"^stepresp: done")
|
||||
FAIL_RE = re.compile(r"^stepresp: abort")
|
||||
|
||||
HEADER = (
|
||||
"wall_time,ms,phase,step,heater_pct,fan_pwm,fan_pct,avg_c,min_c,max_c,spread_c,"
|
||||
"ch2_t,ch3_t,ch4_t,ch5_t"
|
||||
)
|
||||
|
||||
|
||||
def fan_pct(pwm: int) -> int:
|
||||
return (pwm * 100) // 255
|
||||
|
||||
|
||||
def send_command(ser, command: str, timeout: float = 3.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 line.startswith("OK ") or line.startswith("ERR "):
|
||||
break
|
||||
return lines
|
||||
|
||||
|
||||
def parse_sr_line(line: str) -> dict | None:
|
||||
match = SR_RE.match(line)
|
||||
if not match:
|
||||
return None
|
||||
data = match.groupdict()
|
||||
temps = data.pop("temps") or ""
|
||||
channels = (temps.split(",") + ["", "", "", ""])[:4]
|
||||
data["ch2_t"], data["ch3_t"], data["ch4_t"], data["ch5_t"] = channels
|
||||
data["fan_pwm"] = data.pop("fan")
|
||||
data["heater_pct"] = data.pop("heater")
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Capture fan step-response data")
|
||||
parser.add_argument("-p", "--port", help="Serial port (default: auto-detect)")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200)
|
||||
parser.add_argument("--temp", type=float, default=45.0, help="Target temperature (C)")
|
||||
parser.add_argument("--heater", type=float, default=35.0, help="Fixed heater duty (%%)")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Output CSV (default: logs/stepresp_YYYYMMDD_HHMMSS.csv)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
port = resolve_port(args.port)
|
||||
out = args.output
|
||||
if out is None:
|
||||
out = Path("logs") / f"stepresp_{datetime.now():%Y%m%d_%H%M%S}.csv"
|
||||
|
||||
print(f"Port: {port}", file=sys.stderr)
|
||||
print(f"Output: {out}", file=sys.stderr)
|
||||
print(f"Command: stepresp {args.temp:g} {args.heater:g}", file=sys.stderr)
|
||||
print("Ctrl+C to stop\n", file=sys.stderr)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
row_count = 0
|
||||
|
||||
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh:
|
||||
fh.write(HEADER + "\n")
|
||||
ser.reset_input_buffer()
|
||||
|
||||
lines = send_command(ser, f"stepresp {args.temp:g} {args.heater:g}")
|
||||
for line in lines:
|
||||
print(line, flush=True)
|
||||
if any(line.startswith("ERR ") for line in lines):
|
||||
return 1
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = ser.readline()
|
||||
if not raw:
|
||||
continue
|
||||
line = decode_line(raw)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("sr,"):
|
||||
data = parse_sr_line(line)
|
||||
if data is None:
|
||||
print(f"WARN: bad sr line: {line}", file=sys.stderr)
|
||||
continue
|
||||
pwm = int(data["fan_pwm"])
|
||||
wall = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
row = [
|
||||
wall,
|
||||
data["ms"],
|
||||
data["phase"],
|
||||
data["step"],
|
||||
data["heater_pct"],
|
||||
str(pwm),
|
||||
str(fan_pct(pwm)),
|
||||
data["avg"],
|
||||
data["min"],
|
||||
data["max"],
|
||||
data["spread"],
|
||||
data["ch2_t"],
|
||||
data["ch3_t"],
|
||||
data["ch4_t"],
|
||||
data["ch5_t"],
|
||||
]
|
||||
fh.write(",".join(row) + "\n")
|
||||
fh.flush()
|
||||
row_count += 1
|
||||
if row_count % 30 == 0:
|
||||
print(
|
||||
f" {data['phase']} step {data['step']} "
|
||||
f"fan={pwm} avg={data['avg']}C spread={data['spread']}C",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if DONE_RE.match(line) or FAIL_RE.match(line):
|
||||
print(line, flush=True)
|
||||
break
|
||||
|
||||
if line.startswith("stepresp:"):
|
||||
print(line, flush=True)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping…", file=sys.stderr)
|
||||
send_command(ser, "stepresp stop")
|
||||
|
||||
print(f"Wrote {row_count} rows to {out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user