initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.pio
|
||||||
|
logs/
|
||||||
|
compile_commands.json
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"llvm-vs-code-extensions.vscode-clangd"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"platformio.platformio-ide",
|
||||||
|
"davidgomes.platformio-ide-cursor"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.h": "cpp",
|
||||||
|
"platformio.ini": "ini"
|
||||||
|
},
|
||||||
|
"clangd.arguments": [
|
||||||
|
"--compile-commands-dir=${workspaceFolder}",
|
||||||
|
"--header-insertion=never"
|
||||||
|
],
|
||||||
|
}
|
||||||
84
.vscode/tasks.json
vendored
Normal file
84
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "PlatformIO: Build",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${env:HOME}/.platformio/penv/bin/pio",
|
||||||
|
"args": ["run"],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"problemMatcher": "$gcc",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PlatformIO: Upload",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${env:HOME}/.platformio/penv/bin/pio",
|
||||||
|
"args": ["run", "--target", "upload"],
|
||||||
|
"group": "none",
|
||||||
|
"problemMatcher": "$gcc",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PlatformIO: Monitor",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${env:HOME}/.platformio/penv/bin/pio",
|
||||||
|
"args": ["device", "monitor"],
|
||||||
|
"group": "none",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PlatformIO: Update IntelliSense DB",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${env:HOME}/.platformio/penv/bin/pio",
|
||||||
|
"args": ["run", "-t", "compiledb"],
|
||||||
|
"group": "none",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "silent",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PlatformIO: Clean",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${env:HOME}/.platformio/penv/bin/pio",
|
||||||
|
"args": ["run", "--target", "clean"],
|
||||||
|
"group": "none",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PlatformIO: Upload and Monitor",
|
||||||
|
"dependsOn": ["PlatformIO: Upload"],
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${env:HOME}/.platformio/penv/bin/pio",
|
||||||
|
"args": ["device", "monitor"],
|
||||||
|
"group": "none",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
85
include/config.h
Normal file
85
include/config.h
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// I2C — Nano default: A4 (SDA), A5 (SCL). Wire handles pin assignment.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static const uint8_t TCA9548A_ADDRESS = 0x70;
|
||||||
|
|
||||||
|
// Four SHT31 sensors on TCA9548A channels 2, 3, 4, 5
|
||||||
|
static const uint8_t SENSOR_CHANNELS[] = {2, 3, 4, 5};
|
||||||
|
static const uint8_t SENSOR_COUNT = sizeof(SENSOR_CHANNELS) / sizeof(SENSOR_CHANNELS[0]);
|
||||||
|
|
||||||
|
// SHT31 I2C address (ADDR pin low → 0x44, high → 0x45)
|
||||||
|
static const uint8_t SHT31_ADDRESS = 0x44;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static const uint8_t FAN_PIN = 5; // D5 — 24 V fan via N-channel MOSFET
|
||||||
|
static const uint8_t HEATER_PIN = A2; // heater via solid-state relay
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Temperature control
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off)
|
||||||
|
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle
|
||||||
|
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed)
|
||||||
|
static const float TARGET_MAX_C = 80.0f;
|
||||||
|
static const float OVERTEMP_FRACTION = 0.05f; // hard cutoff at target * 1.05
|
||||||
|
|
||||||
|
// PID on chamber average
|
||||||
|
static const float PID_KP = 4.0f;
|
||||||
|
static const float PID_KI = 0.05f;
|
||||||
|
static const float PID_KD = 6.0f;
|
||||||
|
|
||||||
|
// Tiered heater cap — more power when cold, gentle near setpoint
|
||||||
|
static const float HEATER_MAX_DUTY_COLD = 65.0f; // avg >10 °C below target
|
||||||
|
static const float HEATER_MAX_DUTY_MID = 50.0f; // avg 3–10 °C below target
|
||||||
|
static const float HEATER_MAX_DUTY_NEAR = 42.0f; // avg <3 °C below target
|
||||||
|
static const float HEATER_COLD_BELOW_C = 10.0f;
|
||||||
|
static const float HEATER_WARM_BELOW_C = 3.0f;
|
||||||
|
|
||||||
|
// Ramp-up limit (% per second) — still caps sudden jumps
|
||||||
|
static const float HEATER_SLEW_UP_PER_S = 18.0f;
|
||||||
|
|
||||||
|
// Hot-corner limiter: taper heater as max corner approaches stop temperature
|
||||||
|
static const float MAX_TEMP_HEADROOM_C = 15.0f;
|
||||||
|
|
||||||
|
// Average-temp approach: taper only in the last few °C before setpoint
|
||||||
|
static const float APPROACH_BAND_C = 4.0f;
|
||||||
|
|
||||||
|
// When spread is good, allow hottest corner slightly above target so avg can reach setpoint
|
||||||
|
static const float GOOD_SPREAD_C = 5.0f;
|
||||||
|
static const float BALANCED_MAX_ABOVE_TARGET_C = 2.0f;
|
||||||
|
|
||||||
|
static const uint16_t HEATER_CYCLE_MS = 3000;
|
||||||
|
|
||||||
|
// Fan PWM (0–255)
|
||||||
|
static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on"
|
||||||
|
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; // idle: fans off when max corner below this
|
||||||
|
static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises
|
||||||
|
static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating
|
||||||
|
static const uint8_t FAN_HEAT_MAX_PWM = 140; // ~55 % — cap during heat-up
|
||||||
|
static const uint8_t FAN_MIX_MAX_PWM = 200; // ~78 % — cap for spread-driven mixing
|
||||||
|
static const uint8_t FAN_MAX_PWM = 255; // failsafe / over-temp only
|
||||||
|
|
||||||
|
// Corner mixing — moderate airflow; full speed reserved for safety
|
||||||
|
static const float SPREAD_DEADBAND_C = 0.5f;
|
||||||
|
static const float SPREAD_FULL_MIX_C = 8.0f;
|
||||||
|
static const float SPREAD_EMA_ALPHA = 0.45f;
|
||||||
|
|
||||||
|
// PID auto-tune (relay method) — run with: autotune 45
|
||||||
|
static const float AUTOTUNE_HYSTERESIS_C = 0.4f;
|
||||||
|
static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f;
|
||||||
|
static const float AUTOTUNE_PREHEAT_DUTY = 80.0f;
|
||||||
|
static const float AUTOTUNE_ABORT_ABOVE_C = 15.0f;
|
||||||
|
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6;
|
||||||
|
static const uint32_t AUTOTUNE_TIMEOUT_MS = 1800000UL;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static const uint32_t SENSOR_READ_INTERVAL_MS = 1000;
|
||||||
|
static const uint32_t CONTROL_INTERVAL_MS = 500;
|
||||||
|
static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000;
|
||||||
|
static const bool LOG_CSV_DEFAULT = false; // enable with serial command: log on
|
||||||
58
include/csv_logger.h
Normal file
58
include/csv_logger.h
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
#include "thermal_controller.h"
|
||||||
|
|
||||||
|
struct SensorReading {
|
||||||
|
float temperatureC = NAN;
|
||||||
|
float humidityPct = NAN;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
void printCsvHeader() {
|
||||||
|
Serial.println(
|
||||||
|
F("csv_hdr,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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void printCsvField(float value, uint8_t decimals) { Serial.print(value, decimals); }
|
||||||
|
|
||||||
|
void printCsvRow(uint32_t nowMs, const ThermalController &thermal, const SensorReading *sensors,
|
||||||
|
uint8_t sensorCount, float avgTemp, float minTemp, float maxTemp) {
|
||||||
|
Serial.print(F("csv,"));
|
||||||
|
Serial.print(nowMs);
|
||||||
|
Serial.print(',');
|
||||||
|
Serial.print(thermal.target(), 1);
|
||||||
|
Serial.print(',');
|
||||||
|
printCsvField(avgTemp, 2);
|
||||||
|
Serial.print(',');
|
||||||
|
printCsvField(minTemp, 2);
|
||||||
|
Serial.print(',');
|
||||||
|
printCsvField(maxTemp, 2);
|
||||||
|
Serial.print(',');
|
||||||
|
printCsvField(thermal.cornerSpread(), 2);
|
||||||
|
Serial.print(',');
|
||||||
|
Serial.print(thermal.heaterAllowance(), 0);
|
||||||
|
Serial.print(',');
|
||||||
|
printCsvField(thermal.heaterDutyPercent(), 1);
|
||||||
|
Serial.print(',');
|
||||||
|
Serial.print((thermal.fanPwm() * 100) / 255);
|
||||||
|
Serial.print(',');
|
||||||
|
Serial.print(thermal.isCutoffActive() ? 1 : 0);
|
||||||
|
Serial.print(',');
|
||||||
|
Serial.print(thermal.isFailSafeActive() ? 1 : 0);
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < sensorCount; ++i) {
|
||||||
|
Serial.print(',');
|
||||||
|
if (sensors[i].valid) {
|
||||||
|
printCsvField(sensors[i].temperatureC, 2);
|
||||||
|
Serial.print(',');
|
||||||
|
printCsvField(sensors[i].humidityPct, 1);
|
||||||
|
} else {
|
||||||
|
Serial.print(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println();
|
||||||
|
}
|
||||||
251
include/pid_autotuner.h
Normal file
251
include/pid_autotuner.h
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
class PidAutotuner {
|
||||||
|
public:
|
||||||
|
enum class Phase : uint8_t { Idle, Preheat, Relay, Done, Failed };
|
||||||
|
|
||||||
|
PidAutotuner()
|
||||||
|
: phase_(Phase::Idle),
|
||||||
|
setpointC_(AUTOTUNE_DEFAULT_TEMP_C),
|
||||||
|
relayHigh_(0.0f),
|
||||||
|
relayLow_(0.0f),
|
||||||
|
peakSinceCross_(0.0f),
|
||||||
|
valleySinceCross_(0.0f),
|
||||||
|
lastCrossMs_(0),
|
||||||
|
periodSumMs_(0),
|
||||||
|
periodCount_(0),
|
||||||
|
amplitudeSum_(0.0f),
|
||||||
|
amplitudeCount_(0),
|
||||||
|
spreadSum_(0.0f),
|
||||||
|
spreadSamples_(0),
|
||||||
|
cycleCount_(0),
|
||||||
|
aboveSetpoint_(false),
|
||||||
|
phaseStartMs_(0),
|
||||||
|
resultKp_(PID_KP),
|
||||||
|
resultKi_(PID_KI),
|
||||||
|
resultKd_(PID_KD),
|
||||||
|
resultFanMixMax_(FAN_MIX_MAX_PWM) {}
|
||||||
|
|
||||||
|
Phase phase() const { return phase_; }
|
||||||
|
|
||||||
|
bool isActive() const { return phase_ == Phase::Preheat || phase_ == Phase::Relay; }
|
||||||
|
|
||||||
|
bool start(float setpointC) {
|
||||||
|
if (setpointC < 25.0f || setpointC > TARGET_MAX_C) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setpointC_ = setpointC;
|
||||||
|
relayHigh_ = setpointC + AUTOTUNE_HYSTERESIS_C;
|
||||||
|
relayLow_ = setpointC - AUTOTUNE_HYSTERESIS_C;
|
||||||
|
resetMeasurements();
|
||||||
|
phase_ = Phase::Preheat;
|
||||||
|
phaseStartMs_ = millis();
|
||||||
|
Serial.print(F("autotune: preheat to "));
|
||||||
|
Serial.print(setpointC_, 1);
|
||||||
|
Serial.println(F("C"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void abort() {
|
||||||
|
if (phase_ == Phase::Preheat || phase_ == Phase::Relay) {
|
||||||
|
Serial.println(F("autotune: cancelled"));
|
||||||
|
}
|
||||||
|
phase_ = Phase::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() { phase_ = Phase::Idle; }
|
||||||
|
|
||||||
|
float setpoint() const { return setpointC_; }
|
||||||
|
|
||||||
|
float resultKp() const { return resultKp_; }
|
||||||
|
float resultKi() const { return resultKi_; }
|
||||||
|
float resultKd() const { return resultKd_; }
|
||||||
|
uint8_t resultFanMixMax() const { return resultFanMixMax_; }
|
||||||
|
|
||||||
|
Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
|
||||||
|
uint8_t &fanPwmOut) {
|
||||||
|
heaterDutyOut = 0.0f;
|
||||||
|
fanPwmOut = FAN_HEAT_MIN_PWM;
|
||||||
|
|
||||||
|
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
|
||||||
|
return phase_;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxTempC >= setpointC_ + AUTOTUNE_ABORT_ABOVE_C) {
|
||||||
|
fail(F("autotune: abort — temperature too high"));
|
||||||
|
return phase_;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - phaseStartMs_ > AUTOTUNE_TIMEOUT_MS) {
|
||||||
|
fail(F("autotune: abort — timeout"));
|
||||||
|
return phase_;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase_ == Phase::Preheat) {
|
||||||
|
if (avgTempC >= setpointC_ - AUTOTUNE_PREHEAT_BAND_C) {
|
||||||
|
enterRelay(avgTempC, nowMs);
|
||||||
|
} else {
|
||||||
|
heaterDutyOut = AUTOTUNE_PREHEAT_DUTY;
|
||||||
|
}
|
||||||
|
return phase_;
|
||||||
|
}
|
||||||
|
|
||||||
|
spreadSum_ += spreadC;
|
||||||
|
++spreadSamples_;
|
||||||
|
|
||||||
|
if (avgTempC > peakSinceCross_) {
|
||||||
|
peakSinceCross_ = avgTempC;
|
||||||
|
}
|
||||||
|
if (avgTempC < valleySinceCross_) {
|
||||||
|
valleySinceCross_ = avgTempC;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool heatOn = false;
|
||||||
|
if (avgTempC <= relayLow_) {
|
||||||
|
heatOn = true;
|
||||||
|
} else if (avgTempC >= relayHigh_) {
|
||||||
|
heatOn = false;
|
||||||
|
} else {
|
||||||
|
heatOn = !aboveSetpoint_;
|
||||||
|
}
|
||||||
|
heaterDutyOut = heatOn ? 100.0f : 0.0f;
|
||||||
|
|
||||||
|
const bool nowAbove = avgTempC >= setpointC_;
|
||||||
|
if (nowAbove != aboveSetpoint_) {
|
||||||
|
onSetpointCrossing(nowMs);
|
||||||
|
aboveSetpoint_ = nowAbove;
|
||||||
|
}
|
||||||
|
|
||||||
|
return phase_;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void enterRelay(float avgTempC, uint32_t nowMs) {
|
||||||
|
phase_ = Phase::Relay;
|
||||||
|
phaseStartMs_ = nowMs;
|
||||||
|
aboveSetpoint_ = avgTempC >= setpointC_;
|
||||||
|
peakSinceCross_ = avgTempC;
|
||||||
|
valleySinceCross_ = avgTempC;
|
||||||
|
lastCrossMs_ = 0;
|
||||||
|
Serial.println(F("autotune: relay test started"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetMeasurements() {
|
||||||
|
peakSinceCross_ = 0.0f;
|
||||||
|
valleySinceCross_ = 0.0f;
|
||||||
|
lastCrossMs_ = 0;
|
||||||
|
periodSumMs_ = 0;
|
||||||
|
periodCount_ = 0;
|
||||||
|
amplitudeSum_ = 0.0f;
|
||||||
|
amplitudeCount_ = 0;
|
||||||
|
spreadSum_ = 0.0f;
|
||||||
|
spreadSamples_ = 0;
|
||||||
|
cycleCount_ = 0;
|
||||||
|
aboveSetpoint_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSetpointCrossing(uint32_t nowMs) {
|
||||||
|
const float amplitude = peakSinceCross_ - valleySinceCross_;
|
||||||
|
if (amplitude >= 0.3f) {
|
||||||
|
amplitudeSum_ += amplitude;
|
||||||
|
++amplitudeCount_;
|
||||||
|
++cycleCount_;
|
||||||
|
|
||||||
|
Serial.print(F("autotune: cycle "));
|
||||||
|
Serial.print(cycleCount_);
|
||||||
|
Serial.print(F(" amp="));
|
||||||
|
Serial.println(amplitude, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCrossMs_ > 0) {
|
||||||
|
const uint32_t period = nowMs - lastCrossMs_;
|
||||||
|
if (period > 8000 && period < 900000) {
|
||||||
|
periodSumMs_ += period;
|
||||||
|
++periodCount_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCrossMs_ = nowMs;
|
||||||
|
peakSinceCross_ = valleySinceCross_;
|
||||||
|
|
||||||
|
if (cycleCount_ >= AUTOTUNE_CYCLES_REQUIRED && periodCount_ >= 3 && amplitudeCount_ >= 3) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void finish() {
|
||||||
|
const float avgPeriodSec =
|
||||||
|
static_cast<float>(periodSumMs_ / periodCount_) / 1000.0f;
|
||||||
|
const float avgAmplitude = amplitudeSum_ / static_cast<float>(amplitudeCount_);
|
||||||
|
|
||||||
|
if (avgAmplitude < 0.3f || avgPeriodSec < 8.0f) {
|
||||||
|
fail(F("autotune: failed — oscillation too small"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float ku = (4.0f * 100.0f) / (PI * avgAmplitude);
|
||||||
|
resultKp_ = 0.45f * ku;
|
||||||
|
resultKi_ = resultKp_ / (2.2f * avgPeriodSec);
|
||||||
|
resultKd_ = resultKp_ * avgPeriodSec / 6.3f;
|
||||||
|
|
||||||
|
if (resultKp_ < 0.5f) {
|
||||||
|
resultKp_ = 0.5f;
|
||||||
|
}
|
||||||
|
if (resultKi_ > resultKp_ / 3.0f) {
|
||||||
|
resultKi_ = resultKp_ / 3.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultFanMixMax_ = FAN_MIX_MAX_PWM;
|
||||||
|
const float spreadAvg =
|
||||||
|
spreadSamples_ > 0 ? spreadSum_ / static_cast<float>(spreadSamples_) : GOOD_SPREAD_C;
|
||||||
|
if (spreadAvg > GOOD_SPREAD_C) {
|
||||||
|
const float boost = 1.0f + ((spreadAvg - GOOD_SPREAD_C) / 10.0f);
|
||||||
|
int boosted = static_cast<int>(static_cast<float>(FAN_MIX_MAX_PWM) * boost);
|
||||||
|
if (boosted > static_cast<int>(FAN_MAX_PWM) - 20) {
|
||||||
|
boosted = FAN_MAX_PWM - 20;
|
||||||
|
}
|
||||||
|
resultFanMixMax_ = static_cast<uint8_t>(boosted);
|
||||||
|
}
|
||||||
|
|
||||||
|
phase_ = Phase::Done;
|
||||||
|
Serial.println(F("autotune: done"));
|
||||||
|
Serial.print(F(" Kp="));
|
||||||
|
Serial.print(resultKp_, 3);
|
||||||
|
Serial.print(F(" Ki="));
|
||||||
|
Serial.print(resultKi_, 4);
|
||||||
|
Serial.print(F(" Kd="));
|
||||||
|
Serial.print(resultKd_, 3);
|
||||||
|
Serial.print(F(" fanMixMax="));
|
||||||
|
Serial.println(resultFanMixMax_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fail(const __FlashStringHelper *reason) {
|
||||||
|
Serial.println(reason);
|
||||||
|
phase_ = Phase::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Phase phase_;
|
||||||
|
float setpointC_;
|
||||||
|
float relayHigh_;
|
||||||
|
float relayLow_;
|
||||||
|
float peakSinceCross_;
|
||||||
|
float valleySinceCross_;
|
||||||
|
uint32_t lastCrossMs_;
|
||||||
|
uint32_t periodSumMs_;
|
||||||
|
uint8_t periodCount_;
|
||||||
|
float amplitudeSum_;
|
||||||
|
uint8_t amplitudeCount_;
|
||||||
|
float spreadSum_;
|
||||||
|
uint16_t spreadSamples_;
|
||||||
|
uint8_t cycleCount_;
|
||||||
|
bool aboveSetpoint_;
|
||||||
|
uint32_t phaseStartMs_;
|
||||||
|
float resultKp_;
|
||||||
|
float resultKi_;
|
||||||
|
float resultKd_;
|
||||||
|
uint8_t resultFanMixMax_;
|
||||||
|
};
|
||||||
83
include/pid_controller.h
Normal file
83
include/pid_controller.h
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class PidController {
|
||||||
|
public:
|
||||||
|
PidController(float kp, float ki, float kd, float outputMin, float outputMax)
|
||||||
|
: kp_(kp),
|
||||||
|
ki_(ki),
|
||||||
|
kd_(kd),
|
||||||
|
outputMin_(outputMin),
|
||||||
|
outputMax_(outputMax) {}
|
||||||
|
|
||||||
|
void setTunings(float kp, float ki, float kd) {
|
||||||
|
kp_ = kp;
|
||||||
|
ki_ = ki;
|
||||||
|
kd_ = kd;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSetpoint(float setpoint) { setpoint_ = setpoint; }
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
integral_ = 0.0f;
|
||||||
|
prevInput_ = 0.0f;
|
||||||
|
firstSample_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
float compute(float input, uint32_t nowMs) {
|
||||||
|
if (firstSample_) {
|
||||||
|
prevInput_ = input;
|
||||||
|
prevTimeMs_ = nowMs;
|
||||||
|
firstSample_ = false;
|
||||||
|
return outputMin_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float dt = static_cast<float>(nowMs - prevTimeMs_) / 1000.0f;
|
||||||
|
if (dt <= 0.0f) {
|
||||||
|
return lastOutput_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float error = setpoint_ - input;
|
||||||
|
integral_ += error * dt;
|
||||||
|
|
||||||
|
// Anti-windup: clamp integral so output cannot exceed limits
|
||||||
|
const float integralMax = (outputMax_ - outputMin_) / (ki_ > 0.0f ? ki_ : 1.0f);
|
||||||
|
if (integral_ > integralMax) {
|
||||||
|
integral_ = integralMax;
|
||||||
|
} else if (integral_ < 0.0f) {
|
||||||
|
integral_ = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float derivative = (input - prevInput_) / dt;
|
||||||
|
float output = kp_ * error + ki_ * integral_ - kd_ * derivative;
|
||||||
|
|
||||||
|
if (output < outputMin_) {
|
||||||
|
output = outputMin_;
|
||||||
|
} else if (output > outputMax_) {
|
||||||
|
output = outputMax_;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevInput_ = input;
|
||||||
|
prevTimeMs_ = nowMs;
|
||||||
|
lastOutput_ = output;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
float lastOutput() const { return lastOutput_; }
|
||||||
|
|
||||||
|
float kp() const { return kp_; }
|
||||||
|
float ki() const { return ki_; }
|
||||||
|
float kd() const { return kd_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
float kp_;
|
||||||
|
float ki_;
|
||||||
|
float kd_;
|
||||||
|
float outputMin_;
|
||||||
|
float outputMax_;
|
||||||
|
float setpoint_ = 0.0f;
|
||||||
|
float integral_ = 0.0f;
|
||||||
|
float prevInput_ = 0.0f;
|
||||||
|
float lastOutput_ = 0.0f;
|
||||||
|
uint32_t prevTimeMs_ = 0;
|
||||||
|
bool firstSample_ = true;
|
||||||
|
};
|
||||||
44
include/tca9548a.h
Normal file
44
include/tca9548a.h
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Wire.h>
|
||||||
|
|
||||||
|
class Tca9548a {
|
||||||
|
public:
|
||||||
|
explicit Tca9548a(uint8_t address) : address_(address), activeChannel_(0xFF) {}
|
||||||
|
|
||||||
|
bool begin() {
|
||||||
|
Wire.beginTransmission(address_);
|
||||||
|
return Wire.endTransmission() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool selectChannel(uint8_t channel) {
|
||||||
|
if (channel > 7) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t mask = static_cast<uint8_t>(1u << channel);
|
||||||
|
if (mask == activeChannel_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Wire.beginTransmission(address_);
|
||||||
|
Wire.write(mask);
|
||||||
|
if (Wire.endTransmission() != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeChannel_ = mask;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void disableAll() {
|
||||||
|
Wire.beginTransmission(address_);
|
||||||
|
Wire.write(0x00);
|
||||||
|
Wire.endTransmission();
|
||||||
|
activeChannel_ = 0x00;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint8_t address_;
|
||||||
|
uint8_t activeChannel_;
|
||||||
|
};
|
||||||
536
include/thermal_controller.h
Normal file
536
include/thermal_controller.h
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "pid_autotuner.h"
|
||||||
|
#include "pid_controller.h"
|
||||||
|
#include "tuning_store.h"
|
||||||
|
|
||||||
|
class ThermalController {
|
||||||
|
public:
|
||||||
|
ThermalController()
|
||||||
|
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f),
|
||||||
|
autotuner_(),
|
||||||
|
targetTempC_(TARGET_TEMP_C),
|
||||||
|
heaterDutyPercent_(0.0f),
|
||||||
|
heaterAllowancePercent_(100.0f),
|
||||||
|
cornerSpreadC_(0.0f),
|
||||||
|
lastMaxTempC_(0.0f),
|
||||||
|
fanPwm_(FAN_MAX_PWM),
|
||||||
|
fanMixMax_(FAN_MIX_MAX_PWM),
|
||||||
|
adaptiveEnabled_(false),
|
||||||
|
fanIdleOverride_(false),
|
||||||
|
sensorWarmValid_(false),
|
||||||
|
cutoffActive_(false),
|
||||||
|
failSafeActive_(true),
|
||||||
|
heaterCycleStartMs_(0),
|
||||||
|
lastHeaterUpdateMs_(0),
|
||||||
|
heaterOn_(false) {}
|
||||||
|
|
||||||
|
void begin() {
|
||||||
|
pinMode(FAN_PIN, OUTPUT);
|
||||||
|
pinMode(HEATER_PIN, OUTPUT);
|
||||||
|
digitalWrite(HEATER_PIN, LOW);
|
||||||
|
|
||||||
|
pid_.setSetpoint(targetTempC_);
|
||||||
|
pid_.reset();
|
||||||
|
heaterCycleStartMs_ = millis();
|
||||||
|
lastHeaterUpdateMs_ = 0;
|
||||||
|
failSafeActive_ = true;
|
||||||
|
fanPwm_ = FAN_MAX_PWM;
|
||||||
|
cornerSpreadC_ = 0.0f;
|
||||||
|
lastMaxTempC_ = 0.0f;
|
||||||
|
sensorWarmValid_ = false;
|
||||||
|
heaterAllowancePercent_ = 100.0f;
|
||||||
|
if (isIdle()) {
|
||||||
|
forceHeaterOff();
|
||||||
|
}
|
||||||
|
applyFan();
|
||||||
|
|
||||||
|
TuningData stored;
|
||||||
|
if (tuningLoad(stored)) {
|
||||||
|
applyTuning(stored);
|
||||||
|
Serial.println(F("Loaded learned PID from EEPROM"));
|
||||||
|
printTuning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyTuning(const TuningData &data) {
|
||||||
|
pid_.setTunings(data.kp, data.ki, data.kd);
|
||||||
|
fanMixMax_ = data.fanMixMax;
|
||||||
|
adaptiveEnabled_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearTuning() {
|
||||||
|
adaptiveEnabled_ = false;
|
||||||
|
fanMixMax_ = FAN_MIX_MAX_PWM;
|
||||||
|
pid_.setTunings(PID_KP, PID_KI, PID_KD);
|
||||||
|
tuningClear();
|
||||||
|
pid_.reset();
|
||||||
|
Serial.println(F("PID reset to defaults"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void printTuning() const {
|
||||||
|
Serial.print(F("PID Kp="));
|
||||||
|
Serial.print(pidKp(), 3);
|
||||||
|
Serial.print(F(" Ki="));
|
||||||
|
Serial.print(pidKi(), 4);
|
||||||
|
Serial.print(F(" Kd="));
|
||||||
|
Serial.print(pidKd(), 3);
|
||||||
|
Serial.print(F(" fanMixMax="));
|
||||||
|
Serial.print(fanMixMax_);
|
||||||
|
Serial.print(F(" adaptive="));
|
||||||
|
Serial.println(adaptiveEnabled_ ? F("yes") : F("no"));
|
||||||
|
}
|
||||||
|
|
||||||
|
float pidKp() const { return pid_.kp(); }
|
||||||
|
float pidKi() const { return pid_.ki(); }
|
||||||
|
float pidKd() const { return pid_.kd(); }
|
||||||
|
|
||||||
|
bool isAdaptive() const { return adaptiveEnabled_; }
|
||||||
|
|
||||||
|
bool startAutotune(float setpointC) {
|
||||||
|
if (autotuner_.isActive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
adaptiveEnabled_ = false;
|
||||||
|
cutoffActive_ = false;
|
||||||
|
pid_.reset();
|
||||||
|
return autotuner_.start(setpointC);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopAutotune() { autotuner_.abort(); }
|
||||||
|
|
||||||
|
bool isAutotuning() const { return autotuner_.isActive(); }
|
||||||
|
|
||||||
|
bool commitAutotuneIfDone() {
|
||||||
|
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TuningData data;
|
||||||
|
data.magic = TUNING_MAGIC;
|
||||||
|
data.kp = autotuner_.resultKp();
|
||||||
|
data.ki = autotuner_.resultKi();
|
||||||
|
data.kd = autotuner_.resultKd();
|
||||||
|
data.fanMixMax = autotuner_.resultFanMixMax();
|
||||||
|
tuningSave(data);
|
||||||
|
applyTuning(data);
|
||||||
|
autotuner_.reset();
|
||||||
|
Serial.println(F("Saved learned PID to EEPROM"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTarget(float targetC) {
|
||||||
|
targetTempC_ = targetC;
|
||||||
|
pid_.setSetpoint(targetC);
|
||||||
|
pid_.reset();
|
||||||
|
cutoffActive_ = false;
|
||||||
|
if (targetC > 0.0f) {
|
||||||
|
fanIdleOverride_ = false;
|
||||||
|
} else {
|
||||||
|
forceHeaterOff();
|
||||||
|
fanIdleOverride_ = false;
|
||||||
|
}
|
||||||
|
applyFan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void noteSensorMax(float maxTempC) {
|
||||||
|
lastMaxTempC_ = maxTempC;
|
||||||
|
sensorWarmValid_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool setFanOff() {
|
||||||
|
if (!isIdle()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fanIdleOverride_ = false;
|
||||||
|
applyFan();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFanIdle() {
|
||||||
|
if (!isIdle()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fanIdleOverride_ = true;
|
||||||
|
applyFan();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cutoffThreshold() const {
|
||||||
|
if (isIdle()) {
|
||||||
|
return INFINITY;
|
||||||
|
}
|
||||||
|
return targetTempC_ * (1.0f + OVERTEMP_FRACTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_; }
|
||||||
|
|
||||||
|
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
|
||||||
|
failSafeActive_ = false;
|
||||||
|
noteSensorMax(maxTempC);
|
||||||
|
|
||||||
|
cornerSpreadC_ =
|
||||||
|
SPREAD_EMA_ALPHA * cornerSpreadC +
|
||||||
|
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_;
|
||||||
|
|
||||||
|
if (autotuner_.isActive()) {
|
||||||
|
updateAutotune(avgTempC, maxTempC, nowMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIdle()) {
|
||||||
|
forceHeaterOff();
|
||||||
|
cutoffActive_ = false;
|
||||||
|
pid_.reset();
|
||||||
|
lastHeaterUpdateMs_ = nowMs;
|
||||||
|
applyFan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adaptiveEnabled_) {
|
||||||
|
updateAdaptive(avgTempC, maxTempC, nowMs);
|
||||||
|
} else {
|
||||||
|
updateLegacy(avgTempC, maxTempC, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastHeaterUpdateMs_ = nowMs;
|
||||||
|
applyHeaterBurst(nowMs);
|
||||||
|
applyFan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void enterFailSafe() {
|
||||||
|
failSafeActive_ = true;
|
||||||
|
cutoffActive_ = false;
|
||||||
|
forceHeaterOff();
|
||||||
|
applyFan();
|
||||||
|
pid_.reset();
|
||||||
|
autotuner_.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
void forceHeaterOff() {
|
||||||
|
heaterDutyPercent_ = 0.0f;
|
||||||
|
heaterAllowancePercent_ = 0.0f;
|
||||||
|
heaterOn_ = false;
|
||||||
|
digitalWrite(HEATER_PIN, LOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeFan(uint8_t pwm) {
|
||||||
|
fanPwm_ = pwm;
|
||||||
|
if (pwm == 0) {
|
||||||
|
// Re-assert output and stop Timer0 PWM on D5 — analogWrite(0) can leave the pin driving
|
||||||
|
pinMode(FAN_PIN, OUTPUT);
|
||||||
|
digitalWrite(FAN_PIN, LOW);
|
||||||
|
} else {
|
||||||
|
analogWrite(FAN_PIN, pwm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||||
|
float duty = 0.0f;
|
||||||
|
uint8_t fan = FAN_HEAT_MIN_PWM;
|
||||||
|
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
||||||
|
|
||||||
|
heaterDutyPercent_ = duty;
|
||||||
|
heaterAllowancePercent_ = duty;
|
||||||
|
heaterOn_ = duty >= 50.0f;
|
||||||
|
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
|
||||||
|
writeFan(fan);
|
||||||
|
lastHeaterUpdateMs_ = nowMs;
|
||||||
|
commitAutotuneIfDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateAdaptive(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||||
|
const float cutoff = cutoffThreshold();
|
||||||
|
|
||||||
|
if (maxTempC >= cutoff) {
|
||||||
|
cutoffActive_ = true;
|
||||||
|
heaterDutyPercent_ = 0.0f;
|
||||||
|
heaterAllowancePercent_ = 0.0f;
|
||||||
|
heaterOn_ = false;
|
||||||
|
pid_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutoffActive_ && maxTempC <= targetTempC_) {
|
||||||
|
cutoffActive_ = false;
|
||||||
|
pid_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutoffActive_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float maxHeatStopC = maxHeatStopTemp(avgTempC);
|
||||||
|
if (maxTempC >= maxHeatStopC) {
|
||||||
|
heaterDutyPercent_ = 0.0f;
|
||||||
|
heaterAllowancePercent_ = 0.0f;
|
||||||
|
pid_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float duty = pid_.compute(avgTempC, nowMs);
|
||||||
|
const float maxDuty = heaterMaxDuty(avgTempC);
|
||||||
|
if (duty > maxDuty) {
|
||||||
|
duty = maxDuty;
|
||||||
|
}
|
||||||
|
heaterAllowancePercent_ = maxDuty;
|
||||||
|
heaterDutyPercent_ = duty;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateLegacy(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||||
|
const float cutoff = cutoffThreshold();
|
||||||
|
|
||||||
|
if (maxTempC >= cutoff) {
|
||||||
|
cutoffActive_ = true;
|
||||||
|
heaterDutyPercent_ = 0.0f;
|
||||||
|
heaterAllowancePercent_ = 0.0f;
|
||||||
|
heaterOn_ = false;
|
||||||
|
pid_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutoffActive_ && maxTempC <= targetTempC_) {
|
||||||
|
cutoffActive_ = false;
|
||||||
|
pid_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutoffActive_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float maxHeatStopC = maxHeatStopTemp(avgTempC);
|
||||||
|
if (maxTempC >= maxHeatStopC) {
|
||||||
|
heaterDutyPercent_ = 0.0f;
|
||||||
|
heaterAllowancePercent_ = 0.0f;
|
||||||
|
pid_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float pidOut = pid_.compute(avgTempC, nowMs);
|
||||||
|
heaterAllowancePercent_ = heaterAllowancePercent(avgTempC, maxTempC);
|
||||||
|
float duty = pidOut;
|
||||||
|
if (duty > heaterAllowancePercent_) {
|
||||||
|
duty = heaterAllowancePercent_;
|
||||||
|
}
|
||||||
|
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
static float clampPercent(float value) {
|
||||||
|
if (value < 0.0f) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
if (value > 100.0f) {
|
||||||
|
return 100.0f;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; }
|
||||||
|
|
||||||
|
float maxHeatStopTemp(float avgTempC) const {
|
||||||
|
if (isBalancedChamber() && avgTempC < targetTempC_) {
|
||||||
|
return targetTempC_ + BALANCED_MAX_ABOVE_TARGET_C;
|
||||||
|
}
|
||||||
|
return targetTempC_;
|
||||||
|
}
|
||||||
|
|
||||||
|
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
|
||||||
|
if (isBalancedChamber() && avgTempC < targetTempC_) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
float allowanceFromAverage(float avgTempC) const {
|
||||||
|
if (avgTempC >= targetTempC_) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float below = targetTempC_ - avgTempC;
|
||||||
|
if (below >= APPROACH_BAND_C) {
|
||||||
|
return 100.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampPercent((below / APPROACH_BAND_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 heaterAllowancePercent(float avgTempC, float maxTempC) const {
|
||||||
|
const float fromMax = allowanceFromMaxCorner(maxTempC, avgTempC);
|
||||||
|
const float fromAvg = allowanceFromAverage(avgTempC);
|
||||||
|
float allowance = fromMax < fromAvg ? fromMax : fromAvg;
|
||||||
|
const float maxDuty = heaterMaxDuty(avgTempC);
|
||||||
|
if (allowance > maxDuty) {
|
||||||
|
allowance = maxDuty;
|
||||||
|
}
|
||||||
|
return allowance;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t fanPwmForHeaterDemand() const {
|
||||||
|
if (heaterDutyPercent_ <= 0.0f) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM;
|
||||||
|
return FAN_HEAT_MIN_PWM +
|
||||||
|
static_cast<uint8_t>((heaterDutyPercent_ / 100.0f) * static_cast<float>(span));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t fanPwmForCornerSpread() const {
|
||||||
|
if (cornerSpreadC_ <= SPREAD_DEADBAND_C) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
float spread = cornerSpreadC_;
|
||||||
|
if (spread > SPREAD_FULL_MIX_C) {
|
||||||
|
spread = SPREAD_FULL_MIX_C;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float t =
|
||||||
|
(spread - SPREAD_DEADBAND_C) / (SPREAD_FULL_MIX_C - SPREAD_DEADBAND_C);
|
||||||
|
const uint8_t mixMax = fanMixMax_;
|
||||||
|
const uint8_t mixMin = FAN_MIX_MIN_PWM;
|
||||||
|
const uint8_t span = mixMax > mixMin ? mixMax - mixMin : 0;
|
||||||
|
return mixMin + static_cast<uint8_t>(t * static_cast<float>(span));
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t duty = fanPwmForHeaterDemand();
|
||||||
|
const uint8_t mixFan = fanPwmForCornerSpread();
|
||||||
|
if (mixFan > duty) {
|
||||||
|
duty = mixFan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMaxTempC_ > targetTempC_ && duty < FAN_MAX_PWM) {
|
||||||
|
duty = FAN_MAX_PWM;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFan(duty);
|
||||||
|
}
|
||||||
|
|
||||||
|
PidController pid_;
|
||||||
|
PidAutotuner autotuner_;
|
||||||
|
float targetTempC_;
|
||||||
|
float heaterDutyPercent_;
|
||||||
|
float heaterAllowancePercent_;
|
||||||
|
float cornerSpreadC_;
|
||||||
|
float lastMaxTempC_;
|
||||||
|
uint8_t fanPwm_;
|
||||||
|
uint8_t fanMixMax_;
|
||||||
|
bool adaptiveEnabled_;
|
||||||
|
bool fanIdleOverride_;
|
||||||
|
bool sensorWarmValid_;
|
||||||
|
bool cutoffActive_;
|
||||||
|
bool failSafeActive_;
|
||||||
|
uint32_t heaterCycleStartMs_;
|
||||||
|
uint32_t lastHeaterUpdateMs_;
|
||||||
|
bool heaterOn_;
|
||||||
|
};
|
||||||
45
include/tuning_store.h
Normal file
45
include/tuning_store.h
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <EEPROM.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
static const uint16_t TUNING_MAGIC = 0xDA7A;
|
||||||
|
static const int TUNING_EEPROM_ADDR = 0;
|
||||||
|
|
||||||
|
struct TuningData {
|
||||||
|
uint16_t magic = 0;
|
||||||
|
float kp = PID_KP;
|
||||||
|
float ki = PID_KI;
|
||||||
|
float kd = PID_KD;
|
||||||
|
uint8_t fanMixMax = FAN_MIX_MAX_PWM;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline uint8_t tuningChecksum(const TuningData &data) {
|
||||||
|
const uint8_t *bytes = reinterpret_cast<const uint8_t *>(&data);
|
||||||
|
uint8_t sum = 0;
|
||||||
|
for (uint8_t i = 0; i < sizeof(TuningData) - 1; ++i) {
|
||||||
|
sum ^= bytes[i];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool tuningLoad(TuningData &out) {
|
||||||
|
EEPROM.get(TUNING_EEPROM_ADDR, out);
|
||||||
|
const uint8_t stored = EEPROM.read(TUNING_EEPROM_ADDR + sizeof(TuningData));
|
||||||
|
if (out.magic != TUNING_MAGIC) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return tuningChecksum(out) == stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void tuningSave(const TuningData &data) {
|
||||||
|
EEPROM.put(TUNING_EEPROM_ADDR, data);
|
||||||
|
EEPROM.write(TUNING_EEPROM_ADDR + sizeof(TuningData), tuningChecksum(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void tuningClear() {
|
||||||
|
TuningData cleared;
|
||||||
|
tuningSave(cleared);
|
||||||
|
}
|
||||||
9
platformio.ini
Normal file
9
platformio.ini
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
; Voron filament dryer — Arduino Nano (ATmega328P)
|
||||||
|
[env:nanoatmega328]
|
||||||
|
platform = atmelavr
|
||||||
|
board = nanoatmega328
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps =
|
||||||
|
adafruit/Adafruit SHT31 Library@^2.2.2
|
||||||
|
adafruit/Adafruit BusIO@^1.16.1
|
||||||
147
scripts/capture_csv.py
Executable file
147
scripts/capture_csv.py
Executable file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture filament dryer CSV lines from serial into a file.
|
||||||
|
|
||||||
|
Designed for a Raspberry Pi attached to the dryer Nano over USB. The Arduino
|
||||||
|
still owns sensors and control; this script only records the csv,* stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FALLBACK_HEADER = (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_serial_port() -> str | None:
|
||||||
|
by_id = Path("/dev/serial/by-id")
|
||||||
|
if by_id.is_dir():
|
||||||
|
patterns = ("*Arduino*", "*arduino*", "*2341*", "*1a86*", "*CH340*", "*ch340*")
|
||||||
|
for pattern in patterns:
|
||||||
|
matches = sorted(by_id.glob(pattern))
|
||||||
|
if matches:
|
||||||
|
return str(matches[0])
|
||||||
|
for candidate in ("/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyUSB0", "/dev/ttyUSB1"):
|
||||||
|
if Path(candidate).exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def enable_dryer_logging(ser, retries: int = 3) -> None:
|
||||||
|
for attempt in range(retries):
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.write(b"log on\n")
|
||||||
|
ser.flush()
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
raw = ser.readline()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if line == "OK csv logging on" or line.startswith("csv_hdr,"):
|
||||||
|
return
|
||||||
|
if line.startswith("csv,"):
|
||||||
|
return
|
||||||
|
time.sleep(0.5 * (attempt + 1))
|
||||||
|
print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"-p",
|
||||||
|
"--port",
|
||||||
|
help="Serial port (default: auto-detect on Pi, else /dev/ttyUSB0)",
|
||||||
|
)
|
||||||
|
parser.add_argument("-b", "--baud", type=int, default=115200)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
help="Output CSV file (default: logs/dryer_YYYYMMDD_HHMMSS.csv)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("logs"),
|
||||||
|
help="Directory for default timestamped log files",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--auto-log-on",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
default=True,
|
||||||
|
help="Send 'log on' to the dryer after connect (default: on)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
port = args.port
|
||||||
|
if port is None:
|
||||||
|
port = detect_serial_port()
|
||||||
|
if port is None:
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
print(
|
||||||
|
f"WARN: no serial device found, using {port}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
print("Install pyserial: pip install pyserial", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
out = args.output
|
||||||
|
if out is None:
|
||||||
|
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Logging {port} -> {out}", file=sys.stderr)
|
||||||
|
if args.auto_log_on:
|
||||||
|
print("Will send 'log on' after connect", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("Send 'log on' to the dryer if CSV rows are not appearing", file=sys.stderr)
|
||||||
|
|
||||||
|
header_written = False
|
||||||
|
with serial.Serial(port, args.baud, timeout=1) as ser, out.open("w", encoding="utf-8") as fh:
|
||||||
|
time.sleep(2.0) # allow Nano reset after USB open
|
||||||
|
if args.auto_log_on:
|
||||||
|
enable_dryer_logging(ser)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = ser.readline()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line.startswith("csv_hdr,") and not line.startswith("csv,"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("csv_hdr,"):
|
||||||
|
device_header = line[len("csv_hdr,") :]
|
||||||
|
fh.write("wall_time," + device_header + "\n")
|
||||||
|
header_written = True
|
||||||
|
fh.flush()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not header_written:
|
||||||
|
fh.write(FALLBACK_HEADER + "\n")
|
||||||
|
header_written = True
|
||||||
|
|
||||||
|
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
|
||||||
|
fh.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
17
scripts/dryer-logger.service
Normal file
17
scripts/dryer-logger.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Voron filament dryer CSV logger
|
||||||
|
After=multi-user.target
|
||||||
|
# Give USB time to enumerate after boot
|
||||||
|
After=systemd-udev-settle.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Adjust user and paths to match your Pi setup
|
||||||
|
User=pi
|
||||||
|
WorkingDirectory=/home/pi/voron-filament-dryer
|
||||||
|
ExecStart=/usr/bin/python3 /home/pi/voron-filament-dryer/scripts/capture_csv.py --log-dir /home/pi/voron-filament-dryer/logs
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
1
scripts/requirements.txt
Normal file
1
scripts/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pyserial>=3.5
|
||||||
409
src/main.cpp
Normal file
409
src/main.cpp
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <Adafruit_SHT31.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "csv_logger.h"
|
||||||
|
#include "tca9548a.h"
|
||||||
|
#include "thermal_controller.h"
|
||||||
|
|
||||||
|
Tca9548a mux(TCA9548A_ADDRESS);
|
||||||
|
Adafruit_SHT31 sht31;
|
||||||
|
ThermalController thermal;
|
||||||
|
|
||||||
|
SensorReading sensors[SENSOR_COUNT];
|
||||||
|
|
||||||
|
uint32_t lastSensorReadMs = 0;
|
||||||
|
uint32_t lastControlMs = 0;
|
||||||
|
uint32_t lastReportMs = 0;
|
||||||
|
|
||||||
|
char serialLine[48];
|
||||||
|
uint8_t serialLineLen = 0;
|
||||||
|
bool csvLogEnabled = LOG_CSV_DEFAULT;
|
||||||
|
|
||||||
|
bool readSensorOnChannel(uint8_t channel, SensorReading &out) {
|
||||||
|
if (!mux.selectChannel(channel)) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sht31.begin(SHT31_ADDRESS)) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float temp = sht31.readTemperature();
|
||||||
|
const float humidity = sht31.readHumidity();
|
||||||
|
|
||||||
|
if (isnan(temp) || isnan(humidity)) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.temperatureC = temp;
|
||||||
|
out.humidityPct = humidity;
|
||||||
|
out.valid = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void readAllSensors() {
|
||||||
|
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||||
|
readSensorOnChannel(SENSOR_CHANNELS[i], sensors[i]);
|
||||||
|
}
|
||||||
|
mux.disableAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
float averageValidTemperature() {
|
||||||
|
float sum = 0.0f;
|
||||||
|
uint8_t count = 0;
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||||
|
if (sensors[i].valid) {
|
||||||
|
sum += sensors[i].temperatureC;
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0 ? sum / static_cast<float>(count) : NAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
float minValidTemperature() {
|
||||||
|
float minTemp = INFINITY;
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||||
|
if (sensors[i].valid && sensors[i].temperatureC < minTemp) {
|
||||||
|
minTemp = sensors[i].temperatureC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minTemp < INFINITY ? minTemp : NAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
float maxValidTemperature() {
|
||||||
|
float maxTemp = -INFINITY;
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||||
|
if (sensors[i].valid && sensors[i].temperatureC > maxTemp) {
|
||||||
|
maxTemp = sensors[i].temperatureC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxTemp > -INFINITY ? maxTemp : NAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
float cornerTemperatureSpread() {
|
||||||
|
const float minTemp = minValidTemperature();
|
||||||
|
const float maxTemp = maxValidTemperature();
|
||||||
|
if (isnan(minTemp) || isnan(maxTemp)) {
|
||||||
|
return NAN;
|
||||||
|
}
|
||||||
|
return maxTemp - minTemp;
|
||||||
|
}
|
||||||
|
|
||||||
|
void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||||
|
Serial.print(F("target="));
|
||||||
|
if (thermal.isIdle()) {
|
||||||
|
Serial.print(F("idle"));
|
||||||
|
} else {
|
||||||
|
Serial.print(thermal.target(), 1);
|
||||||
|
}
|
||||||
|
Serial.print(F("C cutoff="));
|
||||||
|
if (thermal.isIdle()) {
|
||||||
|
Serial.print(F("n/a"));
|
||||||
|
} else {
|
||||||
|
Serial.print(thermal.cutoffThreshold(), 1);
|
||||||
|
}
|
||||||
|
Serial.print(F("C avg="));
|
||||||
|
Serial.print(avgTemp, 2);
|
||||||
|
Serial.print(F("C min="));
|
||||||
|
Serial.print(minTemp, 2);
|
||||||
|
Serial.print(F("C max="));
|
||||||
|
Serial.print(maxTemp, 2);
|
||||||
|
Serial.print(F("C spread="));
|
||||||
|
Serial.print(thermal.cornerSpread(), 2);
|
||||||
|
Serial.print(F("C heatlim="));
|
||||||
|
Serial.print(thermal.heaterAllowance(), 0);
|
||||||
|
Serial.print(F("% heater="));
|
||||||
|
Serial.print(thermal.heaterDutyPercent(), 1);
|
||||||
|
Serial.print(F("% fan="));
|
||||||
|
Serial.print((thermal.fanPwm() * 100) / 255);
|
||||||
|
if (thermal.isIdle() && thermal.isFanOff()) {
|
||||||
|
Serial.print(F("(off)"));
|
||||||
|
} else if (thermal.isIdleCooling()) {
|
||||||
|
Serial.print(F("(cooldown)"));
|
||||||
|
}
|
||||||
|
Serial.print(F(" cutoff="));
|
||||||
|
Serial.print(thermal.isCutoffActive() ? F("YES") : F("no"));
|
||||||
|
Serial.print(F(" failsafe="));
|
||||||
|
Serial.print(thermal.isFailSafeActive() ? F("YES") : F("no"));
|
||||||
|
Serial.print(F(" mode="));
|
||||||
|
if (thermal.isAutotuning()) {
|
||||||
|
Serial.print(F("autotune"));
|
||||||
|
} else if (thermal.isAdaptive()) {
|
||||||
|
Serial.print(F("learned"));
|
||||||
|
} else {
|
||||||
|
Serial.print(F("manual"));
|
||||||
|
}
|
||||||
|
Serial.print(F(" sensors=["));
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||||
|
if (i > 0) {
|
||||||
|
Serial.print(F(", "));
|
||||||
|
}
|
||||||
|
Serial.print(F("ch"));
|
||||||
|
Serial.print(SENSOR_CHANNELS[i]);
|
||||||
|
Serial.print(F(":"));
|
||||||
|
if (sensors[i].valid) {
|
||||||
|
Serial.print(sensors[i].temperatureC, 1);
|
||||||
|
Serial.print(F("C/"));
|
||||||
|
Serial.print(sensors[i].humidityPct, 0);
|
||||||
|
Serial.print(F("%"));
|
||||||
|
} else {
|
||||||
|
Serial.print(F("ERR"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println(F("]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void printHelp() {
|
||||||
|
Serial.println(F("Commands:"));
|
||||||
|
Serial.println(F(" target <C> set target (0 = idle)"));
|
||||||
|
Serial.println(F(" fan off cancel idle fan override (auto-off below 40C)"));
|
||||||
|
Serial.println(F(" fan on idle fan 30% (optional, auto-off below 40C)"));
|
||||||
|
Serial.println(F(" autotune [C] learn PID (default: 40C when idle)"));
|
||||||
|
Serial.println(F(" autotune stop"));
|
||||||
|
Serial.println(F(" pid show PID / adaptive status"));
|
||||||
|
Serial.println(F(" pid default reset to factory PID"));
|
||||||
|
Serial.println(F(" status print current readings"));
|
||||||
|
Serial.println(F(" log on|off CSV data stream"));
|
||||||
|
Serial.println(F(" help show this message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshThermalSensorMax() {
|
||||||
|
const float maxTemp = maxValidTemperature();
|
||||||
|
if (!isnan(maxTemp)) {
|
||||||
|
thermal.noteSensorMax(maxTemp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void processSerialLine(const char *line) {
|
||||||
|
while (*line == ' ' || *line == '\t') {
|
||||||
|
++line;
|
||||||
|
}
|
||||||
|
if (*line == '\0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strncmp(line, "target ", 7) == 0) {
|
||||||
|
const float targetC = atof(line + 7);
|
||||||
|
if (targetC < TARGET_MIN_C || targetC > TARGET_MAX_C) {
|
||||||
|
Serial.print(F("ERR target must be "));
|
||||||
|
Serial.print(TARGET_MIN_C, 0);
|
||||||
|
Serial.print(F("-"));
|
||||||
|
Serial.print(TARGET_MAX_C, 0);
|
||||||
|
Serial.println(F(" C"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshThermalSensorMax();
|
||||||
|
thermal.setTarget(targetC);
|
||||||
|
if (targetC <= 0.0f) {
|
||||||
|
Serial.println(F("OK idle — heater off, fan auto-off when max < 40C"));
|
||||||
|
} else {
|
||||||
|
Serial.print(F("OK target="));
|
||||||
|
Serial.print(targetC, 1);
|
||||||
|
Serial.print(F("C cutoff="));
|
||||||
|
Serial.print(thermal.cutoffThreshold(), 1);
|
||||||
|
Serial.println(F("C"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "fan off") == 0) {
|
||||||
|
refreshThermalSensorMax();
|
||||||
|
if (!thermal.setFanOff()) {
|
||||||
|
Serial.println(F("ERR fan off requires target 0 first (send: target 0)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thermal.isIdleCooling()) {
|
||||||
|
Serial.println(F("OK cooling — fans stay on until max < 40C"));
|
||||||
|
} else {
|
||||||
|
Serial.println(F("OK fans off"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "fan on") == 0) {
|
||||||
|
refreshThermalSensorMax();
|
||||||
|
if (!thermal.isIdle()) {
|
||||||
|
Serial.println(F("ERR fan on only when target is 0"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thermal.isIdleCooling()) {
|
||||||
|
Serial.println(F("ERR fan on blocked — still cooling (max >= 40C)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thermal.setFanIdle();
|
||||||
|
Serial.println(F("OK fans at idle 30%"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "status") == 0) {
|
||||||
|
const float avgTemp = averageValidTemperature();
|
||||||
|
const float minTemp = minValidTemperature();
|
||||||
|
const float maxTemp = maxValidTemperature();
|
||||||
|
if (!isnan(avgTemp) && !isnan(minTemp) && !isnan(maxTemp)) {
|
||||||
|
printStatus(avgTemp, minTemp, maxTemp);
|
||||||
|
} else {
|
||||||
|
Serial.println(F("WARN: no valid sensor readings"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strncmp(line, "autotune", 8) == 0) {
|
||||||
|
if (strcmp(line, "autotune stop") == 0) {
|
||||||
|
thermal.stopAutotune();
|
||||||
|
Serial.println(F("OK autotune cancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float tuneTarget = thermal.target();
|
||||||
|
if (line[8] == ' ') {
|
||||||
|
tuneTarget = atof(line + 9);
|
||||||
|
} else if (tuneTarget <= 0.0f) {
|
||||||
|
tuneTarget = AUTOTUNE_DEFAULT_TEMP_C;
|
||||||
|
}
|
||||||
|
if (tuneTarget <= 0.0f || tuneTarget > TARGET_MAX_C) {
|
||||||
|
Serial.println(F("ERR autotune temperature must be 25-80 C"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thermal.setTarget(tuneTarget);
|
||||||
|
if (!thermal.startAutotune(tuneTarget)) {
|
||||||
|
Serial.println(F("ERR autotune already running"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Serial.println(F("OK autotune started — keep chamber closed, wait ~10-20 min"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "pid") == 0 || strcmp(line, "pid show") == 0) {
|
||||||
|
thermal.printTuning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "pid default") == 0) {
|
||||||
|
thermal.clearTuning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "help") == 0) {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "log on") == 0) {
|
||||||
|
csvLogEnabled = true;
|
||||||
|
printCsvHeader();
|
||||||
|
Serial.println(F("OK csv logging on"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "log off") == 0) {
|
||||||
|
csvLogEnabled = false;
|
||||||
|
Serial.println(F("OK csv logging off"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(line, "log") == 0) {
|
||||||
|
Serial.println(csvLogEnabled ? F("OK csv logging on") : F("OK csv logging off"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println(F("ERR unknown command (try help)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void pollSerial() {
|
||||||
|
while (Serial.available() > 0) {
|
||||||
|
const char c = static_cast<char>(Serial.read());
|
||||||
|
if (c == '\n' || c == '\r') {
|
||||||
|
if (serialLineLen > 0) {
|
||||||
|
serialLine[serialLineLen] = '\0';
|
||||||
|
processSerialLine(serialLine);
|
||||||
|
serialLineLen = 0;
|
||||||
|
}
|
||||||
|
} else if (serialLineLen < sizeof(serialLine) - 1) {
|
||||||
|
serialLine[serialLineLen++] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
// Claim outputs before anything else — fan on, heater off (fail-safe)
|
||||||
|
thermal.begin();
|
||||||
|
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial && millis() < 3000) {
|
||||||
|
delay(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
Wire.begin();
|
||||||
|
|
||||||
|
if (!mux.begin()) {
|
||||||
|
Serial.println(F("ERROR: TCA9548A not found on I2C bus"));
|
||||||
|
} else {
|
||||||
|
Serial.println(F("TCA9548A detected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.print(F("Filament dryer ready. "));
|
||||||
|
if (thermal.isIdle()) {
|
||||||
|
Serial.println(F("Idle — send target <C> to start drying"));
|
||||||
|
} else {
|
||||||
|
Serial.print(F("Target "));
|
||||||
|
Serial.print(thermal.target(), 1);
|
||||||
|
Serial.print(F(" C, hard cutoff at "));
|
||||||
|
Serial.print(thermal.cutoffThreshold(), 1);
|
||||||
|
Serial.println(F(" C"));
|
||||||
|
}
|
||||||
|
printHelp();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
const uint32_t now = millis();
|
||||||
|
|
||||||
|
pollSerial();
|
||||||
|
|
||||||
|
if (now - lastSensorReadMs >= SENSOR_READ_INTERVAL_MS) {
|
||||||
|
lastSensorReadMs = now;
|
||||||
|
readAllSensors();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - lastControlMs >= CONTROL_INTERVAL_MS) {
|
||||||
|
lastControlMs = now;
|
||||||
|
|
||||||
|
const float avgTemp = averageValidTemperature();
|
||||||
|
const float maxTemp = maxValidTemperature();
|
||||||
|
const float spread = cornerTemperatureSpread();
|
||||||
|
|
||||||
|
if (!isnan(avgTemp) && !isnan(maxTemp) && !isnan(spread)) {
|
||||||
|
thermal.update(avgTemp, maxTemp, spread, now);
|
||||||
|
} else {
|
||||||
|
thermal.enterFailSafe();
|
||||||
|
Serial.println(F("WARN: no valid sensor readings — heater off"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - lastReportMs >= SERIAL_REPORT_INTERVAL_MS) {
|
||||||
|
lastReportMs = now;
|
||||||
|
const float avgTemp = averageValidTemperature();
|
||||||
|
const float minTemp = minValidTemperature();
|
||||||
|
const float maxTemp = maxValidTemperature();
|
||||||
|
if (!isnan(avgTemp) && !isnan(minTemp) && !isnan(maxTemp)) {
|
||||||
|
printStatus(avgTemp, minTemp, maxTemp);
|
||||||
|
if (csvLogEnabled) {
|
||||||
|
printCsvRow(now, thermal, sensors, SENSOR_COUNT, avgTemp, minTemp, maxTemp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user