630 lines
16 KiB
C++
630 lines
16 KiB
C++
#pragma once
|
|
|
|
#include <Arduino.h>
|
|
|
|
#include "config.h"
|
|
#include "fan_characterize.h"
|
|
#include "pid_autotuner.h"
|
|
#include "pid_controller.h"
|
|
#include "settings_store.h"
|
|
#include "tuning_store.h"
|
|
|
|
class ThermalController {
|
|
public:
|
|
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
|
|
|
|
ThermalController()
|
|
: heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
|
|
autotuner_(),
|
|
fanchars_(),
|
|
targetTempC_(TARGET_TEMP_C),
|
|
heaterDutyPercent_(0.0f),
|
|
heaterAllowancePercent_(100.0f),
|
|
cornerSpreadC_(0.0f),
|
|
lastMaxTempC_(0.0f),
|
|
regulatingFanPwm_(0),
|
|
stirFanPwm_(FAN_STIR_PWM),
|
|
fanManualPwm_(0),
|
|
fanPwm_(0),
|
|
tuningLoaded_(false),
|
|
fanIdleOverride_(false),
|
|
fanManualActive_(false),
|
|
sensorWarmValid_(false),
|
|
cutoffActive_(false),
|
|
failSafeActive_(true),
|
|
heaterCycleStartMs_(0),
|
|
lastHeaterUpdateMs_(0),
|
|
heaterOn_(false),
|
|
lastAvgTempC_(0.0f),
|
|
heaterBlock_(HeaterBlock::None),
|
|
fanTestActive_(false),
|
|
fanTestPwm_(0),
|
|
fanTestEndMs_(0) {}
|
|
|
|
void begin() {
|
|
pinMode(FAN_PIN, OUTPUT);
|
|
pinMode(HEATER_PIN, OUTPUT);
|
|
digitalWrite(HEATER_PIN, LOW);
|
|
writeFan(0);
|
|
|
|
heatPi_.setSetpoint(targetTempC_);
|
|
heatPi_.reset();
|
|
heaterCycleStartMs_ = millis();
|
|
lastHeaterUpdateMs_ = 0;
|
|
failSafeActive_ = true;
|
|
cornerSpreadC_ = 0.0f;
|
|
lastMaxTempC_ = 0.0f;
|
|
sensorWarmValid_ = false;
|
|
heaterAllowancePercent_ = 100.0f;
|
|
if (isIdle()) {
|
|
forceHeaterOff();
|
|
}
|
|
applyFan(millis());
|
|
|
|
TuningData stored;
|
|
if (tuningLoad(stored)) {
|
|
applyTuning(stored);
|
|
Serial.println(F("Loaded learned PI from EEPROM"));
|
|
printTuning();
|
|
}
|
|
|
|
SettingsData settings;
|
|
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);
|
|
tuningLoaded_ = true;
|
|
}
|
|
|
|
void clearTuning() {
|
|
tuningLoaded_ = false;
|
|
heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f);
|
|
tuningClear();
|
|
heatPi_.reset();
|
|
Serial.println(F("PI reset to defaults"));
|
|
}
|
|
|
|
void printTuning() const {
|
|
Serial.print(F("Heat PI Kp="));
|
|
Serial.print(heatPi_.kp(), 3);
|
|
Serial.print(F(" Ki="));
|
|
Serial.print(heatPi_.ki(), 4);
|
|
Serial.print(F(" tuned="));
|
|
Serial.println(tuningLoaded_ ? F("yes") : F("no"));
|
|
}
|
|
|
|
void saveTuningToEeprom() {
|
|
TuningData data;
|
|
data.magic = TUNING_MAGIC;
|
|
data.heatKp = heatPi_.kp();
|
|
data.heatKi = heatPi_.ki();
|
|
tuningSave(data);
|
|
tuningLoaded_ = true;
|
|
Serial.println(F("Saved PI to EEPROM"));
|
|
}
|
|
|
|
float heatKp() const { return heatPi_.kp(); }
|
|
float heatKi() const { return heatPi_.ki(); }
|
|
|
|
bool isTuningLoaded() const { return tuningLoaded_; }
|
|
|
|
bool startAutotune(float setpointC) {
|
|
if (autotuner_.isActive() || fanchars_.isActive()) {
|
|
return false;
|
|
}
|
|
cutoffActive_ = false;
|
|
heatPi_.reset();
|
|
return autotuner_.start(setpointC);
|
|
}
|
|
|
|
void stopAutotune() { autotuner_.abort(); }
|
|
|
|
bool isAutotuning() const { return autotuner_.isActive(); }
|
|
|
|
uint32_t autotuneElapsedMs(uint32_t nowMs) const { return autotuner_.elapsedMs(nowMs); }
|
|
|
|
const char *autotunePhaseName() const { return autotuner_.phaseName(); }
|
|
|
|
uint8_t autotuneCycleCount() const { return autotuner_.cycleCount(); }
|
|
|
|
uint8_t autotunePeriodCount() const { return autotuner_.periodCount(); }
|
|
|
|
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
|
|
|
|
bool startFanCharacterize(float maxCornerC, float avgTempC) {
|
|
if (autotuner_.isActive() || fanchars_.isActive()) {
|
|
return false;
|
|
}
|
|
stopFanTest();
|
|
cutoffActive_ = false;
|
|
heatPi_.reset();
|
|
setTarget(0.0f, false);
|
|
return fanchars_.start(maxCornerC, avgTempC);
|
|
}
|
|
|
|
void stopFanCharacterize() {
|
|
fanchars_.abort();
|
|
forceHeaterOff();
|
|
writeFan(0);
|
|
}
|
|
|
|
bool isFanCharacterizeActive() const { return fanchars_.isActive(); }
|
|
|
|
uint32_t fanCharacterizeElapsedMs(uint32_t nowMs) const { return fanchars_.elapsedMs(nowMs); }
|
|
|
|
const char *fanCharacterizePhaseName() const { return fanchars_.phaseName(); }
|
|
|
|
uint8_t fanCharacterizeProfileIndex() const { return fanchars_.profileIndex(); }
|
|
|
|
uint8_t fanCharacterizeProfileCount() const { return fanchars_.profileCount(); }
|
|
|
|
uint8_t fanCharacterizeFanPwm() const { return fanchars_.currentFanPwm(); }
|
|
|
|
bool isFanCharacterizeRefineRun() const { return fanchars_.isRefineRun(); }
|
|
|
|
float fanCharacterizeHeaterPct() const { return fanchars_.heaterPct(); }
|
|
|
|
uint8_t stirFanPwm() const { return stirFanPwm_; }
|
|
|
|
bool isFanManualOverride() const { return fanManualActive_ && !isIdle(); }
|
|
|
|
bool setRegulatingFanManual(uint8_t pwm) {
|
|
if (isIdle()) {
|
|
return false;
|
|
}
|
|
fanManualPwm_ = pwm;
|
|
fanManualActive_ = true;
|
|
return true;
|
|
}
|
|
|
|
void clearRegulatingFanManual() {
|
|
fanManualActive_ = false;
|
|
}
|
|
|
|
bool setStirFanPwm(uint8_t pwm, bool persist = true) {
|
|
if (pwm == 0) {
|
|
return false;
|
|
}
|
|
stirFanPwm_ = pwm;
|
|
if (persist) {
|
|
settingsSaveStirFan(pwm);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void logFanCharacterizeIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
|
float avgTempC, float minTempC, float maxTempC, float spreadC,
|
|
uint32_t nowMs) {
|
|
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;
|
|
}
|
|
|
|
heatPi_.setTunings(autotuner_.resultKp(), autotuner_.resultKi(), 0.0f);
|
|
heatPi_.reset();
|
|
|
|
TuningData data;
|
|
data.magic = TUNING_MAGIC;
|
|
data.heatKp = autotuner_.resultKp();
|
|
data.heatKi = autotuner_.resultKi();
|
|
tuningSave(data);
|
|
tuningLoaded_ = true;
|
|
autotuner_.reset();
|
|
Serial.println(F("Saved heat PI to EEPROM"));
|
|
return true;
|
|
}
|
|
|
|
void setTarget(float targetC, bool persist = true) {
|
|
targetTempC_ = targetC;
|
|
heatPi_.setSetpoint(targetC);
|
|
heatPi_.reset();
|
|
fanManualActive_ = false;
|
|
cutoffActive_ = false;
|
|
if (targetC > 0.0f) {
|
|
fanIdleOverride_ = false;
|
|
} else {
|
|
forceHeaterOff();
|
|
fanIdleOverride_ = false;
|
|
}
|
|
applyFan(millis());
|
|
if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) {
|
|
settingsSaveTarget(targetC);
|
|
}
|
|
}
|
|
|
|
void noteSensorMax(float maxTempC) {
|
|
lastMaxTempC_ = maxTempC;
|
|
sensorWarmValid_ = true;
|
|
}
|
|
|
|
bool setFanOff() {
|
|
if (!isIdle()) {
|
|
return false;
|
|
}
|
|
fanIdleOverride_ = false;
|
|
applyFan(millis());
|
|
return true;
|
|
}
|
|
|
|
void setFanIdle() {
|
|
if (!isIdle()) {
|
|
return;
|
|
}
|
|
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
|
return;
|
|
}
|
|
fanIdleOverride_ = true;
|
|
applyFan(millis());
|
|
}
|
|
|
|
bool isFanOff() const {
|
|
return isIdle() && sensorWarmValid_ && lastMaxTempC_ < IDLE_AUTO_FAN_OFF_TEMP_C &&
|
|
!fanIdleOverride_;
|
|
}
|
|
|
|
bool isIdleCooling() const {
|
|
return isIdle() &&
|
|
(!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C);
|
|
}
|
|
|
|
float target() const { return targetTempC_; }
|
|
|
|
bool isIdle() const { return targetTempC_ <= 0.0f; }
|
|
|
|
float emergencyCutoffC() const { return emergencyCutoffForTarget(targetTempC_); }
|
|
|
|
float cutoffThreshold() const {
|
|
if (isIdle()) {
|
|
return INFINITY;
|
|
}
|
|
return emergencyCutoffC();
|
|
}
|
|
|
|
bool isCutoffActive() const { return cutoffActive_; }
|
|
|
|
bool isFailSafeActive() const { return failSafeActive_; }
|
|
|
|
float heaterDutyPercent() const { return heaterDutyPercent_; }
|
|
|
|
float heaterAllowance() const { return heaterAllowancePercent_; }
|
|
|
|
float cornerSpread() const { return cornerSpreadC_; }
|
|
|
|
uint8_t fanPwm() const { return fanPwm_; }
|
|
|
|
bool isHeaterSsrOn() const { return heaterOn_; }
|
|
|
|
bool isFanTestActive(uint32_t nowMs) const {
|
|
return fanTestActive_ && nowMs < fanTestEndMs_;
|
|
}
|
|
|
|
bool startFanTest(uint8_t pwm, uint32_t nowMs, uint32_t durationMs = 15000) {
|
|
fanTestPwm_ = pwm;
|
|
fanTestEndMs_ = nowMs + durationMs;
|
|
fanTestActive_ = true;
|
|
writeFan(pwm);
|
|
return true;
|
|
}
|
|
|
|
void stopFanTest() { fanTestActive_ = false; }
|
|
|
|
float maxHeatStopAt(float avgTempC) const {
|
|
if (isIdle()) {
|
|
return INFINITY;
|
|
}
|
|
return maxHeatStopTemp(avgTempC);
|
|
}
|
|
|
|
const char *heaterBlockReason() const {
|
|
switch (heaterBlock_) {
|
|
case HeaterBlock::Cutoff:
|
|
return "cutoff";
|
|
case HeaterBlock::Corner:
|
|
return "corner";
|
|
case HeaterBlock::Autotune:
|
|
return "autotune";
|
|
case HeaterBlock::FanChars:
|
|
return "fanchars";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
const char *regulatingModeName() const {
|
|
return tuningLoaded_ ? "regulating" : "manual";
|
|
}
|
|
|
|
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
|
|
failSafeActive_ = false;
|
|
noteSensorMax(maxTempC);
|
|
lastAvgTempC_ = avgTempC;
|
|
heaterBlock_ = HeaterBlock::None;
|
|
|
|
cornerSpreadC_ =
|
|
SPREAD_EMA_ALPHA * cornerSpreadC_ +
|
|
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
|
|
|
|
if (fanchars_.isActive()) {
|
|
updateFanCharacterize(avgTempC, maxTempC, nowMs);
|
|
return;
|
|
}
|
|
|
|
if (autotuner_.isActive()) {
|
|
updateAutotune(avgTempC, maxTempC, nowMs);
|
|
return;
|
|
}
|
|
|
|
if (isIdle()) {
|
|
forceHeaterOff();
|
|
cutoffActive_ = false;
|
|
heatPi_.reset();
|
|
lastHeaterUpdateMs_ = nowMs;
|
|
applyFan(nowMs);
|
|
return;
|
|
}
|
|
|
|
updateRegulating(avgTempC, maxTempC, nowMs);
|
|
lastHeaterUpdateMs_ = nowMs;
|
|
applyHeaterBurst(nowMs);
|
|
writeFan(regulatingFanPwm_);
|
|
}
|
|
|
|
void enterFailSafe() {
|
|
failSafeActive_ = true;
|
|
cutoffActive_ = false;
|
|
forceHeaterOff();
|
|
applyFan(millis());
|
|
heatPi_.reset();
|
|
autotuner_.abort();
|
|
fanchars_.abort();
|
|
}
|
|
|
|
void forceHeaterOff() {
|
|
heaterDutyPercent_ = 0.0f;
|
|
heaterAllowancePercent_ = 0.0f;
|
|
heaterOn_ = false;
|
|
digitalWrite(HEATER_PIN, LOW);
|
|
}
|
|
|
|
void writeFan(uint8_t pwm) {
|
|
fanPwm_ = pwm;
|
|
pinMode(FAN_PIN, OUTPUT);
|
|
|
|
if (FAN_PWM_INVERT) {
|
|
if (pwm == 0) {
|
|
digitalWrite(FAN_PIN, HIGH);
|
|
return;
|
|
}
|
|
if (pwm >= 254) {
|
|
digitalWrite(FAN_PIN, LOW);
|
|
return;
|
|
}
|
|
analogWrite(FAN_PIN, static_cast<uint8_t>(255 - pwm));
|
|
return;
|
|
}
|
|
|
|
if (pwm == 0) {
|
|
digitalWrite(FAN_PIN, LOW);
|
|
return;
|
|
}
|
|
if (pwm >= 254) {
|
|
digitalWrite(FAN_PIN, HIGH);
|
|
return;
|
|
}
|
|
analogWrite(FAN_PIN, pwm);
|
|
}
|
|
|
|
private:
|
|
void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
|
|
float duty = 0.0f;
|
|
uint8_t fan = 0;
|
|
fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
|
|
|
heaterDutyPercent_ = duty;
|
|
heaterAllowancePercent_ = duty;
|
|
heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
|
|
applyHeaterBurst(nowMs);
|
|
writeFan(fan);
|
|
lastHeaterUpdateMs_ = nowMs;
|
|
}
|
|
|
|
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
|
|
float duty = 0.0f;
|
|
uint8_t fan = stirFanPwm_;
|
|
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
|
|
|
heaterDutyPercent_ = duty;
|
|
heaterAllowancePercent_ = duty;
|
|
heaterOn_ = duty >= 50.0f;
|
|
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
|
|
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
|
|
writeFan(fanManualActive_ ? fanManualPwm_ : stirFanPwm_);
|
|
lastHeaterUpdateMs_ = nowMs;
|
|
commitAutotuneIfDone();
|
|
}
|
|
|
|
void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
|
|
const float cutoffC = emergencyCutoffC();
|
|
if (maxTempC >= cutoffC) {
|
|
cutoffActive_ = true;
|
|
heaterDutyPercent_ = 0.0f;
|
|
heaterAllowancePercent_ = 0.0f;
|
|
regulatingFanPwm_ = FAN_MAX_PWM;
|
|
heaterBlock_ = HeaterBlock::Cutoff;
|
|
heatPi_.reset();
|
|
return;
|
|
}
|
|
|
|
if (cutoffActive_ && maxTempC < cutoffC - CUTOFF_RECOVERY_BAND_C) {
|
|
cutoffActive_ = false;
|
|
heatPi_.reset();
|
|
}
|
|
|
|
if (cutoffActive_) {
|
|
heaterBlock_ = HeaterBlock::Cutoff;
|
|
regulatingFanPwm_ = FAN_MAX_PWM;
|
|
return;
|
|
}
|
|
|
|
heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
|
|
|
|
float duty = heatPi_.compute(avgTempC, nowMs);
|
|
duty = clampPercent(duty);
|
|
if (duty > heaterAllowancePercent_) {
|
|
duty = heaterAllowancePercent_;
|
|
if (heaterAllowancePercent_ < 100.0f) {
|
|
heaterBlock_ = HeaterBlock::Corner;
|
|
}
|
|
}
|
|
heaterDutyPercent_ = duty;
|
|
|
|
regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
|
|
}
|
|
|
|
static float clampPercent(float value) {
|
|
if (value < 0.0f) {
|
|
return 0.0f;
|
|
}
|
|
if (value > 100.0f) {
|
|
return 100.0f;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
bool shouldLimitMaxCorner(float avgTempC) const {
|
|
return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C;
|
|
}
|
|
|
|
float maxHeatStopTemp(float avgTempC) const {
|
|
if (!shouldLimitMaxCorner(avgTempC)) {
|
|
return emergencyCutoffC();
|
|
}
|
|
return emergencyCutoffC() - CORNER_STOP_MARGIN_C;
|
|
}
|
|
|
|
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
|
|
const float cutoffC = emergencyCutoffC();
|
|
if (!shouldLimitMaxCorner(avgTempC)) {
|
|
// Heat-up: only taper when a hot corner nears the dynamic cutoff
|
|
if (maxTempC >= cutoffC - 3.0f) {
|
|
const float headroom = cutoffC - maxTempC;
|
|
return clampPercent((headroom / 3.0f) * 100.0f);
|
|
}
|
|
return 100.0f;
|
|
}
|
|
|
|
const float stopAt = maxHeatStopTemp(avgTempC);
|
|
if (maxTempC >= stopAt) {
|
|
return 0.0f;
|
|
}
|
|
|
|
const float headroom = stopAt - maxTempC;
|
|
if (headroom >= MAX_TEMP_HEADROOM_C) {
|
|
return 100.0f;
|
|
}
|
|
|
|
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
|
|
}
|
|
|
|
void applyHeaterBurst(uint32_t nowMs) {
|
|
if (heaterDutyPercent_ <= 0.0f) {
|
|
forceHeaterOff();
|
|
return;
|
|
}
|
|
|
|
if (nowMs - heaterCycleStartMs_ >= HEATER_CYCLE_MS) {
|
|
heaterCycleStartMs_ = nowMs;
|
|
}
|
|
|
|
const float onFraction = heaterDutyPercent_ / 100.0f;
|
|
const uint32_t onTimeMs = static_cast<uint32_t>(HEATER_CYCLE_MS * onFraction);
|
|
const bool shouldHeat = (nowMs - heaterCycleStartMs_) < onTimeMs;
|
|
|
|
if (shouldHeat != heaterOn_) {
|
|
heaterOn_ = shouldHeat;
|
|
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
|
|
}
|
|
}
|
|
|
|
void applyFan(uint32_t nowMs) {
|
|
if (fanTestActive_) {
|
|
if (nowMs < fanTestEndMs_) {
|
|
writeFan(fanTestPwm_);
|
|
return;
|
|
}
|
|
fanTestActive_ = false;
|
|
}
|
|
|
|
if (isIdle()) {
|
|
if (sensorWarmValid_ && lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
|
writeFan(FAN_MAX_PWM);
|
|
} else if (fanIdleOverride_) {
|
|
writeFan(FAN_IDLE_PWM);
|
|
} else {
|
|
writeFan(0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (failSafeActive_ || cutoffActive_) {
|
|
writeFan(FAN_MAX_PWM);
|
|
return;
|
|
}
|
|
}
|
|
|
|
PidController heatPi_;
|
|
PidAutotuner autotuner_;
|
|
FanCharacterize fanchars_;
|
|
float targetTempC_;
|
|
float heaterDutyPercent_;
|
|
float heaterAllowancePercent_;
|
|
float cornerSpreadC_;
|
|
float lastMaxTempC_;
|
|
uint8_t regulatingFanPwm_;
|
|
uint8_t stirFanPwm_;
|
|
uint8_t fanManualPwm_;
|
|
uint8_t fanPwm_;
|
|
bool tuningLoaded_;
|
|
bool fanIdleOverride_;
|
|
bool fanManualActive_;
|
|
bool sensorWarmValid_;
|
|
bool cutoffActive_;
|
|
bool failSafeActive_;
|
|
uint32_t heaterCycleStartMs_;
|
|
uint32_t lastHeaterUpdateMs_;
|
|
bool heaterOn_;
|
|
float lastAvgTempC_;
|
|
HeaterBlock heaterBlock_;
|
|
bool fanTestActive_;
|
|
uint8_t fanTestPwm_;
|
|
uint32_t fanTestEndMs_;
|
|
};
|