From ee684c0d7d64e862d0ef40edb9c12f5ed4df37a4 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Fri, 28 Aug 2026 15:09:48 +0200 Subject: [PATCH 1/8] Add Inhero MR2 board variant (nRF52840/RAK4630 solar repeater) Application-specific repeater platform for autonomous off-grid operation, in production and field-deployed: - RAK4630 core module (nRF52840 + SX1262), 45 x 40 mm - BQ25798 buck/boost charger: universal solar input 3.6-24V with MPPT, JEITA temperature-controlled charging - INA228 coulomb counter for SOC tracking and 7-day energy statistics - Li-ion, LiFePO4, LTO and Na-ion battery chemistry profiles - RV-3028 RTC wakeup with low-voltage system sleep (<500uA) and autonomous recovery - BME280 environment telemetry - board.* CLI namespace for configuration and diagnostics, dispatched through MainBoard::handleCommand() - slim variant README; the full documentation set (EN/DE) is maintained in the vendor fork Build environments: Inhero_MR2_repeater, Inhero_MR2_repeater_bridge_rs232, Inhero_MR2_sensor. --- boards/inhero_mr2.json | 74 + variants/inhero_mr2/BoardConfigContainer.cpp | 2323 +++++++++++++++++ variants/inhero_mr2/BoardConfigContainer.h | 345 +++ variants/inhero_mr2/InheroMr2Board.cpp | 563 ++++ variants/inhero_mr2/InheroMr2Board.h | 76 + variants/inhero_mr2/README.md | 20 + .../inhero_mr2/helpers/BatteryOcvMapping.cpp | 43 + .../inhero_mr2/helpers/BatteryOcvMapping.h | 18 + .../inhero_mr2/helpers/BqLowPowerSetup.cpp | 63 + variants/inhero_mr2/helpers/BqLowPowerSetup.h | 16 + variants/inhero_mr2/helpers/CliCommands.cpp | 492 ++++ variants/inhero_mr2/helpers/CliCommands.h | 30 + .../inhero_mr2/helpers/I2cBusRecovery.cpp | 40 + variants/inhero_mr2/helpers/I2cBusRecovery.h | 17 + variants/inhero_mr2/helpers/Rv3028Wake.cpp | 78 + variants/inhero_mr2/helpers/Rv3028Wake.h | 21 + .../inhero_mr2/helpers/SystemSleepGpio.cpp | 104 + variants/inhero_mr2/helpers/SystemSleepGpio.h | 25 + .../inhero_mr2/helpers/UsbAutoManagement.cpp | 49 + .../inhero_mr2/helpers/UsbAutoManagement.h | 19 + variants/inhero_mr2/helpers/Watchdog.cpp | 58 + variants/inhero_mr2/helpers/Watchdog.h | 18 + variants/inhero_mr2/lib/BqDriver.cpp | 586 +++++ variants/inhero_mr2/lib/BqDriver.h | 234 ++ variants/inhero_mr2/lib/Ina228Driver.cpp | 491 ++++ variants/inhero_mr2/lib/Ina228Driver.h | 204 ++ variants/inhero_mr2/lib/SimplePreferences.h | 109 + variants/inhero_mr2/platformio.ini | 70 + variants/inhero_mr2/target.cpp | 55 + variants/inhero_mr2/target.h | 30 + variants/inhero_mr2/variant.cpp | 48 + variants/inhero_mr2/variant.h | 177 ++ 32 files changed, 6496 insertions(+) create mode 100644 boards/inhero_mr2.json create mode 100644 variants/inhero_mr2/BoardConfigContainer.cpp create mode 100644 variants/inhero_mr2/BoardConfigContainer.h create mode 100644 variants/inhero_mr2/InheroMr2Board.cpp create mode 100644 variants/inhero_mr2/InheroMr2Board.h create mode 100644 variants/inhero_mr2/README.md create mode 100644 variants/inhero_mr2/helpers/BatteryOcvMapping.cpp create mode 100644 variants/inhero_mr2/helpers/BatteryOcvMapping.h create mode 100644 variants/inhero_mr2/helpers/BqLowPowerSetup.cpp create mode 100644 variants/inhero_mr2/helpers/BqLowPowerSetup.h create mode 100644 variants/inhero_mr2/helpers/CliCommands.cpp create mode 100644 variants/inhero_mr2/helpers/CliCommands.h create mode 100644 variants/inhero_mr2/helpers/I2cBusRecovery.cpp create mode 100644 variants/inhero_mr2/helpers/I2cBusRecovery.h create mode 100644 variants/inhero_mr2/helpers/Rv3028Wake.cpp create mode 100644 variants/inhero_mr2/helpers/Rv3028Wake.h create mode 100644 variants/inhero_mr2/helpers/SystemSleepGpio.cpp create mode 100644 variants/inhero_mr2/helpers/SystemSleepGpio.h create mode 100644 variants/inhero_mr2/helpers/UsbAutoManagement.cpp create mode 100644 variants/inhero_mr2/helpers/UsbAutoManagement.h create mode 100644 variants/inhero_mr2/helpers/Watchdog.cpp create mode 100644 variants/inhero_mr2/helpers/Watchdog.h create mode 100644 variants/inhero_mr2/lib/BqDriver.cpp create mode 100644 variants/inhero_mr2/lib/BqDriver.h create mode 100644 variants/inhero_mr2/lib/Ina228Driver.cpp create mode 100644 variants/inhero_mr2/lib/Ina228Driver.h create mode 100644 variants/inhero_mr2/lib/SimplePreferences.h create mode 100644 variants/inhero_mr2/platformio.ini create mode 100644 variants/inhero_mr2/target.cpp create mode 100644 variants/inhero_mr2/target.h create mode 100644 variants/inhero_mr2/variant.cpp create mode 100644 variants/inhero_mr2/variant.h diff --git a/boards/inhero_mr2.json b/boards/inhero_mr2.json new file mode 100644 index 0000000000..ed8414ce48 --- /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 MR2", + "upload": { + "maximum_ram_size": 235520, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://inhero.de", + "vendor": "Inhero GmbH" +} diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp new file mode 100644 index 0000000000..b87c5c0400 --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -0,0 +1,2323 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Board Configuration Container Implementation + */ +#include "BoardConfigContainer.h" +#include "InheroMr2Board.h" +#include "target.h" + +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" +#include "lib/SimplePreferences.h" + +#include +#include +#include + +#include "helpers/Watchdog.h" +#include // For NRF_POWER (GPREGRET2) + +#if ENV_INCLUDE_BME280 +#include +#endif + +// rtc_clock is defined in target.cpp +extern AutoDiscoverRTCClock rtc_clock; + +namespace { + inline uint32_t getRTCTime() { + return rtc_clock.getCurrentTime(); + } + + 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 +bool BoardConfigContainer::jeitaIgnoreActive = false; // Derived on chemistry apply, never persisted +float BoardConfigContainer::lastValidBatteryTemp = 25.0f; // 25°C = no derating until first valid reading +uint32_t BoardConfigContainer::lastTempUpdateMs = 0; // 0 = never updated + +// Battery voltage thresholds live in the BatteryProperties table (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. + +// 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 + +void BoardConfigContainer::setupWatchdog() { inhero::setupWatchdog(leds_enabled); } +void BoardConfigContainer::feedWatchdog() { inhero::feedWatchdog(); } +void BoardConfigContainer::disableWatchdog() { inhero::disableWatchdog(); } + +// Re-enables MPPT if BQ25798 disabled it (e.g., during !PG state). +// BQ25798 does not persist MPPT=1 and automatically sets MPPT=0 when PG=0; +// this restores MPPT=1 when PG returns to 1. +// 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"); + } +} + +// Single MPPT cycle — called from tickPeriodic() every 60s +// Checks solar logic and updates MPPT stats. +void BoardConfigContainer::runMpptCycle() { + // Clear any pending BQ25798 flags so the INT line stays de-asserted + // (we don't wire INT to an MCU IRQ, but leaving flags latched costs current). + if (bqDriverInstance) { + BqDriver::clearInterruptFlags(); + } + + checkAndFixSolarLogic(); + bool mpptEnabled; + BoardConfigContainer::loadMpptEnabled(mpptEnabled); + if (mpptEnabled && bqDriverInstance) { + updateMpptStats(); + } +} + +// Stops heartbeat task and disarms alerts before OTA. +// 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 + } +} + +// Enable or disable heartbeat LED and BQ25798 stat LED +bool BoardConfigContainer::setLEDsEnabled(bool enabled) { + leds_enabled = enabled; + + // Save to filesystem + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(LEDSKEY, 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 (bqInitialized && bqDriverInstance) { + bqDriverInstance->setStatPinEnable(enabled); + } + + return true; +} + +// Get current LED enable state +bool BoardConfigContainer::getLEDsEnabled() const { + return leds_enabled; +} + +// 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; + } +} + +// Returns current max charge current as string +const char* BoardConfigContainer::getChargeCurrentAsStr() { + static char buffer[16]; + snprintf(buffer, sizeof(buffer), "%dmA", this->getMaxChargeCurrent_mA()); + return buffer; +} + +// Writes charger status information into provided 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 (!bqInitialized) { + 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); + } + } + + // BQ die temperature (from the last ADC one-shot; up to one telemetry period old) + float tdie = bq.getDieTemperature_C(); + if (tdie > -100.0f && tdie < 200.0f) { + size_t len = strlen(buffer); + snprintf(buffer + len, bufferSize - len, " TDIE:%dC", (int)lroundf(tdie)); + } +} + +// RV-3028 self-test: address ACK plus user-RAM write/readback (see getSelfTest()). +bool BoardConfigContainer::probeRtc() { + // Address ACK + Wire.beginTransmission(0x52); + if (Wire.endTransmission() != 0) return false; + + // User-RAM 0x1F write/readback (two patterns, save/restore original). + 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); +} + +// Reads BQ25798 status/fault registers and produces a compact diagnostic string. +// 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] +void BoardConfigContainer::getBqDiagnostics(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + memset(buffer, 0, bufferSize); + + if (!bqInitialized) { + 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); +} + +// Initializes battery manager, preferences, and background tasks +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(PREFS_NAMESPACE)) { + char led_buffer[8]; + prefs_led.getString(LEDSKEY, 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) + ina228Initialized = 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)"); + ina228Initialized = false; + } + } else { + MESH_DEBUG_PRINTLN("INA228 no I2C ACK @ 0x40"); + ina228Initialized = false; + } + delay(10); + + // Initialize BQ25798 + if (bq.begin()) { + bqInitialized = 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."); + bqInitialized = 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. + BqDriver::maskAllInterrupts(); + + // Clear any latched flag/interrupt status from previous operation/boot + // so the INT line is de-asserted before we leave begin(). + BqDriver::clearInterruptFlags(); + + // 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 = bqInitialized && ina228Initialized && rtc_initialized; + + if (!all_components_ok) { + // Start permanent slow red LED blink to indicate missing component + MESH_DEBUG_PRINTLN("WARNING: Missing components - starting error LED"); + if (!bqInitialized) MESH_DEBUG_PRINTLN(" - BQ25798 missing"); + if (!ina228Initialized) 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 bqInitialized && ina228Initialized; +} + +// Loads battery type from preferences +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; +} + +// Loads frost charge behavior from preferences +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; +} + +// Loads maximum charge current from preferences +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; +} + +// Loads MPPT enabled setting from preferences +bool BoardConfigContainer::loadMpptEnabled(bool& enabled) { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + + if (prefs.getString(MPPTENABLEKEY, 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; +} + +// Returns combined telemetry from INA228 (battery) and BQ25798 (solar + temperature). +// Battery voltage/current come from the INA228 (20-bit ADC, ±0.1% accuracy); +// solar data and battery temperature from the 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->battery.temperature; + if (bqTemp >= -50.0f && bqTemp <= 90.0f) { + float ntcTemp = bqTemp + tcCalOffset; + // Plausibility against the BME280: an open/missing NTC decodes through the + // RT2-only pole to ≈-46°C regardless of ambient, which this window check + // cannot catch. A reading further than NTC_BME_MAX_DIFF_C from the board + // temperature is not the battery → discard. Without a BME reading the + // check stands down and the value passes as before. + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f && + fabsf(ntcTemp - bmeTemp) > NTC_BME_MAX_DIFF_C) { + telemetry.battery.temperature = -999.0f; // implausible → N/A + } else { + telemetry.battery.temperature = ntcTemp; + // Cache for temperature derating in updateBatterySOC() (static context) + lastValidBatteryTemp = ntcTemp; + lastTempUpdateMs = millis(); + } + } else { + // NTC unavailable (no solar / I2C error / ADC not ready) → propagate sentinel + telemetry.battery.temperature = -999.0f; + } + + telemetry.battery.voltage = batt_voltage; + telemetry.battery.current = batt_current; + telemetry.battery.power = batt_power; + + return &telemetry; +} + +// Configures base BQ25798 settings (timers, watchdog, input limits, MPPT) +bool BoardConfigContainer::configureBaseBQ() { + if (!bqInitialized) { + return false; + } + + bq.setRechargeThreshOffsetV(.2); + bq.setPrechargeTimerEnable(false); + + // Thermal regulation at 60°C instead of the 120°C POR default. In the + // buck-boost transition region (LTO 2S at ~5V input) the converter otherwise + // rides the 120°C ceiling at high charge currents. Harmless for 1S + // chemistries — clean buck operation never gets near 60°C. + bq.setThermRegulationThresh(BQ25798_TREG_60C); + 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: keep VREG unchanged. Default -400mV triggers VBAT_OVP on LiFePO4 + // and is unnecessarily conservative for Li-Ion (4.1V / 3.5V are already safe). + 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; +} + +// Configures battery chemistry-specific parameters (cell count, charge voltage) +bool BoardConfigContainer::configureChemistry(BatteryType type) { + if (!bqInitialized) { + 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 + + if (!props->charge_enable) { + // No charging at all for this type, so the temperature guard has nothing to + // guard; derive the override anyway to keep the reported state truthful. + applyJeitaIgnore(props); + MESH_DEBUG_PRINTLN("WARNING: Battery type UNKNOWN - Charging DISABLED for safety!"); + return true; // No further configuration needed for unknown battery + } + + // Configure chemistry-specific parameters, starting with the 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); + + // Writing the CELL bits resets ICHG, VSYSMIN and VREG to the per-cell-count + // POR defaults (datasheet 9.3.1.2 — 2S: 1A / 7V / 8.4V). VREG is re-applied + // above; restore the other two, otherwise a 2S chemistry runs with + // VSYSMIN=7V and the BATFET burns (VSYS - VBAT) × ICHG linearly during + // charging (LTO at 4.9V/0.93A: ~2W → BQ rides its thermal limit), and the + // configured imax silently falls back to the 1A default on every boot. + bq.setMinSystemV(2.75); + bq.setChargeLimitA(getMaxChargeCurrent_mA() / 1000.0f); + + // Derive the JEITA override LAST, once ICHG holds the configured imax again. + // Deriving it earlier leaves a window in which the temperature guard is off + // while setCellCount() has just reset ICHG to the 1A POR default — and an I2C + // failure inside that window would freeze the board in exactly that state. + // configureBaseBQ() clears TS_IGNORE, so the hardware guard rules until here. + applyJeitaIgnore(props); + + return true; +} + +// Gets current battery type from preferences +BoardConfigContainer::BatteryType BoardConfigContainer::getBatteryType() const { + BatteryType bat; + if (loadBatType(bat)) { + return bat; + } else { + return DEFAULT_BATTERY_TYPE; + } +} + +// Gets current frost charge behavior from preferences +BoardConfigContainer::FrostChargeBehaviour BoardConfigContainer::getFrostChargeBehaviour() const { + FrostChargeBehaviour frost; + if (loadFrost(frost)) { + return frost; + } else { + return NO_CHARGE; + } +} + +// Gets maximum charge current from preferences +uint16_t BoardConfigContainer::getMaxChargeCurrent_mA() const { + uint16_t maxI = 100; + loadMaxChrgI(maxI); + return maxI; +} + +// Gets current MPPT enable status from preferences +bool BoardConfigContainer::getMPPTEnabled() const { + bool enabled; + loadMpptEnabled(enabled); + return enabled; +} + +// === JEITA override (board.jeitaignore) === + +// Loads the stored user wish. Only meaningful for needs_jeita chemistries. +bool BoardConfigContainer::loadJeitaIgnoreWish(bool& on) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[4]; + if (prefs.getString(JEITAIGNKEY, buffer, sizeof(buffer), "") > 0) { + on = (buffer[0] == '1'); + return true; + } + on = false; + return false; +} + +bool BoardConfigContainer::getJeitaIgnoreWish() const { + bool on = false; + loadJeitaIgnoreWish(on); + return on; +} + +// The gate: batcap must be user-set and imax must not exceed 0.05C of it. +// The safety is this static bound, not a firmware control loop — in SYSTEMOFF +// sleep the charger stays enabled and no loop runs, so an unattended frozen +// cell must never see more than that rate. +bool BoardConfigContainer::jeitaIgnoreGateOk() const { + if (!isBatteryCapacitySet()) { + return false; + } + // Read the persisted capacity, not getBatteryCapacity(): that returns the + // socStats RAM cache, which begin() fills only AFTER configureChemistry() — + // the boot derivation would gate against 0 mAh and always fail. + float cap_mah = 0.0f; + loadBatteryCapacity(cap_mah); + return getMaxChargeCurrent_mA() <= jeitaIgnoreLimit_mA(cap_mah); +} + +// Stores the wish and re-derives the effective state. The wish survives a +// failed gate — it re-arms as soon as imax/batcap pass again. +bool BoardConfigContainer::setJeitaIgnoreWish(bool on) { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + if (!prefs.putString(JEITAIGNKEY, on ? "1" : "0")) { + return false; + } + applyJeitaIgnore(); + return true; +} + +// Re-derives for the current chemistry (CLI writers of imax/batcap/wish). +bool BoardConfigContainer::applyJeitaIgnore() { + return applyJeitaIgnore(getBatteryProperties(getBatteryType())); +} + +// Derives the effective JEITA override and programs the BQ: +// chemistry needs no JEITA (LTO, Na-ion, UNKNOWN) → forced on +// otherwise → user wish AND 0.05C gate +// TS_IGNORE stops the BQ's temperature regulation permanently — deliberately +// including SYSTEMOFF sleep. Turning the override off restores the stored +// fmax mapping (ISETC); ISETH needs no restore, its POR default is UNCHANGED. +bool BoardConfigContainer::applyJeitaIgnore(const BatteryProperties* props) { + if (!bqInitialized || !props) { + jeitaIgnoreActive = false; + return false; + } + + bool ignore = !props->needs_jeita || (getJeitaIgnoreWish() && jeitaIgnoreGateOk()); + bool was_active = jeitaIgnoreActive; + jeitaIgnoreActive = ignore; + + bq.setTsIgnore(ignore); + if (ignore) { + bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); + bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED); + } else if (was_active) { + setFrostChargeBehaviour(getFrostChargeBehaviour()); + } + return ignore; +} + +// Enables or disables MPPT +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; +} + +// Gets current maximum charge voltage +float BoardConfigContainer::getMaxChargeVoltage() const { + return bq.getChargeLimitV(); +} + +// Sets battery type and reconfigures BQ accordingly +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; +} + +// Sets frost charge behavior (JEITA cold region) +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; +} + +// Sets maximum charge current (ICHG) and recalculates solar IINDPM +// 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; +} + +// Notify USB connection state change — adjusts IINDPM accordingly +// 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(); + } +} + +// Calculate IINDPM for solar input from battery chemistry and charge current. +// 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; +} + +// 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); +} + +// Calculates 7-day moving average of MPPT enabled percentage +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 ===== + +// Get current State of Charge in percent +float BoardConfigContainer::getStateOfCharge() const { + return socStats.current_soc_percent; +} + +// Get nominal voltage for battery chemistry type +float BoardConfigContainer::getNominalVoltage(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->nominal_voltage : 3.7f; +} + +// Get battery capacity in mAh +float BoardConfigContainer::getBatteryCapacity() const { + return socStats.capacity_mah; +} + +// Check if battery capacity was explicitly set via CLI +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'); +} + +// Set battery capacity manually via CLI (converts to mWh internally) +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; +} + +// Time To Live in hours (see calculateTTL() for the full formula/model). +uint16_t BoardConfigContainer::getTTL_Hours() const { + return socStats.ttl_hours; +} + +// Check if living on battery (net deficit) +bool BoardConfigContainer::isLivingOnBattery() const { + return socStats.living_on_battery; +} + +// Sync SOC to 100% after "Charging Done" event from BQ25798 +// 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 + refreshTempDerating(); + + MESH_DEBUG_PRINTLN("SOC: Synced to 100%% (Charging Done) - INA228 baseline reset, d=%.2f", + socStats.temp_derating_factor); +} + +void BoardConfigContainer::refreshTempDerating() { + // BME280 fallback: if NTC hasn't updated for >5 min, try BME280. + // If BME280 also fails, lastValidBatteryTemp keeps its previous value + // (default 25°C = no derating). + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + uint32_t now = millis(); + if (lastTempUpdateMs == 0 || (now - lastTempUpdateMs) > 300000UL) { + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f) { + lastValidBatteryTemp = bmeTemp; + lastTempUpdateMs = now; + } + } + socStats.temp_derating_factor = getTemperatureDerating(props, lastValidBatteryTemp); + socStats.last_battery_temp_c = lastValidBatteryTemp; +} + +// Manually set SOC to specific percentage (e.g. after reboot with known SOC) +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. + refreshTempDerating(); + + 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; +} + +// Load battery capacity from preferences +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 +} + +// Get INA228 driver instance +Ina228Driver* BoardConfigContainer::getIna228Driver() { + return ina228DriverInstance; +} + +// ===== NTC Temperature Calibration ===== + +// Load NTC temperature calibration offset from preferences +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; +} + +// Set NTC temperature calibration offset and save to preferences +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; +} + +// Get current NTC temperature calibration offset +float BoardConfigContainer::getTcCalOffset() const { + return tcCalOffset; +} + +// Perform NTC temperature calibration using a reference temperature +// Averages 5 NTC readings to reduce ADC noise, computes offset = reference - avg, stores it. +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->battery.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; +} + +// Perform NTC temperature calibration using on-board BME280 as reference +// Averages 5 BME280 readings, then delegates to performTcCalibration(float). +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); +} + +// Read BME280 temperature directly via I2C (temporary instance, no core code changes) +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 +} + +// Arm INA228 BUVL alert at the chemistry's lowv_sleep_mv threshold. +// Fires → ISR → flag → tickPeriodic() → System Sleep. BAT_UNKNOWN = disabled. +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"); +} + +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"); +} + +// ISR: falling edge on INA228 ALERT (active-LOW, latched). Just sets the flag; +// tickPeriodic() consumes it and initiates shutdown. +void BoardConfigContainer::lowVoltageAlertISR() { + lowVoltageAlertFired = true; +} + +// Get low-voltage sleep threshold (INA228 ALERT fires at this level) +uint16_t BoardConfigContainer::getLowVoltageSleepThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_sleep_mv : 2000; +} + +// Get low-voltage wake threshold (RTC wake boots if VBAT >= this, 0% SOC marker) +uint16_t BoardConfigContainer::getLowVoltageWakeThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_wake_mv : 2200; +} + +// Update battery SOC from INA228 Hardware Coulomb Counter +// 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 + + // "Charging Done" → sync to 100%. Edge-triggered: DONE must be stable for two + // consecutive reads (a single misread never syncs; UNKNOWN = failed I2C read + // neither counts as DONE nor resets the streak), and the sync fires once per + // DONE episode. Syncing on the edge (not the persisting DONE state) keeps + // discharge during supplement mode on the ledger instead of erasing it + // every minute. + if (bqDriverInstance) { + static uint8_t done_streak = 0; + bq25798_charging_status status = bqDriverInstance->getChargingStatus(); + if (status == BQ25798_CHARGER_STATE_DONE_CHARGING) { + if (done_streak < 3) done_streak++; + } else if (status != BQ25798_CHARGER_STATE_UNKNOWN) { + done_streak = 0; + } + + if (done_streak == 2) { + done_streak = 3; // fire once per DONE episode + MESH_DEBUG_PRINTLN("SOC: \"Charging Done\" edge - syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after the counter reset — and refresh the LOCAL reading + // too: the ledger clamp below would otherwise capture the stale pre-reset + // value into the baseline, wrecking the SOC one tick later. + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + charge_mah = last_charge_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; + + // Ledger clamp: the battery cannot hold more than its capacity. Counted charge + // beyond "full" (charge losses, measurement drift, a sync at not-actually-full) + // would otherwise pile up as an invisible surplus that later discharge must burn + // off before the displayed SOC moves below 100%. Pull the baseline forward so + // the ledger itself — not just the displayed percentage — is capped at capacity. + if (remaining_mah > socStats.capacity_mah) { + socStats.ina228_baseline_mah = charge_mah; + net_charge_mah = 0.0f; + remaining_mah = socStats.capacity_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), + // 2) BME280 fallback after >5min of no NTC (no NTC fitted, or filtered out). + refreshTempDerating(); + + // 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; + } +} + +uint32_t BoardConfigContainer::getRTCTimestamp() { + return getRTCTime(); +} + +// Update hourly battery statistics and advance rolling window +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(); + } +} + +// 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(); +} + +// Calculate Time To Live (hours until battery empty). +// TTL is based on the 7-day rolling average of daily net energy consumption +// (avg_7day_daily_net_mah), computed from a 168-hour ring buffer of hourly +// INA228 Coulomb-counter measurements (charged/discharged/solar mAh). +// +// Data flow: +// 1. INA228 hardware Coulomb counter measures charge flow continuously (20-bit ADC) +// 2. updateHourlyStats() samples the counter every hour, storing per-hour deltas +// (charged_mah, discharged_mah, solar_mah) in the 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 the 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 ===== + +// Called from InheroMr2Board::tick() — dispatches all periodic I2C work with +// 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 ===== + +// 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; +} + +// 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; +} + +// Get battery properties for a given battery type +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 +} + +// Temperature derating factor (0..1) — extractable-capacity scaling at cold temps. +// Linear model: f(T)=1 for T>=T_ref, else max(f_min, 1 - k*(T_ref-T)). Used only +// for TTL/display, never for SOC% (SOC is purely Coulomb-based). +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; +} + +// 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"; +} + +// 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"; +} + +// 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; +} + +// 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; +} + +// 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..40b1543a61 --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" + +#include + +#define SOLAR_MPPT_INTERVAL_MS (1 * 60 * 1000) // 1 minute + +#define MPPT_STATS_HOURS 168 // 7 days + +typedef struct { + uint8_t mpptEnabledMinutes; // 0–60 + uint32_t timestamp; // unix seconds + uint32_t harvestedEnergy_mWh; +} MpptHourlyStats; + +typedef struct { + MpptHourlyStats hours[MPPT_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastUpdateTime; // unix seconds, or millis if RTC unavailable + uint16_t currentHourMinutes; + bool usingRTC; + uint32_t currentHourEnergy_mWh; + int32_t lastPower_mW; +} MpptStatistics; + +// Battery SOC: mAh-based, uses INA228 hardware coulomb counter (CHARGE register) +#define HOURLY_STATS_HOURS 168 // 7 days + +typedef struct { + uint32_t timestamp; // start of hour, unix seconds + float charged_mah; + float discharged_mah; + float solar_mah; +} HourlyBatteryStats; + +typedef struct { + // Battery configuration + float capacity_mah; + float nominal_voltage; + + // SOC tracking via INA228 CHARGE register + float current_soc_percent; // 0–100 + bool soc_valid; // true after first "Charging Done" sync + float ina228_baseline_mah; // INA228 CHARGE at last 100% sync + + uint32_t last_soc_update_ms; + + // 168-hour rolling buffer + HourlyBatteryStats hours[HOURLY_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastHourUpdateTime; + + // Current hour accumulators + float current_hour_charged_mah; + float current_hour_discharged_mah; + float current_hour_solar_mah; + float last_charge_reading_mah; // last INA228 CHARGE for delta calc + + // Rolling window stats (computed from hourly buffer) + float last_24h_net_mah; + float last_24h_charged_mah; + float last_24h_discharged_mah; + float avg_3day_daily_net_mah; + float avg_3day_daily_charged_mah; + float avg_3day_daily_discharged_mah; + float avg_7day_daily_net_mah; + float avg_7day_daily_charged_mah; + float avg_7day_daily_discharged_mah; + // TTL: hours until battery empty (0 = not calculated). Based on 7-day rolling + // avg of daily net deficit from INA228 coulomb-counter samples. + uint16_t ttl_hours; + bool living_on_battery; // net deficit over last 24h + uint16_t soc_update_count; + float temp_derating_factor; // 0.0–1.0 + float last_battery_temp_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; + float nominal_voltage; + uint16_t lowv_sleep_mv; // INA228 ALERT → System Sleep + uint16_t lowv_wake_mv; // 0% SOC marker, RTC wake decision + bool charge_enable; + // Whether the chemistry needs JEITA temperature supervision. false (LTO, + // Na-ion) forces the JEITA override on; true (Li-ion, LiFePO4) leaves it + // off unless the user sets board.jeitaignore and passes the 0.05C gate. + bool needs_jeita; + // Capacity derating at cold temps. Calibrated for ~0.01C avg / ~0.05C TX + // peak loads on 2–8 Ah cells (much milder than datasheet 0.2C–0.5C values). + // 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; + float temp_derating_min; + float temp_ref_c; + } BatteryProperties; + + static inline constexpr BatteryProperties battery_properties[] = { + // Type ChgV NomV SleepMv WakeMv ChgEn NeedsJ k min Tref + { BAT_UNKNOWN, 0.0f, 0.0f, 2000, 2200, false, false, 0.000f, 1.00f, 25.0f }, + { LTO_2S, 5.4f, 4.6f, 3900, 4100, true, false, 0.002f, 0.88f, 25.0f }, + { LIFEPO4_1S, 3.5f, 3.2f, 2700, 2900, true, true, 0.006f, 0.70f, 25.0f }, + { LIION_1S, 4.1f, 3.7f, 3100, 3300, true, true, 0.005f, 0.75f, 25.0f }, + { NAION_1S, 3.9f, 3.1f, 2500, 2700, true, false, 0.003f, 0.85f, 25.0f } + }; + + 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 } + }; + + // Defaults for newly flashed boards + static constexpr BatteryType DEFAULT_BATTERY_TYPE = BAT_UNKNOWN; + 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 = 1.2 × (V_charge × I_charge) / V_panel_assumed. + // Prevents weak panels from tripping POORSRC after PG qualification. + static constexpr float IINDPM_MAX_A = 2.0f; // JST connector limit + static constexpr float IINDPM_USB_A = 0.5f; // USB 2.0 max + static constexpr float IINDPM_PANEL_V = 4.0f; + static constexpr float IINDPM_MARGIN = 1.2f; + + // If PG=0 but VBUS >= this, 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); + + // Returns 0.0–1.0; reduces SOC% and TTL at cold temps. Coulomb counter unaffected. + static float getTemperatureDerating(const BatteryProperties* props, float temp_c); + + 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(); // periodic I2C work (MPPT, SOC, hourly stats) + static void stopBackgroundTasks(); + + 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; + + // Caps IINDPM to 500mA when USB is the input source. + static void setUsbConnected(bool connected); + static bool isUsbConnected() { return usbInputActive; } + static float calculateSolarIINDPM(); + static void updateSolarIINDPM(); + + bool getMPPTEnabled() const; + bool setMPPTEnable(bool enableMPPT); + + float getMaxChargeVoltage() const; + + bool begin(); + + // INA228 for VBAT/IBAT, BQ25798 for solar. + const Telemetry* getTelemetryData(); + + const char* getChargeCurrentAsStr(); + void getChargerInfo(char* buffer, uint32_t bufferSize); + void getBqDiagnostics(char* buffer, uint32_t bufferSize); + + // "INA:OK BQ:OK RTC:OK BME:OK". RTC probe writes/reads user-RAM to catch + // zombie chips that ACK but don't persist. + void getSelfTest(char* buffer, uint32_t bufferSize); + + // Address ACK + user-RAM write/readback verify (bytes 0x1F, 0x20 are scratch). + static bool probeRtc(); + + float getMpptEnabledPercentage7Day() const; + + // Battery SOC & coulomb counter + float getStateOfCharge() const; + float getBatteryCapacity() const; + bool setBatteryCapacity(float capacity_mah); + bool isBatteryCapacitySet() const; + uint16_t getTTL_Hours() const; + bool isLivingOnBattery() const; + // Sync SOC to 100% after "Charging Done". + static void syncSOCToFull(); + static bool setSOCManually(float soc_percent); + const BatterySOCStats* getSOCStats() const { return &socStats; } + const MpptStatistics* getMpptStats() const { return &mpptStats; } + static void updateBatterySOC(); + static uint32_t getRTCTimestamp(); + + static float getNominalVoltage(BatteryType type); + void setLowVoltageRecovery() { lowVoltageRecovery = true; } + Ina228Driver* getIna228Driver(); + + // NTC calibration via BME280 reference + bool setTcCalOffset(float offset_c); + float getTcCalOffset() const; + float performTcCalibration(float* bme_temp_out = nullptr); + static float readBmeTemperature(); + + // JEITA override (board.jeitaignore). The stored value is the USER WISH and + // only exists for needs_jeita chemistries; the effective state is derived on + // every chemistry apply: forced on when the chemistry needs no JEITA, + // otherwise wish AND 0.05C gate. The wish survives a failed gate — it + // re-arms as soon as imax/batcap pass again. + bool setJeitaIgnoreWish(bool on); // store wish, re-derive, program the BQ + bool getJeitaIgnoreWish() const; // stored wish (default false) + bool isJeitaIgnoreActive() const { return jeitaIgnoreActive; } + bool jeitaIgnoreGateOk() const; // batcap user-set AND imax <= 0.05C + static float jeitaIgnoreLimit_mA(float capacity_mah) { return 0.05f * capacity_mah; } + bool applyJeitaIgnore(); // re-derive for the current chemistry + + // INA228 ALERT on P1.02 (Rev 1.1) + void armLowVoltageAlert(); + static void disarmLowVoltageAlert(); + static void lowVoltageAlertISR(); + + static uint16_t getLowVoltageSleepThreshold(BatteryType type); + static uint16_t getLowVoltageWakeThreshold(BatteryType type); + + // 600s timeout; nRF52 WDT cannot truly be disabled. + static void setupWatchdog(); + static void feedWatchdog(); + static void disableWatchdog(); + + bool setLEDsEnabled(bool enabled); + bool getLEDsEnabled() const; + +private: + static BqDriver* bqDriverInstance; + static Ina228Driver* ina228DriverInstance; + static TaskHandle_t heartbeatTaskHandle; + static volatile bool lowVoltageAlertFired; // INA228 ALERT fired, checked in tickPeriodic + + // Tick scheduling (millis-based, overflow-safe) + uint32_t lastMpptMs = 0; + uint32_t lastSocMs = 0; + 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 + // Cached battery type for static methods (set by begin()/setBatteryType()) + static BatteryType cachedBatteryType; + + bool bqInitialized = false; + bool ina228Initialized = 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 bool jeitaIgnoreActive; // effective JEITA override state (derived, never persisted) + // NTC plausibility: an open/missing NTC decodes through the RT2-only pole to + // ≈-46°C regardless of ambient — in DC indistinguishable from a real NTC at + // that temperature. The BME280 is the reference; readings further apart than + // this are discarded as "not the battery". + static constexpr float NTC_BME_MAX_DIFF_C = 15.0f; + // Last valid battery temperature in °C, updated by getTelemetryData() or + // BME280 fallback (default 25.0 = no derating) + static float lastValidBatteryTemp; + static uint32_t lastTempUpdateMs; // millis() of last valid temperature update (0 = never updated) + + // Refresh socStats.temp_derating_factor and last_battery_temp_c. + // Falls back to BME280 if NTC has not updated for >5 min. + static void refreshTempDerating(); + + 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* LEDSKEY = "leds_en"; + static constexpr const char* BATTERY_CAPACITY_KEY = "batCap"; + static constexpr const char* TCCAL_KEY = "tcCal"; // NTC temperature calibration offset + static constexpr const char* JEITAIGNKEY = "jeitaIgn"; // JEITA override user wish + + bool applyJeitaIgnore(const BatteryProperties* props); // derive + program TS_IGNORE/ISETC/ISETH + bool loadJeitaIgnoreWish(bool& on) const; + 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/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp new file mode 100644 index 0000000000..d14bea1a29 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -0,0 +1,563 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Inhero MR2 Board Implementation + */ + +#include "InheroMr2Board.h" + +#include "BoardConfigContainer.h" +#include "helpers/BatteryOcvMapping.h" +#include "helpers/BqLowPowerSetup.h" +#include "helpers/CliCommands.h" +#include "helpers/I2cBusRecovery.h" +#include "helpers/Rv3028Wake.h" +#include "helpers/SystemSleepGpio.h" +#include "helpers/UsbAutoManagement.h" +#include "target.h" + +#include +#include +#include + +static BoardConfigContainer boardConfig; +volatile bool InheroMr2Board::rtc_irq_pending = false; +volatile uint32_t InheroMr2Board::ota_dfu_reset_at = 0; + +// ===== 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. + inhero::clearTimerFlag(); + + // 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, INA228_I2C_ADDR); + 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 + + // Put INA228 + BQ25798 into a state that draws minimal current during System Sleep. + inhero::prepareIcsForSystemOff(); + + // 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. + inhero::prepareRadioForSystemOff(false); + + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + Wire.end(); + + // Disconnect GPIO pull-ups before System Sleep (Wire.end() keeps SDA/SCL + // pull-ups active on nRF52 — each held-LOW line wastes ~250µA). + inhero::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: drive LOW on boot so the external FET stays OFF (Rev 1.1 inverts + // logic via DMN2004TK-7). configureChemistry() raises it after successful I2C init. +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, LOW); +#endif + + // PE4259 RF switch VDD (P1.05 → PE4259 pin 6). Required for TX/RX; DIO2 drives CTRL. + 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). + inhero::recoverI2cBus(PIN_BOARD_SDA, PIN_BOARD_SCL); + + Wire.begin(); + delay(50); // Give I2C bus time to stabilize + + 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, INA228_I2C_ADDR); + 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); + inhero::prepareRadioForSystemOff(false); + + // INA228 → Shutdown mode with readback verification + for (int retry = 0; retry < 3; retry++) { + Wire.beginTransmission(INA228_I2C_ADDR); + 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(INA228_I2C_ADDR); + Wire.write(0x01); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)INA228_I2C_ADDR, (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(INA228_I2C_ADDR); + Wire.write(0x0B); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)INA228_I2C_ADDR, (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(BME280_I2C_ADDR); + 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(); + inhero::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 REG1 (VDD 3.3V → 1.3V core, ~1.5mA saving). REG0 not needed — + // RAK4630 is powered from TPS62840 VDD, not VBUS. Done after peripheral init. + 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 (inhero::isUsbPowered()) { + BoardConfigContainer::setUsbConnected(true); + } +} + +void InheroMr2Board::tick() { + inhero::serviceUsbAutoManagement(); + + // 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 inhero::socToLiIonMilliVolts(boardConfig.getStateOfCharge()); + } + + // Fallback: no valid Coulomb-counting SOC yet — return real voltage + const Telemetry* telemetry = boardConfig.getTelemetryData(); + if (!telemetry) { + return 0; + } + return telemetry->battery.voltage; +} + +bool InheroMr2Board::startOTAUpdate(const char* id, char reply[]) { + // Skip in-app BLE DFU (unstable here) and jump to the Adafruit bootloader's + // native OTA DFU via enterOTADfu() (sets GPREGRET=0xA8 + reset). + 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; +} + +// Collects board telemetry and appends to CayenneLPP packet +bool InheroMr2Board::queryBoardTelemetry(CayenneLPP& telemetry) { + return inhero::appendBoardTelemetry(boardConfig, telemetry); +} + +// Handles this board's own 'get board.*' / 'set board.*' CLI commands. +// CommonCLI::handleCommand() offers every command to the board before its own +// dispatch, so returning false just falls through to the core CLI. +// +// The reply buffer carries no length. The smallest one a caller on this board's +// builds provides is 160 bytes (simple_repeater/main.cpp, simple_sensor/main.cpp); +// the DM path gives 161. +bool InheroMr2Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + const uint32_t maxlen = 160; + + if (memcmp(command, "get board.", 10) == 0) { + return inhero::handleGet(boardConfig, &command[10], reply, maxlen); + } + if (memcmp(command, "set board.", 10) == 0) { + const char* result = inhero::handleSet(boardConfig, &command[10]); + if (result == NULL) return false; + + strncpy(reply, result, maxlen - 1); + reply[maxlen - 1] = 0; + return true; + } + return false; +} + +// ===== Power Management Methods (Rev 1.1) ===== + +// Get low-voltage sleep threshold (chemistry-specific) +uint16_t InheroMr2Board::getLowVoltageSleepThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageSleepThreshold(chemType); +} + +// Get low-voltage wake threshold (chemistry-specific) +uint16_t InheroMr2Board::getLowVoltageWakeThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageWakeThreshold(chemType); +} + +// Initiate controlled shutdown with filesystem protection (Rev 1.1) + +// 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 ALERT pin: latched LOW after LV trip wastes ~330µA through the + // RAK4630 pull-up. Clear ALATCH + BUVL (transparent mode) before ADC shutdown. + 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) + inhero::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. INA228 + BQ25798 \u2192 minimum sleep current. Must be AFTER CE=HIGH + // (charge enable may re-enable BQ ADC). Repeats INA228 shutdown via raw I2C + // with readback as a safety net if the driver call in step 2 silently failed. + inhero::prepareIcsForSystemOff(); + + // 5c. 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(BME280_I2C_ADDR); + 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 + inhero::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(); + inhero::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(); +} + +void InheroMr2Board::configureRTCWake(uint32_t minutes) { + uint16_t ticks = static_cast( + minutes == 0 ? LOW_VOLTAGE_SLEEP_MINUTES + : (minutes > 4095 ? 4095 : minutes)); + inhero::configurePeriodicWake(ticks); +} + +void InheroMr2Board::rtcInterruptHandler() { + // Defer I2C work to the main loop to avoid ISR I2C collisions. + rtc_irq_pending = true; +} diff --git a/variants/inhero_mr2/InheroMr2Board.h b/variants/inhero_mr2/InheroMr2Board.h new file mode 100644 index 0000000000..933cde35fe --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include + +// LoRa (SX1262) +#define P_LORA_DIO_1 47 +#define P_LORA_NSS 42 +#define P_LORA_RESET RADIOLIB_NC +#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 + +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define PIN_VBAT_READ 5 +#define ADC_MULTIPLIER (3 * 1.73 * 1.187 * 1000) + +// Power management (INA228 + RV-3028) +#define RTC_INT_PIN 17 // GPIO17 (WB_IO1) +#define RTC_I2C_ADDR 0x52 +#define INA228_I2C_ADDR 0x40 // A0=GND, A1=GND +#define BQ25798_I2C_ADDR 0x6B +#define BME280_I2C_ADDR 0x76 + +// RV-3028-C7 registers +#define RV3028_REG_STATUS 0x0E +#define RV3028_REG_CTRL1 0x0F +#define RV3028_REG_CTRL2 0x10 +#define RV3028_REG_TIMER_VALUE_0 0x0A +#define RV3028_REG_TIMER_VALUE_1 0x0B + +// GPREGRET2 layout: [1:0] shutdown reason, [7:2] state flags +#define SHUTDOWN_REASON_NONE 0x00 +#define SHUTDOWN_REASON_LOW_VOLTAGE 0x01 +#define SHUTDOWN_REASON_USER_REQUEST 0x02 +#define SHUTDOWN_REASON_THERMAL 0x03 +#define GPREGRET2_LOW_VOLTAGE_SLEEP 0x04 + +#define LOW_VOLTAGE_SLEEP_MINUTES (60) + +class InheroMr2Board : public NRF52BoardDCDC { +public: + InheroMr2Board() : NRF52Board("InheroMR2_OTA") {} + void begin(); + void tick() override; + + uint16_t getBattMilliVolts() override; + + void initiateShutdown(uint8_t reason); + void configureRTCWake(uint32_t minutes); + uint16_t getLowVoltageSleepThreshold(); + uint16_t getLowVoltageWakeThreshold(); + + static void rtcInterruptHandler(); + + const char *getManufacturerName() const override { return "Inhero MR2"; } + void reboot() override { NVIC_SystemReset(); } + + bool startOTAUpdate(const char *id, char reply[]) override; + bool handleCommand(const char *command, uint32_t sender_timestamp, char *reply) override; + bool queryBoardTelemetry(CayenneLPP &telemetry) override; + +private: + static volatile bool rtc_irq_pending; + static volatile uint32_t ota_dfu_reset_at; // millis() of 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..b6dc0e6c9f --- /dev/null +++ b/variants/inhero_mr2/README.md @@ -0,0 +1,20 @@ +# Inhero MR2 + +Purpose-built solar repeater board: RAK4630 (nRF52840 + SX1262), BQ25798 +buck/boost charger with universal 3.6–24 V solar input and MPPT, INA228 +coulomb counter, RV-3028 RTC, BME280 environment sensor, 45 × 40 mm, +CE-certified (RED 2014/53/EU). + +Build environments: + +```bash +pio run -e Inhero_MR2_repeater +pio run -e Inhero_MR2_repeater_bridge_rs232 +pio run -e Inhero_MR2_sensor +``` + +Full documentation (English and German) — quick start, datasheet, battery +chemistry guide, power management, telemetry, CLI reference, FAQ — is +maintained by the manufacturer at +, +also reachable via . diff --git a/variants/inhero_mr2/helpers/BatteryOcvMapping.cpp b/variants/inhero_mr2/helpers/BatteryOcvMapping.cpp new file mode 100644 index 0000000000..ffab9a8244 --- /dev/null +++ b/variants/inhero_mr2/helpers/BatteryOcvMapping.cpp @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BatteryOcvMapping.h" + +namespace inhero { + +uint16_t 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; + if (idx_lo >= 10) idx_lo = 9; // safety clamp + uint8_t idx_hi = idx_lo + 1; + + float frac = index_f - (float)idx_lo; + 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 +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BatteryOcvMapping.h b/variants/inhero_mr2/helpers/BatteryOcvMapping.h new file mode 100644 index 0000000000..6be420de29 --- /dev/null +++ b/variants/inhero_mr2/helpers/BatteryOcvMapping.h @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace inhero { + +// Maps a SOC percentage (0-100%) to a fake Li-Ion 1S OCV in millivolts. +// Uses a standard Li-Ion NMC/NCA OCV lookup table with piecewise-linear +// interpolation. The companion app reverse-maps these voltages back to the +// same SOC%, giving a correct battery-level display regardless of the +// actual cell chemistry (Li-Ion, LiFePO4, LTO, Na-Ion). +uint16_t socToLiIonMilliVolts(float soc_percent); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp new file mode 100644 index 0000000000..2b552e6a80 --- /dev/null +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BqLowPowerSetup.h" + +#include +#include + +#include "../InheroMr2Board.h" +#include "../lib/BqDriver.h" + +namespace inhero { + +static constexpr uint8_t INA228_ADDR = 0x40; + +void prepareIcsForSystemOff() { + // INA228 -> shutdown mode (~3.5uA vs ~350uA continuous). + // I2C writes can fail silently -> retry with readback verification. + for (int retry = 0; retry < 3; retry++) { + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x01); // ADC_CONFIG + Wire.write(0x00); + Wire.write(0x00); + if (Wire.endTransmission() != 0) { + delay(10); + continue; + } + delay(2); + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x01); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)INA228_ADDR, (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 latched ALERT (under-voltage alert is ALATCH=1 -> ALERT stays LOW + // -> RAK4630 internal pull-up wastes ~330uA). Switch to transparent mode and + // zero the threshold so no condition can re-assert. + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x0B); // DIAG_ALRT + Wire.write(0x00); + Wire.write(0x00); + Wire.endTransmission(); + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x08); // BUVL + Wire.write(0x00); + Wire.write(0x00); + Wire.endTransmission(); + + // BQ25798 -> low-power housekeeping. These static helpers use raw Wire and + // work even when the driver instance has not been constructed yet (LV-Wake). + BqDriver::disableAdc(); // ~500uA saving + BqDriver::maskAllInterrupts(); // prevent INT holding LOW + BqDriver::clearInterruptFlags(); // de-assert latched INT +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.h b/variants/inhero_mr2/helpers/BqLowPowerSetup.h new file mode 100644 index 0000000000..7c4efbc68f --- /dev/null +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.h @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// Prepares INA228 and BQ25798 for nRF52 System Sleep so the analog ICs +// don't burn quiescent current while the MCU is off: +// - INA228: shutdown ADC (with readback retry), release latched ALERT +// - BQ25798: disable ADC, mask all interrupts and clear flags so INT goes high-Z +// Wire must be initialised before calling. +void prepareIcsForSystemOff(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp new file mode 100644 index 0000000000..ac850f1b00 --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "CliCommands.h" + +#include "../BoardConfigContainer.h" + +#include +#include +#include +#include +#include + +namespace inhero { + +namespace { + +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; + } +} + +} // namespace + +uint8_t findNextFreeLppChannel(CayenneLPP& lpp) { + uint8_t max_channel = 0; + uint8_t cursor = 0; + uint8_t* buffer = lpp.getBuffer(); + uint8_t size = lpp.getSize(); + + while (cursor < size) { + if (cursor + 1 >= size) break; + uint8_t channel = buffer[cursor]; + uint8_t type = buffer[cursor + 1]; + uint8_t data_len = getLPPDataLength(type); + if (data_len == 0) break; // unknown type, can't continue + if (channel > max_channel) max_channel = channel; + cursor += 2 + data_len; + } + return max_channel + 1; +} + +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry) { + const Telemetry* telemetryData = cfg.getTelemetryData(); + if (!telemetryData) return false; + + uint8_t batteryChannel = findNextFreeLppChannel(telemetry); + uint8_t solarChannel = batteryChannel + 1; + + const BatterySOCStats* socStats = cfg.getSOCStats(); + bool hasValidSoc = (socStats && socStats->soc_valid); + float socPercent = roundf(cfg.getStateOfCharge() * 10.0f) / 10.0f; + + uint16_t ttlHours = cfg.getTTL_Hours(); + bool isInfiniteTtl = (socStats && socStats->soc_valid && !socStats->living_on_battery); + constexpr float MAX_TTL_DAYS = 990.0f; // sentinel reported when TTL is effectively infinite + + // Battery: VBAT[V], SOC[%] (opt), IBAT[A], TBAT[°C], TTL[d] (opt) + telemetry.addVoltage(batteryChannel, telemetryData->battery.voltage / 1000.0f); + if (hasValidSoc) telemetry.addPercentage(batteryChannel, socPercent); + telemetry.addCurrent(batteryChannel, telemetryData->battery.current / 1000.0f); + if (telemetryData->battery.temperature > -100.0f) { + telemetry.addTemperature(batteryChannel, telemetryData->battery.temperature); + } + if (ttlHours > 0) { + telemetry.addDistance(batteryChannel, ttlHours / 24.0f); + } else if (isInfiniteTtl) { + telemetry.addDistance(batteryChannel, MAX_TTL_DAYS); + } + + // Solar: VSOL[V], ISOL[A], MPPT_7D[%] + telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); + telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + telemetry.addPercentage(solarChannel, cfg.getMpptEnabledPercentage7Day()); + + return true; +} + +bool handleGet(BoardConfigContainer& cfg, 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(cfg.getBatteryType())); + return true; + } else if (strcmp(cmd, "fmax") == 0) { + if (cfg.isJeitaIgnoreActive()) { + snprintf(reply, maxlen, "N/A"); + } else { + snprintf(reply, maxlen, "%s", + BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour())); + } + return true; + } else if (strcmp(cmd, "imax") == 0) { + snprintf(reply, maxlen, "%s", cfg.getChargeCurrentAsStr()); + return true; + } else if (strcmp(cmd, "mppt") == 0) { + snprintf(reply, maxlen, "MPPT=%s", cfg.getMPPTEnabled() ? "1" : "0"); + return true; + } else if (strcmp(cmd, "stats") == 0) { + const BatterySOCStats* socStats = cfg.getSOCStats(); + if (!socStats) { + snprintf(reply, maxlen, "N/A M:%.0f%%", cfg.getMpptEnabledPercentage7Day()); + return true; + } + + // Rolling windows incl. current-hour accumulators (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"; + uint16_t ttl = cfg.getTTL_Hours(); + float mppt_pct = cfg.getMpptEnabledPercentage7Day(); + + 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%% BT:%s", + last_24h_net, socStats->avg_3day_daily_net_mah, socStats->avg_7day_daily_net_mah, + last_24h_charged, last_24h_discharged, + socStats->avg_3day_daily_charged_mah, socStats->avg_3day_daily_discharged_mah, + socStats->avg_7day_daily_charged_mah, socStats->avg_7day_daily_discharged_mah, + status, mppt_pct, ttlBuf); + return true; + } else if (strcmp(cmd, "cinfo") == 0) { + char infoBuffer[100]; + cfg.getChargerInfo(infoBuffer, sizeof(infoBuffer)); + snprintf(reply, maxlen, "%s", infoBuffer); + return true; + } else if (strcmp(cmd, "bqdiag") == 0) { + char diagBuffer[100]; + cfg.getBqDiagnostics(diagBuffer, sizeof(diagBuffer)); + snprintf(reply, maxlen, "%s", diagBuffer); + return true; + } else if (strcmp(cmd, "selftest") == 0) { + char stBuffer[64]; + cfg.getSelfTest(stBuffer, sizeof(stBuffer)); + snprintf(reply, maxlen, "%s", stBuffer); + return true; + } else if (strcmp(cmd, "socdebug") == 0) { + Ina228Driver* ina = cfg.getIna228Driver(); + if (!ina) { + snprintf(reply, maxlen, "INA228 n/a"); + return true; + } + const BatterySOCStats* s = cfg.getSOCStats(); + uint16_t scal = ina->readShuntCalRegister(); + float chg = ina->readCharge_mAh(); + float cur = ina->readCurrent_mA_precise(); + uint32_t rtc = BoardConfigContainer::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 = cfg.getTelemetryData(); + if (!telemetry) { + snprintf(reply, maxlen, "Err: Telemetry unavailable"); + return true; + } + + float precise_current_ma = telemetry->battery.current; + float soc = cfg.getStateOfCharge(); + const BatterySOCStats* socStats = cfg.getSOCStats(); + + // INA228 returns signed: 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); + + char temp_str[8]; + if (telemetry->battery.temperature <= -100.0f) { + snprintf(temp_str, sizeof(temp_str), "N/A"); + } else { + snprintf(temp_str, sizeof(temp_str), "%.0fC", telemetry->battery.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->battery.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->battery.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->battery.voltage / 1000.0f, bat_current_str, temp_str, + telemetry->solar.voltage / 1000.0f, sol_current_str); + } + return true; + } else if (strcmp(cmd, "conf") == 0) { + const char* batType = BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType()); + const auto* confProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + const char* frostBehaviour = cfg.isJeitaIgnoreActive() + ? "N/A" + : BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour()); + + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + snprintf(reply, maxlen, "B:%s (no battery, charging disabled)", batType); + } else { + float chargeVoltage = cfg.getMaxChargeVoltage(); + float voltage0Soc = + BoardConfigContainer::getLowVoltageWakeThreshold(cfg.getBatteryType()) / 1000.0f; + const char* imax = cfg.getChargeCurrentAsStr(); + bool mpptEnabled = cfg.getMPPTEnabled(); + // J:1 appears only while the user override is on — for chemistries that + // run without JEITA anyway the line is unchanged. + const char* jeitaMark = + (confProps && confProps->needs_jeita && cfg.isJeitaIgnoreActive()) ? " J:1" : ""; + snprintf(reply, maxlen, "B:%s F:%s M:%s I:%s Vco:%.2f V0:%.2f%s", batType, frostBehaviour, + mpptEnabled ? "1" : "0", imax, chargeVoltage, voltage0Soc, jeitaMark); + } + return true; + } else if (strcmp(cmd, "tccal") == 0) { + snprintf(reply, maxlen, "TC offset: %+.2f C (0.00=default)", cfg.getTcCalOffset()); + return true; + } else if (strcmp(cmd, "leds") == 0) { + snprintf(reply, maxlen, "LEDs: %s (Heartbeat + BQ Stat)", + cfg.getLEDsEnabled() ? "ON" : "OFF"); + return true; + } else if (strcmp(cmd, "batcap") == 0) { + float capacity_mah = cfg.getBatteryCapacity(); + bool explicitly_set = cfg.isBatteryCapacitySet(); + snprintf(reply, maxlen, "%.0f mAh (%s)", capacity_mah, explicitly_set ? "set" : "default"); + return true; + } else if (strcmp(cmd, "jeitaignore") == 0) { + const auto* jiProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + // No chemistry set means no charging at all, so the question has no answer yet. + snprintf(reply, maxlen, "N/A"); + } else if (jiProps && !jiProps->needs_jeita) { + snprintf(reply, maxlen, "jeitaignore 1 (chemistry)"); + } else if (cfg.isJeitaIgnoreActive()) { + snprintf(reply, maxlen, "jeitaignore 1"); + } else if (cfg.getJeitaIgnoreWish()) { + // Wish is stored but the gate blocks it — name the blocker. + snprintf(reply, maxlen, "jeitaignore 1, N/A, %s", + cfg.isBatteryCapacitySet() ? "C>0.05" : "batcap not set"); + } else { + snprintf(reply, maxlen, "jeitaignore 0"); + } + return true; + } + + snprintf(reply, maxlen, + "Err: bat|fmax|imax|mppt|telem|stats|cinfo|conf|tccal|leds|batcap|jeitaignore"); + return true; +} + +const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { + static char ret[100]; + memset(ret, 0, sizeof(ret)); + + 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) { + cfg.setBatteryType(bt); + snprintf(ret, sizeof(ret), "Bat set to %s", + BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType())); + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", + BoardConfigContainer::getAvailableBatOptions()); + } + return ret; + } else if (strncmp(setCommand, "fmax ", 5) == 0) { + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + return "Err: Set board.bat first"; + } + const auto* fmaxProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (fmaxProps && !fmaxProps->needs_jeita) { + snprintf(ret, sizeof(ret), "Err: Fmax setting N/A for this chemistry (JEITA disabled)"); + return ret; + } + if (cfg.isJeitaIgnoreActive()) { + snprintf(ret, sizeof(ret), "Err: Fmax N/A while jeitaignore is on"); + return ret; + } + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + BoardConfigContainer::FrostChargeBehaviour fcb = + BoardConfigContainer::getFrostChargeBehaviourFromCommandString(value); + if (fcb != BoardConfigContainer::FrostChargeBehaviour::REDUCE_UNKNOWN) { + cfg.setFrostChargeBehaviour(fcb); + snprintf(ret, sizeof(ret), "Fmax charge current set to %s of imax", + BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour())); + } 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) { + // imax is a gate quantity — the write goes through, the override is + // re-derived, and a state change is reported (same pattern as batcap). + // The stored wish survives and re-arms once the gate passes again. + bool wasActive = cfg.isJeitaIgnoreActive(); + cfg.setMaxChargeCurrent_mA(ma); + cfg.applyJeitaIgnore(); + if (wasActive && !cfg.isJeitaIgnoreActive()) { + snprintf(ret, sizeof(ret), "Max charge current set to %s; jeitaignore N/A, C>0.05", + cfg.getChargeCurrentAsStr()); + } else if (!wasActive && cfg.isJeitaIgnoreActive()) { + snprintf(ret, sizeof(ret), "Max charge current set to %s; jeitaignore 1", + cfg.getChargeCurrentAsStr()); + } else { + snprintf(ret, sizeof(ret), "Max charge current set to %s", cfg.getChargeCurrentAsStr()); + } + return ret; + } + return "Err: Try 50-1500"; + } else if (strncmp(setCommand, "mppt ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + 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) { + cfg.setMPPTEnable(true); + snprintf(ret, sizeof(ret), "MPPT enabled"); + return ret; + } else if (strcmp(lowerValue, "false") == 0 || strcmp(lowerValue, "0") == 0) { + cfg.setMPPTEnable(false); + snprintf(ret, sizeof(ret), "MPPT disabled"); + return ret; + } + return "Err: Try true|false or 1|0"; + } else if (strncmp(setCommand, "batcap ", 7) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[7])); + float capacity_mah = atof(value); + bool wasActive = cfg.isJeitaIgnoreActive(); + if (cfg.setBatteryCapacity(capacity_mah)) { + // batcap is a gate quantity — re-derive and report a state change. + cfg.applyJeitaIgnore(); + if (wasActive && !cfg.isJeitaIgnoreActive()) { + snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh; jeitaignore N/A, C>0.05", + capacity_mah); + } else if (!wasActive && cfg.isJeitaIgnoreActive()) { + snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh; jeitaignore 1", + capacity_mah); + } else { + 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) { + // `set board.tccal` -> auto-read BME280 as reference + // `set board.tccal reset` -> reset to 0.00 + const char* rest = &setCommand[5]; + if (*rest == ' ') rest++; + const char* value = BoardConfigContainer::trim(const_cast(rest)); + + if (strcmp(value, "reset") == 0 || strcmp(value, "RESET") == 0) { + if (cfg.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; + } + + float bme_avg = 0.0f; + float new_offset = cfg.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) { + 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) { + cfg.setLEDsEnabled(enabled); + snprintf(ret, sizeof(ret), "LEDs %s (Heartbeat + BQ Stat)", + enabled ? "enabled" : "disabled"); + } else { + snprintf(ret, sizeof(ret), "Err: Use 'on/1' or 'off/0'"); + } + return ret; + } else if (strncmp(setCommand, "soc ", 4) == 0) { + 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; + } else if (strncmp(setCommand, "jeitaignore ", 12) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[12])); + bool on = (strcmp(value, "1") == 0 || strcmp(value, "true") == 0); + bool off = (strcmp(value, "0") == 0 || strcmp(value, "false") == 0); + if (!on && !off) { + return "Err: Use 1|0"; + } + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + return "Err: Set board.bat first"; + } + const auto* jiProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (jiProps && !jiProps->needs_jeita) { + return "Err: This chemistry runs without JEITA (always 1)"; + } + if (!cfg.setJeitaIgnoreWish(on)) { + return "Err: Failed to store setting"; + } + if (!on) { + snprintf(ret, sizeof(ret), "jeitaignore set to 0"); + } else if (cfg.isJeitaIgnoreActive()) { + snprintf(ret, sizeof(ret), "jeitaignore set to 1"); + } else { + // Wish stored, gate blocks it — name the blocker; re-arms on its own + // once imax/batcap pass. + snprintf(ret, sizeof(ret), "jeitaignore set to 1, N/A, %s", + cfg.isBatteryCapacitySet() ? "C>0.05" : "batcap not set"); + } + return ret; + } + + snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|batcap|tccal|leds|soc|jeitaignore"); + return ret; +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.h b/variants/inhero_mr2/helpers/CliCommands.h new file mode 100644 index 0000000000..979d6a06b2 --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.h @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +class BoardConfigContainer; + +namespace inhero { + +// Handles `get board.` queries. Writes formatted result into `reply`. +// Returns true if the command was recognised (always true currently — falls +// through to a usage hint on unknown commands). +bool handleGet(BoardConfigContainer& cfg, const char* cmd, char* reply, uint32_t maxlen); + +// Handles `set board. ` commands. Returns a pointer to a static +// reply buffer owned by the helper (caller must not free). +const char* handleSet(BoardConfigContainer& cfg, const char* setCommand); + +// Appends battery + solar telemetry to `lpp` starting at the next free channel. +// Returns false if telemetry data is unavailable (cfg.getTelemetryData() == nullptr). +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& lpp); + +// Parses a CayenneLPP buffer and returns highest_used_channel + 1, or 1 if empty. +uint8_t findNextFreeLppChannel(CayenneLPP& lpp); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/I2cBusRecovery.cpp b/variants/inhero_mr2/helpers/I2cBusRecovery.cpp new file mode 100644 index 0000000000..8e8dcc1a24 --- /dev/null +++ b/variants/inhero_mr2/helpers/I2cBusRecovery.cpp @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "I2cBusRecovery.h" + +#include +#include + +namespace inhero { + +void recoverI2cBus(uint8_t sda, uint8_t scl) { + pinMode(sda, INPUT_PULLUP); + pinMode(scl, OUTPUT); + digitalWrite(scl, HIGH); + + if (digitalRead(sda) == LOW) { + for (int i = 0; i < 9; i++) { + digitalWrite(scl, LOW); + delayMicroseconds(5); + digitalWrite(scl, HIGH); + delayMicroseconds(5); + if (digitalRead(sda) == HIGH) break; + } + // 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)"); + } + + pinMode(sda, INPUT); + pinMode(scl, INPUT); +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/I2cBusRecovery.h b/variants/inhero_mr2/helpers/I2cBusRecovery.h new file mode 100644 index 0000000000..c26907c6aa --- /dev/null +++ b/variants/inhero_mr2/helpers/I2cBusRecovery.h @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace inhero { + +// Manually toggles SCL (up to 9 clocks) to release a slave that holds SDA low +// after OTA/warm-reset. Generates a STOP after recovery. Wire.begin() cannot +// do this on its own. Pins are released back to INPUT before returning so the +// Wire library can take them over. +void recoverI2cBus(uint8_t sda, uint8_t scl); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Rv3028Wake.cpp b/variants/inhero_mr2/helpers/Rv3028Wake.cpp new file mode 100644 index 0000000000..d15c3c4e5d --- /dev/null +++ b/variants/inhero_mr2/helpers/Rv3028Wake.cpp @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "Rv3028Wake.h" + +#include "../InheroMr2Board.h" // RTC_I2C_ADDR + RV3028_REG_* + +#include +#include + +namespace inhero { + +void configurePeriodicWake(uint16_t minutes) { + uint16_t ticks = (minutes == 0) ? 1 : minutes; + if (ticks > 4095) ticks = 4095; // 12-bit register + + MESH_DEBUG_PRINTLN("PWRMGT: Configuring RTC wake in %u minutes", + static_cast(ticks)); + + // Per RV-3028 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 + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(0x00); // Clear TF + 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(ticks & 0xFF); + Wire.write((ticks >> 8) & 0x0F); + Wire.endTransmission(); + + // Step 3: Enable timer (1/60 Hz, single shot) + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x07); // TE=1, 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 + Wire.endTransmission(); + + MESH_DEBUG_PRINTLN("PWRMGT: RTC countdown configured (%u ticks at 1/60 Hz)", ticks); +} + +void clearTimerFlag() { + 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; // TF already clear + + status &= ~(1 << 3); + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); + Wire.endTransmission(); +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Rv3028Wake.h b/variants/inhero_mr2/helpers/Rv3028Wake.h new file mode 100644 index 0000000000..64028f9292 --- /dev/null +++ b/variants/inhero_mr2/helpers/Rv3028Wake.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace inhero { + +// Configures the RV-3028-C7 periodic countdown timer to fire after `minutes` +// at 1/60 Hz, single-shot, with TIE=1 so the INT pin asserts on expiry. +// `minutes` is clamped to [1, 4095] (12-bit timer register). +void configurePeriodicWake(uint16_t minutes); + +// Clears the RV-3028 Timer Flag (TF, status bit 3) without touching other bits. +// Read-modify-write: required because System Sleep wake is a reset, so the +// FALLING-edge ISR never sees the RTC event and TF stays latched. +void clearTimerFlag(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/SystemSleepGpio.cpp b/variants/inhero_mr2/helpers/SystemSleepGpio.cpp new file mode 100644 index 0000000000..a834190f93 --- /dev/null +++ b/variants/inhero_mr2/helpers/SystemSleepGpio.cpp @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "SystemSleepGpio.h" + +#include +#include +#include + +#include "../InheroMr2Board.h" +#include "../target.h" + +namespace inhero { + +void prepareRadioForSystemOff(bool radioInitialized) { + if (radioInitialized) { + // Cold sleep via SPI while SPIM is still active. + radio_driver.powerOff(); + delay(10); + } else { + // Early Boot: SPI/RadioLib not initialised. SX1262 may be in POR Standby RC + // (~600uA) or Cold Sleep (~160nA). Send SetSleep via bit-banged SPI to be sure. + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); + pinMode(P_LORA_SCLK, OUTPUT); + digitalWrite(P_LORA_SCLK, LOW); // CPOL=0 + pinMode(P_LORA_MOSI, OUTPUT); + digitalWrite(P_LORA_MOSI, LOW); + pinMode(P_LORA_BUSY, INPUT); + + // SX1262 §13.1.1: a wake-up NSS pulse is consumed; the actual command + // needs a SUBSEQUENT NSS falling edge. + digitalWrite(P_LORA_NSS, LOW); + delayMicroseconds(2); + uint32_t t0 = millis(); + while (digitalRead(P_LORA_BUSY) == HIGH && (millis() - t0) < 10) { + delayMicroseconds(100); + } + digitalWrite(P_LORA_NSS, HIGH); + delayMicroseconds(10); + + // SetSleep 0x84 0x00 (Cold Start, no retention, TCXO off). + static const uint8_t cmd[2] = { 0x84, 0x00 }; + + digitalWrite(P_LORA_NSS, LOW); + delayMicroseconds(2); + + 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); + } + + // PE4259 RF switch off + digitalWrite(SX126X_POWER_EN, LOW); + + // Latch SX1262 SPI pins at defined levels so floating CMOS inputs don't + // pull shoot-through current during System Sleep. + 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 HIGH + NRF_P1->OUTCLR = (1UL << 11) | (1UL << 12); // SCLK LOW, MOSI LOW + NRF_P1->PIN_CNF[10] = pin_cfg_out; + NRF_P1->PIN_CNF[11] = pin_cfg_out; + NRF_P1->PIN_CNF[12] = pin_cfg_out; +} + +void 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: skip BQ_CE_PIN (P0.04) and RTC_INT_PIN (P0.17) + for (uint8_t pin = 0; pin < 32; pin++) { + if (pin == 4 || pin == 17) continue; + NRF_P0->PIN_CNF[pin] = pin_cfg_discon; + } + // P1: skip SX1262 SPI pins latched by prepareRadioForSystemOff() + for (uint8_t pin = 0; pin < 16; pin++) { + if (pin == 10 || pin == 11 || pin == 12) continue; + NRF_P1->PIN_CNF[pin] = pin_cfg_discon; + } +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/SystemSleepGpio.h b/variants/inhero_mr2/helpers/SystemSleepGpio.h new file mode 100644 index 0000000000..be8e678378 --- /dev/null +++ b/variants/inhero_mr2/helpers/SystemSleepGpio.h @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// Puts the SX1262 into Cold Sleep and latches NSS/SCK/MOSI to defined levels +// before nRF52 System Sleep. +// radioInitialized=true -> uses RadioLib (radio_driver.powerOff()). +// radioInitialized=false -> bit-bangs SetSleep on P_LORA_* directly. +// SPI.end() must NOT be called: floating SCK during the disconnect window +// re-wakes the SX1262 (~600uA Standby RC). +void prepareRadioForSystemOff(bool radioInitialized = true); + +// Resets every GPIO to INPUT_DISCONNECT/PULL_DISABLED except the few pins +// the design must keep alive across System Sleep: +// P0.04 (BQ_CE_PIN) -> OUTPUT HIGH (charging stays enabled) +// P0.17 (RTC_INT_PIN) -> INPUT_PULLUP + SENSE_Low (wake source) +// P1.10/11/12 -> latched by prepareRadioForSystemOff() (NSS/SCK/MOSI) +// Must run after Wire.end() and after prepareRadioForSystemOff(). +void disconnectLeakyPullups(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/UsbAutoManagement.cpp b/variants/inhero_mr2/helpers/UsbAutoManagement.cpp new file mode 100644 index 0000000000..e4fb5ea603 --- /dev/null +++ b/variants/inhero_mr2/helpers/UsbAutoManagement.cpp @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "UsbAutoManagement.h" + +#include +#include +#include + +#include "../BoardConfigContainer.h" + +namespace inhero { + +// USB starts enabled (Serial.begin in main) +static bool s_usbActive = true; + +bool isUsbPowered() { + return (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; +} + +void disableUsb() { + if (s_usbActive) { + Serial.end(); + NRF_USBD->ENABLE = 0; + s_usbActive = false; + BoardConfigContainer::setUsbConnected(false); + MESH_DEBUG_PRINTLN("USB disabled"); + } +} + +void enableUsb() { + if (!s_usbActive) { + NRF_USBD->ENABLE = 1; + Serial.begin(115200); + s_usbActive = true; + BoardConfigContainer::setUsbConnected(true); + MESH_DEBUG_PRINTLN("USB enabled"); + } +} + +void serviceUsbAutoManagement() { + // After Serial.end(), Serial.available() returns 0 and Serial.read() returns -1, + // so no serial guard is needed in the main loop. + if (!s_usbActive && isUsbPowered()) enableUsb(); + if (s_usbActive && !isUsbPowered()) disableUsb(); +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/UsbAutoManagement.h b/variants/inhero_mr2/helpers/UsbAutoManagement.h new file mode 100644 index 0000000000..e70303f03b --- /dev/null +++ b/variants/inhero_mr2/helpers/UsbAutoManagement.h @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// Manages the nRF52 USB peripheral based on VBUS presence so the device can +// safely run from battery without an enumerated USB host. Also keeps +// BoardConfigContainer's USB-connected state in sync (used for IINDPM). +bool isUsbPowered(); +void enableUsb(); +void disableUsb(); + +// Call from board tick(); enables/disables USB on VBUS edge. +void serviceUsbAutoManagement(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Watchdog.cpp b/variants/inhero_mr2/helpers/Watchdog.cpp new file mode 100644 index 0000000000..05c329d776 --- /dev/null +++ b/variants/inhero_mr2/helpers/Watchdog.cpp @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "Watchdog.h" + +#include +#include +#include + +namespace inhero { + +static bool s_wdtEnabled = false; + +void setupWatchdog(bool blinkLed) { +#ifndef DEBUG_MODE + NRF_WDT->CONFIG = (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos) | + (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos); + NRF_WDT->CRV = 32768 * 600; // 600 s @ 32.768 kHz - long enough for OTA + NRF_WDT->RREN = WDT_RREN_RR0_Enabled << WDT_RREN_RR0_Pos; + NRF_WDT->TASKS_START = 1; + s_wdtEnabled = true; + MESH_DEBUG_PRINTLN("Watchdog enabled: 600s timeout"); + +#ifdef LED_BLUE + if (blinkLed) { + for (int i = 0; i < 3; i++) { + digitalWrite(LED_BLUE, HIGH); + delay(100); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + } +#else + (void)blinkLed; +#endif +#else + (void)blinkLed; + MESH_DEBUG_PRINTLN("Watchdog disabled (DEBUG_MODE)"); +#endif +} + +void feedWatchdog() { +#ifndef DEBUG_MODE + if (s_wdtEnabled) { + NRF_WDT->RR[0] = WDT_RR_RR_Reload; + } +#endif +} + +void disableWatchdog() { +#ifndef DEBUG_MODE + // nRF52 WDT cannot be stopped once started -- only stop feeding. + s_wdtEnabled = false; +#endif +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Watchdog.h b/variants/inhero_mr2/helpers/Watchdog.h new file mode 100644 index 0000000000..fb032b2c4d --- /dev/null +++ b/variants/inhero_mr2/helpers/Watchdog.h @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// nRF52 hardware watchdog wrappers. The nRF52 WDT cannot be stopped once +// started; disable() only stops the feed loop so the next CRV expiry resets +// the chip. All three are no-ops when DEBUG_MODE is defined. +// +// setupWatchdog(true) blinks LED_BLUE three times as visual confirmation. +void setupWatchdog(bool blinkLed); +void feedWatchdog(); +void disableWatchdog(); + +} // namespace inhero diff --git a/variants/inhero_mr2/lib/BqDriver.cpp b/variants/inhero_mr2/lib/BqDriver.cpp new file mode 100644 index 0000000000..057ba278f2 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -0,0 +1,586 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver Implementation + */ +#include "BqDriver.h" + +#include + +BqDriver::BqDriver() {} + +BqDriver::~BqDriver() { + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } +} + +// Initializes BQ25798 charger and creates dedicated I2C device for NTC access +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; +} + +// Reads Power Good status from charger — true if input power is 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; +} + +// Reads current charging state from charger +bq25798_charging_status BqDriver::getChargingStatus() { + Adafruit_BusIO_Register chrg_stat_1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_1); + + // Read with explicit error check. RegisterBits::read() would return bits of -1 + // on a failed I2C read — CHG_STAT[7:5] of 0xFF decodes as 0x07 = DONE_CHARGING, + // which the SOC logic treats as "battery full". + uint8_t reg_value = 0; + if (!chrg_stat_1_reg.read(®_value, 1)) { + return BQ25798_CHARGER_STATE_UNKNOWN; + } + + return (bq25798_charging_status)((reg_value >> 5) & 0x07); +} + +// 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. +// +// vbat_mv: battery voltage in mV from INA228 (0 = unknown, assume sufficient). +// Returns pointer to internal Telemetry struct (valid until next call). +const Telemetry* BqDriver::getTelemetryData(uint16_t vbat_mv) { + telemetryData = { 0 }; + + // The TS channel runs regardless of chemistry. A missing NTC then decodes + // through the RT2-only pole to a bogus ≈-46°C that slips past the open-pin + // check; the BME280 plausibility filter in + // BoardConfigContainer::getTelemetryData() discards such readings. + // + // The VBAT >= 3.2V requirement applies to battery-only operation (datasheet + // quote above). With an input source qualified the ADC runs off VBUS, so the + // channel stays on — that is exactly the case worth measuring: a cold, nearly + // empty cell being charged. Below the threshold and without an input the TS + // channel would stall the whole conversion, costing the solar readings too, + // so it is switched off there. + bool ts_enabled = true; + if (vbat_mv > 0 && vbat_mv < 3200 && !this->getChargerStatusPowerGood()) { + 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.battery.temperature = this->calculateBatteryTemp(getTS()); + } else { + // TS channel off — battery-only below 3.2V. No reading. + telemetryData.battery.temperature = -888.0f; + } + } else { + // ADC didn't complete — VBAT < 2.9V and no VBUS, or I2C issue + telemetryData.battery.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 +// ts_pct: voltage at TS pin in percentage of REGN (e.g., 70.5 for 70.5%). +// Special input values: -1.0 = I2C error, -2.0 = ADC not ready/invalid. +// Returns 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) +// Gets JEITA voltage setting for warm/cool regions +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; +} + +// Sets JEITA voltage setting for warm/cool temperature regions +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; +} + +// Gets JEITA current setting for hot region +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; +} + +// Sets JEITA current setting for hot temperature region +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; +} + +// Gets JEITA current setting for cold region +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; +} + +// Sets JEITA current setting for cold temperature region +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; +} + +// Gets TS Cool threshold (lower boundary of COOL region) +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; +} + +// Sets TS Cool threshold (lower boundary of COOL region) +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; +} + +// Gets TS Warm threshold (upper boundary of WARM region) +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; +} + +// Sets TS Warm threshold (upper boundary of WARM region) +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; +} + +// Gets BHOT threshold (upper limit for charging) +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; +} + +// Sets BHOT threshold (upper limit for charging) +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; +} + +// Gets BCOLD threshold (lower limit for charging) +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; +} + +// Sets BCOLD threshold (lower limit for charging) +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; +} + +// Gets TS ignore status (disables all temperature monitoring) +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(); +} + +// Sets TS ignore status (disables all temperature monitoring) +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; +} + +// 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. +// +// ts_enabled: true = enable TS channel (requires VBAT >= 3.2V per datasheet). +// Returns true if the I2C writes succeeded. +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 = 0x58; // Enable IBUS(7), VBUS(5), TS(2), TDIE(1) — disable rest + if (!ts_enabled) { + disable0 |= 0x04; // Also disable TS(2) → 0x5C + } + 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::getDieTemperature_C() { + Adafruit_BusIO_Register tdie_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_TDIE_ADC, 2, MSBFIRST); + uint16_t raw; + if (!tdie_reg.read(&raw)) { + return -999.0f; + } + return (int16_t)raw * 0.5f; // 2's complement, 0.5°C/LSB +} + +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); +} + + +// Gets EN_AUTO_IBATDIS state (auto battery discharge during VBAT_OVP; 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(); +} + +// Sets EN_AUTO_IBATDIS (auto battery discharge during VBAT_OVP). +// enable: true = BQ sinks 30mA from BAT during OVP, false = no active discharge. +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]; +} + +// Static, raw-Wire helpers — safe pre-begin(). +void BqDriver::maskAllInterrupts(TwoWire& wire, uint8_t addr) { + static const uint8_t mask_regs[] = {0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D}; + for (uint8_t r : mask_regs) { + wire.beginTransmission(addr); + wire.write(r); + wire.write(0xFF); + wire.endTransmission(); + } +} + +void BqDriver::clearInterruptFlags(TwoWire& wire, uint8_t addr) { + static const uint8_t flag_regs[] = {0x22, 0x23, 0x24, 0x25, 0x26, 0x27}; + for (uint8_t r : flag_regs) { + wire.beginTransmission(addr); + wire.write(r); + wire.endTransmission(false); + wire.requestFrom(addr, (uint8_t)1); + while (wire.available()) wire.read(); + } +} + +void BqDriver::disableAdc(TwoWire& wire, uint8_t addr) { + wire.beginTransmission(addr); + wire.write(0x2E); // ADC_CONTROL + wire.write(0x00); + wire.endTransmission(); +} diff --git a/variants/inhero_mr2/lib/BqDriver.h b/variants/inhero_mr2/lib/BqDriver.h new file mode 100644 index 0000000000..4c5ac42765 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver for Inhero MR2 + * 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 +// 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, see note above) + 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 battery; // 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_CHARGER_STATE_UNKNOWN = 0xFF // I2C read failed — status unavailable + +} 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; + +// 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); + + // Die temperature from the TDIE ADC (0.5°C/LSB, two's complement). The value + // stems from the last completed ADC one-shot (getTelemetryData), so it may be + // up to one telemetry period old. Returns -999.0 on I2C error. + float getDieTemperature_C(); + + // Read solar + temperature telemetry via BQ25798 ADC. + // vbat_mv: battery voltage from INA228 in mV, used to decide if the 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* 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); + + // Low-level BQ25798 housekeeping via raw TwoWire. These are safe to call + // before begin() — used on the low-voltage wake path where the driver + // instance has not been constructed yet. + + // Mask every charger- and fault-interrupt source (MASK regs 0x28..0x2D) so + // the INT line stays HIGH when INT is not wired to an MCU IRQ. Otherwise a + // pull-up on INT wastes current (~254µA with RAK4630 INPUT_PULLUP). + static void maskAllInterrupts(TwoWire& wire = Wire, uint8_t addr = 0x6B); + + // Read flag regs 0x22..0x27 (read-to-clear) to de-assert the INT line. + // Status regs 0x1B/0x20/0x21 must NOT be touched here — they are read-only. + static void clearInterruptFlags(TwoWire& wire = Wire, uint8_t addr = 0x6B); + + // Disable the BQ25798 ADC (saves ~500µA continuous draw). Used when entering + // System Sleep; re-enabled on wake by the driver's normal startup path. + static void disableAdc(TwoWire& wire = Wire, uint8_t addr = 0x6B); + + // 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..079a197497 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -0,0 +1,491 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver Implementation + */ + +#include "Ina228Driver.h" +#include // for MESH_DEBUG_PRINTLN + +Ina228Driver::Ina228Driver(uint8_t i2c_addr) + : _i2c_addr(i2c_addr), _shunt_mohm(0.0f), _current_lsb(0.0f), _base_shunt_cal(0) {} + +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 value: 0xFFCB + + // 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) + + writeRegister16(INA228_REG_SHUNT_CAL, _base_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) + } + + // Accept any non-error DEV_ID — some INA228 clones report 0x2281 instead of 0x228. + + 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); + + return vbus_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 = _base_shunt_cal; + 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; +} + + diff --git a/variants/inhero_mr2/lib/Ina228Driver.h b/variants/inhero_mr2/lib/Ina228Driver.h new file mode 100644 index 0000000000..838c578f65 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.h @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver for Inhero MR2 + * + * 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: + // i2c_addr default is for A0=A1=GND + Ina228Driver(uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + + // Initialize INA228 with default configuration. + // shunt_resistor_mohm is in milliohms; the MR2 board fits a 100mΩ shunt. + bool begin(float shunt_resistor_mohm); + + // Check if INA228 is present and responsive + bool isConnected(); + + // Reset INA228 to default values + void reset(); + + // Read battery voltage in mV + uint16_t readVoltage_mV(); + + // Read battery current in mA (+ = charging, - = discharging) + int16_t readCurrent_mA(); + + // Read battery current in mA with full float precision (+ = charging, - = discharging). + // ±1 LSB ≈ 3.125 µA. + float readCurrent_mA_precise(); + + // Read battery power in mW + int32_t readPower_mW(); + + // Read accumulated energy in mWh (Coulomb Counter) + int32_t readEnergy_mWh(); + + // Read accumulated charge in mAh (Coulomb Counter) + float readCharge_mAh(); + + // Read die temperature in °C + float readDieTemperature_C(); + + // Get all battery data in one call + bool readAll(Ina228BatteryData* data); + + // Reset Coulomb Counter (energy and charge accumulators) + void resetCoulombCounter(); + + // Read back SHUNT_CAL register value (diagnostic) + uint16_t readShuntCalRegister(); + + // Read back ADC_CONFIG register value (diagnostic) + uint16_t readAdcConfigRegister(); + + // Read back CONFIG register value (diagnostic) + uint16_t readConfigRegister(); + + // Validate SHUNT_CAL and repair if corrupted. + // Returns true if SHUNT_CAL is correct (or was repaired), false if repair failed. + bool validateAndRepairShuntCal(); + + // Set bus under-voltage alert threshold in mV (for UVLO; e.g., 3200 for Li-Ion) + bool setUnderVoltageAlert(uint16_t voltage_mv); + + // Set bus over-voltage alert threshold in mV + bool setOverVoltageAlert(uint16_t voltage_mv); + + // Enable alert output on ALERT pin. + // latch_alert: true to latch the alert until DIAG_ALRT is read. + void enableAlert(bool enable_uvlo = true, bool active_high = false, bool latch_alert = false); + + // Check if alert condition is active + bool isAlertActive(); + + // Clear alert flags + void clearAlert(); + + // Get diagnostic and alert register value. + // WARNING: In LATCH mode, reading DIAG_ALRT clears latched alert flags and de-asserts ALERT pin! + uint16_t getDiagnosticFlags(); + + // Read back raw BUVL threshold register value (multiply by 3.125 for mV) + uint16_t readBuvlRegister(); + + // Put INA228 into shutdown mode — disables all measurements and Coulomb Counter to save power. + // Returns true if shutdown confirmed via readback, false if write failed. + bool shutdown(); + + // Wake INA228 from shutdown mode (re-enables continuous measurement mode) + void wakeup(); + + // Read battery voltage in mV directly via I2C, without requiring driver initialization — + // for early boot use before the INA228 is initialized. Returns 0 if the read fails. + // Triggers a One-Shot ADC conversion; uses the high-precision 20-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; // SHUNT_CAL value, computed from fixed constants in begin() + + bool writeRegister16(uint8_t reg, uint16_t value); + uint16_t readRegister16(uint8_t reg); + + // Read 24-bit register (sign-extended to 32-bit) + int32_t readRegister24(uint8_t reg); + + // 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..f3c5cfaf8b --- /dev/null +++ b/variants/inhero_mr2/lib/SimplePreferences.h @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include + +using namespace Adafruit_LittleFS_Namespace; + +// Mini preferences library compatible with Arduino Preferences API +// Provides simple file-based key-value storage using LittleFS backend +class SimplePreferences { +private: + String _namespace; + bool _started = false; + + // 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 + size_t putString(const char* key, const char* value) { + if (!_started || value == nullptr) return 0; + + 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 accepts const char* directly — no extra copy + 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); + } + + // Read string value into the caller-provided 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); + } + + size_t bytesRead = file.readBytes(buffer, maxLen - 1); + buffer[bytesRead] = '\0'; // Set null-terminator manually + + // Trim trailing whitespace and newline characters + 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()); + } +}; diff --git a/variants/inhero_mr2/platformio.ini b/variants/inhero_mr2/platformio.ini new file mode 100644 index 0000000000..b182f0bf95 --- /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#01e8dc09 + 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..fc4d99e651 --- /dev/null +++ b/variants/inhero_mr2/target.cpp @@ -0,0 +1,55 @@ +#include +#include "target.h" +#include + +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; +AutoDiscoverRTCClock 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 + +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..7d5b473737 --- /dev/null +++ b/variants/inhero_mr2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#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 AutoDiscoverRTCClock 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..fed9cc7c6a --- /dev/null +++ b/variants/inhero_mr2/variant.cpp @@ -0,0 +1,48 @@ +/* + 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 +*/ + +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include "nrf.h" + +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 +}; + +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..0772894889 --- /dev/null +++ b/variants/inhero_mr2/variant.h @@ -0,0 +1,177 @@ +/* + 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 MR2 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) +// BQ25798 CE via DMN2004TK-7 FET — inverted: HIGH = charge enable, LOW = charge disable +#define BQ_CE_PIN (4) // P0.04 (WB_IO4) +// INA228 ALERT (active-low, open-drain) for low-voltage sleep trigger +#define INA_ALERT_PIN (34) // P1.02 (WB_IO2) + +#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 MR2 + +/* + * 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 From 0c948159f152546cafabcc141fc4259cd136cb77 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Tue, 15 Sep 2026 07:31:31 +0200 Subject: [PATCH 2/8] Fix MR2 undervoltage sleep after battery chemistry changes --- variants/inhero_mr2/BoardConfigContainer.cpp | 50 +++++++++++++++----- variants/inhero_mr2/BoardConfigContainer.h | 6 ++- variants/inhero_mr2/lib/Ina228Driver.cpp | 4 +- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp index b87c5c0400..184bbadd28 100644 --- a/variants/inhero_mr2/BoardConfigContainer.cpp +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -59,6 +59,7 @@ BqDriver* BoardConfigContainer::bqDriverInstance = nullptr; Ina228Driver* BoardConfigContainer::ina228DriverInstance = nullptr; TaskHandle_t BoardConfigContainer::heartbeatTaskHandle = NULL; volatile bool BoardConfigContainer::lowVoltageAlertFired = false; +uint16_t BoardConfigContainer::lowVoltageSleepMv = 0; MpptStatistics BoardConfigContainer::mpptStats = {}; BatterySOCStats BoardConfigContainer::socStats = {}; BoardConfigContainer::BatteryType BoardConfigContainer::cachedBatteryType = BAT_UNKNOWN; @@ -628,7 +629,7 @@ bool BoardConfigContainer::begin() { // 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(); + armLowVoltageAlert(getBatteryType()); // NOTE: Low-voltage recovery SOC=0% is handled in InheroMr2Board::begin() // (after setLowVoltageRecovery()), not here, because lowVoltageRecovery isn't set yet. @@ -1223,7 +1224,8 @@ bool BoardConfigContainer::setBatteryType(BatteryType type) { // === CRITICAL: Update INA228 low-voltage alert threshold when battery type changes === if (ina228DriverInstance) { - armLowVoltageAlert(); + // Preferences still contain the previous chemistry until the write below. + armLowVoltageAlert(type); delay(10); } @@ -1752,42 +1754,47 @@ float BoardConfigContainer::readBmeTemperature() { // Arm INA228 BUVL alert at the chemistry's lowv_sleep_mv threshold. // Fires → ISR → flag → tickPeriodic() → System Sleep. BAT_UNKNOWN = disabled. -void BoardConfigContainer::armLowVoltageAlert() { +void BoardConfigContainer::armLowVoltageAlert(BatteryType bat_type) { + disarmLowVoltageAlert(); 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; } + // Keep the software check active even if configuring the hardware alert fails. + lowVoltageSleepMv = sleep_mv; + lastLowVoltageMs = millis(); 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); + // A latched alert may already be LOW before the falling-edge ISR is attached. + if (digitalRead(INA_ALERT_PIN) == LOW) { + lowVoltageAlertFired = true; + } MESH_DEBUG_PRINTLN("INA228 Low-V Alert: ARMED @ %dmV (BUVL write %s)", sleep_mv, buvl_ok ? "OK" : "FAILED"); } void BoardConfigContainer::disarmLowVoltageAlert() { + detachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN)); + lowVoltageAlertFired = false; + lowVoltageSleepMv = 0; 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"); } @@ -2139,7 +2146,7 @@ void BoardConfigContainer::calculateTTL() { // Called from InheroMr2Board::tick() — dispatches all periodic I2C work with // millis()-based scheduling in the main loop context. -// Also checks the ISR-set lowVoltageAlertFired flag for immediate shutdown. +// Checks the INA228 alert and polls voltage as a fallback if the interrupt is missed. void BoardConfigContainer::tickPeriodic() { // First-call init: clear MPPT stats if (!tickInitialized) { @@ -2147,7 +2154,26 @@ void BoardConfigContainer::tickPeriodic() { tickInitialized = true; } - // Check low-voltage alert flag (set by INA228 ALERT ISR) + uint32_t now = millis(); + + if (lowVoltageSleepMv != 0 && ina228DriverInstance) { + if (digitalRead(INA_ALERT_PIN) == LOW) { + lowVoltageAlertFired = true; + } + + // Use the INA228's averaged voltage, independent of SOC and CLI requests. + if (!lowVoltageAlertFired && now - lastLowVoltageMs >= 1000UL) { + lastLowVoltageMs = now; + uint16_t vbat_mv = ina228DriverInstance->readVoltage_mV(); + // A failed I2C read returns 0; it is not a valid battery voltage sample. + if (vbat_mv > 0 && vbat_mv < lowVoltageSleepMv) { + MESH_DEBUG_PRINTLN("PWRMGT: VBAT %dmV below %dmV (voltage fallback)", vbat_mv, lowVoltageSleepMv); + lowVoltageAlertFired = true; + } + } + } + + // Check low-voltage alert flag (set by ISR, pin level, or voltage fallback) if (lowVoltageAlertFired) { MESH_DEBUG_PRINTLN("PWRMGT: Low-voltage alert fired - initiating System Sleep"); blinkRed(1, 100, 100, leds_enabled); @@ -2158,8 +2184,6 @@ void BoardConfigContainer::tickPeriodic() { // Never returns } - uint32_t now = millis(); - // Every ~60s: MPPT cycle (solar charging control) if (now - lastMpptMs >= SOLAR_MPPT_INTERVAL_MS) { lastMpptMs = now; diff --git a/variants/inhero_mr2/BoardConfigContainer.h b/variants/inhero_mr2/BoardConfigContainer.h index 40b1543a61..7f200f938d 100644 --- a/variants/inhero_mr2/BoardConfigContainer.h +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -260,7 +260,7 @@ class BoardConfigContainer { bool applyJeitaIgnore(); // re-derive for the current chemistry // INA228 ALERT on P1.02 (Rev 1.1) - void armLowVoltageAlert(); + void armLowVoltageAlert(BatteryType type); static void disarmLowVoltageAlert(); static void lowVoltageAlertISR(); @@ -280,10 +280,12 @@ class BoardConfigContainer { static Ina228Driver* ina228DriverInstance; static TaskHandle_t heartbeatTaskHandle; static volatile bool lowVoltageAlertFired; // INA228 ALERT fired, checked in tickPeriodic + static uint16_t lowVoltageSleepMv; // Active threshold; 0 when disarmed // Tick scheduling (millis-based, overflow-safe) uint32_t lastMpptMs = 0; uint32_t lastSocMs = 0; + uint32_t lastLowVoltageMs = 0; uint32_t lastHourlyMs = 0; // Last updateHourlyStats() execution bool tickInitialized = false; // First-call init flag for MPPT stats @@ -342,4 +344,4 @@ class BoardConfigContainer { 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/lib/Ina228Driver.cpp b/variants/inhero_mr2/lib/Ina228Driver.cpp index 079a197497..c327cff28b 100644 --- a/variants/inhero_mr2/lib/Ina228Driver.cpp +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -445,7 +445,9 @@ uint16_t Ina228Driver::readRegister16(uint8_t reg) { int32_t Ina228Driver::readRegister24(uint8_t reg) { Wire.beginTransmission(_i2c_addr); Wire.write(reg); - Wire.endTransmission(false); + if (Wire.endTransmission(false) != 0) { + return 0; // Register selection failed; do not read from a stale register pointer. + } Wire.requestFrom(_i2c_addr, (uint8_t)3); if (Wire.available() < 3) { From 2406d6f7c06dd94fb4a88eb616b1cf367c62fdd5 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Sun, 20 Sep 2026 14:46:51 +0200 Subject: [PATCH 3/8] Update MR2 solar recovery and keep QNH in the variant --- variants/inhero_mr2/BoardConfigContainer.cpp | 114 +++++++++++++----- variants/inhero_mr2/BoardConfigContainer.h | 23 +++- variants/inhero_mr2/InheroMr2Board.cpp | 56 ++++++--- .../inhero_mr2/InheroMr2SensorManager.cpp | 78 ++++++++++++ variants/inhero_mr2/InheroMr2SensorManager.h | 19 +++ variants/inhero_mr2/README.md | 15 ++- .../inhero_mr2/helpers/BqLowPowerSetup.cpp | 71 +++++++++++ variants/inhero_mr2/helpers/BqLowPowerSetup.h | 4 + variants/inhero_mr2/helpers/CliCommands.cpp | 57 +++++++-- variants/inhero_mr2/lib/BqDriver.cpp | 107 +++++++++++----- variants/inhero_mr2/lib/BqDriver.h | 7 +- variants/inhero_mr2/lib/SimplePreferences.h | 7 ++ variants/inhero_mr2/platformio.ini | 1 - variants/inhero_mr2/target.cpp | 4 +- variants/inhero_mr2/target.h | 4 +- 15 files changed, 457 insertions(+), 110 deletions(-) create mode 100644 variants/inhero_mr2/InheroMr2SensorManager.cpp create mode 100644 variants/inhero_mr2/InheroMr2SensorManager.h diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp index 184bbadd28..5686a5773e 100644 --- a/variants/inhero_mr2/BoardConfigContainer.cpp +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -20,9 +20,7 @@ #include "helpers/Watchdog.h" #include // For NRF_POWER (GPREGRET2) -#if ENV_INCLUDE_BME280 #include -#endif // rtc_clock is defined in target.cpp extern AutoDiscoverRTCClock rtc_clock; @@ -76,17 +74,16 @@ uint32_t BoardConfigContainer::lastTempUpdateMs = 0; // 0 = never updated // PG-Stuck recovery: timestamp of last HIZ toggle (0 = never) static uint32_t lastPgStuckToggleTime = 0; +static uint32_t lastPgStuckAttemptTime = 0; +static bool pgStuckRecoveryAttempted = false; #define PG_STUCK_COOLDOWN_MS (5 * 60 * 1000) // 5 minutes between toggles void BoardConfigContainer::setupWatchdog() { inhero::setupWatchdog(leds_enabled); } void BoardConfigContainer::feedWatchdog() { inhero::feedWatchdog(); } void BoardConfigContainer::disableWatchdog() { inhero::disableWatchdog(); } -// Re-enables MPPT if BQ25798 disabled it (e.g., during !PG state). -// BQ25798 does not persist MPPT=1 and automatically sets MPPT=0 when PG=0; -// this restores MPPT=1 when PG returns to 1. -// Only runs when PowerGood=1 to avoid false positives; exception: PG-stuck -// recovery toggles HIZ when VBUS is present but PG=0. +// Retry source qualification at !PG without relying on ADC availability. +// With PG set, restore configured MPPT if the charger disabled it. void BoardConfigContainer::checkAndFixSolarLogic() { if (!bqDriverInstance) return; @@ -96,16 +93,21 @@ void BoardConfigContainer::checkAndFixSolarLogic() { 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); + uint8_t mpptVal; + if (bqDriverInstance->readReg(0x15, mpptVal) && (mpptVal & 0x01) && + bqDriverInstance->writeReg(0x15, mpptVal & ~0x01)) { MESH_DEBUG_PRINTLN("MPPT disabled via config"); } return; } - // Check if PowerGood is currently set - bool powerGood = bqDriverInstance->getChargerStatusPowerGood(); + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + if (!props || !props->charge_enable) return; + + // A missing/unpowered BQ must not be mistaken for PG=0. + uint8_t status; + if (!bqDriverInstance->readReg(0x1B, status)) return; + bool powerGood = (status & 0x08) != 0; if (!powerGood) { // PG-Stuck recovery: Panel may be connected but BQ didn't qualify it. @@ -113,26 +115,36 @@ void BoardConfigContainer::checkAndFixSolarLogic() { // 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) { + if (pgStuckRecoveryAttempted && (now - lastPgStuckAttemptTime) < 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); + lastPgStuckAttemptTime = now; + pgStuckRecoveryAttempted = true; + + // Low VBAT + HIZ can leave ADC unavailable. Let the BQ qualify the + // source itself, including at night or with a weak panel. + uint8_t control; + if (!bqDriverInstance->readReg(BQ25798_REG_CHARGER_CONTROL_0, control)) return; + if (!bqDriverInstance->writeReg(BQ25798_REG_CHARGER_CONTROL_0, control | 0x04)) return; + delay(50); + for (int retry = 0; retry < 3; ++retry) { + // Re-read to preserve any control bits changed by the charger. + if (bqDriverInstance->readReg(BQ25798_REG_CHARGER_CONTROL_0, control) && + bqDriverInstance->writeReg(BQ25798_REG_CHARGER_CONTROL_0, control & ~0x04)) { + lastPgStuckToggleTime = now; + MESH_DEBUG_PRINTLN("PG recovery: PG=0, toggled HIZ"); + break; + } + delay(10); } return; } // Re-enable MPPT when PGOOD=1 - uint8_t mpptVal = bqDriverInstance->readReg(0x15); - - if ((mpptVal & 0x01) == 0) { - bqDriverInstance->writeReg(0x15, mpptVal | 0x01); + uint8_t mpptVal; + if (bqDriverInstance->readReg(0x15, mpptVal) && !(mpptVal & 0x01) && + bqDriverInstance->writeReg(0x15, mpptVal | 0x01) && + bqDriverInstance->readReg(0x15, mpptVal) && (mpptVal & 0x01)) { MESH_DEBUG_PRINTLN("MPPT re-enabled via register"); } } @@ -874,6 +886,50 @@ bool BoardConfigContainer::loadMpptEnabled(bool& enabled) { return false; } +bool BoardConfigContainer::loadStationAltitude(float& altitude_m) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[24]; + if (prefs.getString(ALTITUDEKEY, buffer, sizeof(buffer), "") == 0) { + return false; + } + + char* end = nullptr; + const float value = strtof(buffer, &end); + if (end == buffer || *end != '\0' || !isfinite(value) || + value < MIN_STATION_ALTITUDE_M || value > MAX_STATION_ALTITUDE_M) { + return false; + } + + altitude_m = value; + return true; +} + +bool BoardConfigContainer::getStationAltitude(float& altitude_m) const { + return loadStationAltitude(altitude_m); +} + +bool BoardConfigContainer::setStationAltitude(float altitude_m) { + if (!isfinite(altitude_m) || altitude_m < MIN_STATION_ALTITUDE_M || + altitude_m > MAX_STATION_ALTITUDE_M) { + return false; + } + + char buffer[24]; + snprintf(buffer, sizeof(buffer), "%.1f", altitude_m); + + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + return prefs.putString(ALTITUDEKEY, buffer) > 0; +} + +bool BoardConfigContainer::clearStationAltitude() { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + return prefs.remove(ALTITUDEKEY); +} + // Returns combined telemetry from INA228 (battery) and BQ25798 (solar + temperature). // Battery voltage/current come from the INA228 (20-bit ADC, ±0.1% accuracy); // solar data and battery temperature from the BQ25798 ADC. @@ -1150,7 +1206,8 @@ bool BoardConfigContainer::applyJeitaIgnore() { } // Derives the effective JEITA override and programs the BQ: -// chemistry needs no JEITA (LTO, Na-ion, UNKNOWN) → forced on +// chemistry runs without JEITA (LTO, Na-ion, UNKNOWN) → forced on; for +// Na-ion the cell datasheet sets the charge window, the board does not // otherwise → user wish AND 0.05C gate // TS_IGNORE stops the BQ's temperature regulation permanently — deliberately // including SYSTEMOFF sleep. Turning the override off restores the stored @@ -1727,7 +1784,6 @@ float BoardConfigContainer::performTcCalibration(float* bme_temp_out) { // Read BME280 temperature directly via I2C (temporary instance, no core code changes) 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"); @@ -1746,10 +1802,6 @@ float BoardConfigContainer::readBmeTemperature() { 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 } // Arm INA228 BUVL alert at the chemistry's lowv_sleep_mv threshold. diff --git a/variants/inhero_mr2/BoardConfigContainer.h b/variants/inhero_mr2/BoardConfigContainer.h index 7f200f938d..f9ee8cbe11 100644 --- a/variants/inhero_mr2/BoardConfigContainer.h +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -97,9 +97,13 @@ class BoardConfigContainer { uint16_t lowv_sleep_mv; // INA228 ALERT → System Sleep uint16_t lowv_wake_mv; // 0% SOC marker, RTC wake decision bool charge_enable; - // Whether the chemistry needs JEITA temperature supervision. false (LTO, - // Na-ion) forces the JEITA override on; true (Li-ion, LiFePO4) leaves it - // off unless the user sets board.jeitaignore and passes the 0.05C gate. + // Whether the board supervises the charge temperature for this chemistry. + // false forces the JEITA override on: LTO because its anode sits far above + // the plating potential, Na-ion by product decision — the permissible charge + // window differs per cell (0 to -20 C by manufacturer) and is left to the + // cell datasheet, not enforced here. true (Li-ion, LiFePO4) leaves the + // override off unless the user sets board.jeitaignore and passes the 0.05C + // gate. bool needs_jeita; // Capacity derating at cold temps. Calibrated for ~0.01C avg / ~0.05C TX // peak loads on 2–8 Ah cells (much milder than datasheet 0.2C–0.5C values). @@ -151,6 +155,8 @@ class BoardConfigContainer { 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; + static constexpr float MIN_STATION_ALTITUDE_M = -500.0f; + static constexpr float MAX_STATION_ALTITUDE_M = 9000.0f; // IINDPM = 1.2 × (V_charge × I_charge) / V_panel_assumed. // Prevents weak panels from tripping POORSRC after PG qualification. @@ -159,9 +165,6 @@ class BoardConfigContainer { static constexpr float IINDPM_PANEL_V = 4.0f; static constexpr float IINDPM_MARGIN = 1.2f; - // If PG=0 but VBUS >= this, 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); @@ -202,6 +205,12 @@ class BoardConfigContainer { bool getMPPTEnabled() const; bool setMPPTEnable(bool enableMPPT); + // Installation altitude used to reduce BME280 station pressure to QNH. + // Returns false when the operator has not configured an altitude yet. + bool getStationAltitude(float& altitude_m) const; + bool setStationAltitude(float altitude_m); + bool clearStationAltitude(); + float getMaxChargeVoltage() const; bool begin(); @@ -328,6 +337,7 @@ class BoardConfigContainer { static constexpr const char* BATTERY_CAPACITY_KEY = "batCap"; static constexpr const char* TCCAL_KEY = "tcCal"; // NTC temperature calibration offset static constexpr const char* JEITAIGNKEY = "jeitaIgn"; // JEITA override user wish + static constexpr const char* ALTITUDEKEY = "altitude"; // BME280 installation altitude (m) bool applyJeitaIgnore(const BatteryProperties* props); // derive + program TS_IGNORE/ISETC/ISETH bool loadJeitaIgnoreWish(bool& on) const; @@ -336,6 +346,7 @@ class BoardConfigContainer { bool loadMaxChrgI(uint16_t& maxCharge_mA) const; bool loadBatteryCapacity(float& capacity_mah) const; bool loadTcCalOffset(float& offset) const; // NTC temperature calibration + bool loadStationAltitude(float& altitude_m) const; // MPPT Statistics helper static void updateMpptStats(); diff --git a/variants/inhero_mr2/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp index d14bea1a29..0fbecc92b7 100644 --- a/variants/inhero_mr2/InheroMr2Board.cpp +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -26,6 +26,18 @@ static BoardConfigContainer boardConfig; volatile bool InheroMr2Board::rtc_irq_pending = false; volatile uint32_t InheroMr2Board::ota_dfu_reset_at = 0; +// CE output is retained during SYSTEMOFF, but GPIO configuration resets on wake. +// Restore only the charge permission from the stored chemistry (unknown = off). +static bool restoreConfiguredChargeEnable() { + const auto* props = BoardConfigContainer::getBatteryProperties(boardConfig.getBatteryType()); + bool enabled = props && props->charge_enable; +#ifdef BQ_CE_PIN + digitalWrite(BQ_CE_PIN, enabled ? HIGH : LOW); + pinMode(BQ_CE_PIN, OUTPUT); +#endif + return enabled; +} + // ===== Public Methods ===== void InheroMr2Board::begin() { @@ -66,14 +78,10 @@ void InheroMr2Board::begin() { // 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 + bool chargeEnabled = restoreConfiguredChargeEnable(); + if (chargeEnabled) { + inhero::maintainSolarDuringLowVoltageWake(boardConfig.getMPPTEnabled()); + } // Put INA228 + BQ25798 into a state that draws minimal current during System Sleep. inhero::prepareIcsForSystemOff(); @@ -233,10 +241,8 @@ void InheroMr2Board::begin() { Wire.requestFrom((uint8_t)INA228_I2C_ADDR, (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 + // Hold CE according to the configured chemistry during sleep + restoreConfiguredChargeEnable(); // BQ25798 — Disable ADC (saves ~500µA continuous draw) Wire.beginTransmission(BQ25798_I2C_ADDR); @@ -293,6 +299,15 @@ void InheroMr2Board::begin() { MESH_DEBUG_PRINTLN("Initializing Rev 1.1 features (BQ25798, INA228, RTC, CE-FET)"); boardConfig.begin(); + float station_altitude_m = NAN; + if (boardConfig.getStationAltitude(station_altitude_m)) { + sensors.setBme280StationAltitude(station_altitude_m); + MESH_DEBUG_PRINTLN("BME280 pressure correction: QNH at %.1f m", station_altitude_m); + } else { + sensors.setBme280StationAltitude(NAN); + MESH_DEBUG_PRINTLN("BME280 pressure correction: disabled (station pressure)"); + } + // Handle low-voltage recovery (deferred until after boardConfig.begin()) if (isLowVoltageRecovery) { boardConfig.setLowVoltageRecovery(); @@ -421,13 +436,17 @@ bool InheroMr2Board::queryBoardTelemetry(CayenneLPP& telemetry) { bool InheroMr2Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { const uint32_t maxlen = 160; - if (memcmp(command, "get board.", 10) == 0) { + if (strncmp(command, "get board.", 10) == 0) { return inhero::handleGet(boardConfig, &command[10], reply, maxlen); } - if (memcmp(command, "set board.", 10) == 0) { + if (strncmp(command, "set board.", 10) == 0) { const char* result = inhero::handleSet(boardConfig, &command[10]); if (result == NULL) return false; + float station_altitude_m = NAN; + sensors.setBme280StationAltitude( + boardConfig.getStationAltitude(station_altitude_m) ? station_altitude_m : NAN); + strncpy(reply, result, maxlen - 1); reply[maxlen - 1] = 0; return true; @@ -486,14 +505,11 @@ void InheroMr2Board::initiateShutdown(uint8_t reason) { delay(100); // Allow I/O to complete - // 5. Latch BQ CE pin HIGH (FET ON = CE LOW = charge enabled) + // 5. Hold the configured CE level (HIGH enables charging via FET) // 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 + restoreConfiguredChargeEnable(); - // 5b. INA228 + BQ25798 \u2192 minimum sleep current. Must be AFTER CE=HIGH + // 5b. INA228 + BQ25798 -> minimum sleep current. Must be AFTER CE restore // (charge enable may re-enable BQ ADC). Repeats INA228 shutdown via raw I2C // with readback as a safety net if the driver call in step 2 silently failed. inhero::prepareIcsForSystemOff(); diff --git a/variants/inhero_mr2/InheroMr2SensorManager.cpp b/variants/inhero_mr2/InheroMr2SensorManager.cpp new file mode 100644 index 0000000000..3b8bae7256 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2SensorManager.cpp @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "InheroMr2SensorManager.h" + +#include +#include +#include + +#ifndef TELEM_BME280_ADDRESS +#define TELEM_BME280_ADDRESS 0x76 +#endif + +namespace { +Adafruit_BME280 bme280; +float station_altitude_m = NAN; + +void queryBme280(uint8_t channel, uint8_t, CayenneLPP& telemetry) { + if (!bme280.takeForcedMeasurement()) return; + + telemetry.addTemperature(channel, bme280.readTemperature()); + telemetry.addRelativeHumidity(channel, bme280.readHumidity()); + const float pressure_hpa = bme280.readPressure() / 100.0f; + if (isnan(station_altitude_m)) { + telemetry.addBarometricPressure(channel, pressure_hpa); + telemetry.addAltitude(channel, bme280.readAltitude(1013.25f)); + } else { + telemetry.addBarometricPressure( + channel, InheroMr2SensorManager::pressureToQnh(pressure_hpa, station_altitude_m)); + telemetry.addAltitude(channel, station_altitude_m); + } +} +} + +bool InheroMr2SensorManager::begin() { + // The base resets the registry and retains optional GPS and other sensors. + if (!EnvironmentSensorManager::begin()) return false; + if (_active_sensor_count >= MAX_ACTIVE_SENSORS) return true; + +#if ENV_PIN_SDA && ENV_PIN_SCL + TwoWire* wire = &Wire1; +#else + TwoWire* wire = &Wire; +#endif + const uint8_t addresses[] = { + TELEM_BME280_ADDRESS, TELEM_BME280_ADDRESS == 0x77 ? 0x76 : 0x77 + }; + for (uint8_t address : addresses) { + wire->beginTransmission(address); + if (wire->endTransmission() != 0) continue; + if (!bme280.begin(address, wire)) continue; + + bme280.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); + _active_sensors[_active_sensor_count++] = { queryBme280, 0 }; + MESH_DEBUG_PRINTLN("Found MR2 BME280 at address: %02X", address); + break; // One physical BME280; the second address is only a fallback. + } + return true; +} + +void InheroMr2SensorManager::setBme280StationAltitude(float altitude_m) { + station_altitude_m = altitude_m; +} + +float InheroMr2SensorManager::getBme280StationAltitude() { + return station_altitude_m; +} + +float InheroMr2SensorManager::pressureToQnh(float pressure_hpa, float altitude_m) { + // ICAO standard atmosphere, matching Adafruit's seaLevelForAltitude(). + return pressure_hpa / powf(1.0f - altitude_m / 44330.0f, 5.255f); +} diff --git a/variants/inhero_mr2/InheroMr2SensorManager.h b/variants/inhero_mr2/InheroMr2SensorManager.h new file mode 100644 index 0000000000..ac2b4310f7 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2SensorManager.h @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +class InheroMr2SensorManager : public EnvironmentSensorManager { +public: + using EnvironmentSensorManager::EnvironmentSensorManager; + + bool begin() override; + + // NAN keeps station pressure and the pressure-derived altitude. + static void setBme280StationAltitude(float altitude_m); + static float getBme280StationAltitude(); + static float pressureToQnh(float pressure_hpa, float altitude_m); +}; diff --git a/variants/inhero_mr2/README.md b/variants/inhero_mr2/README.md index b6dc0e6c9f..5c62f643fe 100644 --- a/variants/inhero_mr2/README.md +++ b/variants/inhero_mr2/README.md @@ -5,6 +5,10 @@ buck/boost charger with universal 3.6–24 V solar input and MPPT, INA228 coulomb counter, RV-3028 RTC, BME280 environment sensor, 45 × 40 mm, CE-certified (RED 2014/53/EU). +Supports Li-ion, LiFePO4, LTO and Na-ion battery profiles, solar-input recovery +and RTC wakeup after low-voltage sleep. An optional installation altitude +(`set board.altitude `) enables QNH pressure telemetry from the BME280. + Build environments: ```bash @@ -13,8 +17,9 @@ pio run -e Inhero_MR2_repeater_bridge_rs232 pio run -e Inhero_MR2_sensor ``` -Full documentation (English and German) — quick start, datasheet, battery -chemistry guide, power management, telemetry, CLI reference, FAQ — is -maintained by the manufacturer at -, -also reachable via . +Full documentation — quick start, datasheet, battery chemistry guide, power +management, telemetry, CLI reference and FAQ — is maintained by the manufacturer: + +- [English documentation](https://docs.inhero.de/en/mr2/) +- [Deutsche Dokumentation](https://docs.inhero.de/mr2/) +- [Documentation sources in the vendor fork](https://github.com/liekmarflow/MeshCore/tree/main/variants/inhero_mr2/docs) diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp index 2b552e6a80..cfe8049f8a 100644 --- a/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp @@ -6,6 +6,7 @@ #include #include +#include #include "../InheroMr2Board.h" #include "../lib/BqDriver.h" @@ -14,6 +15,76 @@ namespace inhero { static constexpr uint8_t INA228_ADDR = 0x40; +namespace { +bool readBq(uint8_t reg, uint8_t* data, uint8_t count = 1) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)BQ25798_I2C_ADDR, count) != count) { + while (Wire.available()) Wire.read(); + return false; + } + for (uint8_t i = 0; i < count; ++i) data[i] = Wire.read(); + return true; +} + +bool writeBq(uint8_t reg, uint8_t value) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(reg); + Wire.write(value); + return Wire.endTransmission() == 0; +} +} + +void maintainSolarDuringLowVoltageWake(bool mpptEnabled) { + // The caller already checked charge_enable and restored the CE output. + if (!mpptEnabled) return; + + // At deep discharge the charger may be unpowered. Never use failed reads + // as status or ADC data, and never initialise/reset its retained settings. + Wire.beginTransmission(BQ25798_I2C_ADDR); + if (Wire.endTransmission() != 0) { + MESH_DEBUG_PRINTLN("LV-Wake: BQ unavailable, skipping solar maintenance"); + return; + } + + // Do not require an ADC conversion: low VBAT + HIZ can prevent it from + // starting. Source qualification itself rejects missing/weak input power. + uint8_t status; + if (!readBq(0x1B, &status)) return; + if (!(status & 0x08)) { + uint8_t control; + if (!readBq(BQ25798_REG_CHARGER_CONTROL_0, &control)) return; + if (!writeBq(BQ25798_REG_CHARGER_CONTROL_0, control | 0x04)) return; + delay(50); + // Retry clearing HIZ on transient bus errors so charging can resume. + bool cleared = false; + for (int retry = 0; retry < 3; ++retry) { + if (readBq(BQ25798_REG_CHARGER_CONTROL_0, &control) && + writeBq(BQ25798_REG_CHARGER_CONTROL_0, control & ~0x04)) { + cleared = true; + break; + } + delay(10); + } + if (!cleared) return; + MESH_DEBUG_PRINTLN("LV-Wake: PG=0, toggled HIZ"); + uint32_t start = millis(); + do { + delay(20); + if (!readBq(0x1B, &status)) return; + if (status & 0x08) break; + } while (millis() - start < 1000); + } + if (!(status & 0x08)) return; + uint8_t mppt; + if (readBq(0x15, &mppt) && !(mppt & 0x01)) { + // Below VSYSMIN the BQ may immediately reset EN_MPPT. Verify before logging. + if (writeBq(0x15, mppt | 0x01) && readBq(0x15, &mppt) && (mppt & 0x01)) + MESH_DEBUG_PRINTLN("LV-Wake: MPPT re-enabled"); + } +} + void prepareIcsForSystemOff() { // INA228 -> shutdown mode (~3.5uA vs ~350uA continuous). // I2C writes can fail silently -> retry with readback verification. diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.h b/variants/inhero_mr2/helpers/BqLowPowerSetup.h index 7c4efbc68f..84dd2576ed 100644 --- a/variants/inhero_mr2/helpers/BqLowPowerSetup.h +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.h @@ -13,4 +13,8 @@ namespace inhero { // Wire must be initialised before calling. void prepareIcsForSystemOff(); +// Bounded solar recovery before returning to UV sleep; no driver init required. +// Caller must check configured charge_enable. !PG retries do not require ADC/VBUS. +void maintainSolarDuringLowVoltageWake(bool mpptEnabled); + } // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp index ac850f1b00..55ac69106f 100644 --- a/variants/inhero_mr2/helpers/CliCommands.cpp +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -106,8 +106,10 @@ bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry) { } // Solar: VSOL[V], ISOL[A], MPPT_7D[%] - telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); - telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + if (telemetryData->solar.valid) { + telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); + telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + } telemetry.addPercentage(solarChannel, cfg.getMpptEnabledPercentage7Day()); return true; @@ -138,6 +140,14 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u } else if (strcmp(cmd, "mppt") == 0) { snprintf(reply, maxlen, "MPPT=%s", cfg.getMPPTEnabled() ? "1" : "0"); return true; + } else if (strcmp(cmd, "altitude") == 0) { + float altitude_m = 0.0f; + if (cfg.getStationAltitude(altitude_m)) { + snprintf(reply, maxlen, "%.1f m", altitude_m); + } else { + snprintf(reply, maxlen, "N/A (station pressure)"); + } + return true; } else if (strcmp(cmd, "stats") == 0) { const BatterySOCStats* socStats = cfg.getSOCStats(); if (!socStats) { @@ -222,6 +232,13 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u 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); + char solar_str[32]; + if (telemetry->solar.valid) { + snprintf(solar_str, sizeof(solar_str), "%.2fV/%s", telemetry->solar.voltage / 1000.0f, sol_current_str); + } else { + snprintf(solar_str, sizeof(solar_str), "N/A"); + } + char temp_str[8]; if (telemetry->battery.temperature <= -100.0f) { snprintf(temp_str, sizeof(temp_str), "N/A"); @@ -237,18 +254,18 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u 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", + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% (%.0f%%) S:%s", telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, - soc, derated_soc, telemetry->solar.voltage / 1000.0f, sol_current_str); + soc, derated_soc, solar_str); } else { - snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% S:%.2fV/%s", + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% S:%s", telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, - soc, telemetry->solar.voltage / 1000.0f, sol_current_str); + soc, solar_str); } } else { - snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:N/A S:%.2fV/%s", + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:N/A S:%s", telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, - telemetry->solar.voltage / 1000.0f, sol_current_str); + solar_str); } return true; } else if (strcmp(cmd, "conf") == 0) { @@ -306,7 +323,7 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u } snprintf(reply, maxlen, - "Err: bat|fmax|imax|mppt|telem|stats|cinfo|conf|tccal|leds|batcap|jeitaignore"); + "Err: bat|fmax|imax|mppt|altitude|telem|stats|cinfo|conf|tccal|leds|batcap|jeitaignore"); return true; } @@ -390,6 +407,26 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { return ret; } return "Err: Try true|false or 1|0"; + } else if (strncmp(setCommand, "altitude ", 9) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[9])); + if (strcmp(value, "clear") == 0) { + if (!cfg.clearStationAltitude()) { + return "Err: Failed to clear altitude"; + } + return "Altitude cleared (BME280 reports station pressure)"; + } + char* end = nullptr; + const float altitude_m = strtof(value, &end); + if (end == value || *end != '\0' || !isfinite(altitude_m) || + altitude_m < BoardConfigContainer::MIN_STATION_ALTITUDE_M || + altitude_m > BoardConfigContainer::MAX_STATION_ALTITUDE_M) { + return "Err: Try -500 to 9000 m"; + } + if (!cfg.setStationAltitude(altitude_m)) { + return "Err: Failed to store altitude"; + } + snprintf(ret, sizeof(ret), "Altitude set to %.1f m (BME280 pressure is now QNH)", altitude_m); + return ret; } else if (strncmp(setCommand, "batcap ", 7) == 0) { const char* value = BoardConfigContainer::trim(const_cast(&setCommand[7])); float capacity_mah = atof(value); @@ -485,7 +522,7 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { return ret; } - snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|batcap|tccal|leds|soc|jeitaignore"); + snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|altitude|batcap|tccal|leds|soc|jeitaignore"); return ret; } diff --git a/variants/inhero_mr2/lib/BqDriver.cpp b/variants/inhero_mr2/lib/BqDriver.cpp index 057ba278f2..d7f02d1c61 100644 --- a/variants/inhero_mr2/lib/BqDriver.cpp +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -68,7 +68,7 @@ bq25798_charging_status BqDriver::getChargingStatus() { // Reads solar and temperature telemetry via BQ25798 ADC one-shot // -// BQ25798 ADC Operating Conditions (Datasheet SLUSE22, Section 9.3.16): +// BQ25798 ADC Operating Conditions (SLUSDV2B 9.3.10 / SLUSDV2C 7.3.10): // "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 @@ -84,20 +84,24 @@ bq25798_charging_status BqDriver::getChargingStatus() { // 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. +// 3. Only channels used on MR2 are enabled (IBUS, VBUS, TDIE, optional TS). +// Other channels are disabled to avoid unnecessary conversion time. // // 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. +// have completed conversion. However, a rejected or interrupted conversion +// can also leave ADC_EN=0 without fresh data. Require ADC_DONE_FLAG as well. // // vbat_mv: battery voltage in mV from INA228 (0 = unknown, assume sufficient). // Returns pointer to internal Telemetry struct (valid until next call). const Telemetry* BqDriver::getTelemetryData(uint16_t vbat_mv) { telemetryData = { 0 }; + telemetryData.battery.temperature = -888.0f; + if (!ih_i2c_dev) return &telemetryData; + + Adafruit_BusIO_Register inputStatus(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_0); + uint8_t input; + if (!inputStatus.read(&input, 1)) return &telemetryData; // The TS channel runs regardless of chemistry. A missing NTC then decodes // through the RT2-only pole to a bogus ≈-46°C that slips past the open-pin @@ -111,23 +115,33 @@ const Telemetry* BqDriver::getTelemetryData(uint16_t vbat_mv) { // channel would stall the whole conversion, costing the solar readings too, // so it is switched off there. bool ts_enabled = true; - if (vbat_mv > 0 && vbat_mv < 3200 && !this->getChargerStatusPowerGood()) { + if (vbat_mv > 0 && vbat_mv < 3200 && !(input & 0x08)) { ts_enabled = false; // Disable TS → ADC threshold drops to 2.9V } bool success = this->startADCOneShot(ts_enabled); if (!success) { + setADCEnabled(false); return &telemetryData; } - // Poll ADC_EN bit until it auto-clears (conversion complete) or timeout. - // Channels: IBUS + VBUS (+ TS if enabled) → ~48-72ms typical. + // ADC_EN=0 alone does not prove that a conversion happened (e.g. low supply). + // Require a fresh ADC_DONE_FLAG, cleared before this one-shot was started. + // Channels: IBUS + VBUS + TDIE (+ TS if enabled) → ~72-96ms typical. const uint32_t ADC_TIMEOUT_MS = 250; uint32_t start = millis(); bool conversion_done = false; + bool doneSeen = false; + Adafruit_BusIO_Register adc_flags(ih_i2c_dev, 0x24); + Adafruit_BusIO_Register adc_control(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); while ((millis() - start) < ADC_TIMEOUT_MS) { - if (!this->getADCEnabled()) { + uint8_t control = 0, flags = 0; + if (!adc_control.read(&control, 1) || !adc_flags.read(&flags, 1)) { + break; + } + doneSeen = doneSeen || (flags & 0x20); + if (!(control & 0x80) && doneSeen) { conversion_done = true; break; } @@ -139,8 +153,14 @@ const Telemetry* BqDriver::getTelemetryData(uint16_t vbat_mv) { } if (conversion_done) { - telemetryData.solar.voltage = getVBUS(); - telemetryData.solar.current = getIBUS(); + Adafruit_BusIO_Register vbus(ih_i2c_dev, BQ25798_REG_VBUS_ADC, 2, MSBFIRST); + Adafruit_BusIO_Register ibus(ih_i2c_dev, BQ25798_REG_IBUS_ADC, 2, MSBFIRST); + uint16_t voltage = 0, current = 0; + if (vbus.read(&voltage) && ibus.read(¤t)) { + telemetryData.solar.voltage = voltage; + telemetryData.solar.current = (int16_t)current; + telemetryData.solar.valid = true; + } if (telemetryData.solar.current < 0) { telemetryData.solar.current = 0; } @@ -406,7 +426,7 @@ bool BqDriver::setTsIgnore(bool ignore) { // 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 1: TDIE → ENABLED (charger die temperature) // Bit 0: reserved // // Reg 0x30 (ADC_FUNCTION_DISABLE_1): all disabled on MR2 @@ -415,13 +435,30 @@ bool BqDriver::setTsIgnore(bool ignore) { // 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. +// Unused channels stay disabled to reduce conversion time. Supply availability +// still limits ADC operation; channel masks cannot compensate for an invalid supply. // -// ts_enabled: true = enable TS channel (requires VBAT >= 3.2V per datasheet). -// Returns true if the I2C writes succeeded. +// Release the input before a one-shot, without changing charge enable. +bool BqDriver::prepareADCInput() { + if (!ih_i2c_dev) return false; + Adafruit_BusIO_Register charger(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + uint8_t control; + if (!charger.read(&control, 1)) return false; + if (control & 0x04) { + // Preserve EN_CHG and all other settings; CE is controlled by board config. + if (!charger.write(control & ~0x04)) return false; + } + return true; +} + +// ts_enabled: true = enable TS (requires VBAT > 3.2V in battery-only operation). +// Success confirms setup/start register accesses, not conversion completion. bool BqDriver::startADCOneShot(bool ts_enabled) { + // Stop any previous conversion, then discard its read-to-clear done flag. + if (!setADCEnabled(false)) return false; + Adafruit_BusIO_Register adc_flags(ih_i2c_dev, 0x24); + uint8_t flags; + if (!adc_flags.read(&flags, 1)) return false; 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); @@ -436,9 +473,17 @@ bool BqDriver::startADCOneShot(bool ts_enabled) { // Reg 0x30: Disable all — D+(7), D-(6), VAC2(5), VAC1(4) not connected on MR2 if (!disable_reg_1.write(0xF0)) { return false; } + uint8_t mask0, mask1; + if (!disable_reg_0.read(&mask0, 1) || !disable_reg_1.read(&mask1, 1)) return false; + if (mask0 != disable0 || mask1 != 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; + // Release HIZ only after setup, immediately before ADC_EN. Waiting here can + // let source qualification reassert HIZ before our conversion even starts. + if (!prepareADCInput()) return false; + uint8_t control; + if (!adc_ctrl_reg.write(0xC0) || !adc_ctrl_reg.read(&control, 1)) return false; + return true; } // ADC Control register (0x2E) implementations @@ -451,9 +496,9 @@ bool BqDriver::getADCEnabled() { 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; + uint8_t control; + if (!adc_ctrl_reg.read(&control, 1)) return false; + return adc_ctrl_reg.write(enabled ? (control | 0x80) : (control & ~0x80)); } // ADC Reading implementations @@ -547,13 +592,13 @@ bool BqDriver::writeReg(uint8_t reg, uint8_t val) { } 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]; + uint8_t value = 0; + return readReg(reg, value) ? value : 0; +} + +bool BqDriver::readReg(uint8_t reg, uint8_t& val) { + if (!ih_i2c_dev) return false; + return ih_i2c_dev->write_then_read(®, 1, &val, 1); } // Static, raw-Wire helpers — safe pre-begin(). diff --git a/variants/inhero_mr2/lib/BqDriver.h b/variants/inhero_mr2/lib/BqDriver.h index 4c5ac42765..da819557b7 100644 --- a/variants/inhero_mr2/lib/BqDriver.h +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -30,6 +30,7 @@ typedef struct { uint16_t voltage; // Solar voltage in mV int16_t current; // Solar current in mA (approximate, see note above) int32_t power; // Solar power in mW + bool valid; // Fresh ADC conversion and successful U/I reads bool mppt; // MPPT enabled status } SolarData; @@ -181,7 +182,7 @@ class BqDriver : public Adafruit_BQ25798 { // Read solar + temperature telemetry via BQ25798 ADC. // vbat_mv: battery voltage from INA228 in mV, used to decide if the TS channel - // can be enabled (requires VBAT >= 3.2V without VBUS, per datasheet 9.3.16). + // can be enabled (requires VBAT > 3.2V without VBUS, per SLUSDV2B 9.3.10). // Pass 0 if unknown (assumes sufficient voltage). const Telemetry* getTelemetryData(uint16_t vbat_mv = 0); @@ -198,6 +199,7 @@ class BqDriver : public Adafruit_BQ25798 { // Non-static register access methods (use instance I2C config) bool writeReg(uint8_t reg, uint8_t val); uint8_t readReg(uint8_t reg); + bool readReg(uint8_t reg, uint8_t& val); // Low-level BQ25798 housekeeping via raw TwoWire. These are safe to call // before begin() — used on the low-voltage wake path where the driver @@ -224,6 +226,7 @@ class BqDriver : public Adafruit_BQ25798 { Adafruit_I2CDevice* ih_i2c_dev = nullptr; // Dedicated I2C device for NTC access private: + bool prepareADCInput(); bool startADCOneShot(bool ts_enabled = true); bool setADCEnabled(bool enabled); int16_t getIBUS(); @@ -231,4 +234,4 @@ class BqDriver : public Adafruit_BQ25798 { float calculateBatteryTemp(float ts_pct); Telemetry telemetryData = { 0 }; -}; \ No newline at end of file +}; diff --git a/variants/inhero_mr2/lib/SimplePreferences.h b/variants/inhero_mr2/lib/SimplePreferences.h index f3c5cfaf8b..e5dadfbe64 100644 --- a/variants/inhero_mr2/lib/SimplePreferences.h +++ b/variants/inhero_mr2/lib/SimplePreferences.h @@ -106,4 +106,11 @@ class SimplePreferences { String path = getFilePath(key); return InternalFS.exists(path.c_str()); } + + bool remove(const char* key) { + if (!_started) return false; + String path = getFilePath(key); + if (!InternalFS.exists(path.c_str())) return true; + return InternalFS.remove(path.c_str()); + } }; diff --git a/variants/inhero_mr2/platformio.ini b/variants/inhero_mr2/platformio.ini index b182f0bf95..5b42f83094 100644 --- a/variants/inhero_mr2/platformio.ini +++ b/variants/inhero_mr2/platformio.ini @@ -4,7 +4,6 @@ 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 diff --git a/variants/inhero_mr2/target.cpp b/variants/inhero_mr2/target.cpp index fc4d99e651..229f2f3351 100644 --- a/variants/inhero_mr2/target.cpp +++ b/variants/inhero_mr2/target.cpp @@ -24,9 +24,9 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); - EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); + InheroMr2SensorManager sensors(nmea); #else - EnvironmentSensorManager sensors; + InheroMr2SensorManager sensors; #endif bool radio_init() { diff --git a/variants/inhero_mr2/target.h b/variants/inhero_mr2/target.h index 7d5b473737..ef3e3f40cc 100644 --- a/variants/inhero_mr2/target.h +++ b/variants/inhero_mr2/target.h @@ -6,7 +6,7 @@ #include #include #include -#include +#include #ifdef DISPLAY_CLASS #include @@ -21,7 +21,7 @@ extern InheroMr2Board board; extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; -extern EnvironmentSensorManager sensors; +extern InheroMr2SensorManager sensors; bool radio_init(); uint32_t radio_get_rng_seed(); From 340305449faaf755dd316bb5a45319c89eb4e2e3 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Mon, 21 Sep 2026 18:53:02 +0200 Subject: [PATCH 4/8] Fix MR2 low-voltage MPPT and expose checked register diagnostics --- test/mr2_mpptdiag/run_test.py | 182 +++++++++++++++ test/mr2_vsysmin/README.md | 29 +++ test/mr2_vsysmin/fixture.inc | 232 +++++++++++++++++++ test/mr2_vsysmin/run.py | 112 +++++++++ variants/inhero_mr2/BoardConfigContainer.cpp | 49 +++- variants/inhero_mr2/BoardConfigContainer.h | 4 + variants/inhero_mr2/README.md | 4 + variants/inhero_mr2/helpers/CliCommands.cpp | 5 + 8 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 test/mr2_mpptdiag/run_test.py create mode 100644 test/mr2_vsysmin/README.md create mode 100644 test/mr2_vsysmin/fixture.inc create mode 100644 test/mr2_vsysmin/run.py diff --git a/test/mr2_mpptdiag/run_test.py b/test/mr2_mpptdiag/run_test.py new file mode 100644 index 0000000000..af20dbb4a0 --- /dev/null +++ b/test/mr2_mpptdiag/run_test.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Compile the real MR2 MPPT diagnostic method against a checked I2C mock. + +Run with: python test/mr2_mpptdiag/run_test.py [--cxx path/to/g++] +The temporary C++ source stays outside PlatformIO's test discovery. +""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include + +struct BqMock { + uint8_t registers[256] = {}; + int fail = -1; + std::vector reads; + bool readReg(uint8_t reg, uint8_t& value) { + reads.push_back(reg); + if (reg == fail) return false; // Leave value untouched on failure. + value = registers[reg]; + return true; + } + // Intentionally no unchecked read, writes, or ADC-start methods. +} bq; + +struct BoardConfigContainer { + bool bqInitialized = true; + bool wish = true; + bool getMPPTEnabled() const { return wish; } + void getMpptDiagnostics(char* buffer, uint32_t bufferSize); +}; + +// The runner inserts the production method here, without rewriting it. +@METHOD@ + +static void set16(uint8_t reg, unsigned value) { + bq.registers[reg] = value >> 8; + bq.registers[reg + 1] = value & 0xFF; +} + +static std::string snapshot(BoardConfigContainer& cfg) { + char buffer[100]; + memset(buffer, '!', sizeof(buffer)); + cfg.getMpptDiagnostics(buffer, sizeof(buffer)); + assert(memchr(buffer, '\0', sizeof(buffer)) != nullptr); + return buffer; +} + +int main() { + BoardConfigContainer cfg; + + // Na-ion before the change: configured MPPT on, hardware off, VSYS minimum active. + bq.registers[0x00] = 1; + set16(0x01, 390); + set16(0x03, 20); + bq.registers[0x05] = 37; + bq.registers[0x1B] = 0x08; + bq.registers[0x1E] = 0x10; + assert(snapshot(cfg) == + "MPPT:cfg=1/hw=0 VSYSMIN:2750mV VINDPM:3700mV VSYS_MIN:1 PG:1 CELL:1S ICHG:200mA VREG:3900mV"); + + // Expected RC state with the same cell and an enabled hardware MPPT loop. + bq.registers[0x00] = 0; + bq.registers[0x05] = 60; + bq.registers[0x15] = 1; + bq.registers[0x1E] = 0; + assert(snapshot(cfg) == + "MPPT:cfg=1/hw=1 VSYSMIN:2500mV VINDPM:6000mV VSYS_MIN:0 PG:1 CELL:1S ICHG:200mA VREG:3900mV"); + + // LTO2S: register byte order and masks, with unrelated/reserved bits set. + // Config and hardware can disagree in either direction. + cfg.wish = false; + bq.registers[0x00] = 0xC0; + set16(0x01, 0xF800 | 540); + set16(0x03, 0xFE00 | 40); + bq.registers[0x0A] = 0x7F; + bq.registers[0x15] = 0xFF; + bq.registers[0x1B] = 0xF7; + bq.registers[0x1E] = 0xEF; + assert(snapshot(cfg) == + "MPPT:cfg=0/hw=1 VSYSMIN:2500mV VINDPM:6000mV VSYS_MIN:0 PG:0 CELL:2S ICHG:400mA VREG:5400mV"); + + // Even out-of-range/reserved encodings fit in a CLI reply without truncation. + cfg.wish = true; + memset(bq.registers, 0xFF, sizeof(bq.registers)); + const auto maximum = snapshot(cfg); + assert(maximum == + "MPPT:cfg=1/hw=1 VSYSMIN:18250mV VINDPM:25500mV VSYS_MIN:1 PG:1 CELL:4S ICHG:5110mA VREG:20470mV"); + assert(maximum.size() == 95); + + // Check every register read failure, including both bytes of each 16-bit field. + // No partial or zero-valued success response may escape. + const uint8_t expected_reads[] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x0A, 0x15, 0x1B, 0x1E}; + for (unsigned i = 0; i < sizeof(expected_reads); ++i) { + bq.reads.clear(); + bq.fail = expected_reads[i]; + char expected[32]; + snprintf(expected, sizeof(expected), "Err: BQ read %02X", static_cast(expected_reads[i])); + assert(snapshot(cfg) == expected); + assert(bq.reads == std::vector(expected_reads, expected_reads + i + 1)); + } + bq.fail = -1; + + // Truncated callers still receive a terminated string and never lose canaries. + for (unsigned capacity = 1; capacity <= 100; ++capacity) { + char guarded[102]; + memset(guarded, '#', sizeof(guarded)); + cfg.getMpptDiagnostics(guarded + 1, capacity); + assert(guarded[0] == '#'); + assert(guarded[capacity + 1] == '#'); + assert(memchr(guarded + 1, '\0', capacity) != nullptr); + assert(std::string(guarded + 1) == maximum.substr(0, capacity - 1)); + } + + bq.reads.clear(); + char untouched = '#'; + cfg.getMpptDiagnostics(&untouched, 0); + cfg.getMpptDiagnostics(nullptr, 100); + assert(untouched == '#'); + assert(bq.reads.empty()); + + cfg.bqInitialized = false; + assert(snapshot(cfg) == "BQ not init"); + assert(bq.reads.empty()); + puts("PASS: MR2 mpptdiag decode, masks, 95-character bound, all I2C failures, buffer guards"); +} +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=os.environ.get("CXX") or shutil.which("g++")) + args = parser.parse_args() + if not args.cxx: + parser.error("g++ unavailable; pass --cxx with a C++17 compiler") + + root = Path(__file__).resolve().parents[2] + source = (root / "variants/inhero_mr2/BoardConfigContainer.cpp").read_text(encoding="utf-8") + signature = "void BoardConfigContainer::getMpptDiagnostics(" + start = source.index(signature) + body = source.index("{", start) + depth = 1 + end = body + 1 + while depth: + if source[end] == "{": + depth += 1 + elif source[end] == "}": + depth -= 1 + end += 1 + method = source[start:end] + + cli = (root / "variants/inhero_mr2/helpers/CliCommands.cpp").read_text(encoding="utf-8") + branch_start = cli.index('strcmp(cmd, "mpptdiag") == 0') + branch_end = cli.index("} else", branch_start) + branch = cli[branch_start:branch_end] + assert "cfg.getMpptDiagnostics(diagBuffer, sizeof(diagBuffer));" in branch + assert "char diagBuffer[100];" in branch + assert 'snprintf(reply, maxlen, "%s", diagBuffer);' in branch + + with tempfile.TemporaryDirectory(prefix="mr2-mpptdiag-") as directory: + tmp = Path(directory) + cpp = tmp / "mpptdiag.cpp" + exe = tmp / ("mpptdiag.exe" if os.name == "nt" else "mpptdiag") + cpp.write_text(HARNESS.replace("@METHOD@", method), encoding="utf-8") + subprocess.run([args.cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", + str(cpp), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == "__main__": + main() diff --git a/test/mr2_vsysmin/README.md b/test/mr2_vsysmin/README.md new file mode 100644 index 0000000000..f3c96c60f7 --- /dev/null +++ b/test/mr2_vsysmin/README.md @@ -0,0 +1,29 @@ +# MR2 minimum-system-voltage regression + +Run with Python 3 and a C++17 compiler: + +```powershell +.venv/Scripts/python.exe test/mr2_vsysmin/run.py --cxx C:/Tools/mingw64/bin/g++.exe --check-mutations +``` + +The runner extracts the production `configureBaseBQ()`, `configureChemistry()` and +`getBatteryProperties()` implementations and the real battery property table and +constants. It compiles them against a BQ register-state model and executes ten +scenarios. Generated sources and binaries use a temporary directory. The fixture +has an `.inc` extension so PlatformIO does not pick it up as an Arduino/native test +translation unit. + +The model implements successful CELL writes resetting VSYSMIN, VREG and ICHG; +MPPT rejection below VSYSMIN or without a source; and the final BATFET linear-mode +condition. Checks cover Na-ion at 2.63 V, LTO 2S at 4.9 V, repeated chemistry changes, +MPPT disabled, unknown chemistry, voltage below 2.5 V, an absent source, an +uninitialized driver, LiFePO4 and Li-ion. They also check CE, register restoration +order, preservation of the MPPT preference and the existing 60 C thermal setting. + +`--check-mutations` proves the suite rejects the old 2.75 V setting, removal of the +post-CELL VSYSMIN restoration, and removal of the final MPPT enable request. + +These are host sequencing tests, not electrical measurements. The model does not +exercise the I2C bus or driver register encoding, simulate I2C write failures, prove +transient thermal behavior, or simulate MPPT tracking under clouds. Flashing and +reading hardware telemetry is still necessary to confirm the observed behavior. diff --git a/test/mr2_vsysmin/fixture.inc b/test/mr2_vsysmin/fixture.inc new file mode 100644 index 0000000000..0245aa7f17 --- /dev/null +++ b/test/mr2_vsysmin/fixture.inc @@ -0,0 +1,232 @@ +// Host-only state model. Production configuration functions/declarations are +// extracted at test runtime, not copied into this fixture. This models successful +// register writes and documented CELL resets; it does not simulate I2C failures, +// real MPPT tracking, ADC measurements, temperature or electrical transients. +#include +#include +#include +#include +#include +#include + +enum bq25798_cell_count_t { BQ25798_CELL_COUNT_1S, BQ25798_CELL_COUNT_2S }; +enum { BQ25798_TREG_60C = 60, BQ25798_WDT_DISABLE, BQ25798_VOC_DLY_2S, + BQ25798_VOC_RATE_2MIN, BQ25798_VOC_PCT_81_25, BQ25798_TS_COOL_5C, + BQ25798_TS_WARM_55C, BQ25798_JEITA_VSET_UNCHANGED }; +#define MESH_DEBUG_PRINTLN(...) do {} while (false) +#define BQ_CE_PIN 42 +enum { OUTPUT, LOW, HIGH }; +static int ce_level = LOW; +static void pinMode(int, int) {} +static void digitalWrite(int, int level) { ce_level = level; } + +struct FakeBQ { + struct Event { + std::string name; + double value; + }; + std::vector events; + double battery = 2.63; + bool source_present = true; + double sysmin = 3.5; + double vreg = 4.2; + double ichg = 1.0; + int cells = 1; + int treg = 120; + bool mppt = false; + bool charging = false; + + void record(const char* name, double value) { events.push_back({name, value}); } + bool linear() const { return source_present && charging && battery < sysmin; } + void setCellCount(bq25798_cell_count_t count) { + cells = count == BQ25798_CELL_COUNT_2S ? 2 : 1; + // CELL writes restore defaults even when the cell count is unchanged. + sysmin = cells == 2 ? 7.0 : 3.5; + vreg = cells == 2 ? 8.4 : 4.2; + ichg = 1.0; + if (battery < sysmin) mppt = false; + record("CELL", cells); + } + void setMinSystemV(float voltage) { + // REG00 starts at 2.5 V in 250 mV steps. + if (voltage < 2.5 || std::abs((voltage - 2.5) / 0.25 - + std::round((voltage - 2.5) / 0.25)) > 0.001) + throw std::runtime_error("Invalid VSYSMIN register value"); + sysmin = voltage; + if (battery < sysmin) mppt = false; + record("VSYSMIN", voltage); + } + void setChargeLimitV(float voltage) { vreg = voltage; record("VREG", voltage); } + void setChargeLimitA(float amps) { ichg = amps; record("ICHG", amps); } + void setChargeEnable(bool enabled) { charging = enabled; record("CHARGE", enabled); } + void setMPPTenable(bool enabled) { + mppt = enabled && source_present && battery >= sysmin; + record("MPPT", enabled); + } + void setThermRegulationThresh(int threshold) { treg = threshold; } + void setRechargeThreshOffsetV(float) {} + void setPrechargeTimerEnable(bool) {} + void setFastChargeTimerEnable(bool) {} + void setTsIgnore(bool) {} + void setWDT(int) {} + void setExtILIMpin(bool) {} + void setInputLimitA(float) {} + void setICOEnable(bool) {} + void setVOCdelay(int) {} + void setVOCrate(int) {} + void setVOCpercent(int) {} + void setAutoDPinsDetection(bool) {} + void setStatPinEnable(bool) {} + void setTsCool(int) {} + void setTsWarm(int) {} + void setJeitaVSet(int) {} + void setAutoIBATDIS(bool) {} + void getTelemetryData(int) {} +}; + +class BoardConfigContainer { + public: +#include "production_declarations.inc" + FakeBQ bq; + bool bqInitialized = true; + bool leds_enabled = false; + bool mppt_wish = true; + uint16_t imax_mA = 200; + bool configureBaseBQ(); + bool configureChemistry(BatteryType type); + static const BatteryProperties* getBatteryProperties(BatteryType type); + bool getMPPTEnabled() const { return mppt_wish; } + uint16_t getMaxChargeCurrent_mA() const { return imax_mA; } + void applyJeitaIgnore(const BatteryProperties*) { bq.record("JEITA", bq.ichg); } +}; + +#include "production_functions.inc" + +static void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +static void near(double actual, double expected, const char* message) { + require(std::abs(actual - expected) < 0.0001, message); +} +static void configure(BoardConfigContainer& board, BoardConfigContainer::BatteryType type) { + // begin() and setBatteryType() both use this production sequence. + require(board.configureBaseBQ(), "base configuration failed"); + require(!board.bq.mppt, "base must leave MPPT disabled before CELL programming"); + require(board.configureChemistry(type), "chemistry configuration failed"); +} +static void check_final(const BoardConfigContainer& board, int cells, double vreg, double ichg) { + const auto& bq = board.bq; + near(bq.sysmin, 2.5, "VSYSMIN must be 2.5 V after CELL reset"); + near(bq.vreg, vreg, "chemistry VREG must be restored after CELL reset"); + near(bq.ichg, ichg, "configured ICHG must be restored after CELL reset"); + require(bq.cells == cells, "incorrect cell count"); + require(bq.treg == 60, "60 C thermal regulation must remain configured"); + require(bq.charging && ce_level == HIGH, "known chemistry should allow charging"); + require(!bq.linear(), "final configuration must leave BATFET outside linear region"); + int cell = -1, minimum = -1, current = -1, jeita = -1, mppt = -1; + for (size_t i = 0; i < bq.events.size(); ++i) { + const auto& event = bq.events[i]; + if (event.name == "CELL") cell = static_cast(i); + if (event.name == "VSYSMIN") minimum = static_cast(i); + if (event.name == "ICHG") current = static_cast(i); + if (event.name == "JEITA") jeita = static_cast(i); + if (event.name == "MPPT" && event.value) mppt = static_cast(i); + } + require(cell >= 0 && minimum > cell, "VSYSMIN must be written after last CELL write"); + require(current > cell && jeita > current, "ICHG must be restored before JEITA override"); + if (board.mppt_wish) { + require(mppt > minimum && mppt > current, "MPPT must be requested after final register restoration"); + require(bq.mppt, "MPPT should be accepted with source and VBAT above VSYSMIN"); + } else { + require(mppt == -1 && !bq.mppt, "disabled MPPT preference must never be enabled"); + } +} + +int main() { + unsigned passed = 0; + auto test = [&passed](const char* name, auto run) { + run(); + ++passed; + std::cout << "PASS " << name << '\n'; + }; + try { + test("Na-ion at 2.63 V restores 2.50 V and immediately enables requested MPPT", [] { + BoardConfigContainer board; + configure(board, BoardConfigContainer::NAION_1S); + check_final(board, 1, 3.9, 0.2); + }); + test("LTO 2S at 4.90 V restores 2.50 V / 5.40 V / configured 1.50 A", [] { + BoardConfigContainer board; + board.bq.battery = 4.9; + board.imax_mA = 1500; + configure(board, BoardConfigContainer::LTO_2S); + check_final(board, 2, 5.4, 1.5); + }); + test("repeated chemistry changes restore all settings after every CELL write", [] { + BoardConfigContainer board; + for (int cycle = 0; cycle < 3; ++cycle) { + board.bq.battery = 4.9; + board.imax_mA = 1500; + configure(board, BoardConfigContainer::LTO_2S); + check_final(board, 2, 5.4, 1.5); + board.bq.battery = 2.63; + board.imax_mA = 200; + configure(board, BoardConfigContainer::NAION_1S); + check_final(board, 1, 3.9, 0.2); + } + }); + test("MPPT off preference never requests enable", [] { + BoardConfigContainer board; + board.mppt_wish = false; + configure(board, BoardConfigContainer::NAION_1S); + check_final(board, 1, 3.9, 0.2); + }); + test("unknown battery disables charging, CE and previously active MPPT", [] { + BoardConfigContainer board; + configure(board, BoardConfigContainer::NAION_1S); + require(board.bq.mppt, "precondition: MPPT active"); + configure(board, BoardConfigContainer::BAT_UNKNOWN); + require(!board.bq.charging && ce_level == LOW, "unknown battery must prohibit charging"); + require(!board.bq.mppt, "unknown chemistry must not re-enable MPPT"); + require(board.mppt_wish, "configuration must preserve the stored MPPT preference"); + }); + test("below 2.50 V hardware rejects MPPT without erasing saved preference", [] { + BoardConfigContainer board; + board.bq.battery = 2.4; + configure(board, BoardConfigContainer::NAION_1S); + near(board.bq.sysmin, 2.5, "minimum register value must remain 2.5 V"); + require(!board.bq.mppt && board.mppt_wish, "MPPT must be rejected but its preference retained"); + require(board.bq.linear(), "below 2.5 V the model must still report linear operation"); + }); + test("absent source rejects MPPT while preserving saved preference", [] { + BoardConfigContainer board; + board.bq.source_present = false; + configure(board, BoardConfigContainer::NAION_1S); + require(!board.bq.mppt && board.mppt_wish, "no source: hardware MPPT off, preference on"); + }); + test("uninitialized BQ is not written", [] { + BoardConfigContainer board; + board.bqInitialized = false; + require(!board.configureBaseBQ(), "uninitialized base should fail"); + require(!board.configureChemistry(BoardConfigContainer::NAION_1S), "uninitialized chemistry should fail"); + require(board.bq.events.empty(), "uninitialized BQ must not receive writes"); + }); + test("LiFePO4 chemistry retains 3.50 V charge voltage", [] { + BoardConfigContainer board; + board.bq.battery = 3.0; + configure(board, BoardConfigContainer::LIFEPO4_1S); + check_final(board, 1, 3.5, 0.2); + }); + test("Li-ion chemistry retains 4.10 V charge voltage", [] { + BoardConfigContainer board; + board.bq.battery = 3.7; + configure(board, BoardConfigContainer::LIION_1S); + check_final(board, 1, 4.1, 0.2); + }); + } catch (const std::exception& error) { + std::cerr << "FAIL after " << passed << " passing cases: " << error.what() << '\n'; + return 1; + } + std::cout << passed << " cases passed (production configuration, simulated BQ register behavior)\n"; + return 0; +} diff --git a/test/mr2_vsysmin/run.py b/test/mr2_vsysmin/run.py new file mode 100644 index 0000000000..fd2cecdbed --- /dev/null +++ b/test/mr2_vsysmin/run.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Compile production MR2 charger configuration against a host BQ state model. + +No PlatformIO/Arduino dependencies. Generated files live in a temporary directory; +the .inc fixture is deliberately excluded from PlatformIO's normal C++ test scan. +""" + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def match_one(pattern, text): + matches = re.findall(pattern, text, re.DOTALL) + if len(matches) != 1: + raise ValueError(f"Expected one production declaration: {pattern}") + return matches[0] + + +def function_body(text, signature): + """Extract a complete function, ignoring braces in comments and literals.""" + start = text.index(signature) + opening = text.index("{", start) + # Tokenization makes the bracket counter insensitive to comment/string edits. + tokens = re.finditer( + r'//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', + text[opening:], re.DOTALL, + ) + depth = 0 + for token in tokens: + if token.group() == "{": + depth += 1 + elif token.group() == "}": + depth -= 1 + if depth == 0: + return text[start:opening + token.end()] + raise ValueError(f"Unbalanced production function: {signature}") + + +def extract_production(): + header = (ROOT / "variants/inhero_mr2/BoardConfigContainer.h").read_text(encoding="utf-8") + source = (ROOT / "variants/inhero_mr2/BoardConfigContainer.cpp").read_text(encoding="utf-8") + declarations = [ + match_one(r"enum BatteryType\s*:\s*uint8_t\s*\{[^}]*\};", header), + match_one(r"// Battery type properties\s*(typedef struct\s*\{.*?\}\s*BatteryProperties;)", header), + match_one(r"static inline constexpr BatteryProperties battery_properties\[\]\s*=\s*\{.*?\n\s*\};", header), + match_one(r"static constexpr float BQ_MIN_SYSTEM_V\s*=\s*[^;]+;", header), + match_one(r"static constexpr float IINDPM_MAX_A\s*=\s*[^;]+;", header), + ] + functions = [function_body(source, signature) for signature in ( + "bool BoardConfigContainer::configureBaseBQ()", + "bool BoardConfigContainer::configureChemistry(BatteryType type)", + "const BoardConfigContainer::BatteryProperties* BoardConfigContainer::getBatteryProperties(BatteryType type)", + )] + return "\n".join(declarations), "\n\n".join(functions) + + +def compile_and_run(compiler, directory, declarations, functions, name): + (directory / "production_declarations.inc").write_text(declarations, encoding="utf-8") + (directory / "production_functions.inc").write_text(functions, encoding="utf-8") + executable = directory / (name + ".exe") + subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-x", "c++", str(HERE / "fixture.inc"), "-I", str(directory), "-o", str(executable), + ], check=True) + return subprocess.run([str(executable)], capture_output=True, text=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=shutil.which("g++")) + parser.add_argument("--check-mutations", action="store_true", + help="Also prove tests reject three original/regression failure modes") + args = parser.parse_args() + if not args.cxx: + parser.error("g++ not found; supply --cxx /path/to/g++") + declarations, functions = extract_production() + with tempfile.TemporaryDirectory(prefix="mr2-vsysmin-") as temporary: + directory = Path(temporary) + result = compile_and_run(args.cxx, directory, declarations, functions, "regression") + print(result.stdout, end="") + print(result.stderr, end="") + if result.returncode: + return result.returncode + if args.check_mutations: + mutations = ( + ("old-2.75V", declarations.replace("BQ_MIN_SYSTEM_V = 2.50f", "BQ_MIN_SYSTEM_V = 2.75f"), functions), + ("missing-post-CELL-restore", declarations, + functions.replace("bq.setMinSystemV(BQ_MIN_SYSTEM_V);\n bq.setChargeLimitA", + "bq.setChargeLimitA")), + ("missing-final-MPPT-enable", declarations, + functions.replace("bq.setMPPTenable(getMPPTEnabled());", "")), + ) + for name, mutant_declarations, mutant_functions in mutations: + if (mutant_declarations, mutant_functions) == (declarations, functions): + raise ValueError(f"Mutation {name} no longer changes production code") + mutant = compile_and_run(args.cxx, directory, mutant_declarations, mutant_functions, name) + if mutant.returncode == 0: + raise RuntimeError(f"Regression tests failed to detect {name}") + print(f"PASS mutation rejected: {name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp index 5686a5773e..2aedfefcac 100644 --- a/variants/inhero_mr2/BoardConfigContainer.cpp +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -559,6 +559,41 @@ void BoardConfigContainer::getBqDiagnostics(char* buffer, uint32_t bufferSize) { ts_str, flags, en_chg, en_hiz, iindpm_mA, f0, f1, s0, s1, s2, s3, s4, ntc1); } +// Read-only MPPT acceptance snapshot. cfg is the stored wish, hw is EN_MPPT. +// VSYS_MIN reports VSYS_STAT; it does not by itself prove active LDO charging. +void BoardConfigContainer::getMpptDiagnostics(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + if (!bqInitialized) { + snprintf(buffer, bufferSize, "BQ not init"); + return; + } + + // VSYSMIN, VREG hi/lo, ICHG hi/lo, VINDPM, CELL, MPPT, STATUS_0/3. + const uint8_t regs[] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x0A, 0x15, 0x1B, 0x1E}; + uint8_t raw[sizeof(regs)]; + for (unsigned i = 0; i < sizeof(regs); ++i) { + if (!bq.readReg(regs[i], raw[i])) { + snprintf(buffer, bufferSize, "Err: BQ read %02X", static_cast(regs[i])); + return; + } + } + + const unsigned vsysmin_mV = 2500U + (raw[0] & 0x3F) * 250U; + const unsigned vreg_mV = (((raw[1] << 8) | raw[2]) & 0x07FF) * 10U; + const unsigned ichg_mA = (((raw[3] << 8) | raw[4]) & 0x01FF) * 10U; + const unsigned vindpm_mV = raw[5] * 100U; + const unsigned cells = ((raw[6] >> 6) & 0x03) + 1U; + const unsigned mppt = raw[7] & 0x01; + const unsigned pg = (raw[8] >> 3) & 0x01; + const unsigned vsys_min = (raw[9] >> 4) & 0x01; + + // At most 95 characters, including maximum encodings, for the 100-byte CLI buffer. + snprintf(buffer, bufferSize, + "MPPT:cfg=%u/hw=%u VSYSMIN:%umV VINDPM:%umV VSYS_MIN:%u PG:%u CELL:%uS ICHG:%umA VREG:%umV", + static_cast(getMPPTEnabled()), mppt, vsysmin_mV, vindpm_mV, + vsys_min, pg, cells, ichg_mA, vreg_mV); +} + // Initializes battery manager, preferences, and background tasks bool BoardConfigContainer::begin() { // Initialize LEDs early for boot sequence visualization @@ -1031,9 +1066,11 @@ bool BoardConfigContainer::configureBaseBQ() { 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); + // CELL programming below resets VSYSMIN. Enable MPPT only after the final + // chemistry settings are restored, and only if requested in preferences. + bq.setMPPTenable(false); - bq.setMinSystemV(2.75); // 2.75V = next valid step above 2.7V (250mV steps: 2.5, 2.75, 3.0...) + bq.setMinSystemV(BQ_MIN_SYSTEM_V); 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) @@ -1103,16 +1140,20 @@ bool BoardConfigContainer::configureChemistry(BatteryType type) { // VSYSMIN=7V and the BATFET burns (VSYS - VBAT) × ICHG linearly during // charging (LTO at 4.9V/0.93A: ~2W → BQ rides its thermal limit), and the // configured imax silently falls back to the 1A default on every boot. - bq.setMinSystemV(2.75); + bq.setMinSystemV(BQ_MIN_SYSTEM_V); bq.setChargeLimitA(getMaxChargeCurrent_mA() / 1000.0f); - // Derive the JEITA override LAST, once ICHG holds the configured imax again. + // Derive the JEITA override once ICHG holds the configured imax again. // Deriving it earlier leaves a window in which the temperature guard is off // while setCellCount() has just reset ICHG to the 1A POR default — and an I2C // failure inside that window would freeze the board in exactly that state. // configureBaseBQ() clears TS_IGNORE, so the hardware guard rules until here. applyJeitaIgnore(props); + // Below VSYSMIN the BQ rejects EN_MPPT; above it, apply the configured wish + // now instead of waiting for the next 60-second solar-maintenance cycle. + bq.setMPPTenable(getMPPTEnabled()); + return true; } diff --git a/variants/inhero_mr2/BoardConfigContainer.h b/variants/inhero_mr2/BoardConfigContainer.h index f9ee8cbe11..09f9919b1a 100644 --- a/variants/inhero_mr2/BoardConfigContainer.h +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -155,6 +155,9 @@ class BoardConfigContainer { 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; + // REG00[5:0] = 0: allow MPPT above the Na-ion 2.50V sleep threshold. + // Keep below VBAT to avoid minimum-system regulation and linear BATFET loss. + static constexpr float BQ_MIN_SYSTEM_V = 2.50f; static constexpr float MIN_STATION_ALTITUDE_M = -500.0f; static constexpr float MAX_STATION_ALTITUDE_M = 9000.0f; @@ -221,6 +224,7 @@ class BoardConfigContainer { const char* getChargeCurrentAsStr(); void getChargerInfo(char* buffer, uint32_t bufferSize); void getBqDiagnostics(char* buffer, uint32_t bufferSize); + void getMpptDiagnostics(char* buffer, uint32_t bufferSize); // "INA:OK BQ:OK RTC:OK BME:OK". RTC probe writes/reads user-RAM to catch // zombie chips that ACK but don't persist. diff --git a/variants/inhero_mr2/README.md b/variants/inhero_mr2/README.md index 5c62f643fe..9f4433578d 100644 --- a/variants/inhero_mr2/README.md +++ b/variants/inhero_mr2/README.md @@ -9,6 +9,10 @@ Supports Li-ion, LiFePO4, LTO and Na-ion battery profiles, solar-input recovery and RTC wakeup after low-voltage sleep. An optional installation altitude (`set board.altitude `) enables QNH pressure telemetry from the BME280. +The charger uses a 2.50 V minimum system voltage and reapplies the MPPT preference +after restoring the battery profile. `get board.mpptdiag` reports the requested +and actual MPPT state, voltage/current settings and minimum-system status. + Build environments: ```bash diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp index 55ac69106f..97345b4929 100644 --- a/variants/inhero_mr2/helpers/CliCommands.cpp +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -188,6 +188,11 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u cfg.getBqDiagnostics(diagBuffer, sizeof(diagBuffer)); snprintf(reply, maxlen, "%s", diagBuffer); return true; + } else if (strcmp(cmd, "mpptdiag") == 0) { + char diagBuffer[100]; + cfg.getMpptDiagnostics(diagBuffer, sizeof(diagBuffer)); + snprintf(reply, maxlen, "%s", diagBuffer); + return true; } else if (strcmp(cmd, "selftest") == 0) { char stBuffer[64]; cfg.getSelfTest(stBuffer, sizeof(stBuffer)); From 4f9692388a1cb775d2134eb58380da22a2cdea0a Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Tue, 22 Sep 2026 08:02:12 +0200 Subject: [PATCH 5/8] Move MR2 telemetry into the sensor manager --- variants/inhero_mr2/BoardConfigContainer.cpp | 4 +- variants/inhero_mr2/InheroMr2Board.cpp | 10 +- variants/inhero_mr2/InheroMr2Board.h | 4 +- .../inhero_mr2/InheroMr2SensorManager.cpp | 9 ++ variants/inhero_mr2/InheroMr2SensorManager.h | 8 ++ .../inhero_mr2/helpers/BoardTelemetry.cpp | 114 ++++++++++++++++++ variants/inhero_mr2/helpers/BoardTelemetry.h | 15 +++ variants/inhero_mr2/helpers/CliCommands.cpp | 102 ---------------- variants/inhero_mr2/helpers/CliCommands.h | 8 -- .../inhero_mr2/helpers/UsbAutoManagement.h | 2 +- variants/inhero_mr2/lib/BqDriver.h | 2 +- 11 files changed, 154 insertions(+), 124 deletions(-) create mode 100644 variants/inhero_mr2/helpers/BoardTelemetry.cpp create mode 100644 variants/inhero_mr2/helpers/BoardTelemetry.h diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp index 2aedfefcac..a35c3b3887 100644 --- a/variants/inhero_mr2/BoardConfigContainer.cpp +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -819,7 +819,7 @@ bool BoardConfigContainer::begin() { } // MPPT, SOC updates, and voltage monitoring are handled in tickPeriodic() - // (called from InheroMr2Board::tick() — no FreeRTOS tasks doing I2C) + // (called from InheroMr2Board::loop() — no FreeRTOS tasks doing I2C) // Load battery capacity from preferences (or default based on chemistry) float cap_mah = 0.0f; @@ -2237,7 +2237,7 @@ void BoardConfigContainer::calculateTTL() { // ===== Tick-based Periodic Dispatch ===== -// Called from InheroMr2Board::tick() — dispatches all periodic I2C work with +// Called from InheroMr2Board::loop() — dispatches all periodic I2C work with // millis()-based scheduling in the main loop context. // Checks the INA228 alert and polls voltage as a fallback if the interrupt is missed. void BoardConfigContainer::tickPeriodic() { diff --git a/variants/inhero_mr2/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp index 0fbecc92b7..3890ed7dfe 100644 --- a/variants/inhero_mr2/InheroMr2Board.cpp +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -298,6 +298,7 @@ void InheroMr2Board::begin() { // 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(); + sensors.setBoardTelemetrySource(boardConfig); float station_altitude_m = NAN; if (boardConfig.getStationAltitude(station_altitude_m)) { @@ -333,7 +334,7 @@ void InheroMr2Board::begin() { } } -void InheroMr2Board::tick() { +void InheroMr2Board::loop() { inhero::serviceUsbAutoManagement(); // Deferred OTA DFU reset: wait for CLI reply to be sent, then enter bootloader @@ -415,17 +416,12 @@ bool InheroMr2Board::startOTAUpdate(const char* id, char reply[]) { // 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. + // loop() 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; } -// Collects board telemetry and appends to CayenneLPP packet -bool InheroMr2Board::queryBoardTelemetry(CayenneLPP& telemetry) { - return inhero::appendBoardTelemetry(boardConfig, telemetry); -} - // Handles this board's own 'get board.*' / 'set board.*' CLI commands. // CommonCLI::handleCommand() offers every command to the board before its own // dispatch, so returning false just falls through to the core CLI. diff --git a/variants/inhero_mr2/InheroMr2Board.h b/variants/inhero_mr2/InheroMr2Board.h index 933cde35fe..a820bb6752 100644 --- a/variants/inhero_mr2/InheroMr2Board.h +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -5,7 +5,6 @@ #pragma once #include -#include #include #include @@ -52,7 +51,7 @@ class InheroMr2Board : public NRF52BoardDCDC { public: InheroMr2Board() : NRF52Board("InheroMR2_OTA") {} void begin(); - void tick() override; + void loop() override; uint16_t getBattMilliVolts() override; @@ -68,7 +67,6 @@ class InheroMr2Board : public NRF52BoardDCDC { bool startOTAUpdate(const char *id, char reply[]) override; bool handleCommand(const char *command, uint32_t sender_timestamp, char *reply) override; - bool queryBoardTelemetry(CayenneLPP &telemetry) override; private: static volatile bool rtc_irq_pending; diff --git a/variants/inhero_mr2/InheroMr2SensorManager.cpp b/variants/inhero_mr2/InheroMr2SensorManager.cpp index 3b8bae7256..be8f4f5f3b 100644 --- a/variants/inhero_mr2/InheroMr2SensorManager.cpp +++ b/variants/inhero_mr2/InheroMr2SensorManager.cpp @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ #include "InheroMr2SensorManager.h" +#include "helpers/BoardTelemetry.h" #include #include @@ -64,6 +65,14 @@ bool InheroMr2SensorManager::begin() { return true; } +bool InheroMr2SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + const bool result = EnvironmentSensorManager::querySensors(requester_permissions, telemetry); + if (_board_config && (requester_permissions & TELEM_PERM_ENVIRONMENT)) { + inhero::appendBoardTelemetry(*_board_config, telemetry); + } + return result; +} + void InheroMr2SensorManager::setBme280StationAltitude(float altitude_m) { station_altitude_m = altitude_m; } diff --git a/variants/inhero_mr2/InheroMr2SensorManager.h b/variants/inhero_mr2/InheroMr2SensorManager.h index ac2b4310f7..3306dac52b 100644 --- a/variants/inhero_mr2/InheroMr2SensorManager.h +++ b/variants/inhero_mr2/InheroMr2SensorManager.h @@ -6,11 +6,19 @@ #include +class BoardConfigContainer; + class InheroMr2SensorManager : public EnvironmentSensorManager { + BoardConfigContainer* _board_config = nullptr; + public: using EnvironmentSensorManager::EnvironmentSensorManager; bool begin() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + + // The board owns and initializes the telemetry source before sensor setup. + void setBoardTelemetrySource(BoardConfigContainer& config) { _board_config = &config; } // NAN keeps station pressure and the pressure-derived altitude. static void setBme280StationAltitude(float altitude_m); diff --git a/variants/inhero_mr2/helpers/BoardTelemetry.cpp b/variants/inhero_mr2/helpers/BoardTelemetry.cpp new file mode 100644 index 0000000000..667aed8878 --- /dev/null +++ b/variants/inhero_mr2/helpers/BoardTelemetry.cpp @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BoardTelemetry.h" + +#include "../BoardConfigContainer.h" +#include +#include + +namespace inhero { + +namespace { + +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; + } +} + +uint8_t findNextFreeLppChannel(CayenneLPP& lpp) { + uint8_t max_channel = 0; + uint8_t cursor = 0; + uint8_t* buffer = lpp.getBuffer(); + uint8_t size = lpp.getSize(); + + while (cursor < size) { + if (cursor + 1 >= size) break; + uint8_t channel = buffer[cursor]; + uint8_t type = buffer[cursor + 1]; + uint8_t data_len = getLPPDataLength(type); + if (data_len == 0) break; // unknown type, can't continue + if (channel > max_channel) max_channel = channel; + cursor += 2 + data_len; + } + return max_channel + 1; +} + +} // namespace + +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry) { + const Telemetry* telemetryData = cfg.getTelemetryData(); + if (!telemetryData) return false; + + uint8_t batteryChannel = findNextFreeLppChannel(telemetry); + uint8_t solarChannel = batteryChannel + 1; + + const BatterySOCStats* socStats = cfg.getSOCStats(); + bool hasValidSoc = (socStats && socStats->soc_valid); + float socPercent = roundf(cfg.getStateOfCharge() * 10.0f) / 10.0f; + + uint16_t ttlHours = cfg.getTTL_Hours(); + bool isInfiniteTtl = (socStats && socStats->soc_valid && !socStats->living_on_battery); + constexpr float MAX_TTL_DAYS = 990.0f; // sentinel reported when TTL is effectively infinite + + // Battery: VBAT[V], SOC[%] (opt), IBAT[A], TBAT[°C], TTL[d] (opt) + telemetry.addVoltage(batteryChannel, telemetryData->battery.voltage / 1000.0f); + if (hasValidSoc) telemetry.addPercentage(batteryChannel, socPercent); + telemetry.addCurrent(batteryChannel, telemetryData->battery.current / 1000.0f); + if (telemetryData->battery.temperature > -100.0f) { + telemetry.addTemperature(batteryChannel, telemetryData->battery.temperature); + } + if (ttlHours > 0) { + telemetry.addDistance(batteryChannel, ttlHours / 24.0f); + } else if (isInfiniteTtl) { + telemetry.addDistance(batteryChannel, MAX_TTL_DAYS); + } + + // Solar: VSOL[V], ISOL[A], MPPT_7D[%] + if (telemetryData->solar.valid) { + telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); + telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + } + telemetry.addPercentage(solarChannel, cfg.getMpptEnabledPercentage7Day()); + + return true; +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BoardTelemetry.h b/variants/inhero_mr2/helpers/BoardTelemetry.h new file mode 100644 index 0000000000..c688de5a1f --- /dev/null +++ b/variants/inhero_mr2/helpers/BoardTelemetry.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +class BoardConfigContainer; +class CayenneLPP; + +namespace inhero { + +// Appends the MR2 battery and solar values after the existing sensor channels. +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp index 97345b4929..7c22a795bc 100644 --- a/variants/inhero_mr2/helpers/CliCommands.cpp +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -6,7 +6,6 @@ #include "../BoardConfigContainer.h" -#include #include #include #include @@ -14,107 +13,6 @@ namespace inhero { -namespace { - -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; - } -} - -} // namespace - -uint8_t findNextFreeLppChannel(CayenneLPP& lpp) { - uint8_t max_channel = 0; - uint8_t cursor = 0; - uint8_t* buffer = lpp.getBuffer(); - uint8_t size = lpp.getSize(); - - while (cursor < size) { - if (cursor + 1 >= size) break; - uint8_t channel = buffer[cursor]; - uint8_t type = buffer[cursor + 1]; - uint8_t data_len = getLPPDataLength(type); - if (data_len == 0) break; // unknown type, can't continue - if (channel > max_channel) max_channel = channel; - cursor += 2 + data_len; - } - return max_channel + 1; -} - -bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry) { - const Telemetry* telemetryData = cfg.getTelemetryData(); - if (!telemetryData) return false; - - uint8_t batteryChannel = findNextFreeLppChannel(telemetry); - uint8_t solarChannel = batteryChannel + 1; - - const BatterySOCStats* socStats = cfg.getSOCStats(); - bool hasValidSoc = (socStats && socStats->soc_valid); - float socPercent = roundf(cfg.getStateOfCharge() * 10.0f) / 10.0f; - - uint16_t ttlHours = cfg.getTTL_Hours(); - bool isInfiniteTtl = (socStats && socStats->soc_valid && !socStats->living_on_battery); - constexpr float MAX_TTL_DAYS = 990.0f; // sentinel reported when TTL is effectively infinite - - // Battery: VBAT[V], SOC[%] (opt), IBAT[A], TBAT[°C], TTL[d] (opt) - telemetry.addVoltage(batteryChannel, telemetryData->battery.voltage / 1000.0f); - if (hasValidSoc) telemetry.addPercentage(batteryChannel, socPercent); - telemetry.addCurrent(batteryChannel, telemetryData->battery.current / 1000.0f); - if (telemetryData->battery.temperature > -100.0f) { - telemetry.addTemperature(batteryChannel, telemetryData->battery.temperature); - } - if (ttlHours > 0) { - telemetry.addDistance(batteryChannel, ttlHours / 24.0f); - } else if (isInfiniteTtl) { - telemetry.addDistance(batteryChannel, MAX_TTL_DAYS); - } - - // Solar: VSOL[V], ISOL[A], MPPT_7D[%] - if (telemetryData->solar.valid) { - telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); - telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); - } - telemetry.addPercentage(solarChannel, cfg.getMpptEnabledPercentage7Day()); - - return true; -} - bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, uint32_t maxlen) { // Trim trailing whitespace from command char trimmedCommand[100]; diff --git a/variants/inhero_mr2/helpers/CliCommands.h b/variants/inhero_mr2/helpers/CliCommands.h index 979d6a06b2..03609a6a30 100644 --- a/variants/inhero_mr2/helpers/CliCommands.h +++ b/variants/inhero_mr2/helpers/CliCommands.h @@ -5,7 +5,6 @@ #pragma once #include -#include class BoardConfigContainer; @@ -20,11 +19,4 @@ bool handleGet(BoardConfigContainer& cfg, const char* cmd, char* reply, uint32_t // reply buffer owned by the helper (caller must not free). const char* handleSet(BoardConfigContainer& cfg, const char* setCommand); -// Appends battery + solar telemetry to `lpp` starting at the next free channel. -// Returns false if telemetry data is unavailable (cfg.getTelemetryData() == nullptr). -bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& lpp); - -// Parses a CayenneLPP buffer and returns highest_used_channel + 1, or 1 if empty. -uint8_t findNextFreeLppChannel(CayenneLPP& lpp); - } // namespace inhero diff --git a/variants/inhero_mr2/helpers/UsbAutoManagement.h b/variants/inhero_mr2/helpers/UsbAutoManagement.h index e70303f03b..00641a56be 100644 --- a/variants/inhero_mr2/helpers/UsbAutoManagement.h +++ b/variants/inhero_mr2/helpers/UsbAutoManagement.h @@ -13,7 +13,7 @@ bool isUsbPowered(); void enableUsb(); void disableUsb(); -// Call from board tick(); enables/disables USB on VBUS edge. +// Call from board loop(); enables/disables USB on VBUS edge. void serviceUsbAutoManagement(); } // namespace inhero diff --git a/variants/inhero_mr2/lib/BqDriver.h b/variants/inhero_mr2/lib/BqDriver.h index da819557b7..5c81ba27fe 100644 --- a/variants/inhero_mr2/lib/BqDriver.h +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -142,7 +142,7 @@ class BqDriver : public Adafruit_BQ25798 { 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) + // Direct pass-through to parent — all I2C runs in loop() 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(); } From 1b2e5878a1f8a041a02382a7118436a71bb11675 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Tue, 22 Sep 2026 08:35:44 +0200 Subject: [PATCH 6/8] Fix MR2 altitude fallback under nRF52 optimization --- variants/inhero_mr2/platformio.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/variants/inhero_mr2/platformio.ini b/variants/inhero_mr2/platformio.ini index 5b42f83094..b254cc7028 100644 --- a/variants/inhero_mr2/platformio.ini +++ b/variants/inhero_mr2/platformio.ini @@ -4,6 +4,9 @@ board = inhero_mr2 board_check = true board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} + ; The nRF52 framework uses -Ofast. Keep NaN/Inf checks for unset altitude + ; and invalid sensor/configuration values instead of optimizing them away. + -fno-finite-math-only -I variants/inhero_mr2 -D INHERO_MR2 -D PIN_BOARD_SCL=14 From 928d627bbe28e6b045c9fe140ec518f3f31a6541 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Wed, 23 Sep 2026 13:36:34 +0200 Subject: [PATCH 7/8] fix(mr2): match precharge current to charge limit --- test/mr2_vsysmin/fixture.inc | 13 ++++++++++++- variants/inhero_mr2/BoardConfigContainer.cpp | 9 ++++++--- variants/inhero_mr2/helpers/CliCommands.cpp | 4 +++- variants/inhero_mr2/lib/BqDriver.cpp | 14 ++++++++++++++ variants/inhero_mr2/lib/BqDriver.h | 4 ++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/test/mr2_vsysmin/fixture.inc b/test/mr2_vsysmin/fixture.inc index 0245aa7f17..58c98647ae 100644 --- a/test/mr2_vsysmin/fixture.inc +++ b/test/mr2_vsysmin/fixture.inc @@ -31,6 +31,7 @@ struct FakeBQ { double sysmin = 3.5; double vreg = 4.2; double ichg = 1.0; + int precharge_ma = 120; int cells = 1; int treg = 120; bool mppt = false; @@ -58,6 +59,11 @@ struct FakeBQ { } void setChargeLimitV(float voltage) { vreg = voltage; record("VREG", voltage); } void setChargeLimitA(float amps) { ichg = amps; record("ICHG", amps); } + bool setPrechargeLimitmA(uint16_t ma) { + precharge_ma = static_cast((ma / 40) * 40); + record("IPRECHG", precharge_ma); + return true; + } void setChargeEnable(bool enabled) { charging = enabled; record("CHARGE", enabled); } void setMPPTenable(bool enabled) { mppt = enabled && source_present && battery >= sysmin; @@ -119,21 +125,26 @@ static void check_final(const BoardConfigContainer& board, int cells, double vre near(bq.sysmin, 2.5, "VSYSMIN must be 2.5 V after CELL reset"); near(bq.vreg, vreg, "chemistry VREG must be restored after CELL reset"); near(bq.ichg, ichg, "configured ICHG must be restored after CELL reset"); + require(bq.precharge_ma == static_cast(ichg * 1000 + 0.5) / 40 * 40, + "IPRECHG must follow ICHG in 40 mA steps"); require(bq.cells == cells, "incorrect cell count"); require(bq.treg == 60, "60 C thermal regulation must remain configured"); require(bq.charging && ce_level == HIGH, "known chemistry should allow charging"); require(!bq.linear(), "final configuration must leave BATFET outside linear region"); - int cell = -1, minimum = -1, current = -1, jeita = -1, mppt = -1; + int cell = -1, minimum = -1, current = -1, precharge = -1, jeita = -1, mppt = -1; for (size_t i = 0; i < bq.events.size(); ++i) { const auto& event = bq.events[i]; if (event.name == "CELL") cell = static_cast(i); if (event.name == "VSYSMIN") minimum = static_cast(i); if (event.name == "ICHG") current = static_cast(i); + if (event.name == "IPRECHG") precharge = static_cast(i); if (event.name == "JEITA") jeita = static_cast(i); if (event.name == "MPPT" && event.value) mppt = static_cast(i); } require(cell >= 0 && minimum > cell, "VSYSMIN must be written after last CELL write"); require(current > cell && jeita > current, "ICHG must be restored before JEITA override"); + require(precharge > current && jeita > precharge, + "IPRECHG must be restored after ICHG and before JEITA override"); if (board.mppt_wish) { require(mppt > minimum && mppt > current, "MPPT must be requested after final register restoration"); require(bq.mppt, "MPPT should be accepted with source and VBAT above VSYSMIN"); diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp index a35c3b3887..fe3d007c6d 100644 --- a/variants/inhero_mr2/BoardConfigContainer.cpp +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -1141,7 +1141,9 @@ bool BoardConfigContainer::configureChemistry(BatteryType type) { // charging (LTO at 4.9V/0.93A: ~2W → BQ rides its thermal limit), and the // configured imax silently falls back to the 1A default on every boot. bq.setMinSystemV(BQ_MIN_SYSTEM_V); - bq.setChargeLimitA(getMaxChargeCurrent_mA() / 1000.0f); + const uint16_t chargeCurrent_mA = getMaxChargeCurrent_mA(); + bq.setChargeLimitA(chargeCurrent_mA / 1000.0f); + const bool prechargeConfigured = bq.setPrechargeLimitmA(chargeCurrent_mA); // Derive the JEITA override once ICHG holds the configured imax again. // Deriving it earlier leaves a window in which the temperature guard is off @@ -1154,7 +1156,7 @@ bool BoardConfigContainer::configureChemistry(BatteryType type) { // now instead of waiting for the next 60-second solar-maintenance cycle. bq.setMPPTenable(getMPPTEnabled()); - return true; + return prechargeConfigured; } // Gets current battery type from preferences @@ -1371,6 +1373,7 @@ bool BoardConfigContainer::setMaxChargeCurrent_mA(uint16_t maxChrgI) { prefs.putInt(MAXCHARGECURRENTKEY, maxChrgI); bool ok = bq.setChargeLimitA(maxChrgI / 1000.0f); + const bool prechargeOk = bq.setPrechargeLimitmA(maxChrgI); // Readback verification — detect silent I2C failures float readback = bq.getChargeLimitA(); @@ -1385,7 +1388,7 @@ bool BoardConfigContainer::setMaxChargeCurrent_mA(uint16_t maxChrgI) { // Recalculate solar IINDPM — it depends on charge current updateSolarIINDPM(); - return ok; + return ok && prechargeOk; } // Notify USB connection state change — adjusts IINDPM accordingly diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp index 7c22a795bc..e4ea0070b2 100644 --- a/variants/inhero_mr2/helpers/CliCommands.cpp +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -279,7 +279,9 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { // re-derived, and a state change is reported (same pattern as batcap). // The stored wish survives and re-arms once the gate passes again. bool wasActive = cfg.isJeitaIgnoreActive(); - cfg.setMaxChargeCurrent_mA(ma); + if (!cfg.setMaxChargeCurrent_mA(ma)) { + return "Err: Charge current setup failed"; + } cfg.applyJeitaIgnore(); if (wasActive && !cfg.isJeitaIgnoreActive()) { snprintf(ret, sizeof(ret), "Max charge current set to %s; jeitaignore N/A, C>0.05", diff --git a/variants/inhero_mr2/lib/BqDriver.cpp b/variants/inhero_mr2/lib/BqDriver.cpp index d7f02d1c61..57a3df313e 100644 --- a/variants/inhero_mr2/lib/BqDriver.cpp +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -582,6 +582,20 @@ bool BqDriver::setAutoIBATDIS(bool enable) { return auto_ibatdis_bit.write(enable ? 1 : 0); } +bool BqDriver::setPrechargeLimitmA(uint16_t current_mA) { + if (current_mA < 40 || current_mA > 2000) return false; + + // IPRECHG is REG08[5:0] in 40 mA steps. Preserve VBAT_LOWV in [7:6] + // and round down so precharge never exceeds the configured ICHG limit. + const uint8_t steps = static_cast(current_mA / 40); + uint8_t reg; + if (!readReg(BQ25798_REG_PRECHARGE_CONTROL, reg)) return false; + const uint8_t desired = (reg & 0xC0) | steps; + if (!writeReg(BQ25798_REG_PRECHARGE_CONTROL, desired)) return false; + return readReg(BQ25798_REG_PRECHARGE_CONTROL, reg) && + (reg & 0x3F) == steps; +} + // Non-static register access methods (use instance I2C config) bool BqDriver::writeReg(uint8_t reg, uint8_t val) { if (!ih_i2c_dev) return false; diff --git a/variants/inhero_mr2/lib/BqDriver.h b/variants/inhero_mr2/lib/BqDriver.h index 5c81ba27fe..8f6a03f419 100644 --- a/variants/inhero_mr2/lib/BqDriver.h +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -196,6 +196,10 @@ class BqDriver : public Adafruit_BQ25798 { bool getAutoIBATDIS(); bool setAutoIBATDIS(bool enable); + // Match precharge to the configured charge current (40 mA register steps). + // Returns false if the register write or readback fails. + bool setPrechargeLimitmA(uint16_t current_mA); + // Non-static register access methods (use instance I2C config) bool writeReg(uint8_t reg, uint8_t val); uint8_t readReg(uint8_t reg); From 228d5e3c0aab943ee417d8235abaf861855bc003 Mon Sep 17 00:00:00 2001 From: Wolfram Keil Date: Wed, 30 Sep 2026 16:28:28 +0200 Subject: [PATCH 8/8] Optimize MR2 charging configuration and RTC wake Align battery chemistry defaults and JEITA settings. Persist the MR2 RTC configuration and use averaged voltage monitoring with verified hourly wake scheduling. Validated with the INA, RTC, UVLO, VSYSMIN and MPPT host checks; all three MR2 environments and the RAK4631 repeater build pass. --- src/helpers/AutoDiscoverRTCClock.cpp | 70 ++++- test/mr2_ina_alert/fixture.inc | 108 +++++++ test/mr2_ina_alert/run.py | 41 +++ test/mr2_rtc_wake/README.md | 25 ++ test/mr2_rtc_wake/fixture.inc | 111 +++++++ test/mr2_rtc_wake/run_test.py | 51 ++++ test/mr2_rtc_wake/stubs/Arduino.h | 2 + test/mr2_rtc_wake/stubs/MeshCore.h | 2 + test/mr2_rtc_wake/stubs/Wire.h | 70 +++++ test/mr2_uvlo_flow/README.md | 19 ++ test/mr2_uvlo_flow/fixture.inc | 302 +++++++++++++++++++ test/mr2_uvlo_flow/run.py | 63 ++++ variants/inhero_mr2/BoardConfigContainer.cpp | 127 +++++--- variants/inhero_mr2/BoardConfigContainer.h | 23 +- variants/inhero_mr2/InheroMr2Board.cpp | 52 +++- variants/inhero_mr2/InheroMr2Board.h | 6 +- variants/inhero_mr2/README.md | 10 + variants/inhero_mr2/helpers/CliCommands.cpp | 75 ++--- variants/inhero_mr2/helpers/Rv3028Wake.cpp | 84 ++++-- variants/inhero_mr2/helpers/Rv3028Wake.h | 4 +- variants/inhero_mr2/lib/Ina228Driver.cpp | 12 +- variants/inhero_mr2/lib/Ina228Driver.h | 3 +- 22 files changed, 1117 insertions(+), 143 deletions(-) create mode 100644 test/mr2_ina_alert/fixture.inc create mode 100644 test/mr2_ina_alert/run.py create mode 100644 test/mr2_rtc_wake/README.md create mode 100644 test/mr2_rtc_wake/fixture.inc create mode 100644 test/mr2_rtc_wake/run_test.py create mode 100644 test/mr2_rtc_wake/stubs/Arduino.h create mode 100644 test/mr2_rtc_wake/stubs/MeshCore.h create mode 100644 test/mr2_rtc_wake/stubs/Wire.h create mode 100644 test/mr2_uvlo_flow/README.md create mode 100644 test/mr2_uvlo_flow/fixture.inc create mode 100644 test/mr2_uvlo_flow/run.py diff --git a/src/helpers/AutoDiscoverRTCClock.cpp b/src/helpers/AutoDiscoverRTCClock.cpp index af08fddb03..eb9093ed46 100644 --- a/src/helpers/AutoDiscoverRTCClock.cpp +++ b/src/helpers/AutoDiscoverRTCClock.cpp @@ -20,6 +20,69 @@ static bool rtc_8130_success = false; #define PCF8563_ADDRESS 0x51 #define RX8130CE_ADDRESS 0x32 +#if defined(INHERO_MR2) +static bool configureMr2RtcBackup(TwoWire& wire) { + // The library's EEPROM helpers omit the command delays and I2C error checks. + auto read = [&wire](uint8_t reg, uint8_t& value) { + wire.beginTransmission(RV3028_ADDRESS); + wire.write(reg); + if (wire.endTransmission(false) != 0 || + wire.requestFrom((uint8_t)RV3028_ADDRESS, (uint8_t)1) != 1) return false; + value = wire.read(); + return true; + }; + auto write = [&wire](uint8_t reg, uint8_t value) { + wire.beginTransmission(RV3028_ADDRESS); + wire.write(reg); + wire.write(value); + return wire.endTransmission() == 0; + }; + auto waitReady = [&read]() { + const uint32_t start = millis(); + do { + uint8_t status; + if (!read(0x0E, status)) return false; + if ((status & 0x80) == 0) return true; // EEbusy + delay(1); + } while ((uint32_t)(millis() - start) < 500); + return false; + }; + auto startBackupRead = [&]() { + return write(0x25, 0x37) && write(0x27, 0x00) && write(0x27, 0x22); + }; + + uint8_t control1; + if (!read(0x0F, control1)) return false; + const bool ok = [&]() { + if (!write(0x0F, control1 | 0x08) || !waitReady()) return false; // EERD + if (!startBackupRead()) return false; + delay(1); // RV-3028 manual 4.6.7: wait before checking EEbusy after a read. + uint8_t stored; + if (!waitReady() || !read(0x26, stored)) return false; + // No backup battery: BSM=00, TCE=0, BSIE=0, FEDE=1. Keep EEOffset[0]/TCR. + const uint8_t desired = (stored & 0x83) | 0x10; + if (stored != desired) { + // Write only EEPROM byte 0x37, never issue an Update All command. + if (!write(0x25, 0x37) || !write(0x26, desired) || + !write(0x27, 0x00) || !write(0x27, 0x21)) return false; + delay(10); // RV-3028 manual 4.6.7: wait before checking EEbusy after a write. + if (!waitReady() || !startBackupRead()) return false; + delay(1); + uint8_t verified; + if (!waitReady() || !read(0x26, verified) || verified != desired) return false; + } + // A single-byte EEPROM write does not update the active RAM mirror. + uint8_t active; + return write(0x37, desired) && read(0x37, active) && active == desired; + }(); + + // Re-enable automatic refresh on both success and failure, without recovery. + const bool released = write(0x0F, control1 & ~0x08); + uint8_t finalControl1; + return ok && released && read(0x0F, finalControl1) && (finalControl1 & 0x08) == 0; +} +#endif + bool AutoDiscoverRTCClock::i2c_probe(TwoWire& wire, uint8_t addr) { wire.beginTransmission(addr); uint8_t error = wire.endTransmission(); @@ -36,9 +99,14 @@ void AutoDiscoverRTCClock::begin(TwoWire& wire) { if (i2c_probe(wire, RV3028_ADDRESS)) { rtc_rv3028.initI2C(wire); rtc_rv3028.writeToRegister(0x35, 0x00); +#if defined(INHERO_MR2) + // MR2 has no backup battery: VDD and VBACKUP share the 3.3 V supply. + rv3028_success = configureMr2RtcBackup(wire); +#else rtc_rv3028.writeToRegister(0x37, 0xB4); // Direct Switching Mode (DSM): when VDD < VBACKUP, switchover occurs from VDD to VBACKUP - rtc_rv3028.set24HourMode(); // Set the device to use the 24hour format (default) instead of the 12 hour format rv3028_success = true; +#endif + rtc_rv3028.set24HourMode(); // Set the device to use the 24hour format (default) instead of the 12 hour format } if (i2c_probe(wire, PCF8563_ADDRESS)) { diff --git a/test/mr2_ina_alert/fixture.inc b/test/mr2_ina_alert/fixture.inc new file mode 100644 index 0000000000..50028dd45f --- /dev/null +++ b/test/mr2_ina_alert/fixture.inc @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include + +// PRODUCTION_DEFINES +#define MESH_DEBUG_PRINTLN(...) ((void)0) +void delay(int) {} +void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +class Ina228Driver { +public: + uint16_t control = 0; + uint16_t flags = 0; + unsigned writes = 0; + unsigned staleReads = 0; + void enableAlert(bool enable_uvlo, bool active_high, bool latch_alert); + bool writeRegister16(uint8_t reg, uint16_t value) { + check(reg == INA228_REG_DIAG_ALRT, "unexpected register write"); + ++writes; + control = value; + return true; + } + uint16_t readRegister16(uint8_t reg) { + check(reg == INA228_REG_DIAG_ALRT, "unexpected register read"); + if (staleReads) { + --staleReads; + return (control & ~INA228_DIAG_ALRT_SLOWALERT) | flags; + } + return control | flags; + } +}; + +// PRODUCTION_METHOD + +// INA228 datasheet Table 7-16: SLOWALERT selects raw or averaged comparison. +bool underVoltage(uint16_t diag, unsigned rawMv, unsigned averagedMv) { + return ((diag & 0x2000) ? averagedMv : rawMv) < 2500; +} + +int main() { + const std::vector>> tests = { + {"UVLO uses averaging with latch and active-low polarity", [] { + Ina228Driver ina; + ina.enableAlert(true, false, true); + check(ina.control == 0xa000, "expected SLOWALERT + ALATCH (0xa000)"); + check(ina.writes == 1, "successful configuration unexpectedly retried"); + }}, + {"latch and polarity remain independent of averaging", [] { + Ina228Driver ina; + ina.enableAlert(true, false, false); + check(ina.control == 0x2000, "transparent active-low should retain averaging"); + ina.enableAlert(true, true, true); + check(ina.control == 0xb000, "active-high should retain latch and averaging"); + }}, + {"disarming clears averaging and latch", [] { + Ina228Driver ina; + ina.enableAlert(true, false, true); + ina.enableAlert(false, false, false); + check(ina.control == 0, "disarm retained configuration bits"); + ina.enableAlert(false, true, true); + check(ina.control == 0x9000, "disabled UVLO changed requested latch or polarity"); + }}, + {"readback retries if SLOWALERT did not stick", [] { + Ina228Driver ina; + ina.staleReads = 1; + ina.enableAlert(true, false, true); + check(ina.writes == 2, "missing SLOWALERT was accepted during readback"); + check(ina.control == 0xa000, "retry lost averaging"); + }}, + {"changing status flags do not trigger configuration retries", [] { + Ina228Driver ina; + ina.flags = 0x0fff; + ina.enableAlert(true, false, true); + check(ina.writes == 1, "status flags affected configuration verification"); + }}, + {"persistent configuration mismatch retains bounded retries", [] { + Ina228Driver ina; + ina.staleReads = 10; + ina.enableAlert(true, false, true); + check(ina.writes == 3, "configuration retry limit changed"); + }}, + {"raw voltage dip is filtered; sustained undervoltage still alerts", [] { + Ina228Driver ina; + ina.enableAlert(true, false, true); + check(!underVoltage(ina.control, 2400, 3250), "brief raw dip triggered UVLO"); + check(underVoltage(ina.control, 2400, 2440), "real averaged undervoltage missed"); + check(!underVoltage(ina.control, 2400, 2500), "threshold equality triggered UVLO"); + check(underVoltage(0x8000, 2400, 3250), "unfiltered negative control did not alert"); + }} + }; + unsigned failures = 0; + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS " << test.first << '\n'; + } catch (const std::exception& error) { + ++failures; + std::cerr << "FAIL " << test.first << ": " << error.what() << '\n'; + } + } + std::cout << tests.size() - failures << '/' << tests.size() << " passed\n"; + return failures ? 1 : 0; +} diff --git a/test/mr2_ina_alert/run.py b/test/mr2_ina_alert/run.py new file mode 100644 index 0000000000..5b92658b50 --- /dev/null +++ b/test/mr2_ina_alert/run.py @@ -0,0 +1,41 @@ +"""Host regression for the production INA228 alert configuration function. + +Run with Python 3; requires g++ on PATH or CXX pointing to a compiler. +Only register I/O is mocked. The final comparison check models datasheet +Table 7-16, not a physical ADC or a board timing measurement. +""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +DRIVER = ROOT / "variants/inhero_mr2/lib/Ina228Driver.cpp" +HEADER = DRIVER.with_suffix(".h") +source = DRIVER.read_text(encoding="utf-8") +start = source.index("void Ina228Driver::enableAlert(") +brace = source.index("{", start) +depth = 0 +for end in range(brace, len(source)): + depth += (source[end] == "{") - (source[end] == "}") + if depth == 0: + method = source[start:end + 1] + break +else: + raise ValueError("Unbalanced enableAlert function") +defines = "\n".join(re.findall(r"^#define INA228_(?:DIAG_ALRT_|REG_).*$", HEADER.read_text(encoding="utf-8"), re.M)) +fixture = Path(__file__).with_name("fixture.inc").read_text(encoding="utf-8") +code = fixture.replace("// PRODUCTION_DEFINES", defines).replace("// PRODUCTION_METHOD", method) +compiler = os.environ.get("CXX") or shutil.which("g++") +if not compiler and Path("C:/Tools/mingw64/bin/g++.exe").is_file(): + compiler = "C:/Tools/mingw64/bin/g++.exe" +if not compiler: + raise SystemExit("Set CXX to a C++17 compiler or add g++ to PATH") +with tempfile.TemporaryDirectory(prefix="mr2-ina-alert-") as temp: + cpp = Path(temp) / "test.cpp" + exe = Path(temp) / ("test.exe" if os.name == "nt" else "test") + cpp.write_text(code, encoding="utf-8") + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", str(cpp), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) diff --git a/test/mr2_rtc_wake/README.md b/test/mr2_rtc_wake/README.md new file mode 100644 index 0000000000..a36a8edb71 --- /dev/null +++ b/test/mr2_rtc_wake/README.md @@ -0,0 +1,25 @@ +# MR2 RTC wake verification + +Run with Python 3 and a C++17 compiler: + +```powershell +python test/mr2_rtc_wake/run_test.py --cxx C:/Tools/mingw64/bin/g++.exe +``` + +The runner compiles the complete production `Rv3028Wake.cpp` and its header, +copied verbatim into a temporary directory, against a small Wire register model. +It takes RTC register addresses from the real board header. Generated files and +the executable are temporary; the `.inc` fixture is excluded from PlatformIO's +normal C++ test discovery. + +Tests cover valid and clamped durations; restarting an active timer; NACKs on +every write and register selection; failure to queue any address or data byte; +acknowledged but ignored writes; short and inconsistent reads; incorrect +readback, including a timer flag that did not clear; and an absent RTC. Unrelated +registers must remain unchanged. Reserved register bits are ignored correctly. + +The helper makes one attempt and returns false on failure. Bus recovery, retry, +the GPIO wake input and deciding whether to enter System OFF belong to the board +caller, outside this test. This model does not prove electrical I2C behavior, +oscillator operation or a physical wake from System OFF. Those require a board +test with low voltage and RTC/I2C fault injection. diff --git a/test/mr2_rtc_wake/fixture.inc b/test/mr2_rtc_wake/fixture.inc new file mode 100644 index 0000000000..82194147f1 --- /dev/null +++ b/test/mr2_rtc_wake/fixture.inc @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include "helpers/Rv3028Wake.h" + +FakeWire Wire; +unsigned cases = 0; + +void check(bool condition, const char* name, unsigned index = 0) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s [%u]\n", name, index); + std::exit(1); + } +} + +void reset() { + Wire = {}; + // Previously armed/expired timer makes ignored stop, interrupt-disable and + // flag-clear writes observable, even if the final armed values would match. + Wire.registers[0x0F] = 0x87; + Wire.registers[0x10] = 0x10; + Wire.registers[0x0E] = 0x08; + Wire.registers[0x0A] = 0x33; + Wire.registers[0x0B] = 0x03; + Wire.registers[0x00] = 0x42; // Time/calendar must not be changed. + Wire.registers[0x35] = 0x48; // CLKOUT and backup configuration stay untouched. + Wire.registers[0x37] = 0x93; +} + +void expectFailure(const char* name, unsigned index = 0) { + ++cases; + check(!inhero::configurePeriodicWake(60), name, index); +} + +int main() { + const std::array, 7> durations{{ + {{0, 1}}, {{1, 1}}, {{60, 60}}, {{256, 256}}, + {{4095, 4095}}, {{4096, 4095}}, {{65535, 4095}} + }}; + for (const auto& duration : durations) { + reset(); + const auto before = Wire.registers; + ++cases; + check(inhero::configurePeriodicWake(duration[0]), "valid duration", duration[0]); + check(Wire.registers[0x0A] == (duration[1] & 0xFF) && + Wire.registers[0x0B] == (duration[1] >> 8), "timer preset", duration[0]); + check(Wire.registers[0x0F] == 0x07 && Wire.registers[0x10] == 0x10 && + (Wire.registers[0x0E] & 0x08) == 0 && Wire.starts == 1, + "fresh single-shot timer armed", duration[0]); + for (unsigned reg = 0; reg < before.size(); ++reg) { + if (reg == 0x0A || reg == 0x0B || reg == 0x0E || reg == 0x0F || reg == 0x10) continue; + check(Wire.registers[reg] == before[reg], "unrelated register changed", reg); + } + } + + reset(); + check(inhero::configurePeriodicWake(60), "baseline"); + const unsigned transactions = Wire.transfers; + const unsigned bytes = Wire.queuedBytes; + const unsigned writes = Wire.dataWrites; + const auto readRegisters = Wire.readRegisters; + check(transactions > writes && !readRegisters.empty(), "verification reads present"); + + for (unsigned i = 1; i <= transactions; ++i) { + reset(); + Wire.nackAt = i; + expectFailure("NACK on write or read selection", i); + } + for (unsigned i = 1; i <= bytes; ++i) { + reset(); + Wire.queueFailureAt = i; + expectFailure("queue failure on any address/data byte", i); + } + for (unsigned i = 1; i <= writes; ++i) { + reset(); + Wire.dropWriteAt = i; + expectFailure("ACK but write ignored", i); + } + for (unsigned i = 1; i <= readRegisters.size(); ++i) { + for (const auto& counts : std::array, 4>{{ + {{0, 0}}, {{1, 0}}, {{0, 1}}, {{2, 2}}}}) { + reset(); + Wire.badReadAt = i; + Wire.readReported = counts[0]; + Wire.readDelivered = counts[1]; + expectFailure("short or inconsistent verification read", i); + check(Wire.available() == 0, "failed read buffer drained", i); + } + reset(); + Wire.corruptReadAt = i; + expectFailure("incorrect register readback / TF still set", i); + } + + reset(); + Wire.absent = true; + expectFailure("RTC absent"); + reset(); + Wire.reservedReadBits = true; + ++cases; + check(inhero::configurePeriodicWake(60), "reserved bits do not reject valid timer"); + + // Two successful calls must start two fresh countdowns, including when the + // first timer is still active. This also guards stop-before-rearm behavior. + ++cases; + check(inhero::configurePeriodicWake(60) && Wire.starts == 2, "rearm running timer"); + std::printf("PASS: %u RTC wake cases (durations, all %u transfers, %u queued bytes, " + "%u writes, %zu verification reads, absent RTC and rearm)\n", + cases, transactions, bytes, writes, readRegisters.size()); + return 0; +} diff --git a/test/mr2_rtc_wake/run_test.py b/test/mr2_rtc_wake/run_test.py new file mode 100644 index 0000000000..268e71e0f4 --- /dev/null +++ b/test/mr2_rtc_wake/run_test.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compile the complete production RTC wake helper with a host Wire model.""" + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=shutil.which("g++")) + args = parser.parse_args() + if not args.cxx: + parser.error("g++ not found; supply --cxx /path/to/g++") + + with tempfile.TemporaryDirectory(prefix="mr2-rtc-wake-") as temporary: + directory = Path(temporary) + helper = directory / "helpers" + helper.mkdir() + source = ROOT / "variants/inhero_mr2" + # Copy both files verbatim. Only hardware/framework dependencies are + # replaced; no production function extraction or rewriting is needed. + for name in ("Rv3028Wake.cpp", "Rv3028Wake.h"): + shutil.copyfile(source / "helpers" / name, helper / name) + board_header = (source / "InheroMr2Board.h").read_text(encoding="utf-8") + constants = re.findall( + r"^#define\s+(?:RTC_I2C_ADDR|RV3028_REG_\w+)\s+[^\n]+", + board_header, re.MULTILINE) + if len(constants) != 6: + raise ValueError("Unexpected RTC register declarations") + (directory / "InheroMr2Board.h").write_text( + "\n".join(constants) + "\n", encoding="utf-8") + executable = directory / "rtc_wake_test.exe" + subprocess.run([ + args.cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-I", str(HERE / "stubs"), "-I", str(directory), + "-x", "c++", str(HERE / "fixture.inc"), + str(helper / "Rv3028Wake.cpp"), "-o", str(executable), + ], check=True) + return subprocess.run([str(executable)]).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/mr2_rtc_wake/stubs/Arduino.h b/test/mr2_rtc_wake/stubs/Arduino.h new file mode 100644 index 0000000000..8b14fbed74 --- /dev/null +++ b/test/mr2_rtc_wake/stubs/Arduino.h @@ -0,0 +1,2 @@ +#pragma once +#include diff --git a/test/mr2_rtc_wake/stubs/MeshCore.h b/test/mr2_rtc_wake/stubs/MeshCore.h new file mode 100644 index 0000000000..7d2ad0911c --- /dev/null +++ b/test/mr2_rtc_wake/stubs/MeshCore.h @@ -0,0 +1,2 @@ +#pragma once +#define MESH_DEBUG_PRINTLN(...) diff --git a/test/mr2_rtc_wake/stubs/Wire.h b/test/mr2_rtc_wake/stubs/Wire.h new file mode 100644 index 0000000000..dc0c980d65 --- /dev/null +++ b/test/mr2_rtc_wake/stubs/Wire.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +struct FakeWire { + std::array registers{}; + std::vector tx, rx, readRegisters; + uint8_t pointer = 0; + unsigned transfers = 0, queuedBytes = 0, dataWrites = 0, reads = 0, starts = 0; + unsigned nackAt = 0, queueFailureAt = 0, dropWriteAt = 0, badReadAt = 0; + unsigned corruptReadAt = 0; + uint8_t readReported = 0, readDelivered = 0; + bool absent = false, reservedReadBits = false; + + void beginTransmission(uint8_t) { tx.clear(); } + size_t write(uint8_t value) { + if (++queuedBytes == queueFailureAt) return 0; + tx.push_back(value); + return 1; + } + uint8_t endTransmission(bool = true) { + ++transfers; + if (absent || transfers == nackAt) return 2; + if (tx.empty()) return 0; + pointer = tx[0]; + if (tx.size() > 1 && ++dataWrites == dropWriteAt) return 0; + for (size_t i = 1; i < tx.size(); ++i) { + if (pointer == 0x0F && !(registers[pointer] & 0x04) && (tx[i] & 0x04)) ++starts; + // RV-3028 status flags clear on zero writes; EEbusy is read-only. + if (pointer == 0x0E) registers[pointer] &= tx[i] | 0x80; + else registers[pointer] = tx[i]; + ++pointer; + } + return 0; + } + uint8_t requestFrom(uint8_t, uint8_t count) { + ++reads; + readRegisters.push_back(pointer); + rx.clear(); + if (absent) return 0; + if (reads == badReadAt) { + rx.assign(readDelivered, registers[pointer]); + return readReported; + } + for (uint8_t i = 0; i < count; ++i) { + const uint8_t address = pointer++; + uint8_t value = registers[address]; + if (reads == corruptReadAt) value ^= address == 0x0E ? 0x08 : 0x01; + if (reservedReadBits) { + if (address == 0x0B) value |= 0xF0; + if (address == 0x0F) value |= 0x40; + if (address == 0x0E) value |= 0x80; // EEbusy is unrelated to TF. + } + rx.push_back(value); + } + return count; + } + int available() const { return static_cast(rx.size()); } + int read() { + if (rx.empty()) return -1; + const uint8_t value = rx.front(); + rx.erase(rx.begin()); + return value; + } +}; + +extern FakeWire Wire; diff --git a/test/mr2_uvlo_flow/README.md b/test/mr2_uvlo_flow/README.md new file mode 100644 index 0000000000..096409b151 --- /dev/null +++ b/test/mr2_uvlo_flow/README.md @@ -0,0 +1,19 @@ +# MR2 UVLO control-flow regression + +Run with Python 3 and a C++17 compiler: + +```powershell +python test/mr2_uvlo_flow/run.py --cxx C:/Tools/mingw64/bin/g++.exe +``` + +The runner extracts the production board boot, shutdown, RTC wake wrapper, board +loop and periodic dispatch functions, then compiles them against host hardware +fakes. It exercises validation failure, an asserted RTC INT pin, bounded recovery, +successful sleep, both early boot paths, continued watchdog/periodic work after a +failed shutdown, retry throttling, recovered or invalid voltage, sticky INA ALERT +and the 32-bit millisecond rollover. No production decision logic is copied into +the fixture. Temporary generated sources/binaries live outside the repository. + +These checks cover board sequencing and decisions; the RTC configuration helper +is a fake at this layer. They do not verify register writes, electrical wake-up, +I2C timing, ADC averaging or battery behavior on physical hardware. diff --git a/test/mr2_uvlo_flow/fixture.inc b/test/mr2_uvlo_flow/fixture.inc new file mode 100644 index 0000000000..b1937ef40a --- /dev/null +++ b/test/mr2_uvlo_flow/fixture.inc @@ -0,0 +1,302 @@ +// Production function bodies are extracted by run.py. This fixture supplies +// only hardware/dependency fakes; the sleep and retry decisions are production. +#include +#include +#include +#include +#include +#include +#include +#include + +struct EnteredSystemOff {}; +struct Model { + uint32_t now = 0; + uint16_t voltage = 2400; + std::vector rtc_results{false, false}; + std::vector rtc_levels{1, 1}; + int rtc_calls = 0, recoveries = 0, rtc_level = 1, ina_level = 1; + int initialized = 0, wdt_started = 0, wdt_fed = 0; + int stopped = 0, radio_off = 0, ics_off = 0, ina_off = 0; + int mppt = 0, soc = 0, hourly = 0, wire_ends = 0, wire_begins = 0; + int flag_clears = 0, voltage_reads = 0; + bool sticky_ina = false; + std::vector ticks; + std::vector events; +} model; + +enum { LOW = 0, HIGH = 1, INPUT = 0, OUTPUT = 1, INPUT_PULLUP = 2, FALLING = 3 }; +#define RTC_INT_PIN 17 +#define INA_ALERT_PIN 34 +#define BQ_CE_PIN 4 +#define PIN_BOARD_SDA 13 +#define PIN_BOARD_SCL 14 +#define SX126X_POWER_EN 37 +#define PIN_VBAT_READ 5 +#define LED_BLUE 11 +#define PIN_LED1 11 +#define PIN_LED2 12 +#define RTC_I2C_ADDR 0x52 +#define INA228_I2C_ADDR 0x40 +#define BQ25798_I2C_ADDR 0x6B +#define BME280_I2C_ADDR 0x76 +#define RV3028_REG_STATUS 0x0E +#define LOW_VOLTAGE_SLEEP_MINUTES 60 +#define SHUTDOWN_REASON_NONE 0 +#define SHUTDOWN_REASON_LOW_VOLTAGE 1 +#define GPREGRET2_LOW_VOLTAGE_SLEEP 4 +#define SOLAR_MPPT_INTERVAL_MS 60000UL +enum { GPIO_PIN_CNF_DIR_Input = 0, GPIO_PIN_CNF_DIR_Pos = 0, + GPIO_PIN_CNF_INPUT_Connect = 0, GPIO_PIN_CNF_INPUT_Pos = 0, + GPIO_PIN_CNF_PULL_Pullup = 0, GPIO_PIN_CNF_PULL_Pos = 0, + GPIO_PIN_CNF_DRIVE_S0S1 = 0, GPIO_PIN_CNF_DRIVE_Pos = 0, + GPIO_PIN_CNF_SENSE_Low = 0, GPIO_PIN_CNF_SENSE_Pos = 0 }; +template void debug(const char*, Args...) {} +#define MESH_DEBUG_PRINTLN(...) debug(__VA_ARGS__) + +struct Power { uint8_t GPREGRET2 = 0; uint32_t SYSTEMOFF = 0; } power; +struct Gpio { uint32_t PIN_CNF[64]{}; uint32_t LATCH = 0; } gpio; +auto* NRF_POWER = &power; +auto* NRF_GPIO = &gpio; +auto* NRF_P0 = &gpio; +uint32_t millis() { return model.now; } +void delay(uint32_t ms) { model.now += ms; } +void pinMode(int, int) {} +void digitalWrite(int, int) {} +int digitalRead(int pin) { return pin == RTC_INT_PIN ? model.rtc_level : model.ina_level; } +int digitalPinToInterrupt(int pin) { return pin; } +void attachInterrupt(int, void(*)(), int) {} +void blinkRed(int, int, int, bool) {} +void sd_power_system_off() { model.events.push_back("systemoff"); throw EnteredSystemOff{}; } +void __WFE() { throw std::runtime_error("unexpected fallback WFE"); } + +struct FakeWire { + int available_bytes = 0; + void setPins(int, int) {} + void begin() { ++model.wire_begins; model.events.push_back("wire.begin"); } + void end() { ++model.wire_ends; model.events.push_back("wire.end"); } + void beginTransmission(int) {} + void write(int) {} + int endTransmission(bool = true) { return 0; } + int requestFrom(int, int count) { available_bytes = count; return count; } + int available() { return available_bytes; } + int read() { --available_bytes; return 0; } +} Wire; + +struct Ina228Driver { + static uint16_t readVBATDirect(FakeWire*, uint8_t) { return model.voltage; } + uint16_t readVoltage_mV() { ++model.voltage_reads; return model.voltage; } + void enableAlert(bool, bool, bool, bool = false) {} + bool setUnderVoltageAlert(int) { return true; } + void shutdown() { ++model.ina_off; model.events.push_back("ina.off"); } + void clearAlert() { + ++model.flag_clears; + if (!model.sticky_ina) model.ina_level = HIGH; + } +} ina; + +struct BoardConfigContainer { + enum BatteryType { BAT_UNKNOWN, BAT_NA_ION_1S }; + struct BatteryProperties { bool charge_enable; }; + static const BatteryProperties* getBatteryProperties(BatteryType) { + static BatteryProperties props{true}; return &props; + } + static inline Ina228Driver* ina228DriverInstance = &ina; + static inline bool lowVoltageAlertFired = false; + static inline uint16_t lowVoltageSleepMv = 2500; + static inline bool leds_enabled = false; + static inline struct { int unused; } mpptStats{}; + uint32_t lastMpptMs = 0, lastSocMs = 0, lastLowVoltageMs = 0, lastHourlyMs = 0; + bool tickInitialized = false, lowVoltageSleepRetryPending = false; + uint32_t lastLowVoltageSleepAttemptMs = 0; + BatteryType getBatteryType() { return BAT_NA_ION_1S; } + bool getMPPTEnabled() { return true; } + Ina228Driver* getIna228Driver() { return &ina; } + void begin() { ++model.initialized; model.events.push_back("board.begin"); } + bool getStationAltitude(float&) { return false; } + void setLowVoltageRecovery() {} + static bool setSOCManually(float) { return true; } + static void setupWatchdog() { ++model.wdt_started; } + static void feedWatchdog() { ++model.wdt_fed; } + static void setUsbConnected(bool) {} + static void stopBackgroundTasks() { ++model.stopped; model.events.push_back("tasks.stop"); } + void tickPeriodic(); + void runMpptCycle() { ++model.mppt; } + void updateBatterySOC() { ++model.soc; } + void updateHourlyStats() { ++model.hourly; } +}; +struct NRF52BoardDCDC { void begin() {} }; +struct InheroMr2Board : NRF52BoardDCDC { + static inline volatile bool rtc_irq_pending = false; + static inline volatile uint32_t ota_dfu_reset_at = 0; + void begin(); + void loop(); + void initiateShutdown(uint8_t); + bool configureRTCWake(uint32_t); + uint16_t getLowVoltageWakeThreshold() { return 2700; } + uint16_t getLowVoltageSleepThreshold() { return 2500; } + static void rtcInterruptHandler() { rtc_irq_pending = true; } + void enterOTADfu() { throw std::runtime_error("unexpected DFU"); } + void sleep(uint32_t) {} +} board; +static BoardConfigContainer boardConfig; +struct Sensors { + void setBme280StationAltitude(float) {} + void setBoardTelemetrySource(BoardConfigContainer&) {} +} sensors; + +namespace inhero { +void clearTimerFlag() {} +void recoverI2cBus(int, int) { ++model.recoveries; model.events.push_back("recover"); } +bool configurePeriodicWake(uint16_t ticks) { + auto index = static_cast(model.rtc_calls++); + model.ticks.push_back(ticks); + model.events.push_back("rtc.configure"); + model.rtc_level = model.rtc_levels.at(std::min(index, model.rtc_levels.size() - 1)); + return model.rtc_results.at(std::min(index, model.rtc_results.size() - 1)); +} +void maintainSolarDuringLowVoltageWake(bool) {} +void prepareIcsForSystemOff() { ++model.ics_off; model.events.push_back("ics.off"); } +void prepareRadioForSystemOff(bool = true) { ++model.radio_off; model.events.push_back("radio.off"); } +void disconnectLeakyPullups() {} +bool isUsbPowered() { return false; } +void serviceUsbAutoManagement() {} +} + +#include "production.inc" + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +void reset() { + model = Model{}; + power = Power{}; + gpio = Gpio{}; + boardConfig = BoardConfigContainer{}; + BoardConfigContainer::lowVoltageAlertFired = false; + BoardConfigContainer::lowVoltageSleepMv = 2500; + InheroMr2Board::rtc_irq_pending = false; + InheroMr2Board::ota_dfu_reset_at = 0; +} +template bool slept(Operation operation) { + try { operation(); } catch (const EnteredSystemOff&) { return true; } + return false; +} +void no_shutdown_effects() { + require(model.stopped == 0 && model.radio_off == 0 && model.ics_off == 0 && model.ina_off == 0, + "failed wake validation must not shut down tasks, radio or INA"); + require(power.GPREGRET2 == 0, "failed wake validation must not leave a shutdown marker"); +} + +int main() { + try { + reset(); + require(!slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "failed RTC must not sleep"); + no_shutdown_effects(); + require(model.rtc_calls == 2 && model.recoveries == 1, "RTC setup must have one bounded recovery retry"); + + reset(); model.rtc_results = {true, true}; model.rtc_levels = {LOW, LOW}; + require(!slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "asserted RTC INT must not sleep"); + no_shutdown_effects(); + require(model.rtc_calls == 2 && model.recoveries == 1, "stuck INT should get one bounded recovery retry"); + + reset(); model.rtc_results = {true, true}; model.rtc_levels = {LOW, HIGH}; + require(slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "released RTC INT on retry must allow sleep"); + require(model.rtc_calls == 2 && model.recoveries == 1, "recovered INT needs exactly one recovery retry"); + + reset(); model.rtc_results = {false, true}; + require(slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "recovered RTC must allow sleep"); + require(model.rtc_calls == 2 && model.recoveries == 1 && model.stopped == 1, + "validated retry must proceed to one shutdown"); + require(power.GPREGRET2 == (GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE), + "successful sleep must retain UVLO reason"); + auto validated = std::find(model.events.begin(), model.events.end(), "tasks.stop"); + require(std::count(model.events.begin(), validated, "rtc.configure") == 2, + "RTC validation must precede destructive shutdown work"); + + reset(); model.rtc_results = {true}; + require(board.configureRTCWake(0), "default interval must validate"); + require(model.ticks.back() == LOW_VOLTAGE_SLEEP_MINUTES, "zero uses configured sleep interval"); + require(board.configureRTCWake(9000) && model.ticks.back() == 4095, "interval must clamp to RTC width"); + require(model.recoveries == 0, "healthy wake configuration needs no bus recovery"); + + for (bool wake : {false, true}) { + reset(); + if (wake) power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(!slept([] { board.begin(); }), "failed early RTC must continue normal boot"); + no_shutdown_effects(); + require(model.initialized == 1 && model.wdt_started == 1, "failed early sleep must reach board init and watchdog"); + require(model.rtc_calls == 2, "early wake failure must not retry through a second boot path"); + + reset(); model.rtc_results = {true}; + if (wake) power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(slept([] { board.begin(); }), "validated early low voltage must sleep"); + require(model.initialized == 0 && model.wdt_started == 0, "successful early sleep must keep fast path"); + require(model.rtc_calls == 1, "healthy early path needs exactly one RTC validation"); + } + + reset(); model.voltage = 0; + power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(!slept([] { board.begin(); }), "unreadable VBAT plus RTC failure must not trap fast wake in sleep"); + no_shutdown_effects(); + require(model.initialized == 1 && model.wdt_started == 1 && model.rtc_calls == 2, + "unreadable fast-wake voltage must reach board init and WDT after bounded RTC failure"); + + reset(); model.voltage = 3250; + power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(!slept([] { board.begin(); }), "recovered voltage must boot normally"); + require(model.initialized == 1 && model.wdt_started == 1 && model.rtc_calls == 0, + "voltage recovery must not depend on RTC wake availability"); + + reset(); model.now = 3600000; + BoardConfigContainer::lowVoltageAlertFired = true; + board.loop(); + no_shutdown_effects(); + require(boardConfig.lowVoltageSleepRetryPending, "failed runtime sleep must enter retry state"); + require(model.wdt_fed == 1 && model.mppt == 1 && model.soc == 1 && model.hourly == 1, + "failed shutdown must preserve periodic work and watchdog feeds"); + auto retry_at = boardConfig.lastLowVoltageSleepAttemptMs; + model.now = retry_at + 59999; + BoardConfigContainer::lowVoltageAlertFired = true; + model.ina_level = LOW; + board.loop(); + require(model.rtc_calls == 2 && model.wdt_fed == 2, "ISR and asserted ALERT must respect 60s cooldown"); + model.now = retry_at + 60000; + model.rtc_results = {true}; + require(slept([] { board.loop(); }), "valid persistent low voltage retries after cooldown"); + + for (uint16_t voltage : {uint16_t(0), uint16_t(3250)}) { + reset(); BoardConfigContainer::lowVoltageAlertFired = true; board.loop(); + model.voltage = voltage; + model.rtc_results = {true}; + model.ina_level = LOW; + BoardConfigContainer::lowVoltageAlertFired = true; + model.now = boardConfig.lastLowVoltageSleepAttemptMs + 60000; + require(!slept([] { board.loop(); }), "invalid/healed voltage must not retry stale alert"); + no_shutdown_effects(); + require(model.rtc_calls == 2, "invalid/healed voltage must not reconfigure RTC"); + require(boardConfig.lowVoltageSleepRetryPending == (voltage == 0), "valid healed voltage releases retry state"); + } + + reset(); BoardConfigContainer::lowVoltageAlertFired = true; board.loop(); + model.voltage = 3250; model.sticky_ina = true; model.ina_level = LOW; + model.rtc_results = {true}; + model.now = boardConfig.lastLowVoltageSleepAttemptMs + 60000; + require(!slept([] { board.loop(); }), "healthy voltage with stuck ALERT must remain awake"); + require(boardConfig.lowVoltageSleepRetryPending, "stuck ALERT must remain guarded against stale retries"); + model.now += 1; board.loop(); + require(model.rtc_calls == 2, "stuck pin must not retrigger shutdown on next loop"); + + reset(); model.now = UINT32_MAX - 30000; + BoardConfigContainer::lowVoltageAlertFired = true; board.loop(); + model.now = boardConfig.lastLowVoltageSleepAttemptMs + uint32_t(60000); + model.rtc_results = {true}; + require(slept([] { board.loop(); }), "retry cooldown must survive millis wraparound"); + + std::cout << "PASS MR2 UVLO flow: RTC/INT failures, bounded recovery, boot/runtime gates, cooldown, voltage recovery, wraparound\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } +} diff --git a/test/mr2_uvlo_flow/run.py b/test/mr2_uvlo_flow/run.py new file mode 100644 index 0000000000..424ed0d031 --- /dev/null +++ b/test/mr2_uvlo_flow/run.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Exercise the production MR2 UVLO control flow with host hardware fakes.""" + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def function(text, signature): + start = text.index(signature) + opening = text.index("{", start) + depth = 0 + for token in re.finditer( + r'//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', + text[opening:], re.DOTALL, + ): + if token.group() == "{": + depth += 1 + elif token.group() == "}": + depth -= 1 + if depth == 0: + return text[start:opening + token.end()] + raise ValueError(f"Unbalanced function: {signature}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=shutil.which("g++")) + args = parser.parse_args() + if not args.cxx: + parser.error("g++ not found; supply --cxx /path/to/g++") + board = (ROOT / "variants/inhero_mr2/InheroMr2Board.cpp").read_text(encoding="utf-8") + config = (ROOT / "variants/inhero_mr2/BoardConfigContainer.cpp").read_text(encoding="utf-8") + production = "\n\n".join([ + *(function(board, signature) for signature in ( + "static bool restoreConfiguredChargeEnable()", + "void InheroMr2Board::begin()", + "void InheroMr2Board::loop()", + "void InheroMr2Board::initiateShutdown(uint8_t reason)", + "bool InheroMr2Board::configureRTCWake(uint32_t minutes)", + )), + function(config, "void BoardConfigContainer::tickPeriodic()"), + ]) + with tempfile.TemporaryDirectory(prefix="mr2-uvlo-flow-") as temporary: + directory = Path(temporary) + (directory / "production.inc").write_text(production, encoding="utf-8") + executable = directory / "flow.exe" + subprocess.run([ + args.cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-x", "c++", str(HERE / "fixture.inc"), "-I", str(directory), + "-o", str(executable), + ], check=True) + return subprocess.run([str(executable)]).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp index fe3d007c6d..6b28da2d23 100644 --- a/variants/inhero_mr2/BoardConfigContainer.cpp +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -769,7 +769,7 @@ bool BoardConfigContainer::begin() { // Charger active by default — HIZ-Gate removed (Rev 1.1 PCB stable). bq.setHIZMode(false); - this->setFrostChargeBehaviour(frost); + this->setFrostChargeBehaviour(getFrostChargeBehaviour()); this->setMaxChargeCurrent_mA(maxChargeCurrent_mA); // Mask ALL BQ25798 interrupts — INT pin is not used (polling only). @@ -1195,8 +1195,8 @@ bool BoardConfigContainer::getMPPTEnabled() const { // === JEITA override (board.jeitaignore) === -// Loads the stored user wish. Only meaningful for needs_jeita chemistries. -bool BoardConfigContainer::loadJeitaIgnoreWish(bool& on) const { +// Loads the accepted user override. Only meaningful for needs_jeita chemistries. +bool BoardConfigContainer::loadJeitaIgnoreEnabled(bool& on) const { SimplePreferences prefs; prefs.begin(PREFS_NAMESPACE); @@ -1209,13 +1209,13 @@ bool BoardConfigContainer::loadJeitaIgnoreWish(bool& on) const { return false; } -bool BoardConfigContainer::getJeitaIgnoreWish() const { +bool BoardConfigContainer::getJeitaIgnoreEnabled() const { bool on = false; - loadJeitaIgnoreWish(on); + loadJeitaIgnoreEnabled(on); return on; } -// The gate: batcap must be user-set and imax must not exceed 0.05C of it. +// The gate: batcap must be user-set and imax must be strictly below 0.05C. // The safety is this static bound, not a firmware control loop — in SYSTEMOFF // sleep the charger stays enabled and no loop runs, so an unattended frozen // cell must never see more than that rate. @@ -1228,22 +1228,29 @@ bool BoardConfigContainer::jeitaIgnoreGateOk() const { // the boot derivation would gate against 0 mAh and always fail. float cap_mah = 0.0f; loadBatteryCapacity(cap_mah); - return getMaxChargeCurrent_mA() <= jeitaIgnoreLimit_mA(cap_mah); + return isJeitaIgnoreCurrentAllowed(getMaxChargeCurrent_mA(), cap_mah); } -// Stores the wish and re-derives the effective state. The wish survives a -// failed gate — it re-arms as soon as imax/batcap pass again. -bool BoardConfigContainer::setJeitaIgnoreWish(bool on) { +bool BoardConfigContainer::setJeitaIgnore(bool on) { + const auto* props = getBatteryProperties(getBatteryType()); + if (!props || !props->needs_jeita || (on && !jeitaIgnoreGateOk())) return false; + const bool wasEnabled = getJeitaIgnoreEnabled(); SimplePreferences prefs; prefs.begin(PREFS_NAMESPACE); + // No hidden frost value: enabling and a real 1->0 transition reset it. + if ((on || wasEnabled) && + !prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(DEFAULT_FROST_BEHAVIOUR))) return false; if (!prefs.putString(JEITAIGNKEY, on ? "1" : "0")) { return false; } - applyJeitaIgnore(); + if (!applyJeitaIgnore(props)) { + prefs.putString(JEITAIGNKEY, wasEnabled ? "1" : "0"); + return false; + } return true; } -// Re-derives for the current chemistry (CLI writers of imax/batcap/wish). +// Applies the accepted setting for the current chemistry. bool BoardConfigContainer::applyJeitaIgnore() { return applyJeitaIgnore(getBatteryProperties(getBatteryType())); } @@ -1251,28 +1258,39 @@ bool BoardConfigContainer::applyJeitaIgnore() { // Derives the effective JEITA override and programs the BQ: // chemistry runs without JEITA (LTO, Na-ion, UNKNOWN) → forced on; for // Na-ion the cell datasheet sets the charge window, the board does not -// otherwise → user wish AND 0.05C gate +// otherwise → accepted user override // TS_IGNORE stops the BQ's temperature regulation permanently — deliberately -// including SYSTEMOFF sleep. Turning the override off restores the stored -// fmax mapping (ISETC); ISETH needs no restore, its POR default is UNCHANGED. +// including SYSTEMOFF sleep. Turning the override off restores default fmax. bool BoardConfigContainer::applyJeitaIgnore(const BatteryProperties* props) { if (!bqInitialized || !props) { jeitaIgnoreActive = false; return false; } - bool ignore = !props->needs_jeita || (getJeitaIgnoreWish() && jeitaIgnoreGateOk()); + bool enabled = getJeitaIgnoreEnabled(); + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + // Remove legacy deferred requests, and discard any frost value they hid. + if (enabled || !props->needs_jeita) { + if (getFrostChargeBehaviour() != DEFAULT_FROST_BEHAVIOUR && + !prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(DEFAULT_FROST_BEHAVIOUR))) return false; + } + if (enabled && (!props->needs_jeita || !jeitaIgnoreGateOk())) { + if (!prefs.putString(JEITAIGNKEY, "0")) return false; + enabled = false; + } + bool ignore = !props->needs_jeita || enabled; bool was_active = jeitaIgnoreActive; - jeitaIgnoreActive = ignore; - bq.setTsIgnore(ignore); + bool ok = bq.setTsIgnore(ignore); if (ignore) { - bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); - bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED); + ok = bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED) && ok; + ok = bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED) && ok; } else if (was_active) { - setFrostChargeBehaviour(getFrostChargeBehaviour()); + ok = setFrostChargeBehaviour(getFrostChargeBehaviour()) && ok; } - return ignore; + jeitaIgnoreActive = ok && ignore; + return ok; } // Enables or disables MPPT @@ -1304,6 +1322,20 @@ float BoardConfigContainer::getMaxChargeVoltage() const { // Sets battery type and reconfigures BQ accordingly bool BoardConfigContainer::setBatteryType(BatteryType type) { + if (!getBatteryProperties(type)) return false; + if (type == getBatteryType()) return true; + + // An actual chemistry change starts with battery/charging defaults. + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + if (!prefs.putInt(MAXCHARGECURRENTKEY, DEFAULT_MAX_CHARGE_CURRENT_MA) || + !prefs.putString(MPPTENABLEKEY, "0") || + !prefs.putString(JEITAIGNKEY, "0") || + !prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(DEFAULT_FROST_BEHAVIOUR)) || + !prefs.remove(BATTERY_CAPACITY_KEY) || + !prefs.putString(BATTKEY, getBatteryTypeCommandString(type))) return false; + loadBatteryCapacity(socStats.capacity_mah); + bool bqBaseConfigured = this->configureBaseBQ(); bool bqConfigured = this->configureChemistry(type); cachedBatteryType = type; // Update cache for static methods (updateBatterySOC, calculateTTL) @@ -1324,16 +1356,10 @@ bool BoardConfigContainer::setBatteryType(BatteryType type) { // === CRITICAL: Update INA228 low-voltage alert threshold when battery type changes === if (ina228DriverInstance) { - // Preferences still contain the previous chemistry until the write below. armLowVoltageAlert(type); 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) { @@ -1345,6 +1371,8 @@ bool BoardConfigContainer::setBatteryType(BatteryType type) { // Sets frost charge behavior (JEITA cold region) bool BoardConfigContainer::setFrostChargeBehaviour(FrostChargeBehaviour behaviour) { + const auto* props = getBatteryProperties(getBatteryType()); + if (!props || !props->needs_jeita || getJeitaIgnoreEnabled()) return false; switch (behaviour) { case BoardConfigContainer::FrostChargeBehaviour::NO_CHARGE: bq.setJeitaISetC(BQ25798_JEITA_ISETC_SUSPEND); @@ -1368,6 +1396,10 @@ bool BoardConfigContainer::setFrostChargeBehaviour(FrostChargeBehaviour behaviou // Sets maximum charge current (ICHG) and recalculates solar IINDPM // Note: Also calls updateSolarIINDPM() because IINDPM depends on ICHG. bool BoardConfigContainer::setMaxChargeCurrent_mA(uint16_t maxChrgI) { + if (getJeitaIgnoreEnabled()) { + float capacity = 0.0f; + if (!loadBatteryCapacity(capacity) || !isJeitaIgnoreCurrentAllowed(maxChrgI, capacity)) return false; + } SimplePreferences prefs; prefs.begin(PREFS_NAMESPACE); prefs.putInt(MAXCHARGECURRENTKEY, maxChrgI); @@ -1519,9 +1551,15 @@ bool BoardConfigContainer::isBatteryCapacitySet() const { // Set battery capacity manually via CLI (converts to mWh internally) bool BoardConfigContainer::setBatteryCapacity(float capacity_mah) { - if (capacity_mah < 100.0f || capacity_mah > 100000.0f) { + if (!(capacity_mah >= 100.0f && capacity_mah <= 100000.0f)) { return false; // Sanity check } + // Check the same one-decimal value the existing storage format will retain. + char buffer[20]; + snprintf(buffer, sizeof(buffer), "%.1f", capacity_mah); + capacity_mah = atof(buffer); + if (getJeitaIgnoreEnabled() && + !isJeitaIgnoreCurrentAllowed(getMaxChargeCurrent_mA(), capacity_mah)) return false; // Store user-configured capacity in mAh socStats.capacity_mah = capacity_mah; @@ -1538,8 +1576,6 @@ bool BoardConfigContainer::setBatteryCapacity(float capacity_mah) { 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", @@ -1852,6 +1888,7 @@ float BoardConfigContainer::readBmeTemperature() { // Fires → ISR → flag → tickPeriodic() → System Sleep. BAT_UNKNOWN = disabled. void BoardConfigContainer::armLowVoltageAlert(BatteryType bat_type) { disarmLowVoltageAlert(); + lowVoltageSleepRetryPending = false; if (!ina228DriverInstance) { return; } @@ -2252,7 +2289,24 @@ void BoardConfigContainer::tickPeriodic() { uint32_t now = millis(); - if (lowVoltageSleepMv != 0 && ina228DriverInstance) { + if (lowVoltageSleepMv != 0 && ina228DriverInstance && lowVoltageSleepRetryPending) { + // An aborted sleep leaves all hardware running. Bound RTC retries and do + // not act later on an old latched alert after the voltage has recovered. + if (now - lastLowVoltageSleepAttemptMs >= 60000UL) { + lastLowVoltageSleepAttemptMs = now; + lowVoltageAlertFired = false; + ina228DriverInstance->clearAlert(); + uint16_t vbat_mv = ina228DriverInstance->readVoltage_mV(); + if (vbat_mv > 0 && vbat_mv < lowVoltageSleepMv) { + lowVoltageSleepRetryPending = false; + lowVoltageAlertFired = true; + } else if (vbat_mv >= lowVoltageSleepMv && digitalRead(INA_ALERT_PIN) == HIGH) { + lowVoltageSleepRetryPending = false; + lowVoltageAlertFired = false; + } + // An unreadable voltage or uncleared latch keeps the bounded retry mode. + } + } else if (lowVoltageSleepMv != 0 && ina228DriverInstance) { if (digitalRead(INA_ALERT_PIN) == LOW) { lowVoltageAlertFired = true; } @@ -2270,14 +2324,17 @@ void BoardConfigContainer::tickPeriodic() { } // Check low-voltage alert flag (set by ISR, pin level, or voltage fallback) - if (lowVoltageAlertFired) { + if (lowVoltageAlertFired && !lowVoltageSleepRetryPending) { 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 + // Returns only when RTC wake could not be verified, before any shutdown. + lastLowVoltageSleepAttemptMs = millis(); + lowVoltageSleepRetryPending = true; + lowVoltageAlertFired = false; + if (ina228DriverInstance) ina228DriverInstance->clearAlert(); } // Every ~60s: MPPT cycle (solar charging control) diff --git a/variants/inhero_mr2/BoardConfigContainer.h b/variants/inhero_mr2/BoardConfigContainer.h index 09f9919b1a..b87ed0d1da 100644 --- a/variants/inhero_mr2/BoardConfigContainer.h +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -260,16 +260,15 @@ class BoardConfigContainer { float performTcCalibration(float* bme_temp_out = nullptr); static float readBmeTemperature(); - // JEITA override (board.jeitaignore). The stored value is the USER WISH and - // only exists for needs_jeita chemistries; the effective state is derived on - // every chemistry apply: forced on when the chemistry needs no JEITA, - // otherwise wish AND 0.05C gate. The wish survives a failed gate — it - // re-arms as soon as imax/batcap pass again. - bool setJeitaIgnoreWish(bool on); // store wish, re-derive, program the BQ - bool getJeitaIgnoreWish() const; // stored wish (default false) + // Accepted user override; rejected requests are never stored or reactivated. + bool setJeitaIgnore(bool on); + bool getJeitaIgnoreEnabled() const; bool isJeitaIgnoreActive() const { return jeitaIgnoreActive; } - bool jeitaIgnoreGateOk() const; // batcap user-set AND imax <= 0.05C - static float jeitaIgnoreLimit_mA(float capacity_mah) { return 0.05f * capacity_mah; } + bool jeitaIgnoreGateOk() const; // batcap user-set AND imax < 0.05C + static bool isJeitaIgnoreCurrentAllowed(uint16_t imax_mA, float capacity_mah) { + return capacity_mah >= 100.0f && capacity_mah <= 100000.0f && + static_cast(imax_mA) * 20.0 < static_cast(capacity_mah); + } bool applyJeitaIgnore(); // re-derive for the current chemistry // INA228 ALERT on P1.02 (Rev 1.1) @@ -299,6 +298,8 @@ class BoardConfigContainer { uint32_t lastMpptMs = 0; uint32_t lastSocMs = 0; uint32_t lastLowVoltageMs = 0; + uint32_t lastLowVoltageSleepAttemptMs = 0; + bool lowVoltageSleepRetryPending = false; // RTC failure: retry at most once/minute uint32_t lastHourlyMs = 0; // Last updateHourlyStats() execution bool tickInitialized = false; // First-call init flag for MPPT stats @@ -340,11 +341,11 @@ class BoardConfigContainer { static constexpr const char* LEDSKEY = "leds_en"; static constexpr const char* BATTERY_CAPACITY_KEY = "batCap"; static constexpr const char* TCCAL_KEY = "tcCal"; // NTC temperature calibration offset - static constexpr const char* JEITAIGNKEY = "jeitaIgn"; // JEITA override user wish + static constexpr const char* JEITAIGNKEY = "jeitaIgn"; // accepted JEITA user override static constexpr const char* ALTITUDEKEY = "altitude"; // BME280 installation altitude (m) bool applyJeitaIgnore(const BatteryProperties* props); // derive + program TS_IGNORE/ISETC/ISETH - bool loadJeitaIgnoreWish(bool& on) const; + bool loadJeitaIgnoreEnabled(bool& on) const; bool loadBatType(BatteryType& type) const; bool loadFrost(FrostChargeBehaviour& behaviour) const; bool loadMaxChrgI(uint16_t& maxCharge_mA) const; diff --git a/variants/inhero_mr2/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp index 3890ed7dfe..f3ad20dc4b 100644 --- a/variants/inhero_mr2/InheroMr2Board.cpp +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -74,8 +74,9 @@ void InheroMr2Board::begin() { 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. + if ((vbat_mv == 0 || vbat_mv < wake_threshold) && + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)) { + // Only resleep after verifying the next wake, before shutting down any IC. // INA228 ADC needs shutdown (readVBATDirect left it in one-shot mode). bool chargeEnabled = restoreConfiguredChargeEnable(); @@ -91,7 +92,6 @@ void InheroMr2Board::begin() { // Both are needed: SetSleep puts it to Cold Sleep, NSS latch prevents re-wake. inhero::prepareRadioForSystemOff(false); - configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); NRF_P0->LATCH = (1UL << RTC_INT_PIN); Wire.end(); @@ -105,10 +105,11 @@ void InheroMr2Board::begin() { while (1) __WFE(); } - // Voltage recovered — close I2C and fall through to normal boot + // Voltage recovered, or wake setup failed: complete boot so the board remains + // serviceable and the main loop can retry low-voltage sleep with its WDT active. Wire.end(); - // Recovery LED flash + // Normal boot LED flash pinMode(LED_BLUE, OUTPUT); for (int i = 0; i < 3; i++) { digitalWrite(LED_BLUE, HIGH); @@ -119,7 +120,7 @@ void InheroMr2Board::begin() { 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); + MESH_DEBUG_PRINTLN("LV-Wake: Normal boot (VBAT=%dmV, wake=%dmV)", vbat_mv, wake_threshold); } // === Standard boot path (ColdBoot, recovery, or non-LV wake) === @@ -204,7 +205,8 @@ void InheroMr2Board::begin() { } } // ColdBoot with voltage below sleep threshold — first entry into LV sleep - else if (vbat_mv < sleep_threshold) { + else if (vbat_mv < sleep_threshold && + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)) { 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); @@ -275,7 +277,6 @@ void InheroMr2Board::begin() { Wire.write(0x00); // Sleep mode Wire.endTransmission(); - configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); NRF_P0->LATCH = (1UL << RTC_INT_PIN); Wire.end(); @@ -286,6 +287,9 @@ void InheroMr2Board::begin() { NRF_POWER->SYSTEMOFF = 1; while (1) __WFE(); } + else if (vbat_mv < sleep_threshold) { + MESH_DEBUG_PRINTLN("ColdBoot: RTC wake unavailable - continuing normal boot"); + } // Normal ColdBoot — voltage OK else { MESH_DEBUG_PRINTLN("Normal ColdBoot - voltage OK (%dmV >= %dmV)", vbat_mv, sleep_threshold); @@ -294,7 +298,7 @@ void InheroMr2Board::begin() { } // === Normal boot path: Initialize board hardware === - // Only reached when voltage is OK (or unreadable) — resleep paths exit above. + // Also reached if RTC wake verification failed; keep normal operation available. // 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(); @@ -475,6 +479,14 @@ uint16_t InheroMr2Board::getLowVoltageWakeThreshold() { void InheroMr2Board::initiateShutdown(uint8_t reason) { MESH_DEBUG_PRINTLN("PWRMGT: Initiating shutdown (reason=0x%02X)", reason); + // A failed wake setup must leave normal operation intact. Check before + // stopping tasks, putting the INA/radio to sleep, or storing a sleep marker. + if (reason == SHUTDOWN_REASON_LOW_VOLTAGE && + !configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)) { + MESH_DEBUG_PRINTLN("PWRMGT: RTC wake unavailable - low-voltage sleep aborted"); + return; + } + // 1. Stop background tasks to prevent filesystem corruption BoardConfigContainer::stopBackgroundTasks(); @@ -519,10 +531,7 @@ void InheroMr2Board::initiateShutdown(uint8_t reason) { 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. + // 7. Clear GPIO LATCH for RTC INT pin (timer was verified before shutdown). // 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); @@ -562,11 +571,24 @@ void InheroMr2Board::initiateShutdown(uint8_t reason) { while (1) __WFE(); } -void InheroMr2Board::configureRTCWake(uint32_t minutes) { +bool InheroMr2Board::configureRTCWake(uint32_t minutes) { uint16_t ticks = static_cast( minutes == 0 ? LOW_VOLTAGE_SLEEP_MINUTES : (minutes > 4095 ? 4095 : minutes)); - inhero::configurePeriodicWake(ticks); + for (uint8_t attempt = 0; attempt < 2; ++attempt) { + if (inhero::configurePeriodicWake(ticks) && digitalRead(RTC_INT_PIN) == HIGH) { + return true; + } + if (attempt == 0) { + // One bounded recovery/retry, using the existing board bus recovery. + Wire.end(); + inhero::recoverI2cBus(PIN_BOARD_SDA, PIN_BOARD_SCL); + Wire.begin(); + delay(10); + } + } + MESH_DEBUG_PRINTLN("PWRMGT: RTC wake verification failed"); + return false; } void InheroMr2Board::rtcInterruptHandler() { diff --git a/variants/inhero_mr2/InheroMr2Board.h b/variants/inhero_mr2/InheroMr2Board.h index a820bb6752..3af8747176 100644 --- a/variants/inhero_mr2/InheroMr2Board.h +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -45,7 +45,10 @@ #define SHUTDOWN_REASON_THERMAL 0x03 #define GPREGRET2_LOW_VOLTAGE_SLEEP 0x04 +// Hardware test builds may override the interval; normal firmware wakes hourly. +#ifndef LOW_VOLTAGE_SLEEP_MINUTES #define LOW_VOLTAGE_SLEEP_MINUTES (60) +#endif class InheroMr2Board : public NRF52BoardDCDC { public: @@ -56,7 +59,8 @@ class InheroMr2Board : public NRF52BoardDCDC { uint16_t getBattMilliVolts() override; void initiateShutdown(uint8_t reason); - void configureRTCWake(uint32_t minutes); + // Returns false unless the timer registers and released INT pin are verified. + bool configureRTCWake(uint32_t minutes); uint16_t getLowVoltageSleepThreshold(); uint16_t getLowVoltageWakeThreshold(); diff --git a/variants/inhero_mr2/README.md b/variants/inhero_mr2/README.md index 9f4433578d..93112b4b6a 100644 --- a/variants/inhero_mr2/README.md +++ b/variants/inhero_mr2/README.md @@ -13,6 +13,16 @@ The charger uses a 2.50 V minimum system voltage and reapplies the MPPT preferen after restoring the battery profile. `get board.mpptdiag` reports the requested and actual MPPT state, voltage/current settings and minimum-system status. +Power-management optimizations combine averaged voltage monitoring, checked +RTC wake scheduling and a one-hour recovery interval. RTC configuration is +persisted for the MR2 supply arrangement without a separate backup battery. + +Changing the battery chemistry resets charging settings to 200 mA, MPPT off, +frost charging off, no user-set capacity and no user JEITA override. Reapplying +the same chemistry preserves settings. Enabling `board.jeitaignore` for a +JEITA-controlled chemistry requires an explicit `board.batcap` and charge +current strictly below 0.05C; rejected requests are not saved for later. + Build environments: ```bash diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp index e4ea0070b2..5d1dd090bd 100644 --- a/variants/inhero_mr2/helpers/CliCommands.cpp +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -25,7 +25,8 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType())); return true; } else if (strcmp(cmd, "fmax") == 0) { - if (cfg.isJeitaIgnoreActive()) { + const auto* props = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (!props || !props->needs_jeita || cfg.getJeitaIgnoreEnabled()) { snprintf(reply, maxlen, "N/A"); } else { snprintf(reply, maxlen, "%s", @@ -174,7 +175,7 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u } else if (strcmp(cmd, "conf") == 0) { const char* batType = BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType()); const auto* confProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); - const char* frostBehaviour = cfg.isJeitaIgnoreActive() + const char* frostBehaviour = (!confProps || !confProps->needs_jeita || cfg.getJeitaIgnoreEnabled()) ? "N/A" : BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour()); @@ -189,7 +190,7 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u // J:1 appears only while the user override is on — for chemistries that // run without JEITA anyway the line is unchanged. const char* jeitaMark = - (confProps && confProps->needs_jeita && cfg.isJeitaIgnoreActive()) ? " J:1" : ""; + (confProps && confProps->needs_jeita && cfg.getJeitaIgnoreEnabled()) ? " J:1" : ""; snprintf(reply, maxlen, "B:%s F:%s M:%s I:%s Vco:%.2f V0:%.2f%s", batType, frostBehaviour, mpptEnabled ? "1" : "0", imax, chargeVoltage, voltage0Soc, jeitaMark); } @@ -213,12 +214,8 @@ bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, u snprintf(reply, maxlen, "N/A"); } else if (jiProps && !jiProps->needs_jeita) { snprintf(reply, maxlen, "jeitaignore 1 (chemistry)"); - } else if (cfg.isJeitaIgnoreActive()) { + } else if (cfg.getJeitaIgnoreEnabled()) { snprintf(reply, maxlen, "jeitaignore 1"); - } else if (cfg.getJeitaIgnoreWish()) { - // Wish is stored but the gate blocks it — name the blocker. - snprintf(reply, maxlen, "jeitaignore 1, N/A, %s", - cfg.isBatteryCapacitySet() ? "C>0.05" : "batcap not set"); } else { snprintf(reply, maxlen, "jeitaignore 0"); } @@ -238,7 +235,7 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { 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) { - cfg.setBatteryType(bt); + if (!cfg.setBatteryType(bt)) return "Err: Battery setup failed"; snprintf(ret, sizeof(ret), "Bat set to %s", BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType())); } else { @@ -255,7 +252,7 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { snprintf(ret, sizeof(ret), "Err: Fmax setting N/A for this chemistry (JEITA disabled)"); return ret; } - if (cfg.isJeitaIgnoreActive()) { + if (cfg.getJeitaIgnoreEnabled()) { snprintf(ret, sizeof(ret), "Err: Fmax N/A while jeitaignore is on"); return ret; } @@ -275,23 +272,15 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); int ma = atoi(value); if (ma >= 50 && ma <= 1500) { - // imax is a gate quantity — the write goes through, the override is - // re-derived, and a state change is reported (same pattern as batcap). - // The stored wish survives and re-arms once the gate passes again. - bool wasActive = cfg.isJeitaIgnoreActive(); + if (cfg.getJeitaIgnoreEnabled() && + (!cfg.isBatteryCapacitySet() || + !BoardConfigContainer::isJeitaIgnoreCurrentAllowed(ma, cfg.getBatteryCapacity()))) { + return "N/A, jeitaignore=1"; + } if (!cfg.setMaxChargeCurrent_mA(ma)) { return "Err: Charge current setup failed"; } - cfg.applyJeitaIgnore(); - if (wasActive && !cfg.isJeitaIgnoreActive()) { - snprintf(ret, sizeof(ret), "Max charge current set to %s; jeitaignore N/A, C>0.05", - cfg.getChargeCurrentAsStr()); - } else if (!wasActive && cfg.isJeitaIgnoreActive()) { - snprintf(ret, sizeof(ret), "Max charge current set to %s; jeitaignore 1", - cfg.getChargeCurrentAsStr()); - } else { - snprintf(ret, sizeof(ret), "Max charge current set to %s", cfg.getChargeCurrentAsStr()); - } + snprintf(ret, sizeof(ret), "Max charge current set to %s", cfg.getChargeCurrentAsStr()); return ret; } return "Err: Try 50-1500"; @@ -335,19 +324,18 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { } else if (strncmp(setCommand, "batcap ", 7) == 0) { const char* value = BoardConfigContainer::trim(const_cast(&setCommand[7])); float capacity_mah = atof(value); - bool wasActive = cfg.isJeitaIgnoreActive(); + if (!(capacity_mah >= 100.0f && capacity_mah <= 100000.0f)) { + return "Err: Invalid capacity (100-100000 mAh)"; + } + char storedCapacity[20]; + snprintf(storedCapacity, sizeof(storedCapacity), "%.1f", capacity_mah); + capacity_mah = atof(storedCapacity); + if (cfg.getJeitaIgnoreEnabled() && + !BoardConfigContainer::isJeitaIgnoreCurrentAllowed(cfg.getMaxChargeCurrent_mA(), capacity_mah)) { + return "N/A, jeitaignore=1"; + } if (cfg.setBatteryCapacity(capacity_mah)) { - // batcap is a gate quantity — re-derive and report a state change. - cfg.applyJeitaIgnore(); - if (wasActive && !cfg.isJeitaIgnoreActive()) { - snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh; jeitaignore N/A, C>0.05", - capacity_mah); - } else if (!wasActive && cfg.isJeitaIgnoreActive()) { - snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh; jeitaignore 1", - capacity_mah); - } else { - snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh", 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)"); } @@ -411,19 +399,12 @@ const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { if (jiProps && !jiProps->needs_jeita) { return "Err: This chemistry runs without JEITA (always 1)"; } - if (!cfg.setJeitaIgnoreWish(on)) { + if (on && !cfg.isBatteryCapacitySet()) return "N/A, batcap not set"; + if (on && !cfg.jeitaIgnoreGateOk()) return "N/A, imax >=0,05C"; + if (!cfg.setJeitaIgnore(on)) { return "Err: Failed to store setting"; } - if (!on) { - snprintf(ret, sizeof(ret), "jeitaignore set to 0"); - } else if (cfg.isJeitaIgnoreActive()) { - snprintf(ret, sizeof(ret), "jeitaignore set to 1"); - } else { - // Wish stored, gate blocks it — name the blocker; re-arms on its own - // once imax/batcap pass. - snprintf(ret, sizeof(ret), "jeitaignore set to 1, N/A, %s", - cfg.isBatteryCapacitySet() ? "C>0.05" : "batcap not set"); - } + snprintf(ret, sizeof(ret), "jeitaignore set to %d", on ? 1 : 0); return ret; } diff --git a/variants/inhero_mr2/helpers/Rv3028Wake.cpp b/variants/inhero_mr2/helpers/Rv3028Wake.cpp index d15c3c4e5d..132f94811f 100644 --- a/variants/inhero_mr2/helpers/Rv3028Wake.cpp +++ b/variants/inhero_mr2/helpers/Rv3028Wake.cpp @@ -10,8 +10,40 @@ #include namespace inhero { +namespace { -void configurePeriodicWake(uint16_t minutes) { +bool writeRegister(uint8_t reg, uint8_t value) { + Wire.beginTransmission(RTC_I2C_ADDR); + bool queued = Wire.write(reg) == 1; + queued = (Wire.write(value) == 1) && queued; + return Wire.endTransmission() == 0 && queued; +} + +bool readRegister(uint8_t reg, uint8_t& value) { + Wire.beginTransmission(RTC_I2C_ADDR); + bool queued = Wire.write(reg) == 1; + // STOP also terminates the transfer if queuing the register address failed. + if (Wire.endTransmission() != 0 || !queued) return false; + if (Wire.requestFrom((uint8_t)RTC_I2C_ADDR, (uint8_t)1) != 1 || Wire.available() != 1) { + while (Wire.available()) Wire.read(); + return false; + } + value = Wire.read(); + return true; +} + +bool verifyRegister(uint8_t reg, uint8_t expected, uint8_t mask = 0xFF) { + uint8_t value; + return readRegister(reg, value) && (value & mask) == (expected & mask); +} + +bool writeVerified(uint8_t reg, uint8_t value, uint8_t mask = 0xFF) { + return writeRegister(reg, value) && verifyRegister(reg, value, mask); +} + +} // namespace + +bool configurePeriodicWake(uint16_t minutes) { uint16_t ticks = (minutes == 0) ? 1 : minutes; if (ticks > 4095) ticks = 4095; // 12-bit register @@ -19,42 +51,38 @@ void configurePeriodicWake(uint16_t minutes) { static_cast(ticks)); // Per RV-3028 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 - Wire.endTransmission(); - - Wire.beginTransmission(RTC_I2C_ADDR); - Wire.write(RV3028_REG_STATUS); - Wire.write(0x00); // Clear TF - Wire.endTransmission(); + // Step 1: Stop Timer and clear flags. Verify the stopped state: if TE never + // goes LOW, writing TE=1 later would not reliably start a fresh countdown. + // Control 1 bit 6 is reserved; only TF is relevant in the status register. + if (!writeVerified(RV3028_REG_CTRL1, 0x00, 0xBF) || + !writeVerified(RV3028_REG_CTRL2, 0x00) || + !writeVerified(RV3028_REG_STATUS, 0x00, 0x08)) return false; // Step 2: Set Timer Value (ticks at 1/60 Hz) Wire.beginTransmission(RTC_I2C_ADDR); - Wire.write(RV3028_REG_TIMER_VALUE_0); - Wire.write(ticks & 0xFF); - Wire.write((ticks >> 8) & 0x0F); - Wire.endTransmission(); + bool queued = Wire.write(RV3028_REG_TIMER_VALUE_0) == 1; + queued = (Wire.write(ticks & 0xFF) == 1) && queued; + queued = (Wire.write((ticks >> 8) & 0x0F) == 1) && queued; + if (Wire.endTransmission() != 0 || !queued || + !verifyRegister(RV3028_REG_TIMER_VALUE_0, ticks & 0xFF) || + !verifyRegister(RV3028_REG_TIMER_VALUE_1, (ticks >> 8) & 0x0F, 0x0F)) return false; // Step 3: Enable timer (1/60 Hz, single shot) - Wire.beginTransmission(RTC_I2C_ADDR); - Wire.write(RV3028_REG_CTRL1); - Wire.write(0x07); // TE=1, TD=11 (1/60 Hz), TRPT=0 (single shot) - Wire.endTransmission(); + if (!writeRegister(RV3028_REG_CTRL1, 0x07)) return false; // Step 4: Enable timer interrupt - Wire.beginTransmission(RTC_I2C_ADDR); - Wire.write(RV3028_REG_CTRL2); - Wire.write(0x10); // TIE=1 - Wire.endTransmission(); + if (!writeRegister(RV3028_REG_CTRL2, 0x10)) return false; + + // Verify the final state before allowing System OFF. Read the preset (0A/0B), + // not the live countdown (0C/0D), and reject a timer that already expired. + if (!verifyRegister(RV3028_REG_TIMER_VALUE_0, ticks & 0xFF) || + !verifyRegister(RV3028_REG_TIMER_VALUE_1, (ticks >> 8) & 0x0F, 0x0F) || + !verifyRegister(RV3028_REG_CTRL1, 0x07, 0xBF) || + !verifyRegister(RV3028_REG_CTRL2, 0x10) || + !verifyRegister(RV3028_REG_STATUS, 0x00, 0x08)) return false; MESH_DEBUG_PRINTLN("PWRMGT: RTC countdown configured (%u ticks at 1/60 Hz)", ticks); + return true; } void clearTimerFlag() { diff --git a/variants/inhero_mr2/helpers/Rv3028Wake.h b/variants/inhero_mr2/helpers/Rv3028Wake.h index 64028f9292..3bdbbcd4aa 100644 --- a/variants/inhero_mr2/helpers/Rv3028Wake.h +++ b/variants/inhero_mr2/helpers/Rv3028Wake.h @@ -11,7 +11,9 @@ namespace inhero { // Configures the RV-3028-C7 periodic countdown timer to fire after `minutes` // at 1/60 Hz, single-shot, with TIE=1 so the INT pin asserts on expiry. // `minutes` is clamped to [1, 4095] (12-bit timer register). -void configurePeriodicWake(uint16_t minutes); +// Returns true only after the stopped state, preset and armed state are verified. +// Performs one attempt; the caller must not enter System OFF on failure. +bool configurePeriodicWake(uint16_t minutes); // Clears the RV-3028 Timer Flag (TF, status bit 3) without touching other bits. // Read-modify-write: required because System Sleep wake is a reset, so the diff --git a/variants/inhero_mr2/lib/Ina228Driver.cpp b/variants/inhero_mr2/lib/Ina228Driver.cpp index c327cff28b..9544d343e9 100644 --- a/variants/inhero_mr2/lib/Ina228Driver.cpp +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -27,14 +27,14 @@ bool Ina228Driver::begin(float shunt_resistor_mohm) { // 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) + // - AVG_256 filters TX voltage peaks; enableAlert() also selects averaged alerts + // - One averaged update takes (2074 + 4120 + 540)us * 256 = ~1.72s 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 value: 0xFFCB + // Expected value: 0xFDE5 // Write ADC_CONFIG with retry and verify // Sometimes the first write after readVBATDirect() fails @@ -372,6 +372,12 @@ void Ina228Driver::enableAlert(bool enable_uvlo, bool active_high, bool latch_al // Bus under-voltage comparison is enabled by setting BUVL register to non-zero. uint16_t diag_alrt = 0; + if (enable_uvlo) { + // AVG in ADC_CONFIG alone does not filter alerts. Compare the completed + // average to attenuate brief TX voltage dips before evaluating undervoltage. + diag_alrt |= INA228_DIAG_ALRT_SLOWALERT; + } + if (latch_alert) { diag_alrt |= INA228_DIAG_ALRT_ALATCH; // Latch mode: Alert stays active until DIAG_ALRT is read } diff --git a/variants/inhero_mr2/lib/Ina228Driver.h b/variants/inhero_mr2/lib/Ina228Driver.h index 838c578f65..ea3f6a3578 100644 --- a/variants/inhero_mr2/lib/Ina228Driver.h +++ b/variants/inhero_mr2/lib/Ina228Driver.h @@ -158,7 +158,8 @@ class Ina228Driver { // Set bus over-voltage alert threshold in mV bool setOverVoltageAlert(uint16_t voltage_mv); - // Enable alert output on ALERT pin. + // Configure ALERT output. enable_uvlo selects averaged comparisons (SLOWALERT). + // BUVL must be set separately; setUnderVoltageAlert(0) disables UVLO comparison. // latch_alert: true to latch the alert until DIAG_ALRT is read. void enableAlert(bool enable_uvlo = true, bool active_high = false, bool latch_alert = false);