From 45a2f33708543c9b1b71ec527a00f8f3ebe45d3d Mon Sep 17 00:00:00 2001 From: bason Date: Sat, 26 Sep 2026 06:41:40 +0200 Subject: [PATCH] fix(heltec-v2): use correct battery divider per revision The V2 and V2.1 use different battery divider pins and resistor ratios, and the divider rail must be enabled after wake. Probe both candidate inputs at startup, attach only the detected channel, and scale the measurement with the matching ratio. Add a generic pre-deep-sleep hook so the Heltec board can cut the battery divider rail before sleeping. Fixes #1286 --- src/helpers/ESP32Board.cpp | 3 + src/helpers/ESP32Board.h | 4 ++ variants/heltec_v2/HeltecV2Board.h | 100 ++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index a55abb264e..8a6a4b067c 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -83,6 +83,9 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { esp_sleep_enable_timer_wakeup(secs * 1000000ULL); } + // Board-specific final preparation (e.g. cutting external power rails) + preDeepSleep(); + // Finally set ESP32 into deepsleep esp_deep_sleep_start(); // CPU halts here and never returns! } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 75428bc690..dcd1a88262 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -68,6 +68,10 @@ class ESP32Board : public mesh::MainBoard { virtual void powerOff() override; void enterDeepSleep(uint32_t secs); + // Called at the end of enterDeepSleep(), right before esp_deep_sleep_start(). + // Boards override this for final deep-sleep preparation (e.g. cutting + // external power rails). Default: no-op. + virtual void preDeepSleep() { } uint32_t getIRQGpio() override { return P_LORA_DIO_1; // default for SX1262 diff --git a/variants/heltec_v2/HeltecV2Board.h b/variants/heltec_v2/HeltecV2Board.h index 9b08fe9430..084a4fe4af 100644 --- a/variants/heltec_v2/HeltecV2Board.h +++ b/variants/heltec_v2/HeltecV2Board.h @@ -7,11 +7,73 @@ #define PIN_VBAT_READ 37 #define PIN_LED_BUILTIN 25 +// Battery measurement (Heltec WiFi LoRa 32 V2 / V2.1) +// GPIO21 drives the enable of BOTH the VEXT rail and the battery voltage +// divider (ACTIVE-LOW: LOW = ON). Without this the divider output floats up +// to ~VBAT and the ADC saturates. preDeepSleep() drives it back HIGH +// before deep sleep (power saving); begin() re-enables it on every wake. +// The divider (R10/R12) has DIFFERENT values by hardware revision: +// original V2 (2018): GPIO13 (ADC2), 220K/100K = 3.2:1 (per schematic) +// - readable only while WiFi is off +// V2.1 (2019+): GPIO37 (ADC1), 270K/100K = 3.7:1 (measured: +// VBAT 4.23 V -> raw 356 @ 10 bit => 3.687; the +// 270K/100K nominal of 3.7 matches within the +// 2 % resistor tolerance) - always readable +// -> VBAT = (raw/1024 * 3.3V) * ratio(revision). +// begin() probes both pins once, remembers the detected revision, and from +// then on only the detected channel is attached and sampled. +#define PIN_VEXT_ENABLE 21 +#define PIN_VBAT_V2 13 +#define PIN_VBAT_V2_1 37 + class HeltecV2Board : public ESP32Board { public: + enum HwRev { HW_REV_V2 = 0, HW_REV_V2_1 = 1, HW_REV_UNKNOWN = 2 }; + + // Divider ratio (R10+R12)/R12 per revision, see the block comment above. + static float vbatRatio(HwRev rev) { + return (rev == HW_REV_V2) ? 3.2f : 3.7f; + } + void begin() { ESP32Board::begin(); + // Enable the battery voltage divider (and the VEXT rail). GPIO21 is + // active-LOW on both V2 and V2.1: LOW = divider ON. + pinMode(PIN_VEXT_ENABLE, OUTPUT); + digitalWrite(PIN_VEXT_ENABLE, LOW); + + analogReadResolution(10); + delay(100); // let the divider settle after being enabled + + // Detect the hardware revision by probing both candidate pins. + // No adcAttachPin() needed for the probe: every ADC channel already has + // its reset-default attenuation (11 dB) at boot, which is what the + // formula below assumes. + const uint32_t raw37 = vbatAvg10(PIN_VBAT_V2_1); + const uint32_t raw13 = vbatAvg10(PIN_VBAT_V2); + // Each candidate pin is scored with ITS OWN revision's divider ratio: + const uint16_t mv37 = vbatMV(raw37, vbatRatio(HW_REV_V2_1)); + const uint16_t mv13 = vbatMV(raw13, vbatRatio(HW_REV_V2)); + + // A Li-ion pack at the divider input reads ~2300..4500 mV. + const bool ok37 = (mv37 >= 2300) && (mv37 <= 4500); + const bool ok13 = (mv13 >= 2300) && (mv13 <= 4500); + + if (ok37) { hwRev = HW_REV_V2_1; vbatPin = PIN_VBAT_V2_1; } + else if (ok13) { hwRev = HW_REV_V2; vbatPin = PIN_VBAT_V2; } + else { hwRev = HW_REV_UNKNOWN; vbatPin = PIN_VBAT_V2_1; } + + // Attach ONLY the detected channel; the other one is never attached + // (the arduino-esp32 core has no detach API - "not attached" is the + // detach). Note the base class still attaches PIN_VBAT_READ (37); on an + // original V2 that pin is simply unconnected and harmless. + adcAttachPin(vbatPin); + + // Serial.printf("[vbat] HW=%s pin=%u raw37=%u(%umV) raw13=%u(%umV)\n", + // getManufacturerName(), (unsigned)vbatPin, + // (unsigned)raw37, mv37, (unsigned)raw13, mv13); + esp_reset_reason_t reason = esp_reset_reason(); if (reason == ESP_RST_DEEPSLEEP) { long wakeup_source = esp_sleep_get_ext1_wakeup_status(); @@ -25,22 +87,44 @@ class HeltecV2Board : public ESP32Board { } uint16_t getBattMilliVolts() override { - analogReadResolution(10); - - uint32_t raw = 0; - for (int i = 0; i < 8; i++) { - raw += analogRead(PIN_VBAT_READ); + if (hwRev == HW_REV_UNKNOWN) { + return 0; // revision could not be detected - report unsupported } - raw = raw / 8; + analogReadResolution(10); + return vbatMV(vbatAvg10(vbatPin), vbatRatio(hwRev)); + } - return (1.98 * (2 / 1024.0) * raw) * 1000; + // Called by the base enterDeepSleep() as the last step before + // esp_deep_sleep_start() (see ESP32Board::preDeepSleep()). Cut the + // VEXT + divider rail for the whole sleep. GPIO state is retained + // across deep sleep; begin() re-enables the rail on every wake. + void preDeepSleep() override { + digitalWrite(PIN_VEXT_ENABLE, HIGH); } const char* getManufacturerName() const override { - return "Heltec V2"; + return (hwRev == HW_REV_V2_1) ? "Heltec V2.1" : "Heltec V2"; } + HwRev getHwRev() const { return hwRev; } + uint32_t getIRQGpio() override { return P_LORA_DIO_0; // default for SX1276 } + +private: + HwRev hwRev = HW_REV_UNKNOWN; + uint8_t vbatPin = PIN_VBAT_V2_1; + + // Convert a 10-bit sample from the divided line to millivolts at the pack: + // mV = (raw/1024 * 3.3V) * ratio + static uint16_t vbatMV(uint32_t raw10, float ratio) { + return (uint16_t)((ratio * (3.3 / 1024.0) * (double)raw10) * 1000.0); + } + + static uint32_t vbatAvg10(uint8_t pin) { + uint32_t s = 0; + for (int i = 0; i < 8; i++) s += analogRead(pin); + return s / 8; + } };