diff --git a/protobufs b/protobufs index bc63a57f9e5..097269e3928 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit bc63a57f9e5dba8a7c90ee0bd4a9840862d61f6d +Subproject commit 097269e3928d67254b5a8398f506778462cd74af diff --git a/src/Power.cpp b/src/Power.cpp index b2a4ddaaf6e..c011741323b 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -25,6 +25,10 @@ #include "power/PowerHAL.h" #include "sleep.h" +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_BATTERY_CALIBRATION +#include "modules/BatteryCalibrationModule.h" +#endif + #if defined(ARCH_PORTDUINO) #include "api/WiFiServerAPI.h" #include "input/LinuxInputImpl.h" @@ -175,6 +179,26 @@ Power *power; using namespace meshtastic; +// pulls saved OCV array from config +namespace +{ +bool copyOcvFromConfig(uint16_t *dest, size_t len) +{ + if (config.power.ocv_count == 0) { + return false; + } + if (config.power.ocv_count != len) { + LOG_WARN("Power config OCV array has %u entries, expected %u; using defaults", config.power.ocv_count, + static_cast(len)); + return false; + } + for (size_t i = 0; i < len; ++i) { + dest[i] = static_cast(config.power.ocv[i]); + } + return true; +} +} // namespace + // NRF52 has AREF_VOLTAGE defined in architecture.h but // make sure it's included. If something is wrong with NRF52 // definition - compilation will fail on missing definition @@ -229,10 +253,26 @@ static void battery_adcDisable() /** * A simple battery level sensor that assumes the battery voltage is attached * via a voltage-divider to an analog input + * OCV array is pulled from saved config if available */ class AnalogBatteryLevel : public HasBatteryLevel { public: + void applyOcvConfig(bool reset_read_value = false) + { + bool ocv_loaded = copyOcvFromConfig(OCV, NUM_OCV_POINTS); + LOG_INFO("OCV load from config: %s (first: %u, last: %u)", ocv_loaded ? "true" : "false", OCV[0], + OCV[NUM_OCV_POINTS - 1]); + if (!ocv_loaded) { + return; + } + chargingVolt = (OCV[0] + 10) * NUM_CELLS; + noBatVolt = (OCV[NUM_OCV_POINTS - 1] - 500) * NUM_CELLS; + if (reset_read_value || !initial_read_done) { + last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS); + } + } + /** * Battery state of charge, from 0 to 100 or -1 for unknown */ @@ -510,9 +550,9 @@ class AnalogBatteryLevel : public HasBatteryLevel /// For heltecs with no battery connected, the measured voltage is 2204, so // need to be higher than that, in this case is 2500mV (3000-500) - const uint16_t OCV[NUM_OCV_POINTS] = {OCV_ARRAY}; - const float chargingVolt = (OCV[0] + 10) * NUM_CELLS; - const float noBatVolt = (OCV[NUM_OCV_POINTS - 1] - 500) * NUM_CELLS; + uint16_t OCV[NUM_OCV_POINTS] = {OCV_ARRAY}; + float chargingVolt = (OCV[0] + 10) * NUM_CELLS; + float noBatVolt = (OCV[NUM_OCV_POINTS - 1] - 500) * NUM_CELLS; // Start value from minimum voltage for the filter to not start from 0 // that could trigger some events. // This value is over-written by the first ADC reading, it the voltage seems @@ -605,7 +645,18 @@ Power::Power() : OSThread("Power") lastheap = memGet.getFreeHeap(); #endif } - +// Allows overwriting defaults with values loaded from config independent of boot sequence +void Power::loadOcvFromConfig() +{ + copyOcvFromConfig(OCV, NUM_OCV_POINTS); +} +bool Power::reloadOcvFromConfig() +{ + bool loaded = copyOcvFromConfig(OCV, NUM_OCV_POINTS); + analogLevel.applyOcvConfig(true); + LOG_INFO("Power OCV reload %s (first=%u last=%u)", loaded ? "ok" : "failed", OCV[0], OCV[NUM_OCV_POINTS - 1]); + return loaded; +} bool Power::analogInit() { #ifdef EXT_PWR_DETECT @@ -672,7 +723,7 @@ bool Power::analogInit() #ifndef ARCH_ESP32 analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); #endif - + analogLevel.applyOcvConfig(); batteryLevel = &analogLevel; return true; #else @@ -688,6 +739,7 @@ bool Power::analogInit() bool Power::setup() { bool found = false; + analogLevel.applyOcvConfig(); if (axpChipInit()) { found = true; } else if (lipoInit()) { diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 9f8097b84a4..3de8a424b06 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -18,6 +18,9 @@ #include "main.h" #include "sleep.h" #include "target_specific.h" +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_BATTERY_CALIBRATION +#include "modules/BatteryCalibrationModule.h" +#endif #if HAS_WIFI && !defined(ARCH_PORTDUINO) || defined(MESHTASTIC_EXCLUDE_WIFI) #include "mesh/wifi/WiFiAPClient.h" @@ -65,6 +68,12 @@ static void sdsEnter() static void lowBattSDSEnter() { LOG_POWERFSM("State: Lower batt SDS"); +// Save OCV array to persistent memory if in battery calibration +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_BATTERY_CALIBRATION + if (batteryCalibrationModule && batteryCalibrationModule->persistCalibrationOcv()) { + nodeDB->saveToDisk(SEGMENT_CONFIG); + } +#endif doDeepSleep(Default::getConfiguredOrDefaultMs(config.power.sds_secs), false, true); } extern Power *power; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 111a47f7cf2..00871ba4463 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -38,6 +38,7 @@ along with this program. If not, see . #include "draw/NodeListRenderer.h" #include "draw/NotificationRenderer.h" #include "draw/UIRenderer.h" +#include "modules/BatteryCalibrationModule.h" #include "modules/CannedMessageModule.h" #if !MESHTASTIC_EXCLUDE_GPS @@ -155,6 +156,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus #endif + NotificationRenderer::bannerGeneration++; // bugfix for external modules // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, banner_overlay_options.message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination @@ -166,7 +168,8 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) NotificationRenderer::alertBannerCallback = banner_overlay_options.bannerCallback; NotificationRenderer::curSelected = banner_overlay_options.InitialSelected; NotificationRenderer::pauseBanner = false; - NotificationRenderer::current_notification_type = notificationTypeEnum::selection_picker; + NotificationRenderer::current_notification_type = banner_overlay_options.notificationType; + NotificationRenderer::inEvent.inputEvent = INPUT_BROKER_NONE; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); @@ -179,6 +182,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus #endif + NotificationRenderer::bannerGeneration++; nodeDB->pause_sort(true); // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, message, 255); @@ -202,6 +206,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus #endif + NotificationRenderer::bannerGeneration++; // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination @@ -224,6 +229,8 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t { LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs); + NotificationRenderer::bannerGeneration++; + // Start OnScreenKeyboardModule session (non-touch variant) OnScreenKeyboardModule::instance().start(header, initialText, durationMs, textCallback); NotificationRenderer::textInputCallback = textCallback; @@ -1761,7 +1768,10 @@ int Screen::handleInputEvent(const InputEvent *event) this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); } else if (event->inputEvent == INPUT_BROKER_SELECT) { - if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { + if (batteryCalibrationModule && this->ui->getUiState()->currentFrame < moduleFrames.size() && + moduleFrames.at(this->ui->getUiState()->currentFrame) == batteryCalibrationModule) { + menuHandler::batteryCalibrationMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { menuHandler::homeBaseMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) { menuHandler::systemBaseMenu(); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6d29e9f7f62..8bd9bdd9a47 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -19,6 +19,8 @@ #include "mesh/Default.h" #include "mesh/MeshTypes.h" #include "modules/AdminModule.h" +#include "modules/BatteryCalibrationModule.h" +#include "modules/BatteryCalibrationSampler.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" #include "modules/KeyVerificationModule.h" @@ -2396,6 +2398,92 @@ void menuHandler::powerMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::batteryCalibrationMenu() +{ + + static const char *optionsArrayIdle[] = {"Back", "Begin Calibration", "Reset OCV Array"}; + static const char *optionsArrayActive[] = {"Back", "Stop Calibration", "Reset OCV Array", "Save OCV & End"}; + + enum optionsNumbers { Back = 0, Start = 1, Reset = 2, Apply = 3 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Battery Calibration Action"; + const bool calibrationActive = batteryCalibrationModule && batteryCalibrationModule->isCalibrationActive(); + bannerOptions.optionsArrayPtr = calibrationActive ? optionsArrayActive : optionsArrayIdle; + bannerOptions.optionsCount = calibrationActive ? 4 : 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Start) { + if (batteryCalibrationModule && batteryCalibrationModule->isCalibrationActive()) { + batteryCalibrationModule->stopCalibration(); + IF_SCREEN(screen->showSimpleBanner("Calibration stopped.", 2000)); + } else { + menuHandler::menuQueue = menuHandler::battery_calibration_confirm_menu; + screen->runNow(); + } + } else if (selected == Reset) { + if (batteryCalibrationSampler) { + batteryCalibrationSampler->resetSamples(); + batteryCalibrationModule->stopCalibration(); + } + config.power.ocv_count = 0; + for (size_t i = 0; i < NUM_OCV_POINTS; ++i) { + config.power.ocv[i] = 0; + } + if (nodeDB) { + nodeDB->saveToDisk(SEGMENT_CONFIG); + } + IF_SCREEN(screen->showSimpleBanner("OCV array reset.\nRebooting...", 2000)); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + screen->runNow(); + } else if (selected == Apply) { + if (batteryCalibrationModule && batteryCalibrationModule->isCalibrationActive()) { + if (batteryCalibrationModule->persistCalibrationOcv()) { + if (nodeDB) { + nodeDB->saveToDisk(SEGMENT_CONFIG); + } else { + } + batteryCalibrationModule->stopCalibration(); + IF_SCREEN(screen->showSimpleBanner("OCV saved.\nCalibration ended.", 2000)); + } else { + IF_SCREEN(screen->showSimpleBanner("OCV not ready yet.", 2000)); + } + } else { + } + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::batteryCalibrationConfirmMenu() +{ + static const char *optionsArray[] = {"Back", "Start Calibration"}; + enum optionsNumbers { Back = 0, Start = 1 }; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Confirm Battery Calibration\n" + "1) Fully charge battery\n" + "2) Remove charger\n" + "3) Start calibration"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Start) { + if (batteryCalibrationModule) { + batteryCalibrationModule->startCalibration(); + IF_SCREEN(screen->showSimpleBanner( + + "Calibration started.\nUse device as normal.\nDo not charge until battery dies.", 5000)); + } else if (batteryCalibrationSampler) { + batteryCalibrationSampler->resetSamples(); + } + } else { + menuHandler::menuQueue = menuHandler::battery_calibration_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::keyVerificationInitMenu() { screen->showNodePicker("Node to Verify", 30000, @@ -2725,6 +2813,12 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case power_menu: powerMenu(); break; + case battery_calibration_menu: + batteryCalibrationMenu(); + break; + case battery_calibration_confirm_menu: + batteryCalibrationConfirmMenu(); + break; case FrameToggles: FrameToggles_menu(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 1b964678b15..0c3267ad9db 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -43,6 +43,8 @@ class menuHandler bluetooth_toggle_menu, screen_options_menu, power_menu, + battery_calibration_menu, + battery_calibration_confirm_menu, system_base_menu, key_verification_init, key_verification_final_prompt, @@ -105,6 +107,8 @@ class menuHandler static void wifiToggleMenu(); static void screenOptionsMenu(); static void powerMenu(); + static void batteryCalibrationMenu(); + static void batteryCalibrationConfirmMenu(); static void nodeNameLengthMenu(); static void FrameToggles_menu(); static void DisplayUnits_menu(); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 8d76b4592f8..4167a544711 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -47,6 +47,7 @@ uint8_t NotificationRenderer::alertBannerOptions = 0; // last x lines are seelct const char **NotificationRenderer::optionsArrayPtr = nullptr; const int *NotificationRenderer::optionsEnumPtr = nullptr; std::function NotificationRenderer::alertBannerCallback = NULL; +uint32_t NotificationRenderer::bannerGeneration = 0; bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; uint32_t NotificationRenderer::numDigits = 0; @@ -204,8 +205,13 @@ void NotificationRenderer::drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiS return; } if (curSelected == static_cast(numDigits)) { + uint32_t generation = bannerGeneration; alertBannerCallback(currentNumber); - resetBanner(); + if (bannerGeneration == generation) { + resetBanner(); + } else { + inEvent.inputEvent = INPUT_BROKER_NONE; + } return; } @@ -270,8 +276,13 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta inEvent.inputEvent == INPUT_BROKER_USER_PRESS || inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { curSelected++; } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { + uint32_t generation = bannerGeneration; alertBannerCallback(selectedNodenum); - resetBanner(); + if (bannerGeneration == generation) { + resetBanner(); + } else { + inEvent.inputEvent = INPUT_BROKER_NONE; + } return; } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { @@ -387,13 +398,18 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp inEvent.inputEvent == INPUT_BROKER_USER_PRESS || inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { curSelected++; } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { + uint32_t generation = bannerGeneration; if (optionsEnumPtr != nullptr) { alertBannerCallback(optionsEnumPtr[curSelected]); optionsEnumPtr = nullptr; } else { alertBannerCallback(curSelected); } - resetBanner(); + if (bannerGeneration == generation) { + resetBanner(); + } else { + inEvent.inputEvent = INPUT_BROKER_NONE; + } return; } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index e51bfa5ab29..c2d3d6190de 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -24,6 +24,7 @@ class NotificationRenderer static const int *optionsEnumPtr; static uint8_t alertBannerOptions; // last x lines are seelctable options static std::function alertBannerCallback; + static uint32_t bannerGeneration; static uint32_t numDigits; static uint32_t currentNumber; static VirtualKeyboard *virtualKeyboard; diff --git a/src/main.cpp b/src/main.cpp index 68eda2d0ddd..f6e8ac24b1f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -748,6 +748,9 @@ void setup() // We do this as early as possible because this loads preferences from flash // but we need to do this after main cpu init (esp32setup), because we need the random seed set nodeDB = new NodeDB; + if (power) { + power->reloadOcvFromConfig(); + } #if HAS_TFT if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { tftSetup(); diff --git a/src/modules/BatteryCalibrationModule.cpp b/src/modules/BatteryCalibrationModule.cpp new file mode 100644 index 00000000000..3a2631393f1 --- /dev/null +++ b/src/modules/BatteryCalibrationModule.cpp @@ -0,0 +1,321 @@ +#include "BatteryCalibrationModule.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "power.h" +#include + +BatteryCalibrationModule *batteryCalibrationModule; + +BatteryCalibrationModule::BatteryCalibrationModule() : SinglePortModule("battery-calibration", meshtastic_PortNum_PRIVATE_APP) +{ + batteryCalibrationModule = this; +} + +#if HAS_SCREEN +void BatteryCalibrationModule::startCalibration() +{ + calibrationActive = true; + calibrationOcvValid = false; + if (batteryCalibrationSampler) { + batteryCalibrationSampler->resetSamples(); + } +} + +void BatteryCalibrationModule::stopCalibration() +{ + calibrationActive = false; +} +#else +void BatteryCalibrationModule::startCalibration() {} +void BatteryCalibrationModule::stopCalibration() {} +#endif + +bool BatteryCalibrationModule::persistCalibrationOcv() +{ + if (!calibrationOcvValid) { + LOG_INFO("Battery calibration OCV not valid; skipping persistence"); + return false; + } + LOG_INFO("Persisting battery calibration OCV array"); + config.power.ocv_count = NUM_OCV_POINTS; + for (size_t i = 0; i < NUM_OCV_POINTS; ++i) { + config.power.ocv[i] = calibrationOcv[i]; + LOG_INFO("OCV[%u]=%u", static_cast(i), static_cast(calibrationOcv[i])); + } + LOG_INFO("Battery calibration OCV array persisted to config"); + return true; +} + +#if HAS_SCREEN +void BatteryCalibrationModule::handleSampleUpdate() +{ + if (!calibrationActive) { + return; + } + calibrationOcvValid = computeOcvFromSamples(calibrationOcv, NUM_OCV_POINTS); +} +#else +void BatteryCalibrationModule::handleSampleUpdate() {} +#endif + +ProcessMessage BatteryCalibrationModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + (void)mp; + return ProcessMessage::CONTINUE; +} + +#if HAS_SCREEN +bool BatteryCalibrationModule::computeOcvFromSamples(uint16_t *ocvOut, size_t ocvCount) +{ + const BatteryCalibrationSampler::BatterySample *samples = nullptr; + uint16_t sampleCount = 0; + uint16_t sampleStart = 0; + if (!batteryCalibrationSampler) { + return false; + } + batteryCalibrationSampler->getSamples(samples, sampleCount, sampleStart); + if (!samples || sampleCount < 2 || ocvCount < 2) { + return false; + } + + auto sampleAt = [&](uint16_t logicalIndex) -> const BatteryCalibrationSampler::BatterySample & { + const uint16_t sampleIndex = static_cast((sampleStart + logicalIndex) % BatteryCalibrationSampler::kMaxSamples); + return samples[sampleIndex]; + }; + + const uint32_t firstTimestamp = sampleAt(0).timestampMs; + const uint32_t lastTimestamp = sampleAt(static_cast(sampleCount - 1)).timestampMs; + const uint32_t totalMs = (lastTimestamp >= firstTimestamp) ? (lastTimestamp - firstTimestamp) : 0; + const float totalPoints = static_cast(ocvCount - 1); + + for (size_t i = 0; i < ocvCount; ++i) { + const float fraction = totalPoints > 0.0f ? static_cast(i) / totalPoints : 0.0f; + if (totalMs == 0) { + const float samplePos = fraction * static_cast(sampleCount - 1); + const uint16_t lowerIndex = static_cast(samplePos); + const uint16_t upperIndex = static_cast(std::min(lowerIndex + 1, sampleCount - 1)); + const float interp = samplePos - static_cast(lowerIndex); + const uint16_t lowerVoltage = sampleAt(lowerIndex).voltageMv; + const uint16_t upperVoltage = sampleAt(upperIndex).voltageMv; + ocvOut[i] = static_cast(lowerVoltage + interp * (upperVoltage - lowerVoltage)); + continue; + } + + const uint32_t targetTimestamp = firstTimestamp + static_cast(fraction * totalMs); + const BatteryCalibrationSampler::BatterySample *prevSample = &sampleAt(0); + const BatteryCalibrationSampler::BatterySample *nextSample = nullptr; + for (uint16_t j = 1; j < sampleCount; ++j) { + const uint16_t sampleIndex = static_cast((sampleStart + j) % BatteryCalibrationSampler::kMaxSamples); + const BatteryCalibrationSampler::BatterySample *candidate = &samples[sampleIndex]; + if (candidate->timestampMs >= targetTimestamp) { + nextSample = candidate; + break; + } + prevSample = candidate; + } + if (!nextSample) { + ocvOut[i] = sampleAt(static_cast(sampleCount - 1)).voltageMv; + continue; + } + + if (nextSample->timestampMs == prevSample->timestampMs) { + ocvOut[i] = nextSample->voltageMv; + continue; + } + + const float timeFraction = static_cast(targetTimestamp - prevSample->timestampMs) / + static_cast(nextSample->timestampMs - prevSample->timestampMs); + const float voltage = + static_cast(prevSample->voltageMv) + + timeFraction * (static_cast(nextSample->voltageMv) - static_cast(prevSample->voltageMv)); + ocvOut[i] = static_cast(voltage); + } + return true; +} +#else +bool BatteryCalibrationModule::computeOcvFromSamples(uint16_t *, size_t) +{ + return false; +} +#endif + +#if HAS_SCREEN +void BatteryCalibrationModule::computeGraphBounds(OLEDDisplay *display, int16_t x, int16_t y, int16_t &graphX, int16_t &graphY, + int16_t &graphW, int16_t &graphH) +{ + (void)y; + const int *textPositions = graphics::getTextPositions(display); + const int16_t lineY = textPositions[1]; + graphX = x; + graphY = static_cast(lineY + FONT_HEIGHT_SMALL + 2); + graphW = SCREEN_WIDTH; + graphH = static_cast(SCREEN_HEIGHT - graphY); + if (graphH < 0) { + graphH = 0; + } +} + +void BatteryCalibrationModule::drawBatteryGraph(OLEDDisplay *display, int16_t graphX, int16_t graphY, int16_t graphW, + int16_t graphH, const BatteryCalibrationSampler::BatterySample *samples, + uint16_t sampleCount, uint16_t sampleStart, uint32_t minMv, uint32_t maxMv) +{ + if (!samples || sampleCount < 2 || graphW <= 1 || graphH <= 1 || maxMv <= minMv) { + return; + } + + const uint32_t rangeMv = maxMv - minMv; + const int32_t xSpan = graphW - 1; + const int32_t ySpan = graphH - 1; + const uint16_t maxIndex = static_cast(sampleCount - 1); + + auto clampY = [&](int16_t yValue) -> int16_t { + if (yValue < graphY) { + return graphY; + } + const int16_t maxY = static_cast(graphY + ySpan); + if (yValue > maxY) { + return maxY; + } + return yValue; + }; + + auto voltageToY = [&](uint16_t voltageMv) -> int16_t { + const uint32_t denom = (rangeMv == 0) ? 1 : rangeMv; + const int32_t scaled = + static_cast(static_cast(voltageMv) - static_cast(minMv)) * ySpan / denom; + const int16_t yValue = static_cast(graphY + ySpan - scaled); + return clampY(yValue); + }; + + const uint16_t prevIndex = sampleStart; + int16_t prevX = graphX; + int16_t prevY = voltageToY(samples[prevIndex].voltageMv); + + for (uint16_t i = 1; i < sampleCount; ++i) { + const uint16_t sampleIndex = static_cast((sampleStart + i) % BatteryCalibrationSampler::kMaxSamples); + const int16_t currX = static_cast(graphX + (static_cast(i) * xSpan) / maxIndex); + const int16_t currY = voltageToY(samples[sampleIndex].voltageMv); + display->drawLine(prevX, prevY, currX, currY); + prevX = currX; + prevY = currY; + } +} + +void BatteryCalibrationModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + (void)state; + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + const char *titleStr = "Battery Calibration"; + + graphics::drawCommonHeader(display, x, y, titleStr); + + char voltageStr[12] = {0}; + char durationStr[32] = {0}; + const bool hasBattery = powerStatus && powerStatus->getHasBattery(); + const bool calibrating = calibrationActive; + if (hasBattery) { + const int batV = powerStatus->getBatteryVoltageMv() / 1000; + const int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; + const int batMv = powerStatus->getBatteryVoltageMv(); // just for debug use mV + // snprintf(voltageStr, sizeof(voltageStr), "%01d.%02dV", batV, batCv); + snprintf(voltageStr, sizeof(voltageStr), "%04dmV", batMv); // just for debug use mV + } else { + snprintf(voltageStr, sizeof(voltageStr), "USB"); + } + + const int lineY = graphics::getTextPositions(display)[1]; + display->drawString(x, lineY, voltageStr); + + uint32_t displayWindowMs = 0; + const BatteryCalibrationSampler::BatterySample *samples = nullptr; + uint16_t sampleCount = 0; + uint16_t sampleStart = 0; + if (batteryCalibrationSampler) { + batteryCalibrationSampler->getSamples(samples, sampleCount, sampleStart); + } + if (samples && sampleCount >= 2) { + const uint16_t firstIndex = sampleStart; + const uint16_t lastIndex = + static_cast((sampleStart + sampleCount - 1) % BatteryCalibrationSampler::kMaxSamples); + const uint32_t firstTimestamp = samples[firstIndex].timestampMs; + const uint32_t lastTimestamp = samples[lastIndex].timestampMs; + displayWindowMs = (lastTimestamp >= firstTimestamp) ? (lastTimestamp - firstTimestamp) : 0; + } + const uint32_t hourMs = 60 * 60 * 1000U; + if (displayWindowMs >= hourMs && displayWindowMs % hourMs == 0) { + snprintf(durationStr, sizeof(durationStr), "%luh", static_cast(displayWindowMs / hourMs)); + } else { + snprintf(durationStr, sizeof(durationStr), "%lum", static_cast(displayWindowMs / 60000U)); + } + + const int16_t leftWidth = display->getStringWidth(voltageStr); + const int16_t durationWidth = display->getStringWidth(durationStr); + const int16_t durationX = static_cast(x + SCREEN_WIDTH - durationWidth); + if (durationX >= x + leftWidth) { + display->drawString(durationX, lineY, durationStr); + } + + if (calibrating) { + const char *calibratingLabel = "Calibrating..."; + const int16_t rightWidth = durationWidth; + const int16_t labelWidth = display->getStringWidth(calibratingLabel); + const int16_t midStart = static_cast(x + leftWidth); + const int16_t midWidth = static_cast(SCREEN_WIDTH - leftWidth - rightWidth); + int16_t labelX = static_cast(midStart + (midWidth - labelWidth) / 2); + if (labelX < midStart) { + labelX = midStart; + } + if (labelX + labelWidth > durationX) { + labelX = static_cast(durationX - labelWidth); + } + if (labelX >= x && labelX + labelWidth <= x + SCREEN_WIDTH) { + display->drawString(labelX, lineY, calibratingLabel); + } + } + + int16_t graphX = 0; + int16_t graphY = 0; + int16_t graphW = 0; + int16_t graphH = 0; + computeGraphBounds(display, x, y, graphX, graphY, graphW, graphH); + + if (!hasBattery) { + if (graphH > 0) { + const char *placeholder = "No battery"; + const int16_t textX = static_cast(graphX + (graphW - display->getStringWidth(placeholder)) / 2); + const int16_t textY = static_cast(graphY + (graphH - FONT_HEIGHT_SMALL) / 2); + display->drawString(textX, textY, placeholder); + } + return; + } + + uint32_t minMv = 0; + uint32_t maxMv = 0; + const uint16_t *ocvValues = power ? power->getOcvArray() : nullptr; + if (ocvValues) { + minMv = ocvValues[0]; + maxMv = ocvValues[0]; + for (size_t i = 1; i < NUM_OCV_POINTS; ++i) { + minMv = std::min(minMv, ocvValues[i]); + maxMv = std::max(maxMv, ocvValues[i]); + } + constexpr uint32_t marginMv = 200; + minMv = (minMv > marginMv) ? (minMv - marginMv) : 0; + maxMv += marginMv; + } else { + minMv = samples[sampleStart].voltageMv; + maxMv = samples[sampleStart].voltageMv; + for (uint16_t i = 1; i < sampleCount; ++i) { + const uint16_t sampleIndex = static_cast((sampleStart + i) % BatteryCalibrationSampler::kMaxSamples); + const uint16_t voltageMv = samples[sampleIndex].voltageMv; + minMv = std::min(minMv, voltageMv); + maxMv = std::max(maxMv, voltageMv); + } + } + + drawBatteryGraph(display, graphX, graphY, graphW, graphH, samples, sampleCount, sampleStart, minMv, maxMv); +} +#endif diff --git a/src/modules/BatteryCalibrationModule.h b/src/modules/BatteryCalibrationModule.h new file mode 100644 index 00000000000..81ed54fe756 --- /dev/null +++ b/src/modules/BatteryCalibrationModule.h @@ -0,0 +1,38 @@ +#pragma once + +#include "BatteryCalibrationSampler.h" +#include "SinglePortModule.h" +#include "power.h" + +class BatteryCalibrationModule : public SinglePortModule +{ + public: + BatteryCalibrationModule(); + void startCalibration(); + void stopCalibration(); + bool isCalibrationActive() const { return calibrationActive; } + bool persistCalibrationOcv(); + void handleSampleUpdate(); + +#if HAS_SCREEN + bool wantUIFrame() override { return true; } + void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override; +#endif + + protected: + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + private: + bool computeOcvFromSamples(uint16_t *ocvOut, size_t ocvCount); + bool calibrationActive = false; + bool calibrationOcvValid = false; + uint16_t calibrationOcv[NUM_OCV_POINTS]{}; +#if HAS_SCREEN + void computeGraphBounds(OLEDDisplay *display, int16_t x, int16_t y, int16_t &graphX, int16_t &graphY, int16_t &graphW, + int16_t &graphH); + void drawBatteryGraph(OLEDDisplay *display, int16_t graphX, int16_t graphY, int16_t graphW, int16_t graphH, + const BatteryCalibrationSampler::BatterySample *samples, uint16_t sampleCount, uint16_t sampleStart, + uint32_t minMv, uint32_t maxMv); +#endif +}; +extern BatteryCalibrationModule *batteryCalibrationModule; \ No newline at end of file diff --git a/src/modules/BatteryCalibrationSampler.cpp b/src/modules/BatteryCalibrationSampler.cpp new file mode 100644 index 00000000000..fe862b26909 --- /dev/null +++ b/src/modules/BatteryCalibrationSampler.cpp @@ -0,0 +1,104 @@ +#include "BatteryCalibrationSampler.h" +#include "configuration.h" +#include "modules/BatteryCalibrationModule.h" + +#if HAS_SCREEN + +#include "mesh/NodeDB.h" +#include "power.h" +#include + +BatteryCalibrationSampler *batteryCalibrationSampler; + +BatteryCalibrationSampler::BatteryCalibrationSampler() : concurrency::OSThread("BatteryCalibrationSampler") +{ + batteryCalibrationSampler = this; + startSampling(); +} + +void BatteryCalibrationSampler::startSampling() +{ + active = true; + enabled = true; + setIntervalFromNow(0); +} + +void BatteryCalibrationSampler::stopSampling() +{ + active = false; + disable(); +} + +void BatteryCalibrationSampler::resetSamples() +{ + sampleCount = 0; + sampleStart = 0; + lastSampleMs = 0; + sampleIntervalMs = kBaseSampleIntervalMs; +} + +void BatteryCalibrationSampler::getSamples(const BatterySample *&samplesOut, uint16_t &countOut, uint16_t &startOut) const +{ + samplesOut = samples; + countOut = sampleCount; + startOut = sampleStart; +} + +void BatteryCalibrationSampler::appendSample(uint16_t voltageMv, uint32_t nowMs) +{ + + lastSampleMs = nowMs; + + if (sampleCount == kMaxSamples) { + downsampleSamples(); + } + + const uint16_t index = static_cast((sampleStart + sampleCount) % kMaxSamples); + sampleCount = static_cast(sampleCount + 1); + + samples[index].voltageMv = voltageMv; + samples[index].timestampMs = nowMs; +} + +void BatteryCalibrationSampler::downsampleSamples() +{ + if (sampleCount < 2) { + return; + } + + const uint16_t newCount = static_cast(sampleCount / 2); + for (uint16_t i = 0; i < newCount; ++i) { + const uint16_t firstIndex = static_cast((sampleStart + (2 * i)) % kMaxSamples); + const uint16_t secondIndex = static_cast((sampleStart + (2 * i + 1)) % kMaxSamples); + const uint32_t avgVoltage = + (static_cast(samples[firstIndex].voltageMv) + static_cast(samples[secondIndex].voltageMv)) / 2U; + const uint32_t avgTimestamp = (samples[firstIndex].timestampMs + samples[secondIndex].timestampMs) / 2U; + samples[i].voltageMv = static_cast(avgVoltage); + samples[i].timestampMs = avgTimestamp; + } + + sampleCount = newCount; + sampleStart = 0; + sampleIntervalMs = static_cast(sampleIntervalMs * 2U); +} + +int32_t BatteryCalibrationSampler::runOnce() +{ + if (!active) { + return disable(); + } + + const uint32_t nowMs = millis(); + if (!powerStatus || !powerStatus->getHasBattery()) { + resetSamples(); + return sampleIntervalMs; + } + + appendSample(static_cast(powerStatus->getBatteryVoltageMv()), nowMs); + if (batteryCalibrationModule) { + batteryCalibrationModule->handleSampleUpdate(); + } + return sampleIntervalMs; +} + +#endif \ No newline at end of file diff --git a/src/modules/BatteryCalibrationSampler.h b/src/modules/BatteryCalibrationSampler.h new file mode 100644 index 00000000000..025131bedb0 --- /dev/null +++ b/src/modules/BatteryCalibrationSampler.h @@ -0,0 +1,44 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN + +#include "concurrency/OSThread.h" + +class BatteryCalibrationSampler : private concurrency::OSThread +{ + public: + struct BatterySample { + uint16_t voltageMv; + uint32_t timestampMs; + }; + + static constexpr uint16_t kMaxSamples = 1024; + static constexpr uint32_t kBaseSampleIntervalMs = 5000; + BatteryCalibrationSampler(); + + void startSampling(); + void stopSampling(); + bool isSampling() const { return active; } + void resetSamples(); + void getSamples(const BatterySample *&samplesOut, uint16_t &countOut, uint16_t &startOut) const; + uint32_t getSampleIntervalMs() const { return sampleIntervalMs; } + + protected: + int32_t runOnce() override; + + private: + BatterySample samples[kMaxSamples]{}; + uint16_t sampleCount = 0; + uint16_t sampleStart = 0; + uint32_t lastSampleMs = 0; + uint32_t sampleIntervalMs = kBaseSampleIntervalMs; + bool active = false; + + void appendSample(uint16_t voltageMv, uint32_t nowMs); + void downsampleSamples(); +}; + +extern BatteryCalibrationSampler *batteryCalibrationSampler; +#endif \ No newline at end of file diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index a24bd95b524..c6b82b5babc 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -16,6 +16,10 @@ #if !MESHTASTIC_EXCLUDE_CANNEDMESSAGES #include "modules/CannedMessageModule.h" #endif +#if !MESHTASTIC_EXCLUDE_BATTERY_CALIBRATION +#include "modules/BatteryCalibrationModule.h" +#include "modules/BatteryCalibrationSampler.h" +#endif #if !MESHTASTIC_EXCLUDE_DETECTIONSENSOR #include "modules/DetectionSensorModule.h" #endif @@ -169,6 +173,10 @@ void setupModules() #if ARCH_PORTDUINO new HostMetricsModule(); #endif +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_BATTERY_CALIBRATION + new BatteryCalibrationModule(); + new BatteryCalibrationSampler(); +#endif #if HAS_TELEMETRY new DeviceTelemetryModule(); #endif diff --git a/src/power.h b/src/power.h index e4b456d3b71..b45e06c92c8 100644 --- a/src/power.h +++ b/src/power.h @@ -93,8 +93,9 @@ class Power : private concurrency::OSThread void readPowerStatus(); virtual bool setup(); virtual int32_t runOnce() override; + bool reloadOcvFromConfig(); void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; } - const uint16_t OCV[11] = {OCV_ARRAY}; + const uint16_t *getOcvArray() const { return OCV; } protected: meshtastic::PowerStatus *statusHandler; @@ -113,9 +114,11 @@ class Power : private concurrency::OSThread bool serialBatteryInit(); private: + void loadOcvFromConfig(); void shutdown(); void reboot(); // open circuit voltage lookup table + uint16_t OCV[NUM_OCV_POINTS] = {OCV_ARRAY}; uint8_t low_voltage_counter; uint32_t lastLogTime = 0; #ifdef DEBUG_HEAP