From 8fd689311772783b3fac106f002b51dd7b884e24 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 4 Sep 2026 16:03:41 -0700 Subject: [PATCH 1/2] feat: add T-Beam 1W thermal fan control --- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 341 +++++++++++++++++++++- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 39 ++- variants/lilygo_tbeam_1w/variant.h | 21 +- 3 files changed, 388 insertions(+), 13 deletions(-) diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 1719d73334..e82e4623b0 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -1,5 +1,12 @@ #include "TBeam1WBoard.h" +#include +#include +#include +#include + +static const int FAN_PWM_MAX = (1 << FAN_PWM_RES_BITS) - 1; + void TBeam1WBoard::begin() { ESP32Board::begin(); @@ -15,31 +22,57 @@ void TBeam1WBoard::begin() { pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); - // Initialize fan control (on by default - 1W PA can overheat) + // NTC ADC (PA-adjacent thermistor) + pinMode(NTC_PIN, INPUT); + analogSetPinAttenuation(NTC_PIN, ADC_11db); + analogReadResolution(12); + + // Fan: auto/onoff. Thermal on at 36C / off below 30C; TX still forces a cooldown. pinMode(FAN_CTRL_PIN, OUTPUT); digitalWrite(FAN_CTRL_PIN, HIGH); + _temp_c = readNtcTempC(); + applyDuty(100); + startFanTask(); +} + +void TBeam1WBoard::startFanTask() { + if (_fan_task) return; + xTaskCreate(fanTaskThunk, "tbeam1w_fan", 4096, this, 1, &_fan_task); +} + +void TBeam1WBoard::fanTaskThunk(void* arg) { + auto* self = static_cast(arg); + for (;;) { + if (!self->_stopped) { + self->updateFan(); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + } } void TBeam1WBoard::onBeforeTransmit() { - // RF switching handled by RadioLib via SX126X_DIO2_AS_RF_SWITCH and setRfSwitchPins() digitalWrite(LED_PIN, HIGH); // TX LED on + _tx_active = true; + if (_mode == FAN_AUTO && _manual_duty < 0 && !_stopped) { + applyDuty(FAN_TX_FLOOR_PCT); + } } void TBeam1WBoard::onAfterTransmit() { digitalWrite(LED_PIN, LOW); // TX LED off + _tx_until_ms = millis() + FAN_TX_COOLDOWN_MS; + _tx_cooldown_active = true; + _tx_active = false; } uint16_t TBeam1WBoard::getBattMilliVolts() { // T-Beam 1W uses 7.4V battery with voltage divider - // ADC reads through divider - adjust multiplier based on actual divider ratio analogReadResolution(12); uint32_t raw = 0; for (int i = 0; i < 8; i++) { raw += analogRead(BATTERY_PIN); } raw = raw / 8; - // Assuming voltage divider ratio from ADC_MULTIPLIER - // 3.3V reference, 12-bit ADC (4095 max) return static_cast((raw * 3300 * ADC_MULTIPLIER) / 4095); } @@ -48,6 +81,9 @@ const char* TBeam1WBoard::getManufacturerName() const { } void TBeam1WBoard::powerOff() { + _stopped = true; + applyDuty(0); + // Turn off radio LNA (CTRL pin must be LOW when not receiving) digitalWrite(SX126X_RXEN, LOW); @@ -55,17 +91,304 @@ void TBeam1WBoard::powerOff() { digitalWrite(SX126X_POWER_EN, LOW); radio_powered = false; - // Turn off LED and fan digitalWrite(LED_PIN, LOW); - digitalWrite(FAN_CTRL_PIN, LOW); ESP32Board::powerOff(); } void TBeam1WBoard::setFanEnabled(bool enabled) { - digitalWrite(FAN_CTRL_PIN, enabled ? HIGH : LOW); + applyDuty(enabled ? 100 : 0); } bool TBeam1WBoard::isFanEnabled() const { - return digitalRead(FAN_CTRL_PIN) == HIGH; + return _duty_pct > 0; +} + +float TBeam1WBoard::readNtcTempC() { + analogReadMilliVolts(NTC_PIN); // settle + uint32_t sum = 0; + for (int i = 0; i < 8; i++) { + uint32_t sample_mv = analogReadMilliVolts(NTC_PIN); + // GPIO14 is ADC2 on ESP32-S3. Wi-Fi/ESP-NOW arbitration failures are + // reported by Arduino as 0 mV; never average a failed sample into a + // plausible-but-low temperature. + if (sample_mv == 0 || sample_mv >= NTC_VCC_MV) return NAN; + sum += sample_mv; + } + float mv = sum / 8.0f; + + // R_ntc = R_fixed * (Vcc - V) / V for 3.3V-NTC-ADC-10k-GND + float r_ntc = NTC_R_FIXED * (NTC_VCC_MV - mv) / mv; + if (r_ntc <= 0.0f) return NAN; + + float temp_k = 1.0f / (1.0f / 298.15f + (1.0f / NTC_B) * logf(r_ntc / NTC_R25)); + return temp_k - 273.15f; +} + +bool TBeam1WBoard::ntcImplausible(float temp_c) const { + return isnan(temp_c) || temp_c < -20.0f || temp_c > 120.0f; +} + +int TBeam1WBoard::rampDuty(float temp_c) const { + if (temp_c < (float)_lo_c) return 0; + if (temp_c >= (float)_hi_c) return 100; + float span = (float)(_hi_c - _lo_c); + if (span <= 0.0f) return 100; + float t = (temp_c - (float)_lo_c) / span; + return FAN_MIN_DUTY_PCT + (int)((100 - FAN_MIN_DUTY_PCT) * t + 0.5f); +} + +bool TBeam1WBoard::isTxCooling(uint32_t now) { + if (_tx_active) return true; + if (!_tx_cooldown_active) return false; + if ((int32_t)(now - _tx_until_ms) >= 0) { + _tx_cooldown_active = false; + return false; + } + return true; +} + +int TBeam1WBoard::cooldownSecs() { + if (_tx_active) return (FAN_TX_COOLDOWN_MS + 999) / 1000; + if (!_tx_cooldown_active) return 0; + int32_t remain_ms = (int32_t)(_tx_until_ms - millis()); + if (remain_ms <= 0) { + _tx_cooldown_active = false; + return 0; + } + return (remain_ms + 999) / 1000; +} + +void TBeam1WBoard::applyDuty(int pct) { + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + _duty_pct = pct; + + if (_drive == FAN_DRIVE_PWM) { + if (!_pwm_attached) { + ledcSetup(FAN_PWM_CHANNEL, FAN_PWM_FREQ_HZ, FAN_PWM_RES_BITS); + ledcAttachPin(FAN_CTRL_PIN, FAN_PWM_CHANNEL); + _pwm_attached = true; + } + uint32_t ticks = ((uint32_t)pct * FAN_PWM_MAX + 50) / 100; + ledcWrite(FAN_PWM_CHANNEL, ticks); + } else { + if (_pwm_attached) { + ledcDetachPin(FAN_CTRL_PIN); + _pwm_attached = false; + pinMode(FAN_CTRL_PIN, OUTPUT); + } + digitalWrite(FAN_CTRL_PIN, pct > 0 ? HIGH : LOW); + } +} + +void TBeam1WBoard::updateFan() { + float t = readNtcTempC(); + _temp_c = t; + bool tx_cooling = isTxCooling(millis()); + + int duty; + if (_manual_duty >= 0) { + duty = _manual_duty; + } else if (_mode == FAN_ON) { + duty = 100; + } else if (_mode == FAN_OFF) { + duty = 0; + } else if (ntcImplausible(t)) { + duty = 100; // fail-safe: treat bad NTC as hot + } else if (_drive == FAN_DRIVE_PWM) { + duty = rampDuty(t); + } else { + // Thermal hysteresis only: on at hi, off below lo. TX cooldown is applied + // after this and must not latch _thermal_on, or a TX at 30C keeps the fan + // running until temp dips under lo. + if (t >= (float)_hi_c) _thermal_on = true; + else if (t < (float)_lo_c) _thermal_on = false; + duty = _thermal_on ? 100 : 0; + } + + if (_mode == FAN_AUTO && _manual_duty < 0 && !ntcImplausible(t)) { + if (tx_cooling && duty < FAN_TX_FLOOR_PCT) duty = FAN_TX_FLOOR_PCT; + } + + applyDuty(duty); +} + +bool TBeam1WBoard::persistKey(const char* key, const char* value) { + return _prefs && _prefs->setByKey(key, value); +} + +bool TBeam1WBoard::parseIntArg(const char* text, int& value) { + if (!text || !*text) return false; + errno = 0; + char* end = nullptr; + long parsed = strtol(text, &end, 10); + if (errno == ERANGE || end == text || *end != '\0' || parsed < INT_MIN || parsed > INT_MAX) { + return false; + } + value = (int)parsed; + return true; +} + +void TBeam1WBoard::loadFanPrefs() { + if (!_prefs) return; + + char buf[12]; + buf[0] = 0; + if (_prefs->getByKey("fan", buf, 11)) { + if (strcmp(buf, "auto") == 0) _mode = FAN_AUTO; + else if (strcmp(buf, "off") == 0) _mode = FAN_OFF; + else if (strcmp(buf, "on") == 0) _mode = FAN_ON; + } + + buf[0] = 0; + if (_prefs->getByKey("fan_drv", buf, 11)) { + if (strcmp(buf, "onoff") == 0) _drive = FAN_DRIVE_ONOFF; + else if (strcmp(buf, "pwm") == 0) _drive = FAN_DRIVE_PWM; + } + + int lo = _lo_c; + int hi = _hi_c; + buf[0] = 0; + if (_prefs->getByKey("fan_lo", buf, 11)) lo = atoi(buf); + buf[0] = 0; + if (_prefs->getByKey("fan_hi", buf, 11)) hi = atoi(buf); + if (lo >= 0 && hi <= 120 && lo < hi) { + _lo_c = lo; + _hi_c = hi; + } + + _manual_duty = -1; +} + +void TBeam1WBoard::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + loadFanPrefs(); + updateFan(); +} + +const char* TBeam1WBoard::modeName() const { + if (_manual_duty >= 0) return "manual"; + if (_mode == FAN_AUTO) return "auto"; + if (_mode == FAN_OFF) return "off"; + return "on"; +} + +const char* TBeam1WBoard::driveName() const { + return _drive == FAN_DRIVE_ONOFF ? "onoff" : "pwm"; +} + +bool TBeam1WBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + + if (strcmp(command, "get fan") == 0) { + int cd = cooldownSecs(); + if (ntcImplausible(_temp_c)) { + sprintf(reply, "> %s n/a duty=%d%% %s cd=%ds", + modeName(), (int)_duty_pct, driveName(), cd); + } else { + sprintf(reply, "> %s %.1fC duty=%d%% %s cd=%ds", + modeName(), (double)_temp_c, (int)_duty_pct, driveName(), cd); + } + return true; + } + + if (strncmp(command, "set fan.lo ", 11) == 0) { + int lo; + if (!parseIntArg(&command[11], lo) || lo < 0 || lo >= _hi_c || lo > 100) { + strcpy(reply, "Error: fan.lo must be 0..100 and < fan.hi"); + } else if (!persistKey("fan_lo", &command[11])) { + strcpy(reply, "Error: failed to save fan.lo"); + } else { + _lo_c = lo; + sprintf(reply, "OK - fan.lo %d", lo); + } + return true; + } + + if (strncmp(command, "set fan.hi ", 11) == 0) { + int hi; + if (!parseIntArg(&command[11], hi) || hi <= _lo_c || hi > 120) { + strcpy(reply, "Error: fan.hi must be > fan.lo and <= 120"); + } else if (!persistKey("fan_hi", &command[11])) { + strcpy(reply, "Error: failed to save fan.hi"); + } else { + _hi_c = hi; + sprintf(reply, "OK - fan.hi %d", hi); + } + return true; + } + + if (strncmp(command, "set fan.drive ", 14) == 0) { + const char* arg = &command[14]; + if (strcmp(arg, "pwm") == 0) { + if (!persistKey("fan_drv", "pwm")) { + strcpy(reply, "Error: failed to save fan.drive"); + } else { + _drive = FAN_DRIVE_PWM; + applyDuty(_duty_pct); + strcpy(reply, "OK - fan.drive pwm"); + } + } else if (strcmp(arg, "onoff") == 0) { + if (!persistKey("fan_drv", "onoff")) { + strcpy(reply, "Error: failed to save fan.drive"); + } else { + _drive = FAN_DRIVE_ONOFF; + applyDuty(_duty_pct); + strcpy(reply, "OK - fan.drive onoff"); + } + } else { + strcpy(reply, "Error: fan.drive must be pwm or onoff"); + } + return true; + } + + if (strncmp(command, "set fan.duty ", 13) == 0) { + int duty; + if (!parseIntArg(&command[13], duty) || duty < 0 || duty > 100) { + strcpy(reply, "Error: fan.duty must be 0-100"); + } else { + _manual_duty = duty; + applyDuty(duty); + sprintf(reply, "OK - fan.duty %d (not saved)", duty); + } + return true; + } + + if (strncmp(command, "set fan ", 8) == 0) { + const char* arg = &command[8]; + if (strcmp(arg, "on") == 0) { + if (!persistKey("fan", "on")) { + strcpy(reply, "Error: failed to save fan mode"); + } else { + _mode = FAN_ON; + _manual_duty = -1; + applyDuty(100); + strcpy(reply, "OK - fan on"); + } + } else if (strcmp(arg, "off") == 0) { + if (!persistKey("fan", "off")) { + strcpy(reply, "Error: failed to save fan mode"); + } else { + _mode = FAN_OFF; + _manual_duty = -1; + applyDuty(0); + strcpy(reply, "OK - fan off"); + } + } else if (strcmp(arg, "auto") == 0) { + if (!persistKey("fan", "auto")) { + strcpy(reply, "Error: failed to save fan mode"); + } else { + _mode = FAN_AUTO; + _manual_duty = -1; + updateFan(); + strcpy(reply, "OK - fan auto"); + } + } else { + strcpy(reply, "Error: fan must be on, off, or auto"); + } + return true; + } + + return false; } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index d999dfd4c1..e96923c53f 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "variant.h" @@ -28,18 +30,53 @@ // - Battery must support 2A+ discharge for high-power TX class TBeam1WBoard : public ESP32Board { +public: + enum FanMode { FAN_ON, FAN_OFF, FAN_AUTO }; + enum FanDrive { FAN_DRIVE_PWM, FAN_DRIVE_ONOFF }; + private: bool radio_powered = false; + bool _stopped = false; + bool _pwm_attached = false; + KeyValueStore* _prefs = nullptr; + FanMode _mode = FAN_AUTO; + FanDrive _drive = FAN_DRIVE_ONOFF; + int _lo_c = FAN_DEFAULT_LO_C; + int _hi_c = FAN_DEFAULT_HI_C; + int _manual_duty = -1; // -1 = follow mode; 0..100 = CLI override + bool _thermal_on = false; // onoff hysteresis; TX boost must not latch this + volatile float _temp_c = NAN; + volatile int _duty_pct = 100; + volatile bool _tx_active = false; + volatile bool _tx_cooldown_active = false; + volatile uint32_t _tx_until_ms = 0; + TaskHandle_t _fan_task = nullptr; + + void startFanTask(); + void updateFan(); + void applyDuty(int pct); + float readNtcTempC(); + int rampDuty(float temp_c) const; + int cooldownSecs(); + bool isTxCooling(uint32_t now); + bool ntcImplausible(float temp_c) const; + bool persistKey(const char* key, const char* value); + static bool parseIntArg(const char* text, int& value); + void loadFanPrefs(); + const char* modeName() const; + const char* driveName() const; + static void fanTaskThunk(void* arg); public: void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; void onBeforeTransmit() override; void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; void powerOff() override; - // Fan control methods void setFanEnabled(bool enabled); bool isFanEnabled() const; }; diff --git a/variants/lilygo_tbeam_1w/variant.h b/variants/lilygo_tbeam_1w/variant.h index f6807e56b4..a67e2e8468 100644 --- a/variants/lilygo_tbeam_1w/variant.h +++ b/variants/lilygo_tbeam_1w/variant.h @@ -77,11 +77,26 @@ #define BATTERY_SENSE_SAMPLES 30 #define ADC_MULTIPLIER 3.0 -// NTC temperature sensor +// NTC thermistor (Murata NCP18XH103F03RB, 10k, B25/50=3380K) +// Divider: 3.3V -> NTC -> GPIO14 -> 10k pull-down -> GND (Vadc rises with temp) #define NTC_PIN 14 - -// Fan control +#define NTC_B 3380.0f +#define NTC_R25 10000.0f +#define NTC_R_FIXED 10000.0f +#define NTC_VCC_MV 3300.0f + +// Fan control (GPIO41). Default auto + on/off: NTC is PA-adjacent PCB temp, +// not die temp, so trip well below the SX1262/ESP32 85C operating limit. +// This fan/MOSFET path does not respond to PWM below 100% duty. #define FAN_CTRL_PIN 41 +#define FAN_PWM_CHANNEL 4 +#define FAN_PWM_FREQ_HZ 25000 +#define FAN_PWM_RES_BITS 8 +#define FAN_MIN_DUTY_PCT 40 +#define FAN_TX_COOLDOWN_MS 15000 +#define FAN_TX_FLOOR_PCT 100 +#define FAN_DEFAULT_LO_C 30 // off below typical indoor idle (~86F) +#define FAN_DEFAULT_HI_C 36 // on at ~97F PCB; still far below 85C chip ratings // PA Ramp Time - T-Beam 1W requires >800us stabilization (default is 200us) // Value 0x05 = RADIOLIB_SX126X_PA_RAMP_800U From 6c1f4c09a6e46603ee291e36bb8d8f6446e75b33 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 4 Sep 2026 16:51:53 -0700 Subject: [PATCH 2/2] Fix T-Beam 1W fan races and remove PWM controls --- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 220 +++++++++------------- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 25 +-- variants/lilygo_tbeam_1w/variant.h | 11 +- 3 files changed, 101 insertions(+), 155 deletions(-) diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index e82e4623b0..e041995546 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -5,8 +5,6 @@ #include #include -static const int FAN_PWM_MAX = (1 << FAN_PWM_RES_BITS) - 1; - void TBeam1WBoard::begin() { ESP32Board::begin(); @@ -30,8 +28,8 @@ void TBeam1WBoard::begin() { // Fan: auto/onoff. Thermal on at 36C / off below 30C; TX still forces a cooldown. pinMode(FAN_CTRL_PIN, OUTPUT); digitalWrite(FAN_CTRL_PIN, HIGH); + _fan_on = true; _temp_c = readNtcTempC(); - applyDuty(100); startFanTask(); } @@ -43,26 +41,30 @@ void TBeam1WBoard::startFanTask() { void TBeam1WBoard::fanTaskThunk(void* arg) { auto* self = static_cast(arg); for (;;) { - if (!self->_stopped) { - self->updateFan(); - } + self->updateFan(); vTaskDelay(pdMS_TO_TICKS(1000)); } } void TBeam1WBoard::onBeforeTransmit() { digitalWrite(LED_PIN, HIGH); // TX LED on + portENTER_CRITICAL(&_fan_mux); _tx_active = true; - if (_mode == FAN_AUTO && _manual_duty < 0 && !_stopped) { - applyDuty(FAN_TX_FLOOR_PCT); + if (_mode == FAN_AUTO && !_stopped) { + setFanOutputLocked(true); } + portEXIT_CRITICAL(&_fan_mux); } void TBeam1WBoard::onAfterTransmit() { digitalWrite(LED_PIN, LOW); // TX LED off - _tx_until_ms = millis() + FAN_TX_COOLDOWN_MS; - _tx_cooldown_active = true; + portENTER_CRITICAL(&_fan_mux); + if (!_stopped) { + _tx_until_ms = millis() + FAN_TX_COOLDOWN_MS; + _tx_cooldown_active = true; + } _tx_active = false; + portEXIT_CRITICAL(&_fan_mux); } uint16_t TBeam1WBoard::getBattMilliVolts() { @@ -81,8 +83,12 @@ const char* TBeam1WBoard::getManufacturerName() const { } void TBeam1WBoard::powerOff() { + portENTER_CRITICAL(&_fan_mux); _stopped = true; - applyDuty(0); + _tx_active = false; + _tx_cooldown_active = false; + setFanOutputLocked(false); + portEXIT_CRITICAL(&_fan_mux); // Turn off radio LNA (CTRL pin must be LOW when not receiving) digitalWrite(SX126X_RXEN, LOW); @@ -97,11 +103,16 @@ void TBeam1WBoard::powerOff() { } void TBeam1WBoard::setFanEnabled(bool enabled) { - applyDuty(enabled ? 100 : 0); + portENTER_CRITICAL(&_fan_mux); + setFanOutputLocked(enabled && !_stopped); + portEXIT_CRITICAL(&_fan_mux); } bool TBeam1WBoard::isFanEnabled() const { - return _duty_pct > 0; + portENTER_CRITICAL(&_fan_mux); + bool enabled = _fan_on; + portEXIT_CRITICAL(&_fan_mux); + return enabled; } float TBeam1WBoard::readNtcTempC() { @@ -129,16 +140,7 @@ bool TBeam1WBoard::ntcImplausible(float temp_c) const { return isnan(temp_c) || temp_c < -20.0f || temp_c > 120.0f; } -int TBeam1WBoard::rampDuty(float temp_c) const { - if (temp_c < (float)_lo_c) return 0; - if (temp_c >= (float)_hi_c) return 100; - float span = (float)(_hi_c - _lo_c); - if (span <= 0.0f) return 100; - float t = (temp_c - (float)_lo_c) / span; - return FAN_MIN_DUTY_PCT + (int)((100 - FAN_MIN_DUTY_PCT) * t + 0.5f); -} - -bool TBeam1WBoard::isTxCooling(uint32_t now) { +bool TBeam1WBoard::isTxCoolingLocked(uint32_t now) { if (_tx_active) return true; if (!_tx_cooldown_active) return false; if ((int32_t)(now - _tx_until_ms) >= 0) { @@ -148,7 +150,7 @@ bool TBeam1WBoard::isTxCooling(uint32_t now) { return true; } -int TBeam1WBoard::cooldownSecs() { +int TBeam1WBoard::cooldownSecsLocked() { if (_tx_active) return (FAN_TX_COOLDOWN_MS + 999) / 1000; if (!_tx_cooldown_active) return 0; int32_t remain_ms = (int32_t)(_tx_until_ms - millis()); @@ -159,59 +161,41 @@ int TBeam1WBoard::cooldownSecs() { return (remain_ms + 999) / 1000; } -void TBeam1WBoard::applyDuty(int pct) { - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - _duty_pct = pct; - - if (_drive == FAN_DRIVE_PWM) { - if (!_pwm_attached) { - ledcSetup(FAN_PWM_CHANNEL, FAN_PWM_FREQ_HZ, FAN_PWM_RES_BITS); - ledcAttachPin(FAN_CTRL_PIN, FAN_PWM_CHANNEL); - _pwm_attached = true; - } - uint32_t ticks = ((uint32_t)pct * FAN_PWM_MAX + 50) / 100; - ledcWrite(FAN_PWM_CHANNEL, ticks); - } else { - if (_pwm_attached) { - ledcDetachPin(FAN_CTRL_PIN); - _pwm_attached = false; - pinMode(FAN_CTRL_PIN, OUTPUT); - } - digitalWrite(FAN_CTRL_PIN, pct > 0 ? HIGH : LOW); - } +void TBeam1WBoard::setFanOutputLocked(bool enabled) { + _fan_on = enabled; + digitalWrite(FAN_CTRL_PIN, enabled ? HIGH : LOW); } void TBeam1WBoard::updateFan() { float t = readNtcTempC(); + uint32_t now = millis(); + + portENTER_CRITICAL(&_fan_mux); + if (_stopped) { + portEXIT_CRITICAL(&_fan_mux); + return; + } + _temp_c = t; - bool tx_cooling = isTxCooling(millis()); + bool tx_cooling = isTxCoolingLocked(now); - int duty; - if (_manual_duty >= 0) { - duty = _manual_duty; - } else if (_mode == FAN_ON) { - duty = 100; + bool enabled; + if (_mode == FAN_ON) { + enabled = true; } else if (_mode == FAN_OFF) { - duty = 0; + enabled = false; } else if (ntcImplausible(t)) { - duty = 100; // fail-safe: treat bad NTC as hot - } else if (_drive == FAN_DRIVE_PWM) { - duty = rampDuty(t); + enabled = true; // fail-safe: treat bad NTC as hot } else { - // Thermal hysteresis only: on at hi, off below lo. TX cooldown is applied - // after this and must not latch _thermal_on, or a TX at 30C keeps the fan - // running until temp dips under lo. + // TX cooldown must not latch _thermal_on, or a TX at 30C keeps the fan + // running until the temperature dips under lo. if (t >= (float)_hi_c) _thermal_on = true; else if (t < (float)_lo_c) _thermal_on = false; - duty = _thermal_on ? 100 : 0; + enabled = _thermal_on || tx_cooling; } - if (_mode == FAN_AUTO && _manual_duty < 0 && !ntcImplausible(t)) { - if (tx_cooling && duty < FAN_TX_FLOOR_PCT) duty = FAN_TX_FLOOR_PCT; - } - - applyDuty(duty); + setFanOutputLocked(enabled); + portEXIT_CRITICAL(&_fan_mux); } bool TBeam1WBoard::persistKey(const char* key, const char* value) { @@ -233,32 +217,28 @@ bool TBeam1WBoard::parseIntArg(const char* text, int& value) { void TBeam1WBoard::loadFanPrefs() { if (!_prefs) return; + FanMode mode = FAN_AUTO; char buf[12]; buf[0] = 0; if (_prefs->getByKey("fan", buf, 11)) { - if (strcmp(buf, "auto") == 0) _mode = FAN_AUTO; - else if (strcmp(buf, "off") == 0) _mode = FAN_OFF; - else if (strcmp(buf, "on") == 0) _mode = FAN_ON; - } - - buf[0] = 0; - if (_prefs->getByKey("fan_drv", buf, 11)) { - if (strcmp(buf, "onoff") == 0) _drive = FAN_DRIVE_ONOFF; - else if (strcmp(buf, "pwm") == 0) _drive = FAN_DRIVE_PWM; + if (strcmp(buf, "off") == 0) mode = FAN_OFF; + else if (strcmp(buf, "on") == 0) mode = FAN_ON; } - int lo = _lo_c; - int hi = _hi_c; + int lo = FAN_DEFAULT_LO_C; + int hi = FAN_DEFAULT_HI_C; buf[0] = 0; if (_prefs->getByKey("fan_lo", buf, 11)) lo = atoi(buf); buf[0] = 0; if (_prefs->getByKey("fan_hi", buf, 11)) hi = atoi(buf); + + portENTER_CRITICAL(&_fan_mux); + _mode = mode; if (lo >= 0 && hi <= 120 && lo < hi) { _lo_c = lo; _hi_c = hi; } - - _manual_duty = -1; + portEXIT_CRITICAL(&_fan_mux); } void TBeam1WBoard::attachDynamicPrefs(KeyValueStore* prefs) { @@ -267,40 +247,44 @@ void TBeam1WBoard::attachDynamicPrefs(KeyValueStore* prefs) { updateFan(); } -const char* TBeam1WBoard::modeName() const { - if (_manual_duty >= 0) return "manual"; +const char* TBeam1WBoard::modeNameLocked() const { if (_mode == FAN_AUTO) return "auto"; if (_mode == FAN_OFF) return "off"; return "on"; } -const char* TBeam1WBoard::driveName() const { - return _drive == FAN_DRIVE_ONOFF ? "onoff" : "pwm"; -} - bool TBeam1WBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { (void)sender_timestamp; if (strcmp(command, "get fan") == 0) { - int cd = cooldownSecs(); - if (ntcImplausible(_temp_c)) { - sprintf(reply, "> %s n/a duty=%d%% %s cd=%ds", - modeName(), (int)_duty_pct, driveName(), cd); + portENTER_CRITICAL(&_fan_mux); + int cd = cooldownSecsLocked(); + float temp_c = _temp_c; + bool enabled = _fan_on; + const char* mode = modeNameLocked(); + portEXIT_CRITICAL(&_fan_mux); + if (ntcImplausible(temp_c)) { + sprintf(reply, "> %s n/a fan=%s cd=%ds", mode, enabled ? "on" : "off", cd); } else { - sprintf(reply, "> %s %.1fC duty=%d%% %s cd=%ds", - modeName(), (double)_temp_c, (int)_duty_pct, driveName(), cd); + sprintf(reply, "> %s %.1fC fan=%s cd=%ds", + mode, (double)temp_c, enabled ? "on" : "off", cd); } return true; } if (strncmp(command, "set fan.lo ", 11) == 0) { int lo; - if (!parseIntArg(&command[11], lo) || lo < 0 || lo >= _hi_c || lo > 100) { + portENTER_CRITICAL(&_fan_mux); + int hi_limit = _hi_c; + portEXIT_CRITICAL(&_fan_mux); + if (!parseIntArg(&command[11], lo) || lo < 0 || lo >= hi_limit || lo > 100) { strcpy(reply, "Error: fan.lo must be 0..100 and < fan.hi"); } else if (!persistKey("fan_lo", &command[11])) { strcpy(reply, "Error: failed to save fan.lo"); } else { + portENTER_CRITICAL(&_fan_mux); _lo_c = lo; + portEXIT_CRITICAL(&_fan_mux); sprintf(reply, "OK - fan.lo %d", lo); } return true; @@ -308,79 +292,51 @@ bool TBeam1WBoard::handleCommand(const char* command, uint32_t sender_timestamp, if (strncmp(command, "set fan.hi ", 11) == 0) { int hi; - if (!parseIntArg(&command[11], hi) || hi <= _lo_c || hi > 120) { + portENTER_CRITICAL(&_fan_mux); + int lo_limit = _lo_c; + portEXIT_CRITICAL(&_fan_mux); + if (!parseIntArg(&command[11], hi) || hi <= lo_limit || hi > 120) { strcpy(reply, "Error: fan.hi must be > fan.lo and <= 120"); } else if (!persistKey("fan_hi", &command[11])) { strcpy(reply, "Error: failed to save fan.hi"); } else { + portENTER_CRITICAL(&_fan_mux); _hi_c = hi; + portEXIT_CRITICAL(&_fan_mux); sprintf(reply, "OK - fan.hi %d", hi); } return true; } - if (strncmp(command, "set fan.drive ", 14) == 0) { - const char* arg = &command[14]; - if (strcmp(arg, "pwm") == 0) { - if (!persistKey("fan_drv", "pwm")) { - strcpy(reply, "Error: failed to save fan.drive"); - } else { - _drive = FAN_DRIVE_PWM; - applyDuty(_duty_pct); - strcpy(reply, "OK - fan.drive pwm"); - } - } else if (strcmp(arg, "onoff") == 0) { - if (!persistKey("fan_drv", "onoff")) { - strcpy(reply, "Error: failed to save fan.drive"); - } else { - _drive = FAN_DRIVE_ONOFF; - applyDuty(_duty_pct); - strcpy(reply, "OK - fan.drive onoff"); - } - } else { - strcpy(reply, "Error: fan.drive must be pwm or onoff"); - } - return true; - } - - if (strncmp(command, "set fan.duty ", 13) == 0) { - int duty; - if (!parseIntArg(&command[13], duty) || duty < 0 || duty > 100) { - strcpy(reply, "Error: fan.duty must be 0-100"); - } else { - _manual_duty = duty; - applyDuty(duty); - sprintf(reply, "OK - fan.duty %d (not saved)", duty); - } - return true; - } - if (strncmp(command, "set fan ", 8) == 0) { const char* arg = &command[8]; if (strcmp(arg, "on") == 0) { if (!persistKey("fan", "on")) { strcpy(reply, "Error: failed to save fan mode"); } else { + portENTER_CRITICAL(&_fan_mux); _mode = FAN_ON; - _manual_duty = -1; - applyDuty(100); + if (!_stopped) setFanOutputLocked(true); + portEXIT_CRITICAL(&_fan_mux); strcpy(reply, "OK - fan on"); } } else if (strcmp(arg, "off") == 0) { if (!persistKey("fan", "off")) { strcpy(reply, "Error: failed to save fan mode"); } else { + portENTER_CRITICAL(&_fan_mux); _mode = FAN_OFF; - _manual_duty = -1; - applyDuty(0); + setFanOutputLocked(false); + portEXIT_CRITICAL(&_fan_mux); strcpy(reply, "OK - fan off"); } } else if (strcmp(arg, "auto") == 0) { if (!persistKey("fan", "auto")) { strcpy(reply, "Error: failed to save fan mode"); } else { + portENTER_CRITICAL(&_fan_mux); _mode = FAN_AUTO; - _manual_duty = -1; + portEXIT_CRITICAL(&_fan_mux); updateFan(); strcpy(reply, "OK - fan auto"); } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index e96923c53f..429b7bd1cf 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -32,39 +32,34 @@ class TBeam1WBoard : public ESP32Board { public: enum FanMode { FAN_ON, FAN_OFF, FAN_AUTO }; - enum FanDrive { FAN_DRIVE_PWM, FAN_DRIVE_ONOFF }; private: bool radio_powered = false; bool _stopped = false; - bool _pwm_attached = false; KeyValueStore* _prefs = nullptr; FanMode _mode = FAN_AUTO; - FanDrive _drive = FAN_DRIVE_ONOFF; int _lo_c = FAN_DEFAULT_LO_C; int _hi_c = FAN_DEFAULT_HI_C; - int _manual_duty = -1; // -1 = follow mode; 0..100 = CLI override bool _thermal_on = false; // onoff hysteresis; TX boost must not latch this - volatile float _temp_c = NAN; - volatile int _duty_pct = 100; - volatile bool _tx_active = false; - volatile bool _tx_cooldown_active = false; - volatile uint32_t _tx_until_ms = 0; + bool _fan_on = true; + float _temp_c = NAN; + bool _tx_active = false; + bool _tx_cooldown_active = false; + uint32_t _tx_until_ms = 0; TaskHandle_t _fan_task = nullptr; + mutable portMUX_TYPE _fan_mux = portMUX_INITIALIZER_UNLOCKED; void startFanTask(); void updateFan(); - void applyDuty(int pct); + void setFanOutputLocked(bool enabled); float readNtcTempC(); - int rampDuty(float temp_c) const; - int cooldownSecs(); - bool isTxCooling(uint32_t now); + int cooldownSecsLocked(); + bool isTxCoolingLocked(uint32_t now); bool ntcImplausible(float temp_c) const; bool persistKey(const char* key, const char* value); static bool parseIntArg(const char* text, int& value); void loadFanPrefs(); - const char* modeName() const; - const char* driveName() const; + const char* modeNameLocked() const; static void fanTaskThunk(void* arg); public: diff --git a/variants/lilygo_tbeam_1w/variant.h b/variants/lilygo_tbeam_1w/variant.h index a67e2e8468..528459a4c0 100644 --- a/variants/lilygo_tbeam_1w/variant.h +++ b/variants/lilygo_tbeam_1w/variant.h @@ -85,16 +85,11 @@ #define NTC_R_FIXED 10000.0f #define NTC_VCC_MV 3300.0f -// Fan control (GPIO41). Default auto + on/off: NTC is PA-adjacent PCB temp, -// not die temp, so trip well below the SX1262/ESP32 85C operating limit. -// This fan/MOSFET path does not respond to PWM below 100% duty. +// Fan control (GPIO41). NTC is PA-adjacent PCB temp, not die temp, so trip +// well below the SX1262/ESP32 85C operating limit. Hardware testing confirmed +// that this fan/MOSFET path is on/off; PWM below 100% does not spin the fan. #define FAN_CTRL_PIN 41 -#define FAN_PWM_CHANNEL 4 -#define FAN_PWM_FREQ_HZ 25000 -#define FAN_PWM_RES_BITS 8 -#define FAN_MIN_DUTY_PCT 40 #define FAN_TX_COOLDOWN_MS 15000 -#define FAN_TX_FLOOR_PCT 100 #define FAN_DEFAULT_LO_C 30 // off below typical indoor idle (~86F) #define FAN_DEFAULT_HI_C 36 // on at ~97F PCB; still far below 85C chip ratings