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/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) { 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