safety commit

This commit is contained in:
2026-07-06 20:16:51 +02:00
parent 02e51717e8
commit 28ae97fa45
8 changed files with 806 additions and 34 deletions

View File

@@ -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
View 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
View 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())