This commit is contained in:
2026-08-07 17:36:15 +02:00
parent d1385baa45
commit 31a6764484
9 changed files with 75219 additions and 68 deletions

View File

@@ -99,7 +99,7 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok`
autotune 50 autotune 50
``` ```
Emergency cutoff is fixed at **70°C** — you can autotune at 5055°C with ABS in the chamber while hot corners stay below that. Cutoff follows target (`target + 12°C`, max 95°C) — e.g. ABS at 55°C trips at 67°C corner, nylon at 80°C at 92°C.
5. Dry at your target — fan runs at stir PWM (default **178**) whenever target > 0, including heat-up. Override with `fan <pwm>`; `fan auto` returns to default. Fan auto-off below 40°C applies only in idle (`target 0`). 5. Dry at your target — fan runs at stir PWM (default **178**) whenever target > 0, including heat-up. Override with `fan <pwm>`; `fan auto` returns to default. Fan auto-off below 40°C applies only in idle (`target 0`).

View File

@@ -12,6 +12,10 @@ static const uint8_t SENSOR_COUNT = sizeof(SENSOR_CHANNELS) / sizeof(SENSOR_CHAN
// SHT31 I2C address (ADDR pin low → 0x44, high → 0x45) // SHT31 I2C address (ADDR pin low → 0x44, high → 0x45)
static const uint8_t SHT31_ADDRESS = 0x44; static const uint8_t SHT31_ADDRESS = 0x44;
// Bus timeout (Wire.setWireTimeout) — bounds a stuck I2C transaction so a
// glitch resets the TWI hardware instead of hanging the whole sketch.
static const uint32_t I2C_TIMEOUT_US = 25000UL;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly) // Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -25,9 +29,22 @@ static const float TARGET_TEMP_C = 0.0f;
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f;
static const float TARGET_MIN_C = 0.0f; static const float TARGET_MIN_C = 0.0f;
static const float TARGET_MAX_C = 80.0f; static const float TARGET_MAX_C = 80.0f;
// Absolute max corner — heater off + full fan (decoupled from PID target). // Hard ceiling (sensor / enclosure limit). Cutoff when regulating = target + CUTOFF_ABOVE_TARGET_C.
static const float EMERGENCY_MAX_TEMP_C = 70.0f; static const float EMERGENCY_ABSOLUTE_MAX_C = 95.0f;
static const float CORNER_STOP_MARGIN_C = 3.0f; static const float CUTOFF_ABOVE_TARGET_C = 12.0f;
static const float CUTOFF_RECOVERY_BAND_C = 5.0f;
static const float CORNER_STOP_MARGIN_C = 5.0f;
inline float emergencyCutoffForTarget(float targetC) {
if (targetC <= 0.0f) {
return EMERGENCY_ABSOLUTE_MAX_C;
}
float cutoff = targetC + CUTOFF_ABOVE_TARGET_C;
if (cutoff > EMERGENCY_ABSOLUTE_MAX_C) {
cutoff = EMERGENCY_ABSOLUTE_MAX_C;
}
return cutoff;
}
// Heat PI on average temp (no D term) // Heat PI on average temp (no D term)
static const float HEAT_PI_KP = 4.0f; static const float HEAT_PI_KP = 4.0f;
@@ -42,15 +59,8 @@ static const float PID_KI = HEAT_PI_KI;
static const float PID_KD = 0.0f; static const float PID_KD = 0.0f;
static const float GOOD_SPREAD_C = 5.0f; static const float GOOD_SPREAD_C = 5.0f;
// Tiered heater cap during heat-up (100% until near target) // Corner taper when avg is near target — keeps hottest sensor below emergency
static const float HEATER_MAX_DUTY_COLD = 100.0f;
static const float HEATER_MAX_DUTY_MID = 75.0f;
static const float HEATER_MAX_DUTY_NEAR = 45.0f;
static const float HEATER_COLD_BELOW_C = 10.0f;
static const float HEATER_WARM_BELOW_C = 3.0f;
// Corner taper only within this band below target (was 10°C — blocked heat-up in uneven chambers)
static const float CORNER_LIMIT_BAND_C = 2.0f; static const float CORNER_LIMIT_BAND_C = 2.0f;
static const float HEATER_SLEW_UP_PER_S = 18.0f;
static const float MAX_TEMP_HEADROOM_C = 15.0f; static const float MAX_TEMP_HEADROOM_C = 15.0f;
static const uint16_t HEATER_CYCLE_MS = 3000; static const uint16_t HEATER_CYCLE_MS = 3000;

View File

@@ -106,7 +106,7 @@ public:
return phase_; return phase_;
} }
if (maxTempC >= EMERGENCY_MAX_TEMP_C) { if (maxTempC >= emergencyCutoffForTarget(setpointC_)) {
fail(F("autotune: abort — max sensor at emergency limit")); fail(F("autotune: abort — max sensor at emergency limit"));
return phase_; return phase_;
} }

View File

@@ -299,11 +299,13 @@ public:
bool isIdle() const { return targetTempC_ <= 0.0f; } bool isIdle() const { return targetTempC_ <= 0.0f; }
float emergencyCutoffC() const { return emergencyCutoffForTarget(targetTempC_); }
float cutoffThreshold() const { float cutoffThreshold() const {
if (isIdle()) { if (isIdle()) {
return INFINITY; return INFINITY;
} }
return EMERGENCY_MAX_TEMP_C; return emergencyCutoffC();
} }
bool isCutoffActive() const { return cutoffActive_; } bool isCutoffActive() const { return cutoffActive_; }
@@ -470,7 +472,8 @@ private:
} }
void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) { void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
if (maxTempC >= EMERGENCY_MAX_TEMP_C) { const float cutoffC = emergencyCutoffC();
if (maxTempC >= cutoffC) {
cutoffActive_ = true; cutoffActive_ = true;
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
@@ -480,7 +483,7 @@ private:
return; return;
} }
if (cutoffActive_ && maxTempC < EMERGENCY_MAX_TEMP_C - 5.0f) { if (cutoffActive_ && maxTempC < cutoffC - CUTOFF_RECOVERY_BAND_C) {
cutoffActive_ = false; cutoffActive_ = false;
heatPi_.reset(); heatPi_.reset();
} }
@@ -494,24 +497,14 @@ private:
heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC); heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
float duty = heatPi_.compute(avgTempC, nowMs); float duty = heatPi_.compute(avgTempC, nowMs);
const float below = targetTempC_ - avgTempC; duty = clampPercent(duty);
if (below > 8.0f) {
const float floor = below > 15.0f ? 75.0f : 60.0f;
if (duty < floor) {
duty = floor;
}
}
const float maxDuty = heaterMaxDuty(avgTempC);
if (duty > maxDuty) {
duty = maxDuty;
}
if (duty > heaterAllowancePercent_) { if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_; duty = heaterAllowancePercent_;
if (heaterAllowancePercent_ < 100.0f) { if (heaterAllowancePercent_ < 100.0f) {
heaterBlock_ = HeaterBlock::Corner; heaterBlock_ = HeaterBlock::Corner;
} }
} }
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); heaterDutyPercent_ = duty;
regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_; regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
} }
@@ -532,16 +525,17 @@ private:
float maxHeatStopTemp(float avgTempC) const { float maxHeatStopTemp(float avgTempC) const {
if (!shouldLimitMaxCorner(avgTempC)) { if (!shouldLimitMaxCorner(avgTempC)) {
return EMERGENCY_MAX_TEMP_C; return emergencyCutoffC();
} }
return EMERGENCY_MAX_TEMP_C - CORNER_STOP_MARGIN_C; return emergencyCutoffC() - CORNER_STOP_MARGIN_C;
} }
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
const float cutoffC = emergencyCutoffC();
if (!shouldLimitMaxCorner(avgTempC)) { if (!shouldLimitMaxCorner(avgTempC)) {
// Heat-up: only taper when a hot corner nears the emergency ceiling // Heat-up: only taper when a hot corner nears the dynamic cutoff
if (maxTempC >= EMERGENCY_MAX_TEMP_C - 3.0f) { if (maxTempC >= cutoffC - 3.0f) {
const float headroom = EMERGENCY_MAX_TEMP_C - maxTempC; const float headroom = cutoffC - maxTempC;
return clampPercent((headroom / 3.0f) * 100.0f); return clampPercent((headroom / 3.0f) * 100.0f);
} }
return 100.0f; return 100.0f;
@@ -560,38 +554,6 @@ private:
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f); return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
} }
float heaterMaxDuty(float avgTempC) const {
if (avgTempC >= targetTempC_) {
return HEATER_MAX_DUTY_NEAR;
}
const float below = targetTempC_ - avgTempC;
if (below >= HEATER_COLD_BELOW_C) {
return HEATER_MAX_DUTY_COLD;
}
if (below >= HEATER_WARM_BELOW_C) {
return HEATER_MAX_DUTY_MID;
}
return HEATER_MAX_DUTY_NEAR;
}
float applyHeaterRamp(float requestedDuty, float avgTempC, uint32_t nowMs) {
const float maxDuty = heaterMaxDuty(avgTempC);
if (requestedDuty > maxDuty) {
requestedDuty = maxDuty;
}
if (lastHeaterUpdateMs_ > 0 && requestedDuty > heaterDutyPercent_) {
const float dt = static_cast<float>(nowMs - lastHeaterUpdateMs_) / 1000.0f;
const float maxUp = heaterDutyPercent_ + HEATER_SLEW_UP_PER_S * dt;
if (requestedDuty > maxUp) {
requestedDuty = maxUp;
}
}
return requestedDuty;
}
void applyHeaterBurst(uint32_t nowMs) { void applyHeaterBurst(uint32_t nowMs) {
if (heaterDutyPercent_ <= 0.0f) { if (heaterDutyPercent_ <= 0.0f) {
forceHeaterOff(); forceHeaterOff();

75127
live/dryer_20260801_181152.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
wall_time,ms,target_c,avg_c,min_c,max_c,spread_c,heatlim_pct,heater_pct,fan_pct,cutoff,failsafe,ch2_t,ch2_h,ch3_t,ch3_h,ch4_t,ch4_h,ch5_t,ch5_h
2026-08-06T17:01:04+00:00,1786035664603,75.0,35.97,31.92,41.43,8.59,100,100.0,69,0,0,41.4,18,38.4,21,32.1,28,31.9,28
2026-08-06T17:01:06+00:00,1786035666605,75.0,37.64,32.08,41.97,9.72,100,100.0,69,0,0,42.0,18,38.9,21,,,32.1,28
2026-08-06T17:01:08+00:00,1786035668605,75.0,36.70,32.37,42.46,10.05,100,100.0,69,0,0,42.5,18,39.3,21,32.7,27,32.4,27
2026-08-06T17:01:10+00:00,1786035670605,75.0,37.02,32.50,43.01,10.29,100,100.0,69,0,0,43.0,17,39.7,20,32.8,27,32.5,27
2026-08-06T17:01:12+00:00,1786035672605,75.0,41.96,40.24,43.68,6.58,100,100.0,69,0,0,43.7,17,40.2,20,,,,
2026-08-06T17:01:14+00:00,1786035674607,75.0,37.74,32.97,44.18,10.56,100,100.0,69,0,0,44.2,17,40.6,20,33.2,27,33.0,27
1 wall_time ms target_c avg_c min_c max_c spread_c heatlim_pct heater_pct fan_pct cutoff failsafe ch2_t ch2_h ch3_t ch3_h ch4_t ch4_h ch5_t ch5_h
2 2026-08-06T17:01:04+00:00 1786035664603 75.0 35.97 31.92 41.43 8.59 100 100.0 69 0 0 41.4 18 38.4 21 32.1 28 31.9 28
3 2026-08-06T17:01:06+00:00 1786035666605 75.0 37.64 32.08 41.97 9.72 100 100.0 69 0 0 42.0 18 38.9 21 32.1 28
4 2026-08-06T17:01:08+00:00 1786035668605 75.0 36.70 32.37 42.46 10.05 100 100.0 69 0 0 42.5 18 39.3 21 32.7 27 32.4 27
5 2026-08-06T17:01:10+00:00 1786035670605 75.0 37.02 32.50 43.01 10.29 100 100.0 69 0 0 43.0 17 39.7 20 32.8 27 32.5 27
6 2026-08-06T17:01:12+00:00 1786035672605 75.0 41.96 40.24 43.68 6.58 100 100.0 69 0 0 43.7 17 40.2 20
7 2026-08-06T17:01:14+00:00 1786035674607 75.0 37.74 32.97 44.18 10.56 100 100.0 69 0 0 44.2 17 40.6 20 33.2 27 33.0 27

View File

@@ -22,6 +22,10 @@ FALLBACK_HEADER = (
SENSOR_CHANNELS = [2, 3, 4, 5] SENSOR_CHANNELS = [2, 3, 4, 5]
# If no bytes at all arrive for this long, treat the device as hung (e.g. an
# I2C bus lockup freezing the Arduino) rather than looping forever in silence.
DEFAULT_STALL_TIMEOUT_S = 20.0
_FAN_PCT_RE = re.compile(r"\((\d+)%\)") _FAN_PCT_RE = re.compile(r"\((\d+)%\)")
_FAN_PWM_RE = re.compile(r"^(\d+)/") _FAN_PWM_RE = re.compile(r"^(\d+)/")
@@ -206,6 +210,11 @@ def enable_dryer_logging(ser, retries: int = 3) -> None:
print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr) print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr)
def log_notice(message: str) -> None:
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(f"{stamp} {message}", file=sys.stderr)
def cmd_log(args: argparse.Namespace) -> int: def cmd_log(args: argparse.Namespace) -> int:
from dryer_tui import parse_status from dryer_tui import parse_status
@@ -214,6 +223,7 @@ def cmd_log(args: argparse.Namespace) -> int:
if out is None: if out is None:
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
stall_timeout = args.stall_timeout
print(f"Logging {port} -> {out}", file=sys.stderr) print(f"Logging {port} -> {out}", file=sys.stderr)
if args.auto_log_on: if args.auto_log_on:
print("Will send 'log on' after connect", file=sys.stderr) print("Will send 'log on' after connect", file=sys.stderr)
@@ -223,6 +233,7 @@ def cmd_log(args: argparse.Namespace) -> int:
if args.auto_log_on: if args.auto_log_on:
enable_dryer_logging(ser) enable_dryer_logging(ser)
last_activity = time.monotonic()
while True: while True:
try: try:
raw = ser.readline() raw = ser.readline()
@@ -230,10 +241,26 @@ def cmd_log(args: argparse.Namespace) -> int:
print(f"\nStopped ({session.row_count} rows).", file=sys.stderr) print(f"\nStopped ({session.row_count} rows).", file=sys.stderr)
session.close() session.close()
return 0 return 0
except Exception as exc:
log_notice(
f"ERROR: serial read failed ({exc}) — closing after "
f"{session.row_count} rows"
)
session.close()
return 1
if not raw: if not raw:
if time.monotonic() - last_activity >= stall_timeout:
log_notice(
f"WARN: no data from {port} for {stall_timeout:.0f}s — "
f"device likely hung (e.g. I2C bus lockup on the Arduino) "
f"— closing after {session.row_count} rows"
)
session.close()
return 1
continue continue
last_activity = time.monotonic()
line = decode_line(raw) line = decode_line(raw)
parsed = parse_status(line) parsed = parse_status(line)
if parsed: if parsed:
@@ -277,6 +304,15 @@ def build_parser() -> argparse.ArgumentParser:
default=True, default=True,
help="Send 'log on' after connect in log mode (default: on)", help="Send 'log on' after connect in log mode (default: on)",
) )
parser.add_argument(
"--stall-timeout",
type=float,
default=DEFAULT_STALL_TIMEOUT_S,
help=(
"log mode: seconds without any data before treating the device as "
f"hung and exiting (default: {DEFAULT_STALL_TIMEOUT_S:.0f})"
),
)
subparsers = parser.add_subparsers(dest="action") subparsers = parser.add_subparsers(dest="action")
log_p = subparsers.add_parser("log", help="Headless CSV capture", add_help=False) log_p = subparsers.add_parser("log", help="Headless CSV capture", add_help=False)
@@ -285,6 +321,7 @@ def build_parser() -> argparse.ArgumentParser:
log_p.add_argument("-o", "--output", type=Path) log_p.add_argument("-o", "--output", type=Path)
log_p.add_argument("--log-dir", type=Path, default=Path("logs")) log_p.add_argument("--log-dir", type=Path, default=Path("logs"))
log_p.add_argument("--auto-log-on", action=argparse.BooleanOptionalAction, default=True) log_p.add_argument("--auto-log-on", action=argparse.BooleanOptionalAction, default=True)
log_p.add_argument("--stall-timeout", type=float, default=DEFAULT_STALL_TIMEOUT_S)
tui_p = subparsers.add_parser("tui", help="Interactive curses dashboard") tui_p = subparsers.add_parser("tui", help="Interactive curses dashboard")
tui_p.add_argument("-p", "--port") tui_p.add_argument("-p", "--port")

View File

@@ -54,7 +54,7 @@ const char *FanCharacterize::phaseName() const {
} }
bool FanCharacterize::start(float maxCornerC, float avgTempC) { bool FanCharacterize::start(float maxCornerC, float avgTempC) {
if (maxCornerC < 45.0f || maxCornerC > EMERGENCY_MAX_TEMP_C - 5.0f) { if (maxCornerC < 45.0f || maxCornerC > EMERGENCY_ABSOLUTE_MAX_C - 5.0f) {
return false; return false;
} }
@@ -101,7 +101,7 @@ bool FanCharacterize::update(float avgTempC, float maxTempC, float spreadC, uint
return false; return false;
} }
if (maxTempC >= EMERGENCY_MAX_TEMP_C) { if (maxTempC >= EMERGENCY_ABSOLUTE_MAX_C) {
fail(F("fanchars: abort limit")); fail(F("fanchars: abort limit"));
return false; return false;
} }

View File

@@ -51,6 +51,9 @@ void readAllSensors() {
readSensorOnChannel(SENSOR_CHANNELS[i], sensors[i]); readSensorOnChannel(SENSOR_CHANNELS[i], sensors[i]);
} }
mux.disableAll(); mux.disableAll();
if (Wire.getWireTimeoutFlag()) {
Wire.clearWireTimeoutFlag();
}
} }
float averageValidTemperature() { float averageValidTemperature() {
@@ -437,7 +440,7 @@ void processSerialLine(const char *line) {
maxC = atof(line + 9); maxC = atof(line + 9);
} }
if (maxC < 45.0f || maxC > EMERGENCY_MAX_TEMP_C - 5.0f) { if (maxC < 45.0f || maxC > EMERGENCY_ABSOLUTE_MAX_C - 5.0f) {
Serial.println(F("ERR fanchars max 45-65 C")); Serial.println(F("ERR fanchars max 45-65 C"));
return; return;
} }
@@ -521,6 +524,11 @@ void setup() {
} }
Wire.begin(); Wire.begin();
// Without a timeout, a glitched I2C transaction (electrical noise, a
// momentary bad connection) can hang the AVR's Wire library forever,
// freezing the whole sketch. This bounds any transaction and resets the
// TWI hardware so the loop keeps running instead of locking up silently.
Wire.setWireTimeout(I2C_TIMEOUT_US, true);
if (!mux.begin()) { if (!mux.begin()) {
Serial.println(F("ERROR: TCA9548A not found on I2C bus")); Serial.println(F("ERROR: TCA9548A not found on I2C bus"));