add fanchars
This commit is contained in:
37
README.md
37
README.md
@@ -82,7 +82,22 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok`
|
||||
|
||||
1. Flash firmware and open the serial monitor at 115200 baud.
|
||||
2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2`–`ch5`).
|
||||
3. Tune **heat PI** (stored in EEPROM on autotune complete):
|
||||
3. Find **stir fan** speed (chamber ~35°C, idle):
|
||||
|
||||
```
|
||||
target 0
|
||||
fanchars
|
||||
```
|
||||
|
||||
Heats to max **60°C** at **30%, 100%, 60%, 80%** fan (cool to **40°C** avg between each). If the best is not at 30% or 100%, runs once at the midpoint of the two lowest spreads (~1–3 h). Or: `python3 scripts/fan_characterize.py`. When done:
|
||||
|
||||
```
|
||||
fanchars save
|
||||
```
|
||||
|
||||
Stored PWM is the minimum-stir floor while regulating (≥40°C avg).
|
||||
|
||||
4. Tune **heat PI** (stored in EEPROM on autotune complete):
|
||||
|
||||
```
|
||||
target 0
|
||||
@@ -92,25 +107,9 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok`
|
||||
|
||||
Emergency cutoff is fixed at **70°C** — you can autotune at 50–55°C with ABS in the chamber while hot corners stay below that.
|
||||
|
||||
4. Tune **mix PI** (spread → fan) while drying:
|
||||
5. Optional: adjust **mix PI** at target if spread still drifts (`mixpi`, `pid save`). Stir fan from step 3 is usually enough to start drying.
|
||||
|
||||
```
|
||||
target 55
|
||||
log on
|
||||
```
|
||||
|
||||
Watch `spread` in status. Adjust mix gains without reflash:
|
||||
|
||||
```
|
||||
mixpi 40 2
|
||||
pid save
|
||||
```
|
||||
|
||||
Defaults: heat Kp/Ki from autotune; mix Kp=40 Ki=2 in firmware until tuned.
|
||||
|
||||
5. Optional: `stepresp 45 35` logs open-loop fan steps if you want to estimate mix gain before live tuning.
|
||||
|
||||
Send `help` over serial for all commands (`target`, `mixpi`, `fan test`, `log on/off`, `status`, `pid`, etc.).
|
||||
Send `help` over serial for all commands (`target`, `fanchars`, `mixpi`, `fan test`, `log on/off`, `status`, `pid`, etc.).
|
||||
|
||||
## Raspberry Pi control
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ static const float MIX_PI_KP = 40.0f;
|
||||
static const float MIX_PI_KI = 2.0f;
|
||||
static const float SPREAD_TARGET_C = 0.5f;
|
||||
static const float HEAT_UP_BAND_C = 8.0f;
|
||||
static const uint8_t FAN_COLD_CAP_PWM = 0;
|
||||
// Mix PI only at/above target; below target use minimum stir only (no mix PI).
|
||||
// Cap mix fan — high airflow often increases spread / heat loss rather than fixing it.
|
||||
static const uint8_t FAN_MIX_MAX_PWM = 140;
|
||||
|
||||
// Legacy aliases for autotuner relay math only
|
||||
static const float PID_KP = HEAT_PI_KP;
|
||||
@@ -56,7 +58,7 @@ static const float MAX_TEMP_HEADROOM_C = 15.0f;
|
||||
|
||||
static const uint16_t HEATER_CYCLE_MS = 3000;
|
||||
|
||||
// Fan PWM
|
||||
// Fan PWM — FAN_IDLE_PWM ≈ 30%; off only while chamber avg is below 40°C
|
||||
static const uint8_t FAN_IDLE_PWM = 77;
|
||||
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f;
|
||||
static const uint8_t FAN_MAX_PWM = 255;
|
||||
@@ -75,19 +77,22 @@ static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL;
|
||||
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL;
|
||||
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL;
|
||||
|
||||
// Fan step-response — open-loop heater, fan PWM steps (command: stepresp)
|
||||
static const float STEPRESP_DEFAULT_TEMP_C = 45.0f;
|
||||
static const float STEPRESP_DEFAULT_HEATER_PCT = 35.0f;
|
||||
static const float STEPRESP_MIN_HEATER_PCT = 10.0f;
|
||||
static const float STEPRESP_MAX_HEATER_PCT = 70.0f;
|
||||
static const float STEPRESP_PREHEAT_BAND_C = 2.0f;
|
||||
static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL;
|
||||
static const uint32_t STEPRESP_BASELINE_MS = 120000UL;
|
||||
static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL;
|
||||
static const uint32_t STEPRESP_LOG_INTERVAL_MS = 1000UL;
|
||||
static const uint8_t STEPRESP_FAN_STEPS[] = {0, 77, 140, 200, 255};
|
||||
static const uint8_t STEPRESP_FAN_STEP_COUNT =
|
||||
sizeof(STEPRESP_FAN_STEPS) / sizeof(STEPRESP_FAN_STEPS[0]);
|
||||
// Fan characterize — fixed heater, sweep fan PWMs, pick lowest spread
|
||||
static const float FANCHARS_MAX_CORNER_C = 60.0f;
|
||||
static const float FANCHARS_COOL_AVG_C = 40.0f;
|
||||
static const float FANCHARS_PRECOOL_MARGIN_C = 2.0f;
|
||||
static const float FANCHARS_HEATER_PCT = 85.0f;
|
||||
// Coarse sweep order: 30%, 100%, 60%, 80% fan
|
||||
static const uint8_t FANCHARS_COARSE_PWM[] = {77, 255, 153, 204};
|
||||
static const uint8_t FANCHARS_COARSE_COUNT =
|
||||
sizeof(FANCHARS_COARSE_PWM) / sizeof(FANCHARS_COARSE_PWM[0]);
|
||||
static const uint8_t FANCHARS_LIMIT_LOW_PWM = 77;
|
||||
static const uint8_t FANCHARS_LIMIT_HIGH_PWM = 255;
|
||||
static const uint8_t FANCHARS_MAX_RESULTS = FANCHARS_COARSE_COUNT + 1;
|
||||
static const uint32_t FANCHARS_HOLD_MS = 60000UL;
|
||||
static const uint32_t FANCHARS_HEAT_TIMEOUT_MS = 2700000UL;
|
||||
static const uint32_t FANCHARS_COOLDOWN_TIMEOUT_MS = 2700000UL;
|
||||
static const uint32_t FANCHARS_LOG_INTERVAL_MS = 1000UL;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
class FanStepResponse {
|
||||
public:
|
||||
enum class Phase : uint8_t { Idle, Preheat, Baseline, StepHold, Done, Failed };
|
||||
|
||||
FanStepResponse()
|
||||
: phase_(Phase::Idle),
|
||||
targetC_(STEPRESP_DEFAULT_TEMP_C),
|
||||
heaterPct_(STEPRESP_DEFAULT_HEATER_PCT),
|
||||
stepIndex_(0),
|
||||
sessionStartMs_(0),
|
||||
phaseStartMs_(0),
|
||||
lastLogMs_(0),
|
||||
lastAvgC_(0.0f) {}
|
||||
|
||||
Phase phase() const { return phase_; }
|
||||
|
||||
bool isActive() const {
|
||||
return phase_ == Phase::Preheat || phase_ == Phase::Baseline || phase_ == Phase::StepHold;
|
||||
}
|
||||
|
||||
uint32_t elapsedMs(uint32_t nowMs) const {
|
||||
if (sessionStartMs_ == 0) {
|
||||
return 0;
|
||||
}
|
||||
return nowMs - sessionStartMs_;
|
||||
}
|
||||
|
||||
uint8_t stepIndex() const { return stepIndex_; }
|
||||
|
||||
uint8_t stepCount() const { return STEPRESP_FAN_STEP_COUNT; }
|
||||
|
||||
float targetC() const { return targetC_; }
|
||||
|
||||
float heaterPct() const { return heaterPct_; }
|
||||
|
||||
uint8_t currentFanPwm() const {
|
||||
if (stepIndex_ >= STEPRESP_FAN_STEP_COUNT) {
|
||||
return 0;
|
||||
}
|
||||
return STEPRESP_FAN_STEPS[stepIndex_];
|
||||
}
|
||||
|
||||
const char *phaseName() const {
|
||||
switch (phase_) {
|
||||
case Phase::Preheat:
|
||||
return "preheat";
|
||||
case Phase::Baseline:
|
||||
return "baseline";
|
||||
case Phase::StepHold:
|
||||
return "step";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
bool start(float targetC, float heaterPct) {
|
||||
if (targetC < 25.0f || targetC > TARGET_MAX_C) {
|
||||
return false;
|
||||
}
|
||||
if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetC_ = targetC;
|
||||
heaterPct_ = heaterPct;
|
||||
stepIndex_ = 0;
|
||||
sessionStartMs_ = millis();
|
||||
phaseStartMs_ = sessionStartMs_;
|
||||
lastLogMs_ = 0;
|
||||
lastAvgC_ = 0.0f;
|
||||
phase_ = Phase::Preheat;
|
||||
|
||||
Serial.print(F("stepresp: preheat to "));
|
||||
Serial.print(targetC_ - STEPRESP_PREHEAT_BAND_C, 1);
|
||||
Serial.print(F("-"));
|
||||
Serial.print(targetC_, 1);
|
||||
Serial.print(F("C avg, heater="));
|
||||
Serial.print(heaterPct_, 0);
|
||||
Serial.println(F("% fan=0"));
|
||||
return true;
|
||||
}
|
||||
|
||||
void abort() {
|
||||
if (isActive()) {
|
||||
Serial.println(F("stepresp: cancelled"));
|
||||
}
|
||||
phase_ = Phase::Idle;
|
||||
sessionStartMs_ = 0;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
phase_ = Phase::Idle;
|
||||
sessionStartMs_ = 0;
|
||||
}
|
||||
|
||||
bool update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
|
||||
uint8_t &fanPwmOut) {
|
||||
heaterDutyOut = 0.0f;
|
||||
fanPwmOut = 0;
|
||||
|
||||
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
|
||||
fail(F("stepresp: abort — max sensor at safety limit"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (phase_ == Phase::Preheat) {
|
||||
fanPwmOut = 0;
|
||||
if (nowMs - phaseStartMs_ > STEPRESP_PREHEAT_TIMEOUT_MS) {
|
||||
fail(F("stepresp: abort — preheat timeout"));
|
||||
return false;
|
||||
}
|
||||
if (avgTempC >= targetC_ - STEPRESP_PREHEAT_BAND_C) {
|
||||
enterBaseline(nowMs);
|
||||
} else {
|
||||
heaterDutyOut = heaterPct_;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
heaterDutyOut = heaterPct_;
|
||||
fanPwmOut = currentFanPwm();
|
||||
|
||||
if (phase_ == Phase::Baseline) {
|
||||
if (nowMs - phaseStartMs_ >= STEPRESP_BASELINE_MS) {
|
||||
advanceStep(nowMs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (phase_ == Phase::StepHold) {
|
||||
if (nowMs - phaseStartMs_ >= STEPRESP_STEP_HOLD_MS) {
|
||||
if (stepIndex_ + 1 >= STEPRESP_FAN_STEP_COUNT) {
|
||||
finish(nowMs, avgTempC, spreadC);
|
||||
} else {
|
||||
++stepIndex_;
|
||||
enterStepHold(nowMs, true);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
||||
float avgTempC, float minTempC, float maxTempC, float spreadC, uint32_t nowMs) {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (lastLogMs_ != 0 && nowMs - lastLogMs_ < STEPRESP_LOG_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastLogMs_ = nowMs;
|
||||
lastAvgC_ = avgTempC;
|
||||
|
||||
Serial.print(F("sr,"));
|
||||
Serial.print(nowMs);
|
||||
Serial.print(',');
|
||||
Serial.print(phaseName());
|
||||
Serial.print(',');
|
||||
Serial.print(stepIndex_);
|
||||
Serial.print('/');
|
||||
Serial.print(STEPRESP_FAN_STEP_COUNT);
|
||||
Serial.print(',');
|
||||
Serial.print(heaterPct_, 0);
|
||||
Serial.print(',');
|
||||
Serial.print(currentFanPwm());
|
||||
Serial.print(',');
|
||||
Serial.print(avgTempC, 2);
|
||||
Serial.print(',');
|
||||
Serial.print(minTempC, 2);
|
||||
Serial.print(',');
|
||||
Serial.print(maxTempC, 2);
|
||||
Serial.print(',');
|
||||
Serial.print(spreadC, 2);
|
||||
|
||||
for (uint8_t i = 0; i < sensorCount; ++i) {
|
||||
Serial.print(',');
|
||||
if (sensorValid[i]) {
|
||||
Serial.print(sensorTemps[i], 2);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
private:
|
||||
void enterBaseline(uint32_t nowMs) {
|
||||
phase_ = Phase::Baseline;
|
||||
phaseStartMs_ = nowMs;
|
||||
stepIndex_ = 0;
|
||||
Serial.print(F("stepresp: baseline fan="));
|
||||
Serial.print(currentFanPwm());
|
||||
Serial.print(F(" for "));
|
||||
Serial.print(STEPRESP_BASELINE_MS / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
}
|
||||
|
||||
void enterStepHold(uint32_t nowMs, bool isStep) {
|
||||
phase_ = Phase::StepHold;
|
||||
phaseStartMs_ = nowMs;
|
||||
Serial.print(F("stepresp: "));
|
||||
if (isStep) {
|
||||
Serial.print(F("step "));
|
||||
}
|
||||
Serial.print(stepIndex_ + 1);
|
||||
Serial.print(F("/"));
|
||||
Serial.print(STEPRESP_FAN_STEP_COUNT);
|
||||
Serial.print(F(" fan="));
|
||||
Serial.print(currentFanPwm());
|
||||
Serial.print(F(" ("));
|
||||
Serial.print((currentFanPwm() * 100) / 255);
|
||||
Serial.print(F("%) hold "));
|
||||
Serial.print(STEPRESP_STEP_HOLD_MS / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
}
|
||||
|
||||
void advanceStep(uint32_t nowMs) {
|
||||
if (STEPRESP_FAN_STEP_COUNT <= 1) {
|
||||
finish(nowMs, lastAvgC_, 0.0f);
|
||||
return;
|
||||
}
|
||||
stepIndex_ = 1;
|
||||
enterStepHold(nowMs, true);
|
||||
}
|
||||
|
||||
void finish(uint32_t nowMs, float avgTempC, float spreadC) {
|
||||
phase_ = Phase::Done;
|
||||
Serial.print(F("stepresp: done in "));
|
||||
Serial.print((nowMs - sessionStartMs_) / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
Serial.print(F(" target="));
|
||||
Serial.print(targetC_, 1);
|
||||
Serial.print(F("C heater="));
|
||||
Serial.print(heaterPct_, 0);
|
||||
Serial.print(F("% final avg="));
|
||||
Serial.print(avgTempC, 1);
|
||||
Serial.print(F("C spread="));
|
||||
Serial.print(spreadC, 1);
|
||||
Serial.println(F("C"));
|
||||
Serial.println(F(" parse sr,... lines for step response (fan PWM vs temp)"));
|
||||
}
|
||||
|
||||
void fail(const __FlashStringHelper *reason) {
|
||||
Serial.println(reason);
|
||||
phase_ = Phase::Failed;
|
||||
sessionStartMs_ = 0;
|
||||
}
|
||||
|
||||
Phase phase_;
|
||||
float targetC_;
|
||||
float heaterPct_;
|
||||
uint8_t stepIndex_;
|
||||
uint32_t sessionStartMs_;
|
||||
uint32_t phaseStartMs_;
|
||||
uint32_t lastLogMs_;
|
||||
float lastAvgC_;
|
||||
};
|
||||
@@ -6,12 +6,13 @@
|
||||
#include "config.h"
|
||||
|
||||
// After TuningData (15 bytes) + checksum (1 byte) at address 0
|
||||
static const uint16_t SETTINGS_MAGIC = 0xDA7E;
|
||||
static const uint16_t SETTINGS_MAGIC = 0xDA7F;
|
||||
static const int SETTINGS_EEPROM_ADDR = 16;
|
||||
|
||||
struct SettingsData {
|
||||
uint16_t magic = 0;
|
||||
float targetC = TARGET_TEMP_C;
|
||||
uint8_t stirFanPwm = 0; // 0 = use FAN_IDLE_PWM (~30%)
|
||||
};
|
||||
|
||||
inline uint8_t settingsChecksum(const SettingsData &data) {
|
||||
@@ -39,8 +40,24 @@ inline void settingsSave(const SettingsData &data) {
|
||||
|
||||
inline void settingsSaveTarget(float targetC) {
|
||||
SettingsData data;
|
||||
if (settingsLoad(data)) {
|
||||
data.targetC = targetC;
|
||||
} else {
|
||||
data.magic = SETTINGS_MAGIC;
|
||||
data.targetC = targetC;
|
||||
data.stirFanPwm = 0;
|
||||
}
|
||||
settingsSave(data);
|
||||
}
|
||||
|
||||
inline void settingsSaveStirFan(uint8_t stirFanPwm) {
|
||||
SettingsData data;
|
||||
if (settingsLoad(data)) {
|
||||
data.stirFanPwm = stirFanPwm;
|
||||
} else {
|
||||
data.magic = SETTINGS_MAGIC;
|
||||
data.stirFanPwm = stirFanPwm;
|
||||
}
|
||||
settingsSave(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "fan_step_response.h"
|
||||
#include "fan_characterize.h"
|
||||
#include "pid_autotuner.h"
|
||||
#include "pid_controller.h"
|
||||
#include "settings_store.h"
|
||||
@@ -11,19 +11,20 @@
|
||||
|
||||
class ThermalController {
|
||||
public:
|
||||
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, StepResp };
|
||||
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
|
||||
|
||||
ThermalController()
|
||||
: heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
|
||||
mixPi_(MIX_PI_KP, MIX_PI_KI, 0.0f, 0.0f, 255.0f),
|
||||
autotuner_(),
|
||||
stepresp_(),
|
||||
fanchars_(),
|
||||
targetTempC_(TARGET_TEMP_C),
|
||||
heaterDutyPercent_(0.0f),
|
||||
heaterAllowancePercent_(100.0f),
|
||||
cornerSpreadC_(0.0f),
|
||||
lastMaxTempC_(0.0f),
|
||||
regulatingFanPwm_(0),
|
||||
stirFanPwm_(FAN_IDLE_PWM),
|
||||
fanPwm_(0),
|
||||
tuningLoaded_(false),
|
||||
fanIdleOverride_(false),
|
||||
@@ -70,11 +71,15 @@ public:
|
||||
}
|
||||
|
||||
SettingsData settings;
|
||||
if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C &&
|
||||
settings.targetC <= TARGET_MAX_C) {
|
||||
if (settingsLoad(settings)) {
|
||||
if (settings.stirFanPwm > 0) {
|
||||
stirFanPwm_ = settings.stirFanPwm;
|
||||
}
|
||||
if (settings.targetC >= TARGET_MIN_C && settings.targetC <= TARGET_MAX_C) {
|
||||
setTarget(settings.targetC, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void applyTuning(const TuningData &data) {
|
||||
heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
|
||||
@@ -140,7 +145,7 @@ public:
|
||||
bool isTuningLoaded() const { return tuningLoaded_; }
|
||||
|
||||
bool startAutotune(float setpointC) {
|
||||
if (autotuner_.isActive() || stepresp_.isActive()) {
|
||||
if (autotuner_.isActive() || fanchars_.isActive()) {
|
||||
return false;
|
||||
}
|
||||
cutoffActive_ = false;
|
||||
@@ -163,46 +168,65 @@ public:
|
||||
|
||||
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
|
||||
|
||||
bool startStepResponse(float targetC, float heaterPct) {
|
||||
if (autotuner_.isActive() || stepresp_.isActive()) {
|
||||
bool startFanCharacterize(float maxCornerC, float avgTempC) {
|
||||
if (autotuner_.isActive() || fanchars_.isActive()) {
|
||||
return false;
|
||||
}
|
||||
stopFanTest();
|
||||
cutoffActive_ = false;
|
||||
heatPi_.reset();
|
||||
mixPi_.reset();
|
||||
setTarget(targetC, false);
|
||||
if (!stepresp_.start(targetC, heaterPct)) {
|
||||
return false;
|
||||
}
|
||||
writeFan(0);
|
||||
return true;
|
||||
setTarget(0.0f, false);
|
||||
return fanchars_.start(maxCornerC, avgTempC);
|
||||
}
|
||||
|
||||
void stopStepResponse() {
|
||||
stepresp_.abort();
|
||||
void stopFanCharacterize() {
|
||||
fanchars_.abort();
|
||||
forceHeaterOff();
|
||||
writeFan(0);
|
||||
}
|
||||
|
||||
bool isStepResponseActive() const { return stepresp_.isActive(); }
|
||||
bool isFanCharacterizeActive() const { return fanchars_.isActive(); }
|
||||
|
||||
uint32_t stepResponseElapsedMs(uint32_t nowMs) const { return stepresp_.elapsedMs(nowMs); }
|
||||
uint32_t fanCharacterizeElapsedMs(uint32_t nowMs) const { return fanchars_.elapsedMs(nowMs); }
|
||||
|
||||
const char *stepResponsePhaseName() const { return stepresp_.phaseName(); }
|
||||
const char *fanCharacterizePhaseName() const { return fanchars_.phaseName(); }
|
||||
|
||||
uint8_t stepResponseStepIndex() const { return stepresp_.stepIndex(); }
|
||||
uint8_t fanCharacterizeProfileIndex() const { return fanchars_.profileIndex(); }
|
||||
|
||||
uint8_t stepResponseStepCount() const { return stepresp_.stepCount(); }
|
||||
uint8_t fanCharacterizeProfileCount() const { return fanchars_.profileCount(); }
|
||||
|
||||
float stepResponseHeaterPct() const { return stepresp_.heaterPct(); }
|
||||
uint8_t fanCharacterizeFanPwm() const { return fanchars_.currentFanPwm(); }
|
||||
|
||||
void logStepResponseIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
||||
bool isFanCharacterizeRefineRun() const { return fanchars_.isRefineRun(); }
|
||||
|
||||
float fanCharacterizeHeaterPct() const { return fanchars_.heaterPct(); }
|
||||
|
||||
uint8_t stirFanPwm() const { return stirFanPwm_; }
|
||||
|
||||
void logFanCharacterizeIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
||||
float avgTempC, float minTempC, float maxTempC, float spreadC,
|
||||
uint32_t nowMs) {
|
||||
stepresp_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
|
||||
fanchars_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
|
||||
spreadC, nowMs);
|
||||
}
|
||||
|
||||
bool saveStirFanFromCharacterize() {
|
||||
if (fanchars_.phase() != FanCharacterize::Phase::Done) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t winner = fanchars_.winnerFanPwm();
|
||||
if (winner == 0) {
|
||||
return false;
|
||||
}
|
||||
stirFanPwm_ = winner;
|
||||
settingsSaveStirFan(winner);
|
||||
Serial.print(F("stir fan "));
|
||||
Serial.println(winner);
|
||||
fanchars_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool commitAutotuneIfDone() {
|
||||
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
|
||||
return false;
|
||||
@@ -331,8 +355,8 @@ public:
|
||||
return "corner";
|
||||
case HeaterBlock::Autotune:
|
||||
return "autotune";
|
||||
case HeaterBlock::StepResp:
|
||||
return "stepresp";
|
||||
case HeaterBlock::FanChars:
|
||||
return "fanchars";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
@@ -352,8 +376,8 @@ public:
|
||||
SPREAD_EMA_ALPHA * cornerSpreadC_ +
|
||||
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
|
||||
|
||||
if (stepresp_.isActive()) {
|
||||
updateStepResponse(avgTempC, maxTempC, nowMs);
|
||||
if (fanchars_.isActive()) {
|
||||
updateFanCharacterize(avgTempC, maxTempC, nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -386,7 +410,7 @@ public:
|
||||
heatPi_.reset();
|
||||
mixPi_.reset();
|
||||
autotuner_.abort();
|
||||
stepresp_.abort();
|
||||
fanchars_.abort();
|
||||
}
|
||||
|
||||
void forceHeaterOff() {
|
||||
@@ -425,22 +449,17 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
void updateStepResponse(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
float duty = 0.0f;
|
||||
uint8_t fan = 0;
|
||||
stepresp_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
||||
fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
||||
|
||||
heaterDutyPercent_ = duty;
|
||||
heaterAllowancePercent_ = duty;
|
||||
heaterBlock_ = duty > 0.0f ? HeaterBlock::StepResp : HeaterBlock::None;
|
||||
heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
|
||||
applyHeaterBurst(nowMs);
|
||||
writeFan(fan);
|
||||
lastHeaterUpdateMs_ = nowMs;
|
||||
|
||||
if (stepresp_.phase() == FanStepResponse::Phase::Done ||
|
||||
stepresp_.phase() == FanStepResponse::Phase::Failed) {
|
||||
stepresp_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
@@ -504,13 +523,28 @@ private:
|
||||
}
|
||||
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
|
||||
|
||||
uint8_t fanPwm = stirFanPwm_;
|
||||
if (avgTempC < targetTempC_) {
|
||||
mixPi_.reset();
|
||||
} else {
|
||||
const float mixInput = SPREAD_TARGET_C - cornerSpreadC_;
|
||||
float fanOut = mixPi_.compute(mixInput, nowMs);
|
||||
uint8_t fanPwm = static_cast<uint8_t>(fanOut + 0.5f);
|
||||
if (avgTempC < targetTempC_ - HEAT_UP_BAND_C && fanPwm > FAN_COLD_CAP_PWM) {
|
||||
fanPwm = FAN_COLD_CAP_PWM;
|
||||
fanPwm = static_cast<uint8_t>(fanOut + 0.5f);
|
||||
if (fanPwm > FAN_MIX_MAX_PWM) {
|
||||
fanPwm = FAN_MIX_MAX_PWM;
|
||||
}
|
||||
regulatingFanPwm_ = fanPwm;
|
||||
}
|
||||
regulatingFanPwm_ = fanWithMinStir(avgTempC, fanPwm);
|
||||
}
|
||||
|
||||
uint8_t fanWithMinStir(float avgTempC, uint8_t pwm) const {
|
||||
if (avgTempC < IDLE_AUTO_FAN_OFF_TEMP_C) {
|
||||
return 0;
|
||||
}
|
||||
if (pwm < stirFanPwm_) {
|
||||
return stirFanPwm_;
|
||||
}
|
||||
return pwm;
|
||||
}
|
||||
|
||||
static float clampPercent(float value) {
|
||||
@@ -633,13 +667,14 @@ private:
|
||||
PidController heatPi_;
|
||||
PidController mixPi_;
|
||||
PidAutotuner autotuner_;
|
||||
FanStepResponse stepresp_;
|
||||
FanCharacterize fanchars_;
|
||||
float targetTempC_;
|
||||
float heaterDutyPercent_;
|
||||
float heaterAllowancePercent_;
|
||||
float cornerSpreadC_;
|
||||
float lastMaxTempC_;
|
||||
uint8_t regulatingFanPwm_;
|
||||
uint8_t stirFanPwm_;
|
||||
uint8_t fanPwm_;
|
||||
bool tuningLoaded_;
|
||||
bool fanIdleOverride_;
|
||||
|
||||
@@ -26,7 +26,7 @@ python3 scripts/capture_csv.py log
|
||||
# logs/dryer_YYYYMMDD_HHMMSS.csv
|
||||
```
|
||||
|
||||
**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` CSV log · `a` autotune · `r` stepresp · `:` command · `q` quit
|
||||
**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` CSV log · `a` autotune · `c` fanchars · `:` command · `q` quit
|
||||
|
||||
**Always-on logging**
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ platform = atmelavr
|
||||
board = nanoatmega328
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
build_flags = -flto
|
||||
lib_deps =
|
||||
adafruit/Adafruit SHT31 Library@^2.2.2
|
||||
adafruit/Adafruit BusIO@^1.16.1
|
||||
|
||||
@@ -7,6 +7,7 @@ import curses
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -43,7 +44,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>\d+/255\(\d+%\)(?:\([^)]+\))?(?:\s+TEST)?)\s+"
|
||||
r"fan=(?P<fan>\d+/255\([^)]+\)(?:\([^)]+\))?(?:\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,27 +56,73 @@ 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+)%"
|
||||
FANCHARS_MODE_RE = re.compile(
|
||||
r"fanchars/(?P<phase>[\w-]+) (?P<elapsed>\d+)s run (?P<run>[\w]+)/(?P<runs>\d+) "
|
||||
r"fan=(?P<testfan>\d+) heat=(?P<heat>\d+)%"
|
||||
)
|
||||
|
||||
FANCHARS_PHASE_HELP: dict[str, str] = {
|
||||
"precool": "Cooling chamber to 40 C avg before first fan test (fan at 100% now)",
|
||||
"cool": "Cooling to 40 C avg before next fan test (fan at 100% now)",
|
||||
"heat": "Heating to 60 C max corner at test fan speed",
|
||||
"hold": "Holding at max — measuring temperature spread",
|
||||
"refine": "Refine run — midpoint PWM between two best spreads",
|
||||
}
|
||||
|
||||
def format_mode_line(mode: str) -> str:
|
||||
|
||||
def fan_pct_from_pwm(pwm: int) -> int:
|
||||
return (pwm * 100) // 255
|
||||
|
||||
|
||||
def format_fan_display(fan_raw: str) -> str:
|
||||
match = re.match(r"(\d+)/255\((\d+)%\)(.*)$", fan_raw.strip())
|
||||
if not match:
|
||||
return fan_raw
|
||||
suffix = match.group(3).strip()
|
||||
pct = match.group(2)
|
||||
if suffix:
|
||||
return f"{pct}% {suffix}"
|
||||
return f"{pct}%"
|
||||
|
||||
|
||||
def format_mode_line(mode: str, avg: str = "") -> tuple[str, str]:
|
||||
"""Return (mode summary, activity detail) for the dashboard."""
|
||||
match = AUTOTUNE_MODE_RE.match(mode)
|
||||
if match:
|
||||
d = match.groupdict()
|
||||
return (
|
||||
f"Autotune {d['phase']}: {d['elapsed']}s elapsed, "
|
||||
summary = (
|
||||
f"Autotune {d['phase']}: {d['elapsed']}s, "
|
||||
f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
|
||||
)
|
||||
match = STEPRESP_MODE_RE.match(mode)
|
||||
return summary, "Relay tuning heat PI — heater bang-bang around setpoint"
|
||||
|
||||
match = FANCHARS_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}"
|
||||
phase = d["phase"]
|
||||
test_pct = fan_pct_from_pwm(int(d["testfan"]))
|
||||
run = d["run"]
|
||||
runs = d["runs"]
|
||||
if run == "pre":
|
||||
run_text = f"preparing (before 1/{runs})"
|
||||
elif run.startswith("n"):
|
||||
run_text = f"before {run[1:]}/{runs} ({test_pct}% fan next)"
|
||||
elif run == "refine":
|
||||
run_text = f"refine ({test_pct}% fan)"
|
||||
else:
|
||||
run_text = f"{run}/{runs} ({test_pct}% fan)"
|
||||
summary = f"Fan chars {phase}: {d['elapsed']}s — {run_text}"
|
||||
detail = FANCHARS_PHASE_HELP.get(phase, "")
|
||||
if phase in ("precool", "cool") and avg not in ("—", ""):
|
||||
try:
|
||||
detail += f" — avg {avg} C"
|
||||
except ValueError:
|
||||
pass
|
||||
return summary, detail
|
||||
|
||||
if mode in ("manual", "regulating"):
|
||||
return f"Mode: {mode}", "Normal temperature control"
|
||||
return f"Mode: {mode}", ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -96,8 +143,9 @@ class DryerState:
|
||||
cutoff_active: str = "no"
|
||||
failsafe: str = "no"
|
||||
mode: str = "—"
|
||||
activity: str = ""
|
||||
sensors: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
messages: deque[str] = field(default_factory=lambda: deque(maxlen=12))
|
||||
messages: deque[str] = field(default_factory=lambda: deque(maxlen=24))
|
||||
csv_logging: bool = False
|
||||
csv_path: Path | None = None
|
||||
port: str = ""
|
||||
@@ -150,13 +198,18 @@ def apply_status(state: DryerState, data: dict) -> None:
|
||||
state.hblk = data["hblk"]
|
||||
state.ssr = data["ssr"]
|
||||
fan_raw = data["fan"]
|
||||
state.fan = fan_raw
|
||||
state.fan = format_fan_display(fan_raw)
|
||||
state.fan_note = ""
|
||||
if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or fan_raw.endswith("(cmd-off)") or " TEST" in fan_raw:
|
||||
if "(off)" in fan_raw or "(cooldown)" in fan_raw or "(cmd-off)" in fan_raw:
|
||||
state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
|
||||
elif "(fanchars-" in fan_raw:
|
||||
state.fan_note = fan_raw[fan_raw.find("(fanchars-") :]
|
||||
state.cutoff_active = data["cutoff_active"]
|
||||
state.failsafe = data["failsafe"]
|
||||
state.mode = data["mode"]
|
||||
summary, activity = format_mode_line(data["mode"], data["avg"])
|
||||
state.mode = summary
|
||||
state.activity = activity
|
||||
state.sensors = data["sensor_list"]
|
||||
|
||||
|
||||
@@ -232,6 +285,10 @@ class SerialWorker:
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("fanchars:"):
|
||||
self._note(line)
|
||||
continue
|
||||
|
||||
if line.startswith("csv,") or line.startswith("csv_hdr,"):
|
||||
continue
|
||||
|
||||
@@ -347,8 +404,8 @@ def _preset_menu(stdscr, worker: SerialWorker) -> None:
|
||||
def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
if height < 19 or width < 60:
|
||||
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x19).")
|
||||
if height < 20 or width < 60:
|
||||
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x20).")
|
||||
stdscr.refresh()
|
||||
return
|
||||
|
||||
@@ -361,14 +418,19 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
_safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr)
|
||||
|
||||
row += 1
|
||||
mode_text = format_mode_line(state.mode)
|
||||
mode_text = state.mode
|
||||
_safe_addstr(stdscr, row, 2, f"{mode_text[: max(0, width - 18)]} FS: {state.failsafe}")
|
||||
|
||||
row += 1
|
||||
if state.activity:
|
||||
_safe_addstr(stdscr, row, 2, state.activity[: max(0, width - 4)], curses.A_DIM)
|
||||
row += 1
|
||||
_safe_addstr(stdscr, row, 2, f"Avg: {state.avg} C Min: {state.min_temp} C Max: {state.max_temp} C Spread: {state.spread} C")
|
||||
|
||||
row += 1
|
||||
fan_text = state.fan if state.fan_note == "" else state.fan
|
||||
fan_text = state.fan
|
||||
if state.fan_note and "(fanchars-" not in state.fan_note:
|
||||
fan_text = f"{state.fan} {state.fan_note}"
|
||||
_safe_addstr(
|
||||
stdscr,
|
||||
row,
|
||||
@@ -420,7 +482,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
stdscr,
|
||||
help_y,
|
||||
1,
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | r stepresp | : cmd | q quit",
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | c fanchars | : cmd | q quit",
|
||||
curses.A_DIM,
|
||||
)
|
||||
stdscr.refresh()
|
||||
@@ -440,6 +502,8 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
worker = SerialWorker(ser, state, lock)
|
||||
worker.start()
|
||||
worker.send("status")
|
||||
time.sleep(0.4)
|
||||
worker.send("status")
|
||||
if auto_log_on:
|
||||
worker.set_csv_logging(True, log_dir)
|
||||
|
||||
@@ -463,8 +527,9 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
cutoff_active=state.cutoff_active,
|
||||
failsafe=state.failsafe,
|
||||
mode=state.mode,
|
||||
activity=state.activity,
|
||||
sensors=list(state.sensors),
|
||||
messages=deque(state.messages, maxlen=12),
|
||||
messages=deque(state.messages, maxlen=24),
|
||||
csv_logging=state.csv_logging,
|
||||
csv_path=state.csv_path,
|
||||
port=state.port,
|
||||
@@ -502,21 +567,9 @@ 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("c"):
|
||||
worker.send("fanchars")
|
||||
worker._note("Started fanchars — 30/100/60/80% then refine if needed")
|
||||
elif key == ord(":"):
|
||||
value = _prompt(stdscr, "Command")
|
||||
if value is not None and value != "":
|
||||
|
||||
89
src/main.cpp
89
src/main.cpp
@@ -142,6 +142,10 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||
Serial.print(F("%)"));
|
||||
if (thermal.isFanTestActive(millis())) {
|
||||
Serial.print(F(" TEST"));
|
||||
} else if (thermal.isFanCharacterizeActive()) {
|
||||
Serial.print(F("(fanchars-"));
|
||||
Serial.print(thermal.fanCharacterizePhaseName());
|
||||
Serial.print(F(")"));
|
||||
} else if (thermal.isIdle() && thermal.isFanOff()) {
|
||||
Serial.print(F("(off)"));
|
||||
} else if (thermal.isIdleCooling()) {
|
||||
@@ -166,17 +170,29 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||
Serial.print(F("cyc pre>="));
|
||||
Serial.print(thermal.autotunePreheatTargetC(), 0);
|
||||
Serial.print(F("C"));
|
||||
} else if (thermal.isStepResponseActive()) {
|
||||
Serial.print(F("stepresp/"));
|
||||
Serial.print(thermal.stepResponsePhaseName());
|
||||
} else if (thermal.isFanCharacterizeActive()) {
|
||||
Serial.print(F("fanchars/"));
|
||||
Serial.print(thermal.fanCharacterizePhaseName());
|
||||
Serial.print(F(" "));
|
||||
Serial.print(thermal.stepResponseElapsedMs(millis()) / 1000UL);
|
||||
Serial.print(F("s step "));
|
||||
Serial.print(thermal.stepResponseStepIndex() + 1);
|
||||
Serial.print(thermal.fanCharacterizeElapsedMs(millis()) / 1000UL);
|
||||
Serial.print(F("s run "));
|
||||
const char *fcPhase = thermal.fanCharacterizePhaseName();
|
||||
if (strcmp(fcPhase, "precool") == 0) {
|
||||
Serial.print(F("pre"));
|
||||
} else if (strcmp(fcPhase, "cool") == 0) {
|
||||
Serial.print(F("n"));
|
||||
Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
|
||||
} else if (thermal.isFanCharacterizeRefineRun()) {
|
||||
Serial.print(F("refine"));
|
||||
} else {
|
||||
Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
|
||||
}
|
||||
Serial.print(F("/"));
|
||||
Serial.print(thermal.stepResponseStepCount());
|
||||
Serial.print(thermal.fanCharacterizeProfileCount());
|
||||
Serial.print(F(" fan="));
|
||||
Serial.print(thermal.fanCharacterizeFanPwm());
|
||||
Serial.print(F(" heat="));
|
||||
Serial.print(thermal.stepResponseHeaterPct(), 0);
|
||||
Serial.print(thermal.fanCharacterizeHeaterPct(), 0);
|
||||
Serial.print(F("%"));
|
||||
} else {
|
||||
Serial.print(thermal.regulatingModeName());
|
||||
@@ -211,8 +227,8 @@ void printHelp() {
|
||||
Serial.println(F(" fan test N set fan PWM 0-255 for 15s (verify wiring)"));
|
||||
Serial.println(F(" autotune [C] learn heat PI (default: 40C when idle)"));
|
||||
Serial.println(F(" autotune stop"));
|
||||
Serial.println(F(" stepresp [C] [heater%] fan step response (default: 45C 35%)"));
|
||||
Serial.println(F(" stepresp stop"));
|
||||
Serial.println(F(" fanchars learn stir fan (30/100/60/80%% + refine)"));
|
||||
Serial.println(F(" fanchars stop | fanchars save"));
|
||||
Serial.println(F(" pid show heat + mix PI gains"));
|
||||
Serial.println(F(" pid default reset all PI to factory"));
|
||||
Serial.println(F(" pid save write current PI to EEPROM"));
|
||||
@@ -346,42 +362,41 @@ void processSerialLine(const char *line) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(line, "stepresp", 8) == 0) {
|
||||
if (strcmp(line, "stepresp stop") == 0) {
|
||||
thermal.stopStepResponse();
|
||||
Serial.println(F("OK stepresp cancelled"));
|
||||
if (strncmp(line, "fanchars", 8) == 0) {
|
||||
if (strcmp(line, "fanchars stop") == 0) {
|
||||
thermal.stopFanCharacterize();
|
||||
Serial.println(F("OK fanchars cancelled"));
|
||||
return;
|
||||
}
|
||||
if (strcmp(line, "fanchars save") == 0) {
|
||||
if (!thermal.saveStirFanFromCharacterize()) {
|
||||
Serial.println(F("ERR fanchars save — no completed run with winner"));
|
||||
return;
|
||||
}
|
||||
Serial.println(F("OK stir fan saved"));
|
||||
return;
|
||||
}
|
||||
|
||||
float tempC = STEPRESP_DEFAULT_TEMP_C;
|
||||
float heaterPct = STEPRESP_DEFAULT_HEATER_PCT;
|
||||
float maxC = FANCHARS_MAX_CORNER_C;
|
||||
if (line[8] == ' ') {
|
||||
const char *args = line + 9;
|
||||
tempC = atof(args);
|
||||
const char *space = strchr(args, ' ');
|
||||
if (space != nullptr) {
|
||||
heaterPct = atof(space + 1);
|
||||
}
|
||||
maxC = atof(line + 9);
|
||||
}
|
||||
|
||||
if (tempC < 25.0f || tempC > TARGET_MAX_C) {
|
||||
Serial.println(F("ERR stepresp temperature must be 25-80 C"));
|
||||
return;
|
||||
}
|
||||
if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) {
|
||||
Serial.print(F("ERR stepresp heater must be "));
|
||||
Serial.print(STEPRESP_MIN_HEATER_PCT, 0);
|
||||
Serial.print(F("-"));
|
||||
Serial.print(STEPRESP_MAX_HEATER_PCT, 0);
|
||||
Serial.println(F("%"));
|
||||
if (maxC < 45.0f || maxC > EMERGENCY_MAX_TEMP_C - 5.0f) {
|
||||
Serial.println(F("ERR fanchars max 45-65 C"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!thermal.startStepResponse(tempC, heaterPct)) {
|
||||
Serial.println(F("ERR stepresp already running or autotune active"));
|
||||
const float avgTemp = averageValidTemperature();
|
||||
if (isnan(avgTemp)) {
|
||||
Serial.println(F("ERR fanchars needs sensors"));
|
||||
return;
|
||||
}
|
||||
Serial.println(F("OK stepresp started — open-loop heater, fan steps, ~25-35 min"));
|
||||
if (!thermal.startFanCharacterize(maxC, avgTemp)) {
|
||||
Serial.println(F("ERR fanchars busy"));
|
||||
return;
|
||||
}
|
||||
Serial.println(F("OK fanchars started"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -527,8 +542,8 @@ void loop() {
|
||||
sensorValid[i] = sensors[i].valid;
|
||||
}
|
||||
const float minTemp = minValidTemperature();
|
||||
if (thermal.isStepResponseActive() && !isnan(minTemp)) {
|
||||
thermal.logStepResponseIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
|
||||
if (thermal.isFanCharacterizeActive() && !isnan(minTemp)) {
|
||||
thermal.logFanCharacterizeIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
|
||||
maxTemp, spread, now);
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user