diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 68a35b6a..69e9a7f5 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -89,6 +89,31 @@ Legacy dotted aliases are also accepted: - `get battery.reporting`: shows whether board battery reporting is enabled. Support is board-dependent. - `set battery.reporting on|off`: enables or disables battery voltage reporting on supported boards. This is currently useful for Heltec V3 boards where USB-only power can produce misleading battery readings. If your board needs this too, open an issue and support can be added board-by-board. +### T-Beam 1W Fan Control + +These commands are only available on `LilyGo_TBeam_1W_*` repeater builds. + +- `get fan`: shows the current fan mode, current fan state, and the last NTC-based board temperature when available. +- `set fan auto`: returns the fan to automatic control and persists that mode across reboot. +- `set fan on`: forces the fan on and persists that mode across reboot. +- `set fan off`: forces the fan off and persists that mode across reboot. +- `set fan timeout `: changes the automatic post-TX hold window in seconds and persists it across reboot, for example `set fan timeout 45s`. + +Auto mode behavior: + +- forces the fan on during TX and keeps it on for the configured timeout afterward +- otherwise turns the fan on at `48C` +- turns it back off at `42C` +- keeps the fan on if the NTC reading is unavailable + +Notes: + +- default repeater fan mode is `auto` +- default post-TX timeout is `30s` +- fan mode and timeout are stored in repeater prefs and survive reboot +- only `LilyGo_TBeam_1W_*` repeater builds use these persisted fan settings +- accepted range is `0s` to `600s` + ## Web Panel CLI Access When the repeater web panel is enabled and you are authenticated, the browser CLI panel can run the same CLI commands accepted by the repeater. diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index e3ffbf63..b7bcb6d0 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -227,6 +227,10 @@ void setup() { } void loop() { +#if defined(TBEAM_1W) + board.updateFanControl(); +#endif + the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b937a084..f8de42aa 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,5 +1,10 @@ #include "MyMesh.h" #include +#include + +#if defined(TBEAM_1W) + #include +#endif #if defined(ESP32) && WITH_WEB_PANEL #include @@ -1043,6 +1048,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.adc_multiplier = 0.0f; // 0.0f means use default board multiplier _prefs.battery_reporting_enabled = 1; + _prefs.fan_mode = 0; // auto + _prefs.fan_timeout_secs = 30; #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN @@ -1148,6 +1155,11 @@ void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) { board.setAdcMultiplier(_prefs.adc_multiplier); board.setBatteryReporting(_prefs.battery_reporting_enabled); +#if defined(TBEAM_1W) + auto& tbeam1w_board = static_cast(board); + tbeam1w_board.setFanPostTxHoldMs(static_cast(_prefs.fan_timeout_secs) * 1000UL); + tbeam1w_board.setFanMode(static_cast(_prefs.fan_mode)); +#endif #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); @@ -1835,6 +1847,55 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply static_cast(_stats_history.getEventCapacity()), (_archive != nullptr && _archive->isMounted()) ? "mounted" : "unavailable"); #endif +#if defined(TBEAM_1W) + } else if (strcmp(command, "get fan") == 0) { + auto& tbeam1w_board = static_cast(board); + const float temp_c = tbeam1w_board.getLastBoardTemperatureC(); + if (isnan(temp_c)) { + snprintf(reply, 160, "> mode:%s state:%s timeout:%lus temp:unavailable", + tbeam1w_board.getFanModeName(), + tbeam1w_board.isFanEnabled() ? "on" : "off", + static_cast(tbeam1w_board.getFanPostTxHoldMs() / 1000UL)); + } else { + snprintf(reply, 160, "> mode:%s state:%s timeout:%lus temp:%.2fC", + tbeam1w_board.getFanModeName(), + tbeam1w_board.isFanEnabled() ? "on" : "off", + static_cast(tbeam1w_board.getFanPostTxHoldMs() / 1000UL), + temp_c); + } + } else if (memcmp(command, "set fan ", 8) == 0) { + auto& tbeam1w_board = static_cast(board); + const char* mode = &command[8]; + if (memcmp(mode, "auto", 4) == 0) { + _prefs.fan_mode = static_cast(TBeam1WBoard::FanMode::Auto); + tbeam1w_board.setFanMode(TBeam1WBoard::FanMode::Auto); + savePrefs(); + strcpy(reply, "OK - fan auto"); + } else if (memcmp(mode, "on", 2) == 0) { + _prefs.fan_mode = static_cast(TBeam1WBoard::FanMode::On); + tbeam1w_board.setFanMode(TBeam1WBoard::FanMode::On); + savePrefs(); + strcpy(reply, "OK - fan on"); + } else if (memcmp(mode, "off", 3) == 0) { + _prefs.fan_mode = static_cast(TBeam1WBoard::FanMode::Off); + tbeam1w_board.setFanMode(TBeam1WBoard::FanMode::Off); + savePrefs(); + strcpy(reply, "OK - fan off"); + } else if (memcmp(mode, "timeout ", 8) == 0) { + char* end = nullptr; + const unsigned long timeout_s = strtoul(&mode[8], &end, 10); + while (end != nullptr && *end == ' ') end++; + if (end != nullptr && *end == 's' && *(end + 1) == 0 && tbeam1w_board.setFanPostTxHoldMs(static_cast(timeout_s * 1000UL))) { + _prefs.fan_timeout_secs = static_cast(timeout_s); + savePrefs(); + snprintf(reply, 160, "OK - fan timeout %lus", timeout_s); + } else { + strcpy(reply, "Err - use 0s..600s"); + } + } else { + strcpy(reply, "Err - use auto|on|off|timeout "); + } +#endif #if defined(ESP_PLATFORM) } else if (memcmp(command, "get wifi.status", 15) == 0) { network.formatWifiStatusReply(reply, 160); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 157aa894..490b2a59 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -109,6 +109,10 @@ void setup() { } void loop() { +#if defined(TBEAM_1W) + board.updateFanControl(); +#endif + int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 825fb007..af54dd63 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -83,6 +83,10 @@ void setup() { } void loop() { +#if defined(TBEAM_1W) + board.updateFanControl(); +#endif + int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); diff --git a/src/helpers/ArchiveStorage.cpp b/src/helpers/ArchiveStorage.cpp index dfd3f4a1..72d77e5a 100644 --- a/src/helpers/ArchiveStorage.cpp +++ b/src/helpers/ArchiveStorage.cpp @@ -35,12 +35,16 @@ const char* cardTypeName(uint8_t type) { } // namespace +#if defined(ESP32) +SPIClass* getBoardSharedArchiveSPI() __attribute__((weak)); +#endif + ArchiveStorage::ArchiveStorage() : _attempted(false), _mounted(false), _mount_failed(false), _supported(false), _card_type(0), _spi_bus(HSPI), _cs_pin(0xFF), _sck_pin(0xFF), _miso_pin(0xFF), _mosi_pin(0xFF), _card_size_bytes(0), _total_bytes(0), _used_bytes(0) #if defined(ESP32) - , _spi(nullptr) + , _spi(nullptr), _owns_spi(false) #endif { } @@ -88,11 +92,15 @@ void ArchiveStorage::begin() { static_cast(_miso_pin), static_cast(_mosi_pin)); - _spi = new SPIClass(_spi_bus); + _spi = getBoardSharedArchiveSPI(); + _owns_spi = (_spi == nullptr); if (_spi == nullptr) { - _mount_failed = true; - ARCHIVE_LOG("mount failed: SPI alloc failed"); - return; + _spi = new SPIClass(_spi_bus); + if (_spi == nullptr) { + _mount_failed = true; + ARCHIVE_LOG("mount failed: SPI alloc failed"); + return; + } } if (!mountArchiveSd(_spi, _spi_bus, _cs_pin, _sck_pin, _miso_pin, _mosi_pin)) { @@ -130,7 +138,9 @@ bool ArchiveStorage::recover() { ARCHIVE_LOG("recover begin bus=%u cs=%u", static_cast(_spi_bus), static_cast(_cs_pin)); SD.end(); - _spi->end(); + if (_owns_spi) { + _spi->end(); + } _mounted = false; _mount_failed = false; _card_size_bytes = 0; diff --git a/src/helpers/ArchiveStorage.h b/src/helpers/ArchiveStorage.h index ccf4e7b3..4d648167 100644 --- a/src/helpers/ArchiveStorage.h +++ b/src/helpers/ArchiveStorage.h @@ -55,5 +55,6 @@ private: #if defined(ESP32) SPIClass* _spi; + bool _owns_spi; #endif }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2888861d..7197a78e 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -93,7 +93,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->rx_boosted_gain)) { file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 291 } - // next: 292 + if (file.available() >= (int)sizeof(_prefs->fan_mode)) { + file.read((uint8_t *)&_prefs->fan_mode, sizeof(_prefs->fan_mode)); // 292 + } + if (file.available() >= (int)sizeof(_prefs->fan_timeout_secs)) { + file.read((uint8_t *)&_prefs->fan_timeout_secs, sizeof(_prefs->fan_timeout_secs)); // 293 + } + // next: 295 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -124,6 +130,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->fan_mode = constrain(_prefs->fan_mode, 0, 2); + _prefs->fan_timeout_secs = constrain(_prefs->fan_timeout_secs, 0, 600); file.close(); } @@ -186,7 +194,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.write((uint8_t *)&_prefs->battery_reporting_enabled, sizeof(_prefs->battery_reporting_enabled)); // 290 file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 291 - // next: 292 + file.write((uint8_t *)&_prefs->fan_mode, sizeof(_prefs->fan_mode)); // 292 + file.write((uint8_t *)&_prefs->fan_timeout_secs, sizeof(_prefs->fan_timeout_secs)); // 293 + // next: 295 file.close(); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index d21f7cef..d5123dc6 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -62,6 +62,8 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; + uint8_t fan_mode; + uint16_t fan_timeout_secs; }; class CommonCLICallbacks { diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 1719d733..5a7fb786 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -1,5 +1,19 @@ #include "TBeam1WBoard.h" +namespace { +constexpr float kFanTempOnC = 48.0f; +constexpr float kFanTempOffC = 42.0f; +constexpr uint32_t kFanControlCheckMs = 5000; +constexpr uint32_t kFanPostTxHoldMs = 30000; +constexpr float kNtcSeriesResistorOhms = 10000.0f; +constexpr float kNtcBeta = 3950.0f; +constexpr float kNtcRoomTempKelvin = 298.15f; +constexpr float kNtcRoomResistanceOhms = 10000.0f; +constexpr float kNtcVrefVolts = 3.3f; +constexpr uint32_t kMinFanPostTxHoldMs = 0; +constexpr uint32_t kMaxFanPostTxHoldMs = 600000; +} + void TBeam1WBoard::begin() { ESP32Board::begin(); @@ -17,16 +31,25 @@ void TBeam1WBoard::begin() { // Initialize fan control (on by default - 1W PA can overheat) pinMode(FAN_CTRL_PIN, OUTPUT); - digitalWrite(FAN_CTRL_PIN, HIGH); + setFanEnabled(true); + fan_force_on_until = millis() + fan_post_tx_hold_ms; + next_fan_control_check_at = millis() + kFanControlCheckMs; } void TBeam1WBoard::onBeforeTransmit() { // RF switching handled by RadioLib via SX126X_DIO2_AS_RF_SWITCH and setRfSwitchPins() digitalWrite(LED_PIN, HIGH); // TX LED on + if (fan_mode == FanMode::Auto) { + fan_force_on_until = millis() + fan_post_tx_hold_ms; + setFanEnabled(true); + } } void TBeam1WBoard::onAfterTransmit() { digitalWrite(LED_PIN, LOW); // TX LED off + if (fan_mode == FanMode::Auto) { + fan_force_on_until = millis() + fan_post_tx_hold_ms; + } } uint16_t TBeam1WBoard::getBattMilliVolts() { @@ -57,15 +80,106 @@ void TBeam1WBoard::powerOff() { // Turn off LED and fan digitalWrite(LED_PIN, LOW); - digitalWrite(FAN_CTRL_PIN, LOW); + setFanEnabled(false); ESP32Board::powerOff(); } void TBeam1WBoard::setFanEnabled(bool enabled) { + fan_enabled = enabled; digitalWrite(FAN_CTRL_PIN, enabled ? HIGH : LOW); } bool TBeam1WBoard::isFanEnabled() const { - return digitalRead(FAN_CTRL_PIN) == HIGH; + return fan_enabled; +} + +void TBeam1WBoard::setFanMode(FanMode mode) { + fan_mode = mode; + next_fan_control_check_at = 0; + updateFanControl(true); +} + +TBeam1WBoard::FanMode TBeam1WBoard::getFanMode() const { + return fan_mode; +} + +const char* TBeam1WBoard::getFanModeName() const { + switch (fan_mode) { + case FanMode::On: + return "on"; + case FanMode::Off: + return "off"; + case FanMode::Auto: + default: + return "auto"; + } +} + +float TBeam1WBoard::getLastBoardTemperatureC() const { + return last_board_temperature_c; +} + +bool TBeam1WBoard::setFanPostTxHoldMs(uint32_t hold_ms) { + if (hold_ms < kMinFanPostTxHoldMs || hold_ms > kMaxFanPostTxHoldMs) { + return false; + } + fan_post_tx_hold_ms = hold_ms; + return true; +} + +uint32_t TBeam1WBoard::getFanPostTxHoldMs() const { + return fan_post_tx_hold_ms; +} + +float TBeam1WBoard::readBoardTemperatureC() const { + const float millivolts = analogReadMilliVolts(NTC_PIN); + if (millivolts <= 0.0f || millivolts >= (kNtcVrefVolts * 1000.0f)) { + return NAN; + } + + const float voltage = millivolts / 1000.0f; + const float resistance = kNtcSeriesResistorOhms * ((kNtcVrefVolts / voltage) - 1.0f); + if (!(resistance > 0.0f)) { + return NAN; + } + + const float kelvin = 1.0f / ((log(resistance / kNtcRoomResistanceOhms) / kNtcBeta) + (1.0f / kNtcRoomTempKelvin)); + return kelvin - 273.15f; +} + +void TBeam1WBoard::updateFanControl(bool force) { + const uint32_t now = millis(); + if (!force && now < next_fan_control_check_at) { + return; + } + next_fan_control_check_at = now + kFanControlCheckMs; + + if (fan_mode == FanMode::On) { + setFanEnabled(true); + return; + } + if (fan_mode == FanMode::Off) { + setFanEnabled(false); + return; + } + + if (now < fan_force_on_until) { + setFanEnabled(true); + return; + } + + const float temperature = readBoardTemperatureC(); + last_board_temperature_c = temperature; + if (isnan(temperature)) { + // Keep the fan on if temperature sensing is unavailable. + setFanEnabled(true); + return; + } + + if (temperature >= kFanTempOnC) { + setFanEnabled(true); + } else if (temperature <= kFanTempOffC) { + setFanEnabled(false); + } } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index d999dfd4..6378c538 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -28,8 +28,22 @@ // - Battery must support 2A+ discharge for high-power TX class TBeam1WBoard : public ESP32Board { +public: + enum class FanMode : uint8_t { + Auto = 0, + On, + Off + }; + private: bool radio_powered = false; + bool fan_enabled = true; + uint32_t fan_force_on_until = 0; + uint32_t next_fan_control_check_at = 0; + uint32_t fan_post_tx_hold_ms = 30000; + FanMode fan_mode = FanMode::Auto; + float last_board_temperature_c = NAN; + float readBoardTemperatureC() const; public: void begin(); @@ -42,4 +56,11 @@ public: // Fan control methods void setFanEnabled(bool enabled); bool isFanEnabled() const; + void setFanMode(FanMode mode); + FanMode getFanMode() const; + const char* getFanModeName() const; + float getLastBoardTemperatureC() const; + bool setFanPostTxHoldMs(uint32_t hold_ms); + uint32_t getFanPostTxHoldMs() const; + void updateFanControl(bool force = false); }; diff --git a/variants/lilygo_tbeam_1w/target.cpp b/variants/lilygo_tbeam_1w/target.cpp index 17733deb..99a80e89 100644 --- a/variants/lilygo_tbeam_1w/target.cpp +++ b/variants/lilygo_tbeam_1w/target.cpp @@ -10,6 +10,10 @@ TBeam1WBoard board; static SPIClass spi; +SPIClass* getBoardSharedArchiveSPI() { + return &spi; +} + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); WRAPPER_CLASS radio_driver(radio, board); @@ -62,4 +66,3 @@ mesh::LocalIdentity radio_new_identity() { RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); } - diff --git a/variants/lilygo_tbeam_1w/target.h b/variants/lilygo_tbeam_1w/target.h index a521332f..70d2744e 100644 --- a/variants/lilygo_tbeam_1w/target.h +++ b/variants/lilygo_tbeam_1w/target.h @@ -20,9 +20,10 @@ extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; extern EnvironmentSensorManager sensors; +SPIClass* getBoardSharedArchiveSPI(); + bool radio_init(); uint32_t radio_get_rng_seed(); void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); void radio_set_tx_power(int8_t dbm); mesh::LocalIdentity radio_new_identity(); -