From f8ed6377594658666a5e953da1d48928ebdf8bc5 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 5 Jul 2026 10:06:21 +0200 Subject: [PATCH] initial commit --- .gitignore | 7 + .vscode/extensions.json | 9 + .vscode/settings.json | 10 + .vscode/tasks.json | 84 ++++++ include/config.h | 85 ++++++ include/csv_logger.h | 58 ++++ include/pid_autotuner.h | 251 ++++++++++++++++ include/pid_controller.h | 83 ++++++ include/tca9548a.h | 44 +++ include/thermal_controller.h | 536 +++++++++++++++++++++++++++++++++++ include/tuning_store.h | 45 +++ platformio.ini | 9 + scripts/capture_csv.py | 147 ++++++++++ scripts/dryer-logger.service | 17 ++ scripts/requirements.txt | 1 + src/main.cpp | 409 ++++++++++++++++++++++++++ 16 files changed, 1795 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 include/config.h create mode 100644 include/csv_logger.h create mode 100644 include/pid_autotuner.h create mode 100644 include/pid_controller.h create mode 100644 include/tca9548a.h create mode 100644 include/thermal_controller.h create mode 100644 include/tuning_store.h create mode 100644 platformio.ini create mode 100755 scripts/capture_csv.py create mode 100644 scripts/dryer-logger.service create mode 100644 scripts/requirements.txt create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ded409 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.pio +logs/ +compile_commands.json +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..658362e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "llvm-vs-code-extensions.vscode-clangd" + ], + "unwantedRecommendations": [ + "platformio.platformio-ide", + "davidgomes.platformio-ide-cursor" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9153120 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "files.associations": { + "*.h": "cpp", + "platformio.ini": "ini" + }, + "clangd.arguments": [ + "--compile-commands-dir=${workspaceFolder}", + "--header-insertion=never" + ], +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..317f878 --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + } + ] +} diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..7d38102 --- /dev/null +++ b/include/config.h @@ -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 diff --git a/include/csv_logger.h b/include/csv_logger.h new file mode 100644 index 0000000..8ab760d --- /dev/null +++ b/include/csv_logger.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#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(); +} diff --git a/include/pid_autotuner.h b/include/pid_autotuner.h new file mode 100644 index 0000000..8823973 --- /dev/null +++ b/include/pid_autotuner.h @@ -0,0 +1,251 @@ +#pragma once + +#include + +#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(periodSumMs_ / periodCount_) / 1000.0f; + const float avgAmplitude = amplitudeSum_ / static_cast(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(spreadSamples_) : GOOD_SPREAD_C; + if (spreadAvg > GOOD_SPREAD_C) { + const float boost = 1.0f + ((spreadAvg - GOOD_SPREAD_C) / 10.0f); + int boosted = static_cast(static_cast(FAN_MIX_MAX_PWM) * boost); + if (boosted > static_cast(FAN_MAX_PWM) - 20) { + boosted = FAN_MAX_PWM - 20; + } + resultFanMixMax_ = static_cast(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_; +}; diff --git a/include/pid_controller.h b/include/pid_controller.h new file mode 100644 index 0000000..fd0965e --- /dev/null +++ b/include/pid_controller.h @@ -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(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; +}; diff --git a/include/tca9548a.h b/include/tca9548a.h new file mode 100644 index 0000000..ab3a38e --- /dev/null +++ b/include/tca9548a.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +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(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_; +}; diff --git a/include/thermal_controller.h b/include/thermal_controller.h new file mode 100644 index 0000000..0dd2929 --- /dev/null +++ b/include/thermal_controller.h @@ -0,0 +1,536 @@ +#pragma once + +#include + +#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(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((heaterDutyPercent_ / 100.0f) * static_cast(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(t * static_cast(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(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_; +}; diff --git a/include/tuning_store.h b/include/tuning_store.h new file mode 100644 index 0000000..d93c6ab --- /dev/null +++ b/include/tuning_store.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#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(&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); +} diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..b24b7e2 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/scripts/capture_csv.py b/scripts/capture_csv.py new file mode 100755 index 0000000..93d7cfe --- /dev/null +++ b/scripts/capture_csv.py @@ -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()) diff --git a/scripts/dryer-logger.service b/scripts/dryer-logger.service new file mode 100644 index 0000000..6ac75a7 --- /dev/null +++ b/scripts/dryer-logger.service @@ -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 diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..7ad05ef --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +pyserial>=3.5 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..a72cffc --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,409 @@ +#include +#include +#include + +#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(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 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(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 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); + } + } + } +}