From bb02055b4b8886af8164b7cd567f089ed7a2431e Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Mon, 20 Apr 2026 07:43:23 +0200 Subject: [PATCH 1/2] feat: Add generic board extension pattern for custom telemetry and CLI commands Add virtual methods to MainBoard (tick, getCustomGetter, setCustomSetter, queryBoardTelemetry) enabling board variants to hook into the main loop, CLI get/set commands, and telemetry queries without modifying core code. - MeshCore.h: CayenneLPP forward decl + 4 virtual methods with default no-op implementations - CommonCLI.cpp: dispatch 'get board.*' and 'set board.*' to board methods - simple_repeater: call board.tick() and board.queryBoardTelemetry() - simple_sensor: call board.tick() --- examples/simple_repeater/MyMesh.cpp | 5 +++++ examples/simple_repeater/main.cpp | 2 ++ examples/simple_sensor/main.cpp | 2 ++ src/MeshCore.h | 8 ++++++++ src/helpers/CommonCLI.cpp | 15 +++++++++++++++ 5 files changed, 32 insertions(+) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 666f79fc5c..77f67ac2fc 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -252,6 +252,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t } sensors.querySensors(perm_mask, telemetry); + // Board-specific telemetry (boards can override queryBoardTelemetry in their Board class) + if (perm_mask & TELEM_PERM_ENVIRONMENT) { + board.queryBoardTelemetry(telemetry); + } + // This default temperature will be overridden by external sensors (if any) float temperature = board.getMCUTemperature(); if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e37078ce5f..e04303a5f8 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -132,6 +132,8 @@ void loop() { command[0] = 0; // reset command buffer } + board.tick(); // Feed watchdog and perform board-specific tasks + #if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) // Hold the user button to power off the SenseCAP Solar repeater. int btnState = digitalRead(PIN_USER_BTN); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 330adcc2e4..7400964fb7 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -141,6 +141,8 @@ void loop() { command[0] = 0; // reset command buffer } + board.tick(); // Feed watchdog and perform board-specific tasks + the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS diff --git a/src/MeshCore.h b/src/MeshCore.h index 2db1d4c3ec..d872d0a878 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -1,5 +1,7 @@ #pragma once +class CayenneLPP; + #include #include @@ -66,6 +68,12 @@ class MainBoard { virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } virtual uint8_t getShutdownReason() const { return 0; } virtual const char* getShutdownReasonString(uint8_t reason) { return "Not available"; } + + // Custom board commands and telemetry (boards can override these) + virtual void tick() {} + virtual bool getCustomGetter(const char* getCommand, char* reply, uint32_t maxlen) { return false; } + virtual const char* setCustomSetter(const char* setCommand) { return nullptr; } + virtual bool queryBoardTelemetry(CayenneLPP& telemetry) { return false; } }; /** diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index d495aada5f..75c59a7b91 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -712,6 +712,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); #endif + } else if (memcmp(config, "board.", 6) == 0) { + const char* result = _board->setCustomSetter(&config[6]); + if (result != nullptr) { + strcpy(reply, result); + } else { + strcpy(reply, "Error: unknown board command"); + } } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); if (_board->setAdcMultiplier(_prefs->adc_multiplier)) { @@ -852,6 +859,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #else strcpy(reply, "ERROR: unsupported"); #endif + } else if (memcmp(config, "board.", 6) == 0) { + char res[100]; + memset(res, 0, sizeof(res)); + if (_board->getCustomGetter(&config[6], res, sizeof(res))) { + strcpy(reply, res); + } else { + strcpy(reply, "Error: unknown board command"); + } } else if (memcmp(config, "adc.multiplier", 14) == 0) { float adc_mult = _board->getAdcMultiplier(); if (adc_mult == 0.0f) { From ab13893b8233c2fd0b204f605f211c7d57ca1026 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Mon, 20 Apr 2026 07:43:52 +0200 Subject: [PATCH 2/2] feat: Add Inhero MR-2 repeater variant Inhero MR-2 is a solar-powered LoRa repeater board based on RAK4630 (nRF52840 + SX1262) with BQ25798 MPPT charger, INA228 fuel gauge, BME280 environment sensor, RV-3028 RTC, and hardware watchdog. Key features: multi-chemistry battery support (Li-Ion, LiFePO4, LTO) with Coulomb-counted SOC, MPPT solar charging with adaptive IINDPM, low-voltage sleep with RTC wake (< 500uA), custom CLI commands (get/set board.*), board telemetry, USB auto-management, and hardware watchdog with I2C health monitoring. Full documentation: https://docs.inhero.de --- boards/inhero_mr2.json | 74 + variants/inhero_mr2/BoardConfigContainer.cpp | 2432 ++++++++++++++++++ variants/inhero_mr2/BoardConfigContainer.h | 350 +++ variants/inhero_mr2/GuardedRTCClock.h | 55 + variants/inhero_mr2/InheroMr2Board.cpp | 1483 +++++++++++ variants/inhero_mr2/InheroMr2Board.h | 116 + variants/inhero_mr2/README.md | 67 + variants/inhero_mr2/lib/BqDriver.cpp | 569 ++++ variants/inhero_mr2/lib/BqDriver.h | 214 ++ variants/inhero_mr2/lib/Ina228Driver.cpp | 547 ++++ variants/inhero_mr2/lib/Ina228Driver.h | 255 ++ variants/inhero_mr2/lib/SimplePreferences.h | 113 + variants/inhero_mr2/platformio.ini | 70 + variants/inhero_mr2/target.cpp | 58 + variants/inhero_mr2/target.h | 30 + variants/inhero_mr2/variant.cpp | 51 + variants/inhero_mr2/variant.h | 175 ++ 17 files changed, 6659 insertions(+) create mode 100644 boards/inhero_mr2.json create mode 100644 variants/inhero_mr2/BoardConfigContainer.cpp create mode 100644 variants/inhero_mr2/BoardConfigContainer.h create mode 100644 variants/inhero_mr2/GuardedRTCClock.h create mode 100644 variants/inhero_mr2/InheroMr2Board.cpp create mode 100644 variants/inhero_mr2/InheroMr2Board.h create mode 100644 variants/inhero_mr2/README.md create mode 100644 variants/inhero_mr2/lib/BqDriver.cpp create mode 100644 variants/inhero_mr2/lib/BqDriver.h create mode 100644 variants/inhero_mr2/lib/Ina228Driver.cpp create mode 100644 variants/inhero_mr2/lib/Ina228Driver.h create mode 100644 variants/inhero_mr2/lib/SimplePreferences.h create mode 100644 variants/inhero_mr2/platformio.ini create mode 100644 variants/inhero_mr2/target.cpp create mode 100644 variants/inhero_mr2/target.h create mode 100644 variants/inhero_mr2/variant.cpp create mode 100644 variants/inhero_mr2/variant.h diff --git a/boards/inhero_mr2.json b/boards/inhero_mr2.json new file mode 100644 index 0000000000..c048f68459 --- /dev/null +++ b/boards/inhero_mr2.json @@ -0,0 +1,74 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_NRF52840_FEATHER -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + [ + "0x239A", + "0x8029" + ], + [ + "0x239A", + "0x0029" + ], + [ + "0x239A", + "0x002A" + ], + [ + "0x239A", + "0x802A" + ] + ], + "usb_product": "Inhero MR2", + "mcu": "nrf52840", + "variant": "Inhero_MR2_Board", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": [ + "bluetooth" + ], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52.cfg" + }, + "frameworks": [ + "arduino" + ], + "name": "Inhero MR-2", + "upload": { + "maximum_ram_size": 235520, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://inhero.de", + "vendor": "Inhero GmbH" +} diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp new file mode 100644 index 0000000000..91802ed736 --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -0,0 +1,2432 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Board Configuration Container Implementation + */ +#include "BoardConfigContainer.h" +#include "GuardedRTCClock.h" +#include "InheroMr2Board.h" +#include "target.h" + +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" +#include "lib/SimplePreferences.h" + +#include +#include +#include +#include +#include +#include // For NRF_POWER (GPREGRET2) + +#if ENV_INCLUDE_BME280 +#include +#endif + +// rtc_clock is defined in target.cpp +extern GuardedRTCClock rtc_clock; + +// Helper function to get RTC time safely +namespace { + inline uint32_t getRTCTime() { + return ((mesh::RTCClock&)rtc_clock).getCurrentTime(); + } + + bool isRtcPeriodicWakeConfigured(uint16_t expected_minutes); + void configureRtcPeriodicWake(uint16_t minutes); + + bool isRtcPeriodicWakeConfigured(uint16_t expected_minutes) { + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom(RTC_I2C_ADDR, (uint8_t)1) != 1) return false; + uint8_t ctrl1 = Wire.read(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom(RTC_I2C_ADDR, (uint8_t)1) != 1) return false; + uint8_t ctrl2 = Wire.read(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_TIMER_VALUE_0); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom(RTC_I2C_ADDR, (uint8_t)2) != 2) return false; + uint8_t val0 = Wire.read(); + uint8_t val1 = Wire.read(); + + uint16_t countdown = (uint16_t)val0 | ((uint16_t)(val1 & 0x0F) << 8); + + bool timer_enabled = (ctrl1 & 0x04) != 0; // TE + bool repeat_enabled = (ctrl1 & 0x80) != 0; // TRPT + bool one_over_60_hz = (ctrl1 & 0x03) == 0x03; // TD=11 (1/60 Hz) + bool interrupt_enabled = (ctrl2 & 0x10) != 0; // TIE + + return timer_enabled && repeat_enabled && one_over_60_hz && interrupt_enabled && + countdown == expected_minutes; + } + + void configureRtcPeriodicWake(uint16_t minutes) { + rtc_clock.setLocked(true); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x00); // TE=0, TD=00 (stop timer) + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + Wire.write(0x00); // TIE=0 + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(0x00); // Clear TF + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_TIMER_VALUE_0); + uint16_t ticks = (minutes == 0) ? 1 : minutes; + Wire.write(ticks & 0xFF); + Wire.write((ticks >> 8) & 0x0F); + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x87); // TE=1, TD=11 (1/60 Hz), TRPT=1 (repeat) + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + Wire.write(0x10); // TIE=1 + Wire.endTransmission(); + + rtc_clock.setLocked(false); + } + + void blinkRed(uint8_t count, uint16_t on_ms, uint16_t off_ms, bool led_enabled) { + if (!led_enabled) { + return; + } + for (uint8_t i = 0; i < count; i++) { + digitalWrite(LED_RED, HIGH); + delay(on_ms); + digitalWrite(LED_RED, LOW); + delay(off_ms); + } + } +} + +// Hardware drivers +static BqDriver bq; +static Ina228Driver ina228(0x40); // A0=GND, A1=GND + +static SimplePreferences prefs; + +// Forward declare board instance +extern InheroMr2Board board; + +// Initialize singleton pointer +BqDriver* BoardConfigContainer::bqDriverInstance = nullptr; +Ina228Driver* BoardConfigContainer::ina228DriverInstance = nullptr; +TaskHandle_t BoardConfigContainer::heartbeatTaskHandle = NULL; +volatile bool BoardConfigContainer::lowVoltageAlertFired = false; +MpptStatistics BoardConfigContainer::mpptStats = {}; +BatterySOCStats BoardConfigContainer::socStats = {}; +BoardConfigContainer::BatteryType BoardConfigContainer::cachedBatteryType = BAT_UNKNOWN; +bool BoardConfigContainer::leds_enabled = true; // Default: enabled +bool BoardConfigContainer::usbInputActive = false; // Default: no USB connected +float BoardConfigContainer::tcCalOffset = 0.0f; // Default: no temperature calibration offset +float BoardConfigContainer::lastValidBatteryTemp = 25.0f; // Default: 25°C (= no derating until first valid reading) +uint32_t BoardConfigContainer::lastTempUpdateMs = 0; // 0 = never updated + +// Battery voltage thresholds moved to BatteryProperties structure (see .h file) +// Rev 1.1: INA228 ALERT pin (P1.02) triggers low-voltage sleep via ISR → volatile flag → tickPeriodic(). +// No hardware UVLO (TPS EN tied to VDD). Low-voltage handling is always active when battery configured. + +// Watchdog state +static bool wdt_enabled = false; + +// PG-Stuck recovery: timestamp of last HIZ toggle (0 = never) +static uint32_t lastPgStuckToggleTime = 0; +#define PG_STUCK_COOLDOWN_MS (5 * 60 * 1000) // 5 minutes between toggles + +/// @brief Initialize and start the hardware watchdog timer +/// @details Configures nRF52 WDT with 600 second timeout for OTA compatibility. Only enabled in release builds. +/// Watchdog continues running during sleep and pauses during debug. +void BoardConfigContainer::setupWatchdog() { + #ifndef DEBUG_MODE // Only activate in release builds + NRF_WDT->CONFIG = (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos) | // Run during sleep + (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos); // Pause during debug + NRF_WDT->CRV = 32768 * 600; // 600 seconds (10 min) @ 32.768 kHz - allows OTA updates + NRF_WDT->RREN = WDT_RREN_RR0_Enabled << WDT_RREN_RR0_Pos; // Enable reload register 0 + NRF_WDT->TASKS_START = 1; // Start watchdog + wdt_enabled = true; + MESH_DEBUG_PRINTLN("Watchdog enabled: 600s timeout"); + + // Visual feedback: blink LED 3 times to indicate WDT is active + #ifdef LED_BLUE + if (leds_enabled) { + for (int i = 0; i < 3; i++) { + digitalWrite(LED_BLUE, HIGH); + delay(100); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + } + #endif + #else + MESH_DEBUG_PRINTLN("Watchdog disabled (DEBUG_MODE)"); + #endif +} + +/// @brief Feed the watchdog timer to prevent system reset +/// @details Should be called regularly from main loop. No-op in debug builds. +void BoardConfigContainer::feedWatchdog() { + #ifndef DEBUG_MODE + if (wdt_enabled) { + NRF_WDT->RR[0] = WDT_RR_RR_Reload; // Reload watchdog + } + #endif +} + +/// @brief Disable the watchdog timer (for OTA updates) +/// @details Note: nRF52 WDT cannot be stopped once started. This only sets flag to stop feeding. +void BoardConfigContainer::disableWatchdog() { + #ifndef DEBUG_MODE + wdt_enabled = false; // Stop feeding the watchdog + #endif +} + +/// @brief Re-enables MPPT if BQ25798 disabled it (e.g., during !PG state) +/// @details BQ25798 does not persist MPPT=1 and automatically sets MPPT=0 when PG=0. +/// This function restores MPPT=1 when PG returns to 1. +/// +/// CRITICAL: Only runs when PowerGood=1 to avoid false positives. +/// Exception: PG-Stuck recovery toggles HIZ when VBUS is present but PG=0. +void BoardConfigContainer::checkAndFixSolarLogic() { + if (!bqDriverInstance) return; + + // Check if MPPT is enabled in configuration + bool mpptEnabled; + BoardConfigContainer::loadMpptEnabled(mpptEnabled); + + if (!mpptEnabled) { + // MPPT disabled in config - only disable if currently enabled (avoid unnecessary writes) + uint8_t mpptVal = bqDriverInstance->readReg(0x15); + if ((mpptVal & 0x01) != 0) { + bqDriverInstance->writeReg(0x15, mpptVal & ~0x01); + MESH_DEBUG_PRINTLN("MPPT disabled via config"); + } + return; + } + + // Check if PowerGood is currently set + bool powerGood = bqDriverInstance->getChargerStatusPowerGood(); + + if (!powerGood) { + // PG-Stuck recovery: Panel may be connected but BQ didn't qualify it. + // Typical at sunrise when VBUS ramps slowly past the input threshold. + // Toggling HIZ forces a new input source qualification cycle (per datasheet). + // Cooldown: max once per 5 minutes to prevent excessive toggling + uint32_t now = millis(); + if (lastPgStuckToggleTime != 0 && (now - lastPgStuckToggleTime) < PG_STUCK_COOLDOWN_MS) { + return; + } + + uint16_t vbus_mv = bqDriverInstance->getVBUS(); + if (vbus_mv >= PG_STUCK_VBUS_THRESHOLD_MV) { + bqDriverInstance->setHIZMode(true); + delay(50); // BQ needs time to enter HIZ and reset input detection + bqDriverInstance->setHIZMode(false); + lastPgStuckToggleTime = now; + MESH_DEBUG_PRINTLN("PG-Stuck recovery: VBUS=%dmV but PG=0, toggled HIZ", vbus_mv); + } + return; + } + + // Re-enable MPPT when PGOOD=1 + uint8_t mpptVal = bqDriverInstance->readReg(0x15); + + if ((mpptVal & 0x01) == 0) { + bqDriverInstance->writeReg(0x15, mpptVal | 0x01); + MESH_DEBUG_PRINTLN("MPPT re-enabled via register"); + } +} + +/// @brief Single MPPT cycle — called from tickPeriodic() every 60s +/// @details Checks solar logic and updates MPPT stats. +void BoardConfigContainer::runMpptCycle() { + // Clear any pending BQ25798 interrupt flags (even though INT pin is not used) + // Flag registers (0x22-0x27) are read-to-clear and de-assert INT pin. + if (bqDriverInstance) { + bqDriverInstance->readReg(0x22); // CHARGER_FLAG_0 — read-to-clear + bqDriverInstance->readReg(0x23); // CHARGER_FLAG_1 + bqDriverInstance->readReg(0x24); // CHARGER_FLAG_2 + bqDriverInstance->readReg(0x25); // CHARGER_FLAG_3 + bqDriverInstance->readReg(0x26); // FAULT_FLAG_0 + bqDriverInstance->readReg(0x27); // FAULT_FLAG_1 + } + + checkAndFixSolarLogic(); + bool mpptEnabled; + BoardConfigContainer::loadMpptEnabled(mpptEnabled); + if (mpptEnabled && bqDriverInstance) { + updateMpptStats(); + } +} + +/// @brief Stops heartbeat task and disarms alerts before OTA +/// @details MPPT and SOC work are tick-based (no tasks to stop). +/// Only the heartbeat LED task and INA228 alert need cleanup. +void BoardConfigContainer::stopBackgroundTasks() { + MESH_DEBUG_PRINTLN("Stopping background tasks for OTA..."); + + // Delete heartbeat task if running + if (heartbeatTaskHandle != NULL) { + vTaskDelete(heartbeatTaskHandle); + heartbeatTaskHandle = NULL; + MESH_DEBUG_PRINTLN("Heartbeat task stopped"); + } + + // Disarm INA228 low-voltage alert (Rev 1.1) + disarmLowVoltageAlert(); + + delay(200); + MESH_DEBUG_PRINTLN("Background cleanup complete"); +} + +void BoardConfigContainer::heartbeatTask(void* pvParameters) { + (void)pvParameters; + + pinMode(LED_BLUE, OUTPUT); + + while (true) { + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + } + vTaskDelay(pdMS_TO_TICKS(10)); // 10ms flash - well visible, minimal power + if (leds_enabled) { + digitalWrite(LED_BLUE, LOW); + } + vTaskDelay(pdMS_TO_TICKS(5000)); // 5s interval - lower power consumption + } +} + +/// @brief Enable or disable heartbeat LED and BQ25798 stat LED +/// @param enabled true = LEDs enabled, false = LEDs disabled +/// @return true on success +bool BoardConfigContainer::setLEDsEnabled(bool enabled) { + leds_enabled = enabled; + + // Save to filesystem + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString("leds_en", enabled ? "1" : "0"); + prefs.end(); + + // Control heartbeat task + if (enabled) { + // Start heartbeat if not running + if (heartbeatTaskHandle == NULL) { + xTaskCreate(heartbeatTask, "Heartbeat", 512, NULL, 1, &heartbeatTaskHandle); + } + } else { + // Stop heartbeat task + if (heartbeatTaskHandle != NULL) { + vTaskDelete(heartbeatTaskHandle); + heartbeatTaskHandle = NULL; + // Turn off LED + pinMode(LED_BLUE, OUTPUT); + digitalWrite(LED_BLUE, LOW); + } + } + + // Control BQ25798 STAT LED (only if BQ is initialized) + if (BQ_INITIALIZED && bqDriverInstance) { + bqDriverInstance->setStatPinEnable(enabled); + } + + return true; +} + +/// @brief Get current LED enable state +/// @return true if LEDs are enabled +bool BoardConfigContainer::getLEDsEnabled() const { + return leds_enabled; +} + +/// @brief Updates MPPT statistics based on elapsed time and current status +/// Should be called when MPPT status changes or periodically for time accounting +void BoardConfigContainer::updateMpptStats() { + if (!bqDriverInstance) return; + + static bool lastMpptStatus = false; + static bool initialized = false; + + // Get current time - prefer RTC, fallback to millis() + uint32_t currentTime; + uint32_t rtcTime = getRTCTime(); + + // Check if RTC is initialized (returns > 0 if time was set) + // AutoDiscoverRTCClock returns 0 if no RTC found and time not set + if (rtcTime > 1000000000) { // Sanity check: After year 2001 + currentTime = rtcTime; + if (!mpptStats.usingRTC) { + // Switch from millis to RTC + mpptStats.usingRTC = true; + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = bqDriverInstance->getMPPTenable(); + initialized = true; + return; // Reset timing on switch + } + } else { + // RTC not available or not set - use millis() in seconds + currentTime = millis() / 1000; + } + + bool currentMpptStatus = bqDriverInstance->getMPPTenable(); + + // Initialize on first run + if (!initialized) { + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + initialized = true; + return; + } + + // Calculate elapsed time since last update + uint32_t elapsedSeconds = currentTime - mpptStats.lastUpdateTime; + + // Sanity check: If more than 48 hours passed, reset + const uint32_t MAX_INTERVAL_SEC = 48UL * 60UL * 60UL; + if (elapsedSeconds > MAX_INTERVAL_SEC) { + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + return; + } + + uint32_t elapsedMinutes = elapsedSeconds / 60; + + if (elapsedMinutes == 0 && lastMpptStatus == currentMpptStatus) { + return; // No time passed and no status change + } + + // Add time to current hour accumulator if MPPT was enabled + if (lastMpptStatus && elapsedMinutes > 0) { + mpptStats.currentHourMinutes += elapsedMinutes; + if (mpptStats.currentHourMinutes > 60) { + mpptStats.currentHourMinutes = 60; // Cap at 60 minutes per hour + } + } + + // Calculate energy harvested since last update if MPPT was enabled + if (lastMpptStatus && elapsedSeconds > 0) { + // Use last measured power and integrate over time: E = P × t + // Energy in mWh = Power in mW × Time in hours + float hours = elapsedSeconds / 3600.0f; + uint32_t energy_mWh = (uint32_t)(mpptStats.lastPower_mW * hours); + mpptStats.currentHourEnergy_mWh += energy_mWh; + } + + // Sample current solar power for next integration period + if (currentMpptStatus) { + uint16_t vbat_mppt = ina228DriverInstance ? ina228DriverInstance->readVoltage_mV() : 0; + const Telemetry* telem = bqDriverInstance->getTelemetryData(vbat_mppt); + if (telem) { + // Calculate power: P = U * I (both in mV and mA, result in mW) + mpptStats.lastPower_mW = (int32_t)telem->solar.voltage * telem->solar.current / 1000; + } + } else { + mpptStats.lastPower_mW = 0; // No power when MPPT disabled + } + + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + + // Check if we need to move to the next hour + static uint32_t lastHourCheck = 0; + uint32_t currentHour = currentTime / 3600; + uint32_t lastHour = lastHourCheck / 3600; + + if (currentHour > lastHour) { + // Store the completed hour's data + mpptStats.hours[mpptStats.currentIndex].mpptEnabledMinutes = mpptStats.currentHourMinutes; + mpptStats.hours[mpptStats.currentIndex].timestamp = currentTime; + mpptStats.hours[mpptStats.currentIndex].harvestedEnergy_mWh = mpptStats.currentHourEnergy_mWh; + + // Move to next index (circular buffer) + mpptStats.currentIndex = (mpptStats.currentIndex + 1) % MPPT_STATS_HOURS; + + // Reset for new hour + mpptStats.currentHourMinutes = 0; + mpptStats.currentHourEnergy_mWh = 0; + lastHourCheck = currentTime; + } +} + +/// @brief Returns current max charge current as string +/// @return Static string buffer with charge current in mA +const char* BoardConfigContainer::getChargeCurrentAsStr() { + static char buffer[16]; + snprintf(buffer, sizeof(buffer), "%dmA", this->getMaxChargeCurrent_mA()); + return buffer; +} + +/// @brief Writes charger status information into provided buffer +/// @param buffer Destination buffer for status string +/// @param bufferSize Size of destination buffer +void BoardConfigContainer::getChargerInfo(char* buffer, uint32_t bufferSize) { + // Check if buffer is valid + if (!buffer || bufferSize == 0) { + return; + } + + // Clear buffer to prevent garbage data + memset(buffer, 0, bufferSize); + + // Check if BQ25798 is initialized and responsive + if (!BQ_INITIALIZED) { + snprintf(buffer, bufferSize, "BQ25798 not initialized"); + return; + } + + const char* powerGood = bq.getChargerStatusPowerGood() ? "PG" : "!PG"; + const char* statusString = "Unknown"; // Initialize with default value + bq25798_charging_status status = bq.getChargingStatus(); + + switch (status) { + case bq25798_charging_status::BQ25798_CHARGER_STATE_NOT_CHARGING: { + statusString = "!CHG"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_PRE_CHARGING: { + statusString = "PRE"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_CC_CHARGING: { + statusString = "CC"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_CV_CHARGING: { + statusString = "CV"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_TRICKLE_CHARGING: { + statusString = "TRICKLE"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_TOP_OF_TIMER_ACTIVE_CHARGING: { + statusString = "TOP"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_DONE_CHARGING: { + statusString = "DONE"; + break; + } + default: + statusString = "Unknown"; + break; + } + + if (lastPgStuckToggleTime == 0) { + snprintf(buffer, bufferSize, "%s / %s HIZ:never", powerGood, statusString); + } else { + uint32_t agoSec = (millis() - lastPgStuckToggleTime) / 1000; + if (agoSec < 60) { + snprintf(buffer, bufferSize, "%s / %s HIZ:%ds ago", powerGood, statusString, agoSec); + } else if (agoSec < 3600) { + snprintf(buffer, bufferSize, "%s / %s HIZ:%dm ago", powerGood, statusString, agoSec / 60); + } else { + snprintf(buffer, bufferSize, "%s / %s HIZ:%dh ago", powerGood, statusString, agoSec / 3600); + } + } +} + +/// @brief Reads BQ25798 status/fault registers and produces a compact diagnostic string +/// @details Register layout (BQ25798 datasheet SLUSDV2B): +/// 0x1B STATUS_0: IINDPM[7] VINDPM[6] WD[5] rsvd[4] PG[3] AC2[2] AC1[1] VBUS[0] +/// 0x1C STATUS_1: CHG_STAT[7:5] VBUS_STAT[4:1] BC12[0] +/// 0x1D STATUS_2: ICO[7:6] rsvd[5:3] TREG[2] DPDM[1] VBAT_PRESENT[0] +/// 0x1E STATUS_3: ACRB2[7] ACRB1[6] ADC_DONE[5] VSYS[4] CHG_TMR[3] TRICHG_TMR[2] PRECHG_TMR[1] rsvd[0] +/// 0x1F STATUS_4: rsvd[7:5] VBATOTG_LOW[4] TS_COLD[3] TS_COOL[2] TS_WARM[1] TS_HOT[0] +/// 0x20 FAULT_0: IBAT_REG[7] VBUS_OVP[6] VBAT_OVP[5] IBUS_OCP[4] IBAT_OCP[3] CONV_OCP[2] VAC2_OVP[1] VAC1_OVP[0] +/// 0x21 FAULT_1: rsvd[7] OTG_UVP[6] OTG_OVP[5] rsvd[4] VSYS_SHORT[3] VSYS_OVP[2] rsvd[1:0] +/// 0x0F CTRL_0: AUTO_IBATDIS[7] FORCE_IBATDIS[6] EN_CHG[5] EN_ICO[4] FORCE_ICO[3] EN_HIZ[2] EN_TERM[1] EN_BACKUP[0] +/// 0x18 NTC_1: TS_COOL[7:6] TS_WARM[5:4] BHOT[3:2] BCOLD[1] TS_IGNORE[0] +/// @param buffer Destination buffer (min 100 bytes recommended) +/// @param bufferSize Size of destination buffer +bool BoardConfigContainer::probeRtc() { + // Address ACK + Wire.beginTransmission(0x52); + if (Wire.endTransmission() != 0) return false; + + // User-RAM 0x1F write/readback with two patterns (catches stuck bits). + // Save original first, restore afterwards — keeps user data intact. + Wire.beginTransmission(0x52); + Wire.write(0x1F); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)0x52, (uint8_t)1) != 1) return false; + uint8_t saved = Wire.read(); + + for (uint8_t pat : {0xA5, 0x5A}) { + Wire.beginTransmission(0x52); + Wire.write(0x1F); + Wire.write(pat); + if (Wire.endTransmission() != 0) return false; + + Wire.beginTransmission(0x52); + Wire.write(0x1F); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)0x52, (uint8_t)1) != 1) return false; + if (Wire.read() != pat) return false; + } + + // Restore original byte + Wire.beginTransmission(0x52); + Wire.write(0x1F); + Wire.write(saved); + Wire.endTransmission(); + return true; +} + +namespace { + bool probeI2CAddr(uint8_t addr) { + Wire.beginTransmission(addr); + return Wire.endTransmission() == 0; + } +} + +void BoardConfigContainer::getSelfTest(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + const char* ina = probeI2CAddr(0x40) ? "OK" : "NACK"; + const char* bq = probeI2CAddr(BQ25798_I2C_ADDR) ? "OK" : "NACK"; + const char* bme = probeI2CAddr(0x76) ? "OK" : "NACK"; + + // RTC: distinguish bus-NACK from write-failure + const char* rtc; + if (!probeI2CAddr(0x52)) { + rtc = "NACK"; + } else if (!probeRtc()) { + rtc = "WR_FAIL"; + } else { + rtc = "OK"; + } + + snprintf(buffer, bufferSize, "INA:%s BQ:%s RTC:%s BME:%s", ina, bq, rtc, bme); +} + +void BoardConfigContainer::getBqDiagnostics(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + memset(buffer, 0, bufferSize); + + if (!BQ_INITIALIZED) { + snprintf(buffer, bufferSize, "BQ not init"); + return; + } + + // Read status registers (read-only, safe to read) + uint8_t s0 = bq.readReg(0x1B); // CHARGER_STATUS_0 + uint8_t s1 = bq.readReg(0x1C); // CHARGER_STATUS_1 + uint8_t s2 = bq.readReg(0x1D); // CHARGER_STATUS_2 + uint8_t s3 = bq.readReg(0x1E); // CHARGER_STATUS_3 + uint8_t s4 = bq.readReg(0x1F); // CHARGER_STATUS_4 + uint8_t f0 = bq.readReg(0x20); // FAULT_STATUS_0 + uint8_t f1 = bq.readReg(0x21); // FAULT_STATUS_1 + + // Read control registers + uint8_t ctrl0 = bq.readReg(0x0F); // CHARGER_CONTROL_0: EN_CHG[5], EN_HIZ[2] + uint8_t ntc1 = bq.readReg(0x18); // NTC_CONTROL_1 + + // Decode TS region from STATUS_4 (0x1F): TS_COLD[3] TS_COOL[2] TS_WARM[1] TS_HOT[0] + const char* ts_str = "OK"; + if (s4 & 0x01) ts_str = "HOT"; + else if (s4 & 0x02) ts_str = "WARM"; + else if (s4 & 0x04) ts_str = "COOL"; + else if (s4 & 0x08) ts_str = "COLD"; + + // Build active-flags substring (only show abnormal conditions) + char flags[50] = ""; + int pos = 0; + if (s0 & 0x80) pos += snprintf(flags + pos, sizeof(flags) - pos, " IINDPM"); + if (s0 & 0x40) pos += snprintf(flags + pos, sizeof(flags) - pos, " VINDPM"); + if (s0 & 0x20) pos += snprintf(flags + pos, sizeof(flags) - pos, " WD!"); + if (s2 & 0x04) pos += snprintf(flags + pos, sizeof(flags) - pos, " TREG"); + if (s3 & 0x08) pos += snprintf(flags + pos, sizeof(flags) - pos, " CHG_TMR"); + if (s3 & 0x04) pos += snprintf(flags + pos, sizeof(flags) - pos, " TCTMR"); + if (s3 & 0x02) pos += snprintf(flags + pos, sizeof(flags) - pos, " PCTMR"); + if (f0 & 0x40) pos += snprintf(flags + pos, sizeof(flags) - pos, " VBUS_OVP"); + if (f0 & 0x20) pos += snprintf(flags + pos, sizeof(flags) - pos, " VBAT_OVP"); + + bool en_chg = (ctrl0 >> 5) & 1; // EN_CHG: bit 5 of CHARGER_CONTROL_0 + bool en_hiz = (ctrl0 >> 2) & 1; // EN_HIZ: bit 2 of CHARGER_CONTROL_0 + + // Read actual IINDPM from REG06 for verification + uint16_t iindpm_mA = (uint16_t)(bq.getInputLimitA() * 1000); + + // Compact output: TS region, active flags, control bits, IINDPM readback, faults, raw status hex, NTC config + snprintf(buffer, bufferSize, + "TS:%s%s CE:%d HIZ:%d IINDPM:%umA F:%02X/%02X S:%02X.%02X.%02X.%02X.%02X N:%02X", + ts_str, flags, en_chg, en_hiz, iindpm_mA, f0, f1, s0, s1, s2, s3, s4, ntc1); +} + +/// @brief Initializes battery manager, preferences, and background tasks +/// @return true if BQ25798 initialized successfully +bool BoardConfigContainer::begin() { + // Initialize LEDs early for boot sequence visualization + pinMode(LED_BLUE, OUTPUT); // Blue LED (P1.03) + pinMode(LED_RED, OUTPUT); // Red LED (P1.04) + digitalWrite(LED_BLUE, LOW); + digitalWrite(LED_RED, LOW); + + // Load LED enable state from filesystem (default: enabled) + SimplePreferences prefs_led; + if (prefs_led.begin("inheromr2")) { + char led_buffer[8]; + prefs_led.getString("leds_en", led_buffer, sizeof(led_buffer), "1"); + leds_enabled = (strcmp(led_buffer, "1") == 0); + prefs_led.end(); + } else { + leds_enabled = true; // Default: enabled + } + + bool skip_fs_writes = ((NRF_POWER->GPREGRET2 & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE); + + // === MR2 Hardware (Rev 1.1): INA228 Power Monitor with ALERT-based low-voltage sleep === + // MR2 uses INA228 at 0x40 (A0=GND, A1=GND) + MESH_DEBUG_PRINTLN("=== INA228 Detection @ 0x40 ==="); + delay(10); // Let serial output flush + + // Visual indicator: Red LED on = INA228 detection in progress + if (leds_enabled) { + digitalWrite(LED_RED, HIGH); + delay(50); + } + + // First test I2C communication + Wire.beginTransmission(0x40); + uint8_t i2c_result = Wire.endTransmission(); + MESH_DEBUG_PRINTLN("INA228: I2C probe result = %d (0=OK)", i2c_result); + delay(10); + + if (i2c_result == 0) { + // Device responds, read ID registers + Wire.beginTransmission(0x40); + Wire.write(0x3E); // Manufacturer ID register + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + if (Wire.available() >= 2) { + uint16_t mfg_id = (Wire.read() << 8) | Wire.read(); + MESH_DEBUG_PRINTLN("INA228: MFG_ID = 0x%04X (expect 0x5449)", mfg_id); + delay(10); + } + + Wire.beginTransmission(0x40); + Wire.write(0x3F); // Device ID register + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + if (Wire.available() >= 2) { + uint16_t dev_id = (Wire.read() << 8) | Wire.read(); + MESH_DEBUG_PRINTLN("INA228: DEV_ID = 0x%04X (expect 0x0228)", dev_id); + delay(10); + } + + // Try to initialize + if (ina228.begin(100.0f)) { // 100mΩ shunt resistor (optimal SNR for 10mA standby / 1A max) + INA228_INITIALIZED = true; + ina228DriverInstance = &ina228; + + // Turn off red LED (INA228 detection complete) + if (leds_enabled) { + digitalWrite(LED_RED, LOW); + delay(10); + } + + // Blue LED flash: INA228 initialized + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + + // Arm INA228 low-voltage alert for this battery chemistry + // Rev 1.1: Always active when battery type is configured (no CLI toggle) + // ISR on ALERT pin → volatile flag → tickPeriodic() → System Sleep with GPIO latch + armLowVoltageAlert(); + + // NOTE: Low-voltage recovery SOC=0% is handled in InheroMr2Board::begin() + // (after setLowVoltageRecovery()), not here, because lowVoltageRecovery isn't set yet. + } else { + MESH_DEBUG_PRINTLN("✗ INA228 begin() failed (check MFG_ID/DEV_ID above)"); + INA228_INITIALIZED = false; + } + } else { + MESH_DEBUG_PRINTLN("✗ INA228 no I2C ACK @ 0x40"); + INA228_INITIALIZED = false; + } + delay(10); + + // Initialize BQ25798 + if (bq.begin()) { + BQ_INITIALIZED = true; + bqDriverInstance = &bq; + MESH_DEBUG_PRINTLN("BQ25798 found. "); + + // Blue LED flash: BQ25798 initialized + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + } else { + MESH_DEBUG_PRINTLN("BQ25798 not found."); + BQ_INITIALIZED = false; + } + + // Load NTC temperature calibration offset (applies to all BQ temperature readings) + float tc_offset = 0.0f; + if (loadTcCalOffset(tc_offset)) { + tcCalOffset = tc_offset; + MESH_DEBUG_PRINTLN("TC calibration offset loaded: %+.2f C", tc_offset); + } else { + MESH_DEBUG_PRINTLN("TC using default calibration (0.0)"); + } + + // === RV-3028 RTC Initialization === + // Address probe + user-RAM write/readback test (catches "zombie" RTCs that + // ACK on bus but reject writes — see probeRtc() for details). + // Retry up to 3 times — after OTA/warm-reset the I2C bus may need recovery. + bool rtc_initialized = false; + for (int attempt = 0; attempt < 3; attempt++) { + if (probeRtc()) { + rtc_initialized = true; + MESH_DEBUG_PRINTLN("RV-3028 RTC OK (attempt %d)", attempt + 1); + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + break; + } + MESH_DEBUG_PRINTLN("RV-3028 RTC self-test failed (attempt %d)", attempt + 1); + delay(20); + } + + // === MR2 Configuration === + SimplePreferences prefs_init; + prefs_init.begin(PREFS_NAMESPACE); + + BatteryType bat = DEFAULT_BATTERY_TYPE; + FrostChargeBehaviour frost = DEFAULT_FROST_BEHAVIOUR; + uint16_t maxChargeCurrent_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + + if (!loadBatType(bat)) { + if (!skip_fs_writes) { + prefs_init.putString(BATTKEY, getBatteryTypeCommandString(bat)); + } + } + if (!loadFrost(frost)) { + if (!skip_fs_writes) { + prefs_init.putString(FROSTKEY, getFrostChargeBehaviourCommandString(frost)); + } + } + if (!loadMaxChrgI(maxChargeCurrent_mA)) { + if (!skip_fs_writes) { + prefs_init.putInt(MAXCHARGECURRENTKEY, maxChargeCurrent_mA); + } + } + + this->configureBaseBQ(); + this->configureChemistry(bat); + cachedBatteryType = bat; // Cache for static methods (updateBatterySOC, calculateTTL) + + // Charger active by default — HIZ-Gate removed (Rev 1.1 PCB stable). + bq.setHIZMode(false); + + this->setFrostChargeBehaviour(frost); + this->setMaxChargeCurrent_mA(maxChargeCurrent_mA); + + // Mask ALL BQ25798 interrupts — INT pin is not used (polling only). + // Default mask registers are 0x00 (all unmasked!) → every event pulls INT LOW. + // With INPUT_PULLUP on BQ_INT_PIN: LOW = ~254µA wasted through pull-up. + bq.writeReg(0x28, 0xFF); // Charger Mask 0 — mask all + bq.writeReg(0x29, 0xFF); // Charger Mask 1 — mask all + bq.writeReg(0x2A, 0xFF); // Charger Mask 2 — mask all + bq.writeReg(0x2B, 0xFF); // Charger Mask 3 — mask all + bq.writeReg(0x2C, 0xFF); // Fault Mask 0 — mask all + bq.writeReg(0x2D, 0xFF); // Fault Mask 1 — mask all + + // Clear any latched flag/interrupt status from previous operation/boot + // Flag registers are read-to-clear and de-assert the INT pin. + // (0x1B/0x20/0x21 are STATUS registers — read-only, do NOT clear flags!) + bq.readReg(0x22); // CHARGER_FLAG_0 — read-to-clear + bq.readReg(0x23); // CHARGER_FLAG_1 + bq.readReg(0x24); // CHARGER_FLAG_2 + bq.readReg(0x25); // CHARGER_FLAG_3 + bq.readReg(0x26); // FAULT_FLAG_0 + bq.readReg(0x27); // FAULT_FLAG_1 + + // Heartbeat LED task (GPIO only — no I2C, safe as FreeRTOS task) + if (heartbeatTaskHandle == NULL && leds_enabled) { + BaseType_t taskCreated = xTaskCreate(BoardConfigContainer::heartbeatTask, "Heartbeat", 1024, NULL, 1, &heartbeatTaskHandle); + if (taskCreated != pdPASS) { + MESH_DEBUG_PRINTLN("Failed to create Heartbeat task!"); + return false; + } + } + + // BQ_INT_PIN no longer used — solar checks run via polling in tickPeriodic() + // Pull up to prevent floating trace on PCB + pinMode(BQ_INT_PIN, INPUT_PULLUP); + + // Check if all critical components initialized + bool all_components_ok = BQ_INITIALIZED && INA228_INITIALIZED && rtc_initialized; + + if (!all_components_ok) { + // Start permanent slow red LED blink to indicate missing component + MESH_DEBUG_PRINTLN("⚠️ Missing components - starting error LED"); + if (!BQ_INITIALIZED) MESH_DEBUG_PRINTLN(" - BQ25798 missing"); + if (!INA228_INITIALIZED) MESH_DEBUG_PRINTLN(" - INA228 missing"); + if (!rtc_initialized) MESH_DEBUG_PRINTLN(" - RV-3028 RTC missing"); + + // Create error LED blink task (GPIO only) + if (leds_enabled) { + xTaskCreate([](void* param) { + while (1) { + digitalWrite(LED_RED, HIGH); // Red LED on + vTaskDelay(pdMS_TO_TICKS(500)); + digitalWrite(LED_RED, LOW); // Red LED off + vTaskDelay(pdMS_TO_TICKS(500)); + } + }, "ErrorLED", 512, NULL, 1, NULL); + } + } + + // MPPT, SOC updates, and voltage monitoring are handled in tickPeriodic() + // (called from InheroMr2Board::tick() — no FreeRTOS tasks doing I2C) + + // Load battery capacity from preferences (or default based on chemistry) + float cap_mah = 0.0f; + loadBatteryCapacity(cap_mah); + socStats.capacity_mah = cap_mah; + socStats.nominal_voltage = getNominalVoltage(bat); + MESH_DEBUG_PRINTLN("SOC: capacity=%.0f mAh, nominal=%.2f V", cap_mah, socStats.nominal_voltage); + + // MR2 requires BQ25798 + INA228 (RTC is optional for basic operation) + return BQ_INITIALIZED && INA228_INITIALIZED; +} + +/// @brief Loads battery type from preferences +/// @param type Reference to store loaded battery type +/// @return true if preference found and valid, false if default used +bool BoardConfigContainer::loadBatType(BatteryType& type) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + if (prefs.getString(BATTKEY, buffer, sizeof(buffer), "") > 0) { + type = this->getBatteryTypeFromCommandString(buffer); + if (type != BAT_UNKNOWN) { + return true; + } else { + type = DEFAULT_BATTERY_TYPE; + return false; + } + } + + // No preference found - use default + type = DEFAULT_BATTERY_TYPE; + return false; +} + +/// @brief Loads frost charge behavior from preferences +/// @param behaviour Reference to store loaded behavior +/// @return true if preference found and valid, false if default used +bool BoardConfigContainer::loadFrost(FrostChargeBehaviour& behaviour) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + if (prefs.getString(FROSTKEY, buffer, sizeof(buffer), "") > 0) { + behaviour = this->getFrostChargeBehaviourFromCommandString(buffer); + if (behaviour != REDUCE_UNKNOWN) { + return true; + } else { + behaviour = DEFAULT_FROST_BEHAVIOUR; + return false; + } + } + + // No preference found - use default + behaviour = DEFAULT_FROST_BEHAVIOUR; + return false; +} + +/// @brief Loads maximum charge current from preferences +/// @param maxCharge_mA Reference to store loaded current in mA +/// @return true if preference found and valid (1-3000mA), false if default used +bool BoardConfigContainer::loadMaxChrgI(uint16_t& maxCharge_mA) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + + if (prefs.getString(MAXCHARGECURRENTKEY, buffer, sizeof(buffer), "") > 0) { + + int val = atoi(buffer); + // Bounds check: Reasonable charge current range + if (val > 0 && val <= 3000) { // Max 3A for safety + maxCharge_mA = val; + return true; + } else { + maxCharge_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + return false; + } + } + + // No preference found - use default + maxCharge_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + return false; +} + +/// @brief Loads MPPT enabled setting from preferences +/// @param enabled Reference to store loaded setting +/// @return true if preference found, false if default used +bool BoardConfigContainer::loadMpptEnabled(bool& enabled) { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + + if (prefs.getString("mpptEn", buffer, sizeof(buffer), "") > 0) { + if (buffer[0] != '\0') { + enabled = buffer[0] == '1' ? true : false; + return true; + } else { + enabled = DEFAULT_MPPT_ENABLED; + return false; + } + } + + // No preference found - use default + enabled = DEFAULT_MPPT_ENABLED; + return false; +} + +/// @brief Returns combined telemetry from INA228 (battery) and BQ25798 (solar + temperature) +/// @note Battery voltage/current from INA228 (24-bit ADC, ±0.1% accuracy) +/// Solar data and battery temperature from BQ25798 ADC +/// +/// Temperature availability depends on power conditions: +/// VBUS > 3.4V → BQ25798 ADC runs → temperature available +/// VBAT >= 3.2V → BQ25798 ADC runs → temperature available +/// VBAT < 3.2V → TS channel disabled (datasheet 9.3.16) → temperature = N/A +/// VBAT < 2.9V → ADC cannot operate at all → temperature = N/A, solar = 0 +/// +/// Temperature sentinel values (propagated from BqDriver::calculateBatteryTemp): +/// -999.0f = I2C communication error or NTC unavailable +/// -888.0f = ADC not ready / TS disabled due to low VBAT +/// -99.0f = NTC open circuit (disconnected) +/// 99.0f = NTC short circuit +/// Values outside -50..+90°C are treated as invalid → displayed as "N/A" +const Telemetry* BoardConfigContainer::getTelemetryData() { + static Telemetry telemetry; + + // Battery voltage/current ALWAYS from INA228 (no fallback to BQ25798) + // INA228 for precise battery monitoring + uint16_t batt_voltage = 0; + float batt_current = 0.0f; + int32_t batt_power = 0; + if (ina228DriverInstance != nullptr) { + batt_voltage = ina228DriverInstance->readVoltage_mV(); + batt_current = ina228DriverInstance->readCurrent_mA_precise(); + batt_power = (int32_t)((batt_voltage * batt_current) / 1000.0f); + } + + // Get base telemetry from BQ25798 (solar data + temperature) + // Pass VBAT so BqDriver can disable TS channel when VBAT < 3.2V + // (BQ25798 ADC requires VBAT >= 3.2V with TS enabled, else ADC won't start) + const Telemetry* bqData = bq.getTelemetryData(batt_voltage); + if (!bqData) { + memset(&telemetry, 0, sizeof(Telemetry)); + return &telemetry; + } + + // Copy BQ25798 data (solar, system) + telemetry.solar = bqData->solar; + telemetry.system = bqData->system; + + // Temperature: BQ25798 TS ADC reads NTC via REGN-biased divider. + // Error codes from calculateBatteryTemp: -999 (I2C), -888 (ADC not ready), -99 (open), 99 (short). + // Valid NTC range: approx -40..+85 °C. Anything outside -50..+90 is treated as unavailable. + float bqTemp = bqData->batterie.temperature; + if (bqTemp >= -50.0f && bqTemp <= 90.0f) { + telemetry.batterie.temperature = bqTemp + tcCalOffset; + // Cache for temperature derating in updateBatterySOC() (static context) + lastValidBatteryTemp = telemetry.batterie.temperature; + lastTempUpdateMs = millis(); + } else { + // NTC unavailable (no solar / I2C error / ADC not ready) → propagate sentinel + telemetry.batterie.temperature = -999.0f; + } + + telemetry.batterie.voltage = batt_voltage; + telemetry.batterie.current = batt_current; + telemetry.batterie.power = batt_power; + + return &telemetry; +} + +/// @brief Configures base BQ25798 settings (timers, watchdog, input limits, MPPT) +/// @return true if BQ initialized and configuration successful +bool BoardConfigContainer::configureBaseBQ() { + if (!BQ_INITIALIZED) { + return false; + } + + bq.setRechargeThreshOffsetV(.2); + bq.setPrechargeTimerEnable(false); + bq.setFastChargeTimerEnable(false); + bq.setTsIgnore(false); + bq.setWDT(BQ25798_WDT_DISABLE); + bq.setExtILIMpin(false); // Disable ILIM_HIZ pin clamp — IINDPM managed by software + bq.setInputLimitA(IINDPM_MAX_A); // Safe default before chemistry is known; updateSolarIINDPM() refines later + bq.setICOEnable(false); // Disable ICO — IINDPM is explicitly managed, ICO must not overwrite it + + bq.setVOCdelay(BQ25798_VOC_DLY_2S); + bq.setVOCrate(BQ25798_VOC_RATE_2MIN); + bq.setVOCpercent(BQ25798_VOC_PCT_81_25); // 81.25% matches Vmp/Voc of typical crystalline Si panels (~80-83%) + bq.setAutoDPinsDetection(false); + bq.setMPPTenable(true); + + bq.setMinSystemV(2.75); // 2.75V = next valid step above 2.7V (250mV steps: 2.5, 2.75, 3.0...) + bq.setStatPinEnable(leds_enabled); // Configure STAT LED based on user preference + bq.setTsCool(BQ25798_TS_COOL_5C); + bq.setTsWarm(BQ25798_TS_WARM_55C); // 37.7% REGN → ~52°C with Inhero divider (default 45°C was ~42°C) + + // JEITA WARM zone: keep VREG unchanged (no voltage reduction). + // Default JEITA_VSET = VREG-400mV causes VBAT_OVP for LiFePO4 (3.5V - 0.4V = 3.1V, OVP at 3.22V) + // and is unnecessarily conservative for Li-Ion (4.1V - 0.4V = 3.7V). + // Both chemistries use safe charge voltages (4.1V / 3.5V), no reduction needed. + bq.setJeitaVSet(BQ25798_JEITA_VSET_UNCHANGED); + + // Disable auto battery discharge during VBAT_OVP (EN_AUTO_IBATDIS). + // POR default = enabled → BQ actively sinks 30mA from battery during OVP to lower VBAT. + // With JEITA_VSET fixed, VBAT_OVP should no longer trigger. Belt-and-suspenders safety. + bq.setAutoIBATDIS(false); + + // Flush stale ADC registers by running one discard conversion. + // After reboot (e.g. low-voltage recovery), BQ25798 retains old ADC values + // from before shutdown. A fresh one-shot ensures registers reflect actual state. + bq.getTelemetryData(0); // VBAT unknown at this point, assume sufficient + + return true; +} + +/// @brief Configures battery chemistry-specific parameters (cell count, charge voltage) +/// @param type Battery chemistry type (LIION_1S, LIFEPO4_1S, LTO_2S, BAT_UNKNOWN) +/// @return true if configuration successful +bool BoardConfigContainer::configureChemistry(BatteryType type) { + if (!BQ_INITIALIZED) { + return false; + } + + // Get battery properties from lookup table + const BatteryProperties* props = getBatteryProperties(type); + if (!props) { + MESH_DEBUG_PRINTLN("ERROR: Invalid battery type"); + return false; + } + + // Apply charge enable/disable based on battery type + bq.setChargeEnable(props->charge_enable); + + // CE-Pin hardware safety: Only pull CE HIGH (enable charging via FET) when chemistry is known + // Rev 1.1: DMN2004TK-7 N-FET inverts CE logic — HIGH=enable, LOW=disable + // External pull-down ensures CE stays LOW (charging disabled) when RAK is off or unbooted +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, props->charge_enable ? HIGH : LOW); + MESH_DEBUG_PRINTLN("BQ CE pin %s (charge_enable=%d)", props->charge_enable ? "HIGH (enabled via FET)" : "LOW (disabled via FET)", props->charge_enable); +#endif + + // Apply TS_IGNORE before potential early return — configureBaseBQ() resets it to false, + // so chemistries with ts_ignore=true (BAT_UNKNOWN, LTO, NAION) need it set here. + bq.setTsIgnore(props->ts_ignore); + if (props->ts_ignore) { + bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); + bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED); + } + + if (!props->charge_enable) { + MESH_DEBUG_PRINTLN("WARNING: Battery type UNKNOWN - Charging DISABLED for safety!"); + return true; // No further configuration needed for unknown battery + } + + // Configure chemistry-specific parameters + // Configure cell count + bq25798_cell_count_t cellCount = (type == BoardConfigContainer::BatteryType::LTO_2S) ? BQ25798_CELL_COUNT_2S : BQ25798_CELL_COUNT_1S; + bq.setCellCount(cellCount); + + bq.setChargeLimitV(props->charge_voltage); + + return true; +} + +/// @brief Gets current battery type from preferences +/// @return Current battery chemistry type, defaults to DEFAULT_BATTERY_TYPE if read fails +BoardConfigContainer::BatteryType BoardConfigContainer::getBatteryType() const { + BatteryType bat; + if (loadBatType(bat)) { + return bat; + } else { + return DEFAULT_BATTERY_TYPE; + } +} + +/// @brief Gets current frost charge behavior from preferences +/// @return Frost charge behavior, defaults to NO_CHARGE if read fails +BoardConfigContainer::FrostChargeBehaviour BoardConfigContainer::getFrostChargeBehaviour() const { + FrostChargeBehaviour frost; + if (loadFrost(frost)) { + return frost; + } else { + return NO_CHARGE; + } +} + +/// @brief Gets maximum charge current from preferences +/// @return Maximum charge current in mA, defaults to DEFAULT_MAX_CHARGE_CURRENT_MA (200mA) if read fails +uint16_t BoardConfigContainer::getMaxChargeCurrent_mA() const { + uint16_t maxI = 100; + loadMaxChrgI(maxI); + return maxI; +} + +/// @brief Gets current MPPT enable status from preferences +/// @return true if MPPT enabled in configuration +bool BoardConfigContainer::getMPPTEnabled() const { + bool enabled; + loadMpptEnabled(enabled); + return enabled; +} + +/// @brief Enables or disables MPPT +/// @param enableMPPT true to enable MPPT +/// @return true if successful +bool BoardConfigContainer::setMPPTEnable(bool enableMPPT) { + // Save to preferences first + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + if (!prefs.putString(MPPTENABLEKEY, enableMPPT ? "1" : "0")) { + return false; + } + + // Set the hardware register + if (!enableMPPT) { + // Disable MPPT in hardware + bq.setMPPTenable(false); + } else { + // Enable MPPT in hardware register + bq.setMPPTenable(true); + } + + return true; +} + +/// @brief Gets current maximum charge voltage +/// @return Charge voltage limit in V +float BoardConfigContainer::getMaxChargeVoltage() const { + return bq.getChargeLimitV(); +}; + +/// @brief Sets battery type and reconfigures BQ accordingly +/// @param type Battery chemistry type +/// @return true if all configurations successful +bool BoardConfigContainer::setBatteryType(BatteryType type) { + bool bqBaseConfigured = this->configureBaseBQ(); + bool bqConfigured = this->configureChemistry(type); + cachedBatteryType = type; // Update cache for static methods (updateBatterySOC, calculateTTL) + + // Invalidate SOC — voltage-to-SOC mapping changes with chemistry. + // SOC will remain NA until next "Charging Done" sync or manual set. + socStats.soc_valid = false; + socStats.nominal_voltage = getNominalVoltage(type); + + // Restore correct IINDPM — configureBaseBQ() sets safe 2A default, + // but USB must be capped to 500mA per USB 2.0 spec. + if (usbInputActive && bqDriverInstance) { + bqDriverInstance->setInputLimitA(IINDPM_USB_A); + MESH_DEBUG_PRINTLN("USB active: IINDPM restored to %dmA after chemistry change", (int)(IINDPM_USB_A * 1000)); + } else { + updateSolarIINDPM(); + } + + // === CRITICAL: Update INA228 low-voltage alert threshold when battery type changes === + if (ina228DriverInstance) { + armLowVoltageAlert(); + delay(10); + } + + // Store battery type in preferences + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(BATTKEY, getBatteryTypeCommandString(type)); + + // Safety: When switching to Li-Ion or LiFePO4, reset frost charge to NO_CHARGE + // These chemistries should not be charged at low temperatures + if (type == BatteryType::LIION_1S || type == BatteryType::LIFEPO4_1S) { + setFrostChargeBehaviour(FrostChargeBehaviour::NO_CHARGE); + } + + return bqBaseConfigured && bqConfigured; +} + +/// @brief Sets frost charge behavior (JEITA cold region) +/// @param behaviour Charging behavior at low temperature +/// @return true if successful +bool BoardConfigContainer::setFrostChargeBehaviour(FrostChargeBehaviour behaviour) { + switch (behaviour) { + case BoardConfigContainer::FrostChargeBehaviour::NO_CHARGE: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_SUSPEND); + break; + case BoardConfigContainer::FrostChargeBehaviour::NO_REDUCE: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); + break; + case BoardConfigContainer::FrostChargeBehaviour::I_REDUCE_TO_40: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_40_PERCENT); + break; + case BoardConfigContainer::FrostChargeBehaviour::I_REDUCE_TO_20: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_20_PERCENT); + break; + } + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(behaviour)); + return true; +} + +/// @brief Sets maximum charge current (ICHG) and recalculates solar IINDPM +/// @param maxChrgI Maximum charge current in mA +/// @return true if successful +/// @note Also calls updateSolarIINDPM() because IINDPM depends on ICHG. +bool BoardConfigContainer::setMaxChargeCurrent_mA(uint16_t maxChrgI) { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putInt(MAXCHARGECURRENTKEY, maxChrgI); + + bool ok = bq.setChargeLimitA(maxChrgI / 1000.0f); + + // Readback verification — detect silent I2C failures + float readback = bq.getChargeLimitA(); + uint16_t readback_mA = (uint16_t)(readback * 1000.0f + 0.5f); + + MESH_DEBUG_PRINTLN("ICHG: set=%dmA, readback=%dmA, ok=%d", maxChrgI, readback_mA, ok); + + if (readback_mA != maxChrgI) { + MESH_DEBUG_PRINTLN("WARNING: ICHG readback mismatch! Expected %d, got %d", maxChrgI, readback_mA); + } + + // Recalculate solar IINDPM — it depends on charge current + updateSolarIINDPM(); + + return ok; +} + +/// @brief Notify USB connection state change — adjusts IINDPM accordingly +/// @param connected true when USB VBUS detected, false when removed +/// @details USB: IINDPM = 500mA (USB 2.0 spec). No USB: IINDPM calculated from battery/charge config. +void BoardConfigContainer::setUsbConnected(bool connected) { + if (usbInputActive == connected) return; // No state change + usbInputActive = connected; + + if (!bqDriverInstance) return; + + if (connected) { + bqDriverInstance->setInputLimitA(IINDPM_USB_A); + MESH_DEBUG_PRINTLN("USB connected: IINDPM = %dmA", (int)(IINDPM_USB_A * 1000)); + } else { + updateSolarIINDPM(); + } +} + +/// @brief Calculate IINDPM for solar input from battery chemistry and charge current +/// @return IINDPM in Amps, capped at IINDPM_MAX_A (JST limit), min 100mA (BQ25798 register min) +/// @details Power conservation: I_in = IINDPM_MARGIN × (V_charge × I_charge) / V_panel. +/// Prevents weak panels from POORSRC fault after PG qualification. +float BoardConfigContainer::calculateSolarIINDPM() { + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + if (!props || !props->charge_enable) { + return IINDPM_MAX_A; // Unknown chemistry: use safe max + } + + // Read current imax from preferences + uint16_t imax_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + char buffer[10]; + if (prefs.getString(MAXCHARGECURRENTKEY, buffer, sizeof(buffer), "") > 0) { + int val = atoi(buffer); + if (val > 0 && val <= 3000) { + imax_mA = val; + } + } + + float i_charge_A = imax_mA / 1000.0f; + float v_charge = props->charge_voltage; + + // I_input = margin × (V_bat × I_bat) / V_panel + float iindpm = IINDPM_MARGIN * (v_charge * i_charge_A) / IINDPM_PANEL_V; + + // Clamp to hardware limits + if (iindpm > IINDPM_MAX_A) iindpm = IINDPM_MAX_A; + if (iindpm < 0.1f) iindpm = 0.1f; // BQ25798 register minimum + + return iindpm; +} + +/// @brief Apply calculated solar IINDPM to BQ25798 (skipped when USB active) +void BoardConfigContainer::updateSolarIINDPM() { + if (usbInputActive || !bqDriverInstance) return; + + float iindpm = calculateSolarIINDPM(); + bqDriverInstance->setInputLimitA(iindpm); + MESH_DEBUG_PRINTLN("Solar IINDPM = %dmA (Vchg=%.1fV, margin=%.1fx, Vpanel=%.0fV)", + (int)(iindpm * 1000), + getBatteryProperties(cachedBatteryType) ? getBatteryProperties(cachedBatteryType)->charge_voltage : 0.0f, + IINDPM_MARGIN, IINDPM_PANEL_V); +} + +/// @brief Calculates 7-day moving average of MPPT enabled percentage +/// @return Percentage (0.0-100.0) of time MPPT was enabled over last 7 days +float BoardConfigContainer::getMpptEnabledPercentage7Day() const { + // Return 0 if MPPT is disabled in config + bool mpptEnabled; + loadMpptEnabled(mpptEnabled); + if (!mpptEnabled) { + return 0.0f; + } + + uint32_t totalMinutes = 0; + uint32_t enabledMinutes = 0; + uint32_t validHours = 0; + + // Count backwards through the circular buffer + for (int i = 0; i < MPPT_STATS_HOURS; i++) { + int index = (mpptStats.currentIndex - 1 - i + MPPT_STATS_HOURS) % MPPT_STATS_HOURS; + + // Skip entries that haven't been filled yet (timestamp == 0) + if (mpptStats.hours[index].timestamp == 0) { + continue; + } + + validHours++; + enabledMinutes += mpptStats.hours[index].mpptEnabledMinutes; + } + + if (validHours == 0) { + return 0.0f; // No data yet + } + + totalMinutes = validHours * 60; // Each hour has 60 minutes + + return (enabledMinutes * 100.0f) / totalMinutes; +} + +// ===== Battery SOC & Coulomb Counter Methods ===== + +/// @brief Get current State of Charge in percent +/// @return SOC in % (0-100) +float BoardConfigContainer::getStateOfCharge() const { + return socStats.current_soc_percent; +} + +/// @brief Get nominal voltage for battery chemistry type +/// @param type Battery chemistry type +/// @return Nominal voltage in V (used for mAh → mWh conversion) +float BoardConfigContainer::getNominalVoltage(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->nominal_voltage : 3.7f; +} + +/// @brief Get battery capacity in mAh +/// @return Battery capacity (user-configured) +float BoardConfigContainer::getBatteryCapacity() const { + return socStats.capacity_mah; +} + +/// @brief Check if battery capacity was explicitly set via CLI +/// @return true if capacity was set in preferences, false if using default +bool BoardConfigContainer::isBatteryCapacitySet() const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + size_t len = prefs.getString(BATTERY_CAPACITY_KEY, buffer, sizeof(buffer), ""); + return (len > 0 && buffer[0] != '\0'); +} + +/// @brief Set battery capacity manually via CLI (converts to mWh internally) +/// @param capacity_mah Capacity in mAh (user input) +/// @return true if successful +bool BoardConfigContainer::setBatteryCapacity(float capacity_mah) { + if (capacity_mah < 100.0f || capacity_mah > 100000.0f) { + return false; // Sanity check + } + + // Store user-configured capacity in mAh + socStats.capacity_mah = capacity_mah; + + // Get nominal voltage for current chemistry + BatteryType batType = getBatteryType(); + float v_nominal = getNominalVoltage(batType); + socStats.nominal_voltage = v_nominal; + + // Invalidate SOC until next "Charging Done" sync + socStats.soc_valid = false; + + // Save to preferences + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + snprintf(buffer, sizeof(buffer), "%.1f", capacity_mah); + prefs.putString(BATTERY_CAPACITY_KEY, buffer); + + MESH_DEBUG_PRINTLN("Battery capacity set to %.0f mAh @ %.1fV", + capacity_mah, v_nominal); + return true; +} + +/// @brief Get Time To Live in hours +/// @details Based on the 7-day rolling average of daily net energy deficit (avg_7day_daily_net_mah), +/// calculated from hourly INA228 Coulomb-counter samples in a 168h ring buffer. +/// Formula: TTL = (current_soc% * capacity_mah) / abs(avg_7day_daily_net_mah) * 24h +/// @return Hours until battery empty (0 = not calculated, charging, or insufficient data) +uint16_t BoardConfigContainer::getTTL_Hours() const { + return socStats.ttl_hours; +} + +/// @brief Check if living on battery (net deficit) +/// @return true if using more than charging +bool BoardConfigContainer::isLivingOnBattery() const { + return socStats.living_on_battery; +} + +/// @brief Sync SOC to 100% after "Charging Done" event from BQ25798 +/// @details Resets INA228 Coulomb Counter baseline and marks SOC as valid +void BoardConfigContainer::syncSOCToFull() { + if (!ina228DriverInstance) { + return; + } + + // Reset INA228 Coulomb Counter (clears ENERGY and CHARGE registers) + ina228DriverInstance->resetCoulombCounter(); + + // Set baseline to 0 (we just reset the counter) + socStats.ina228_baseline_mah = 0; + socStats.last_soc_update_ms = millis(); // Reset time reference + + // Mark as fully charged + socStats.current_soc_percent = 100.0f; + socStats.soc_valid = true; + + // Update temperature derating factor immediately + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + uint32_t now_sync = millis(); + if (lastTempUpdateMs == 0 || (now_sync - lastTempUpdateMs) > 300000UL) { + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f) { + lastValidBatteryTemp = bmeTemp; + lastTempUpdateMs = now_sync; + } + } + socStats.temp_derating_factor = getTemperatureDerating(props, lastValidBatteryTemp); + socStats.last_battery_temp_c = lastValidBatteryTemp; + + MESH_DEBUG_PRINTLN("SOC: Synced to 100%% (Charging Done) - INA228 baseline reset, d=%.2f", + socStats.temp_derating_factor); +} + +/// @brief Manually set SOC to specific percentage (e.g. after reboot with known SOC) +/// @param soc_percent Desired SOC value (0-100) +/// @return true if successful, false if invalid parameters +bool BoardConfigContainer::setSOCManually(float soc_percent) { + if (!ina228DriverInstance) { + MESH_DEBUG_PRINTLN("SOC: Cannot set - INA228 not initialized"); + return false; + } + + // Validate SOC range + if (soc_percent < 0.0f || soc_percent > 100.0f) { + MESH_DEBUG_PRINTLN("SOC: Invalid value %.1f%% (must be 0-100)", soc_percent); + return false; + } + + if (socStats.capacity_mah <= 0) { + MESH_DEBUG_PRINTLN("SOC: Cannot set - battery capacity unknown"); + return false; + } + + // Read current CHARGE register value + float current_charge_mah = ina228DriverInstance->readCharge_mAh(); + + // Calculate remaining capacity at desired SOC + float remaining_mah = (soc_percent / 100.0f) * socStats.capacity_mah; + + // Calculate baseline: charge_mah = baseline + net_charge + // We want: remaining_mah = capacity + net_charge = capacity + (charge - baseline) + // Therefore: baseline = charge - (remaining - capacity) + socStats.ina228_baseline_mah = current_charge_mah - (remaining_mah - socStats.capacity_mah); + socStats.last_soc_update_ms = millis(); // Reset time reference + + // Set SOC and mark as valid + socStats.current_soc_percent = soc_percent; + socStats.soc_valid = true; + + // Update temperature derating factor immediately so telem/TTL are correct + // without waiting for the next periodic updateBatterySOC() cycle. + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + uint32_t now_manual = millis(); + if (lastTempUpdateMs == 0 || (now_manual - lastTempUpdateMs) > 300000UL) { + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f) { + lastValidBatteryTemp = bmeTemp; + lastTempUpdateMs = now_manual; + } + } + socStats.temp_derating_factor = getTemperatureDerating(props, lastValidBatteryTemp); + socStats.last_battery_temp_c = lastValidBatteryTemp; + + MESH_DEBUG_PRINTLN("SOC: Manually set to %.1f%% (CHARGE=%.1fmAh, Baseline=%.1fmAh, d=%.2f)", + soc_percent, current_charge_mah, socStats.ina228_baseline_mah, + socStats.temp_derating_factor); + + return true; +} + +/// @brief Load battery capacity from preferences +/// @param capacity_mah Output parameter +/// @return true if loaded successfully +bool BoardConfigContainer::loadBatteryCapacity(float& capacity_mah) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + if (prefs.getString(BATTERY_CAPACITY_KEY, buffer, sizeof(buffer), "") > 0) { + if (buffer[0] != '\0') { + capacity_mah = atof(buffer); + return (capacity_mah > 0.0f); + } + } + + // Default capacity based on battery type (estimate) + BatteryType type; + if (loadBatType(type)) { + switch (type) { + case BatteryType::LTO_2S: + capacity_mah = 2000.0f; // Typical LTO capacity + break; + case BatteryType::LIFEPO4_1S: + capacity_mah = 1500.0f; // Typical LiFePO4 capacity + break; + case BatteryType::NAION_1S: + capacity_mah = 2000.0f; // Typical Na-Ion capacity + break; + case BatteryType::LIION_1S: + default: + capacity_mah = 2000.0f; // Typical Li-Ion capacity + break; + } + } else { + capacity_mah = 2000.0f; // Default fallback + } + + return false; // Not loaded from prefs +} + +/// @brief Get INA228 driver instance +/// @return Pointer to INA228 driver or nullptr if not initialized +Ina228Driver* BoardConfigContainer::getIna228Driver() { + return ina228DriverInstance; +} + +// ===== NTC Temperature Calibration ===== + +/// @brief Load NTC temperature calibration offset from preferences +/// @param offset Output parameter (°C) +/// @return true if loaded successfully, false if using default (0.0) +bool BoardConfigContainer::loadTcCalOffset(float& offset) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + if (prefs.getString(TCCAL_KEY, buffer, sizeof(buffer), "") > 0) { + if (buffer[0] != '\0') { + offset = atof(buffer); + + // Validate offset is in reasonable range (±20°C) + if (offset >= -20.0f && offset <= 20.0f) { + return true; + } + } + } + + // Default: no offset + offset = 0.0f; + return false; +} + +/// @brief Set NTC temperature calibration offset and save to preferences +/// @param offset_c Calibration offset in °C (-20 to +20) +/// @return true if saved successfully +bool BoardConfigContainer::setTcCalOffset(float offset_c) { + // Clamp to reasonable range + if (offset_c < -20.0f) offset_c = -20.0f; + if (offset_c > 20.0f) offset_c = 20.0f; + + // Apply to runtime variable + tcCalOffset = offset_c; + + // Save to preferences + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + snprintf(buffer, sizeof(buffer), "%.2f", offset_c); + + if (prefs.putString(TCCAL_KEY, buffer)) { + MESH_DEBUG_PRINTLN("TC calibration offset saved: %.2f °C", offset_c); + return true; + } + + return false; +} + +/// @brief Get current NTC temperature calibration offset +/// @return Current offset in °C (0.0 = no calibration) +float BoardConfigContainer::getTcCalOffset() const { + return tcCalOffset; +} + + + +/// @brief Perform NTC temperature calibration using a reference temperature +/// Averages 5 NTC readings to reduce ADC noise, computes offset = reference - avg, stores it. +/// @param actual_temp_c Reference temperature in °C (e.g. from BME280) +/// @return Computed offset in °C, or -999.0 on error +float BoardConfigContainer::performTcCalibration(float actual_temp_c) { + if (!bqDriverInstance) { + return -999.0f; + } + + // Temporarily remove any existing offset to get raw NTC readings + float old_offset = tcCalOffset; + tcCalOffset = 0.0f; + + // Average multiple NTC readings to reduce ADC noise + const int NUM_SAMPLES = 5; + const int SAMPLE_DELAY_MS = 200; + float ntc_sum = 0.0f; + int valid_count = 0; + + for (int i = 0; i < NUM_SAMPLES; i++) { + if (i > 0) delay(SAMPLE_DELAY_MS); + + const Telemetry* bqData = bqDriverInstance->getTelemetryData(0); // TC calibration: VBAT unknown, assume sufficient + if (!bqData) continue; + + float raw = bqData->batterie.temperature; + // Skip error codes + if (raw <= -800.0f || raw >= 98.0f) continue; + + ntc_sum += raw; + valid_count++; + } + + if (valid_count < 3) { + tcCalOffset = old_offset; // Restore old offset + MESH_DEBUG_PRINTLN("TC Cal: Only %d/%d valid NTC readings", valid_count, NUM_SAMPLES); + return -999.0f; + } + + float raw_ntc_avg = ntc_sum / valid_count; + + // Compute offset: calibrated = raw + offset → offset = reference - raw + float new_offset = actual_temp_c - raw_ntc_avg; + + MESH_DEBUG_PRINTLN("TC Cal: ref=%.2f NTC_avg=%.2f (%d samples) offset=%.2f", + actual_temp_c, raw_ntc_avg, valid_count, new_offset); + + // Store persistently + if (!setTcCalOffset(new_offset)) { + tcCalOffset = old_offset; // Restore on failure + return -999.0f; + } + + return new_offset; +} + +/// @brief Perform NTC temperature calibration using on-board BME280 as reference +/// Averages 5 BME280 readings, then delegates to performTcCalibration(float). +/// @param bme_temp_out Optional: receives the averaged BME temperature used for calibration +/// @return Computed offset in °C, or -999.0 on error +float BoardConfigContainer::performTcCalibration(float* bme_temp_out) { + // Average multiple BME280 readings to reduce noise + const int NUM_SAMPLES = 5; + const int SAMPLE_DELAY_MS = 200; + float bme_sum = 0.0f; + int valid_count = 0; + + for (int i = 0; i < NUM_SAMPLES; i++) { + float t = readBmeTemperature(); + if (t <= -900.0f) continue; + bme_sum += t; + valid_count++; + if (i < NUM_SAMPLES - 1) delay(SAMPLE_DELAY_MS); + } + + if (valid_count < 3) { + MESH_DEBUG_PRINTLN("TC Cal: Only %d/%d valid BME readings", valid_count, NUM_SAMPLES); + return -999.0f; + } + + float bme_avg = bme_sum / valid_count; + MESH_DEBUG_PRINTLN("TC Cal: BME avg=%.2f (%d samples)", bme_avg, valid_count); + + if (bme_temp_out) { + *bme_temp_out = bme_avg; + } + + return performTcCalibration(bme_avg); +} + +/// @brief Read BME280 temperature directly via I2C (temporary instance, no core code changes) +/// @return Temperature in °C, or -999.0 if BME280 not available +float BoardConfigContainer::readBmeTemperature() { +#if ENV_INCLUDE_BME280 + Adafruit_BME280 bme; + if (!bme.begin(0x76, &Wire)) { + MESH_DEBUG_PRINTLN("TC Cal: BME280 not found at 0x76"); + return -999.0f; + } + bme.setSampling(Adafruit_BME280::MODE_FORCED, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::FILTER_OFF, + Adafruit_BME280::STANDBY_MS_1000); + if (!bme.takeForcedMeasurement()) { + MESH_DEBUG_PRINTLN("TC Cal: BME280 forced measurement failed"); + return -999.0f; + } + float temp = bme.readTemperature(); + MESH_DEBUG_PRINTLN("TC Cal: BME280 reads %.2f C", temp); + return temp; +#else + MESH_DEBUG_PRINTLN("TC Cal: BME280 not compiled in (ENV_INCLUDE_BME280=0)"); + return -999.0f; +#endif +} + +/// @brief Arm INA228 low-voltage alert for current battery chemistry +/// @details Programs INA228 BUVL register with lowv_sleep_mv threshold. +/// Alert fires when VBAT drops below this level → ISR → volatile flag → tickPeriodic() → System Sleep. +/// Always active when battery type is configured (no CLI toggle). +/// BAT_UNKNOWN: alert disabled (threshold = 0). +void BoardConfigContainer::armLowVoltageAlert() { + if (!ina228DriverInstance) { + return; + } + + BatteryType bat_type = getBatteryType(); + const BatteryProperties* props = getBatteryProperties(bat_type); + uint16_t sleep_mv = props ? props->lowv_sleep_mv : 0; + + if (bat_type == BAT_UNKNOWN || sleep_mv == 0) { + // No battery configured — disarm alert + ina228DriverInstance->setUnderVoltageAlert(0); + ina228DriverInstance->enableAlert(false, false, false); + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: DISABLED (BAT_UNKNOWN)"); + return; + } + + bool buvl_ok = ina228DriverInstance->setUnderVoltageAlert(sleep_mv); + ina228DriverInstance->enableAlert(true, false, true); // active-LOW, LATCHED + + // Attach ISR on ALERT pin (active-LOW, falling edge) + pinMode(INA_ALERT_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN), lowVoltageAlertISR, FALLING); + + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: ARMED @ %dmV (BUVL write %s)", sleep_mv, buvl_ok ? "OK" : "FAILED"); +} + +/// @brief Disarm INA228 low-voltage alert and detach ISR +void BoardConfigContainer::disarmLowVoltageAlert() { + if (!ina228DriverInstance) { + return; + } + + detachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN)); + ina228DriverInstance->setUnderVoltageAlert(0); + ina228DriverInstance->enableAlert(false, false, false); + lowVoltageAlertFired = false; + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: DISARMED"); +} + +/// @brief ISR for INA228 ALERT pin — sets flag checked in tickPeriodic() +/// @details Called on falling edge of INA228 ALERT (active-LOW, latched). +/// Sets volatile flag; tickPeriodic() checks it and initiates shutdown. +void BoardConfigContainer::lowVoltageAlertISR() { + lowVoltageAlertFired = true; +} + +/// @brief Get low-voltage sleep threshold (INA228 ALERT fires at this level) +/// @param type Battery chemistry type +/// @return Threshold in millivolts +uint16_t BoardConfigContainer::getLowVoltageSleepThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_sleep_mv : 2000; +} + +/// @brief Get low-voltage wake threshold (RTC wake boots if VBAT >= this, 0% SOC marker) +/// @param type Battery chemistry type +/// @return Threshold in millivolts +uint16_t BoardConfigContainer::getLowVoltageWakeThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_wake_mv : 2200; +} + +/// @brief Update battery SOC from INA228 Hardware Coulomb Counter +/// @details Uses INA228 CHARGE register (mAh) for accurate charge tracking +void BoardConfigContainer::updateBatterySOC() { + if (!ina228DriverInstance) { + return; + } + + // Periodic SHUNT_CAL self-heal (~every 5 min via static counter) + // If SHUNT_CAL got wiped (clone chip glitch, I2C error, etc.), + // CURRENT and CHARGE registers read 0 forever → stats stay at 0. + static uint8_t scal_check_counter = 0; + if (++scal_check_counter >= 5) { // Every 5th call = ~5 minutes (called every 60s) + scal_check_counter = 0; + ina228DriverInstance->validateAndRepairShuntCal(); + } + + // Read INA228 Hardware Coulomb Counter (mAh) - TWO'S COMPLEMENT, has correct sign! + // Positive = charging (into battery), Negative = discharging (from battery) + float charge_mah = ina228DriverInstance->readCharge_mAh(); + + uint32_t now_ms = millis(); + socStats.last_soc_update_ms = now_ms; + socStats.soc_update_count++; + + // Update current hour statistics (track charged/discharged charge in mAh) + // This runs ALWAYS, independent of SOC validity + static float last_charge_mah = 0.0f; + static bool first_read = true; + + if (first_read) { + // Initialize baseline on first read, don't count initial value as delta + last_charge_mah = charge_mah; + first_read = false; + } else { + float delta_mah = charge_mah - last_charge_mah; + last_charge_mah = charge_mah; + + // Handle potential counter wrap or reset (ignore huge jumps > 10Ah) + if (delta_mah > 10000.0f || delta_mah < -10000.0f) { + MESH_DEBUG_PRINTLN("SOC: Large charge delta %.0fmAh - ignoring (counter reset?)", delta_mah); + } else { + // CHARGE register inverted in driver: positive delta = charging, negative delta = discharging + if (delta_mah > 0.0f) { + // Charging (positive delta) + socStats.current_hour_charged_mah += delta_mah; + socStats.current_hour_solar_mah += delta_mah; // Assume solar (BQ tracks this) + } else if (delta_mah < 0.0f) { + // Discharging (negative delta) + socStats.current_hour_discharged_mah += (-delta_mah); + } + } + } + socStats.last_charge_reading_mah = charge_mah; // Always update for diagnostics + + // Check if BQ reports charging done → auto-sync + if (bqDriverInstance) { + bq25798_charging_status status = bqDriverInstance->getChargingStatus(); + if (status == BQ25798_CHARGER_STATE_DONE_CHARGING) { + if (!socStats.soc_valid) { + MESH_DEBUG_PRINTLN("SOC: First \"Charging Done\" detected - syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after counter reset to prevent false discharge spike + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + } else if (socStats.current_soc_percent < 99.0f) { + MESH_DEBUG_PRINTLN("SOC: \"Charging Done\" detected - re-syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after counter reset to prevent false discharge spike + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + } + } + } + + // SOC calculation is only valid after first "Charging Done" sync via syncSOCToFull() + if (!socStats.soc_valid) { + return; // Wait for first sync + } + + // Net charge since last baseline reset (using CHARGE register in mAh) + // Driver inverted: positive = charged into battery, negative = discharged from battery + float net_charge_mah = charge_mah - socStats.ina228_baseline_mah; + + // Remaining capacity = Initial capacity + net charge (positive=charged adds, negative=discharged subtracts) + float remaining_mah = socStats.capacity_mah + net_charge_mah; + + // Temperature derating: calculate factor for TTL and display purposes. + // The derating factor is NOT applied to SOC% — SOC% is purely Coulomb-based + // (remaining_mah / capacity_mah) and represents the actual stored charge. + // Derating only affects TTL calculation (extractable capacity) and is shown + // separately in CLI output as "derated SOC%". + // + // Temperature source priority: + // 1. NTC via BQ25798 TS ADC (cached in lastValidBatteryTemp by getTelemetryData()) + // 2. BME280 fallback — used when NTC has not provided a reading for >5 minutes. + // This covers Na-Ion and LTO setups where ts_ignore=true and no NTC is connected. + // BME280 measures PCB/ambient temperature, not battery temperature directly, + // but is a reasonable proxy (typically within ±3°C in a sealed enclosure). + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + + // BME280 fallback: if NTC hasn't updated for >5 min, try BME280 + uint32_t now_derating = millis(); + if (lastTempUpdateMs == 0 || (now_derating - lastTempUpdateMs) > 300000UL) { + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f) { + lastValidBatteryTemp = bmeTemp; + lastTempUpdateMs = now_derating; + } + // If BME280 also fails, lastValidBatteryTemp keeps its previous value (default 25°C = no derating) + } + + float derating = getTemperatureDerating(props, lastValidBatteryTemp); + socStats.temp_derating_factor = derating; + socStats.last_battery_temp_c = lastValidBatteryTemp; + + // Calculate SOC percentage — purely Coulomb-based, NO temperature derating + if (socStats.capacity_mah > 0) { + socStats.current_soc_percent = (remaining_mah / socStats.capacity_mah) * 100.0f; + + // Clamp to 0-100% + if (socStats.current_soc_percent > 100.0f) socStats.current_soc_percent = 100.0f; + if (socStats.current_soc_percent < 0.0f) socStats.current_soc_percent = 0.0f; + } +} + +/// @brief Update daily balance statistics (mAh-based) +/// @brief Update hourly battery statistics and advance rolling window +uint32_t BoardConfigContainer::getRTCTimestamp() { + return getRTCTime(); +} + +void BoardConfigContainer::updateHourlyStats() { + uint32_t currentTime = getRTCTime(); + + // Calculate hour boundary (align to full hours) + uint32_t currentHour = (currentTime / 3600) * 3600; // Truncate to hour boundary + + // Check if hour has changed + if (socStats.lastHourUpdateTime == 0) { + // First run - initialize + socStats.lastHourUpdateTime = currentHour; + MESH_DEBUG_PRINTLN("SOC: Hourly stats initialized at timestamp %u", currentHour); + return; + } + + uint32_t lastHour = (socStats.lastHourUpdateTime / 3600) * 3600; + + if (currentHour > lastHour) { + // Hour boundary crossed - save current hour stats + MESH_DEBUG_PRINTLN("SOC: Hour changed (%u -> %u) - saving stats: C:%.1f D:%.1f S:%.1f mAh", + lastHour, currentHour, + socStats.current_hour_charged_mah, + socStats.current_hour_discharged_mah, + socStats.current_hour_solar_mah); + + // Move to next hour slot in circular buffer + uint8_t nextIndex = (socStats.currentIndex + 1) % HOURLY_STATS_HOURS; + + // Save completed hour's stats + HourlyBatteryStats& completedHour = socStats.hours[socStats.currentIndex]; + completedHour.timestamp = lastHour; + completedHour.charged_mah = socStats.current_hour_charged_mah; + completedHour.discharged_mah = socStats.current_hour_discharged_mah; + completedHour.solar_mah = socStats.current_hour_solar_mah; + + // Reset accumulators for new hour + socStats.currentIndex = nextIndex; + socStats.current_hour_charged_mah = 0.0f; + socStats.current_hour_discharged_mah = 0.0f; + socStats.current_hour_solar_mah = 0.0f; + socStats.lastHourUpdateTime = currentHour; + + // Recalculate rolling window statistics (24h and 3-day averages) + calculateRollingStats(); + } +} + +/// @brief Calculate 24h and 3-day rolling averages from hourly buffer +void BoardConfigContainer::calculateRollingStats() { + // Calculate last 24 hours net balance + float sum_24h_charged = 0.0f; + float sum_24h_discharged = 0.0f; + float sum_24h_solar = 0.0f; + int valid_hours_24h = 0; + + // Sum up last 24 hours (most recent 24 entries) + for (int i = 0; i < 24 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_24h_charged += socStats.hours[idx].charged_mah; + sum_24h_discharged += socStats.hours[idx].discharged_mah; + sum_24h_solar += socStats.hours[idx].solar_mah; + valid_hours_24h++; + } + } + + // Last 24h net: solar - discharged (positive = surplus, negative = deficit) + socStats.last_24h_net_mah = sum_24h_solar - sum_24h_discharged; + socStats.last_24h_charged_mah = sum_24h_charged; + socStats.last_24h_discharged_mah = sum_24h_discharged; + socStats.living_on_battery = (socStats.last_24h_net_mah < 0.0f); + + // Calculate 3-day average daily net (72 hours) + float sum_72h_charged = 0.0f; + float sum_72h_discharged = 0.0f; + float sum_72h_solar = 0.0f; + int valid_hours_72h = 0; + + for (int i = 0; i < 72 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_72h_charged += socStats.hours[idx].charged_mah; + sum_72h_discharged += socStats.hours[idx].discharged_mah; + sum_72h_solar += socStats.hours[idx].solar_mah; + valid_hours_72h++; + } + } + + // Average daily net over 3 days (divide 72h sum by 3) + if (valid_hours_72h >= 24) { // Need at least 24h of data + float net_72h = sum_72h_solar - sum_72h_discharged; + socStats.avg_3day_daily_net_mah = net_72h / 3.0f; // Divide by 3 days + socStats.avg_3day_daily_charged_mah = sum_72h_charged / 3.0f; + socStats.avg_3day_daily_discharged_mah = sum_72h_discharged / 3.0f; + } else { + socStats.avg_3day_daily_net_mah = 0.0f; + socStats.avg_3day_daily_charged_mah = 0.0f; + socStats.avg_3day_daily_discharged_mah = 0.0f; + } + + // Calculate 7-day average daily net (168 hours) + float sum_168h_charged = 0.0f; + float sum_168h_discharged = 0.0f; + float sum_168h_solar = 0.0f; + int valid_hours_168h = 0; + + for (int i = 0; i < 168 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_168h_charged += socStats.hours[idx].charged_mah; + sum_168h_discharged += socStats.hours[idx].discharged_mah; + sum_168h_solar += socStats.hours[idx].solar_mah; + valid_hours_168h++; + } + } + + // Average daily net over 7 days (divide 168h sum by 7) + if (valid_hours_168h >= 24) { // Need at least 24h of data + float net_168h = sum_168h_solar - sum_168h_discharged; + socStats.avg_7day_daily_net_mah = net_168h / 7.0f; // Divide by 7 days + socStats.avg_7day_daily_charged_mah = sum_168h_charged / 7.0f; + socStats.avg_7day_daily_discharged_mah = sum_168h_discharged / 7.0f; + } else { + socStats.avg_7day_daily_net_mah = 0.0f; + socStats.avg_7day_daily_charged_mah = 0.0f; + socStats.avg_7day_daily_discharged_mah = 0.0f; + } + + MESH_DEBUG_PRINTLN("SOC: Rolling stats - 24h net: %+.1fmAh, 3d avg: %+.1fmAh/day, 7d avg: %+.1fmAh/day", + socStats.last_24h_net_mah, socStats.avg_3day_daily_net_mah, socStats.avg_7day_daily_net_mah); + + // Calculate TTL + calculateTTL(); +} + +/// @brief Calculate Time To Live (hours until battery empty) +/// @details TTL is based on the **7-day rolling average** of daily net energy consumption +/// (avg_7day_daily_net_mah). This average is computed from a 168-hour (7-day) +/// ring buffer of hourly INA228 Coulomb-counter measurements (charged/discharged/solar mAh). +/// +/// Data flow: +/// 1. INA228 hardware Coulomb counter measures charge flow continuously (24-bit ADC) +/// 2. updateHourlyStats() samples the counter every hour, storing per-hour deltas +/// (charged_mah, discharged_mah, solar_mah) in hours[168] ring buffer +/// 3. calculateRollingStats() sums the last 168 hours and divides by 7 to get +/// avg_7day_daily_net_mah (= solar - discharged per day) +/// 4. This method extrapolates: remaining_mah / deficit_per_day * 24 = TTL hours +/// +/// Preconditions for TTL > 0: +/// - living_on_battery == true (24h net is negative, i.e. energy deficit) +/// - avg_7day_daily_net_mah < 0 (7-day average shows net discharge) +/// - capacity_mah > 0 (battery capacity is known) +/// - At least 24 hours of valid hourly data exist in the ring buffer +/// +/// When the device is solar-powered with energy surplus (net >= 0), +/// TTL is 0 and callers interpret this as "infinite" via living_on_battery flag. +void BoardConfigContainer::calculateTTL() { + if (!socStats.living_on_battery || socStats.avg_7day_daily_net_mah >= 0) { + socStats.ttl_hours = 0; // Not draining or charging + return; + } + + if (socStats.capacity_mah <= 0) { + socStats.ttl_hours = 0; // Capacity unknown + return; + } + + // Trapped Charge model: cold temperatures "lock" the bottom of the discharge + // curve — the cell shuts down (OCV near cutoff + TX-peak IR-drop) while charge + // is still physically stored. trapped_mah is the unusable floor. + // trapped_mah = capacity × (1 − f(T)) e.g. 8000 × 0.17 = 1360 mAh at −10 °C + // extractable = max(0, remaining − trapped) + // This is more realistic than proportional scaling (remaining × f) because + // capacity loss at cold is not uniform — it steals from the bottom. + float remaining_mah = (socStats.current_soc_percent / 100.0f) * socStats.capacity_mah; + float trapped_mah = socStats.capacity_mah * (1.0f - socStats.temp_derating_factor); + float extractable_mah = remaining_mah - trapped_mah; + if (extractable_mah < 0.0f) extractable_mah = 0.0f; + + // Daily deficit (negative value) + float deficit_per_day = -socStats.avg_7day_daily_net_mah; + + if (deficit_per_day <= 0) { + socStats.ttl_hours = 0; + return; + } + + // Days until empty (based on extractable capacity) + float days_remaining = extractable_mah / deficit_per_day; + + // Convert to hours + socStats.ttl_hours = (uint16_t)(days_remaining * 24.0f); + + MESH_DEBUG_PRINTLN("TTL: %.1f days (%.0f mAh stored, %.0f trapped, %.0f extractable @d=%.2f, -%.0f mAh/day)", + days_remaining, remaining_mah, trapped_mah, extractable_mah, socStats.temp_derating_factor, deficit_per_day); +} + +// ===== Tick-based Periodic Dispatch ===== + +/// @brief Called from InheroMr2Board::tick() — dispatches all periodic I2C work +/// @details millis()-based scheduling in the main loop context. +/// Also checks the ISR-set lowVoltageAlertFired flag for immediate shutdown. +void BoardConfigContainer::tickPeriodic() { + // First-call init: clear MPPT stats + if (!tickInitialized) { + memset(&mpptStats, 0, sizeof(mpptStats)); + tickInitialized = true; + } + + // Check low-voltage alert flag (set by INA228 ALERT ISR) + if (lowVoltageAlertFired) { + MESH_DEBUG_PRINTLN("PWRMGT: Low-voltage alert fired — initiating System Sleep"); + blinkRed(1, 100, 100, leds_enabled); + blinkRed(3, 300, 300, leds_enabled); + + NRF_POWER->GPREGRET2 |= GPREGRET2_LOW_VOLTAGE_SLEEP; + board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); + // Never returns + } + + uint32_t now = millis(); + + // Every ~60s: MPPT cycle (solar charging control) + if (now - lastMpptMs >= SOLAR_MPPT_INTERVAL_MS) { + lastMpptMs = now; + runMpptCycle(); + } + + // Every ~60s: SOC update from Coulomb Counter + if (now - lastSocMs >= 60000UL) { + lastSocMs = now; + updateBatterySOC(); + } + + // Every ~60 min: hourly statistics + if (now - lastHourlyMs >= 3600000UL) { + lastHourlyMs = now; + MESH_DEBUG_PRINTLN("SOC: 60 minutes elapsed - updating hourly stats"); + updateHourlyStats(); + } +} + +// ===== Helper Functions ===== + +/// @brief Trim whitespace from string +char* BoardConfigContainer::trim(char* str) { + char* end; + + while (isspace((unsigned char)*str)) + str++; + + if (*str == 0) { + return str; + } + + end = str + strlen(str) - 1; + + while (end > str && isspace((unsigned char)*end)) + end--; + + *(end + 1) = 0; + + return str; +} + +/// @brief Convert command string to battery type enum +BoardConfigContainer::BatteryType BoardConfigContainer::getBatteryTypeFromCommandString(const char* cmdStr) { + for (const auto& entry : bat_map) { + if (entry.command_string == nullptr) break; + if (strcmp(entry.command_string, cmdStr) == 0) { + return entry.type; + } + } + return BatteryType::BAT_UNKNOWN; +} + +/// @brief Get battery properties for a given battery type +/// @param type Battery type +/// @return Pointer to BatteryProperties structure, or nullptr if not found +const BoardConfigContainer::BatteryProperties* BoardConfigContainer::getBatteryProperties(BatteryType type) { + for (const auto& props : battery_properties) { + if (props.type == type) { + return &props; + } + } + return nullptr; // Should never happen if battery_properties is complete +} + +/// @brief Temperature derating factor for a battery chemistry +/// @details At cold temperatures, the internal resistance of a battery increases, +/// reducing the extractable capacity — even though the stored charge +/// (measured by the coulomb counter) remains unchanged. This function +/// returns a factor 0.0–1.0 that scales the nominal capacity to reflect +/// the actually available (extractable) capacity. +/// +/// Model: f(T) = 1.0 for T >= T_ref +/// f(T) = max(f_min, 1.0 - k * (T_ref - T)) for T < T_ref +/// +/// The linear model is a conservative approximation sufficient for SOC/TTL +/// display purposes. Real curves are slightly concave (capacity drops faster +/// at extreme cold), but the f_min clamp prevents unrealistic values. +/// +/// @param props Battery properties (contains k, f_min, T_ref per chemistry) +/// @param temp_c Current battery temperature in °C +/// @return Derating factor (1.0 = full capacity, e.g. 0.75 = 75% extractable) +float BoardConfigContainer::getTemperatureDerating(const BatteryProperties* props, float temp_c) { + if (!props) return 1.0f; + if (temp_c >= props->temp_ref_c) return 1.0f; + + float delta = props->temp_ref_c - temp_c; + float factor = 1.0f - props->temp_derating_k * delta; + if (factor < props->temp_derating_min) factor = props->temp_derating_min; + return factor; +} + +/// @brief Convert battery type enum to command string +const char* BoardConfigContainer::getBatteryTypeCommandString(BatteryType type) { + for (const auto& entry : bat_map) { + if (entry.command_string == nullptr) break; + if (entry.type == type) { + return entry.command_string; + } + } + return "unknown"; +} + +/// @brief Convert frost charge behaviour enum to command string +const char* BoardConfigContainer::getFrostChargeBehaviourCommandString(FrostChargeBehaviour type) { + for (const auto& entry : frostchargebehaviour_map) { + if (entry.command_string == nullptr) break; + if (entry.type == type) { + return entry.command_string; + } + } + return "unknown"; +} + +/// @brief Convert command string to frost charge behaviour enum +BoardConfigContainer::FrostChargeBehaviour BoardConfigContainer::getFrostChargeBehaviourFromCommandString(const char* cmdStr) { + for (const auto& entry : frostchargebehaviour_map) { + if (entry.command_string == nullptr) break; + if (strcmp(entry.command_string, cmdStr) == 0) { + return entry.type; + } + } + return FrostChargeBehaviour::REDUCE_UNKNOWN; +} + +/// @brief Get available frost charge behaviour option strings +const char* BoardConfigContainer::getAvailableFrostChargeBehaviourOptions() { + static char buffer[64]; + + if (buffer[0] != '\0') return buffer; + + buffer[0] = '\0'; + + for (const auto& entry : frostchargebehaviour_map) { + if (entry.command_string == nullptr) break; + + size_t space_needed = strlen(buffer) + 1 + strlen(entry.command_string) + 1; + + if (space_needed >= sizeof(buffer)) { + break; + } + + if (buffer[0] != '\0') { + strcat(buffer, "|"); + } + strcat(buffer, entry.command_string); + } + + return buffer; +} + +/// @brief Get available battery type option strings +const char* BoardConfigContainer::getAvailableBatOptions() { + static char buffer[64]; + + if (buffer[0] != '\0') return buffer; + + buffer[0] = '\0'; + + for (const auto& entry : bat_map) { + if (entry.command_string == nullptr) break; + + size_t space_needed = strlen(buffer) + 1 + strlen(entry.command_string) + 1; + + if (space_needed >= sizeof(buffer)) { + break; + } + + if (buffer[0] != '\0') { + strcat(buffer, "|"); + } + strcat(buffer, entry.command_string); + } + + return buffer; +} diff --git a/variants/inhero_mr2/BoardConfigContainer.h b/variants/inhero_mr2/BoardConfigContainer.h new file mode 100644 index 0000000000..488d0e4c85 --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Board Configuration Container for Inhero MR-2 + */ +#pragma once +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" + +#include + +// Solar MPPT polling interval (no interrupt — pure polling) +#define SOLAR_MPPT_INTERVAL_MS (1 * 60 * 1000) // 1 minute + +// MPPT Statistics tracking for 7-day moving average +#define MPPT_STATS_HOURS 168 // 7 days * 24 hours + +typedef struct { + uint8_t mpptEnabledMinutes; ///< Minutes MPPT was enabled in this hour (0-60) + uint32_t timestamp; ///< Unix timestamp (seconds) for this hour + uint32_t harvestedEnergy_mWh; ///< Harvested solar energy (mWh) during this hour +} MpptHourlyStats; + +typedef struct { + MpptHourlyStats hours[MPPT_STATS_HOURS]; ///< Rolling buffer of hourly stats + uint8_t currentIndex; ///< Current position in circular buffer + uint32_t lastUpdateTime; ///< Last update time (Unix seconds or millis if RTC unavailable) + uint16_t currentHourMinutes; ///< Accumulated minutes for current hour + bool usingRTC; ///< True if using RTC, false if using millis fallback + uint32_t currentHourEnergy_mWh; ///< Accumulated energy for current hour (mWh) + int32_t lastPower_mW; ///< Last measured power for energy calculation +} MpptStatistics; + +// Battery SOC Tracking - mAh-based using INA228 Hardware Coulomb Counter (CHARGE register) +#define HOURLY_STATS_HOURS 168 // 7 days * 24 hours = 168 hours + +// Hourly battery statistics for rolling window +typedef struct { + uint32_t timestamp; ///< Unix timestamp (start of hour, seconds) + float charged_mah; ///< Charge added this hour (mAh) + float discharged_mah; ///< Charge removed this hour (mAh) + float solar_mah; ///< Solar charge contribution this hour (mAh) +} HourlyBatteryStats; + +typedef struct { + // Battery configuration + float capacity_mah; ///< Total battery capacity in mAh + float nominal_voltage; ///< Nominal voltage for current chemistry (V) + + // SOC tracking using INA228 hardware counter (CHARGE register in mAh) + float current_soc_percent; ///< Current State of Charge in % (0-100) + bool soc_valid; ///< True after first "Charging Done" sync + float ina228_baseline_mah; ///< INA228 CHARGE reading at last 100% sync (mAh) + + uint32_t last_soc_update_ms; ///< millis() of last updateBatterySOC() call (for offset dt) + + // Hourly statistics (168-hour rolling buffer for 7 days) + HourlyBatteryStats hours[HOURLY_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastHourUpdateTime; ///< Last hour boundary timestamp + + // Current hour accumulators (reset every hour) + float current_hour_charged_mah; + float current_hour_discharged_mah; + float current_hour_solar_mah; + float last_charge_reading_mah; ///< Last INA228 CHARGE reading for delta calculation + + // Rolling window statistics (calculated from hourly buffer) + float last_24h_net_mah; ///< Net balance over last 24 hours + float last_24h_charged_mah; ///< Total charged over last 24 hours + float last_24h_discharged_mah; ///< Total discharged over last 24 hours + float avg_3day_daily_net_mah; ///< Average daily net over last 3 days (72h) + float avg_3day_daily_charged_mah; ///< Average daily charged over last 3 days + float avg_3day_daily_discharged_mah; ///< Average daily discharged over last 3 days + float avg_7day_daily_net_mah; ///< Average daily net over last 7 days (168h) + float avg_7day_daily_charged_mah; ///< Average daily charged over last 7 days + float avg_7day_daily_discharged_mah; ///< Average daily discharged over last 7 days + uint16_t ttl_hours; ///< Time To Live - hours until battery empty (0 = not calculated). + ///< Based on 7-day rolling avg of daily net deficit (avg_7day_daily_net_mah) + ///< from hourly INA228 Coulomb-counter samples in 168h ring buffer. + bool living_on_battery; ///< True if net deficit over last 24h + uint16_t soc_update_count; ///< Debug: number of updateBatterySOC() calls since boot + float temp_derating_factor; ///< Current temperature derating factor (0.0–1.0, 1.0 = no derating) + float last_battery_temp_c; ///< Last valid battery temperature used for derating (°C) +} BatterySOCStats; + +class BoardConfigContainer { + +public: + enum BatteryType : uint8_t { BAT_UNKNOWN = 0, LTO_2S = 1, LIFEPO4_1S = 2, LIION_1S = 3, NAION_1S = 4 }; + typedef struct { + const char* command_string; + BatteryType type; + } BatteryMapping; + + // Battery type properties + typedef struct { + BatteryType type; + float charge_voltage; // Max charge voltage in V + float nominal_voltage; // Nominal voltage for energy calculations + uint16_t lowv_sleep_mv; // Low-voltage sleep threshold (INA228 ALERT triggers System Sleep) in mV + uint16_t lowv_wake_mv; // Low-voltage wake threshold (0% SOC marker, RTC wake decision) in mV + bool charge_enable; // Enable/disable charging (false for BAT_UNKNOWN) + bool ts_ignore; // Ignore TS/NTC temperature monitoring (disables JEITA) + // Temperature derating — models reduced extractable capacity at cold temperatures. + // At our typical load (~8 mA average, ~100 mA TX peaks), capacity loss is much smaller + // than datasheet values measured at 0.2C–0.5C. The real risk at cold temperatures is + // transient voltage dips during TX peaks (100 mA × increased R_internal) that could + // trigger the INA228 low-voltage alert and cause premature System Sleep. + // Values are calibrated for ~0.01C average / ~0.05C peak discharge rates. + // f(T) = 1.0 for T >= temp_ref_c + // f(T) = max(temp_derating_min, 1 - k*(Tref - T)) for T < temp_ref_c + float temp_derating_k; // Capacity loss per °C below temp_ref_c (e.g. 0.005 = 0.5%/°C) + float temp_derating_min; // Minimum derating factor (clamp, e.g. 0.75 = 75% at extreme cold) + float temp_ref_c; // Reference temperature in °C where capacity = 100% (typically 25.0) + } BatteryProperties; + + // Battery properties lookup table + // All battery-specific thresholds in one central location + // Temperature derating columns: k = capacity loss per °C below Tref, min = floor factor, Tref = reference temp + // Values calibrated for low C-rate discharge (~0.01C avg, ~0.05C TX peaks). + // Standard datasheet values (0.2C–0.5C) would be 2–3× more aggressive but are unrealistic + // for our ~8 mA average / ~100 mA peak loads on 2–8 Ah cells. + static inline constexpr BatteryProperties battery_properties[] = { + // Type ChgV NomV SleepMv WakeMv ChgEn TsIgn k min Tref + { BAT_UNKNOWN, 0.0f, 0.0f, 2000, 2200, false, true, 0.000f, 1.00f, 25.0f }, // No derating + { LTO_2S, 5.4f, 4.6f, 3900, 4100, true, true, 0.002f, 0.88f, 25.0f }, // LTO: best cold performance + { LIFEPO4_1S, 3.5f, 3.2f, 2700, 2900, true, false, 0.006f, 0.70f, 25.0f }, // LiFePO4: flat plateau helps at low C + { LIION_1S, 4.1f, 3.7f, 3100, 3300, true, false, 0.005f, 0.75f, 25.0f }, // Li-Ion NMC/NCA: moderate + { NAION_1S, 3.9f, 3.1f, 2500, 2700, true, true, 0.003f, 0.85f, 25.0f } // Na-Ion: good cold tolerance + }; + + static inline constexpr BatteryMapping bat_map[] = { { "lto2s", LTO_2S }, + { "lifepo1s", LIFEPO4_1S }, + { "liion1s", LIION_1S }, + { "naion1s", NAION_1S }, + { "none", BAT_UNKNOWN }, + { nullptr, BAT_UNKNOWN } }; + + enum FrostChargeBehaviour : uint8_t { + NO_CHARGE = 4, + I_REDUCE_TO_20 = 3, + I_REDUCE_TO_40 = 2, + NO_REDUCE = 1, + REDUCE_UNKNOWN = 0 + }; + typedef struct { + const char* command_string; + FrostChargeBehaviour type; + } FrostChargeBehaviourMapping; + + static inline constexpr FrostChargeBehaviourMapping frostchargebehaviour_map[] = { + { "0%", NO_CHARGE }, + { "20%", I_REDUCE_TO_20 }, + { "40%", I_REDUCE_TO_40 }, + { "100%", NO_REDUCE }, + { nullptr, REDUCE_UNKNOWN } + }; + + // Default values for newly flashed boards + static constexpr BatteryType DEFAULT_BATTERY_TYPE = BAT_UNKNOWN; // Safe: low thresholds, user must configure + static constexpr FrostChargeBehaviour DEFAULT_FROST_BEHAVIOUR = NO_CHARGE; + static constexpr uint16_t DEFAULT_MAX_CHARGE_CURRENT_MA = 200; + static constexpr bool DEFAULT_MPPT_ENABLED = false; + + // IINDPM (input current limit) — calculated from battery voltage and charge current. + // Formula: IINDPM = 1.2 × (V_charge × I_charge) / V_panel_assumed + // This prevents weak panels from tripping POORSRC after PG qualification. + static constexpr float IINDPM_MAX_A = 2.0f; // JST connector limit on PCB (hard cap) + static constexpr float IINDPM_USB_A = 0.5f; // USB 2.0 spec maximum + static constexpr float IINDPM_PANEL_V = 4.0f; // Conservative panel voltage for IINDPM calc + static constexpr float IINDPM_MARGIN = 1.2f; // Headroom for converter efficiency + + // PG-Stuck recovery: VBUS threshold above which a panel is assumed present. + // If PG=0 but VBUS >= this value, toggle HIZ to force input re-qualification. + static constexpr uint16_t PG_STUCK_VBUS_THRESHOLD_MV = 4500; + + static BatteryType getBatteryTypeFromCommandString(const char* cmdStr); + static char* trim(char* str); + static const char* getBatteryTypeCommandString(BatteryType type); + static const char* getFrostChargeBehaviourCommandString(FrostChargeBehaviour type); + static FrostChargeBehaviour getFrostChargeBehaviourFromCommandString(const char* cmdStr); + static const char* getAvailableFrostChargeBehaviourOptions(); + static const char* getAvailableBatOptions(); + static const BatteryProperties* getBatteryProperties(BatteryType type); + + /// @brief Get temperature derating factor for a battery chemistry + /// @details Returns 0.0–1.0 (1.0 = full capacity at or above reference temperature). + /// The derating reduces SOC% and TTL to reflect reduced extractable energy at cold temps. + /// The coulomb counter itself is NOT affected — it always records true charge flow. + /// @param props Battery properties (contains derating coefficients) + /// @param temp_c Current battery temperature in °C + /// @return Derating factor (1.0 = no derating, <1.0 = reduced available capacity) + static float getTemperatureDerating(const BatteryProperties* props, float temp_c); + + // Solar Power Management Functions + // These functions work together to handle stuck PGOOD conditions and MPPT recovery: + + static void heartbeatTask(void* pvParameters); + + /// Re-enable MPPT if BQ disabled it (when PG=1). + static void checkAndFixSolarLogic(); + + static bool loadMpptEnabled(bool& enabled); + void tickPeriodic(); ///< Called from tick() — dispatches all periodic I2C work (MPPT, SOC, hourly stats) + static void stopBackgroundTasks(); ///< Stop heartbeat task and disarm alerts before OTA + + bool setBatteryType(BatteryType type); + + BatteryType getBatteryType() const; + + bool setFrostChargeBehaviour(FrostChargeBehaviour behaviour); + FrostChargeBehaviour getFrostChargeBehaviour() const; + + bool setMaxChargeCurrent_mA(uint16_t maxChrgI); + uint16_t getMaxChargeCurrent_mA() const; + + static void setUsbConnected(bool connected); ///< Notify USB state change — caps IINDPM to 500mA when USB is source + static bool isUsbConnected() { return usbInputActive; } + static float calculateSolarIINDPM(); ///< Calculate IINDPM from battery voltage and charge current + static void updateSolarIINDPM(); ///< Apply calculated solar IINDPM (no-op when USB active) + + bool getMPPTEnabled() const; + bool setMPPTEnable(bool enableMPPT); + + float getMaxChargeVoltage() const; + + bool begin(); + + const Telemetry* getTelemetryData(); ///< Get combined telemetry (INA228 for VBAT/IBAT, BQ25798 for Solar) + + const char* getChargeCurrentAsStr(); + void getChargerInfo(char* buffer, uint32_t bufferSize); + void getBqDiagnostics(char* buffer, uint32_t bufferSize); + + /// @brief Probe all I2C devices on the board and report status. + /// @details Tests INA228 (0x40), BQ25798 (0x6B), RV-3028 (0x52, with + /// user-RAM write/readback to catch \"zombie\" chips), BME280 (0x76). + /// Format: \"INA:OK BQ:OK RTC:OK BME:OK\" or \"INA:OK BQ:NACK RTC:WR_FAIL BME:OK\". + /// @param buffer Output buffer + /// @param bufferSize Output buffer size + void getSelfTest(char* buffer, uint32_t bufferSize); + + /// @brief Probe RV-3028 RTC: address ACK + user-RAM write/readback verify. + /// @details Catches RTCs that ACK on the bus but do not persist register + /// writes. User-RAM bytes (0x1F, 0x20) per RV-3028 datasheet are + /// scratch and safe to overwrite; original byte is restored. + /// @return true if RTC ACKs and persists a write, false otherwise. + static bool probeRtc(); + + // MPPT Statistics methods + float getMpptEnabledPercentage7Day() const; ///< Get 7-day moving average of MPPT enabled % + + // Battery SOC & Coulomb Counter methods + float getStateOfCharge() const; ///< Get current SOC in % (0-100) + float getBatteryCapacity() const; ///< Get battery capacity in mAh + bool setBatteryCapacity(float capacity_mah); ///< Set battery capacity manually via CLI (converts to mWh internally) + bool isBatteryCapacitySet() const; ///< Check if battery capacity was explicitly set (vs default) + uint16_t getTTL_Hours() const; ///< Get Time To Live in hours (0 = not calculated) + bool isLivingOnBattery() const; ///< True if net deficit over last 24h + static void syncSOCToFull(); ///< Sync SOC to 100% after "Charging Done" (resets INA228 baseline) + static bool setSOCManually(float soc_percent); ///< Manually set SOC to specific value (e.g. after reboot) + const BatterySOCStats* getSOCStats() const { return &socStats; } ///< Get SOC stats for CLI + const MpptStatistics* getMpptStats() const { return &mpptStats; } ///< Get MPPT stats for CLI + static void updateBatterySOC(); ///< Update SOC from INA228 Coulomb Counter + static uint32_t getRTCTimestamp(); ///< Get current RTC time (for diagnostics) + + static float getNominalVoltage(BatteryType type); ///< Get nominal voltage for chemistry type + void setLowVoltageRecovery() { lowVoltageRecovery = true; } ///< Mark as low-voltage recovery boot + Ina228Driver* getIna228Driver(); ///< Get INA228 driver instance + + // NTC Temperature Calibration methods + bool setTcCalOffset(float offset_c); ///< Store temperature calibration offset in °C (persistent) + float getTcCalOffset() const; ///< Get current temperature calibration offset + float performTcCalibration(float* bme_temp_out = nullptr); ///< Calibrate NTC using BME280 as auto-reference + static float readBmeTemperature(); ///< Read BME280 temperature directly via I2C + + // Low-voltage alert methods (Rev 1.1 — INA228 ALERT on P1.02) + void armLowVoltageAlert(); ///< Arm INA228 BUVL alert at lowv_sleep_mv (called on battery config) + static void disarmLowVoltageAlert(); ///< Disarm INA228 BUVL alert and detach ISR + static void lowVoltageAlertISR(); ///< ISR for INA228 ALERT pin — sets flag (checked in tickPeriodic) + + // Voltage threshold helpers (chemistry-specific) + static uint16_t getLowVoltageSleepThreshold(BatteryType type); ///< Get sleep threshold (INA228 ALERT) + static uint16_t getLowVoltageWakeThreshold(BatteryType type); ///< Get wake threshold (0% SOC marker) + + // Watchdog methods + static void setupWatchdog(); ///< Initialize and start hardware watchdog (600s timeout) + static void feedWatchdog(); ///< Feed the watchdog to prevent reset + static void disableWatchdog(); ///< Disable watchdog before OTA (cannot truly disable nRF52 WDT) + + // LED control methods + bool setLEDsEnabled(bool enabled); ///< Enable/disable heartbeat LED and BQ stat LED (persistent) + bool getLEDsEnabled() const; ///< Get current LED enable state + +private: + static BqDriver* bqDriverInstance; ///< Singleton reference for static methods + static Ina228Driver* ina228DriverInstance; ///< Singleton reference for INA228 + static TaskHandle_t heartbeatTaskHandle; ///< Handle for heartbeat task + static volatile bool lowVoltageAlertFired; ///< ISR flag: INA228 ALERT fired (checked in tickPeriodic) + + // Tick-based scheduling state (millis()-based, overflow-safe) + uint32_t lastMpptMs = 0; ///< Last runMpptCycle() execution + uint32_t lastSocMs = 0; ///< Last updateBatterySOC() execution + uint32_t lastHourlyMs = 0; ///< Last updateHourlyStats() execution + bool tickInitialized = false; ///< First-call init flag for MPPT stats + + void runMpptCycle(); ///< Single MPPT cycle + static MpptStatistics mpptStats; ///< MPPT statistics data + static BatterySOCStats socStats; ///< Battery SOC statistics + static BatteryType cachedBatteryType; ///< Cached battery type for static methods (set by begin()/setBatteryType()) + + bool BQ_INITIALIZED = false; + bool INA228_INITIALIZED = false; + bool lowVoltageRecovery = false; ///< Set in begin() if booting from low-voltage sleep (GPREGRET2) + static bool leds_enabled; // Heartbeat and BQ stat LED control (static for ISR access) + static bool usbInputActive; // True when USB VBUS detected — caps IINDPM to 500mA + static float tcCalOffset; // NTC temperature calibration offset in °C (0.0 = no calibration) + static float lastValidBatteryTemp; // Last valid battery temperature in °C (updated by getTelemetryData() or BME280 fallback, default 25.0 = no derating) + static uint32_t lastTempUpdateMs; // millis() of last valid temperature update (0 = never updated) + + bool configureBaseBQ(); + bool configureChemistry(BatteryType type); + float performTcCalibration(float actual_temp_c); ///< Internal: calibrate NTC given reference temp (called by BME auto-cal) + static constexpr const char* PREFS_NAMESPACE = "inheromr2"; + static constexpr const char* BATTKEY = "batType"; + static constexpr const char* FROSTKEY = "frost"; + static constexpr const char* MAXCHARGECURRENTKEY = "maxChrg"; + static constexpr const char* MPPTENABLEKEY = "mpptEn"; + static constexpr const char* BATTERY_CAPACITY_KEY = "batCap"; + static constexpr const char* TCCAL_KEY = "tcCal"; // NTC temperature calibration offset + + + bool loadBatType(BatteryType& type) const; + bool loadFrost(FrostChargeBehaviour& behaviour) const; + bool loadMaxChrgI(uint16_t& maxCharge_mA) const; + bool loadBatteryCapacity(float& capacity_mah) const; + bool loadTcCalOffset(float& offset) const; // NTC temperature calibration + + + // MPPT Statistics helper + static void updateMpptStats(); + + // Battery SOC helpers + static void updateHourlyStats(); ///< Update hourly statistics (called every 60 minutes) + static void calculateRollingStats(); ///< Calculate 24h and 3-day averages from rolling buffer + static void calculateTTL(); ///< Calculate TTL from 7-day avg net deficit and remaining SOC capacity +}; \ No newline at end of file diff --git a/variants/inhero_mr2/GuardedRTCClock.h b/variants/inhero_mr2/GuardedRTCClock.h new file mode 100644 index 0000000000..0ea4800984 --- /dev/null +++ b/variants/inhero_mr2/GuardedRTCClock.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +// Wraps AutoDiscoverRTCClock with a lock to avoid RTC I2C access during +// critical timer configuration; writes are deferred until unlocked. +class GuardedRTCClock : public mesh::RTCClock { + public: + explicit GuardedRTCClock(mesh::RTCClock& fallback) + : _fallback(&fallback), + _inner(fallback), + _locked(false), + _pending_time_valid(false), + _pending_time(0) {} + + void begin(TwoWire& wire) { _inner.begin(wire); } + + void setLocked(bool locked) { + _locked = locked; + if (!_locked && _pending_time_valid) { + _inner.setCurrentTime(_pending_time); + _pending_time_valid = false; + } + } + + uint32_t getCurrentTime() override { + if (_locked) { + return _fallback->getCurrentTime(); + } + return _inner.getCurrentTime(); + } + + void setCurrentTime(uint32_t time) override { + if (_locked) { + _pending_time = time; + _pending_time_valid = true; + return; + } + _inner.setCurrentTime(time); + } + + void tick() override { _inner.tick(); } + + private: + mesh::RTCClock* _fallback; + AutoDiscoverRTCClock _inner; + volatile bool _locked; + bool _pending_time_valid; + uint32_t _pending_time; +}; diff --git a/variants/inhero_mr2/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp new file mode 100644 index 0000000000..302ed1dc49 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -0,0 +1,1483 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Inhero MR-2 Board Implementation + */ + +// Includes +#include "InheroMr2Board.h" + +#include "BoardConfigContainer.h" +#include "target.h" + +#include +#include +#include + +namespace { + +void clearRtcTimerFlag() { + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + if (Wire.endTransmission(false) != 0) { + return; + } + + Wire.requestFrom((uint8_t)RTC_I2C_ADDR, (uint8_t)1); + if (!Wire.available()) { + return; + } + + uint8_t status = Wire.read(); + if ((status & (1 << 3)) == 0) { + return; + } + + status &= ~(1 << 3); + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); + Wire.endTransmission(); +} + +} // namespace + +// Static declarations +static BoardConfigContainer boardConfig; +volatile bool InheroMr2Board::rtc_irq_pending = false; +volatile uint32_t InheroMr2Board::ota_dfu_reset_at = 0; + +// USB power auto-management +static bool usb_active = true; // USB starts enabled (Serial.begin in main) + +static bool isUSBPowered() { + return (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; +} + +static void disableUSB() { + if (usb_active) { + Serial.end(); + NRF_USBD->ENABLE = 0; + usb_active = false; + BoardConfigContainer::setUsbConnected(false); + MESH_DEBUG_PRINTLN("USB disabled"); + } +} + +static void enableUSB() { + if (!usb_active) { + NRF_USBD->ENABLE = 1; + Serial.begin(115200); + usb_active = true; + BoardConfigContainer::setUsbConnected(true); + MESH_DEBUG_PRINTLN("USB enabled"); + } +} + +// ===== Public Methods ===== + +void InheroMr2Board::begin() { + // === FAST PATH: RTC wake from low-voltage sleep === + // Check GPREGRET2 FIRST — before ANY GPIO setup. + // Note: System Sleep wake triggers a System-ON reset. The bootloader runs before our code, + // and the reset clears all PIN_CNF to Input/Disconnect defaults. BSP init() only does + // OUTSET=0xFFFFFFFF which has no physical effect on Input-configured pins. + // Therefore we MUST explicitly re-assert any GPIO we need (CE, etc.) in this path. + uint8_t shutdown_reason = NRF_POWER->GPREGRET2; + + if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE) { + // Minimal I2C setup — only thing we need +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + Wire.begin(); + delay(10); + + // SYSTEMOFF wake is a reset, so the FALLING-edge ISR never sees the RTC event. + // Clear TF here before we arm RTC_INT pull-up + SENSE again. + clearRtcTimerFlag(); + + // RTC INT: must have SENSE_Low for System Sleep wake-up + NRF_GPIO->PIN_CNF[RTC_INT_PIN] = + (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Connect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Pullup << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Low << GPIO_PIN_CNF_SENSE_Pos); + + uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, 0x40); + uint16_t wake_threshold = getLowVoltageWakeThreshold(); + + MESH_DEBUG_PRINTLN("LV-Wake: VBAT=%dmV, wake=%dmV", vbat_mv, wake_threshold); + + if (vbat_mv == 0 || vbat_mv < wake_threshold) { + // Still too low or read failed — go back to sleep immediately. + // INA228 ADC needs shutdown (readVBATDirect left it in one-shot mode). + + // BQ CE pin: The System-ON reset after System Sleep wake resets all PIN_CNF + // to Input/Disconnect defaults. The previous cycle's OUTPUT latch is lost. + // Must explicitly re-assert OUTPUT HIGH so solar charging stays active. +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, HIGH); + MESH_DEBUG_PRINTLN("LV-Wake: CE re-latched HIGH (solar charging active)"); +#endif + + // INA228 → Shutdown mode with readback verification (~3.5µA vs ~350µA continuous). + // I2C writes can fail silently — if this fails, 350µA wasted in System Sleep! + for (int retry = 0; retry < 3; retry++) { + Wire.beginTransmission(0x40); + Wire.write(0x01); // ADC_CONFIG register + Wire.write(0x00); // Shutdown mode (MSB) + Wire.write(0x00); // (LSB) + if (Wire.endTransmission() != 0) { + delay(10); + continue; + } + delay(2); + // Readback verification + Wire.beginTransmission(0x40); + Wire.write(0x01); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + uint16_t rb = 0; + if (Wire.available() >= 2) { + rb = (Wire.read() << 8) | Wire.read(); + } + if ((rb & 0xF000) == 0x0000) break; + delay(10); + } + + // INA228 — Release ALERT pin before sleep. + // The under-voltage alert is latched (ALATCH=1) → ALERT stays LOW. + // RAK4630 internal pull-up on P1.02 → ~330µA through pull-up into OD transistor. + // 1. Write DIAG_ALRT=0: ALATCH=0 (transparent mode) + clear all flag bits + Wire.beginTransmission(0x40); + Wire.write(0x0B); // DIAG_ALRT register + Wire.write(0x00); // MSB: ALATCH=0, CNVR=0, SLOWALERT=0, APOL=0 + Wire.write(0x00); // LSB: clear all flags + Wire.endTransmission(); + // 2. Set BUVL=0 (no threshold → no alert condition in transparent mode) + Wire.beginTransmission(0x40); + Wire.write(0x08); // BUVL register + Wire.write(0x00); + Wire.write(0x00); + Wire.endTransmission(); + + // BQ25798 — Disable ADC (saves ~500µA continuous draw) + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(0x2E); // ADC_CONTROL register + Wire.write(0x00); // ADC_EN=0, ADC disabled + Wire.endTransmission(); + + // BQ25798 — Mask all interrupts + clear flags to de-assert INT pin. + // Without this, any pending flag holds INT LOW and INPUT_PULLUP wastes ~254µA. + { const uint8_t mask_regs[] = {0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D}; + for (uint8_t r : mask_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.write(0xFF); // Mask all + Wire.endTransmission(); + } + const uint8_t flag_regs[] = {0x22, 0x23, 0x24, 0x25, 0x26, 0x27}; + for (uint8_t r : flag_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)BQ25798_I2C_ADDR, (uint8_t)1); + while (Wire.available()) Wire.read(); + } + } + + // SX1262: Send SetSleep command AND latch NSS HIGH. + // After System-ON reset, SX1262 may be in Standby RC (~600µA). + // Both are needed: SetSleep puts it to Cold Sleep, NSS latch prevents re-wake. + prepareRadioForSystemOff(false); + + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + Wire.end(); + + // Disconnect all GPIO pull-ups to prevent leakage in System Sleep. + // Wire.begin() set SDA/SCL to INPUT_PULLUP; if an I2C device holds + // the line LOW, each pull-up wastes ~250µA. Wire.end() alone does NOT + // disable the pull-ups on nRF52. + disconnectLeakyPullups(); + + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + sd_power_system_off(); + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); + } + + // Voltage recovered — close I2C and fall through to normal boot + Wire.end(); + + // Recovery LED flash + pinMode(LED_BLUE, OUTPUT); + for (int i = 0; i < 3; i++) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(150); + } + + NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE; + // setLowVoltageRecovery + setSOCManually deferred to after boardConfig.begin() + MESH_DEBUG_PRINTLN("LV-Wake: Voltage recovered (%dmV >= %dmV) — normal boot", vbat_mv, wake_threshold); + } + + // === Standard boot path (ColdBoot, recovery, or non-LV wake) === + bool isLowVoltageRecovery = ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE); + + pinMode(PIN_VBAT_READ, INPUT); + + // BQ25798 CE pin: Drive LOW on every boot so the external FET stays OFF. + // Rev 1.1: DMN2004TK-7 FET inverts logic — LOW on GPIO = FET off = CE released. + // The external CE pull-up is 100k to REGN, so CE goes HIGH and charging stays disabled only when REGN is present. + // configureChemistry() will drive HIGH only after successful I2C configuration with a known battery type. +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, LOW); +#endif + + // PE4259 RF switch power enable (VDD pin 6 on PE4259) + // P1.05 (GPIO 37) supplies VDD to the PE4259 SPDT antenna switch on the RAK4630. + // DIO2 of the SX1262 controls the CTRL pin (pin 4) for TX/RX path selection. + // Without VDD, the RF switch cannot operate and no TX/RX is possible. + pinMode(SX126X_POWER_EN, OUTPUT); + digitalWrite(SX126X_POWER_EN, HIGH); + delay(10); // Give PE4259 time to power up + +#ifdef PIN_USER_BTN + pinMode(PIN_USER_BTN, INPUT_PULLUP); +#endif + +#ifdef PIN_USER_BTN_ANA + pinMode(PIN_USER_BTN_ANA, INPUT_PULLUP); +#endif + +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + + // === I2C Bus Recovery === + // After OTA/warm-reset, a slave may hold SDA low (stuck mid-transaction). + // Wire.begin() cannot recover this — toggle SCL manually to release the bus. + // Standard recovery: up to 9 clock pulses while SDA is monitored. + { + const uint8_t sda = PIN_BOARD_SDA; + const uint8_t scl = PIN_BOARD_SCL; + + pinMode(sda, INPUT_PULLUP); + pinMode(scl, OUTPUT); + digitalWrite(scl, HIGH); + + bool bus_stuck = (digitalRead(sda) == LOW); + if (bus_stuck) { + for (int i = 0; i < 9; i++) { + digitalWrite(scl, LOW); + delayMicroseconds(5); + digitalWrite(scl, HIGH); + delayMicroseconds(5); + if (digitalRead(sda) == HIGH) break; // SDA released + } + // Generate STOP condition: SDA LOW→HIGH while SCL is HIGH + pinMode(sda, OUTPUT); + digitalWrite(sda, LOW); + delayMicroseconds(5); + digitalWrite(scl, HIGH); + delayMicroseconds(5); + digitalWrite(sda, HIGH); + delayMicroseconds(5); + MESH_DEBUG_PRINTLN("I2C bus recovery performed (SDA was stuck LOW)"); + } + // Release pins for Wire library + pinMode(sda, INPUT); + pinMode(scl, INPUT); + } + + Wire.begin(); + delay(50); // Give I2C bus time to stabilize + + // MR2 Rev 1.1 hardware — no detection needed + MESH_DEBUG_PRINTLN("Inhero MR2 - Hardware Rev 1.1 (INA228 ALERT + RTC + CE-FET)"); + + // === CRITICAL: Configure RTC INT pin for wake-up from System Sleep === + // attachInterrupt() alone is NOT sufficient for System Sleep wake-up! + // We MUST configure the pin with SENSE for nRF52 SYSTEMOFF wake capability + pinMode(RTC_INT_PIN, INPUT_PULLUP); + + // Configure GPIO SENSE for wake-up from System Sleep (nRF52 SYSTEMOFF mode) + // This is essential - without SENSE configuration, System Sleep wake-up will not work + NRF_GPIO->PIN_CNF[RTC_INT_PIN] = + (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Connect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Pullup << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Low << GPIO_PIN_CNF_SENSE_Pos); // Wake on LOW (RTC interrupt is active-low) + + attachInterrupt(digitalPinToInterrupt(RTC_INT_PIN), rtcInterruptHandler, FALLING); + + // === Early Boot Voltage Check (ColdBoot only) === + // LV-wake resleep is handled by the fast path above. + // This section handles ColdBoot below sleep threshold and normal ColdBoot. + + if (!isLowVoltageRecovery) { + MESH_DEBUG_PRINTLN("Early Boot: Reading VBAT from INA228 @ 0x40..."); + uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, 0x40); + MESH_DEBUG_PRINTLN("Early Boot: readVBATDirect returned %dmV", vbat_mv); + + if (vbat_mv == 0) { + MESH_DEBUG_PRINTLN("Early Boot: Failed to read battery voltage, assuming OK"); + } else { + BoardConfigContainer::BatteryType bootBatType = boardConfig.getBatteryType(); + uint16_t wake_threshold = getLowVoltageWakeThreshold(); + uint16_t sleep_threshold = getLowVoltageSleepThreshold(); + + MESH_DEBUG_PRINTLN("Early Boot Check: VBAT=%dmV, Wake=%dmV (0%% SOC), Sleep=%dmV, Reason=0x%02X", + vbat_mv, wake_threshold, sleep_threshold, shutdown_reason); + + if (bootBatType == BoardConfigContainer::BAT_UNKNOWN) { + MESH_DEBUG_PRINTLN("Early Boot: BAT_UNKNOWN - skipping low-voltage check (configure battery type first)"); + if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE || + (shutdown_reason & GPREGRET2_LOW_VOLTAGE_SLEEP)) { + NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE; + MESH_DEBUG_PRINTLN("Early Boot: Cleared stale GPREGRET2 flags (was 0x%02X)", shutdown_reason); + } + } + // ColdBoot with voltage below sleep threshold — first entry into LV sleep + else if (vbat_mv < sleep_threshold) { + MESH_DEBUG_PRINTLN("ColdBoot below sleep threshold (%dmV < %dmV)", vbat_mv, sleep_threshold); + MESH_DEBUG_PRINTLN("Going to sleep for %d min to avoid motorboating", LOW_VOLTAGE_SLEEP_MINUTES); + + delay(100); + prepareRadioForSystemOff(false); + + // INA228 → Shutdown mode with readback verification + for (int retry = 0; retry < 3; retry++) { + Wire.beginTransmission(0x40); + Wire.write(0x01); // ADC_CONFIG register + Wire.write(0x00); // Shutdown (MSB) + Wire.write(0x00); // (LSB) + if (Wire.endTransmission() != 0) { + delay(10); + continue; + } + delay(2); + Wire.beginTransmission(0x40); + Wire.write(0x01); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + uint16_t rb = 0; + if (Wire.available() >= 2) { + rb = (Wire.read() << 8) | Wire.read(); + } + if ((rb & 0xF000) == 0x0000) break; + delay(10); + } + + // Read DIAG_ALRT to clear any latched alert flag + Wire.beginTransmission(0x40); + Wire.write(0x0B); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + while (Wire.available()) Wire.read(); + + // Latch BQ CE pin HIGH (solar charging active in sleep) +#ifdef BQ_CE_PIN + digitalWrite(BQ_CE_PIN, HIGH); +#endif + + // BQ25798 — Disable ADC (saves ~500µA continuous draw) + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(0x2E); // ADC_CONTROL + Wire.write(0x00); // ADC_EN=0 + Wire.endTransmission(); + + // BQ25798 — Mask all interrupts + clear flags to de-assert INT + { const uint8_t mask_regs[] = {0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D}; + for (uint8_t r : mask_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.write(0xFF); + Wire.endTransmission(); + } + const uint8_t flag_regs[] = {0x22, 0x23, 0x24, 0x25, 0x26, 0x27}; + for (uint8_t r : flag_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)BQ25798_I2C_ADDR, (uint8_t)1); + while (Wire.available()) Wire.read(); + } + } + + // BME280 — Force Sleep mode + Wire.beginTransmission(0x76); + Wire.write(0xF4); // ctrl_meas + Wire.write(0x00); // Sleep mode + Wire.endTransmission(); + + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + Wire.end(); + disconnectLeakyPullups(); + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + + sd_power_system_off(); + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); + } + // Normal ColdBoot — voltage OK + else { + MESH_DEBUG_PRINTLN("Normal ColdBoot - voltage OK (%dmV >= %dmV)", vbat_mv, sleep_threshold); + } + } + } + + // === Normal boot path: Initialize board hardware === + // Only reached when voltage is OK (or unreadable) — resleep paths exit above. + // boardConfig.begin() initializes BQ25798, INA228, CE pin, alerts, LEDs, etc. + MESH_DEBUG_PRINTLN("Initializing Rev 1.1 features (BQ25798, INA228, RTC, CE-FET)"); + boardConfig.begin(); + + // Handle low-voltage recovery (deferred until after boardConfig.begin()) + if (isLowVoltageRecovery) { + boardConfig.setLowVoltageRecovery(); + BoardConfigContainer::setSOCManually(0.0f); + MESH_DEBUG_PRINTLN("SOC: Set to 0%% (low-voltage recovery)"); + } + + // Enable DC/DC converter REG1 for improved power efficiency (~1.5mA savings) + // REG1: VDD 3.3V → 1.3V core (DC/DC vs LDO) + // REG0 (DCDCEN0) is NOT needed — RAK4630 is powered from TPS62840 3.3V rail (VDD), + // not from VBUS (USB). REG0 only applies to the VBUS→VDD_nRF path. + // Done after peripheral initialization to avoid voltage glitches + NRF52BoardDCDC::begin(); + + // LEDs already initialized in boardConfig.begin() + // Blue LED was used for boot sequence visualization + // Red LED indicates missing components (if blinking) + + // Start hardware watchdog (600s timeout) + // Must be last - after all initializations are complete + BoardConfigContainer::setupWatchdog(); + + // Set initial USB IINDPM limit based on VBUS state at boot + if (isUSBPowered()) { + BoardConfigContainer::setUsbConnected(true); + } +} + +void InheroMr2Board::tick() { + // USB auto-management: enable/disable USB peripheral based on VBUS presence. + // After Serial.end(), Serial.available() returns 0 and Serial.read() returns -1, + // so no serial guard is needed in the main loop. + if (!usb_active && isUSBPowered()) { + enableUSB(); + } + if (usb_active && !isUSBPowered()) { + disableUSB(); + } + + // Deferred OTA DFU reset: wait for CLI reply to be sent, then enter bootloader + if (ota_dfu_reset_at != 0 && millis() >= ota_dfu_reset_at) { + enterOTADfu(); // disables SoftDevice & interrupts, sets GPREGRET, resets — does not return + } + + if (rtc_irq_pending) { + rtc_irq_pending = false; + + // Clear TF here (not in ISR) to avoid I2C bus collisions with core RTC access. + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.endTransmission(false); + Wire.requestFrom(RTC_I2C_ADDR, (uint8_t)1); + + if (Wire.available()) { + uint8_t status = Wire.read(); + status &= ~(1 << 3); // Clear TF bit (bit 3) + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); + Wire.endTransmission(); + } + } + + // Dispatch all periodic I2C work (MPPT, SOC, hourly stats, low-V alert check) + boardConfig.tickPeriodic(); + + // All healthy — feed watchdog at the END (after I2C operations completed successfully) + BoardConfigContainer::feedWatchdog(); + + // Briefly idle via WFE until next interrupt (radio DIO1, SysTick, USB, I2C). + // Typically wakes within 1ms. Reduces CPU current from ~3mA (busy-loop) to ~0.5-0.8mA. + // Harmless when powersaving also calls sleep() — on nRF52 both are just WFE. + sleep(0); +} + +uint16_t InheroMr2Board::getBattMilliVolts() { + // WORKAROUND: The MeshCore protocol currently only transmits battery voltage + // (via getBattMilliVolts), not a direct SOC percentage. The companion app then + // interprets this voltage using a hardcoded Li-Ion discharge curve to derive SOC%. + // This gives wrong readings for LiFePO4/LTO chemistries whose voltage profiles + // differ significantly from Li-Ion. + // + // Solution: When we have a valid Coulomb-counted SOC, we reverse-map it to + // the Li-Ion 1S OCV (Open Circuit Voltage) that the app expects. + // This way the app always displays our accurate chemistry-independent SOC. + // + // TODO: Remove this workaround once MeshCore supports transmitting the actual + // SOC percentage alongside (or instead of) battery millivolts. At that point, + // this function should return the real battery voltage again. + + const BatterySOCStats* socStats = boardConfig.getSOCStats(); + if (socStats && socStats->soc_valid) { + return socToLiIonMilliVolts(boardConfig.getStateOfCharge()); + } + + // Fallback: no valid Coulomb-counting SOC yet — return real voltage + const Telemetry* telemetry = boardConfig.getTelemetryData(); + if (!telemetry) { + return 0; + } + return telemetry->batterie.voltage; +} + +/// @brief Maps a SOC percentage (0-100%) to a fake Li-Ion 1S OCV in millivolts. +/// @details Uses a standard Li-Ion NMC/NCA OCV lookup table with piecewise-linear +/// interpolation. The companion app will reverse-map these voltages back +/// to the same SOC%, giving correct battery level display regardless of +/// the actual cell chemistry (Li-Ion, LiFePO4, LTO). +/// @param soc_percent State of Charge in percent (0.0 – 100.0) +/// @return Equivalent Li-Ion 1S voltage in millivolts (3000 – 4200) +uint16_t InheroMr2Board::socToLiIonMilliVolts(float soc_percent) { + // Clamp input to valid range + if (soc_percent <= 0.0f) return 3000; + if (soc_percent >= 100.0f) return 4200; + + // Standard Li-Ion 1S OCV table (NMC/NCA, 10% steps) + // Index 0 = 0% SOC, Index 10 = 100% SOC + static const uint16_t LI_ION_OCV_TABLE[] = { + 3000, // 0% + 3300, // 10% + 3450, // 20% + 3530, // 30% + 3600, // 40% + 3670, // 50% + 3740, // 60% + 3820, // 70% + 3920, // 80% + 4050, // 90% + 4200 // 100% + }; + + // Piecewise-linear interpolation between 10% steps + float index_f = soc_percent / 10.0f; // 0.0 – 10.0 + uint8_t idx_lo = (uint8_t)index_f; // lower table index + if (idx_lo >= 10) idx_lo = 9; // safety clamp + uint8_t idx_hi = idx_lo + 1; + + float frac = index_f - (float)idx_lo; // fractional part (0.0 – 1.0) + float mv = (float)LI_ION_OCV_TABLE[idx_lo] + + frac * (float)(LI_ION_OCV_TABLE[idx_hi] - LI_ION_OCV_TABLE[idx_lo]); + + return (uint16_t)(mv + 0.5f); // round to nearest mV +} + +bool InheroMr2Board::startOTAUpdate(const char* id, char reply[]) { + // Skip the in-app BLE DFU (unstable on nRF52 in MeshCore environment) and + // jump directly into the Adafruit bootloader's OTA DFU mode. + // enterOTADfu() sets GPREGRET=0xA8, disables SoftDevice & interrupts, then resets. + // The bootloader handles BLE advertising and firmware transfer natively. + MESH_DEBUG_PRINTLN("OTA: Scheduling Adafruit bootloader DFU mode..."); + + // Read BLE MAC address from nRF52 hardware registers (no Bluefruit needed) + uint32_t addr0 = NRF_FICR->DEVICEADDR[0]; + uint32_t addr1 = NRF_FICR->DEVICEADDR[1]; + snprintf(reply, 64, "OK DFU - mac: %02X:%02X:%02X:%02X:%02X:%02X", + (addr1 >> 8) & 0xFF, addr1 & 0xFF, + (addr0 >> 24) & 0xFF, (addr0 >> 16) & 0xFF, (addr0 >> 8) & 0xFF, addr0 & 0xFF); + + // Schedule deferred reset into bootloader DFU mode. + // Return immediately so the CLI handler can send the reply first. + // tick() will handle cleanup (stop tasks, radio off) and reset after the delay. + ota_dfu_reset_at = millis() + 3000; // 3s delay to ensure reply is transmitted + + return true; +} + +/// @brief Collects board telemetry and appends to CayenneLPP packet +/// @param telemetry CayenneLPP packet to append data to +/// @return true if successful, false if telemetry data unavailable +bool InheroMr2Board::queryBoardTelemetry(CayenneLPP& telemetry) { + const Telemetry* telemetryData = boardConfig.getTelemetryData(); + if (!telemetryData) { + return false; + } + + uint8_t batteryChannel = this->findNextFreeChannel(telemetry); + uint8_t solarChannel = batteryChannel + 1; + + const BatterySOCStats* socStats = boardConfig.getSOCStats(); + bool hasValidSoc = (socStats && socStats->soc_valid); + float socPercent = boardConfig.getStateOfCharge(); + // Requested precision: one decimal place + socPercent = roundf(socPercent * 10.0f) / 10.0f; + + uint16_t ttlHours = boardConfig.getTTL_Hours(); + bool isInfiniteTtl = (socStats && socStats->soc_valid && !socStats->living_on_battery); + constexpr float MAX_TTL_DAYS = 990.0f; // Max encodable LPP distance value + + // Battery channel + // Field order: + // 1) VBAT[V], 2) SOC[%] (optional), 3) IBAT[A], 4) TBAT[°C], 5) TTL[d] (optional) + telemetry.addVoltage(batteryChannel, telemetryData->batterie.voltage / 1000.0f); + if (hasValidSoc) { + telemetry.addPercentage(batteryChannel, socPercent); + } + telemetry.addCurrent(batteryChannel, telemetryData->batterie.current / 1000.0f); + if (telemetryData->batterie.temperature > -100.0f) { + telemetry.addTemperature(batteryChannel, telemetryData->batterie.temperature); + } + + // TTL handling: + // - ttlHours > 0: send finite TTL in days + // - surplus (infinite TTL): send max value + // - unknown TTL (ttlHours == 0 and not surplus): send nothing + if (ttlHours > 0) { + telemetry.addDistance(batteryChannel, ttlHours / 24.0f); + } else if (isInfiniteTtl) { + telemetry.addDistance(batteryChannel, MAX_TTL_DAYS); + } + + // Solar channel + // Field order: + // 1) VSOL[V], 2) ISOL[A], 3) MPPT_7D[%] + telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); + telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + telemetry.addPercentage(solarChannel, boardConfig.getMpptEnabledPercentage7Day()); + + return true; +} + +/// @brief Handles custom CLI getter commands for board configuration +/// @param getCommand Command string (without "board." prefix) +/// @param reply Buffer to write response to +/// @param maxlen Maximum length of reply buffer +/// @return true if command was handled, false otherwise +bool InheroMr2Board::getCustomGetter(const char* getCommand, char* reply, uint32_t maxlen) { + + // Trim trailing whitespace from command + char trimmedCommand[100]; + strncpy(trimmedCommand, getCommand, sizeof(trimmedCommand) - 1); + trimmedCommand[sizeof(trimmedCommand) - 1] = '\0'; + char* cmd = BoardConfigContainer::trim(trimmedCommand); + + if (strcmp(cmd, "bat") == 0) { + snprintf(reply, maxlen, "%s", + BoardConfigContainer::getBatteryTypeCommandString(boardConfig.getBatteryType())); + return true; + } else if (strcmp(cmd, "fmax") == 0) { + const auto* props = BoardConfigContainer::getBatteryProperties(boardConfig.getBatteryType()); + if (props && props->ts_ignore) { + snprintf(reply, maxlen, "N/A"); + } else { + snprintf( + reply, maxlen, "%s", + BoardConfigContainer::getFrostChargeBehaviourCommandString(boardConfig.getFrostChargeBehaviour())); + } + return true; + } else if (strcmp(cmd, "imax") == 0) { + snprintf(reply, maxlen, "%s", boardConfig.getChargeCurrentAsStr()); + return true; + } else if (strcmp(cmd, "mppt") == 0) { + snprintf(reply, maxlen, "MPPT=%s", boardConfig.getMPPTEnabled() ? "1" : "0"); + return true; + } else if (strcmp(cmd, "stats") == 0) { + // Combined energy statistics: balance + MPPT + const BatterySOCStats* socStats = boardConfig.getSOCStats(); + if (!socStats) { + float mppt_pct = boardConfig.getMpptEnabledPercentage7Day(); + snprintf(reply, maxlen, "N/A M:%.0f%%", mppt_pct); + return true; + } + + // Balance info (mAh) - rolling windows (no midnight reset) + // Include current-hour accumulators so data is visible before first hour boundary + float last_24h_net = socStats->last_24h_net_mah + + socStats->current_hour_solar_mah + - socStats->current_hour_discharged_mah; + float last_24h_charged = socStats->last_24h_charged_mah + + socStats->current_hour_charged_mah; + float last_24h_discharged = socStats->last_24h_discharged_mah + + socStats->current_hour_discharged_mah; + const char* status = socStats->living_on_battery ? "BAT" : "SOL"; + float avg3d = socStats->avg_3day_daily_net_mah; + float avg3d_charged = socStats->avg_3day_daily_charged_mah; + float avg3d_discharged = socStats->avg_3day_daily_discharged_mah; + float avg7d = socStats->avg_7day_daily_net_mah; + float avg7d_charged = socStats->avg_7day_daily_charged_mah; + float avg7d_discharged = socStats->avg_7day_daily_discharged_mah; + uint16_t ttl = boardConfig.getTTL_Hours(); + + // MPPT info + float mppt_pct = boardConfig.getMpptEnabledPercentage7Day(); + + // Compact format: 24h/3d/7d Status MPPT% TTL + char ttlBuf[16]; + if (ttl >= 24) { + snprintf(ttlBuf, sizeof(ttlBuf), "%dd%dh", ttl / 24, ttl % 24); + } else if (ttl > 0) { + snprintf(ttlBuf, sizeof(ttlBuf), "%dh", ttl); + } else { + snprintf(ttlBuf, sizeof(ttlBuf), "N/A"); + } + snprintf(reply, maxlen, "%+.0f/%+.0f/%+.0fmAh C:%.0f D:%.0f 3C:%.0f 3D:%.0f 7C:%.0f 7D:%.0f %s M:%.0f%% T:%s", + last_24h_net, avg3d, avg7d, last_24h_charged, last_24h_discharged, avg3d_charged, avg3d_discharged, + avg7d_charged, avg7d_discharged, status, mppt_pct, ttlBuf); + return true; + } else if (strcmp(cmd, "cinfo") == 0) { + char infoBuffer[100]; + boardConfig.getChargerInfo(infoBuffer, sizeof(infoBuffer)); + snprintf(reply, maxlen, "%s", infoBuffer); + return true; + } else if (strcmp(cmd, "bqdiag") == 0) { + char diagBuffer[100]; + boardConfig.getBqDiagnostics(diagBuffer, sizeof(diagBuffer)); + snprintf(reply, maxlen, "%s", diagBuffer); + return true; + } else if (strcmp(cmd, "selftest") == 0) { + char stBuffer[64]; + boardConfig.getSelfTest(stBuffer, sizeof(stBuffer)); + snprintf(reply, maxlen, "%s", stBuffer); + return true; + } else if (strcmp(cmd, "socdebug") == 0) { + Ina228Driver* ina = boardConfig.getIna228Driver(); + if (!ina) { + snprintf(reply, maxlen, "INA228 n/a"); + return true; + } + const BatterySOCStats* s = boardConfig.getSOCStats(); + uint16_t scal = ina->readShuntCalRegister(); + float chg = ina->readCharge_mAh(); + float cur = ina->readCurrent_mA_precise(); + uint32_t rtc = boardConfig.getRTCTimestamp(); + snprintf(reply, maxlen, + "S=%u I=%.1f C=%.1f hC%.1f hD%.1f n=%u t=%lu d=%.2f", + scal, cur, chg, + s->current_hour_charged_mah, + s->current_hour_discharged_mah, + s->soc_update_count, + (unsigned long)rtc, + s->temp_derating_factor); + return true; + } else if (strcmp(cmd, "telem") == 0) { + const Telemetry* telemetry = boardConfig.getTelemetryData(); + if (!telemetry) { + snprintf(reply, maxlen, "Err: Telemetry unavailable"); + return true; + } + + // Use precise battery current from telemetry (INA228) + float precise_current_ma = telemetry->batterie.current; + + // Get SOC info + float soc = boardConfig.getStateOfCharge(); + const BatterySOCStats* socStats = boardConfig.getSOCStats(); + + // Format currents: battery current with 1 decimal, solar current without decimals + // INA228 driver returns correctly signed values: positive=charging, negative=discharging + char bat_current_str[16]; + snprintf(bat_current_str, sizeof(bat_current_str), "%.1fmA", precise_current_ma); + + char sol_current_str[16]; + int16_t sol_current = telemetry->solar.current; + if (sol_current == 0) { + snprintf(sol_current_str, sizeof(sol_current_str), "0mA"); + } else if (sol_current < 50) { + snprintf(sol_current_str, sizeof(sol_current_str), "<50mA"); + } else if (sol_current <= 100) { + snprintf(sol_current_str, sizeof(sol_current_str), "~%dmA", (int)sol_current); + } else { + snprintf(sol_current_str, sizeof(sol_current_str), "%dmA", (int)sol_current); + } + + // Format temperature: "N/A" when NTC unavailable (no solar) + char temp_str[8]; + if (telemetry->batterie.temperature <= -100.0f) { + snprintf(temp_str, sizeof(temp_str), "N/A"); + } else { + snprintf(temp_str, sizeof(temp_str), "%.0fC", telemetry->batterie.temperature); + } + + if (socStats && socStats->soc_valid) { + // Trapped Charge model: cold locks the bottom of the discharge curve. + // trapped% = (1 − f(T)) × 100, extractable% = max(0, SOC% − trapped%) + if (socStats->temp_derating_factor < 0.999f && socStats->temp_derating_factor > 0.0f) { + float trapped_pct = (1.0f - socStats->temp_derating_factor) * 100.0f; + float derated_soc = soc - trapped_pct; + if (derated_soc < 0.0f) derated_soc = 0.0f; + if (derated_soc > 100.0f) derated_soc = 100.0f; + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% (%.0f%%) S:%.2fV/%s", telemetry->batterie.voltage / 1000.0f, + bat_current_str, temp_str, soc, derated_soc, telemetry->solar.voltage / 1000.0f, + sol_current_str); + } else { + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% S:%.2fV/%s", telemetry->batterie.voltage / 1000.0f, + bat_current_str, temp_str, soc, telemetry->solar.voltage / 1000.0f, + sol_current_str); + } + } else { + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:N/A S:%.2fV/%s", telemetry->batterie.voltage / 1000.0f, + bat_current_str, temp_str, telemetry->solar.voltage / 1000.0f, + sol_current_str); + } + return true; + } else if (strcmp(cmd, "conf") == 0) { + // Display all configuration values + const char* batType = BoardConfigContainer::getBatteryTypeCommandString(boardConfig.getBatteryType()); + const char* frostBehaviour; + const auto* confProps = BoardConfigContainer::getBatteryProperties(boardConfig.getBatteryType()); + if (confProps && confProps->ts_ignore) { + frostBehaviour = "N/A"; + } else { + frostBehaviour = + BoardConfigContainer::getFrostChargeBehaviourCommandString(boardConfig.getFrostChargeBehaviour()); + } + if (boardConfig.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + snprintf(reply, maxlen, "B:%s (no battery, charging disabled)", batType); + } else { + float chargeVoltage = boardConfig.getMaxChargeVoltage(); + float voltage0Soc = getLowVoltageWakeThreshold() / 1000.0f; + const char* imax = boardConfig.getChargeCurrentAsStr(); + bool mpptEnabled = boardConfig.getMPPTEnabled(); + + snprintf(reply, maxlen, "B:%s F:%s M:%s I:%s Vco:%.2f V0:%.2f", batType, frostBehaviour, + mpptEnabled ? "1" : "0", imax, chargeVoltage, voltage0Soc); + } + return true; + } else if (strcmp(cmd, "tccal") == 0) { + // Get current NTC temperature calibration offset + float offset = boardConfig.getTcCalOffset(); + snprintf(reply, maxlen, "TC offset: %+.2f C (0.00=default)", offset); + return true; + } else if (strcmp(cmd, "leds") == 0) { + // Get LED enable state (heartbeat + BQ stat LED) + bool enabled = boardConfig.getLEDsEnabled(); + snprintf(reply, maxlen, "LEDs: %s (Heartbeat + BQ Stat)", enabled ? "ON" : "OFF"); + return true; + } else if (strcmp(cmd, "batcap") == 0) { + // Get battery capacity in mAh - show if default or explicitly set + float capacity_mah = boardConfig.getBatteryCapacity(); + bool explicitly_set = boardConfig.isBatteryCapacitySet(); + if (explicitly_set) { + snprintf(reply, maxlen, "%.0f mAh (set)", capacity_mah); + } else { + snprintf(reply, maxlen, "%.0f mAh (default)", capacity_mah); + } + return true; + } + + snprintf(reply, maxlen, + "Err: bat|fmax|imax|mppt|telem|stats|cinfo|conf|tccal|leds|batcap"); + return true; +} + +/// @brief Handles custom CLI setter commands for board configuration +/// @param setCommand Command string with value (without "board." prefix) +/// @return Status message ("OK" on success, error message on failure) +const char* InheroMr2Board::setCustomSetter(const char* setCommand) { + + static char ret[100]; + memset(ret, 0, sizeof(ret)); // Clear buffer to prevent garbage data + + if (strncmp(setCommand, "bat ", 4) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[4])); + BoardConfigContainer::BatteryType bt = BoardConfigContainer::getBatteryTypeFromCommandString(value); + if (bt != BoardConfigContainer::BatteryType::BAT_UNKNOWN || strcmp(value, "none") == 0) { + boardConfig.setBatteryType(bt); + snprintf(ret, sizeof(ret), "Bat set to %s", + BoardConfigContainer::getBatteryTypeCommandString(boardConfig.getBatteryType())); + return ret; + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", BoardConfigContainer::getAvailableBatOptions()); + return ret; + } + } else if (strncmp(setCommand, "fmax ", 5) == 0) { + const auto* fmaxProps = BoardConfigContainer::getBatteryProperties(boardConfig.getBatteryType()); + if (fmaxProps && fmaxProps->ts_ignore) { + snprintf(ret, sizeof(ret), "Err: Fmax setting N/A for this chemistry (JEITA disabled)"); + return ret; + } + + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + BoardConfigContainer::FrostChargeBehaviour fcb = + BoardConfigContainer::getFrostChargeBehaviourFromCommandString(value); + if (fcb != BoardConfigContainer::FrostChargeBehaviour::REDUCE_UNKNOWN) { + boardConfig.setFrostChargeBehaviour(fcb); + snprintf( + ret, sizeof(ret), "Fmax charge current set to %s of imax", + BoardConfigContainer::getFrostChargeBehaviourCommandString(boardConfig.getFrostChargeBehaviour())); + return ret; + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", + BoardConfigContainer::getAvailableFrostChargeBehaviourOptions()); + return ret; + } + } else if (strncmp(setCommand, "imax ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + int ma = atoi(value); + if (ma >= 50 && ma <= 1500) { + boardConfig.setMaxChargeCurrent_mA(ma); + snprintf(ret, sizeof(ret), "Max charge current set to %s", boardConfig.getChargeCurrentAsStr()); + return ret; + } else { + return "Err: Try 50-1500"; + } + } else if (strncmp(setCommand, "mppt ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + // Convert to lowercase for case-insensitive comparison + char lowerValue[20]; + strncpy(lowerValue, value, sizeof(lowerValue) - 1); + lowerValue[sizeof(lowerValue) - 1] = '\0'; + for (char* p = lowerValue; *p; ++p) + *p = tolower(*p); + + if (strcmp(lowerValue, "true") == 0 || strcmp(lowerValue, "1") == 0) { + boardConfig.setMPPTEnable(true); + snprintf(ret, sizeof(ret), "MPPT enabled"); + return ret; + } else if (strcmp(lowerValue, "false") == 0 || strcmp(lowerValue, "0") == 0) { + boardConfig.setMPPTEnable(false); + snprintf(ret, sizeof(ret), "MPPT disabled"); + return ret; + } else { + return "Err: Try true|false or 1|0"; + } + } else if (strncmp(setCommand, "batcap ", 7) == 0) { + // Set battery capacity + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[7])); + float capacity_mah = atof(value); + + if (boardConfig.setBatteryCapacity(capacity_mah)) { + snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh", capacity_mah); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid capacity (100-100000 mAh)"); + } + return ret; + } else if (strncmp(setCommand, "tccal", 5) == 0) { + // NTC temperature calibration: + // set board.tccal → auto-read BME280 as reference + // set board.tccal reset → reset to 0.00 + const char* rest = &setCommand[5]; + + // Skip optional space + if (*rest == ' ') rest++; + + const char* value = BoardConfigContainer::trim(const_cast(rest)); + + // Check for reset command + if (strcmp(value, "reset") == 0 || strcmp(value, "RESET") == 0) { + if (boardConfig.setTcCalOffset(0.0f)) { + snprintf(ret, sizeof(ret), "TC calibration reset to 0.00 (default)"); + } else { + snprintf(ret, sizeof(ret), "Err: Failed to reset TC calibration"); + } + return ret; + } + + // Auto-read BME280 as reference (averages 5 samples each) + float bme_avg = 0.0f; + float new_offset = boardConfig.performTcCalibration(&bme_avg); + if (new_offset > -900.0f) { + snprintf(ret, sizeof(ret), "TC auto-cal: BME=%.1f offset=%+.2f C", bme_avg, new_offset); + } else { + snprintf(ret, sizeof(ret), "Err: Auto-cal failed (BME280/NTC error?)"); + } + return ret; + } else if (strncmp(setCommand, "leds ", 5) == 0) { + // Enable/disable heartbeat LED and BQ stat LED + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + bool enabled = (strcmp(value, "1") == 0 || strcmp(value, "on") == 0 || strcmp(value, "ON") == 0); + bool disabled = (strcmp(value, "0") == 0 || strcmp(value, "off") == 0 || strcmp(value, "OFF") == 0); + + if (enabled || disabled) { + boardConfig.setLEDsEnabled(enabled); + snprintf(ret, sizeof(ret), "LEDs %s (Heartbeat + BQ Stat)", enabled ? "enabled" : "disabled"); + return ret; + } else { + snprintf(ret, sizeof(ret), "Err: Use 'on/1' or 'off/0'"); + return ret; + } + } else if (strncmp(setCommand, "soc ", 4) == 0) { + // Manually set SOC percentage (e.g. after reboot with known SOC) + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[4])); + float soc_percent = atof(value); + + if (BoardConfigContainer::setSOCManually(soc_percent)) { + snprintf(ret, sizeof(ret), "SOC set to %.1f%%", soc_percent); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid SOC (0-100) or INA228 not ready"); + } + return ret; + } + + snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|batcap|tccal|leds|soc"); + return ret; +} + +// ===== Power Management Methods (Rev 1.1) ===== + +/// @brief Get low-voltage sleep threshold (chemistry-specific) +/// @return Sleep threshold in millivolts (INA228 ALERT fires here) +uint16_t InheroMr2Board::getLowVoltageSleepThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageSleepThreshold(chemType); +} + +/// @brief Get low-voltage wake threshold (chemistry-specific) +/// @return Wake threshold in millivolts (0% SOC marker, RTC wake decision) +uint16_t InheroMr2Board::getLowVoltageWakeThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageWakeThreshold(chemType); +} + +/// @brief Put SX1262 radio and SPI pins into lowest power state for System Sleep. +/// Must be called before ANY sd_power_system_off() call — both from initiateShutdown() +/// and Early Boot quick-shutdown paths. +/// +/// @param radioInitialized true if SPI + radio have been initialized (normal shutdown), +/// false if called from Early Boot before SPI.begin() / radio.begin(). +/// +/// CRITICAL: Do NOT call SPI.end() here! SPI.end() runs nrf_gpio_cfg_default() which +/// briefly puts NSS/SCK into "disconnected input" (floating). The SX1262's internal 48kΩ +/// pull-up isn't fast enough — floating SCK triggers a wake from Cold Sleep to Standby RC +/// (~600µA). Once awake, only an explicit SPI SetSleep command can put it back — but we've +/// already released SPI. +/// +/// Instead, pre-configure the GPIO PORT registers (OUTSET/PIN_CNF) while SPIM is still +/// active. These are "shadow" values that take effect when SPIM is disabled. System Sleep +/// stops all peripherals including SPIM, and the GPIO PORT seamlessly takes over — no +/// glitch, no floating pins, SX1262 stays in Cold Sleep (~160nA). +void InheroMr2Board::prepareRadioForSystemOff(bool radioInitialized) { + if (radioInitialized) { + // Put SX1262 into Cold Sleep via SPI while SPIM is still active + radio_driver.powerOff(); // calls radio.sleep(false) — cold sleep, lowest power + delay(10); + } else { + // Early Boot: SPI/RadioLib not initialized. The SX1262 may be in: + // a) POR Standby RC (~600µA) — first power-on or VDD glitch + // b) Cold Sleep (~160nA) — if previous shutdown latched pins correctly + // Case (b) is fine, case (a) wastes ~600µA. We can't tell which, so always + // send SetSleep to ensure Cold Sleep. + // + // Use bit-banged SPI — completely independent of Arduino SPIClass and SPIM + // peripheral configuration. variant.h defines SPI pins as WB header pins + // (P0.03/P0.29/P0.30) which are NOT the LoRa radio pins (P1.11/P1.12/P1.13). + // Bit-bang directly on P_LORA_* pins avoids all BSP/SPIM conflicts. + + // Configure SPI GPIOs + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); + pinMode(P_LORA_SCLK, OUTPUT); + digitalWrite(P_LORA_SCLK, LOW); // CPOL=0: idle LOW + pinMode(P_LORA_MOSI, OUTPUT); + digitalWrite(P_LORA_MOSI, LOW); + pinMode(P_LORA_BUSY, INPUT); + + // SX1262 Wake-Up + SetSleep with correct NSS double-cycle. + // + // SX1262 Datasheet §13.1.1: "When exiting sleep mode using the falling + // edge of NSS pin, the SPI command is NOT interpreted by the transceiver + // so a SUBSEQUENT falling edge of NSS is therefore necessary." + // + // Phase 1: Wake-up cycle (NSS LOW → wait BUSY LOW → NSS HIGH) + // If SX1262 is in Cold Sleep: NSS LOW wakes it, BUSY goes HIGH (~3.5ms), + // then LOW when ready. We wait for BUSY LOW. + // If SX1262 is in Standby RC: NSS LOW does nothing, BUSY already LOW. + // Either way, after this phase SX1262 is in Standby RC and ready. + digitalWrite(P_LORA_NSS, LOW); + delayMicroseconds(2); + // Wait for BUSY LOW — SX1262 ready after wake-up or already awake + uint32_t t0 = millis(); + while (digitalRead(P_LORA_BUSY) == HIGH && (millis() - t0) < 10) { + delayMicroseconds(100); + } + digitalWrite(P_LORA_NSS, HIGH); // End wake-up cycle + delayMicroseconds(10); // Minimum NSS HIGH time + + // Phase 2: Actual SetSleep command on fresh NSS falling edge + // Opcode 0x84, config 0x00 = Cold Start (no config retention, TCXO off) + // SPI Mode 0 (CPOL=0, CPHA=0): data sampled on rising edge, MSB first + static const uint8_t cmd[2] = { 0x84, 0x00 }; + + digitalWrite(P_LORA_NSS, LOW); + delayMicroseconds(2); // NSS setup time + + for (int b = 0; b < 2; b++) { + uint8_t byte = cmd[b]; + for (int i = 7; i >= 0; i--) { + digitalWrite(P_LORA_MOSI, (byte >> i) & 1); + delayMicroseconds(1); + digitalWrite(P_LORA_SCLK, HIGH); + delayMicroseconds(1); + digitalWrite(P_LORA_SCLK, LOW); + delayMicroseconds(1); + } + } + + digitalWrite(P_LORA_MOSI, LOW); + delayMicroseconds(1); + digitalWrite(P_LORA_NSS, HIGH); + + delay(1); // Allow SX1262 to enter Cold Sleep (~500ns typ) + } + + // Power off PE4259 RF switch (cut VDD to antenna switch) + digitalWrite(SX126X_POWER_EN, LOW); + + // Latch ALL SX1262 SPI input pins to defined states during System OFF. + // If SCLK/MOSI float (INPUT_DISCONNECT → ~VDD/2), the SX1262 CMOS input + // buffers draw shoot-through current (hundreds of µA). + // NSS=HIGH (keep Cold Sleep), SCLK=LOW (CPOL=0 idle), MOSI=LOW (idle). + uint32_t pin_cfg_out = (GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Disconnect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Disabled << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Disabled << GPIO_PIN_CNF_SENSE_Pos); + NRF_P1->OUTSET = (1UL << 10); // NSS (P1.10) = HIGH + NRF_P1->OUTCLR = (1UL << 11) | (1UL << 12); // SCLK (P1.11) = LOW, MOSI (P1.12) = LOW + NRF_P1->PIN_CNF[10] = pin_cfg_out; // NSS + NRF_P1->PIN_CNF[11] = pin_cfg_out; // SCLK + NRF_P1->PIN_CNF[12] = pin_cfg_out; // MOSI +} + +/// @brief Set ALL GPIO pins to INPUT_DISCONNECT except the 3 pins needed during System Sleep. +/// Must be called AFTER Wire.end(), AFTER prepareRadioForSystemOff(), and AFTER all I2C/SPI. +/// +/// Comprehensive approach: Instead of chasing individual leaky pins, reset ALL pins to the +/// power-on-reset default (Input, Disconnected, No pull). This eliminates ALL possible +/// current leakage through GPIO — pull-ups driving OD devices LOW, OUTPUT pins driving +/// LEDs/loads, etc. +/// +/// CRITICAL: Adafruit BSP init() runs NRF_P0->OUTSET = NRF_P1->OUTSET = 0xFFFFFFFF on +/// every boot BEFORE our code. Any pin left as OUTPUT from the previous System Sleep cycle +/// gets its output latch set HIGH. For LEDs (P1.03, P1.04) this means LEDs turn ON. +/// For CE (P0.04) this means charge enable pulse. By setting unused pins to INPUT_DISCONNECT, +/// DIR=Input prevents the BSP OUTSET from causing physical pin changes on the next boot. +/// +/// Only 2 pins are preserved: +/// P0.04 (BQ_CE_PIN) — OUTPUT HIGH: solar charging active during sleep +/// P0.17 (RTC_INT_PIN) — INPUT_PULLUP + SENSE_Low: System Sleep wake source +/// NSS (P1.10) has internal pull-up inside RAK4630 — no external latch needed. +void InheroMr2Board::disconnectLeakyPullups() { + uint32_t pin_cfg_discon = (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Disconnect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Disabled << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Disabled << GPIO_PIN_CNF_SENSE_Pos); + + // P0: pins 0–31, skip P0.04 (CE) and P0.17 (RTC INT) + for (uint8_t pin = 0; pin < 32; pin++) { + if (pin == 4 || pin == 17) continue; // BQ_CE_PIN, RTC_INT_PIN + NRF_P0->PIN_CNF[pin] = pin_cfg_discon; + } + + // P1: pins 0–15, skip SX1262 SPI pins latched by prepareRadioForSystemOff() + // NSS (P1.10)=HIGH, SCLK (P1.11)=LOW, MOSI (P1.12)=LOW + for (uint8_t pin = 0; pin < 16; pin++) { + if (pin == 10 || pin == 11 || pin == 12) continue; // NSS, SCLK, MOSI + NRF_P1->PIN_CNF[pin] = pin_cfg_discon; + } +} + +/// @brief Initiate controlled shutdown with filesystem protection (Rev 1.1) +/// @param reason Shutdown reason code (stored in GPREGRET2 for next boot) +/// +/// Rev 1.1 low-voltage shutdown uses System Sleep with GPIO latch (< 500µA total): +/// - INA228 enters shutdown mode (~3.5µA) +/// - BQ CE pin latched HIGH via FET (solar charging continues autonomously) +/// - RTC countdown timer configured for periodic wake +/// - nRF52 enters SYSTEMOFF (~1.5µA) — GPIO latches preserved +/// - RTC wake triggers reboot; Early Boot checks voltage for boot vs sleep-again +void InheroMr2Board::initiateShutdown(uint8_t reason) { + MESH_DEBUG_PRINTLN("PWRMGT: Initiating shutdown (reason=0x%02X)", reason); + + // 1. Stop background tasks to prevent filesystem corruption + BoardConfigContainer::stopBackgroundTasks(); + + // 2. INA228: Shutdown mode to minimize sleep current (~3.5µA vs ~300µA continuous) + // No BUVL monitoring needed in sleep — RTC wakes us for voltage check. + Ina228Driver* ina = boardConfig.getIna228Driver(); + if (ina) { + // Release INA228 ALERT pin before shutdown. + // The under-voltage alert that triggered sleep is latched (ALATCH=1) — ALERT + // stays LOW. RAK4630 has internal pull-up on P1.02 → 3.3V through pull-up + // into OD transistor = ~330µA wasted in System Sleep. + // Fix: 1) Disable ALATCH by writing DIAG_ALRT=0 (transparent mode + clear flags) + // 2) Set BUVL=0 (no threshold → no alert condition in transparent mode) + // 3) Then shutdown ADC + ina->enableAlert(false, false, false); // DIAG_ALRT=0: ALATCH=0, clear all flags + ina->setUnderVoltageAlert(0); // BUVL=0: disable under-voltage comparison + ina->shutdown(); + } + + // 3. SX1262 sleep + SPI cleanup (prevents ~4mA leakage in System Sleep) + prepareRadioForSystemOff(); + + // 4. LEDs off before sleep + digitalWrite(PIN_LED1, LOW); + digitalWrite(PIN_LED2, LOW); + + if (reason == SHUTDOWN_REASON_LOW_VOLTAGE) { + MESH_DEBUG_PRINTLN("PWRMGT: Low voltage shutdown - entering System Sleep with CE latched"); + + delay(100); // Allow I/O to complete + + // 5. Latch BQ CE pin HIGH (FET ON = CE LOW = charge enabled) + // GPIO output latch survives System Sleep as long as VDD is present +#ifdef BQ_CE_PIN + digitalWrite(BQ_CE_PIN, HIGH); + MESH_DEBUG_PRINTLN("PWRMGT: CE latched HIGH (solar charging active in sleep)"); +#endif + + // 5b. BQ25798 — Disable ADC (saves ~500µA continuous draw) + // Must be AFTER CE=HIGH — charge enable may re-enable ADC internally. + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(0x2E); // ADC_CONTROL register + Wire.write(0x00); // ADC_EN=0, ADC disabled + Wire.endTransmission(); + + // 5c. BQ25798 — Mask all interrupts and clear flags to de-assert INT pin. + // Default masks are 0x00 (all unmasked). Any pending flag holds INT LOW, + // and INPUT_PULLUP on BQ_INT_PIN wastes ~254µA through the pull-up. + { // Mask all interrupts + const uint8_t mask_regs[] = {0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D}; + for (uint8_t r : mask_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.write(0xFF); + Wire.endTransmission(); + } + // Read-to-clear all flag registers + const uint8_t flag_regs[] = {0x22, 0x23, 0x24, 0x25, 0x26, 0x27}; + for (uint8_t r : flag_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)BQ25798_I2C_ADDR, (uint8_t)1); + while (Wire.available()) Wire.read(); + } + } + + // 5d. BME280 @ 0x76 — Force Sleep mode (saves ~1-7µA) + // After normal operation readBmeTemperature() may have left BME280 in NORMAL mode. + // Harmless NACK if no BME280 populated. + Wire.beginTransmission(0x76); + Wire.write(0xF4); // ctrl_meas register + Wire.write(0x00); // MODE=00 (Sleep), all oversampling off + Wire.endTransmission(); + MESH_DEBUG_PRINTLN("PWRMGT: BQ25798 ADC/INT + BME280 shut down"); + + // 6. Configure RTC to wake us up periodically for voltage check + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + + // 7. Clear GPIO LATCH for RTC INT pin. + // If a previous RTC wake cycle set the LATCH (retained across System Sleep), + // DETECT would fire immediately → instant wake → boot loop. + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + // 8. Release I2C buses (done AFTER RTC config, which uses Wire) + Wire.end(); + + // 9. Disconnect all GPIO pull-ups on OD/I2C pins to prevent leakage + disconnectLeakyPullups(); + + // 10. Store shutdown reason for Early Boot decision + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | reason; + + MESH_DEBUG_PRINTLN("PWRMGT: Entering System Sleep (< 500uA)"); + delay(50); + + sd_power_system_off(); + // Fallback if SoftDevice not enabled + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); + } + + // Non-low-voltage shutdown (user request, thermal): use System OFF + Wire.end(); + disconnectLeakyPullups(); + NRF_POWER->GPREGRET2 = reason; + + MESH_DEBUG_PRINTLN("PWRMGT: Entering SYSTEMOFF"); + delay(50); + + // Clear LATCH to prevent spurious wake + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + sd_power_system_off(); + // Fallback if SoftDevice not enabled + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); +} + +/// @brief Configure RV-3028 RTC countdown timer for periodic wake-up +/// @param minutes Wake-up interval in minutes (1-4095, RV-3028 12-bit limit) +void InheroMr2Board::configureRTCWake(uint32_t minutes) { +#if defined(INHERO_MR2) + rtc_clock.setLocked(true); +#endif + uint16_t countdown_ticks = static_cast(minutes == 0 ? LOW_VOLTAGE_SLEEP_MINUTES : minutes); + if (countdown_ticks == 0) { + countdown_ticks = 1; + } + // RV-3028 Timer Value register is 12-bit (max 4095) + if (countdown_ticks > 4095) { + countdown_ticks = 4095; + } + MESH_DEBUG_PRINTLN("PWRMGT: Configuring RTC wake in %u minutes", + static_cast(countdown_ticks)); + + // === RTC Timer Configuration per Manual Section 4.8.2 === + // Step 1: Stop Timer and clear flags + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x00); // TE=0, TD=00 (stop timer) + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + Wire.write(0x00); // TIE=0 (disable interrupt) + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(0x00); // Clear TF flag + Wire.endTransmission(); + + // Step 2: Set Timer Value (ticks at 1/60 Hz) + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_TIMER_VALUE_0); + Wire.write(countdown_ticks & 0xFF); // Lower 8 bits + Wire.write((countdown_ticks >> 8) & 0x0F); // Upper 4 bits + Wire.endTransmission(); + + // Step 3: Configure Timer (1/60 Hz clock, Single shot mode) + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x07); // TE=1 (Enable), TD=11 (1/60 Hz), TRPT=0 (Single shot) + Wire.endTransmission(); + + // Step 4: Enable Timer Interrupt + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + Wire.write(0x10); // TIE=1 (Timer Interrupt Enable, bit 4) + Wire.endTransmission(); + + MESH_DEBUG_PRINTLN("PWRMGT: RTC countdown configured (%u ticks at 1/60 Hz)", countdown_ticks); + +#if defined(INHERO_MR2) + rtc_clock.setLocked(false); +#endif +} + +/// @brief RTC interrupt handler - called when countdown timer expires +void InheroMr2Board::rtcInterruptHandler() { + // RTC countdown elapsed - device woke from SYSTEMOFF + // Defer I2C work to the main loop to avoid ISR I2C collisions. + rtc_irq_pending = true; +} + +// ===== Helper Functions ===== + +/// @brief Helper function to get LPP data length for a given type +/// @param type LPP data type +/// @return Data length in bytes, or 0 if unknown type +static uint8_t getLPPDataLength(uint8_t type) { + switch (type) { + case LPP_DIGITAL_INPUT: + case LPP_DIGITAL_OUTPUT: + case LPP_PRESENCE: + case LPP_RELATIVE_HUMIDITY: + case LPP_PERCENTAGE: + case LPP_SWITCH: + return 1; + + case LPP_ANALOG_INPUT: + case LPP_ANALOG_OUTPUT: + case LPP_LUMINOSITY: + case LPP_TEMPERATURE: + case LPP_BAROMETRIC_PRESSURE: + case LPP_VOLTAGE: + case LPP_CURRENT: + case LPP_ALTITUDE: + case LPP_POWER: + case LPP_DIRECTION: + case LPP_CONCENTRATION: + return 2; + + case LPP_COLOUR: + return 3; + + case LPP_GENERIC_SENSOR: + case LPP_FREQUENCY: + case LPP_DISTANCE: + case LPP_ENERGY: + case LPP_UNIXTIME: + return 4; + + case LPP_ACCELEROMETER: + case LPP_GYROMETER: + return 6; + + case LPP_GPS: + return 9; + + case LPP_POLYLINE: + return 8; // minimum size + + default: + return 0; // Unknown type + } +} + +/// @brief Find next available channel number in CayenneLPP packet +/// @param lpp CayenneLPP packet to analyze +/// @return Next free channel number (highest used channel + 1) +/// @note This method parses the LPP buffer to find the highest channel number in use, +/// then returns the next available channel. Used by queryBoardTelemetry() to +/// append board-specific telemetry without channel conflicts. +uint8_t InheroMr2Board::findNextFreeChannel(CayenneLPP& lpp) { + uint8_t max_channel = 0; + uint8_t cursor = 0; + uint8_t* buffer = lpp.getBuffer(); + uint8_t size = lpp.getSize(); + + while (cursor < size) { + // Need at least 2 bytes: channel + type + if (cursor + 1 >= size) break; + + uint8_t channel = buffer[cursor]; + uint8_t type = buffer[cursor + 1]; + uint8_t data_len = getLPPDataLength(type); + + // Unknown type - can't determine length, stop parsing + if (data_len == 0) break; + + // Update max channel + if (channel > max_channel) max_channel = channel; + + // Move to next entry + cursor += 2 + data_len; + } + + // Return next channel after the highest parsed one. + // If packet is empty, start at channel 1. + return max_channel + 1; +} diff --git a/variants/inhero_mr2/InheroMr2Board.h b/variants/inhero_mr2/InheroMr2Board.h new file mode 100644 index 0000000000..0216adb245 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Inhero MR-2 Board Class + */ +#pragma once + +#include +#include +#include +#include + +// LoRa radio module pins for Inhero MR-2 +#define P_LORA_DIO_1 47 +#define P_LORA_NSS 42 +#define P_LORA_RESET RADIOLIB_NC // 38 +#define P_LORA_BUSY 46 +#define P_LORA_SCLK 43 +#define P_LORA_MISO 45 +#define P_LORA_MOSI 44 +#define SX126X_POWER_EN 37 // P1.05 — PE4259 RF switch VDD (power enable) + +// GPS module support (future expansion) +// Note: GPS pins not yet configured in MR2 hardware +// GPS_BAUD_RATE would be 9600, GPS_ADDRESS would be 0x42 (I2C) + +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// built-ins +#define PIN_VBAT_READ 5 +#define ADC_MULTIPLIER (3 * 1.73 * 1.187 * 1000) + +// Power Management Configuration (INA228 + RTC) +// Note: GPIO17 (WB_IO1) is used for RTC_INT, not available for GPS_1PPS +#define RTC_INT_PIN 17 // GPIO17 (WB_IO1) - RTC Interrupt from RV-3028 +#define RTC_I2C_ADDR 0x52 // RV-3028-C7 I2C address +#define INA228_I2C_ADDR 0x40 // INA228 I2C address (A0=GND, A1=GND) +#define BQ25798_I2C_ADDR 0x6B // BQ25798 Battery Charger I2C address +// Note: INA228 ALERT pin (P1.02) triggers low-voltage sleep via interrupt +// TPS62840 EN tied to VDD (always on) — no hardware UVLO cutoff + +// RV-3028-C7 RTC Register Addresses (Per Application Manual Section 3.2) +#define RV3028_REG_STATUS 0x0E // Status register (TF flag at bit 3) +#define RV3028_REG_CTRL1 0x0F // Control 1 (TE at bit 2, TD at bits 1:0) +#define RV3028_REG_CTRL2 0x10 // Control 2 (TIE at bit 4) +#define RV3028_REG_TIMER_VALUE_0 0x0A // Timer Value 0 (lower 8 bits) +#define RV3028_REG_TIMER_VALUE_1 0x0B // Timer Value 1 (upper 4 bits) + +// Shutdown reason codes (stored in GPREGRET2 bits [1:0]) +#define SHUTDOWN_REASON_NONE 0x00 +#define SHUTDOWN_REASON_LOW_VOLTAGE 0x01 +#define SHUTDOWN_REASON_USER_REQUEST 0x02 +#define SHUTDOWN_REASON_THERMAL 0x03 + +// Power management state flags (stored in GPREGRET2 bits [7:2]) +#define GPREGRET2_LOW_VOLTAGE_SLEEP 0x04 // Bit 2: In low-voltage sleep (RTC wake cycle) + +// Low-voltage sleep duration (used by Early Boot and SOC update) +#define LOW_VOLTAGE_SLEEP_MINUTES (60) + +class InheroMr2Board : public NRF52BoardDCDC { +public: + InheroMr2Board() : NRF52Board("InheroMR2_OTA") {} + void begin(); + void tick() override; // Feed watchdog and perform board-specific tasks + + uint16_t getBattMilliVolts() override; + + // Power Management Methods (INA228 + RTC) + /// @brief Initiate controlled shutdown with filesystem protection + /// @param reason Shutdown reason code (stored in GPREGRET2 for next boot) + void initiateShutdown(uint8_t reason); + + /// @brief Configure RV-3028 RTC countdown timer for periodic wake-up + /// @param minutes Wake-up interval in minutes + void configureRTCWake(uint32_t minutes); + + /// @brief Get voltage threshold for low-voltage sleep (chemistry-specific) + /// @return Sleep threshold in millivolts (INA228 ALERT fires here) + uint16_t getLowVoltageSleepThreshold(); + + /// @brief Get voltage threshold for low-voltage wake / 0% SOC (chemistry-specific) + /// @return Wake threshold in millivolts + uint16_t getLowVoltageWakeThreshold(); + + /// @brief RTC interrupt handler (called by hardware interrupt) + static void rtcInterruptHandler(); + + /// @brief Put SX1262 + SPI pins into lowest power state for System Sleep + /// @param radioInitialized false in Early Boot (before SPI.begin), true after full init + /// Must be called before any sd_power_system_off() to prevent ~4mA SX1262 leakage + static void prepareRadioForSystemOff(bool radioInitialized = true); + + /// @brief Disconnect internal pull-ups on OD/I2C pins to prevent leakage in System Sleep + /// Must be called AFTER Wire.end() and before sd_power_system_off() + static void disconnectLeakyPullups(); + + const char *getManufacturerName() const override { return "Inhero MR2"; } + + void reboot() override { NVIC_SystemReset(); } + + bool startOTAUpdate(const char *id, char reply[]) override; + + bool getCustomGetter(const char *getCommand, char *reply, uint32_t maxlen) override; + const char *setCustomSetter(const char *setCommand) override; + bool queryBoardTelemetry(CayenneLPP &telemetry) override; + +private: + uint8_t findNextFreeChannel(CayenneLPP &lpp); + static uint16_t socToLiIonMilliVolts(float soc_percent); + static volatile bool rtc_irq_pending; + static volatile uint32_t ota_dfu_reset_at; ///< millis() timestamp for deferred DFU reset (0 = inactive) +}; diff --git a/variants/inhero_mr2/README.md b/variants/inhero_mr2/README.md new file mode 100644 index 0000000000..5aaca8aa73 --- /dev/null +++ b/variants/inhero_mr2/README.md @@ -0,0 +1,67 @@ +# Inhero MR-2 + +Smart solar mesh repeater board for autonomous long-term deployment. + +**Hardware Revision:** 1.1 + +## Why another repeater board? + +Existing solar-capable boards require the user to match panels, chargers, and fuel gauges manually — and none of them recover autonomously after a deep discharge. The Inhero MR-2 is a single-board solution (45 × 40 mm) that combines: + +- **Universal MPPT solar input (3.6–24 V, buck/boost)** — any panel up to 25 V Voc, including boost charging (e.g. 5 V panel → 5.4 V 2S LTO). +- **4 battery chemistries, software-selectable** — 1S Li-Ion, 1S LiFePO₄, 2S LTO, 1S Na-Ion. No hardware change required. +- **Under-voltage sleep & autonomous recovery** — deep sleep < 500 µA with solar charging active; hourly RTC wake checks if restart is possible. +- **INA228 coulomb counting** — true SOC tracking as you know it from smartphones, instead of unreliable voltage-based guesswork. +- **On-board BME280 + RV-3028 RTC** — environment telemetry and stable time base even during hibernation. +- **6.0 mA @ 4.2 V** total system RX current. TPS62840 high-efficiency rail. +- **JEITA temperature zones** — automatic charge throttling in cold/hot conditions. +- **Hardware watchdog (600 s)** — a silent firmware freeze is impossible. +- **CE certification (RED 2014/53/EU) planned.** + +## Key Specifications + +| Parameter | Value | +|---|---| +| **Core Module** | RAK4630 (nRF52840 + SX1262) | +| **Charger** | TI BQ25798 (buck/boost, MPPT, JEITA) | +| **Power Monitor** | TI INA228 (coulomb counter, ALERT) | +| **RTC** | RV-3028-C7 (wake timer) | +| **Environment Sensor** | Bosch BME280 (T, H, P) | +| **Buck Converter** | TI TPS62840 (3.3 V, 750 mA) | +| **Solar Input** | 3.6–24 V (MPPT), max Voc 25 V | +| **Battery Chemistries** | 1S Li-Ion, 1S LiFePO4, 2S LTO, 1S Na-Ion | +| **Charge Current** | 50–1500 mA (configurable) | +| **Active Current** | 6.0 mA @ 4.2 V (USB off, no TX) | +| **Sleep Current** | < 500 µA (RTC wake, solar charging active) | +| **PCB Size** | 45 × 40 mm | + +## I2C Address Map + +| Address | Component | +|---------|-----------| +| 0x40 | INA228 | +| 0x52 | RV-3028-C7 | +| 0x6B | BQ25798 | +| 0x76 | BME280 | + +## Key GPIOs + +| GPIO | Function | +|------|----------| +| P0.04 (WB_IO4) | BQ CE pin (via N-FET, inverted) | +| P1.02 | INA228 ALERT (low-voltage interrupt) | +| GPIO17 (WB_IO1) | RV-3028 RTC interrupt | +| GPIO21 | BQ25798 INT | + +## Build Targets + +| Environment | Description | +|---|---| +| `Inhero_MR2_repeater` | Standard repeater | +| `Inhero_MR2_repeater_bridge_rs232` | Repeater with RS232 bridge | +| `Inhero_MR2_sensor` | Sensor firmware | + +## Documentation + +Full documentation (datasheet, battery guide, FAQ, CLI reference): +**[docs.inhero.de](https://docs.inhero.de)** diff --git a/variants/inhero_mr2/lib/BqDriver.cpp b/variants/inhero_mr2/lib/BqDriver.cpp new file mode 100644 index 0000000000..8a53522965 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -0,0 +1,569 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver Implementation + */ +#include "BqDriver.h" + +#include + +/// @brief Default constructor +BqDriver::BqDriver() {} + +/// @brief Destructor - cleans up I2C device +BqDriver::~BqDriver() { + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } +} + +/// @brief Initializes BQ25798 charger and creates dedicated I2C device for NTC access +/// @param i2c_addr I2C address of BQ25798 (default: 0x6B) +/// @param wire Pointer to TwoWire instance (default: &Wire) +/// @return true if initialization successful +bool BqDriver::begin(uint8_t i2c_addr, TwoWire* wire) { + if (!Adafruit_BQ25798::begin(i2c_addr, wire)) { + // Cleanup any existing device before returning + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } + return false; + } + if (ih_i2c_dev) { + delete ih_i2c_dev; + } + ih_i2c_dev = new Adafruit_I2CDevice(i2c_addr, wire); + if (!ih_i2c_dev->begin()) { + // Cleanup on failure + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + return false; + } + return true; +} + +/// @brief Reads Power Good status from charger +/// @return true if input power is good (sufficient for charging) +bool BqDriver::getChargerStatusPowerGood() { + Adafruit_BusIO_Register chrg_stat_0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_0); + Adafruit_BusIO_RegisterBits chrg_stat_0_bits = Adafruit_BusIO_RegisterBits(&chrg_stat_0_reg, 1, 3); + + uint8_t reg_value = chrg_stat_0_bits.read(); + + return (bool)reg_value; +} + +/// @brief Reads current charging state from charger +/// @return Charging status enum (NOT_CHARGING, PRE_CHARGING, CC, CV, etc.) +bq25798_charging_status BqDriver::getChargingStatus() { + Adafruit_BusIO_Register chrg_stat_1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_1); + Adafruit_BusIO_RegisterBits chrg_stat_1_bits = Adafruit_BusIO_RegisterBits(&chrg_stat_1_reg, 3, 5); + + uint8_t reg_value = chrg_stat_1_bits.read(); + + return (bq25798_charging_status)reg_value; +} + +/// @brief Reads solar and temperature telemetry via BQ25798 ADC one-shot +/// +/// BQ25798 ADC Operating Conditions (Datasheet SLUSE22, Section 9.3.16): +/// "The ADC is allowed to operate if either VBUS > 3.4V or VBAT > 2.9V is valid. +/// At battery only condition, if the TS_ADC channel is enabled, the ADC only +/// works when battery voltage is higher than 3.2V, otherwise, the ADC works +/// when the battery voltage is higher than 2.9V." +/// +/// This means: +/// VBUS > 3.4V → ADC runs, all channels available +/// VBAT >= 3.2V (no VBUS) → ADC runs, all channels including TS +/// VBAT 2.9-3.2V (no VBUS) → ADC runs ONLY if TS channel is DISABLED +/// VBAT < 2.9V (no VBUS) → ADC cannot run at all +/// +/// Strategy: +/// 1. If VBAT < 3.2V: disable TS channel to lower threshold to 2.9V +/// → Solar data (VBUS/IBUS) still readable, temperature returns N/A +/// 2. If VBAT < 2.9V and no VBUS: ADC times out, all values zero/N/A +/// 3. Only channels actually used on MR2 are enabled (IBUS, VBUS, TS) +/// — unused channels (IBAT, VBAT, VSYS, TDIE, D+, D-, VAC1, VAC2) +/// are disabled to prevent ADC_EN from hanging on unconnected pins. +/// +/// ADC_EN auto-clear behavior: +/// In one-shot mode, ADC_EN resets to 0 only when ALL enabled channels +/// have completed conversion. If any channel cannot complete (e.g. floating +/// input), ADC_EN stays 1 indefinitely. This is why unused channels MUST +/// be disabled via registers 0x2F/0x30. +/// +/// @param vbat_mv Battery voltage in mV from INA228 (0 = unknown, assume sufficient) +/// @return Pointer to internal Telemetry struct (valid until next call) +const Telemetry* const BqDriver::getTelemetryData(uint16_t vbat_mv) { + telemetryData = { 0 }; + + // Determine if TS channel can be enabled based on VBAT + // See datasheet quote above: TS enabled requires VBAT >= 3.2V (battery-only) + bool ts_enabled = true; + if (vbat_mv > 0 && vbat_mv < 3200) { + ts_enabled = false; // Disable TS → ADC threshold drops to 2.9V + } + + bool success = this->startADCOneShot(ts_enabled); + + if (!success) { + return &telemetryData; + } + + // Poll ADC_EN bit until it auto-clears (conversion complete) or timeout. + // Channels: IBUS + VBUS (+ TS if enabled) → ~48-72ms typical. + const uint32_t ADC_TIMEOUT_MS = 250; + uint32_t start = millis(); + bool conversion_done = false; + while ((millis() - start) < ADC_TIMEOUT_MS) { + if (!this->getADCEnabled()) { + conversion_done = true; + break; + } + delay(10); + } + + if (!conversion_done) { + this->setADCEnabled(false); + } + + if (conversion_done) { + telemetryData.solar.voltage = getVBUS(); + telemetryData.solar.current = getIBUS(); + if (telemetryData.solar.current < 0) { + telemetryData.solar.current = 0; + } + telemetryData.solar.power = ((int32_t)telemetryData.solar.voltage * telemetryData.solar.current) / 1000; + + if (ts_enabled) { + telemetryData.batterie.temperature = this->calculateBatteryTemp(getTS()); + } else { + // TS disabled due to low VBAT — cannot read NTC + telemetryData.batterie.temperature = -888.0f; + } + } else { + // ADC didn't complete — VBAT < 2.9V and no VBUS, or I2C issue + telemetryData.batterie.temperature = -888.0f; + } + + telemetryData.solar.mppt = getMPPTenable(); + + return &telemetryData; +} + +/** + * Calculates battery temperature in °C using Steinhart-Hart equation. + * Uses coefficients derived from Murata NCP15XH103F03RC datasheet R-T table. + * Max error vs. datasheet: ±0.36°C over -40..+125°C range. + * + * Per BQ25798 datasheet Figure 9-12: REGN → RT1 → TS → (RT2||NTC) → GND + * @param ts_pct Voltage at TS pin in percentage of REGN (e.g., 70.5 for 70.5%) + * Special values: -1.0 = I2C error, -2.0 = ADC not ready/invalid + * @return Temperature in °C, or error codes: + * -999.0 = I2C communication error + * -888.0 = ADC not ready (read 0 or 0xFFFF) + * -99.0 = NTC open/disconnected (k > 0.99) + * 99.0 = NTC short circuit (k < 0.01) + */ +float BqDriver::calculateBatteryTemp(float ts_pct) { + // Check for I2C read error + if (ts_pct == -1.0f) return -999.0f; // I2C error + if (ts_pct == -2.0f) return -888.0f; // ADC not ready or invalid value + + // Convert TS percentage to ratio (0.0 to 1.0) + // TS% = 100 × R_bottom / (R_top + R_bottom) + // where R_bottom = RT2 || NTC + float k = ts_pct / 100.0f; + + // Plausibility check + if (k > 0.99f) return -99.0f; // NTC open/disconnected + if (k < 0.01f) return 99.0f; // NTC short circuit + + // Calculate total resistance of bottom network (RT2 || NTC) + // From: k = R_bottom / (RT1 + R_bottom) + // Rearranged: R_bottom = RT1 × k / (1 - k) + float r_bottom_total = R_PULLUP * (k / (1.0f - k)); + + // Extract NTC resistance from parallel combination with RT2 + // For parallel resistors: 1/R_total = 1/R_NTC + 1/RT2 + // Therefore: 1/R_NTC = 1/R_total - 1/RT2 + float g_total = 1.0f / r_bottom_total; + float g_rt2 = 1.0f / R_PARALLEL; + + if (g_total <= g_rt2) { + return -99.0f; // Invalid measurement + } + + float r_ntc = 1.0f / (g_total - g_rt2); + + // Apply Steinhart-Hart equation: 1/T = A + B·ln(R) + C·(ln(R))³ + float ln_r = logf(r_ntc); + float inv_T = SH_A + SH_B * ln_r + SH_C * ln_r * ln_r * ln_r; + + // Convert Kelvin to Celsius + return (1.0f / inv_T) - 273.15f; +} + +// Getter/Setter for NTC Control 0 (0x17) +/// @brief Gets JEITA voltage setting for warm/cool regions +/// @return JEITA VSET enum value +bq25798_jeita_vset_t BqDriver::getJeitaVSet() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_vset_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 3, 5); + + uint8_t reg_value = jeita_vset_bits.read(); + + return (bq25798_jeita_vset_t)reg_value; +} + +/// @brief Sets JEITA voltage setting for warm/cool temperature regions +/// @param setting JEITA VSET enum (suspend, or VREG offset) +/// @return true if successful +bool BqDriver::setJeitaVSet(bq25798_jeita_vset_t setting) { + if (setting > BQ25798_JEITA_VSET_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_vset_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 3, 5); + + jeita_vset_bits.write((uint8_t)setting); + + return true; +} + +/// @brief Gets JEITA current setting for hot region +/// @return JEITA ISETH enum value +bq25798_jeita_iseth_t BqDriver::getJeitaISetH() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_iseth_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 3); + + uint8_t reg_value = jeita_iseth_bits.read(); + + return (bq25798_jeita_iseth_t)reg_value; +} + +/// @brief Sets JEITA current setting for hot temperature region +/// @param setting JEITA ISETH enum (suspend or percentage) +/// @return true if successful +bool BqDriver::setJeitaISetH(bq25798_jeita_iseth_t setting) { + if (setting > BQ25798_JEITA_ISETH_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_iseth_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 3); + + jeita_iseth_bits.write((uint8_t)setting); + + return true; +} + +/// @brief Gets JEITA current setting for cold region +/// @return JEITA ISETC enum value +bq25798_jeita_isetc_t BqDriver::getJeitaISetC() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_isetc_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 1); + + uint8_t reg_value = jeita_isetc_bits.read(); + + return (bq25798_jeita_isetc_t)reg_value; +} + +/// @brief Sets JEITA current setting for cold temperature region +/// @param setting JEITA ISETC enum (suspend or percentage) +/// @return true if successful +bool BqDriver::setJeitaISetC(bq25798_jeita_isetc_t setting) { + if (setting > BQ25798_JEITA_ISETC_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_isetc_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 1); + + jeita_isetc_bits.write((uint8_t)setting); + + return true; +} + +/// @brief Gets TS Cool threshold (lower boundary of COOL region) +/// @return TS COOL enum value +bq25798_ts_cool_t BqDriver::getTsCool() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_cool_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 6); + + uint8_t reg_value = ts_cool_bits.read(); + + return (bq25798_ts_cool_t)reg_value; +} + +/// @brief Sets TS Cool threshold (lower boundary of COOL region) +/// @param threshold TS COOL enum (0°C to 20°C) +/// @return true if successful +bool BqDriver::setTsCool(bq25798_ts_cool_t threshold) { + if (threshold > BQ25798_TS_COOL_20C) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_cool_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 6); + + ts_cool_bits.write((uint8_t)threshold); + + return true; +} + +/// @brief Gets TS Warm threshold (upper boundary of WARM region) +/// @return TS WARM enum value +bq25798_ts_warm_t BqDriver::getTsWarm() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_warm_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 4); + + uint8_t reg_value = ts_warm_bits.read(); + + return (bq25798_ts_warm_t)reg_value; +} + +/// @brief Sets TS Warm threshold (upper boundary of WARM region) +/// @param threshold TS WARM enum (40°C to 55°C) +/// @return true if successful +bool BqDriver::setTsWarm(bq25798_ts_warm_t threshold) { + if (threshold > BQ25798_TS_WARM_55C) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_warm_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 4); + + ts_warm_bits.write((uint8_t)threshold); + + return true; +} + +/// @brief Gets BHOT threshold (upper limit for charging) +/// @return BHOT enum value +bq25798_bhot_t BqDriver::getBHot() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bhot_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 2); + + uint8_t reg_value = bhot_bits.read(); + + return (bq25798_bhot_t)reg_value; +} + +/// @brief Sets BHOT threshold (upper limit for charging) +/// @param threshold BHOT enum (55°C to 65°C, or disable) +/// @return true if successful +bool BqDriver::setBHot(bq25798_bhot_t threshold) { + if (threshold > BQ25798_BHOT_DISABLE) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bhot_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 2); + + bhot_bits.write((uint8_t)threshold); + + return true; +} + +/// @brief Gets BCOLD threshold (lower limit for charging) +/// @return BCOLD enum value +bq25798_bcold_t BqDriver::getBCold() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bcold_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 1); + + uint8_t reg_value = bcold_bits.read(); + + return (bq25798_bcold_t)reg_value; +} + +/// @brief Sets BCOLD threshold (lower limit for charging) +/// @param threshold BCOLD enum (-10°C or -20°C) +/// @return true if successful +bool BqDriver::setBCold(bq25798_bcold_t threshold) { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bcold_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 1); + + bcold_bits.write((uint8_t)threshold); + + return true; +} + +/// @brief Gets TS ignore status (disables all temperature monitoring) +/// @return true if temperature monitoring disabled +bool BqDriver::getTsIgnore() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_ignore_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 0); + + return (bool)ts_ignore_bits.read(); +} + +/// @brief Sets TS ignore status (disables all temperature monitoring) +/// @param ignore true to ignore temperature monitoring +/// @return true if successful +bool BqDriver::setTsIgnore(bool ignore) { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_ignore_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 0); + + ts_ignore_bits.write((uint8_t)ignore); + + return true; +} + +/// @brief Starts ADC one-shot conversion for selected channels +/// +/// MR2 ADC Channel Map: +/// Reg 0x2F (ADC_FUNCTION_DISABLE_0): bit=1 means DISABLED +/// Bit 7: IBUS → ENABLED (solar current) +/// Bit 6: IBAT → disabled (INA228 measures battery current) +/// Bit 5: VBUS → ENABLED (solar voltage) +/// Bit 4: VBAT → disabled (INA228 measures battery voltage) +/// Bit 3: VSYS → disabled (not used) +/// Bit 2: TS → ENABLED or disabled depending on VBAT level +/// Bit 1: TDIE → disabled (not used) +/// Bit 0: reserved +/// +/// Reg 0x30 (ADC_FUNCTION_DISABLE_1): all disabled on MR2 +/// Bit 7: D+ → disabled (AutoDPinsDetection=false, pin not connected) +/// Bit 6: D- → disabled (pin not connected) +/// Bit 5: VAC2 → disabled (not routed on PCB) +/// Bit 4: VAC1 → disabled (not routed on PCB) +/// +/// Why only needed channels: ADC_EN only auto-clears when ALL enabled channels +/// complete. Enabling unconnected channels (D+, D-, VAC) causes ADC_EN to hang +/// indefinitely, requiring a timeout and forced disable. +/// +/// @param ts_enabled true = enable TS channel (requires VBAT >= 3.2V per datasheet) +/// @return true if I2C writes successful +bool BqDriver::startADCOneShot(bool ts_enabled) { + Adafruit_BusIO_Register disable_reg_0 = Adafruit_BusIO_Register(ih_i2c_dev, 0x2F); + Adafruit_BusIO_Register disable_reg_1 = Adafruit_BusIO_Register(ih_i2c_dev, 0x30); + + // Reg 0x2F bit map: IBUS(7) IBAT(6) VBUS(5) VBAT(4) VSYS(3) TS(2) TDIE(1) reserved(0) + // 1 = disabled, 0 = enabled + uint8_t disable0 = 0x5A; // Enable IBUS(7), VBUS(5), TS(2) — disable rest + if (!ts_enabled) { + disable0 |= 0x04; // Also disable TS(2) → 0x5E + } + if (!disable_reg_0.write(disable0)) { return false; } + + // Reg 0x30: Disable all — D+(7), D-(6), VAC2(5), VAC1(4) not connected on MR2 + if (!disable_reg_1.write(0xF0)) { return false; } + + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + bool ok = adc_ctrl_reg.write(0xC0); + return ok; +} + +// ADC Control register (0x2E) implementations +bool BqDriver::getADCEnabled() { + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + Adafruit_BusIO_RegisterBits adc_en_bits = Adafruit_BusIO_RegisterBits(&adc_ctrl_reg, 1, 7); + bool result = (bool)adc_en_bits.read(); + return result; +} + +bool BqDriver::setADCEnabled(bool enabled) { + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + Adafruit_BusIO_RegisterBits adc_en_bits = Adafruit_BusIO_RegisterBits(&adc_ctrl_reg, 1, 7); + bool ok = adc_en_bits.write((uint8_t)enabled); + return ok; +} + +// ADC Reading implementations +int16_t BqDriver::getIBUS() { + Adafruit_BusIO_Register ibus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_IBUS_ADC, 2, MSBFIRST); + uint16_t raw; + if (!ibus_reg.read(&raw)) { // MSB first + return 0; + } + int16_t val = (int16_t)raw; // 2's complement for signed + return val; // in mA +} + +uint16_t BqDriver::getVBUS() { + Adafruit_BusIO_Register vbus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_VBUS_ADC, 2, MSBFIRST); + uint16_t val; + if (!vbus_reg.read(&val)) { + return 0; + } + return val; // in mV +} + +float BqDriver::getTS() { + Adafruit_BusIO_Register ts_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_TS_ADC, 2, MSBFIRST); + uint16_t val; + + // Try up to 3 times with small delays if we get invalid values + for (int retry = 0; retry < 3; retry++) { + if (!ts_reg.read(&val)) { + delay(20); + continue; // I2C read error, retry + } + // Check for invalid/uninitialized ADC value (0 or 0xFFFF) + if (val == 0 || val == 0xFFFF) { + if (retry < 2) { + delay(50); // Wait a bit longer for ADC to settle + continue; + } + return -2.0f; // ADC not ready / invalid value after retries + } + // Valid value + return val * 0.09765625f; // 0.09765625 %/LSB (exact: 1/1024) + } + + return -1.0f; // I2C read error after all retries +} + +bool BqDriver::setVOCpercent(bq25798_voc_pct_t pct) { + uint8_t reg15 = readReg(0x15); + reg15 = (reg15 & 0x1F) | ((uint8_t)pct << 5); // Bits [7:5] = VOC_PCT + return writeReg(0x15, reg15); +} + +bq25798_voc_pct_t BqDriver::getVOCpercent() { + uint8_t reg15 = readReg(0x15); + return (bq25798_voc_pct_t)((reg15 >> 5) & 0x07); +} + + +/// @brief Gets EN_AUTO_IBATDIS state (auto battery discharge during VBAT_OVP) +/// @return true if auto discharge is enabled (POR default = enabled) +bool BqDriver::getAutoIBATDIS() { + Adafruit_BusIO_Register ctrl0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + Adafruit_BusIO_RegisterBits auto_ibatdis_bit = Adafruit_BusIO_RegisterBits(&ctrl0_reg, 1, 7); + return (bool)auto_ibatdis_bit.read(); +} + +/// @brief Sets EN_AUTO_IBATDIS (auto battery discharge during VBAT_OVP) +/// @param enable true = BQ sinks 30mA from BAT during OVP, false = no active discharge +/// @return true if successful +bool BqDriver::setAutoIBATDIS(bool enable) { + Adafruit_BusIO_Register ctrl0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + Adafruit_BusIO_RegisterBits auto_ibatdis_bit = Adafruit_BusIO_RegisterBits(&ctrl0_reg, 1, 7); + return auto_ibatdis_bit.write(enable ? 1 : 0); +} + +// Non-static register access methods (use instance I2C config) +bool BqDriver::writeReg(uint8_t reg, uint8_t val) { + if (!ih_i2c_dev) return false; + + uint8_t buffer[2] = {reg, val}; + bool ok = ih_i2c_dev->write(buffer, 2); + return ok; +} + +uint8_t BqDriver::readReg(uint8_t reg) { + if (!ih_i2c_dev) return 0; + + uint8_t buffer[1] = {reg}; + if (!ih_i2c_dev->write_then_read(buffer, 1, buffer, 1)) { + return 0; + } + return buffer[0]; +} diff --git a/variants/inhero_mr2/lib/BqDriver.h b/variants/inhero_mr2/lib/BqDriver.h new file mode 100644 index 0000000000..b9d88659e1 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver for Inhero MR-2 + * Extends Adafruit_BQ25798 library (BSD License). + */ + +#pragma once + +#include +#include +#include + +#define R_PULLUP 5600.0f ///< Upper resistor RT1 in Ohms +#define R_PARALLEL 27000.0f ///< Lower parallel resistor RT2 in Ohms + +// Steinhart-Hart coefficients for NCP15XH103F03RC NTC (10kΩ, B=3380) +// Fitted from Murata datasheet R-T table at -20°C, 25°C, 85°C +// Max error vs. datasheet: ±0.36°C over -40..+125°C range +#define SH_A 8.7248136876e-04f ///< Steinhart-Hart coefficient A +#define SH_B 2.5405556775e-04f ///< Steinhart-Hart coefficient B +#define SH_C 1.8122847672e-07f ///< Steinhart-Hart coefficient C + +/// Solar input telemetry data +/// @note Solar current from BQ25798 IBUS ADC has significant error at low currents (~±30mA). +/// Values are approximate - treat as estimates, not precise measurements. +typedef struct { + uint16_t voltage; ///< Solar voltage in mV + int16_t current; ///< Solar current in mA (approximate, BQ25798 IBUS ADC has ~±30mA error at low currents) + int32_t power; ///< Solar power in mW + bool mppt; ///< MPPT enabled status +} SolarData; + +/// Battery telemetry data +typedef struct { + uint16_t voltage; ///< Battery voltage in mV + float current; ///< Battery current in mA (positive = charging, negative = discharging) + int32_t power; ///< Battery power in mW + float temperature; ///< Battery temperature in °C +} BattData; + +/// System voltage telemetry +typedef struct { + uint16_t voltage; ///< System voltage in mV +} SysData; + +/// Main telemetry container aggregating all data sources +typedef struct { + SysData system; ///< System voltage data + SolarData solar; ///< Solar input data + BattData batterie; ///< Battery data +} Telemetry; + +/// JEITA voltage setting for warm/cool regions (NTC Control 0 Register 0x17) +typedef enum { + BQ25798_JEITA_VSET_SUSPEND = 0x00, ///< Charge Suspend + BQ25798_JEITA_VSET_MINUS_800MV = 0x01, ///< Set VREG to VREG-800mV + BQ25798_JEITA_VSET_MINUS_600MV = 0x02, ///< Set VREG to VREG-600mV + BQ25798_JEITA_VSET_MINUS_400MV = 0x03, ///< Set VREG to VREG-400mV (default) + BQ25798_JEITA_VSET_MINUS_300MV = 0x04, ///< Set VREG to VREG-300mV + BQ25798_JEITA_VSET_MINUS_200MV = 0x05, ///< Set VREG to VREG-200mV + BQ25798_JEITA_VSET_MINUS_100MV = 0x06, ///< Set VREG to VREG-100mV + BQ25798_JEITA_VSET_UNCHANGED = 0x07 ///< VREG unchanged +} bq25798_jeita_vset_t; + +typedef enum { + BQ25798_JEITA_ISETH_SUSPEND = 0x00, ///< Charge Suspend + BQ25798_JEITA_ISETH_20_PERCENT = 0x01, ///< Set ICHG to 20% * ICHG + BQ25798_JEITA_ISETH_40_PERCENT = 0x02, ///< Set ICHG to 40% * ICHG + BQ25798_JEITA_ISETH_UNCHANGED = 0x03 ///< ICHG unchanged (default) +} bq25798_jeita_iseth_t; + +typedef enum { + BQ25798_JEITA_ISETC_SUSPEND = 0x00, ///< Charge Suspend + BQ25798_JEITA_ISETC_20_PERCENT = 0x01, ///< Set ICHG to 20% * ICHG (default) + BQ25798_JEITA_ISETC_40_PERCENT = 0x02, ///< Set ICHG to 40% * ICHG + BQ25798_JEITA_ISETC_UNCHANGED = 0x03 ///< ICHG unchanged +} bq25798_jeita_isetc_t; + +typedef enum { + BQ25798_CHARGER_STATE_NOT_CHARGING = 0x00, + BQ25798_CHARGER_STATE_TRICKLE_CHARGING = 0x01, + BQ25798_CHARGER_STATE_PRE_CHARGING = 0x02, + BQ25798_CHARGER_STATE_CC_CHARGING = 0x03, + BQ25798_CHARGER_STATE_CV_CHARGING = 0x04, + BQ25798_CHARGER_STATE_TOP_OF_TIMER_ACTIVE_CHARGING = 0x06, + BQ25798_CHARGER_STATE_DONE_CHARGING = 0x07 + +} bq25798_charging_status; + +/// New enums for NTC Control 1 (Register 0x18) +typedef enum { + BQ25798_TS_COOL_5C = 0x00, ///< 71.1% of REGN (5°C) + BQ25798_TS_COOL_10C = 0x01, ///< 68.4% of REGN (10°C, default) + BQ25798_TS_COOL_15C = 0x02, ///< 65.5% of REGN (15°C) + BQ25798_TS_COOL_20C = 0x03 ///< 62.4% of REGN (20°C) +} bq25798_ts_cool_t; + +typedef enum { + BQ25798_TS_WARM_40C = 0x00, ///< 48.4% of REGN (40°C) + BQ25798_TS_WARM_45C = 0x01, ///< 44.8% of REGN (45°C, default) + BQ25798_TS_WARM_50C = 0x02, ///< 41.2% of REGN (50°C) + BQ25798_TS_WARM_55C = 0x03 ///< 37.7% of REGN (55°C) +} bq25798_ts_warm_t; + +/// BHOT threshold - upper temperature limit for charging +typedef enum { + BQ25798_BHOT_55C = 0x00, ///< 55°C + BQ25798_BHOT_60C = 0x01, ///< 60°C (default) + BQ25798_BHOT_65C = 0x02, ///< 65°C + BQ25798_BHOT_DISABLE = 0x03 ///< Disable BHOT protection +} bq25798_bhot_t; + +/// BCOLD threshold - lower temperature limit for charging +typedef enum { + BQ25798_BCOLD_MINUS_10C = 0x00, ///< -10°C (default) + BQ25798_BCOLD_MINUS_20C = 0x01 ///< -20°C +} bq25798_bcold_t; + +/// ADC resolution setting +typedef enum { + BQ25798_ADC_SAMPLE_15BIT = 0b00, ///< 15-bit resolution (default, ~24ms conversion) + BQ25798_ADC_SAMPLE_14BIT = 0b01, ///< 14-bit resolution + BQ25798_ADC_SAMPLE_13BIT = 0b10, ///< 13-bit resolution + BQ25798_ADC_SAMPLE_12BIT = 0b11 ///< 12-bit resolution (not recommended) +} bq25798_adc_sample_t; + +/** + * @brief Extended BQ25798 driver with NTC support and comprehensive telemetry + * + * Extends Adafruit_BQ25798 with: + * - JEITA temperature control (VSET, ISETH, ISETC) + * - NTC thermistor temperature calculation + * - Complete ADC telemetry (solar, battery, system) + * - One-shot ADC conversion management + */ +class BqDriver : public Adafruit_BQ25798 { +public: + BqDriver(); + ~BqDriver(); + + bool begin(uint8_t i2c_addr = BQ25798_DEFAULT_ADDR, TwoWire* wire = &Wire); + + // Direct pass-through to parent — all I2C runs in tick() context (no concurrent access) + bool setHIZMode(bool enable) { return Adafruit_BQ25798::setHIZMode(enable); } + bool setChargeEnable(bool enable) { return Adafruit_BQ25798::setChargeEnable(enable); } + bool getChargeEnable() { return Adafruit_BQ25798::getChargeEnable(); } + bool getMPPTenable() { return Adafruit_BQ25798::getMPPTenable(); } + bool setMPPTenable(bool enable) { return Adafruit_BQ25798::setMPPTenable(enable); } + bool setStatPinEnable(bool enable) { return Adafruit_BQ25798::setStatPinEnable(enable); } + bool getStatPinEnable() { return Adafruit_BQ25798::getStatPinEnable(); } + + bq25798_jeita_vset_t getJeitaVSet(); + bool setJeitaVSet(bq25798_jeita_vset_t setting); + + bq25798_jeita_iseth_t getJeitaISetH(); + bool setJeitaISetH(bq25798_jeita_iseth_t setting); + + bq25798_jeita_isetc_t getJeitaISetC(); + bool setJeitaISetC(bq25798_jeita_isetc_t setting); + + bq25798_ts_cool_t getTsCool(); + bool setTsCool(bq25798_ts_cool_t threshold); + + bq25798_ts_warm_t getTsWarm(); + bool setTsWarm(bq25798_ts_warm_t threshold); + + bq25798_bhot_t getBHot(); + bool setBHot(bq25798_bhot_t threshold); + + bq25798_bcold_t getBCold(); + bool setBCold(bq25798_bcold_t threshold); + + bool getTsIgnore(); + bool setTsIgnore(bool ignore); + + /// @brief Read solar + temperature telemetry via BQ25798 ADC + /// @param vbat_mv Battery voltage from INA228 in mV. Used to decide if TS channel + /// can be enabled (requires VBAT >= 3.2V without VBUS, per datasheet 9.3.16). + /// Pass 0 if unknown (assumes sufficient voltage). + const Telemetry* const getTelemetryData(uint16_t vbat_mv = 0); + + // Charger Status + bool getChargerStatusPowerGood(); + bq25798_charging_status getChargingStatus(); + + bool setVOCpercent(bq25798_voc_pct_t pct); + bq25798_voc_pct_t getVOCpercent(); + + bool getAutoIBATDIS(); + bool setAutoIBATDIS(bool enable); + + // Non-static register access methods (use instance I2C config) + bool writeReg(uint8_t reg, uint8_t val); + uint8_t readReg(uint8_t reg); + + // ADC status/result accessors + bool getADCEnabled(); + uint16_t getVBUS(); + +protected: + Adafruit_I2CDevice* ih_i2c_dev = nullptr; ///< Dedicated I2C device for NTC access + +private: + bool startADCOneShot(bool ts_enabled = true); + bool setADCEnabled(bool enabled); + int16_t getIBUS(); + float getTS(); // TS voltage in % of REGN + + float calculateBatteryTemp(float ts_pct); + Telemetry telemetryData = { 0 }; +}; \ No newline at end of file diff --git a/variants/inhero_mr2/lib/Ina228Driver.cpp b/variants/inhero_mr2/lib/Ina228Driver.cpp new file mode 100644 index 0000000000..ea229f413d --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -0,0 +1,547 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver Implementation + */ + +#include "Ina228Driver.h" +#include "../../../src/MeshCore.h" // For MESH_DEBUG_PRINTLN + +Ina228Driver::Ina228Driver(uint8_t i2c_addr) + : _i2c_addr(i2c_addr), _shunt_mohm(10.0f), _current_lsb(0.0f), _base_shunt_cal(0), _calibration_factor(1.0f) {} + +bool Ina228Driver::begin(float shunt_resistor_mohm) { + _shunt_mohm = shunt_resistor_mohm; + + // Check if device is present + if (!isConnected()) { + MESH_DEBUG_PRINTLN("INA228 begin() FAILED: isConnected() = false"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): Device connected"); + // Do NOT reset device - Early Boot voltage check may have configured it + // Resetting causes timing issues where subsequent writes fail + // Just reconfigure registers directly + + // Configure ADC: Continuous mode, all channels, long conversion times, 256 samples averaging + // - Long conversion times (VSHCT=4120µs, VBUSCT=2074µs) reduce noise for accurate SOC tracking + // - AVG_256 filters TX voltage peaks (prevents false UVLO triggers during transmit) + // - Trade-off: ~1s per measurement (excellent accuracy, acceptable for 1h SOC updates) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + // Expected: 0xFFCB (was 0xF003 without conversion time config) + + // Write ADC_CONFIG with retry and verify + // Sometimes the first write after readVBATDirect() fails + bool adc_config_ok = false; + for (int retry = 0; retry < 5; retry++) { + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); + delay(10); + uint16_t readback = readRegister16(INA228_REG_ADC_CONFIG); + if (readback == adc_config) { + adc_config_ok = true; + break; + } + delay(20); // Wait longer before retry + } + + if (!adc_config_ok) { + MESH_DEBUG_PRINTLN("INA228 begin() ERROR: Failed to set ADC_CONFIG after 5 retries!"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): ADC_CONFIG set to 0x%04X", adc_config); + + // Calculate current LSB: Max expected current / 2^19 (20-bit ADC) + // With 100mΩ shunt and ±163.84mV ADC range (ADCRANGE=0): Max = 163.84mV / 0.1Ω = 1.6384A + // Using 1.6384A, LSB = 1.6384A / 524288 ≈ 3.125 µA + // At 10mA standby: V_shunt = 1mV → SNR greatly improved vs. 20mΩ (200µV) + _current_lsb = 1.6384f / 524288.0f; // in Amperes (max ±1.6384A) + + // Calculate shunt calibration value + // SHUNT_CAL = 13107.2 × 10^6 × CURRENT_LSB × R_SHUNT + // R_SHUNT in Ohms, CURRENT_LSB in A + float shunt_ohm = _shunt_mohm / 1000.0f; + _base_shunt_cal = (uint16_t)(13107.2e6 * _current_lsb * shunt_ohm); + + // ADCRANGE = 0 (±163.84mV): No multiplier needed (×4 only required for ADCRANGE=1) + + // Apply calibration factor to SHUNT_CAL (if set) + uint16_t calibrated_shunt_cal = (uint16_t)(_base_shunt_cal * _calibration_factor); + writeRegister16(INA228_REG_SHUNT_CAL, calibrated_shunt_cal); + delay(5); + + // Configure INA228: ADCRANGE = 0 (±163.84mV, default) for 100mΩ shunt + // At 1A: V_shunt = 100mV (61% of full-scale) — sufficient headroom + // At 10mA: V_shunt = 1mV — 5× better SNR than 20mΩ (was 200µV) + uint16_t config = 0; // ADCRANGE=0 (±163.84mV range, bit 4 = 0) + writeRegister16(INA228_REG_CONFIG, config); + delay(5); + + return true; +} + +bool Ina228Driver::isConnected() { + // First check if device responds at all + Wire.beginTransmission(_i2c_addr); + uint8_t i2c_result = Wire.endTransmission(); + + if (i2c_result != 0) { + return false; // No ACK on bus + } + + // Read Manufacturer ID (should be 0x5449 = "TI") + uint16_t mfg_id = readRegister16(INA228_REG_MANUFACTURER); + + if (mfg_id == 0x0000 || mfg_id == 0xFFFF) { + return false; // Invalid MFG_ID (bus error) + } + + // Some INA228 clones may have different MFG_ID, skip strict check + // Just verify it's not a bus error value + + // Read Device ID (should be 0x228 in lower 12 bits) + uint16_t dev_id = readRegister16(INA228_REG_DEVICE_ID); + + if (dev_id == 0x0000 || dev_id == 0xFFFF) { + return false; // Invalid DEV_ID (bus error) + } + + // RELAXED: Accept any valid DEV_ID since some INA228 clones report 0x2281 + // We already verified MFG_ID = 0x5449 (TI), that's sufficient + // Original check: (dev_id & 0x0FFF) != 0x228 + // Observed: 0x2281 (clone/variant), but MFG_ID is correct + + return true; // Accept device if MFG_ID was valid +} + +void Ina228Driver::reset() { + writeRegister16(INA228_REG_CONFIG, INA228_CONFIG_RST); +} + +uint16_t Ina228Driver::readVoltage_mV() { + int32_t vbus_raw = readRegister24(INA228_REG_VBUS); + // INA228 VBUS: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + vbus_raw >>= 4; + // VBUS LSB = 195.3125 µV + float vbus_v = vbus_raw * 195.3125e-6; + return (uint16_t)(vbus_v * 1000.0f); // Convert to mV +} + +int16_t Ina228Driver::readCurrent_mA() { + int32_t current_raw = readRegister24(INA228_REG_CURRENT); + // INA228 CURRENT: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + current_raw >>= 4; + // Current = raw × CURRENT_LSB + // Calibration is applied via SHUNT_CAL register (hardware calibration) + // Sign convention: INVERT because shunt is oriented for battery perspective + // Positive = charging (current into battery), Negative = discharging (current from battery) + float current_a = current_raw * _current_lsb; + float current_mA = -current_a * 1000.0f; // Convert to mA, inverted sign + return (int16_t)(current_mA); +} + +float Ina228Driver::readCurrent_mA_precise() { + int32_t current_raw = readRegister24(INA228_REG_CURRENT); + // INA228 CURRENT: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + current_raw >>= 4; + // Current = raw × CURRENT_LSB + // Calibration is applied via SHUNT_CAL register (hardware calibration) + // Sign convention: INVERT because shunt is oriented for battery perspective + // Positive = charging (current into battery), Negative = discharging (current from battery) + float current_a = current_raw * _current_lsb; + float current_mA = -current_a * 1000.0f; // Convert to mA with full precision, inverted sign + return current_mA; +} + +bool Ina228Driver::shutdown() { + // Set operating mode to Shutdown (MODE = 0x0) + // This disables all conversions and Coulomb Counter. + // Use retry+readback — I2C writes can fail silently (see setUnderVoltageAlert). + // If this fails, INA228 stays in continuous mode (~350µA wasted in System Sleep!). + uint16_t adc_config = 0x0000; // MODE = 0x0 (Shutdown) + + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_ADC_CONFIG, adc_config)) { + delay(10); + continue; + } + delay(2); + uint16_t readback = readRegister16(INA228_REG_ADC_CONFIG); + if ((readback & 0xF000) == 0x0000) { // Check MODE bits [15:12] + return true; + } + delay(10); + } + return false; +} + +void Ina228Driver::wakeup() { + // Re-enable continuous measurement mode with full ADC configuration + // Must restore conversion times from begin() - defaults are much shorter (50µs) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); +} + +uint16_t Ina228Driver::readVBATDirect(TwoWire* wire, uint8_t i2c_addr) { + // === Important: This is called BEFORE begin() in Early Boot Check === + // The INA228 may be in power-on reset state, so we need to be careful + + // First check if device responds + wire->beginTransmission(i2c_addr); + if (wire->endTransmission() != 0) { + return 0; // Device not present + } + + // === One-Shot ADC Trigger === + // Configure ADC for single-shot bus voltage measurement + // MODE = 0x1 (Single-shot bus voltage only) + uint16_t adc_config = (0x1 << 12); // MODE = 0x1, no averaging for speed + + wire->beginTransmission(i2c_addr); + wire->write(INA228_REG_ADC_CONFIG); + wire->write((adc_config >> 8) & 0xFF); + wire->write(adc_config & 0xFF); + if (wire->endTransmission() != 0) { + return 0; // I2C communication failed + } + + // Wait for conversion to complete (~200µs typical, use 2ms to be safe) + delay(2); + + // Read VBUS register (24-bit) + wire->beginTransmission(i2c_addr); + wire->write(INA228_REG_VBUS); + if (wire->endTransmission(false) != 0) { + return 0; + } + + wire->requestFrom(i2c_addr, (uint8_t)3); + if (wire->available() < 3) { + return 0; + } + + int32_t vbus_raw = wire->read() << 16; // MSB + vbus_raw |= wire->read() << 8; // Mid + vbus_raw |= wire->read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (vbus_raw & 0x800000) { + vbus_raw |= 0xFF000000; + } + + // INA228 VBUS: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + vbus_raw >>= 4; + + // VBUS LSB = 195.3125 µV + float vbus_v = vbus_raw * 195.3125e-6; + uint16_t vbus_mv = (uint16_t)(vbus_v * 1000.0f); + + // Sanity check: Battery voltage should be between 2V and 15V + // If implausible, still return value for debugging + + return vbus_mv; // Convert to mV +} + +int32_t Ina228Driver::readPower_mW() { + int32_t power_raw = readRegister24(INA228_REG_POWER); + // Power LSB = 3.2 × CURRENT_LSB + // Sign convention: INVERT to match current sign (positive = charging) + float power_w = power_raw * (3.2f * _current_lsb); + return (int32_t)(-power_w * 1000.0f); // Convert to mW, inverted sign +} + +int32_t Ina228Driver::readEnergy_mWh() { + int64_t energy_raw = readRegister40(INA228_REG_ENERGY); + // Energy LSB = 16 × 3.2 × CURRENT_LSB (in J) + // Convert to Wh: / 3600 + // NO inversion - shunt orientation gives correct battery perspective + // Positive = discharging (energy from battery), Negative = charging (energy into battery) + float energy_j = energy_raw * (16.0f * 3.2f * _current_lsb); + float energy_wh = energy_j / 3600.0f; + return (int32_t)(energy_wh * 1000.0f); // Convert to mWh, NO inversion +} + +float Ina228Driver::readCharge_mAh() { + int64_t charge_raw = readRegister40(INA228_REG_CHARGE); + // Charge LSB = CURRENT_LSB (in C = A·s) + // Convert to Ah: / 3600 + // INVERT: Hardware negative = charging, we want positive = charging (battery perspective) + float charge_c = charge_raw * _current_lsb; + float charge_ah = charge_c / 3600.0f; + return -charge_ah * 1000.0f; // Convert to mAh, INVERTED for battery perspective +} + +float Ina228Driver::readDieTemperature_C() { + // DIETEMP is a 16-bit register (not 24-bit like others!) + int16_t temp_raw = (int16_t)readRegister16(INA228_REG_DIETEMP); + // Temperature LSB = 7.8125 m°C + float temp_c = temp_raw * 7.8125e-3; + return temp_c; +} + +bool Ina228Driver::readAll(Ina228BatteryData* data) { + if (!isConnected()) { + return false; + } + + data->voltage_mv = readVoltage_mV(); + data->current_ma = readCurrent_mA(); + data->power_mw = readPower_mW(); + data->energy_mwh = readEnergy_mWh(); + data->charge_mah = readCharge_mAh(); + data->die_temp_c = readDieTemperature_C(); + + return true; +} + +void Ina228Driver::resetCoulombCounter() { + // Write RSTACC (bit 14) directly — no read-modify-write! + // CONFIG is always 0x0000 (set in begin()), so we can safely write 0x4000. + // RMW is dangerous: if readRegister16() returns garbage on I2C glitch, + // we could accidentally set RST (bit 15) and wipe SHUNT_CAL. + writeRegister16(INA228_REG_CONFIG, (1 << 14)); // RSTACC only +} + +uint16_t Ina228Driver::readShuntCalRegister() { + return readRegister16(INA228_REG_SHUNT_CAL); +} + +uint16_t Ina228Driver::readAdcConfigRegister() { + return readRegister16(INA228_REG_ADC_CONFIG); +} + +uint16_t Ina228Driver::readConfigRegister() { + return readRegister16(INA228_REG_CONFIG); +} + +bool Ina228Driver::validateAndRepairShuntCal() { + uint16_t expected = (uint16_t)(_base_shunt_cal * _calibration_factor); + if (expected == 0) return false; // Not initialized + + uint16_t actual = readShuntCalRegister(); + if (actual == expected) return true; // OK + + // SHUNT_CAL is wrong — repair it! + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL corrupted! Expected=%u, Got=%u — repairing", expected, actual); + writeRegister16(INA228_REG_SHUNT_CAL, expected); + delay(2); + + uint16_t verify = readShuntCalRegister(); + if (verify != expected) { + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repair FAILED! Wrote=%u, Read=%u", expected, verify); + return false; + } + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repaired successfully"); + return true; +} + +bool Ina228Driver::setUnderVoltageAlert(uint16_t voltage_mv) { + // BUVL register: 3.125 mV/LSB (per datasheet Table 7-20) + // Non-zero BUVL enables bus under-voltage comparison → BUSUL flag + ALERT pin + // BUVL = 0 disables comparison (datasheet default) + uint16_t buvl_value = (uint16_t)(voltage_mv / 3.125f); + + // Write with retry and readback verification (I2C writes can fail silently) + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_BUVL, buvl_value)) { + delay(10); + continue; + } + delay(5); + uint16_t readback = readRegister16(INA228_REG_BUVL); + if (readback == buvl_value) { + return true; + } + MESH_DEBUG_PRINTLN("INA228: BUVL write mismatch (wrote=0x%04X, read=0x%04X), retry %d", buvl_value, readback, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: BUVL write FAILED after 3 retries!"); + return false; +} + +void Ina228Driver::enableAlert(bool enable_uvlo, bool active_high, bool latch_alert) { + // DIAG_ALRT register: Only bits [15:12] are R/W (config), bits [11:0] are read-only flags. + // Writing to this register clears all flag bits [11:0]. + // Note: BUSUL/BUSOL flags (bits 3-4) are READ-ONLY status flags, NOT enable bits. + // Bus under-voltage comparison is enabled by setting BUVL register to non-zero. + uint16_t diag_alrt = 0; + + if (latch_alert) { + diag_alrt |= INA228_DIAG_ALRT_ALATCH; // Latch mode: Alert stays active until DIAG_ALRT is read + } + + if (active_high) { + diag_alrt |= INA228_DIAG_ALRT_APOL; // Active-high polarity (default: active-low) + } + + // Write with retry and readback verification + // Only bits [15:12] are readable as config; bits [11:0] are flags (may change between write and read) + uint16_t expected_config = diag_alrt & 0xF000; // Only check config bits + for (int retry = 0; retry < 3; retry++) { + writeRegister16(INA228_REG_DIAG_ALRT, diag_alrt); + delay(5); + uint16_t readback = readRegister16(INA228_REG_DIAG_ALRT); + uint16_t readback_config = readback & 0xF000; + if (readback_config == expected_config) { + return; + } + MESH_DEBUG_PRINTLN("INA228: DIAG_ALRT config mismatch (wrote=0x%04X, read=0x%04X, config=0x%04X vs 0x%04X), retry %d", + diag_alrt, readback, expected_config, readback_config, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: DIAG_ALRT write FAILED after 3 retries!"); +} + +bool Ina228Driver::isAlertActive() { + uint16_t diag_flags = getDiagnosticFlags(); + return (diag_flags & (INA228_DIAG_ALRT_BUSUL | INA228_DIAG_ALRT_BUSOL)) != 0; +} + +void Ina228Driver::clearAlert() { + // Read diagnostic register to clear latched alerts + getDiagnosticFlags(); +} + +uint16_t Ina228Driver::getDiagnosticFlags() { + return readRegister16(INA228_REG_DIAG_ALRT); +} + +uint16_t Ina228Driver::readBuvlRegister() { + return readRegister16(INA228_REG_BUVL); +} + +// ===== Private Methods ===== + +bool Ina228Driver::writeRegister16(uint8_t reg, uint16_t value) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.write((value >> 8) & 0xFF); // MSB + Wire.write(value & 0xFF); // LSB + bool ok = (Wire.endTransmission() == 0); + return ok; +} + +uint16_t Ina228Driver::readRegister16(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); // Repeated start + + Wire.requestFrom(_i2c_addr, (uint8_t)2); + if (Wire.available() < 2) { + return 0; + } + + uint16_t value = Wire.read() << 8; // MSB + value |= Wire.read(); // LSB + return value; +} + +int32_t Ina228Driver::readRegister24(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)3); + if (Wire.available() < 3) { + return 0; + } + + int32_t value = Wire.read() << 16; // MSB + value |= Wire.read() << 8; // Mid + value |= Wire.read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (value & 0x800000) { + value |= 0xFF000000; + } + + return value; +} + +int64_t Ina228Driver::readRegister40(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)5); + if (Wire.available() < 5) { + return 0; + } + + int64_t value = (int64_t)Wire.read() << 32; // MSB + value |= (int64_t)Wire.read() << 24; + value |= (int64_t)Wire.read() << 16; + value |= (int64_t)Wire.read() << 8; + value |= (int64_t)Wire.read(); // LSB + + // Sign-extend 40-bit to 64-bit + if (value & 0x8000000000LL) { + value |= 0xFFFFFF0000000000LL; + } + + return value; +} + +// ===== Calibration Methods ===== + +float Ina228Driver::calibrateCurrent(float actual_current_ma) { + // Step 1: Reset calibration to 1.0 for accurate measurement + setCalibrationFactor(1.0f); + + // Wait for ADC to settle + delay(10); + + // Step 2: Read current measured value (uncalibrated) + int16_t measured_current_ma = readCurrent_mA(); + + // Avoid division by zero + if (measured_current_ma == 0) { + return 1.0f; // No correction possible + } + + // Step 3: Calculate correction factor (INVERSE ratio) + // If INA shows -9.4mA but actual is -10.4mA, we need SHUNT_CAL to be SMALLER + // so INA writes a LARGER value. Factor = measured/actual = 9.4/10.4 = 0.904 + float new_factor = (float)measured_current_ma / actual_current_ma; + + // Step 4: Apply new calibration factor to INA228 hardware + setCalibrationFactor(new_factor); + + return new_factor; +} + +void Ina228Driver::setCalibrationFactor(float factor) { + // Clamp to reasonable range (0.5x to 2.0x) + if (factor < 0.5f) factor = 0.5f; + if (factor > 2.0f) factor = 2.0f; + + _calibration_factor = factor; + + // Apply calibration factor to SHUNT_CAL register + // Lower SHUNT_CAL → INA writes larger values → higher current reading + // Higher SHUNT_CAL → INA writes smaller values → lower current reading + // CURRENT_LSB stays constant (per datasheet design) + if (_base_shunt_cal > 0) { + uint16_t calibrated_shunt_cal = (uint16_t)(_base_shunt_cal * factor); + writeRegister16(INA228_REG_SHUNT_CAL, calibrated_shunt_cal); + } +} + +float Ina228Driver::getCalibrationFactor() const { + return _calibration_factor; +} + + diff --git a/variants/inhero_mr2/lib/Ina228Driver.h b/variants/inhero_mr2/lib/Ina228Driver.h new file mode 100644 index 0000000000..8e810d76bf --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.h @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver for Inhero MR-2 + * + * Features: + * - Voltage, current, power monitoring + * - Coulomb counter (accumulated charge) + * - Alert pin for firmware-triggered low-voltage sleep (INA228 BUVL → ISR → System Sleep) + * - Chemistry-specific thresholds (Li-Ion, LiFePO4, LTO) + */ + +#pragma once + +#include +#include + +// INA228 I2C Address +#define INA228_I2C_ADDR_DEFAULT 0x40 // A0=GND, A1=GND + +// INA228 Register Map +#define INA228_REG_CONFIG 0x00 // Configuration +#define INA228_REG_ADC_CONFIG 0x01 // ADC Configuration +#define INA228_REG_SHUNT_CAL 0x02 // Shunt Calibration +#define INA228_REG_SHUNT_TEMP 0x03 // Shunt Temperature Coefficient +#define INA228_REG_VSHUNT 0x04 // Shunt Voltage +#define INA228_REG_VBUS 0x05 // Bus Voltage +#define INA228_REG_DIETEMP 0x06 // Die Temperature +#define INA228_REG_CURRENT 0x07 // Current +#define INA228_REG_POWER 0x08 // Power +#define INA228_REG_ENERGY 0x09 // Energy (Coulomb Counter) +#define INA228_REG_CHARGE 0x0A // Charge (Coulomb Counter) +#define INA228_REG_DIAG_ALRT 0x0B // Diagnostic and Alert +#define INA228_REG_SOVL 0x0C // Shunt Over-Voltage Limit +#define INA228_REG_SUVL 0x0D // Shunt Under-Voltage Limit +#define INA228_REG_BOVL 0x0E // Bus Over-Voltage Limit +#define INA228_REG_BUVL 0x0F // Bus Under-Voltage Limit +#define INA228_REG_TEMP_LIMIT 0x10 // Temperature Limit +#define INA228_REG_PWR_LIMIT 0x11 // Power Limit +#define INA228_REG_MANUFACTURER 0x3E // Manufacturer ID (should be 0x5449 = "TI") +#define INA228_REG_DEVICE_ID 0x3F // Device ID (should be 0x228) + +// INA228 Configuration bits +#define INA228_CONFIG_RST (1 << 15) // Reset bit +#define INA228_CONFIG_ADCRANGE (1 << 4) // ADC Range (0=±163.84mV, 1=±40.96mV) + +// ADC Configuration - Mode +#define INA228_ADC_MODE_CONT_ALL 0x0F // Continuous conversion, all channels + +// ADC Configuration - Averaging +#define INA228_ADC_AVG_1 0x00 // No averaging +#define INA228_ADC_AVG_4 0x01 // 4 samples average +#define INA228_ADC_AVG_16 0x02 // 16 samples average +#define INA228_ADC_AVG_64 0x03 // 64 samples average +#define INA228_ADC_AVG_128 0x04 // 128 samples average +#define INA228_ADC_AVG_256 0x05 // 256 samples average +#define INA228_ADC_AVG_512 0x06 // 512 samples average +#define INA228_ADC_AVG_1024 0x07 // 1024 samples average + +// ADC Configuration - Conversion Time (VBUSCT, VSHCT, VTCT) +#define INA228_ADC_CT_50us 0x00 // 50 µs +#define INA228_ADC_CT_84us 0x01 // 84 µs +#define INA228_ADC_CT_150us 0x02 // 150 µs +#define INA228_ADC_CT_280us 0x03 // 280 µs +#define INA228_ADC_CT_540us 0x04 // 540 µs +#define INA228_ADC_CT_1052us 0x05 // 1052 µs (default) +#define INA228_ADC_CT_2074us 0x06 // 2074 µs +#define INA228_ADC_CT_4120us 0x07 // 4120 µs (maximum accuracy) + +// Alert Configuration +#define INA228_DIAG_ALRT_ALATCH (1 << 15) // Alert Latch Enable +#define INA228_DIAG_ALRT_CNVR (1 << 14) // Conversion Ready +#define INA228_DIAG_ALRT_SLOWALERT (1 << 13) // Slow Alert (for averaging) +#define INA228_DIAG_ALRT_APOL (1 << 12) // Alert Polarity (1=active high) +#define INA228_DIAG_ALRT_ENERGYOF (1 << 11) // Energy Overflow +#define INA228_DIAG_ALRT_CHARGEOF (1 << 10) // Charge Overflow +#define INA228_DIAG_ALRT_MATHOF (1 << 9) // Math Overflow +#define INA228_DIAG_ALRT_TMPOL (1 << 7) // Temperature Over-Limit +#define INA228_DIAG_ALRT_SHNTOL (1 << 6) // Shunt Over-Voltage +#define INA228_DIAG_ALRT_SHNTUL (1 << 5) // Shunt Under-Voltage +#define INA228_DIAG_ALRT_BUSOL (1 << 4) // Bus Over-Voltage +#define INA228_DIAG_ALRT_BUSUL (1 << 3) // Bus Under-Voltage (UVLO) +#define INA228_DIAG_ALRT_POL (1 << 2) // Power Over-Limit +#define INA228_DIAG_ALRT_CNVRF (1 << 1) // Conversion Ready Flag +#define INA228_DIAG_ALRT_MEMSTAT (1 << 0) // Memory Status + +/// Battery telemetry from INA228 +typedef struct { + uint16_t voltage_mv; ///< Battery voltage in mV + int16_t current_ma; ///< Battery current in mA (+ = charging, - = discharging) + int32_t power_mw; ///< Battery power in mW + int32_t energy_mwh; ///< Accumulated energy in mWh (since last reset) + float charge_mah; ///< Accumulated charge in mAh (since last reset) + float die_temp_c; ///< Die temperature in °C +} Ina228BatteryData; + +class Ina228Driver { +public: + /// @brief Constructor + /// @param i2c_addr I2C address of INA228 (default INA228_I2C_ADDR_DEFAULT for A0=A1=GND) + Ina228Driver(uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + + /// @brief Initialize INA228 with default configuration + /// @param shunt_resistor_mohm Shunt resistor value in milliohms (e.g., 10 for 0.01Ω) + /// @return true if initialization successful + bool begin(float shunt_resistor_mohm = 10.0f); + + /// @brief Check if INA228 is present and responsive + /// @return true if device found + bool isConnected(); + + /// @brief Reset INA228 to default values + void reset(); + + /// @brief Read battery voltage + /// @return Voltage in millivolts + uint16_t readVoltage_mV(); + + /// @brief Read battery current + /// @return Current in milliamps (+ = charging, - = discharging) + int16_t readCurrent_mA(); + + /// @brief Read battery current with full precision + /// @return Current in milliamps with decimal precision (+ = charging, - = discharging) + /// @note Returns float for high-precision measurements (±1 LSB ≈ 1.91 µA) + float readCurrent_mA_precise(); + + /// @brief Read battery power + /// @return Power in milliwatts + int32_t readPower_mW(); + + /// @brief Read accumulated energy (Coulomb Counter) + /// @return Energy in milliwatt-hours + int32_t readEnergy_mWh(); + + /// @brief Read accumulated charge (Coulomb Counter) + /// @return Charge in milliamp-hours + float readCharge_mAh(); + + /// @brief Read die temperature + /// @return Temperature in °C + float readDieTemperature_C(); + + /// @brief Get all battery data in one call + /// @param data Pointer to Ina228BatteryData struct + /// @return true if read successful + bool readAll(Ina228BatteryData* data); + + /// @brief Reset Coulomb Counter (energy and charge accumulators) + void resetCoulombCounter(); + + /// @brief Read back SHUNT_CAL register value (diagnostic) + uint16_t readShuntCalRegister(); + + /// @brief Read back ADC_CONFIG register value (diagnostic) + uint16_t readAdcConfigRegister(); + + /// @brief Read back CONFIG register value (diagnostic) + uint16_t readConfigRegister(); + + /// @brief Validate SHUNT_CAL and repair if corrupted + /// @return true if SHUNT_CAL is correct (or was repaired), false if repair failed + bool validateAndRepairShuntCal(); + + /// @brief Set bus under-voltage alert threshold (for UVLO) + /// @param voltage_mv Threshold in millivolts (e.g., 3200 for Li-Ion) + /// @return true if successful + bool setUnderVoltageAlert(uint16_t voltage_mv); + + /// @brief Set bus over-voltage alert threshold + /// @param voltage_mv Threshold in millivolts + /// @return true if successful + bool setOverVoltageAlert(uint16_t voltage_mv); + + /// @brief Enable alert output on ALERT pin + /// @param enable_uvlo Enable under-voltage alert + /// @param enable_ovlo Enable over-voltage alert + /// @param active_high True for active-high, false for active-low + void enableAlert(bool enable_uvlo = true, bool active_high = false, bool latch_alert = false); + + /// @brief Check if alert condition is active + /// @return true if alert flag is set + bool isAlertActive(); + + /// @brief Clear alert flags + void clearAlert(); + + /// @brief Get diagnostic and alert register value + /// @return 16-bit diagnostic register value + /// @warning In LATCH mode, reading DIAG_ALRT clears latched alert flags and de-asserts ALERT pin! + uint16_t getDiagnosticFlags(); + + /// @brief Read back current BUVL threshold register value + /// @return Raw BUVL register value (multiply by 3.125 for mV) + uint16_t readBuvlRegister(); + + /// @brief Put INA228 into shutdown mode (power-down) + /// @note Disables all measurements and Coulomb Counter to save power + /// @return true if shutdown confirmed via readback, false if write failed + bool shutdown(); + + /// @brief Wake INA228 from shutdown mode + /// @note Re-enables continuous measurement mode + void wakeup(); + + /// @brief Calibrate current measurement based on actual measured current + /// @param actual_current_ma Actual measured battery current in milliamps + /// @return Calculated calibration factor (multiplier for future readings) + /// @note This calculates the ratio between actual and measured current. + /// The calibration factor should be stored persistently and applied via setCalibrationFactor(). + float calibrateCurrent(float actual_current_ma); + + /// @brief Set persistent current calibration factor + /// @param factor Calibration factor (1.0 = no correction, >1.0 = increase readings, <1.0 = decrease) + /// @note This factor is written directly to the INA228 SHUNT_CAL register (hardware calibration). + /// All measurements (current, power, energy, charge) are automatically corrected. + /// Call this at startup with value loaded from persistent storage. + void setCalibrationFactor(float factor); + + /// @brief Get current calibration factor + /// @return Current calibration factor (1.0 = no calibration) + float getCalibrationFactor() const; + + + + /// @brief Read battery voltage directly via I2C without requiring driver initialization + /// @param wire Pointer to TwoWire instance + /// @param i2c_addr I2C address (default INA228_I2C_ADDR_DEFAULT) + /// @return Battery voltage in millivolts, or 0 if read fails + /// @note Static method for early boot use before INA228 is initialized. + /// Triggers One-Shot ADC conversion for accurate voltage reading. + /// Uses high-precision 24-bit ADC (±0.1% accuracy). + static uint16_t readVBATDirect(TwoWire* wire = &Wire, uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + +private: + uint8_t _i2c_addr; + float _shunt_mohm; + float _current_lsb; // Current LSB in A (constant per datasheet) + uint16_t _base_shunt_cal; // Original SHUNT_CAL value (before calibration) + float _calibration_factor; // Current calibration factor (1.0 = no correction) + + /// @brief Write 16-bit register + bool writeRegister16(uint8_t reg, uint16_t value); + + /// @brief Read 16-bit register + uint16_t readRegister16(uint8_t reg); + + /// @brief Read 24-bit register (sign-extended to 32-bit) + int32_t readRegister24(uint8_t reg); + + /// @brief Read 40-bit register (for energy/charge) + int64_t readRegister40(uint8_t reg); +}; diff --git a/variants/inhero_mr2/lib/SimplePreferences.h b/variants/inhero_mr2/lib/SimplePreferences.h new file mode 100644 index 0000000000..e9d798adb5 --- /dev/null +++ b/variants/inhero_mr2/lib/SimplePreferences.h @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include + +using namespace Adafruit_LittleFS_Namespace; + +/// @brief Mini preferences library compatible with Arduino Preferences API +/// @details Provides simple file-based key-value storage using LittleFS backend +class SimplePreferences { +private: + String _namespace; + bool _started = false; + + /// Helper function: Builds filename "/namespace/key.txt" + String getFilePath(const char* key) { + String path = "/" + _namespace; + // Create namespace folder if it doesn't exist + InternalFS.mkdir(path.c_str()); + path += "/"; + path += key; + path += ".txt"; + return path; + } + +public: + SimplePreferences() {}; + + bool begin(const char* name) { + _namespace = name; + _started = true; + return InternalFS.begin(); + } + + void end() { _started = false; } + + /// Store string value (now without String object, directly via char pointer) + size_t putString(const char* key, const char* value) { + if (!_started || value == nullptr) return 0; + + // Create file path (internally uses String briefly for path construction, which is acceptable) + String path = getFilePath(key); + + // Remove existing file (clean overwrite) + InternalFS.remove(path.c_str()); + + File file = InternalFS.open(path.c_str(), FILE_O_WRITE); + if (!file) return 0; + + // file.print kann const char* direkt verarbeiten -> kein Overhead! + size_t len = file.print(value); + + file.close(); + return len; + } + + size_t putInt(const char* key, const uint16_t val) { + char buffer[10]; + snprintf(buffer, sizeof(buffer), "%u", val); + return putString(key, buffer); + } + + /// Lean method: Writes directly into user's buffer + /// New implementation: Reads directly into buffer + size_t getString(const char* key, char* buffer, size_t maxLen, const char* defaultValue = "") { + if (!_started) { + // Copy fallback + strncpy(buffer, defaultValue, maxLen); + buffer[maxLen - 1] = '\0'; // Safety + return strlen(buffer); + } + + String path = getFilePath(key); + + if (!InternalFS.exists(path.c_str())) { + strncpy(buffer, defaultValue, maxLen); + buffer[maxLen - 1] = '\0'; + return strlen(buffer); + } + + File file = InternalFS.open(path.c_str(), FILE_O_READ); + if (!file) { + strncpy(buffer, defaultValue, maxLen); + return strlen(buffer); + } + + // IMPORTANT: Here we read bytes directly into the buffer. No String object! + size_t bytesRead = file.readBytes(buffer, maxLen - 1); + buffer[bytesRead] = '\0'; // Set null-terminator manually + + // Trim (remove newlines) - manually without String class + // We remove \r and \n at the end if present + while (bytesRead > 0 && + (buffer[bytesRead - 1] == '\r' || buffer[bytesRead - 1] == '\n' || buffer[bytesRead - 1] == ' ')) { + buffer[bytesRead - 1] = '\0'; + bytesRead--; + } + + file.close(); + return bytesRead; + } + + bool containsKey(const char* key) { + if (!_started) return false; + String path = getFilePath(key); + return InternalFS.exists(path.c_str()); + } +}; \ No newline at end of file diff --git a/variants/inhero_mr2/platformio.ini b/variants/inhero_mr2/platformio.ini new file mode 100644 index 0000000000..1219725a0e --- /dev/null +++ b/variants/inhero_mr2/platformio.ini @@ -0,0 +1,70 @@ +[inhero_mr2] +extends = nrf52_base +board = inhero_mr2 +board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld +build_flags = ${nrf52_base.build_flags} + -D ENV_INCLUDE_BME280=1 + -I variants/inhero_mr2 + -D INHERO_MR2 + -D PIN_BOARD_SCL=14 + -D PIN_BOARD_SDA=13 + -D PIN_GPS_TX=PIN_SERIAL1_RX + -D PIN_GPS_RX=PIN_SERIAL1_TX + -D PIN_GPS_EN=-1 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/inhero_mr2> + + +lib_deps = + ${nrf52_base.lib_deps} + adafruit/Adafruit BME280 Library @ ^2.3.0 + https://github.com/adafruit/Adafruit_bq25798.git + sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 +upload_protocol = nrfutil +debug_tool = cmsis-dap + + +[env:Inhero_MR2_repeater] +extends = inhero_mr2 +build_flags = + ${inhero_mr2.build_flags} + -D ADVERT_NAME='"Inhero_MR2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${inhero_mr2.build_src_filter} + +<../examples/simple_repeater> + +[env:Inhero_MR2_repeater_bridge_rs232] +extends = inhero_mr2 +build_flags = + ${inhero_mr2.build_flags} + -D ADVERT_NAME='"Inhero_MR2 RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL2_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL2_TX +build_src_filter = ${inhero_mr2.build_src_filter} + + + +<../examples/simple_repeater> + +[env:Inhero_MR2_sensor] +extends = inhero_mr2 +build_flags = + ${inhero_mr2.build_flags} + -D ADVERT_NAME='"Inhero_MR2 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' +build_src_filter = ${inhero_mr2.build_src_filter} + +<../examples/simple_sensor> diff --git a/variants/inhero_mr2/target.cpp b/variants/inhero_mr2/target.cpp new file mode 100644 index 0000000000..99b45e0871 --- /dev/null +++ b/variants/inhero_mr2/target.cpp @@ -0,0 +1,58 @@ +// Includes +#include +#include "target.h" +#include + +// Global objects +InheroMr2Board board; +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); +VolatileRTCClock fallback_clock; +GuardedRTCClock rtc_clock(fallback_clock); + +#ifndef PIN_USER_BTN + #define PIN_USER_BTN (-1) +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); + + #if defined(PIN_USER_BTN_ANA) + MomentaryButton analog_btn(PIN_USER_BTN_ANA, 1000, 20); + #endif +#endif + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +// Public functions +bool radio_init() { + rtc_clock.begin(Wire); + return radio.std_init(&SPI); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(uint8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/inhero_mr2/target.h b/variants/inhero_mr2/target.h new file mode 100644 index 0000000000..42132edcad --- /dev/null +++ b/variants/inhero_mr2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include "GuardedRTCClock.h" +#include + +#ifdef DISPLAY_CLASS + #include + extern DISPLAY_CLASS display; + #include + extern MomentaryButton user_btn; + #if defined(PIN_USER_BTN_ANA) + extern MomentaryButton analog_btn; + #endif +#endif + +extern InheroMr2Board board; +extern WRAPPER_CLASS radio_driver; +extern GuardedRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +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(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/inhero_mr2/variant.cpp b/variants/inhero_mr2/variant.cpp new file mode 100644 index 0000000000..fd6cfe8711 --- /dev/null +++ b/variants/inhero_mr2/variant.cpp @@ -0,0 +1,51 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + Modified (c) 2026, Inhero GmbH + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// Includes +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include "nrf.h" + +// Pin mapping +const uint32_t g_ADigitalPinMap[] = +{ + // P0 + 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , + 8 , 9 , 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47 +}; + +// Initialization +void initVariant() +{ + // LED1 & LED2 + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + + pinMode(PIN_LED2, OUTPUT); + ledOff(PIN_LED2); +} diff --git a/variants/inhero_mr2/variant.h b/variants/inhero_mr2/variant.h new file mode 100644 index 0000000000..7db59d7ce2 --- /dev/null +++ b/variants/inhero_mr2/variant.h @@ -0,0 +1,175 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + Modified (c) 2026, Inhero GmbH + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_INHERO_MR2_ +#define _VARIANT_INHERO_MR2_ + +#define INHERO_MR2 + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF +// define USE_LFRC // Board uses RC for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/* + * Inhero MR-2 GPIO definitions + */ +static const uint8_t WB_IO1 = 17; // SLOT_A SLOT_B +static const uint8_t WB_IO2 = 34; // SLOT_A SLOT_B +static const uint8_t WB_IO3 = 21; // SLOT_C +static const uint8_t WB_IO4 = 4; // SLOT_C +static const uint8_t WB_IO5 = 9; // SLOT_D +static const uint8_t WB_IO6 = 10; // SLOT_D +static const uint8_t WB_SW1 = 33; // IO_SLOT +static const uint8_t WB_A0 = 5; // IO_SLOT +static const uint8_t WB_A1 = 31; // IO_SLOT +static const uint8_t WB_I2C1_SDA = 13; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C1_SCL = 14; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C2_SDA = 24; // IO_SLOT +static const uint8_t WB_I2C2_SCL = 25; // IO_SLOT +static const uint8_t WB_SPI_CS = 26; // IO_SLOT +static const uint8_t WB_SPI_CLK = 3; // IO_SLOT +static const uint8_t WB_SPI_MISO = 29; // IO_SLOT +static const uint8_t WB_SPI_MOSI = 30; // IO_SLOT + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (35) +#define PIN_LED2 (36) +#define BQ_INT_PIN (21) +#define BQ_CE_PIN (4) // P0.04 (WB_IO4) - BQ25798 CE via DMN2004TK-7 FET (inverted: HIGH=charge enable, LOW=charge disable) +#define INA_ALERT_PIN (34) // P1.02 (WB_IO2) - INA228 ALERT (active-low, open-drain) for low-voltage sleep trigger + +#define LED_BUILTIN PIN_LED1 +#define LED_CONN PIN_LED2 + +#define LED_BLUE PIN_LED1 // P1.03 +#define LED_RED PIN_LED2 // P1.04 + +#define LED_STATE_ON 1 // State when LED is litted + +/* + * Buttons + */ +// No user buttons on Inhero MR-2 + +/* + * Analog pins + */ +#define PIN_A0 (5) //(3) +#define PIN_A1 (31) //(4) +#define PIN_A2 (28) +#define PIN_A3 (29) +#define PIN_A4 (30) +#define PIN_A5 (31) +#define PIN_A6 (0xff) +#define PIN_A7 (0xff) + +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; +static const uint8_t A6 = PIN_A6; +static const uint8_t A7 = PIN_A7; +#define ADC_RESOLUTION 14 + +// Other pins +#define PIN_AREF (2) +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +static const uint8_t AREF = PIN_AREF; + +/* + * Serial interfaces + */ +// TXD1 RXD1 on Base Board +#define PIN_SERIAL1_RX (15) +#define PIN_SERIAL1_TX (16) + +// TXD0 RXD0 on Base Board +#define PIN_SERIAL2_RX (19) +#define PIN_SERIAL2_TX (20) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 1 + +#define PIN_SPI_MISO (29) +#define PIN_SPI_MOSI (30) +#define PIN_SPI_SCK (3) + +static const uint8_t SS = 26; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +/* + * Wire Interfaces + */ +#define WIRE_INTERFACES_COUNT 2 + +#define PIN_WIRE_SDA (13) +#define PIN_WIRE_SCL (14) + +#define PIN_WIRE1_SDA (24) +#define PIN_WIRE1_SCL (25) + +// QSPI Pins +// QSPI occupied by GPIO's +#define PIN_QSPI_SCK 3 // 19 +#define PIN_QSPI_CS 26 // 17 +#define PIN_QSPI_IO0 30 // 20 +#define PIN_QSPI_IO1 29 // 21 +#define PIN_QSPI_IO2 28 // 22 +#define PIN_QSPI_IO3 2 // 23 + +// On-board QSPI Flash +// No onboard flash +#define EXTERNAL_FLASH_DEVICES IS25LP080D +#define EXTERNAL_FLASH_USE_QSPI + +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif