diff --git a/boards/inhero_mr2.json b/boards/inhero_mr2.json new file mode 100644 index 0000000000..c048f68459 --- /dev/null +++ b/boards/inhero_mr2.json @@ -0,0 +1,74 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_NRF52840_FEATHER -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + [ + "0x239A", + "0x8029" + ], + [ + "0x239A", + "0x0029" + ], + [ + "0x239A", + "0x002A" + ], + [ + "0x239A", + "0x802A" + ] + ], + "usb_product": "Inhero MR2", + "mcu": "nrf52840", + "variant": "Inhero_MR2_Board", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": [ + "bluetooth" + ], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52.cfg" + }, + "frameworks": [ + "arduino" + ], + "name": "Inhero MR-2", + "upload": { + "maximum_ram_size": 235520, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://inhero.de", + "vendor": "Inhero GmbH" +} diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c93ba1a4cd..907967a1a8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -249,6 +249,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t } sensors.querySensors(perm_mask, telemetry); + // Board-specific telemetry (boards can override queryBoardTelemetry in their Board class) + if (perm_mask & TELEM_PERM_ENVIRONMENT) { + board.queryBoardTelemetry(telemetry); + } + // This default temperature will be overridden by external sensors (if any) float temperature = board.getMCUTemperature(); if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a714db68ec..06e53171ea 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -157,6 +157,8 @@ void loop() { command[0] = 0; // reset command buffer } + board.tick(); // Feed watchdog and perform board-specific tasks + #ifdef ETHERNET_ENABLED ethernet_loop_maintain(); if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) { diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 69182f3a7a..67ad2d2ed0 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -145,6 +145,8 @@ void loop() { command[0] = 0; // reset command buffer } + board.tick(); // Feed watchdog and perform board-specific tasks + the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS diff --git a/src/MeshCore.h b/src/MeshCore.h index 89e60b1f7e..c48be366ba 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -37,6 +37,8 @@ #define BRIDGE_DEBUG_PRINTLN(...) {} #endif +class CayenneLPP; + namespace mesh { #define BD_STARTUP_NORMAL 0 // getStartupReason() codes @@ -75,6 +77,12 @@ class MainBoard { virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } virtual uint8_t getShutdownReason() const { return 0; } virtual const char* getShutdownReasonString(uint8_t reason) { return "Not available"; } + + // Custom board commands and telemetry (boards can override these) + virtual void tick() {} + virtual bool getCustomGetter(const char* getCommand, char* reply, uint32_t maxlen) { return false; } + virtual const char* setCustomSetter(const char* setCommand) { return nullptr; } + virtual bool queryBoardTelemetry(CayenneLPP& telemetry) { return false; } }; /** diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 07181e16ad..cfe5d3a7a3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -740,6 +740,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); #endif + } else if (memcmp(config, "board.", 6) == 0) { + const char* result = _board->setCustomSetter(&config[6]); + if (result != nullptr) { + strcpy(reply, result); + } else { + strcpy(reply, "Error: unknown board command"); + } } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); if (_board->setAdcMultiplier(_prefs->adc_multiplier)) { @@ -914,6 +921,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #else strcpy(reply, "Error: unsupported"); #endif + } else if (memcmp(config, "board.", 6) == 0) { + char res[100]; + memset(res, 0, sizeof(res)); + if (_board->getCustomGetter(&config[6], res, sizeof(res))) { + strcpy(reply, res); + } else { + strcpy(reply, "Error: unknown board command"); + } } else if (memcmp(config, "adc.multiplier", 14) == 0) { float adc_mult = _board->getAdcMultiplier(); if (adc_mult == 0.0f) { diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp new file mode 100644 index 0000000000..a32b09b41f --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -0,0 +1,2185 @@ +/* + * 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 +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); + } + } +} + +// 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 (24-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) { + telemetry.battery.temperature = bqTemp + tcCalOffset; + // Cache for temperature derating in updateBatterySOC() (static context) + lastValidBatteryTemp = telemetry.battery.temperature; + 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); + 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 + + // Apply TS_IGNORE before potential early return — configureBaseBQ() resets it to false, + // so chemistries with ts_ignore=true (BAT_UNKNOWN, LTO, NAION) need it set here. + bq.setTsIgnore(props->ts_ignore); + if (props->ts_ignore) { + bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); + bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED); + } + + if (!props->charge_enable) { + MESH_DEBUG_PRINTLN("WARNING: Battery type UNKNOWN - Charging DISABLED for safety!"); + return true; // No further configuration needed for unknown battery + } + + // Configure chemistry-specific parameters, 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); + + 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; +} + +// 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 + + // Check if BQ reports charging done → auto-sync + if (bqDriverInstance) { + bq25798_charging_status status = bqDriverInstance->getChargingStatus(); + if (status == BQ25798_CHARGER_STATE_DONE_CHARGING) { + if (!socStats.soc_valid) { + MESH_DEBUG_PRINTLN("SOC: First \"Charging Done\" detected - syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after counter reset to prevent false discharge spike + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + } else if (socStats.current_soc_percent < 99.0f) { + MESH_DEBUG_PRINTLN("SOC: \"Charging Done\" detected - re-syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after counter reset to prevent false discharge spike + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + } + } + } + + // SOC calculation is only valid after first "Charging Done" sync via syncSOCToFull() + if (!socStats.soc_valid) { + return; // Wait for first sync + } + + // Net charge since last baseline reset (using CHARGE register in mAh) + // Driver inverted: positive = charged into battery, negative = discharged from battery + float net_charge_mah = charge_mah - socStats.ina228_baseline_mah; + + // Remaining capacity = Initial capacity + net charge (positive=charged adds, negative=discharged subtracts) + float remaining_mah = socStats.capacity_mah + net_charge_mah; + + // Temperature derating: calculate factor for TTL and display purposes. + // The derating factor is NOT applied to SOC% — SOC% is purely Coulomb-based + // (remaining_mah / capacity_mah) and represents the actual stored charge. + // Derating only affects TTL calculation (extractable capacity) and is shown + // separately in CLI output as "derated SOC%". + + // Temperature source priority: 1) NTC via BQ25798 TS ADC (cached), + // 2) BME280 fallback after >5min of no NTC (covers ts_ignore chemistries). + 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 (24-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..dadd503e6c --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -0,0 +1,321 @@ +/* + * 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; + bool ts_ignore; // disables JEITA temperature monitoring + // 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 TsIgn k min Tref + { BAT_UNKNOWN, 0.0f, 0.0f, 2000, 2200, false, true, 0.000f, 1.00f, 25.0f }, + { LTO_2S, 5.4f, 4.6f, 3900, 4100, true, true, 0.002f, 0.88f, 25.0f }, + { LIFEPO4_1S, 3.5f, 3.2f, 2700, 2900, true, false, 0.006f, 0.70f, 25.0f }, + { LIION_1S, 4.1f, 3.7f, 3100, 3300, true, false, 0.005f, 0.75f, 25.0f }, + { NAION_1S, 3.9f, 3.1f, 2500, 2700, true, true, 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(); + + // 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) + // 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 + + 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..8a2a8d7c8e --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -0,0 +1,549 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Inhero MR-2 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 custom CLI getter commands for board configuration +bool InheroMr2Board::getCustomGetter(const char* getCommand, char* reply, uint32_t maxlen) { + return inhero::handleGet(boardConfig, getCommand, reply, maxlen); +} + +// Handles custom CLI setter commands for board configuration +const char* InheroMr2Board::setCustomSetter(const char* setCommand) { + return inhero::handleSet(boardConfig, setCommand); +} + +// ===== 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..8b67eb6885 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -0,0 +1,77 @@ +/* + * 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 getCustomGetter(const char *getCommand, char *reply, uint32_t maxlen) override; + const char *setCustomSetter(const char *setCommand) 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/docs/BATTERY_GUIDE.md b/variants/inhero_mr2/docs/BATTERY_GUIDE.md new file mode 100644 index 0000000000..361fe01fbd --- /dev/null +++ b/variants/inhero_mr2/docs/BATTERY_GUIDE.md @@ -0,0 +1,519 @@ +# Inhero MR2 — Battery Chemistry Guide + +## Contents + +- [Introduction](#introduction) +- [1. Chemistry Overview](#1-chemistry-overview) + - [Li-Ion (NMC/NCA, 1S)](#li-ion-nmcnca-1s) + - [LiFePO4 (LFP, 1S)](#lifepo4-lfp-1s) + - [LTO (Lithium Titanate, 2S)](#lto-lithium-titanate-2s) + - [Na-Ion (Sodium Ion, 1S)](#na-ion-sodium-ion-1s) +- [2. Comparison Table](#2-comparison-table) +- [3. Temperature Behavior](#3-temperature-behavior) + - [Cold Performance Ranking](#cold-performance-ranking) + - [Charging in Cold Conditions](#charging-in-cold-conditions) + - [Temperature Derating](#temperature-derating) +- [4. Cell Selection & Form Factors](#4-cell-selection--form-factors) + - [Li-Ion Cells](#li-ion-cells) + - [LiFePO4 Cells](#lifepo4-cells) + - [LTO Cells](#lto-cells) + - [Na-Ion Cells](#na-ion-cells) +- [5. Capacity Planning](#5-capacity-planning) + - [Power Consumption](#power-consumption) + - [Why current depends on battery voltage](#why-current-depends-on-battery-voltage) + - [Sizing for Autonomy](#sizing-for-autonomy) + - [The 90% Rule](#the-90-rule) +- [6. Solar Charging Considerations](#6-solar-charging-considerations) +- [7. Safety & Protection](#7-safety--protection) +- [8. Deployment Recommendations](#8-deployment-recommendations) +- [9. Long-Term Aging & Cycle Life](#9-long-term-aging--cycle-life) +- [10. Future Outlook](#10-future-outlook) +- [See Also](#see-also) + +--- + +## Introduction + +Choosing the right battery chemistry is one of the most impactful decisions when deploying an Inhero MR2 repeater. It affects runtime, cold-weather reliability, service life, safety, and total cost of ownership. This guide provides the information needed to make an informed choice. + +The Inhero MR2 supports **four battery chemistries**, each with distinct characteristics. There is no single "best" chemistry — the right choice depends on your deployment conditions. + +--- + +## 1. Chemistry Overview + +### Li-Ion (NMC/NCA, 1S) + +The most common rechargeable chemistry. NMC (Nickel-Manganese-Cobalt) and NCA (Nickel-Cobalt-Aluminum) cells dominate the consumer market. + +**Strengths:** +- **Highest energy density** (~250 Wh/kg) — smallest and lightest for a given capacity +- Widely available in many form factors (18650, 21700, pouch) +- Inexpensive — large-scale production drives prices down +- Well-understood technology with decades of field data + +**Weaknesses:** +- Limited cycle life (500–1000 cycles to 80% capacity) +- **Thermal runaway risk** — can ignite under abuse (overcharge, short circuit, puncture) +- Sensitive to cold: significant capacity loss below 0 °C +- Must not be charged below 0 °C — lithium plating risk damages the cell permanently +- Sensitive to heat: accelerated degradation above 40 °C +- **Calendar aging at high SOC + heat** — the #1 aging driver for solar repeaters, where the battery sits at 95–100% SOC for months in summer enclosures reaching 50 °C. This is why the Inhero MR2 lowers Vco to 4.1 V +- Requires protection circuit (BMS) to prevent overcharge/over-discharge + +**Inhero MR2 specifics:** +- Charge voltage set to **4.1 V** (conservative, vs. typical 4.2 V) for improved cycle life +- **JEITA active** — NTC required; charging blocked below −2 °C (T-Cold) +- Frost charge reduction configurable via `set board.fmax` + +### LiFePO4 (LFP, 1S) + +Iron-phosphate cathode chemistry. Popular in solar and off-grid applications for its safety and longevity. + +**Strengths:** +- **Excellent cycle life** (2000–5000 cycles to 80% capacity) +- **No thermal runaway** — inherently safe cathode chemistry +- Good energy density for most deployments (~160 Wh/kg) +- Very flat discharge curve — voltage remains stable over a wide SOC range +- Tolerant to moderate overcharge/over-discharge + +**Weaknesses:** +- **Most cold-sensitive** of all supported chemistries +- Flat discharge curve makes voltage-based SOC estimation unreliable (the Inhero MR2 solves this with a coulomb counter) +- Slightly lower energy density than Li-Ion +- Must not be charged below 0 °C — lithium plating risk damages the cell permanently + +**Inhero MR2 specifics:** +- Charge voltage: **3.5 V** +- **JEITA active** — NTC required; charging blocked below −2 °C (T-Cold) +- Frost charge reduction configurable via `set board.fmax` +- JEITA WARM zone neutralized in firmware to prevent VBAT_OVP (see [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md#jeita-warm-zone--vbat_ovp-prevention)) + +### LTO (Lithium Titanate, 2S) + +Lithium titanate anode chemistry. Used in industrial and transit applications for extreme durability. + +**Strengths:** +- **Best cold performance** of all supported chemistries — 82% extractable at −20 °C +- **Extreme cycle life** (10 000+ cycles, some manufacturers claim 20 000+) +- Very safe — no lithium plating, no thermal runaway +- Wide operating temperature range (−30 °C to +60 °C charging) +- **Can be charged in frost** — no JEITA restriction needed +- Fast-charge capable (up to 5C or more) +- Very flat discharge curve + +**Weaknesses:** +- **Low energy density** (~80 Wh/kg) — needs 2–3× the volume of Li-Ion for the same capacity +- **2S configuration requires an external balancer** — without balancer, cells drift over time and risk over-charge/over-discharge of individual cells +- Exotic form factors — typically cylindrical with screw terminals or aluminum housing +- Difficult to spot-weld (aluminum housing) +- Expensive (2–3× per Wh vs. Li-Ion) +- Limited retail availability — specialty suppliers only + +**Inhero MR2 specifics:** +- Charge voltage: **5.4 V** (2× 2.7 V per cell) +- **JEITA disabled** (`ts_ignore = true`) — no NTC required for charging +- Temperature for SOC derating comes from **BME280 fallback** if no NTC connected +- `set board.fmax` has no effect (shown as "N/A") +- Cell count set to 2S in BQ25798 configuration + +### Na-Ion (Sodium Ion, 1S) + +Sodium-ion technology — the sustainable alternative using abundant, non-critical raw materials. + +**Strengths:** +- **Good cold performance** — 78% extractable at −20 °C +- **No cobalt, no lithium** — ethically sourced, sustainable materials (sodium, iron, manganese) +- **Can be stored and shipped at 0 V** — no deep discharge damage (unique among all chemistries) +- **Can be charged in frost** — no JEITA restriction needed +- Good safety profile — no thermal runaway under normal conditions +- Rapidly improving technology — energy density and cycle life increase with each generation + +**Weaknesses:** +- **New technology** — limited cell availability as of 2025/2026 +- Lower energy density than Li-Ion (~130 Wh/kg, improving) +- Fewer validated cell options and public datasheets +- Cycle life still below LiFePO4 in most current cells (1000–3000 cycles) +- Market still maturing — quality variation between manufacturers + +**Inhero MR2 specifics:** +- Charge voltage: **3.9 V** +- **JEITA disabled** (`ts_ignore = true`) — no NTC required for charging +- Temperature for SOC derating comes from **BME280 fallback** if no NTC connected +- `set board.fmax` has no effect (shown as "N/A") + +--- + +## 2. Comparison Table + +| | Li-Ion 1S | LiFePO4 1S | LTO 2S | Na-Ion 1S | +|---|---|---|---|---| +| **Energy density** | ~250 Wh/kg | ~160 Wh/kg | ~80 Wh/kg | ~130 Wh/kg | +| **Cycle life (to 80%)** | 500–1000 | 2000–5000 | 10 000+ | 1000–3000 | +| **Cold performance** | Moderate | Weakest | Best | Good | +| **Extractable at −20 °C** | 55% | 46% | 82% | 78% | +| **Extractable at −10 °C** | 65% | 58% | 86% | 83% | +| **Extractable at 0 °C** | 75% | 70% | 90% | 88% | +| **Thermal runaway risk** | Yes | No | No | No | +| **NTC required?** | Yes | Yes | No | No | +| **JEITA** | Active | Active | Disabled | Disabled | +| **Charge in frost?** | No (blocked <−2 °C) | No (blocked <−2 °C) | Yes | Yes | +| **Charge voltage** | 4.1 V | 3.5 V | 5.4 V (2S) | 3.9 V | +| **Low-V sleep** | 3100 mV | 2700 mV | 3900 mV | 2500 mV | +| **Low-V wake** | 3300 mV | 2900 mV | 4100 mV | 2700 mV | +| **Nominal voltage** | 3.7 V | 3.2 V | 4.6 V | 3.1 V | +| **Cell formats** | 18650, 21700, pouch | 18650, 26650, prismatic | Screw-terminal, aluminum | 18650, prismatic | +| **Availability** | Excellent | Good | Limited | Limited | +| **Relative cost (per Wh)** | Low | Low–Medium | High | Medium | + +> **Note on the extractable-capacity figures:** These are typical datasheet values at 0.2C–0.5C discharge loads. The MR2's sub-0.05C load is much gentler; the firmware's derating model (section 3) therefore shows higher extractable values in `get board.telem`. + +--- + +## 3. Temperature Behavior + +### Cold Performance Ranking + +From best to worst cold-weather performance: + +1. **LTO** — 82% extractable at −20 °C, charges in frost +2. **Na-Ion** — 78% extractable at −20 °C, charges in frost +3. **Li-Ion** — 55% extractable at −20 °C, charging blocked in frost +4. **LiFePO4** — 46% extractable at −20 °C, charging blocked in frost + +*(Datasheet values at 0.2C–0.5C loads — see the note in section 2; under the MR2's much gentler load the firmware's derating model below shows higher values.)* + +> The ranking may surprise users familiar with LiFePO4's reputation as a "workhorse." While LiFePO4 excels in cycle life and safety, it is actually the **worst performer in cold** among the four supported chemistries. This matters significantly for alpine and winter deployments. + +### Charging in Cold Conditions + +| Chemistry | Charging in frost? | Mechanism | +|---|---|---| +| **Li-Ion** | No — blocked below −2 °C | JEITA T-Cold (hardware, BQ25798) | +| **LiFePO4** | No — blocked below −2 °C | JEITA T-Cold (hardware, BQ25798) | +| **LTO** | Yes — charges at any temperature | JEITA disabled (`ts_ignore = true`) | +| **Na-Ion** | Yes — charges at any temperature | JEITA disabled (`ts_ignore = true`) | + +For Li-Ion and LiFePO4, charging in the **T-Cool range** (+3 °C to −2 °C with the Inhero voltage divider) is blocked by default and can be set to a reduced rate via `set board.fmax` (20%, 40% or 100%). Note that selecting Li-Ion or LiFePO4 via `set board.bat` resets `board.fmax` to 0%. See [FAQ #6](FAQ.md#6-what-does-set-boardfmax-control). + +**Why is frost charging dangerous for Li-Ion and LiFePO4?** At low temperatures, lithium ions cannot intercalate properly into the graphite anode. Instead, they deposit as metallic lithium on the anode surface ("lithium plating"). This permanently reduces capacity and can create internal short circuits — a safety hazard. + +LTO and Na-Ion use different anode materials (lithium titanate and hard carbon respectively) that do not suffer from lithium plating, making frost charging safe. + +> **Field experience vs. theory:** Many repeater operators successfully charge Li-Ion cells in frost with low solar currents (<0.1C) and report no measurable degradation over multiple winters. The [YYCMesh community](https://yycmesh.com/blog/cold-weather-charging) documented two years of alpine deployments in the Canadian Rockies (down to −40 °C) with standard 18650 cells and found internal resistance still within factory spec. Their key factors: very low charge rates (<0.05C), passive solar heating of enclosures, and charging coinciding with the warmest part of the day. +> +> This is valuable real-world data and the practice clearly works for many setups. However, these results apply specifically to configurations with **large battery capacities and relatively low PV power** (keeping charge rates well below 0.05C). They should not be taken as a general dismissal of lithium plating risks. *It depends* — on charge rate, cell quality, panel size, and temperature. The degradation from lithium plating is **cumulative and subtle** — it may not manifest as sudden failure but as gradual capacity loss over years. Two additional risks are often underestimated: +> +> 1. **PV panels produce more power in cold weather** (silicon temperature coefficient ~−0.35%/°C). A 5 W panel at −10 °C delivers significantly more current than at +25 °C. Snow reflection can push output even beyond rated wattage. +> 2. **Cell quality varies.** Results with premium cells (low internal resistance, consistent chemistry) may not transfer to budget cells. +> +> The Inhero MR2 takes a conservative approach: the NTC battery temperature sensor causes the BQ25798 to block charging until the cell warms above −2 °C (JEITA T-Cold) and, by default, keeps charging suspended through the T-Cool zone as well (`board.fmax` default 0%). Setting `set board.fmax` to 20%, 40% or 100% instead allows reduced-rate charging between −2 °C and +3 °C. On sunny winter days, the board runs from solar via the power path while the battery stays protected. Once direct sunlight heats up the enclosure and the battery temperature rises above the threshold — which happens surprisingly fast with proper enclosure design — charging resumes automatically. This gives the best of both worlds: no plating risk, yet minimal lost charge time. + +### Temperature Derating + +The firmware uses a **Trapped Charge** model to estimate **extractable** capacity at the current battery temperature. Cold temperatures lock the bottom of the discharge curve — the cell reaches its cutoff voltage while charge is still stored. SOC% itself is purely Coulomb-based (stored charge) and temperature-independent. Derating is applied to: +- **TTL calculation** — Trapped Charge: extractable = max(0, remaining − capacity × (1−f(T))) +- **CLI display** — `get board.telem` shows the derated value in parentheses: `SOC:95.0% (78%)` + +The derating model uses a per-chemistry linear function: + +``` +f(T) = max(f_min, 1.0 - k × (T_ref - T)) for T < T_ref +f(T) = 1.0 for T >= T_ref +``` + +T_ref = 25 °C for all chemistries (no derating at room temperature and above). + +| Chemistry | k (/°C) | f_min | At −20 °C | At −10 °C | At 0 °C | At 10 °C | +|-----------|---------|-------|-----------|-----------|---------|---------| +| Li-Ion | 0.005 | 0.75 | 0.78 | 0.83 | 0.88 | 0.93 | +| LiFePO4 | 0.006 | 0.70 | 0.73 | 0.79 | 0.85 | 0.91 | +| Na-Ion | 0.003 | 0.85 | 0.87 | 0.90 | 0.93 | 0.96 | +| LTO | 0.002 | 0.88 | 0.91 | 0.93 | 0.95 | 0.97 | + +**Practical effects:** +- SOC% is temperature-independent — only changes with actual charge flow +- The derated (extractable) value in `telem` decreases as the battery cools +- TTL estimates reflect extractable capacity at the current temperature (Trapped Charge model) + +→ See [FAQ #13 — How does temperature derating work?](FAQ.md#13-how-does-temperature-derating-work) for further technical details. + +--- + +## 4. Cell Selection & Form Factors + +### Li-Ion Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **18650** | 2500–3500 mAh | Most common; widely available; easy to source | +| **21700** | 4000–5000 mAh | Higher capacity; becoming the new standard | +| **Pouch** | Varies | Custom shapes; requires careful mounting | + +**Tips:** +- Prefer cells with built-in protection circuit (PCM) for standalone use +- For parallel packs, ensure cells are matched (same manufacturer, same batch) +- Cells with integrated NTC simplify wiring to the TS pin +- Recommended: Samsung, Sony/Murata, LG, Panasonic/Sanyo — avoid no-name cells + +### LiFePO4 Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **18650** | 1400–1800 mAh | Lower capacity than Li-Ion 18650; less common | +| **26650** | 2500–3600 mAh | Larger diameter; popular for LFP | +| **32650** | 5000–6000 mAh | Large cylindrical; good for high-capacity packs | +| **Prismatic** | 5000–50 000 mAh | Flat cells; efficient use of space | + +**Tips:** +- The flat discharge curve means voltage tells you little about SOC — rely on the Inhero MR2's coulomb counter +- Avoid charging below 0 °C — the board's JEITA protection handles this automatically +- EVE, BYD, CATL are reputable manufacturers + +### LTO Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **Cylindrical (screw terminal)** | 10 000–40 000 mAh | Most common LTO format; M6/M8 screw terminals | +| **Prismatic (aluminum)** | 10 000–30 000 mAh | Aluminum housing; cannot be spot-welded easily | + +**⚠️ Important: 2S requires a balancer** + +The Inhero MR2 configures the BQ25798 for 2S operation but provides **no built-in cell balancing**. An external balancer module is required to prevent cell voltage drift over time. Without a balancer, one cell may be overcharged while the other is undercharged — this degrades capacity and can damage cells. + +**Tips:** +- Use a passive or active balancer board rated for your cell voltage range (2.0–2.7 V per cell) +- Yinlong/Toshiba SCiB are common LTO cell brands +- Expect 2–3× the volume and weight compared to Li-Ion for the same energy +- Screw terminals are robust for outdoor deployments — no spot-welding needed + +### Na-Ion Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **18650** | 1000–1500 mAh | Emerging; first-generation cells | +| **Prismatic** | 5000–20 000 mAh | Larger formats appearing from HiNa, CATL, Faradion | + +**Tips:** +- Technology is evolving rapidly — check latest available cells before purchasing +- Can be shipped at 0 V (unlike all lithium chemistries) — simplifies logistics +- No special handling required for storage +- HiNa, CATL, Faradion/Reliance are key manufacturers (as of 2025/2026) + +--- + +## 5. Capacity Planning + +### Power Consumption + +Typical Inhero MR2 power consumption (repeater mode, LEDs off): + +| Condition | Current Draw | Notes | +|---|---|---| +| Idle (RX, no TX) | ~7.6 mA @ 3.3 V | USB off, SX1262 in RX | +| **Measured typical** | **~12.3 mA @ 3.3 V** | 24h measurement, repeater with typical traffic | +| **Worst case (10% DC, EU868 g3)** | **~19.8 mA @ 3.3 V** | 10% duty cycle × ~130 mA TX + 90% × 7.6 mA idle | +| TX burst (SX1262 +22 dBm) | ~130 mA @ 3.3 V | Short bursts only, regulated by duty cycle | +| Low-voltage sleep | <0.5 mA | Solar charging continues | + +> **How these values are determined:** MeshCore operates on **869.618 MHz** in the EU868 **g3 sub-band** (869.4–869.65 MHz), which allows up to +27 dBm ERP and a **10% duty cycle**. At +22 dBm the SX1262 + MCU draw ~130 mA during TX. With 10% TX time: `0.90 × 7.6 + 0.10 × 130 = 19.8 mA` → **~65 mW or ~1.57 Wh/day** — the regulatory maximum. +> +> **Measured typical (~12.3 mA):** Validated 24h measurement in repeater mode with typical traffic: **295 mAh/day @ 3.32 V** = 0.98 Wh/day → **~41 mW or ~0.98 Wh/day**. + +### Why current depends on battery voltage + +The Inhero MR2 uses a high-efficiency **buck converter** to produce the 3.3 V rail that powers the MCU and radio. This means the board draws roughly **constant power** (watts), not constant current (amps). + +Since Power = Voltage × Current, a higher battery voltage means lower current from the battery — but the power stays the same: + +| Chemistry | Nominal Voltage | Idle | Measured typical | Worst case (10% DC) | +|---|---|---|---|---| +| Na-Ion | 3.1 V | ~8.1 mA (25 mW) | ~13.2 mA (41 mW) | ~21.0 mA (65 mW) | +| LiFePO4 | 3.2 V | ~7.8 mA (25 mW) | ~12.8 mA (41 mW) | ~20.3 mA (65 mW) | +| Li-Ion | 3.7 V | ~6.8 mA (25 mW) | ~11.1 mA (41 mW) | ~17.6 mA (65 mW) | +| LTO (2S) | 4.6 V | ~5.4 mA (25 mW) | ~8.9 mA (41 mW) | ~14.1 mA (65 mW) | + +> **Important for capacity planning:** Don't just multiply mA × hours to get mAh — that only works within one chemistry at one voltage. When comparing across chemistries, always calculate in **Wh** (energy): `Energy (Wh) = Wh/day × Days`. Then convert to your chemistry: `mAh = Wh × 1000 ÷ V_nominal`. Use **0.98 Wh/day** (measured typical) or **1.57 Wh/day** (worst case 10% DC) depending on expected traffic and safety margin. +> +> **Example:** 30 days autonomy at measured typical = 29 Wh needed (worst case: 47 Wh). +> - LiFePO4 (3.2 V): 29 000 ÷ 3.2 = **9 063 mAh** (worst case: 14 688 mAh) +> - LTO 2S (4.6 V): 29 000 ÷ 4.6 = **6 304 mAh** (worst case: 10 217 mAh) + +### Sizing for Autonomy + +**Energy approach (recommended):** `Energy (Wh) = Wh/day × Days of autonomy` — use **0.98 Wh/day** (measured typical) or **1.57 Wh/day** (worst case 10% DC) for conservative sizing + +**Convert to mAh for your chemistry:** `mAh = Wh × 1000 ÷ V_nominal` + +The mAh column below is calculated at 3.3 V (≈ LiFePO4 / Na-Ion nominal). For Li-Ion or LTO, use the energy column with the formula above — see [Why current depends on battery voltage](#why-current-depends-on-battery-voltage). + +| Desired Autonomy | Measured typical (0.98 Wh/day) | Worst case (1.57 Wh/day) | mAh @ 3.3 V (typical) | Recommended | +|---|---|---|---|---| +| **3 days** (indoor, grid backup) | 2.9 Wh | 4.7 Wh | 891 mAh | 1500 mAh | +| **7 days** (solar, summer) | 6.9 Wh | 11.0 Wh | 2079 mAh | 3500 mAh | +| **14 days** (solar, winter) | 13.7 Wh | 22.0 Wh | 4158 mAh | 6000 mAh | +| **30 days** (alpine, minimal solar) | 29.4 Wh | 47.1 Wh | 8909 mAh | 12000+ mAh | + +> **Cold-weather margin:** For deployments below 0 °C, increase capacity by the inverse of the derating factor to compensate for reduced extractable capacity. Example: LiFePO4 at −10 °C has f(T) = 0.79, so you need `capacity / 0.79 ≈ 1.27×` the capacity compared to room temperature. The TTL calculation applies this derating automatically. + +### The 90% Rule + +Set `board.batcap` to **90% of nominal capacity**. The Inhero MR2 uses conservative charge voltages (e.g. 4.1 V instead of 4.2 V for Li-Ion), which means the top ~10% of nominal capacity is intentionally not used — this significantly improves cycle life. + +**Example:** 10 000 mAh nominal → `set board.batcap 9000` + +→ See [FAQ #4](FAQ.md#4-what-mah-value-should-i-enter-for-set-boardbatcap) + +--- + +## 6. Solar Charging Considerations + +**Maximum charge current formula:** `I_charge (mA) = Panel power (W) / Nominal battery voltage (V)` + +| Chemistry | Panel | Charge Current | `set board.imax` | +|---|---|---|---| +| Li-Ion (3.7 V) | 2 W | 540 mA | `set board.imax 540` | +| LiFePO4 (3.2 V) | 1 W | 310 mA | `set board.imax 310` | +| LTO (4.6 V) | 5 W | 1090 mA | `set board.imax 1090` | +| Na-Ion (3.1 V) | 3 W | 970 mA | `set board.imax 970` | + +**Panel sizing guidelines:** +- The Inhero MR2 consumes ~7.6 mA @ 3.3 V idle, **measured ~12.3 mA typical** (~0.98 Wh/day), worst case ~19.8 mA at full EU868 g3 10% duty cycle (~1.57 Wh/day) +- With vertical south-facing mounting (see below), **2 W monocrystalline is safely sufficient** for winter autonomy in central Europe with a 9 Ah LiFePO4 battery. Field-tested: even a 1 W panel (vertical, south, unshaded, exposed) with 9 Ah LiFePO4 survived a full central European winter +- For locations with frequent overcast periods or partial shading, add margin — 3–5 W recommended +- MPPT is essential for extracting maximum power — enable with `set board.mppt 1` + +**Panel orientation — vertical is better for autonomy:** + +Conventional PV installations tilt panels at ~30–40° to maximize annual yield. For off-grid repeaters, the goal is different: **maximize winter performance**, especially in the critical months of December and January when the sun is lowest and days are shortest. Mounting panels **vertically (90°)** has significant advantages: + +- **Low winter sun** hits a vertical panel at near-optimal angle while a 30°-tilted panel receives the same light at a glancing angle +- **Self-cleaning:** vertical panels shed snow, ice, and dirt far more effectively — a snow-covered panel produces zero power regardless of rated wattage +- **Practical rule of thumb (central Europe):** expect approximately **1 Wh/day per 1 Wp** of panel rating in January with a vertical, south-facing, unshaded, exposed setup. PVGIS data confirms ~36 Wh/month (after system losses) for a 1 Wp panel in this configuration. In practice, the MR2 cannot harvest on very overcast days when the charger reports !PG (power not good), so a conservative estimate is **~30 Wh/month or ~1 Wh/day** usable. With ~0.98 Wh/day measured typical consumption, a 1 Wp panel provides a positive energy balance in January. **2 Wp provides comfortable headroom** for cloudy stretches and higher-traffic deployments +- In summer, vertical panels produce less than optimally tilted ones — but summer yield is never the bottleneck for autonomy + +**Chemistry-specific considerations:** +- **Li-Ion / LiFePO4:** Solar charging is blocked in frost (<−2 °C). On cold winter days, the panel may produce power but the battery won't accept charge until it warms above −2 °C. Meanwhile, the board runs directly on solar if power is sufficient. Note that PV panels produce **more power in cold weather** (silicon temperature coefficient ~−0.35%/°C), so actual charge currents can exceed nominal ratings — another reason why hardware-level charge blocking via JEITA is essential rather than relying on "low current" assumptions. +- **LTO / Na-Ion:** Solar charging works even in deep frost — a significant advantage for alpine deployments where frost can persist for days or weeks. + +--- + +## 7. Safety & Protection + +| Chemistry | Thermal Runaway | Requires BMS/Protection? | Inhero MR2 Protection | +|---|---|---|---| +| **Li-Ion** | ⚠️ Yes — fire/explosion risk under abuse | Yes — mandatory | JEITA, low-V sleep, OVP, charge voltage limit | +| **LiFePO4** | ✅ No — inherently safe | Recommended | JEITA, low-V sleep, OVP | +| **LTO** | ✅ No — inherently safe | Recommended (balancer!) | Low-V sleep, OVP, cell count config | +| **Na-Ion** | ✅ No under normal conditions | Recommended | Low-V sleep, OVP | + +**Inhero MR2 built-in safety features (all chemistries):** +- **Low-voltage sleep** — INA228 ALERT ISR triggers system sleep to prevent deep discharge +- **Charge voltage limit** — BQ25798 configured per chemistry to prevent overcharge +- **VBAT_OVP** — Hardware overvoltage protection in BQ25798 +- **200 mV hysteresis** — Prevents motorboating (rapid on/off cycling) near empty +- **JEITA temperature protection** (Li-Ion/LiFePO4 only) — Hardware charge control via NTC + +**User responsibilities:** +- Li-Ion: Use cells with protection circuit (PCM) or a proper BMS +- LTO 2S: **External balancer is mandatory** for long-term operation +- All chemistries: Set correct chemistry via `set board.bat` — wrong chemistry = wrong voltages = damage risk + +--- + +## 8. Deployment Recommendations + +| Scenario | Recommended | Alternative | Notes | +|---|---|---|---| +| **Indoor, moderate climate (0–40 °C)** | **LiFePO4** | Li-Ion | LiFePO4: best safety + cycle life balance | +| **Outdoor, temperate (−5 to +35 °C)** | **LiFePO4** | Li-Ion | Frost is rare; fmax handles occasional cold | +| **Space-constrained enclosure** | **Li-Ion** | — | Highest energy density; nothing else fits | +| **Alpine, extreme cold (−20 °C and below)** | **LTO** | Na-Ion | LTO: charges in frost, 82% capacity at −20 °C | +| **Cold climate, moderate frost (−10 to −15 °C)** | **Na-Ion** or **LTO** | LiFePO4 (with margin) | Both charge in frost; Na-Ion balances density and cold | +| **Maximum service life (>10 years)** | **LTO** | LiFePO4 | LTO: 10 000+ cycles; solar repeater essentially unlimited | +| **Sustainability / ethical sourcing** | **Na-Ion** | LiFePO4 | No cobalt, no lithium; improving rapidly | +| **Maritime / coastal (salt, humidity)** | **LiFePO4** | Li-Ion | Sealed prismatic cells; inherent safety in harsh environments | +| **Mobile / portable** | **Li-Ion** | LiFePO4 | Weight and volume matter most | +| **Budget-constrained** | **Li-Ion** | LiFePO4 | Lowest cost per Wh | + +*(Extractable-capacity percentages are datasheet values at 0.2C–0.5C loads — see the note in section 2.)* + +> **Winter alpine checklist:** +> 1. Choose LTO or Na-Ion for frost charging +> 2. Oversize battery capacity by 1.5–2× for cold derating +> 3. Oversize solar panel by 3–5× for short winter days +> 4. Enable MPPT (`set board.mppt 1`) +> 5. If using Li-Ion/LiFePO4: configure `set board.fmax` and run `set board.tccal` +> 6. Monitor via `get board.stats` and `get board.socdebug` + +--- + +## 9. Long-Term Aging & Cycle Life + +| Chemistry | Cycles to 80% | Calendar Aging | Optimal Storage SOC | +|---|---|---|---| +| **Li-Ion** | 500–1000 | Moderate (faster at high temp/SOC) | 40–60% at 15–25 °C | +| **LiFePO4** | 2000–5000 | Low | 50% at room temperature | +| **LTO** | 10 000+ | Very low | Any SOC; very tolerant | +| **Na-Ion** | 1000–3000 | Low | 0 V (unique — no damage) | + +**Solar repeater context:** A well-dimensioned solar repeater does **not** do 1 full cycle per day. The actual profile is shallow micro-cycling with strong seasonal variation: + +- **Daily discharge** is only **2–3%** of battery capacity (with proper sizing: ≥ 7 Ah for a sub-1 W load) +- **Daily recharge** adds **10–20%** on sunny days, depending on panel size +- **Winter (Dec–Jan):** The battery slowly drains over multi-day overcast periods — it "carries" the repeater through the dark weeks. SOC may drop to 30–50% before the next sunny spell +- **Summer:** The battery permanently floats between **95–100% SOC**, rarely dropping below 90% + +This means the battery experiences perhaps **10–30 equivalent full cycles per year** — not 365. + +| Chemistry | Cycles to 80% | Equivalent Full Cycles/Year | Expected Service Life | +|---|---|---|---| +| **Li-Ion** | 500–1000 | ~10–30 | **15–50+ years** (cycle-limited) | +| **LiFePO4** | 2000–5000 | ~10–30 | **65+ years** (cycle-limited) | +| **LTO** | 10 000+ | ~10–30 | **effectively unlimited** | +| **Na-Ion** | 1000–3000 | ~10–30 | **30–100+ years** (cycle-limited) | + +> **The real aging threat is not cycling — it's calendar aging at high SOC and temperature.** +> +> In summer, the battery sits at 95–100% SOC for months in an enclosure that can reach 50 °C in direct sun. For Li-Ion, this combination (high SOC + high temperature) is the #1 aging accelerator. This is exactly why the Inhero MR2 uses **conservative charge cutoff voltages** (e.g. 4.1 V instead of 4.2 V for Li-Ion) — lower Vco reduces the resting SOC and dramatically slows calendar aging. + +**How bad is it? Typical NMC Li-Ion calendar capacity loss per year (no cycling, storage only):** + +| Temperature | 4.2 V (100% SOC) | 4.1 V (~85% SOC) | Reduction | +|---|---|---|---| +| 25 °C (indoor) | ~3–5%/year | ~1–2%/year | 2–3× slower | +| 40 °C (warm enclosure) | ~8–15%/year | ~3–5%/year | 2–3× slower | +| **50 °C (sun-exposed)** | **~20–30%/year** | **~6–10%/year** | **3× slower** | + +> At 4.2 V and 50 °C, a Li-Ion cell reaches 80% capacity (end of life) in roughly **3 years**. At 4.1 V and 50 °C, the same cell lasts **8–10 years**. The 100 mV Vco reduction alone buys 2–3× more service life — at the cost of only ~10% less usable capacity. +> +> **Bottom line:** For sun-exposed outdoor deployments, the Vco reduction from 4.2→4.1 V is the single most effective measure to extend Li-Ion life. If the enclosure regularly exceeds 40 °C, consider LiFePO4 or LTO instead — these chemistries are largely immune to calendar aging at high SOC. +> +> LiFePO4 and LTO are far more tolerant of sustained high SOC. Na-Ion ages moderately. For hot/exposed deployments, prefer LiFePO4 or LTO. + +--- + +## 10. Future Outlook + +**Na-Ion** is the chemistry to watch. As of 2025/2026: +- Energy density is improving with each generation (target: 160+ Wh/kg) +- Major manufacturers (CATL, BYD, HiNa) are ramping production +- Cell costs are expected to drop below Li-Ion within 2–3 years +- Ideal for stationary applications where absolute energy density is less critical + +**Solid-state batteries** may appear in the 2028+ timeframe but are unlikely to be relevant for off-grid repeater applications in the near term. + +**LTO** remains the gold standard for extreme environments and will likely stay relevant for specialized deployments. Its high cost and low density will continue to limit adoption to cases where cold performance and cycle life are critical. + +**LiFePO4** will remain the mainstream choice for the foreseeable future — proven, safe, affordable, and available in many formats. + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware datasheet, pinouts and specifications +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [FAQ.md](FAQ.md) — Frequently asked questions +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/CLI_CHEAT_SHEET.md b/variants/inhero_mr2/docs/CLI_CHEAT_SHEET.md new file mode 100644 index 0000000000..ad3d994b6a --- /dev/null +++ b/variants/inhero_mr2/docs/CLI_CHEAT_SHEET.md @@ -0,0 +1,202 @@ +# Inhero MR2 – CLI Cheat-Sheet + +All board-specific CLI commands at a glance. +Prefix is always `board.` — i.e. `get board.` or `set board. `. + +See [FAQ.md](FAQ.md) for explanations of key parameters (`imax`, `fmax`, `batcap`) and [DATASHEET.md](DATASHEET.md#supported-battery-chemistries) for chemistry details. + +--- + +## Setters (Change Configuration) + +```bash +# Battery chemistry +set board.bat liion1s # Li-Ion 1S (3.7V nominal) +set board.bat lifepo1s # LiFePO4 1S (3.2V nominal) +set board.bat lto2s # LTO 2S (2x 2.3V nominal) +set board.bat naion1s # Na-Ion 1S (3.1V nominal) +set board.bat none # No battery / unknown (charging disabled) + +# Battery capacity (100–100000 mAh) +# Rule of thumb: 90% of nominal capacity (see FAQ #4) +set board.batcap 10000 + +# Maximum charge current (50–1500 mA) +set board.imax 500 + +# Frost charge current reduction (T-Cool approx. -2 °C to +3 °C, see JEITA table in README) +set board.fmax 0% # Charging blocked +set board.fmax 20% # max. 20% of imax +set board.fmax 40% # max. 40% of imax +set board.fmax 100% # no reduction +# Note: No effect on LTO / Na-Ion (JEITA disabled) + +# MPPT on/off +set board.mppt 1 # Enable MPPT +set board.mppt 0 # Disable MPPT + +# LEDs on/off (Heartbeat + BQ Stat) +set board.leds on # Enable LEDs (on/1) +set board.leds off # Disable LEDs (off/0) + +# Manually set SOC (0–100%) +set board.soc 85.0 +``` + +### Calibration + +```bash +# NTC temperature calibration +# Best practice: run in the early morning before sunrise, +# when battery temperature has equalized with ambient (see FAQ #12). +set board.tccal # Auto-calibration via BME280 +set board.tccal reset # Reset offset to 0.00 +``` + +--- + +## Getters (Query Status) + +```bash +# Configuration & Hardware +get board.bat # Current battery type +get board.batcap # Battery capacity in mAh (set/default) +get board.imax # Maximum charge current in mA +get board.fmax # Frost charge behavior (0%/20%/40%/100% or N/A) +get board.mppt # MPPT status (0/1) +get board.leds # LED status (ON/OFF) +get board.conf # Summary of all configs (B, F, M, I, Vco, V0) + +# Real-time telemetry +get board.telem # Battery+Solar: V, I, T, SOC + +# Energy & Statistics +get board.stats # Energy balance (24h/3d/7d), C/D, MPPT%, TTL + # TTL = Time To Live (hours until battery empty) + # Basis: 7-day average of daily net deficit + # from hourly INA228 coulomb counter samples (168h ring buffer) + # Formula: extractable / |7d-avg-deficit| × 24, where +# extractable = SOC% × capacity − trapped charge +# (cold-temperature derating; 0 at normal temperatures) + # TTL only shown in BAT mode (net deficit) + # Prerequisite: min. 24h data + capacity known + +# Charger & Diagnostics +get board.cinfo # Charger status + last PG-stuck HIZ toggle +get board.bqdiag # Diagnostic/debug: compact BQ25798 register dump + # PG/charge state, TS region (COLD/COOL/WARM/HOT), + # active status/fault flags (e.g. VINDPM, VBAT_OVP) +get board.selftest # Probe all I2C devices (INA228/BQ25798/RV-3028/BME280) + # Output: "INA:OK BQ:OK RTC:OK BME:OK" + # RTC includes user-RAM write/readback verify + # to catch cold-solder joints (chip ACKs but + # rejects writes). Possible per-device states: + # OK — device responds and (RTC) persists writes + # NACK — device does not ACK on I2C bus + # WR_FAIL — (RTC only) ACKs but write/readback mismatched +get board.socdebug # Diagnostic/debug: SOC tracking internals + # SHUNT_CAL, precise current, CHARGE register (mAh), + # current-hour charge/discharge accumulators, + # update count, RTC time, temperature derating factor + +# Calibration +get board.tccal # NTC temperature offset in °C (0.00 = default) +``` + +--- + +## Getter Quick Reference + +| Command | Description | +|---|---| +| `get board.bat` | Battery type (`liion1s`, `lifepo1s`, `lto2s`, `naion1s`, `none`) | +| `get board.batcap` | Battery capacity in mAh (set/default) | +| `get board.imax` | Maximum charge current in mA | +| `get board.fmax` | Frost charge behavior (`0%`/`20%`/`40%`/`100%`, LTO/Na-Ion: `N/A`) | +| `get board.mppt` | MPPT status (`0`/`1`) | +| `get board.leds` | LED status Heartbeat + BQ Stat (`ON`/`OFF`) | +| `get board.conf` | Summary: B(at) F(max) M(ppt) I(max) Vco V0 | +| `get board.telem` | Real-time telemetry: Battery/Solar V, I, T, SOC — see [TELEMETRY.md](TELEMETRY.md) | +| `get board.stats` | Energy balance (24h/3d/7d), C/D, MPPT%, TTL (7d-avg-based) | +| `get board.cinfo` | Charger status + PG-stuck HIZ toggle (e.g. "PG / CC HIZ:3m ago") | +| `get board.bqdiag` | Diagnostic/debug: BQ25798 register dump — PG/charge state, TS region, active fault flags | +| `get board.selftest` | I2C device probe — `INA:OK BQ:OK RTC:OK BME:OK` (RTC also write-verified) | +| `get board.socdebug` | Diagnostic/debug: SOC internals — SHUNT_CAL, current, CHARGE, hour accumulators, derating factor | +| `get board.tccal` | NTC temperature offset in °C (`0.00` = default) | + +--- + +## Setter Quick Reference + +| Command | Range | Description | +|---|---|---| +| `set board.bat` | `liion1s` · `lifepo1s` · `lto2s` · `naion1s` · `none` | Set battery chemistry | +| `set board.batcap` | `100`–`100000` (mAh) | Set battery capacity | +| `set board.imax` | `50`–`1500` (mA) | Set max charge current | +| `set board.fmax` | `0%` · `20%` · `40%` · `100%` | Frost charge reduction (not for LTO/Na-Ion) | +| `set board.mppt` | `0`/`1` · `true`/`false` | Enable/disable MPPT | +| `set board.leds` | `on`/`off` · `1`/`0` | Enable/disable LEDs | +| `set board.soc` | `0`–`100` (%) | Manually set SOC | +| `set board.tccal` | `reset` · *(empty = auto)* | Calibrate or reset NTC temperature | + +--- + +## Quick-Start Recipes + +### Li-Ion 1S with 10Ah and Solar +```bash +set board.bat liion1s +set board.batcap 10000 +set board.imax 500 +set board.fmax 20% +set board.mppt 1 +set board.leds off +``` + +### LiFePO4 1S with 6Ah and Solar +```bash +set board.bat lifepo1s +set board.batcap 6000 +set board.imax 300 +set board.fmax 40% +set board.mppt 1 +set board.leds off +``` + +### LTO 2S with 18Ah and Solar +```bash +set board.bat lto2s +set board.batcap 18000 +set board.imax 700 +set board.mppt 1 +set board.leds off +``` + +### Na-Ion 1S with 10Ah and Solar +```bash +set board.bat naion1s +set board.batcap 10000 +set board.imax 500 +set board.mppt 1 +set board.leds off +``` + +### Status Check (everything at a glance) +```bash +get board.conf +get board.telem +get board.stats +get board.cinfo +``` + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/DATASHEET.md b/variants/inhero_mr2/docs/DATASHEET.md new file mode 100644 index 0000000000..fc92e7b0ec --- /dev/null +++ b/variants/inhero_mr2/docs/DATASHEET.md @@ -0,0 +1,235 @@ +# Inhero MR2 — Datasheet + +> **Inhero MR2 – Smart Solar Mesh Board** +> Hardware Revision 1.1 + +--- + +## Board Overview + +The Inhero MR2 is a LoRa mesh repeater board based on the **RAK4630** module (nRF52840 + SX1262) with integrated smart solar charging, power monitoring, and low-voltage protection. Supported battery configurations are 1S Li-Ion, 1S LiFePO4, 2S LTO, and 1S Na-Ion. The board was specifically designed for autonomous long-term deployment at remote or hard-to-reach locations. In Central Europe, uninterrupted continuous repeater operation is possible with unshaded solar panels ≥ 1 W and battery capacities ≥ 9 Ah. + +The charge and discharge cutoff voltages (see table [Supported Battery Chemistries](#supported-battery-chemistries)) are chosen to avoid excessive stress on the batteries during summer while ensuring that sleep mode can be reliably initiated when energy is low. + +In low-voltage sleep, current consumption is < 500 µA. Once the battery voltage has risen above the respective low-V wake threshold (see table [Supported Battery Chemistries](#supported-battery-chemistries)) through solar charging, the board boots normally. The 200 mV hysteresis between sleep and wake thresholds prevents motorboating – an uncontrolled, rapid on/off cycling of the system that would occur if the sleep and wake thresholds were too close together. + +### Safety & Protection Features + +| Feature | Description | +|---------|-------------| +| **Watchdog Timer (WDT)** | nRF52840 hardware watchdog. Automatically reboots the board if the firmware hangs – essential for unattended long-term operation. | +| **Low-Voltage Protection** | INA228 ALERT interrupt on chemistry-specific threshold → controlled System Sleep with RTC wake. Solar charging remains active during sleep (CE pin latched). | +| **Charger requires active firmware** | The BQ25798 only charges when the firmware is actively running. Without flashed firmware or with the 3.3V off switch engaged, charging remains disabled. The nRF52840 must be able to monitor the charger at all times as host. | +| **JEITA Temperature Protection** | Temperature-dependent charge current reduction via the NTC sensor (TS pin). Frost charge protection configurable via `set board.fmax`. JEITA is disabled for LTO and Na-Ion. The Inhero voltage divider (RT1=5.6 kΩ, RT2=27 kΩ) shifts TS thresholds lower than TI reference (~2–3 °C; effective T-Cool range approx. −2 °C to +3 °C, see JEITA table in README). WARM zone configured to start at ~52 °C (register: 55 °C), effectively neutralized (VREG + ICHG unchanged in WARM), auto battery discharge disabled — see [README.md — JEITA](README.md#jeita-temperature-zone-configuration) for details. **Note:** JEITA thresholds are evaluated by the BQ25798 directly in hardware. The `set board.tccal` calibration corrects only the CLI/telemetry temperature readout and does not affect JEITA behavior — see [FAQ #12](FAQ.md#12-when-should-i-run-set-boardtccal). | + +> **⚠ WARNING — No Reverse Polarity Protection:** The board has **no hardware reverse polarity protection** on the battery or solar input. Connecting a battery or solar panel with reversed polarity will cause **immediate, irreversible damage** to the board. Always double-check the polarity before connecting any power source. + +### Solar Power Management + +| Feature | Description | +|---------|-------------| +| **MPPT (Maximum Power Point Tracking)** | The BQ25798 optimizes solar harvesting via MPPT (VOC_PCT = 81.25%, matched for crystalline silicon solar cells). Automatic recovery on power-good loss and stuck-PGOOD detection with HIZ toggle. | +| **PFM Forward Mode** | Enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it. Improves efficiency at low solar currents. | + +### Specifications + +| Parameter | Value | +|---|---| +| **MCU** | nRF52840 (ARM Cortex-M4, 64 MHz) | +| **Radio** | Semtech SX1262 (via RAK4630) | +| **Frequency** | LoRa Sub-GHz (region-dependent) | +| **Connectivity** | LoRa, BLE 5.0, USB-C | +| **Supply Voltage** | 1S Li-Ion / 1S LiFePO4 / 2S LTO / 1S Na-Ion (via firmware config) | +| **Solar Input** | 3.6 V – 24 V (MPPT) | +| **Max. Solar Voc** | 25 V | +| **USB Charging** | 5 V via USB-C (Schottky diode to VBUS-BQ, same charger path as solar) | +| **Charger** | BQ25798 (MPPT, JEITA) | +| **Max. Charge Current** | 50 – 1500 mA (configurable) | +| **Power Monitor** | INA228 (Coulomb Counter, ALERT) | +| **RTC** | RV-3028-C7 (time base / wake-up timer). See [FAQ #23](FAQ.md#23-why-does-the-repeater-board-need-a-correct-time) | +| **Buck Converter** | TPS62840 (3.3 V rail, max. 750 mA) | +| **System-Off Current** | via 3.3V off switch ~15 µA | +| **System Sleep Current** | < 500 µA (firmware sleep with GPIO latch, CE active, RTC wake) | +| **Idle Current (active)** | 6.0 mA @ 4.2 V / 7.7 mA @ 3.3 V (USB off, no radio TX) | +| **USB Peripheral** | ~0.8–1.0 mA additional (auto-enabled on VBUS detect, auto-disabled on removal) | +| **CPU Idle Mode** | WFE (Wait-For-Event) between loop iterations, reduces CPU current from ~3 mA to ~0.5–0.8 mA | +| **PCB Size** | 45 × 40 mm | +| **Mounting Holes** | 4× M2.5, hole spacing 40 × 35 mm | +| **Operating Temperature** | –40 °C to +85 °C (MCU spec) | +| **Bootloader** | Adafruit nRF52 OTA-Fix Bootloader (factory-installed), UF2-capable | + +--- + +## PCB – Front Side (Component Side) + +![Inhero MR2 Front](img/front.jpg) + +![Inhero MR2 Front – Annotated](img/front-annotated_.png) + +### Connectors, Buttons & LEDs – Front Side + +| Label (→ image) | Name | Description | +|-----------------|------|-------------| +| **Ble-Conn** | U.FL – BLE | Antenna connector for Bluetooth Low Energy (top left on RAK4630) | +| **LoRa-Conn** | U.FL – LoRa | Antenna connector for LoRa Sub-GHz (left center on RAK4630) | +| **USB-C** | USB-C Port | USB interface for power supply, charging, firmware flashing and CLI access (top right). CC1/CC2 pulled to GND via 4.7 kΩ (USB sink). VBUS-USB is connected to VBUS-BQ (solar input) via a Schottky diode — USB power feeds the same charger input as the solar panel. | +| **Reset** | Reset Button | Single click: reset the nRF52840. Double click: enter USB mass storage mode for UF2 firmware updates (right side, below USB-C) | +| **Led 1+2** | Status LEDs | LED1 + LED2 = RAK4630 user LEDs (heartbeat / boot indicator, right side, stacked) | +| **Chrg. Led** | Charge LED | BQ25798 STAT output – indicates charge status (bottom right, next to solar connector) | +| **3.3V off** | Power Switch | Slide switch to disconnect the 3.3 V supply (bottom left). **⚠ Caution: Inverted logic!** Switch position "ON" = EN pin low = board **off**. Switch position "OFF" = EN pin high = board **on**. | +| **Bat-Conn** (JST PH2.0-3P) | Battery Connector | 3-pin JST PH2.0 connector: **Batt+**, **Batt−**, **TS** (bottom left) | +| **Solar-Conn** (JST PH2.0-2P) | Solar Connector | 2-pin JST PH2.0 connector: **Solar+**, **Solar−** (bottom right) | +| **Ø 2.5mm** | Mounting Holes | 4× M2.5 mounting holes in the corners | + +### Key Components – Front Side + +| Component | Name | Description | +|-----------|------|-------------| +| **RAK4630** | Core Module | nRF52840 SoC + SX1262 LoRa transceiver (center, shielded) | +| **BME280** | Environmental Sensor | Temperature, humidity, pressure | +| **BQ25798** | Battery Charger | MPPT, JEITA temperature protection, 15-bit ADC | +| **INA228** | Power Monitor | Coulomb counter with ALERT interrupt | +| **TPS62840** | Buck Converter | DC/DC, 750 mA, EN switched via 3.3V off switch | + +### Pinout – Battery Connector (JST PH2.0-3P, left to right) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **Batt +** | Battery positive terminal | +| 2 | **Batt −** | Battery negative terminal (GND) | +| 3 | **TS** | Temperature sensor (NTC) for JEITA charge protection. Required type: NCP15XH103F03RC (10 kΩ @ 25 °C, Beta 3380) or compatible | + +> **⚠ WARNING:** No reverse polarity protection. Verify correct polarity before connecting. + +### Pinout – Solar Connector (JST PH2.0-2P, left to right) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **Solar +** | Solar panel positive (3.6 V – 24 V, max. Voc 25 V) | +| 2 | **Solar −** | Solar panel negative (GND) | + +> **⚠ WARNING:** No reverse polarity protection. Verify correct polarity before connecting. + +### USB Charging Path + +USB-C VBUS is connected to the BQ25798 VBUS input (same single input as solar) via a **Schottky diode**. The BQ25798 has only one VBUS input and does not distinguish between USB and solar. CC1 and CC2 are pulled to GND via 4.7 kΩ resistors, advertising the board as a USB power sink (5 V default). The Schottky diode prevents backflow from the solar panel to the USB bus, but current **can** flow from USB-VBUS out through the solar connector. + +#### USB Auto-Management + +The nRF52840 USB peripheral is automatically managed based on VBUS detection: + +- **VBUS detected** → USB peripheral enabled (Serial available) +- **VBUS removed** → USB peripheral disabled (saves ~0.8–1.0 mA) +- **Boot without USB** → USB disabled on first loop iteration + +No manual CLI commands are required. USB is always available when a cable is connected. See also [FAQ #7 — USB charging](FAQ.md#7-can-i-charge-the-board-via-usb). + +> **⚠ Warning:** Since VBUS-USB and VBUS-BQ (solar input) are connected via the Schottky diode, a **short circuit on the solar connector** will also short VBUS-USB. Never short-circuit the solar input while USB is connected. + +See also [FAQ #16 — 3.3V off switch](FAQ.md#16-what-does-the-33v-off-switch-do-and-when-would-i-use-it) for practical use cases. + +--- + +## PCB – Back Side + +![Inhero MR2 Back](img/back.jpg) + +![Inhero MR2 Back – Annotated](img/back-annotated_.png) + +### Headers & Pads – Back Side + +#### UART/I2C – Header Row 1 (top row, castellated pads) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **GND** | Ground | +| 2 | **RX** | UART Receive | +| 3 | **TX** | UART Transmit | +| 4 | **SDA** | I2C Data | +| 5 | **SCL** | I2C Clock | +| 6 | **3.3V** | 3.3 V output (max. 500 mA, shared with board consumption) | + +#### SWD – Header Row 2 (bottom row, castellated pads) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **RESET** | nRF52840 Reset | +| 2 | **GND** | Ground | +| 3 | **SWCLK** | SWD Clock (debug interface) | +| 4 | **SWDIO** | SWD Data (debug interface) | +| 5 | **3.3V** | 3.3 V output (max. 500 mA, shared with board consumption) | + +#### Solder Bridge – Onboard Temperature Sensor (bottom right) + +| Label (→ image) | Description | +|-----------------|-------------| +| **Solder-Bridge** (close for onboard Temp-Sensor) | Solder bridge for the onboard NTC temperature sensor (NCP15XH103F03RC, 10 kΩ @ 25 °C, Beta 3380). **Closed** = onboard NTC active. **Open** = external NTC of type NCP15XH103F03RC (10 kΩ @ 25 °C, Beta 3380) or compatible required via TS pin on the battery connector. See [FAQ #2](FAQ.md#2-can-i-use-battery-packs-without-a-built-in-ntc). | + +--- + +## I2C Bus – Address Map + +| Address | Component | Function | +|---------|-----------|----------| +| 0x40 | INA228 | Power Monitor / Coulomb Counter | +| 0x52 | RV-3028-C7 | Real-Time Clock (RTC) | +| 0x6B | BQ25798 | Battery Charger (MPPT, JEITA) | +| 0x76 | BME280 | Environmental Sensor (T, H, P) | + +--- + +## Pin Assignment (Key GPIOs) + +| nRF52840 Pin | RAK Module Pin | Function | +|--------------|----------------|----------| +| P0.04 | WB_IO4 | BQ CE pin (via DMN2004TK-7 N-FET, inverted) | +| P1.02 | WB_IO2 | INA228 ALERT (low-voltage interrupt) | +| P0.17 | WB_IO1 | RV-3028 RTC interrupt | +| P0.21 | WB_IO3 | BQ25798 INT (unused, polled; pulled up) | + +--- + +## Supported Battery Chemistries + +| Type | Nominal Voltage | Charge Voltage | Low-V Sleep | Low-V Wake | Hysteresis | +|------|----------------|----------------|-------------|------------|------------| +| **Li-Ion 1S** | 3.7 V | 4.1 V | 3100 mV | 3300 mV | 200 mV | +| **LiFePO4 1S** | 3.2 V | 3.5 V | 2700 mV | 2900 mV | 200 mV | +| **LTO 2S** | 4.6 V (2× 2.3 V) | 5.4 V | 3900 mV | 4100 mV | 200 mV | +| **Na-Ion 1S** | 3.1 V | 3.9 V | 2500 mV | 2700 mV | 200 mV | +| **none** | — | — | — | — | — | + +> **Choosing the right chemistry:** See [BATTERY_GUIDE.md](BATTERY_GUIDE.md) for a detailed comparison of pros, cons, and deployment recommendations. A brief summary is also in [FAQ #1](FAQ.md#1-which-battery-chemistry-should-i-choose). + +--- + +## Firmware Environments + +| Build Target | Description | +|---|---| +| `Inhero_MR2_repeater` | Standard repeater | +| `Inhero_MR2_repeater_bridge_rs232` | Repeater with RS232 bridge (Serial2 on P0.19/P0.20) | +| `Inhero_MR2_sensor` | Sensor firmware | + +--- + +## Absolute Maximum Ratings + +| Parameter | Min | Max | Unit | +|---|---|---|---| +| Solar input voltage (Voc) | — | 25 | V | +| Charge current (configurable) | 50 | 1500 | mA | +| Shunt current (INA228, 100 mΩ) | — | 1600 | mA | +| Ambient temperature (operating) | –40 | +85 | °C | + +--- + +## See Also + +- [README.md](README.md) – Overview, feature matrix and diagnostics +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) – Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) – Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) – Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) – All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) – Complete technical documentation diff --git a/variants/inhero_mr2/docs/FAQ.md b/variants/inhero_mr2/docs/FAQ.md new file mode 100644 index 0000000000..1d95269390 --- /dev/null +++ b/variants/inhero_mr2/docs/FAQ.md @@ -0,0 +1,403 @@ +# Inhero MR2 — FAQ + +## Contents + + +**⚡ Battery & Chemistry** + +1. [Which battery chemistry should I choose?](#1-which-battery-chemistry-should-i-choose) +2. [Can I use battery packs without a built-in NTC?](#2-can-i-use-battery-packs-without-a-built-in-ntc) +3. [Why does current draw increase when battery voltage drops?](#3-why-does-current-draw-increase-when-battery-voltage-drops) + +**🔋 Charging & Solar** + +4. [What mAh value should I enter for `set board.batcap`?](#4-what-mah-value-should-i-enter-for-set-boardbatcap) +5. [Why is it important to set the maximum charge current with `set board.imax`?](#5-why-is-it-important-to-set-the-maximum-charge-current-with-set-boardimax) +6. [What does `set board.fmax` control?](#6-what-does-set-boardfmax-control) +7. [Can I charge the board via USB?](#7-can-i-charge-the-board-via-usb) +8. [Which solar panels can I connect?](#8-which-solar-panels-can-i-connect) +9. [The red LED (BQ status LED) blinks slowly and the battery is not charging.](#9-the-red-led-bq-status-led-blinks-slowly-and-the-battery-is-not-charging) +10. [Why doesn't the board charge without flashed firmware?](#10-why-doesnt-the-board-charge-without-flashed-firmware) + +**📊 SOC & Monitoring** + +11. [Why does the SOC show 0% or N/A?](#11-why-does-the-soc-show-0-or-na) +12. [When should I run `set board.tccal`?](#12-when-should-i-run-set-boardtccal) +13. [How does temperature derating work?](#13-how-does-temperature-derating-work) +14. [What is TTL (Time-To-Live)?](#14-what-is-ttl-time-to-live) + +**🔩 Hardware** + +15. [Does the board have reverse polarity protection?](#15-does-the-board-have-reverse-polarity-protection) +16. [What does the "3.3V off" switch do, and when would I use it?](#16-what-does-the-33v-off-switch-do-and-when-would-i-use-it) +17. [What do the LEDs mean?](#17-what-do-the-leds-mean) +18. [Can I operate the board without an antenna?](#18-can-i-operate-the-board-without-an-antenna) +19. [Why does the RTC have no backup battery?](#19-why-does-the-rtc-have-no-backup-battery) +20. [What are the dimensions of the mounting holes?](#20-what-are-the-dimensions-of-the-mounting-holes) +21. [Are interfaces (UART/I2C) exposed on the board?](#21-are-interfaces-uarti2c-exposed-on-the-board) + +**⚙️ Firmware** + +22. [Are my settings preserved during a firmware update?](#22-are-my-settings-preserved-during-a-firmware-update) +23. [Why does the repeater board need a correct time?](#23-why-does-the-repeater-board-need-a-correct-time) +24. [Why can't the repeater clock be set backwards?](#24-why-cant-the-repeater-clock-be-set-backwards) + +--- + +**⚡ Battery & Chemistry** + +### 1. Which battery chemistry should I choose? + +The Inhero MR2 supports **Li-Ion**, **LiFePO4**, **LTO (2S)**, and **Na-Ion**. The right choice depends on your deployment conditions — especially temperature range, available space, and expected service life. + +In short: **LiFePO4** for most indoor/temperate setups, **LTO** for extreme cold or maximum cycle life, **Li-Ion** when space is tight, **Na-Ion** for sustainable cold-weather deployments. + +→ **Full guide:** [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Detailed comparison, pros & cons, deployment recommendations, capacity planning, solar sizing, safety tips, and long-term aging. + +→ **Setup:** [QUICK_START.md — Step 6](QUICK_START.md#6-set-battery-chemistry) | [CLI_CHEAT_SHEET.md — Quick-Start Recipes](CLI_CHEAT_SHEET.md#quick-start-recipes) + +--- + +### 2. Can I use battery packs without a built-in NTC? + +**Yes, but only with the onboard NTC.** Close the solder bridge on the back side of the board — this activates the onboard NTC (NCP15XH103F03RC, 10 kΩ @ 25 °C, Beta 3380). The TS pin on the battery connector remains unused in this case. + +If your battery pack has a built-in NTC, it must be wired between **TS (Pin 3)** and **GND (Pin 2)** (see [DATASHEET.md — Battery Connector](DATASHEET.md#pinout--battery-connector-jst-ph20-3p-left-to-right)). A compatible 10k NTC (Beta ~3380) is sufficient for basic frost protection — however, temperature accuracy will be slightly reduced. + +**Important:** Without an NTC (solder bridge open and no external NTC connected), the BQ25798 interprets the TS pin as a frost condition for Li-Ion and LiFePO4 — charging is blocked and the BQ status LED blinks. This does not occur with LTO and Na-Ion, as JEITA is disabled for those chemistries. + +--- + +### 3. Why does current draw increase when battery voltage drops? + +The Inhero MR2 has a high-efficiency **buck converter** that converts the battery voltage down to 3.3 V for the MCU and radio. Because this converter is efficient, the board draws roughly **constant power** (watts), not constant current (amps). + +Since Power = Voltage × Current: +- At 4.6 V (LTO full): ~6.3 mA +- At 3.7 V (Li-Ion nominal): ~7.8 mA +- At 3.2 V (LiFePO4 nominal): ~9.1 mA + +All three cases consume exactly **29 mW**. This is normal, not a fault. + +**Practical consequence:** When sizing batteries, always calculate in **Wh** (energy), not mAh — especially when comparing different chemistries. A naive "mA × hours" calculation overestimates the capacity needed for higher-voltage chemistries like LTO. + +→ **Full explanation:** [BATTERY_GUIDE.md — Why current depends on battery voltage](BATTERY_GUIDE.md#why-current-depends-on-battery-voltage) + +--- + +**🔋 Charging & Solar** + +### 4. What mAh value should I enter for `set board.batcap`? + +Enter the **nominal capacity minus a deduction**. Since the charge cutoff voltage is reduced for battery longevity, the full nominal capacity is not available. A slightly pessimistic value is safer: when the SOC shows 10 %, there really is ≥ 10 % left in the battery. This prevents being surprised by an unexpected low-voltage sleep. The TTL prediction (Time-To-Live) also becomes more conservative and reliable. + +**Rule of thumb: 90 % of nominal capacity.** Example: 10,000 mAh nominal → `set board.batcap 9000`. + +For parallel cells, add capacities before the deduction: Two 5,000 mAh cells in parallel = 10,000 mAh nominal → `set board.batcap 9000`. + +--- + +### 5. Why is it important to set the maximum charge current with `set board.imax`? + +`imax` sets the **maximum charge current** — the maximum current flowing into the battery. The firmware also uses `imax` together with the configured chemistry's charge voltage to automatically calculate how much current it may draw from the solar panel. This prevents weak panels from being overloaded and the charger from shutting down. + +Why set `imax` correctly? + +1. **Basis for frost protection:** `imax` is the reference value for `fmax`. Example: `imax 500` with `fmax 20%` results in a maximum of 100 mA charge current in the T-Cool range (+3 °C to –2 °C). + +2. **Battery care:** Lower charge currents are always gentler on the battery. Set `imax` only as high as necessary. + +3. **Panel compatibility:** If `imax` is set too high, the board briefly tries to draw more current from the panel than it can deliver — the charger detects the voltage drop and stops charging. + +**Calculation:** Panel power ÷ battery voltage = imax. +Example: 2 W panel, Li-Ion (3.7 V) → 2000 / 3.7 ≈ 540 mA → `set board.imax 540`. + +--- + +### 6. What does `set board.fmax` control? + +`fmax` limits the maximum charge current in the **T-Cool range** (+3 °C to –2 °C with the Inhero voltage divider) to a percentage of `imax`: + +| Setting | Behavior in T-Cool range | +|---|---| +| `0%` | Charging completely blocked | +| `20%` | Max. 20 % of imax (e.g., 500 mA → 100 mA) | +| `40%` | Max. 40 % of imax (e.g., 500 mA → 200 mA) | +| `100%` | No reduction, full charge current | + +**Below approx. –2 °C (T-Cold),** charging is always completely blocked by JEITA for **Li-Ion and LiFePO4** — regardless of `fmax`. + +**Important:** Only charging is restricted. With sufficient solar power, the board continues to run on solar — the battery is neither charged nor discharged. + +**LTO / Na-Ion:** `fmax` has no effect, as JEITA is disabled for these chemistries. + +--- + +### 7. Can I charge the board via USB? + +**Yes.** USB-C VBUS (5 V) is connected to the BQ25798 VBUS input via a **Schottky diode** — the **same single input** as the solar panel (see [DATASHEET.md — USB Charging Path](DATASHEET.md#usb-charging-path)). The BQ25798 has only one VBUS input and does not distinguish between the two sources. + +When USB is detected (nRF52840 VBUS sense), the firmware automatically limits the input current to **500 mA** (USB 2.0 spec). When USB is removed, the input current limit is recalculated from the configured chemistry and `board.imax`. + +Whichever source provides the higher voltage at the VBUS input is active: If USB voltage (minus Schottky drop) exceeds the solar voltage, USB charges. Otherwise, solar charges. Both sources cannot charge simultaneously. + +> **⚠ WARNING:** The Schottky diode prevents backflow from the solar panel to the USB bus, but current **can** flow from USB-VBUS out through the solar connector. A **short circuit on the solar connector will also short USB-VBUS**. Never short-circuit the solar input while USB is connected. + +--- + +### 8. Which solar panels can I connect? + +**Requirements:** +- **Input voltage:** 3.6 V – 24 V (MPPT range of the BQ25798) +- **Max. open-circuit voltage (Voc):** 25 V — do not exceed! +- **Connector:** JST PH2.0-2P (Solar+, Solar–) + +**Typical panels:** 5 V or 6 V monocrystalline solar panels. The buck/boost charger can also charge higher battery voltages from lower panel voltages (e.g., 5 V panel → LTO 2S at 5.4 V). + +**Not suitable:** 24 V panels or series connections whose Voc can exceed 25 V. See [DATASHEET.md — Specifications](DATASHEET.md#specifications) for the full electrical limits. + +**Sizing (Central Europe):** +- **1 W monocrystalline** is the minimum requirement — only with south-facing, vertical mounting, unshaded, and battery capacity ≥ 7 Ah. +- **From 2 W**, reliable year-round operation is possible. + +--- + +### 9. The red LED (BQ status LED) blinks slowly and the battery is not charging. + +Slow blinking of the BQ status LED indicates a **charger fault**. Most common causes: + +1. **No NTC connected (most frequent):** Neither an external NTC on the TS pin nor the solder bridge for the onboard NTC is closed. The BQ25798 interprets the open TS pin as a frost condition and blocks charging. → **Solution:** Close the solder bridge or connect a compatible NTC (10 kΩ @ 25 °C, Beta ~3380) between TS (Pin 3) and GND (Pin 2). + +2. **Actually too cold / too warm:** Below –2 °C (T-Cold threshold with Inhero voltage divider), charging is completely blocked by JEITA for Li-Ion and LiFePO4. Above ~58 °C (T-Hot threshold), charging is also suspended. → This does not occur with LTO and Na-Ion (JEITA disabled). + +3. **Other charger fault:** The BQ25798 can also signal faults such as VBAT overvoltage (VBAT_OVP), input overvoltage (VBUS_OVP), or watchdog timeout. These are less common in normal operation. + +→ Check with [`get board.telem`](CLI_CHEAT_SHEET.md#getters-query-status) for the current temperature and [`get board.cinfo`](CLI_CHEAT_SHEET.md#getters-query-status) for the charger status; fault flags are shown by [`get board.bqdiag`](CLI_CHEAT_SHEET.md#getters-query-status). + +--- + +### 10. Why doesn’t the board charge without flashed firmware? + +This is a deliberate safety feature. The BQ25798 charger is controlled via the **CE pin (Charge Enable)**, which requires the firmware to actively drive GPIO4 HIGH. + +**Without firmware** (or with the 3.3V off switch engaged): +- External pull-down on the DMN2004TK-7 FET gate → FET OFF → CE HIGH → **charging disabled** + +This ensures the battery cannot be overcharged if the firmware locks up or is not installed. Flash the firmware via USB and configure the battery chemistry (`set board.bat …`) to enable charging. See [POWER_MANAGEMENT.md — CE Pin Safety](POWER_MANAGEMENT.md#10-bq25798-ce-pin-safety-rev-11--fet-inverted) for the hardware design. + +--- + +**📊 SOC & Monitoring** + +### 11. Why does the SOC show 0% or N/A? + +**SOC shows N/A** until the battery has been **fully charged at the board for the first time**. The coulomb counter needs a known reference point (100% = "Charge Done" event) to calculate SOC accurately. Charge the battery completely once via USB after commissioning. See [POWER_MANAGEMENT.md — Coulomb Counter & SOC](POWER_MANAGEMENT.md#2-coulomb-counter--soc-state-of-charge) for the tracking mechanism. + +**SOC shows 0%** after the board wakes from **low-voltage sleep**. This is intentional: the coulomb counter was not running during sleep, so the charge state is unknown. SOC restarts at 0% and begins accumulating again. When the battery next reaches "Charge Done", the SOC synchronizes cleanly to 100%. + +**Note:** In cold conditions, the extractable capacity is lower than stored charge. `get board.telem` shows this as `SOC:95.0% (79%)`. The TTL accounts for this automatically. See [FAQ #13](FAQ.md#13-how-does-temperature-derating-work). + +--- + +### 12. When should I run `set board.tccal`? + +**Ideally in the early morning**, before sunrise. At that time the battery temperature has equalized with the ambient temperature overnight, and no solar radiation has warmed the enclosure yet. This gives the BME280 and the NTC the most consistent baseline for calibration. + +**Why timing matters:** During the day, solar radiation heats the enclosure unevenly — the NTC (close to the battery) and the BME280 (on the PCB) may report different temperatures, resulting in an inaccurate offset. In the early morning, both sensors are at thermal equilibrium. + +**Why TCCal exists:** An NTC and its associated voltage divider resistors are subject to component tolerances that produce measurement errors significantly larger than those of the BME280. Since the BME280 is on the board, it can serve as a reference to calibrate the NTC reading. + +**Important limitations:** +- **Affects telemetry and CLI only.** TCCal corrects the battery temperature displayed via `get board.telem` and transmitted over telemetry. The BQ25798 JEITA thresholds are **not** affected — the charger evaluates the TS pin directly in hardware. Therefore, the actual JEITA switching temperatures may differ slightly from the calibrated CLI readout. +- **Single-point calibration.** The offset is determined at one temperature. Away from the calibration temperature the correction drifts, because NTC non-linearity and divider errors are temperature-dependent. + +**Command:** `set board.tccal` — auto-calibrates the NTC offset using the BME280 as reference. Use `set board.tccal reset` to reset the offset to 0.00. See [`get board.tccal`](CLI_CHEAT_SHEET.md#getter-quick-reference) to verify the current offset. + +--- + +### 13. How does temperature derating work? + +SOC% is **purely Coulomb-based** — it reflects the actual stored charge and does not change with temperature. Only real charge flow (measured by the INA228 coulomb counter) changes SOC%. + +However, the **extractable capacity** decreases at cold temperatures due to slower electrochemical kinetics and increased internal resistance during TX peaks (~100 mA). The firmware calculates a per-chemistry derating factor `f(T)` that is used for: +- **TTL calculation** — Trapped Charge model: extractable = max(0, remaining − capacity × (1−f(T))) +- **CLI display** — `get board.telem` shows the derated value in parentheses: `SOC:95.0% (78%)` = stored (extractable) + +The derating factor is visible in `get board.socdebug` (field `d=`). + +→ **Full details:** [POWER_MANAGEMENT.md — Temperature Derating](POWER_MANAGEMENT.md#5-time-to-live-ttl-prediction) + +--- + +### 14. What is TTL (Time-To-Live)? + +TTL is an estimated **remaining runtime** based on the current energy balance. It is shown in [`get board.stats`](CLI_CHEAT_SHEET.md#getters-query-status). See [POWER_MANAGEMENT.md — TTL Prediction](POWER_MANAGEMENT.md#5-time-to-live-ttl-prediction) for the algorithm. + +**How it works:** +- A 168-hour ring buffer (7 days) records hourly charge/discharge data from the INA228 coulomb counter. +- **Formula:** `TTL = extractable capacity / |7-day avg. daily net consumption| × 24h` — where extractable = SOC%-based remaining charge minus the temperature-locked share (see Cold weather below; identical to `SOC% × capacity / 100` at moderate temperatures) +- **Display format:** `T:12d0h` (12 days, 0 hours) or `T:12h` (< 24 hours) + +**TTL shows N/A or 0 when:** +- Less than 24 hours of data have been collected +- The board is running on solar surplus (no deficit) + +**Note:** If `set board.batcap` is not set, a rough chemistry default (1500–2000 mAh) is used — set the real capacity for a meaningful TTL. + +**Cold weather:** TTL uses the Trapped Charge model — cold temperatures lock the bottom of the discharge curve, so extractable capacity drops faster than SOC% at low charge levels. This is especially critical in winter: at 20% SOC, the extractable capacity may already be near zero. See [FAQ #13](FAQ.md#13-how-does-temperature-derating-work). + +--- + +**🔩 Hardware** + +### 15. Does the board have reverse polarity protection? + +**No.** The board has **no hardware reverse polarity protection** — neither on the battery nor on the solar input. A reverse-connected battery or solar panel can cause **immediate, irreversible damage** to the board. + +**Always verify polarity before plugging in any cable.** See [DATASHEET.md — Safety & Protection Features](DATASHEET.md#safety--protection-features). + +--- + +### 16. What does the "3.3V off" switch do, and when would I use it? + +The slide switch labeled **"3.3V off"** on the bottom-left of the PCB controls the EN pin of the TPS62840 buck converter (see [DATASHEET.md — Connectors, Buttons & LEDs](DATASHEET.md#connectors-buttons--leds--front-side)). + +> **⚠ Caution — Inverted logic:** +> - Switch position **"ON"** = EN pin low = board **powered off** +> - Switch position **"OFF"** = EN pin high = board **running** + +With the 3.3V rail disabled, the nRF52840, the RF frontend, and all 3.3V-powered components (INA228, RV-3028 RTC, BME280) are completely de-energized. Only the BQ25798 charger IC remains powered from VBAT (~15 µA quiescent current). **Charging is disabled** in this state — the firmware must be running to supervise the charger. Note: the RTC loses its time when the 3.3V rail is off — see [FAQ #23](#23-why-does-the-repeater-board-need-a-correct-time). + +**Use cases:** +- **Antenna swap:** Safely power down the RF frontend on a deployed board without disconnecting battery or solar. +- **Transport:** Switch off the board during shipping or relocation. +- **Short/medium-term storage:** ~15 µA total consumption. For longer storage (months), disconnect the battery entirely. + +--- + +### 17. What do the LEDs mean? + +The board has three LEDs: + +| LED | Location | Color | Meaning | +|-----|----------|-------|--------| +| **LED1** | Right side, top | Blue | Heartbeat (periodic blink during normal operation). Short flash during boot for each successfully initialized component (INA228, BQ25798, RTC). | +| **LED2** | Right side, bottom | Red | Hardware error indicator. Blinks permanently if a critical component (BQ25798, INA228, or RTC) was not found during initialization. | +| **Charge LED** | Bottom right, next to solar connector | Red | BQ25798 charge status output (hardware-controlled). Solid on = charging active. Off = not charging or charging done. Slow blinking = charger fault (see FAQ #9). | + +All three LEDs can be disabled with [`set board.leds off`](CLI_CHEAT_SHEET.md#setters-change-configuration). + +**Note:** The descriptions for LED1/LED2 apply only after the firmware has booted. The bootloader uses its own LED patterns (e.g., slow blue pulsing during OTA/UF2 updates). + +--- + +### 18. Can I operate the board without an antenna? + +**No.** Operating without an antenna risks **irreversible damage** to the RF frontend (SX1262 radio). Always connect both antennas (LoRa and BLE) before powering on. + +If you need to swap or install antennas on an already deployed board, use the **3.3V off switch** (see FAQ #16) to safely de-energize the RF frontend without disconnecting battery or solar. + +--- + +### 19. Why does the RTC have no backup battery? + +The RV-3028-C7 RTC has two main functions: +1. **Stable time base** with minimal drift for MeshCore. +2. **Wake-up timer** for low-voltage sleep (hourly wake-up for voltage check). + +As long as a battery is connected, the RTC is continuously powered — including during System Sleep. After a low-voltage sleep and reboot, the time is preserved. + +A backup battery (e.g., CR2032) was intentionally omitted. Its only additional benefit would be to preserve the time when the battery is disconnected. This does not justify the space required on the compact 45 × 40 mm form factor. See [FAQ #23](#23-why-does-the-repeater-board-need-a-correct-time) for why a correct clock matters. + +--- + +### 20. What are the dimensions of the mounting holes? + +The board has **4× M2.5 mounting holes** with a diameter of **2.5 mm** and a hole spacing of **35 × 40 mm**. The PCB itself measures 45 × 40 mm. + +--- + +### 21. Are interfaces (UART/I2C) exposed on the board? + +**Yes.** Two rows of castellated pads are available on the back side of the PCB: + +**Row 1 — UART / I2C:** + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | GND | Ground | +| 2 | RX | UART Receive | +| 3 | TX | UART Transmit | +| 4 | SDA | I2C Data | +| 5 | SCL | I2C Clock | +| 6 | 3.3V | 3.3 V output (max. 500 mA, shared with board consumption) | + +**Row 2 — SWD (Debug):** + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | RESET | nRF52840 Reset | +| 2 | GND | Ground | +| 3 | SWCLK | SWD Clock | +| 4 | SWDIO | SWD Data | +| 5 | 3.3V | 3.3 V output (max. 500 mA, shared with board consumption) | + +The castellated pads can be soldered directly to a carrier board. See [DATASHEET.md — Headers & Pads](DATASHEET.md#headers--pads--back-side) for the complete pad layout. + +--- + +**⚙️ Firmware** + +### 22. Are my settings preserved during a firmware update? + +**Yes.** All board-specific settings are stored on the **LittleFS filesystem**, which is preserved during firmware updates. This includes: + +- Battery chemistry (`set board.bat`) +- Battery capacity (`set board.batcap`) +- Charge current (`set board.imax`) +- Frost protection (`set board.fmax`) +- MPPT, LED settings +- NTC calibration offset + +**Note:** Energy statistics (168h ring buffers for TTL) are held in RAM only and restart after any reboot or update. + +Settings are only lost on a full flash erase or filesystem corruption (rare). See [POWER_MANAGEMENT.md — Statistics Persistence](POWER_MANAGEMENT.md#11-statistics-persistence) for technical details. + +### 23. Why does the repeater board need a correct time? + +The firmware uses the RTC (Real-Time Clock) for several protection mechanisms. An incorrect clock does not cause a total outage — packets are still forwarded — but noticeable problems arise: + +- **Advertisements are rejected:** Every advertisement is cryptographically signed (Ed25519 over public key + timestamp + app data). Receivers compare the contained timestamp against the last stored value and discard timestamps that are equal or smaller as potential replay attacks. The existing entry on other nodes is preserved but no longer updated — name, position and "last seen" become increasingly stale. +- **Debug logs with incorrect timestamps:** `getLogDateTime()` shows wrong absolute times. Relative calculations like "last heard X seconds ago" remain correct as they use the same (wrong) clock source internally. + +Login, admin commands and the rate limiter are **not affected** — login/commands compare client-provided timestamps against each other only, and the rate limiter uses only relative time differences which remain correct as long as the clock ticks monotonically. A `clock sync` can therefore be run at any time after logging in. + +**How is the clock set?** +The Inhero MR2 has a hardware RTC that retains its time during a normal reboot. However, if the battery is disconnected or the 3.3V rail is switched off via the onboard switch, the RV-3028 loses its time and falls back to the POR default (January 2000). The clock can be set via CLI (`clock sync` or `time `) from an admin client. The repeater is **not** automatically synchronized by clients. + +> **Recommendation:** After every battery swap or power-down of the board's 3.3V rail via the onboard switch, run `clock sync` via CLI as soon as possible. A normal reboot is not affected. + +--- + +### 24. Why can't the repeater clock be set backwards? + +`clock sync` and `time ` only allow setting the clock **forward** — setting it backwards is rejected with `ERR: clock cannot go backwards`. + +**Why?** The firmware uses increasing timestamps to protect against replay attacks. Both advertisements and admin commands are rejected if their timestamp is equal to or lower than the last stored value. Since other nodes in the mesh store the last (high) timestamp, a clock rollback would cause new advertisements to be rejected mesh-wide. + +**Solution: `clkreboot`** — resets the clock to a low value and reboots the board; the reboot resets the per-client replay timestamps (stored client entries are kept). Run `clock sync` afterwards to set the correct time. + +> **Note:** After `clkreboot`, advertisements will temporarily be rejected by nodes that still have the old timestamp stored. Visibility normalises once those entries expire. + +See also [FAQ #23](#23-why-does-the-repeater-board-need-a-correct-time). + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware datasheet, pinouts and specifications +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide diff --git a/variants/inhero_mr2/docs/POWER_MANAGEMENT.md b/variants/inhero_mr2/docs/POWER_MANAGEMENT.md new file mode 100644 index 0000000000..5d597e37d0 --- /dev/null +++ b/variants/inhero_mr2/docs/POWER_MANAGEMENT.md @@ -0,0 +1,1035 @@ +# Inhero MR-2 Power Management - Implementation Documentation (Rev 1.1) + +## Table of Contents + +- [Overview](#overview) +- [Hardware Architecture](#hardware-architecture) +- [1. Low-Voltage Detection (INA228 ALERT ISR)](#1-low-voltage-detection-ina228-alert-isr) +- [2. Coulomb Counter & SOC (State of Charge)](#2-coulomb-counter--soc-state-of-charge) +- [3. Daily Energy Balance](#3-daily-energy-balance) +- [4. Solar Power Management](#4-solar-power-management) + - [BQ25798 ADC at Low Battery Voltages](#bq25798-adc-at-low-battery-voltages) + - [JEITA WARM Zone & VBAT_OVP Prevention](#jeita-warm-zone--vbat_ovp-prevention) +- [5. Time-To-Live (TTL) Prediction](#5-time-to-live-ttl-prediction) +- [6. RTC Wakeup Management](#6-rtc-wakeup-management) +- [7. Power Management Flow](#7-power-management-flow) +- [8. INA228 ALERT Pin (Rev 1.1)](#8-ina228-alert-pin-rev-11) +- [9. SX1262 Power Control & PE4259 RF Switch](#9-sx1262-power-control--pe4259-rf-switch) +- [10. BQ25798 CE Pin Safety (Rev 1.1 — FET-inverted)](#10-bq25798-ce-pin-safety-rev-11--fet-inverted) +- [11. Statistics Persistence](#11-statistics-persistence) +- [12. CLI Commands](#12-cli-commands) +- [See Also](#see-also) + +> This documentation describes the power management implementation for the Inhero MR-2 board. +> Hardware Rev 1.1: INA228 ALERT on P1.02, TPS62840 EN via 3.3V_off switch, CE pin via DMN2004TK-7 FET (inverted). + +--- + +## Overview + +The system combines **INA228 ALERT-based low-voltage detection** + **System Sleep with GPIO latch** + **Coulomb Counter** + **daily energy balance** + **CE pin FET safety** for maximum energy efficiency: + +1. **INA228 ALERT ISR** (P1.02) - Low-voltage detection via hardware interrupt +2. **System Sleep with GPIO latch** (< 500µA) with RTC wake - Minimal power consumption during low-voltage +3. **CE Pin FET Safety** (DMN2004TK-7) - Inverted logic, solar charging possible in System Sleep +4. **Coulomb Counter** (INA228) - Real-time SOC tracking +5. **Daily Energy Balance** (7-day rolling) - Solar vs. battery +6. **RTC Wakeup Management** (RV-3028-C7) - Periodic recovery checks + +### Feature Matrix + +| Feature | Status | Notes | +|---------|--------|-------| +| INA228 ALERT → Low-Voltage System Sleep | Active | ISR on P1.02 → volatile flag → tickPeriodic() → System Sleep with GPIO latch + RTC Wake | +| RTC Wakeup (Low-Voltage Recovery) | Active | 60 min (periodic) | +| BQ CE Pin Safety (FET-inverted) | Active | GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low), Dual-Layer: GPIO + I2C | +| System Sleep with latched CE | Active | < 500µA, GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging possible | +| SOC via INA228 + manual battery capacity | Active | `set board.batcap` available | +| SOC→Li-Ion mV Mapping (workaround) | Active | Will be removed when MeshCore transmits SOC% natively | +| MPPT Recovery + Stuck-PGOOD Handling | Active | Cooldown logic active | + + +--- + +## Hardware Architecture + +### Components +| Component | Function | I2C | Pin | Details | +|-----------|----------|-----|-----|---------| +| **RAK4630** | Core Module | — | — | nRF52840 SoC + SX1262 LoRa transceiver | +| **INA228** | Power Monitor | 0x40 | ALERT→P1.02 (ISR) | 100mΩ shunt, 1.6A max, Coulomb Counter, BUVL Alert | +| **BME280** | Temp/Humidity/Pressure sensor | 0x76 | — | NTC calibration reference (`set board.tccal`), selftest | +| **RV-3028-C7** | RTC | 0x52 | INT→GPIO17 | Countdown timer, wake-up. See [FAQ #23](FAQ.md#23-why-does-the-repeater-board-need-a-correct-time) | +| **BQ25798** | Battery Charger | 0x6B | INT→GPIO21 | MPPT, JEITA, 15-bit ADC (IBUS ~±30mA error at low currents; ADC has VBAT-dependent thresholds, see [Section 4](#bq25798-adc-at-low-battery-voltages)) | +| **BQ CE Pin** | Charge Enable | — | GPIO4 (P0.04) | Via DMN2004TK-7 FET: GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low) | +| **TPS62840** | Buck Converter | - | EN via 3.3V_off switch | 750mA, 3.3V rail | +| **DMN2004TK-7** | CE FET | — | Gate←GPIO4 (ext. pull-down) | N-FET, Drain→CE, Source→GND. GPIO HIGH → FET ON → CE LOW → charging on. Pull-down defaults gate LOW when floating. | +| **Schottky diode** | USB→VBUS Diode | — | — | VBUS-USB → VBUS-BQ (solar input). USB-C CC1/CC2 via 4.7kΩ to GND (USB sink). **⚠ Solar short also shorts VBUS-USB.** | + +--- + +## 1. Low-Voltage Detection (INA228 ALERT ISR) + +### Implementation (Rev 1.1 — Flag/Tick Architecture) +- **Trigger**: INA228 BUVL (Bus Under-Voltage Limit) ALERT on P1.02 +- **ISR**: `BoardConfigContainer::lowVoltageAlertISR()` → sets `lowVoltageAlertFired = true` (flag only, no FreeRTOS call) +- **Processing**: `tickPeriodic()` checks flag in main loop context → `board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE)` +- **Arming**: `armLowVoltageAlert()` is called during battery configuration (sets BUVL threshold + enables ISR) + +### Low-Voltage Flow + +``` +INA228 BUVL Alert (P1.02, FALLING edge) + │ + ▼ +lowVoltageAlertISR() [ISR context] + │ Sets lowVoltageAlertFired = true (volatile flag) + ▼ +tickPeriodic() [Main loop context, next tick()] + │ Checks lowVoltageAlertFired == true + ▼ +board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE) + │ CE latched HIGH (GPIO latch preserved → FET ON → CE LOW → charging stays ON) + │ RTC wake configured (LOW_VOLTAGE_SLEEP_MINUTES = 60) + │ GPREGRET2 → LOW_VOLTAGE_SLEEP flag + ▼ +sd_power_system_off() → System Sleep with GPIO latch (< 500µA) +``` + +### Chemistry-Specific Thresholds (1-Level System, uniform 200mV hysteresis) + +| Chemistry | lowv_sleep_mv (ALERT) | lowv_wake_mv (0% SOC) | Hysteresis | +|-----------|----------------------|----------------------|------------| +| **Li-Ion 1S** | 3100 | 3300 | 200mV | +| **LiFePO4 1S** | 2700 | 2900 | 200mV | +| **LTO 2S** | 3900 | 4100 | 200mV | +| **Na-Ion 1S** | 2500 | 2700 | 200mV | + +**Implementation**: `BoardConfigContainer` — `battery_properties[]` lookup table +- `lowv_sleep_mv` → INA228 BUVL Alert threshold, triggers System Sleep +- `lowv_wake_mv` → RTC wake threshold (early boot checks VBAT, decides boot or sleep again) +- Static methods: `getLowVoltageSleepThreshold(type)`, `getLowVoltageWakeThreshold(type)` + +--- + +## 2. Coulomb Counter & SOC (State of Charge) + +### INA228 Integration +- **Driver**: `lib/Ina228Driver.cpp` +- **Init**: `BoardConfigContainer::begin()` + - 100mΩ shunt calibration + - CURRENT_LSB = 1.6384A / 524288 ≈ 3.125µA + - ADC Range ±163.84mV (ADCRANGE=0, optimal for 1A @ 100mΩ) + - **ADC Averaging**: 256 samples (filters TX voltage peaks) + - BUVL Alert configured to `lowv_sleep_mv` (chemistry-specific) + +### SOC Calculation +**Method**: `updateBatterySOC()` in `BoardConfigContainer.cpp` +- **Primary**: Coulomb Counting (INA228 CHARGE register) +- **Update interval**: every 60s via tickPeriodic(); no SOC updates while in low-voltage sleep (the RTC wake only checks VBAT and re-sleeps or boots) + +**Formula**: +``` +SOC_delta = charge_delta_mah / capacity_mah × 100% +SOC_new = SOC_old + SOC_delta +``` + +**Auto-Sync**: On BQ25798 "Charge Done", SOC is set to 100%. + +### Capacity Management + +#### Configuration Required +Battery capacity **must be set manually**, as it varies widely in practice: +- **Typical range**: 4000-24000mAh (4-24Ah) +- **CLI command**: `set board.batcap ` +- **Allowed range**: 100-100000mAh + +**Important**: Without correct capacity, SOC% and TTL calculations are inaccurate! + +#### Persistence Mechanism +**Storage Path**: `/inheromr2/batCap.txt` (LittleFS via SimplePreferences) +**Save Method**: `setBatteryCapacity()` in `BoardConfigContainer.cpp` (persists via SimplePreferences) +**Load Method**: `loadBatteryCapacity()` + +**Saved on**: +1. **Manual setting**: CLI command `set board.batcap ` + - Writes immediately to LittleFS + - Updates `batteryStats.capacity_mah` + +**Loaded on**: +- **Boot time**: `BoardConfigContainer::begin()` calls `loadBatteryCapacity()` +- **Fallback**: When no saved capacity exists +- **Validation**: Range check 100-100000mAh + +**Persistence properties**: +- ✅ **Survives** software shutdowns (System Sleep) +- ✅ **Survives** power cycles and low-voltage recovery +- ✅ **Survives** firmware updates (LittleFS preserved) +- ⚠️ **Lost** on: flash erase, `rm -rf /inheromr2/`, filesystem corruption + +--- + +## 3. Daily Energy Balance + +### Tracking (168-Hour Ring Buffer) +**Methods**: `updateHourlyStats()` + `calculateRollingStats()` in `BoardConfigContainer.cpp` +- **Called by**: tickPeriodic() (every 60 min) +- **Sampling**: On each hour boundary (RTC time truncated to full hours), the completed hour is written into the ring buffer + +**Data structure**: `BatterySOCStats.hours[168]` (7 days × 24 hours) +```cpp +typedef struct { + uint32_t timestamp; // start of hour, unix seconds + float charged_mah; // charged this hour + float discharged_mah; // discharged this hour + float solar_mah; // solar share this hour +} HourlyBatteryStats; +``` + +The per-hour values are accumulated from INA228 CHARGE register deltas in `updateBatterySOC()` (every 60s): a positive delta counts as charged (and solar), a negative delta as discharged. + +### Calculations +**Rolling sums** (`calculateRollingStats()`, after each completed hour): +``` +last_24h_net_mah = Σ(solar − discharged) over the last 24 hours +avg_3day_daily_net_mah = Σ(solar − discharged) over 72h / 3 (needs ≥ 24h of data) +avg_7day_daily_net_mah = Σ(solar − discharged) over 168h / 7 (needs ≥ 24h of data; used for TTL) +``` + +**Living Status**: +- `living_on_battery = true` when `last_24h_net_mah < 0` (net deficit over the last 24h) +- Solar surplus (SOL in `board.stats`) is simply `living_on_battery == false` + +--- + +## 4. Solar Power Management + +### Design Principle + +The BQ25798 decides **itself** via PowerGood (PG) whether an input is usable. +The charger runs in always-active mode (HIZ disabled). +The firmware monitors solar status and re-enables MPPT as needed. + +No INT pin interrupt — everything runs via polling in `runMpptCycle()` (60s interval). + +### Solar Checks + +`runMpptCycle()` performs two checks each cycle: +1. `checkAndFixSolarLogic()` — PG-stuck recovery + MPPT re-enablement +2. `updateMpptStats()` — Updates MPPT statistics for 7-day average + +### PFM Forward Mode + +- PFM forward mode is enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it +- PFM improves efficiency at low solar currents + +### MPPT Recovery + PG-Stuck + +`checkAndFixSolarLogic()` handles two scenarios: + +**PG=1**: MPPT re-enablement — BQ25798 automatically disables MPPT on faults. +Readback check: only write when actual change needed. + +**PG=0 + VBUS ≥ 4.5V**: PG-stuck recovery — panel delivers voltage, but BQ has not +qualified the input source (typical during slow sunrise). HIZ toggle forces +new input qualification. 5-minute cooldown prevents excessive toggling. +Constant: `PG_STUCK_VBUS_THRESHOLD_MV = 4500` in BoardConfigContainer.h + +### BQ25798 Interrupt Handling + +**BQ INT pin (GPIO 21)**: Not used as interrupt — `INPUT_PULLUP` against floating. +BQ status is checked via polling in `runMpptCycle()` every 60s. + +**Flag clearing on boot**: `BqDriver::clearInterruptFlags()` (called from `BoardConfigContainer::begin()`) +- Reads the CHARGER_FLAG/FAULT_FLAG registers 0x22–0x27 to de-assert the INT line +- Prevents stale faults from previous power cycle + +### Flag/Tick Architecture + +All I2C operations run in main loop context via `tickPeriodic()` (called by `InheroMr2Board::tick()`). There are no FreeRTOS tasks for I2C access — this eliminates mutex and race conditions. + +**I2C Bus Recovery** (in `InheroMr2Board::begin()`): After OTA/warm reset, an I2C slave may hold SDA low. Before `Wire.begin()`, up to 9 SCL pulses + STOP condition are generated to free the bus. + +**tickPeriodic()** dispatches periodic work via `millis()` timers: +``` +tickPeriodic() [called by tick(), main loop] + ├─ Check low-voltage alert flag → initiateShutdown() + ├─ Every 60s: runMpptCycle() + │ ├─ checkAndFixSolarLogic() — PG-stuck recovery (HIZ toggle) + MPPT recovery + │ └─ updateMpptStats() — Update MPPT statistics + ├─ Every 60s: updateBatterySOC() + └─ Every 60min: updateHourlyStats() +``` + +**Remaining FreeRTOS tasks** (GPIO only, no I2C): +- `heartbeatTask` — blue LED blink pattern +- `ErrorLED` lambda — red LED on missing components + +**Timing Summary**: +- **MPPT Cycle**: 60 seconds (via tickPeriodic) +- **SOC Update**: 60 seconds (via tickPeriodic) +- **Hourly Stats**: 60 minutes (via tickPeriodic) + +### BQ25798 ADC at Low Battery Voltages + +> **Reference:** BQ25798 Datasheet (TI SLUSE22), Section 9.3.16 — ADC + +#### Problem + +The 15-bit ADC in the BQ25798 has **voltage-dependent operating thresholds** that become relevant in battery-only operation (without solar). At low battery voltages, the ADC cannot complete its conversion — `ADC_EN` stays set and the firmware runs into a timeout. + +#### Datasheet Quote (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."* + +#### Operating Scenarios + +| Condition | VBUS | VBAT | TS Channel | ADC | Temperature | +|-----------|------|------|------------|-----|-------------| +| Solar connected | > 3.4V | any | enabled | ✅ runs | ✅ available | +| Battery operation, normal | — | ≥ 3.2V | enabled | ✅ runs | ✅ available | +| Battery operation, low | — | 2.9–3.2V | **disabled** | ✅ runs | ❌ not available | +| Battery operation, critical | — | < 2.9V | disabled | ❌ timeout | ❌ not available | + +#### Firmware Solution: VBAT-dependent TS Channel Control + +The firmware reads the current battery voltage from the INA228 and passes it to `BqDriver::getTelemetryData(vbat_mv)`: + +- **VBAT ≥ 3.2V** (or unknown): TS channel enabled → ADC threshold 3.2V, temperature available +- **VBAT < 3.2V**: TS channel disabled → ADC threshold drops to 2.9V, temperature shown as "N/A" + +This allows the ADC to continue working in the 2.9–3.2V range for solar measurements (VBUS, IBUS), even when battery temperature cannot be read. + +#### ADC Channel Configuration (only required channels) + +On the MR-2, D+, D−, VAC1, VAC2 are not connected. The firmware enables only the actually used channels: + +| Register | Value (TS on) | Value (TS off) | Active Channels | +|----------|---------------|----------------|-----------------| +| 0x2F (ADC_FUNCTION_DISABLE_0) | `0x5A` | `0x5E` | IBUS, VBUS, (TS) | +| 0x30 (ADC_FUNCTION_DISABLE_1) | `0xF0` | `0xF0` | none (D+/D−/VAC disabled) | + +**Important:** In one-shot mode, `ADC_EN` is only cleared when **all enabled channels** have completed conversion. Unconnected channels can block this → therefore only required channels are enabled. + +#### Temperature Sentinel Values + +The firmware uses special return values for invalid temperatures: + +| Value | Meaning | Display | +|-------|---------|---------| +| −999.0 | I2C communication error | N/A | +| −888.0 | ADC not ready / TS disabled (low VBAT) | N/A | +| −99.0 | NTC open/not connected | N/A | +| +99.0 | NTC short circuit | N/A | +| −50…+90°C | Valid measurement | XX°C | + +**Display rule:** Values ≤ −100°C are shown as "N/A" in CLI and omitted from CayenneLPP packets. + +#### Code References +- `BqDriver::getTelemetryData(vbat_mv)` — Main function with VBAT-dependent TS control +- `BqDriver::startADCOneShot(ts_enabled)` — Configures ADC channels and starts conversion +- `BoardConfigContainer::getTelemetryData()` — Passes INA228 VBAT to BqDriver + +### JEITA WARM Zone & VBAT_OVP Prevention + +#### Problem: Default JEITA Configuration + Inhero Divider + +The Inhero MR2 uses a non-standard NTC voltage divider (RT1=5.6 kΩ pullup to REGN, RT2=27 kΩ parallel to GND) instead of the TI reference design (5.24 kΩ / 30.31 kΩ). This shifts TS thresholds lower by a **temperature-dependent** amount: ~5–6 °C in the cold range (where NTC resistance is large relative to RT2, amplifying the divider mismatch) and ~2–3 °C in the warm/hot range. + +With the BQ25798 POR defaults (`TS_WARM = 45°C`, `JEITA_VSET = VREG−400mV`, `EN_AUTO_IBATDIS = 1`), this caused a critical failure chain at moderate temperatures (~42 °C): + +``` +42°C ambient → TS = 44.65% REGN (below VT3_FALL = 44.8%) + → BQ enters WARM zone + → JEITA_VSET reduces VREG: 3.5V − 400mV = 3.1V (LiFePO4) + → Battery at 3.47V > 104% × 3.1V = 3.224V → VBAT_OVP triggers + → Converter stops, EN_AUTO_IBATDIS sinks IBAT_LOAD = 30mA from battery + → Total drain: −11mA (system) + −30mA (IBAT_LOAD) = −41mA + → Recovery requires VBAT < 102% × 3.1V = 3.162V → hours of battery drain +``` + +#### Fix: Three Register Settings in `configureBaseBQ()` + +| Setting | Register | Value | Effect | +|---------|----------|-------|--------| +| `setTsWarm(BQ25798_TS_WARM_55C)` | NTC Control 1 (0x18), bits 5:4 | 55 °C (37.7% REGN) | WARM zone starts at ~52 °C (Inhero), not ~42 °C | +| `setJeitaVSet(BQ25798_JEITA_VSET_UNCHANGED)` | NTC Control 0 (0x17), bits 7:5 | UNCHANGED | No VREG reduction in WARM — prevents VBAT_OVP | +| `JEITA_ISETH` (POR default retained) | NTC Control 0, bits 4:3 | 11b = ICHG unchanged | No charge current reduction in WARM | +| `setAutoIBATDIS(false)` | Charger Control 0, bit 7 | 0 | Disables 30 mA active battery discharge during OVP | + +> **Result:** With JEITA_VSET=UNCHANGED and JEITA_ISETH=ICHG unchanged, the WARM zone (T3–T5) is effectively neutralized. Charging continues at full voltage and full current until T-Hot (~58 °C), where charging is suspended entirely. + +#### TS Threshold Comparison + +| Zone Boundary | BQ Register | % REGN | TI Reference (°C) | Inhero MR2 (°C) | Shift | +|---------------|-------------|--------|--------------------|------------------|-------| +| VT1 (Cold) | — | 72.0% | +3.7 | −2.0 | −5.7 °C | +| VT2 (Cool) | — | 69.8% | +7.9 | +2.8 | −5.1 °C | +| VT3 (Warm) | TS_WARM=55°C | 37.7% | +54.5 | +52.2 | −2.3 °C | +| VT5 (Hot) | — | 34.2% | +59.9 | +57.7 | −2.2 °C | + +> NTC models: 103AT (B25/50=3435) for TI reference, NCP15XH103F03RC (B25/85=3380) for Inhero. Typical %REGN from BQ25798 datasheet. + +#### Code References +- `BoardConfigContainer::configureBaseBQ()` — Applies all three settings at startup +- `BqDriver::setTsWarm()` / `setJeitaVSet()` — Existing driver API +- `BqDriver::setAutoIBATDIS()` — Added to driver (Charger Control 0, bit 7) + +--- + +## 5. Time-To-Live (TTL) Prediction + +### Data Source and Time Base + +The TTL calculation is based on the **7-day moving average** of daily net energy consumption, calculated from a **168-hour ring buffer** (7 days) of hourly INA228 coulomb counter measurements. + +#### Data Flow + +``` +INA228 Hardware Coulomb Counter (24-bit ADC, ±0.1% accuracy) + │ + ▼ +updateHourlyStats() — every hour + │ Stores per hour: charged_mah, discharged_mah, solar_mah + │ in hours[168] ring buffer (BatterySOCStats.hours[]) + ▼ +calculateRollingStats() — after each hourly update + │ Sums last 168 hours → divides by 7 + │ → avg_7day_daily_net_mah (= solar − discharged per day) + │ Minimum requirement: ≥ 24 hours of valid data + ▼ +calculateTTL() — after calculateRollingStats() + │ extractable_mah / |deficit_per_day| × 24 = TTL hours + │ (extractable = remaining − trapped charge, see formula below) + ▼ +socStats.ttl_hours → getTTL_Hours() → board.stats / telemetry +``` + +### Calculation +**Method**: `calculateTTL()` in `BoardConfigContainer.cpp` +- **Called**: After `calculateRollingStats()` (hourly) +- **Time base**: 7-day moving average (`avg_7day_daily_net_mah`) from hourly samples + +**Prerequisites for TTL > 0**: +1. `living_on_battery == true` (24h net is negative, i.e. energy deficit) +2. `avg_7day_daily_net_mah < 0` (7-day average shows net discharge) +3. `capacity_mah > 0` (battery capacity known, via `set board.batcap`) +4. At least **24 hours** of valid data in the ring buffer + +**Formula** (Trapped-Charge model): +``` +remaining_capacity_mah = (SOC% / 100) × capacity_mah +trapped_mah = capacity_mah × (1 − f(T)) +extractable_mah = max(0, remaining_capacity_mah − trapped_mah) +daily_deficit_mah = -avg_7day_daily_net_mah (positive value) +TTL_hours = extractable_mah / daily_deficit_mah × 24 +``` +f(T) is the chemistry-specific cold-temperature derating factor (`temp_derating_factor`); f(T) = 1 at ≥ 25 °C, so at moderate temperatures nothing is trapped and the formula reduces to remaining/deficit. + +**TTL = 0 means**: +- Device is solar-powered (net surplus) → `living_on_battery == false` +- Less than 24h of data collected (cold start) +- Battery capacity unknown + +**Infinite TTL (telemetry)**: +- When `living_on_battery == false` and SOC valid → transmitted as 990 days (max value) + +**Example**: +- SOC: 60% = 1200mAh remaining (with 2000mAh capacity) +- Temperature ≥ 25 °C → f(T) = 1, no trapped charge → extractable = 1200mAh +- 7-day avg: -100 mAh/day (from 168h hourly samples) +- TTL: 1200 / 100 × 24 = 288 hours = 12 days + +**CLI output**: `board.stats` +``` ++150/+120/+90mAh C:200 D:50 3C:180 3D:60 7C:160 7D:70 SOL M:85% T:N/A ← Solar surplus +``` +or +``` +-80/-100/-110mAh C:10 D:90 3C:15 3D:115 7C:20 7D:130 BAT M:45% T:12d0h ← 12 days until empty +``` + +--- + +## 6. RTC Wakeup Management + +### RV-3028-C7 Integration +**Pin**: GPIO17 (WB_IO1) → RTC INT +**Init**: `InheroMr2Board::begin()` +- `attachInterrupt(RTC_INT_PIN, rtcInterruptHandler, FALLING)` +- Checks `GPREGRET2` for wake-up reason + +### Countdown Timer Configuration +**Method**: `configureRTCWake()` in `InheroMr2Board.cpp` +- **Tick Rate**: 1/60 Hz (1 minute per tick), configured via TD=11 in CTRL1 +- **Max Countdown**: 4095 minutes ≈ 2.8 days (12-bit timer register) +- **Low-Voltage Sleep Interval**: `LOW_VOLTAGE_SLEEP_MINUTES` = 60 min (1h) +- **Rationale**: Each wake is a System-ON reset with an early-boot fast path (minimal I2C: clear RTC TF, read VBAT, re-sleep) costing only ~0.03 mAh + +**Registers**: +```cpp +RV3028_CTRL1 (0x0F): TE=1, TD=11 (1/60 Hz), TRPT=0 (Single shot) +RV3028_CTRL2 (0x10): TIE=1 (Timer Interrupt Enable, bit 4) +RV3028_STATUS (0x0E): TF (Timer Flag, bit 3) — must be cleared after wake! +RV3028_TIMER_VALUE_0 (0x0A): Countdown value LSB +RV3028_TIMER_VALUE_1 (0x0B): Countdown value MSB (upper 4 bits) +``` + +### Interrupt Handler +**Method**: `rtcInterruptHandler()` — only sets `rtc_irq_pending = true`. + +The actual TF clear happens in main loop context in `tick()` via I2C (read-modify-write, clears only the TF bit): +```cpp +// In InheroMr2Board::tick() — main loop context: +if (rtc_irq_pending) { + rtc_irq_pending = false; + // Read RV3028_REG_STATUS ... + uint8_t status = Wire.read(); + status &= ~(1 << 3); // Clear TF bit only → INT pin goes HIGH via pull-up + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); // write back — other status flags stay untouched + Wire.endTransmission(); +} +``` + +**Why not in the ISR?** I2C (Wire) must not be called from an ISR context. +The ISR only sets the flag; `tick()` checks it in the main loop. + +--- + +## 7. Power Management Flow + +### Shutdown Sequence (Rev 1.1 — System Sleep with GPIO latch) +**Method**: `initiateShutdown()` in `InheroMr2Board.cpp` + +**On Low-Voltage → System Sleep with GPIO latch** (< 500µA, CE FET holds state): + +**Flow:** INA228 ALERT ISR → Flag → tickPeriodic() → `board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE)`: + +1. **Stop Background Tasks**: `BoardConfigContainer::stopBackgroundTasks()` + - Stops heartbeat task (only remaining FreeRTOS task with GPIO) + - Disarms INA228 low-voltage alert (detach ISR, disable BUVL) + +2. **INA228 to minimum current**: release the ALERT pin (`enableAlert(false, ...)`, `setUnderVoltageAlert(0)` — a latched-LOW ALERT would waste ~330µA through the pull-up), then `shutdown()` (ADC off, ~3.5µA) + +3. **SX1262 Sleep + PE4259 off**: `inhero::prepareRadioForSystemOff()` — first `radio.sleep(false)` (Cold Sleep via SPI, ~0.16µA), then `digitalWrite(SX126X_POWER_EN, LOW)` (PE4259 VDD off) + +4. **LEDs off**: PIN_LED1, PIN_LED2 LOW + +5. **Latch CE pin HIGH** (GPIO output latch preserved for P0.04): + - `digitalWrite(BQ_CE_PIN, HIGH)` → DMN2004TK-7 ON → CE LOW → charging active + - P0.04 is excluded from `disconnectLeakyPullups()` → GPIO latch stays HIGH in System Sleep + - Without latch: ext. pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** + +6. **INA228 + BQ25798 minimal current**: `inhero::prepareIcsForSystemOff()` (raw-I2C safety net, repeats the INA228 shutdown with readback) + +7. **BME280 to sleep**: forced Sleep mode via I2C (saves ~1–7µA; harmless NACK if not populated) + +8. **Configure RTC wake**: `configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)` (60 min) + +9. **Clear P0 LATCH for the RTC INT pin** (a stale latch would fire DETECT immediately → instant wake → boot loop) + +10. **Release I2C**: `Wire.end()`, then `inhero::disconnectLeakyPullups()` (each held-LOW pull-up wastes ~250µA) + +11. **Save shutdown reason**: `NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | reason` + +12. **System Sleep with GPIO latch**: `sd_power_system_off()` → nRF52840 System-Off (< 500µA total) + - GPIO4 latch preserved (excluded from disconnectLeakyPullups) → FET stays ON → CE LOW → **charging active** + - RAM contents are lost (168h statistics, SOC, etc.) + - RTC interrupt on GPIO17 wakes system after timer expires + +SOC is not written during shutdown — on the next successful recovery boot, `begin()` calls `setSOCManually(0.0)` (low-voltage recovery), so SOC restarts at 0%. + +**Why System Sleep with GPIO latch?** +- DMN2004TK-7 FET for CE pin → GPIO4 latch preserved HIGH → FET ON → CE LOW → charging active +- Total consumption: **< 500µA** (nRF52840 System-Off + RTC + quiescent currents of all components) + +**168h statistics are lost on System Sleep** — no persistence mechanism exists for the ring buffer data. After recovery, statistics start from zero. + +### Wake-up Check (Anti-Motorboating) +**Method**: `InheroMr2Board::begin()` + +The code checks `GPREGRET2` for shutdown reason and battery voltage for wake-up decisions. + +**2 Cases**: + +**Case 1: Wake from Low-Voltage Sleep** (`(GPREGRET2 & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE`) +```cpp +// InheroMr2Board::begin() — Early Boot Fast Path (simplified) +uint8_t shutdown_reason = NRF_POWER->GPREGRET2; +if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE) { + Wire.begin(); + inhero::clearTimerFlag(); // wake was a reset — the ISR never saw the RTC event + uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR); + uint16_t wake_threshold = getLowVoltageWakeThreshold(); + + if (vbat_mv == 0 || vbat_mv < wake_threshold) { + // Voltage still too low → back to System Sleep. + // The wake reset cleared all PIN_CNF — the sleep-time GPIO latch does NOT + // survive it. CE must be re-driven OUTPUT HIGH or solar charging stops. + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, HIGH); + inhero::prepareIcsForSystemOff(); // INA228 + BQ25798 to minimal current + inhero::prepareRadioForSystemOff(false); // SX1262 back to Cold Sleep + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + inhero::disconnectLeakyPullups(); + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + sd_power_system_off(); // Stays in low-voltage sleep cycle + } + // Voltage OK → normal boot; low-voltage recovery marking + SOC=0% + // are applied after boardConfig.begin() + NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE; +} +``` + +**Case 2: Normal Cold Boot** (power-on, reset button, voltage OK) +```cpp +else { + // Continue normal boot + // INA228 and all other components are initialized +} +``` + +**Direct ADC Read** (boardConfig not yet ready): +```cpp +// Must read directly from INA228 ADC registers (24-bit, ±0.1% accuracy) +uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR); +``` + +**Voltage Thresholds** (Chemistry-Specific, 1-Level System): +| Chemistry | lowv_sleep_mv (ALERT) | lowv_wake_mv (Recovery) | Hysteresis | +|-----------|----------------------|------------------------|------------| +| Li-Ion 1S | 3100 | 3300 | 200mV | +| LiFePO4 1S | 2700 | 2900 | 200mV | +| LTO 2S | 3900 | 4100 | 200mV | +| Na-Ion 1S | 2500 | 2700 | 200mV | + +**Anti-Motorboating**: The early-boot check in `begin()` prevents the system from repeatedly booting and immediately crashing at marginal voltage. Only when VBAT is above `lowv_wake_mv` does it boot normally. + +**Power consumption in System Sleep with GPIO latch (Low-Voltage Sleep)**: +- **Total: < 500µA** (nRF52840 System-Off + RTC + quiescent currents of all components) +- CE FET: GPIO4 latch preserved HIGH → FET ON → CE LOW → **solar charging active** + +--- + +## 8. INA228 ALERT Pin (Rev 1.1) + +### Wiring +**Pin**: INA228 ALERT → P1.02 (nRF52840 GPIO, with ext. pull-up) +**TPS62840 EN**: Switched via 3.3V_off slide switch + +### Operation +The ALERT pin is used as a **software interrupt**: + +1. `armLowVoltageAlert()` configures INA228 BUVL (Bus Under-Voltage Limit) to `lowv_sleep_mv` +2. ALERT fires as FALLING edge interrupt on P1.02 +3. ISR (`lowVoltageAlertISR()`) sets `lowVoltageAlertFired = true` (flag only, no FreeRTOS call) +4. `tickPeriodic()` checks flag in the next main loop tick and calls `initiateShutdown()` → System Sleep + +**No latch problem**: Since ALERT does not go to TPS62840 EN, there is no latched-off behavior. +The system can boot normally after RTC wake and check voltage in `begin()`. + +--- + +## 9. SX1262 Power Control & PE4259 RF Switch + +### Hardware Architecture +- **SX1262**: LoRa transceiver (SPI bus), sleep mode via `SetSleep` SPI command +- **PE4259**: SPDT RF antenna switch in **single-pin mode**: + - **Pin 6 (VDD)**: GPIO 37 (P1.05, `SX126X_POWER_EN`) — power supply (must be HIGH for operation) + - **Pin 4 (CTRL)**: SX1262 DIO2 — TX/RX switching (automatic via `setDio2AsRfSwitch(true)`) + +### Shutdown Sequence (in `initiateShutdown()`) +The SX1262 is powered down in **two steps** — **order is critical**: + +```cpp +// Step 1: SX1262 to Cold Sleep via SPI (MUST be first!) +radio_driver.powerOff(); // → radio.sleep(false) → SPI SetSleep command +delay(10); + +// Step 2: PE4259 RF switch power off +digitalWrite(SX126X_POWER_EN, LOW); // VDD off → PE4259 off +``` + +**Why this order?** +- `radio.sleep(false)` sends an SPI command to the SX1262 → ensures clean radio shutdown +- PE4259 VDD (GPIO 37) powers the RF switch, NOT the SX1262 directly +- SPI is powered by the nRF52840 3.3V rail, not by PE4259 +- For safety: First put SX1262 to sleep, then power off PE4259 + +### Boot Sequence (in `begin()`) +```cpp +// PE4259 VDD on → RF switch ready +pinMode(SX126X_POWER_EN, OUTPUT); +digitalWrite(SX126X_POWER_EN, HIGH); +delay(10); // PE4259 power-on time + +// Later in radio_init() → target.cpp: +radio.std_init(&SPI); // → setDio2AsRfSwitch(true) → DIO2 controls TX/RX +``` + +**Important details**: +- **`SX126X_POWER_EN`** (GPIO 37 / P1.05) controls the **PE4259 VDD**, NOT the SX1262 power +- **`DIO2`** is controlled internally by the SX1262 (`setDio2AsRfSwitch(true)`) — no GPIO needed +- **Sleep current SX1262**: ~0.16µA (Cold Sleep) — datasheet value +- **Without `radio_driver.powerOff()`**: SX1262 remains in RX mode → ~5mA power consumption! + +--- + +## 10. BQ25798 CE Pin Safety (Rev 1.1 — FET-inverted) + +### Problem +The BQ25798 starts with default configuration (1S Li-Ion, 4.2V charge voltage). If a LiFePO4 battery (3.5V max) is connected and the RAK has not yet booted, the BQ25798 would overcharge the battery → **fire hazard**. + +### Hardware Design (Rev 1.1 — FET-inverted) +- **Pin**: `BQ_CE_PIN` = GPIO 4 (P0.04 / WB_IO4) +- **DMN2004TK-7 N-FET**: Gate ← GPIO4 (ext. pull-down), Drain → CE, Source → GND +- **External pull-down on Gate**: Defaults gate LOW when GPIO is floating → FET OFF +- **External pull-up on CE**: 10kΩ to VSYS → CE HIGH when FET OFF → **charging OFF** (BQ25798 CE active-low) +- **GPIO HIGH** → FET ON → CE pulled to GND (LOW) → **charging ON** +- **GPIO LOW** → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** +- **GPIO High-Z** (unpowered/reset) → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** + +**Key point Rev 1.1**: Charging is only active when GPIO4 is driven HIGH (by firmware or GPIO output latch in System Sleep). When the RAK is unpowered or unflashed, the external pull-down ensures FET OFF → CE HIGH → **charging disabled** — a deliberate safety feature. + +### 3-Layer Protection (Rev 1.1) + +| Layer | Location | Mechanism | When | +|---|---|---|---| +| **1. Hardware (passive)** | Pull-down + Pull-up | RAK unpowered → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** | Always (safety default) | +| **2. Early Boot** | `InheroMr2Board::begin()` | GPIO4 not yet driven → FET OFF → CE HIGH → **charging OFF** until firmware configures it | Before I2C init | +| **3. Chemistry Configuration** | `configureChemistry()` | GPIO HIGH → FET ON → CE LOW → **charging ON** + I2C register for known chemistry | After BQ25798 configuration | + +### Dual-Layer Safety (Hardware + Software) + +```cpp +// In configureChemistry() — after BQ25798 register configuration: +bq.setChargeEnable(props->charge_enable); // Software layer (I2C register) +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + // Rev 1.1 FET-inverted: HIGH → FET ON → CE LOW → charging active (BQ25798: CE active-low) + // FET OFF → pull-up on CE → CE HIGH → charging disabled (safety default) + digitalWrite(BQ_CE_PIN, props->charge_enable ? HIGH : LOW); // HIGH=FET ON=CE LOW=charge on +#endif +``` + +- `charge_enable` is part of the `BatteryProperties` table +- `BAT_UNKNOWN` → `charge_enable = false` → GPIO LOW → FET OFF → CE HIGH → **charging disabled** + register disabled +- Known chemistry → `charge_enable = true` → GPIO HIGH → FET ON → CE LOW → **charging enabled** + register enabled + +### Behavior in System Sleep with GPIO latch (Rev 1.1) + +In Rev 1.1, **System Sleep with GPIO latch** is used (via `initiateShutdown()`): +- `digitalWrite(BQ_CE_PIN, HIGH)` is called before entering System Sleep +- P0.04 is excluded from `disconnectLeakyPullups()` → GPIO output latch preserved at HIGH +- GPIO4 latched HIGH → DMN2004TK-7 FET ON → CE LOW → **charging active** +- BQ25798 MPPT/CC/CV runs autonomously in hardware → solar charging possible +- Power consumption: **< 500µA** (nRF52840 System-Off + RTC + quiescent currents of all components) + +| State | CE Pin | Charging | Solar Recovery | +|---|---|---|---| +| RAK unpowered (no battery) | HIGH (pull-up, FET OFF) | **Disabled** (safety default) | N/A | +| Early Boot | HIGH (pull-up, GPIO not driven) | **Disabled** (not yet configured) | No | +| BAT_UNKNOWN | HIGH (GPIO LOW → FET OFF) | **Disabled** (CE + I2C register) | No | +| Chemistry configured | LOW (GPIO HIGH → FET ON) | **Active** | **Yes** | +| System Sleep (Low-Voltage) | LOW (GPIO latch HIGH → FET ON) | **Active** | **Yes** | + +--- + +## 11. Statistics Persistence + +### Current State + +The 168h ring buffer statistics (coulomb counter, MPPT data, SOC state) are stored **in RAM only** and are lost on every reboot — whether System Sleep or cold boot. No persistence mechanism exists (neither `.noinit` section nor LittleFS snapshot). + +**Persistent data** (survives reboots via LittleFS): +- Battery type (`batType`) +- Battery capacity (`batCap`) +- NTC calibration (`tcCal`) +- MPPT setting (`mpptEn`) +- Frost behavior (`frost`) +- Max charge current (`maxChrg`) +- LED setting (`leds_en`) + +The INA228 calibration factor is **not** persisted — it lives in RAM only and resets to 1.0 on every boot. + +**Non-persistent data** (lost on reboot): +- 168h energy ring buffer (hourly charge/discharge/solar mAh) +- MPPT statistics (168h MPPT activity buffer) +- SOC percentage (set to 0% after recovery, synchronized to 100% on "Charging Done") +- TTL calculation (requires min. 24h data after each restart) +- Daily energy balance (7-day window rebuilds after restart) + +--- + +## 12. CLI Commands + +### Getters +```bash +board.bat # Query battery type + # Output: liion1s | lifepo1s | lto2s | naion1s | none + +board.fmax # Query frost charge behavior + # Output: 0% | 20% | 40% | 100% (LTO/Na-Ion: N/A) + +board.imax # Query maximum charge current + # Output: mA (e.g. 500mA) + +board.mppt # Query MPPT status + # Output: MPPT=1 | MPPT=0 + +board.telem # Real-time telemetry with SOC + # Output: B:V/mA/C SOC:% S:V/ + # Example: B:3.85V/125.4mA/22C SOC:68.5% S:5.12V/385mA + # Example: B:3.85V/-8.2mA/N/A SOC:N/A S:0.00V/0mA + +board.stats # Energy statistics (balance + MPPT + TTL) + # Output: <24h>/<3d>/<7d>mAh C:<24h> D:<24h> 3C:<3d> 3D:<3d> 7C:<7d> 7D:<7d> M:% T: + # Example: +125/+45/+38mAh C:200 D:75 3C:150 3D:105 7C:140 7D:102 SOL M:85% T:N/A + # Example: -30/-45/-40mAh C:10 D:40 3C:5 3D:50 7C:8 7D:48 BAT M:45% T:12d0h + # SOL = Solar surplus, BAT = Energy deficit + # T: Time To Live (N/A if solar surplus or <24h data) + +board.cinfo # Charger info + last PG-stuck HIZ toggle + # Output: "PG / CC HIZ:never" or "!PG / !CHG HIZ:3m ago" + +board.selftest # I²C hardware probe (all on-board devices) + # Output: "INA:OK BQ:OK RTC:OK BME:OK" + # Per-device states: OK | NACK | WR_FAIL (RTC only) + +board.conf # All configuration values + # Output: B: F: M: I: Vco: V0: + # Example: B:liion1s F:0% M:1 I:500mA Vco:4.10 V0:3.30 + +board.tccal # NTC temperature calibration offset + # Output: TC offset: +0.00 C (0.00=default) + +board.leds # LED enable status (Heartbeat + BQ Stat) + # Output: "LEDs: ON (Heartbeat + BQ Stat)" + +board.batcap # Battery capacity + # Output: 10000 mAh (set) or 2000 mAh (default; LiFePO4 defaults to 1500 mAh) +``` + +### Setters +```bash +set board.bat # Set battery chemistry + # Options: liion1s | lifepo1s | lto2s | naion1s | none + +set board.fmax # Set frost charge current reduction + # Options: 0% | 20% | 40% | 100% + # Limits charge current in T-Cool range (approx. -2 °C to +3 °C, see JEITA table in README) + # No effect on LTO / Na-Ion (JEITA disabled) + +set board.imax # Set maximum charge current + # Range: 50-1500 mA + +set board.mppt <0|1> # Enable/disable MPPT + +set board.batcap # Set battery capacity + # Range: 100-100000 mAh + +set board.tccal # Calibrate NTC temperature (auto via BME280) +set board.tccal reset # Reset offset to 0.00 + +set board.leds # Enable/disable LEDs (on/1, off/0) + +set board.soc # Manually set SOC (0-100, INA228 must be ready) +``` + +--- + +## File Overview + +### Main Implementation +| File | Description | +|------|-------------| +| **InheroMr2Board.h/cpp** | Board class, init, shutdown, RTC, CLI commands | +| **BoardConfigContainer.h/cpp** | Battery management, BQ25798, INA228, MPPT, SOC, daily balance | +| **lib/Ina228Driver.h/cpp** | INA228 I2C communication, calibration, coulomb counter | +| **lib/BqDriver.h/cpp** | BQ25798 I2C communication, MPPT, charging | + +### Key Methods +| Method | File | Function | +|--------|------|----------| +| `begin()` | InheroMr2Board.cpp | Board initialization, wake-up check, early-boot low-voltage check | +| `initiateShutdown()` | InheroMr2Board.cpp | System Sleep shutdown (called by tickPeriodic after ALERT) | +| `configureRTCWake()` | InheroMr2Board.cpp | RTC countdown timer | +| `rtcInterruptHandler()` | InheroMr2Board.cpp | RTC INT ISR (sets flag) | +| `queryBoardTelemetry()` | InheroMr2Board.cpp | CayenneLPP telemetry collection | +| `getLowVoltageSleepThreshold()` | InheroMr2Board.cpp | Chemistry-specific sleep voltage (INA228 ALERT) | +| `getLowVoltageWakeThreshold()` | InheroMr2Board.cpp | Chemistry-specific wake voltage (0% SOC) | +| `armLowVoltageAlert()` | BoardConfigContainer.cpp | Arm INA228 BUVL alert + register ISR | +| `disarmLowVoltageAlert()` | BoardConfigContainer.cpp | Disarm INA228 alert + detach ISR | +| `lowVoltageAlertISR()` | BoardConfigContainer.cpp | ISR: sets lowVoltageAlertFired flag (checked in tickPeriodic) | +| `tickPeriodic()` | BoardConfigContainer.cpp | Main loop dispatch: MPPT (60s), SOC (60s), hourly (60min), low-V check | +| `runMpptCycle()` | BoardConfigContainer.cpp | Single MPPT cycle (solar checks, MPPT recovery) | +| `updateBatterySOC()` | BoardConfigContainer.cpp | Coulomb counter SOC calculation | +| `updateHourlyStats()` | BoardConfigContainer.cpp | Hourly sampling into the 168h ring buffer | +| `calculateRollingStats()` | BoardConfigContainer.cpp | 24h/3d/7d rolling sums + living_on_battery | +| `calculateTTL()` | BoardConfigContainer.cpp | Time To Live forecast | +| `Ina228Driver::begin()` | lib/Ina228Driver.cpp | 100mΩ calibration, ADC config | +| `Ina228Driver::readVBATDirect()` | lib/Ina228Driver.cpp | Static early-boot VBAT read | + +--- + +## Code Fragments (Key Sections) + +### INA228 Shutdown Mode +```cpp +// Ina228Driver.cpp — returns bool: false if the INA228 stays in continuous mode +bool Ina228Driver::shutdown() { + // Set operating mode to Shutdown (MODE = 0x0) + // This disables all conversions and Coulomb Counter. + // Retries up to 3× with readback — I2C writes can fail silently. + uint16_t adc_config = 0x0000; // MODE = 0x0 (Shutdown) + // ... write + readback retry loop, checks MODE bits [15:12] ... +} +``` + +### INA228 Wake-up +```cpp +// Ina228Driver.cpp +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 + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples (TX peak filtering) + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); +} +``` + +### RTC Interrupt Handler +```cpp +// InheroMr2Board.cpp — ISR only sets flag, no I2C! +void InheroMr2Board::rtcInterruptHandler() { + rtc_irq_pending = true; +} +// TF clear happens in main loop context (tick()) +``` + +### INA228 Driver Access +```cpp +// Direct access to INA228 driver +if (boardConfig.getIna228Driver() != nullptr) { + // INA228 specific code +} +``` + +--- + +## Scenarios + +### Scenario A: Normal Discharge (Low-Voltage System Sleep) - Li-Ion +``` +t=0: VBAT = 3.7V → Normal (60s checks, coulomb counter running) + Daily balance: Today +150mAh SOLAR + +t=+1h: VBAT = 3.5V → Normal (INA228 ALERT not triggered) + SOC: 45% + +t=+2h: VBAT = 3.08V → INA228 ALERT fires (< 3100mV lowv_sleep_mv) + - lowVoltageAlertISR() → sets lowVoltageAlertFired flag + - tickPeriodic() detects flag in next tick() + - board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE) + - CE latched (GPIO4 latch HIGH → FET ON → CE LOW → charging active) + - RTC: Wake in 1h (LOW_VOLTAGE_SLEEP_MINUTES = 60) + - sd_power_system_off() → System Sleep with GPIO latch (< 500µA) + +t=+3h: RTC wakes → system boots → early boot check + - Ina228Driver::readVBATDirect() → VBAT = 3.15V + - VBAT < lowv_wake_mv (3300mV) → immediately back to sleep + - configureRTCWake(60) + sd_power_system_off() + +t=+4h: RTC wakes → system boots → early boot check + - VBAT = 3.20V → still below 3300mV → back to sleep + +t=+5h: RTC wakes → system boots → early boot check + - VBAT = 3.45V (solar recovery!) + - VBAT > lowv_wake_mv (3300mV) → normal boot + - Low-voltage recovery marked, SOC at 0% + - Coulomb counter restarts + - Daily balance rebuilds +``` + +### Scenario B: Critical Discharge (Rev 1.1 — no hardware UVLO) +``` +In Rev 1.1 there is no hardware UVLO (TPS62840 EN via 3.3V_off switch). +The INA228 ALERT on P1.02 serves as software interrupt for System Sleep. + +t=0: VBAT = 3.08V → INA228 ALERT fires + - tickPeriodic() → initiateShutdown() + - System Sleep with GPIO latch (< 500µA), CE latched LOW (charging active), RTC wake 1h + +t=+1h: RTC wake → early boot → VBAT = 3.05V (still below 3300mV) + - Immediately back to sleep (CE remains latched LOW → solar charging possible) + +t=+2h: RTC wake → VBAT = 2.95V (dropped further, no solar) + - Immediately back to sleep + - Board continues cycling at < 500µA + hourly boot (~0.03mAh) + +t=+∞: At < 500µA the battery can survive for months + - As soon as solar available → VBAT rises → normal boot at >3300mV + - NO latching: system can ALWAYS recover on its own +``` + +### Scenario C: Energy Balance Tracking - LiFePO4 +``` +Day 0: VBAT = 3.2V, SOC = 85% + 24 hourly entries land in hours[]: Σ charged +800mAh (solar), Σ discharged -450mAh + last_24h_net = +350mAh → SOLAR + +Day 1: VBAT = 3.15V, SOC = 72% + Charged: +650mAh, Discharged: -520mAh + last_24h_net = +130mAh → SOLAR + +Day 2: VBAT = 3.05V, SOC = 58% + Charged: +200mAh (heavy clouds), Discharged: -480mAh + last_24h_net = -280mAh → BAT (living_on_battery = true) + + 3-day avg: (350+130-280)/3 = +66.7 mAh/day + 7-day avg: (350+130-280)/7 = +28.6 mAh/day + (168h window still part-filled — the sum is always divided by 7) + → 7-day avg positive → TTL stays 0 (shown as N/A) + +Day 3: VBAT = 2.95V, SOC = 42% + Charged: +150mAh (heavy clouds), Discharged: -500mAh + last_24h_net = -350mAh → BAT + + 3-day avg: (130-280-350)/3 = -166.7 mAh/day + 7-day avg: (350+130-280-350)/7 = -21.4 mAh/day → negative → TTL is calculated + living_on_battery = true + + TTL calculation (7-day avg basis, ≥25 °C → f(T)=1, nothing trapped): + remaining = 42% × 1500mAh = 630mAh + deficit = |-21.4| = 21.4 mAh/day + TTL = (630 / 21.4) × 24 ≈ 706 hours ≈ 29.4 days + + CLI output: "-350/-167/-21mAh C:150 D:500 3C:.. 3D:.. 7C:.. 7D:.. BAT M:45% T:29d10h" +``` + +--- + +## See Also + +- [README.md](README.md) — User documentation and CLI reference +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) — Commissioning and configuration +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All CLI commands at a glance + +### Datasheets +- **INA228**: https://www.ti.com/product/INA228 +- **RV-3028-C7**: https://www.microcrystal.com/en/products/real-time-clock-rtc-modules/rv-3028-c7/ +- **BQ25798**: https://www.ti.com/product/BQ25798 +- **TPS62840**: https://www.ti.com/product/TPS62840 +- **nRF52840**: https://www.nordicsemi.com/products/nrf52840 diff --git a/variants/inhero_mr2/docs/QUICK_START.md b/variants/inhero_mr2/docs/QUICK_START.md new file mode 100644 index 0000000000..5c3ee7ce2d --- /dev/null +++ b/variants/inhero_mr2/docs/QUICK_START.md @@ -0,0 +1,206 @@ +# Inhero MR2 Quick-Start + +This guide walks you through commissioning and the most important CLI commands. + +## 1) Prepare Temperature Sensor (TS/NTC) +- Either use the 3-pin battery connector with TS/NTC, or close the onboard NTC solder bridge on the back side. +- Firmware NTC type: NCP15XH103F03RC (10k @ 25C, Beta 3380). +- Purpose: The charger uses the TS pin for JEITA/frost logic. +- → [FAQ #2 — Battery packs without NTC](FAQ.md#2-can-i-use-battery-packs-without-a-built-in-ntc) + +## 2) Connect Antennas +- Never operate without an antenna — risk of damage to the RF frontend. + +## 3) Connect Battery +- A charge level >90% is recommended so the battery can be fully charged via USB and the SOC calculation starts reliably. + +> **⚠ WARNING — No Reverse Polarity Protection:** The board has no hardware reverse polarity protection. Connecting the battery with reversed polarity will cause immediate, irreversible damage. Always verify correct polarity before plugging in. + +## 4) Configure Repeater via USB +- Connect the repeater to a computer via USB cable. +- Go to https://meshcore.io/flasher -> Repeater Setup to configure (LoRa settings, name, admin password, etc.). +- This sets the basic parameters on the device. + +## 5) Open CLI +- https://meshcore.io/flasher -> Console +- or MeshCore App -> Manage -> Command-Line +- Board-specific commands are set here. + +## 6) Set Battery Chemistry +- Command: + - set board.bat liion1s + - or set board.bat lifepo1s + - or set board.bat lto2s + - or set board.bat naion1s +- Defines charge parameters and low-voltage thresholds. +- → [FAQ #1](FAQ.md#1-which-battery-chemistry-should-i-choose) | [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Which battery chemistry should I choose? + +## 7) Set Battery Capacity +- Command: set board.batcap +- Example: set board.batcap 10000 +- Important for accurate SOC calculation. +- → [FAQ #4 — What mAh value?](FAQ.md#4-what-mah-value-should-i-enter-for-set-boardbatcap) + +## 8) Set Maximum Charge Current +- Command: set board.imax +- Firmware range: 50 to 1500 mA (BQ25798 minimum: 50mA). +- Choose to match your solar setup so currents fit the PG check. +- Rule of thumb: panel power / panel voltage * 1.2 +- → [FAQ #5 — Why set imax?](FAQ.md#5-why-is-it-important-to-set-the-maximum-charge-current-with-set-boardimax) + +## 9) Set Frost Charge Current Reduction +- Command: set board.fmax <0%|20%|40%|100%> +- Limits the maximum charge current in the T-Cool range (approx. -2 °C to +3 °C, see JEITA table in README) to X% of board.imax. +- 0% = Charging blocked in T-Cool range. +- 20% = max. 20% of imax (e.g. 500mA → 100mA at approx. -2 °C to +3 °C). +- 40% = max. 40% of imax (e.g. 500mA → 200mA at approx. -2 °C to +3 °C). +- 100% = no reduction, full charge current even in cold conditions. +- Below approx. -2 °C (T-Cold): Charging always completely blocked by JEITA. +- Important: Only charging is restricted. With sufficient solar, the board continues to run on solar power — the battery is neither charged nor discharged. +- Note: For LTO and Na-Ion, JEITA is disabled (`set board.fmax` is rejected with an error, charging works even in frost). +- → [FAQ #6 — What does fmax control?](FAQ.md#6-what-does-set-boardfmax-control) + +## 10) Enable MPPT +- Command: set board.mppt <0|1> +- 1 = MPPT on, 0 = MPPT off. +- Typically enable for solar input. + +## 11) Enable/Disable LEDs +- Command: set board.leds or set board.leds <1|0> +- Controls heartbeat LED and BQ status LED (bootloader LED patterns are unaffected). +- → [FAQ #17 — What do the LEDs mean?](FAQ.md#17-what-do-the-leds-mean) + +## 12) Fully Charge Battery (SOC Sync) +- Fully charge the battery once via USB so the SOC synchronizes cleanly. +- → [FAQ #11 — SOC shows 0% or N/A?](FAQ.md#11-why-does-the-soc-show-0-or-na) + +> **Cold weather note:** SOC% is purely Coulomb-based and does not change with temperature. However, `get board.telem` shows both the stored and extractable capacity when it's cold: `SOC:95.0% (78%)`. The firmware uses a Trapped Charge model — at low SOC and cold temperatures, the extractable value drops steeply (the bottom of the discharge curve is "locked"). See [FAQ #13](FAQ.md#13-how-does-temperature-derating-work) for details. + +## Additional Notes (Practical) +- After setting the battery chemistry, a quick check with `get board.bat` confirms the setting was saved. +- For solar operation, `set board.mppt 1` is recommended; for USB-only operation, MPPT can stay off. + +## Example Values per Battery Chemistry (Starting Point) +These values are safe starting points and should be adjusted to match battery, panel, and usage profile. + +The `imax` values below are derived from the rule of thumb from section 8: +**`imax ≈ panel power ÷ panel voltage × 1.2`** (e.g. 2 W ÷ 5 V × 1.2 ≈ 480 mA → round to 500). +`fmax` is given as a percentage of `imax` and only applies in the T-Cool zone (approx. -2 °C to +3 °C, see JEITA table in README). + +### Li-Ion 1S (3.7V nominal) +```bash +set board.bat liion1s # chemistry: 1S Li-Ion (sets charge profile + low-V thresholds) +set board.imax 500 # max charge current — ≈ 2 W panel @ 5 V (2 W ÷ 5 V × 1.2 ≈ 480 mA) +set board.fmax 20% # T-Cool (approx. -2…+3 °C): cap at 20 % × 500 mA = 100 mA +``` + +### LiFePO4 1S (3.2V nominal) +```bash +set board.bat lifepo1s # chemistry: 1S LiFePO4 (sets charge profile + low-V thresholds) +set board.imax 300 # max charge current — ≈ 1 W panel @ 5 V (1 W ÷ 5 V × 1.2 ≈ 240 mA, rounded up for headroom) +set board.fmax 40% # T-Cool (approx. -2…+3 °C): cap at 40 % × 300 mA = 120 mA +``` + +### LTO 2S (2x 2.3V nominal) +```bash +set board.bat lto2s # chemistry: 2S LTO (sets charge profile + low-V thresholds) +set board.imax 700 # max charge current — ≈ 3 W panel @ 5 V (3 W ÷ 5 V × 1.2 = 720 mA → 700) + # fmax is omitted: rejected for LTO (JEITA disabled — LTO charges even at frost) +``` + +### Na-Ion 1S (3.1V nominal) +```bash +set board.bat naion1s # chemistry: 1S Na-Ion (sets charge profile + low-V thresholds) +set board.imax 500 # max charge current — ≈ 2 W panel @ 5 V (2 W ÷ 5 V × 1.2 ≈ 480 mA) + # fmax is omitted: rejected for Na-Ion (JEITA disabled) +``` + +Note: `set board.fmax` is rejected with an error for LTO and Na-Ion (JEITA disabled); `get board.fmax` shows N/A. + +## Solar Panel Notes +- Maximum open-circuit voltage (Voc) for the input: 25V. +- Typical panels are 5V or 6V (MPP below that). +- The board has buck/boost and can charge higher battery voltages from lower panel voltages. +- 24V panels or series connections may exceed the 25V Voc limit and are not suitable. +- Wattage class: at least 1W, typically 2W. +- For 1W panels, a battery capacity of >7Ah is recommended. +- This applies only with south-facing, vertical mounting, and an unshaded location. +- In worse solar conditions, either use 2W or increase battery capacity for "winter survival". + +→ [FAQ #8 — Which solar panels?](FAQ.md#8-which-solar-panels-can-i-connect) + +## USB Charging +- The board can also be charged via USB-C (5V). +- USB-C VBUS is routed to the BQ25798 VBUS input via a **Schottky diode** — the same single input as the solar panel. The BQ25798 has only one VBUS input and does not distinguish between USB and solar. +- The Schottky diode prevents backflow from the solar panel to the USB bus. However, current **can** flow from USB-VBUS out through the solar connector. +- CC1/CC2 are pulled to GND via 4.7kΩ (USB sink, 5V default). +- **⚠ Warning:** Since VBUS-USB and VBUS-BQ share the same bus (via the Schottky diode), a **short circuit on the solar connector will also short VBUS-USB**. Never short-circuit the solar input while USB is connected. + +## Voltage Thresholds per Battery Chemistry +Thresholds are optimized for maximum lifespan and stable operation. + +| Battery Chemistry | lowv_sleep_mv (System Sleep) | lowv_wake_mv (0% SOC) | Hysteresis | +|---|---|---|---| +| Li-Ion 1S | 3100 | 3300 | 200mV | +| LiFePO4 1S | 2700 | 2900 | 200mV | +| LTO 2S | 3900 | 4100 | 200mV | +| Na-Ion 1S | 2500 | 2700 | 200mV | + +## Low-Voltage Behavior +- **Low-Voltage System Sleep:** When VBAT drops below `lowv_sleep_mv`, the INA228 ALERT interrupt fires (P1.02). The firmware latches CE HIGH (`digitalWrite(BQ_CE_PIN, HIGH)` → FET ON → CE LOW → charging active), configures the RTC wake timer, and enters System Sleep with GPIO latch (< 500µA). P0.04 is excluded from `disconnectLeakyPullups()` so the GPIO latch stays HIGH. Periodic RTC wakes (hourly) check voltage — only when recovery above `lowv_wake_mv` does it boot normally. +- **Solar Recovery:** In System Sleep, GPIO4 latch is preserved HIGH → DMN2004TK-7 FET ON → CE LOW → charging active. Solar charging continues autonomously until the battery charges above `lowv_wake_mv`. Without GPIO latch (RAK unpowered): ext. pull-down on gate → FET OFF → CE HIGH → charging OFF (safety default). + +## CLI Examples (Compact) +```bash +# Battery chemistry and capacity +set board.bat liion1s +set board.batcap 10000 + +# Charge parameters +set board.imax 500 +set board.fmax 20% +set board.mppt 1 + +# LEDs +set board.leds off + +# Status checks +get board.bat +get board.imax +get board.fmax +get board.mppt +get board.leds +get board.batcap +get board.telem +get board.stats +get board.cinfo +get board.selftest +get board.conf +``` + +## Getter Quick Reference (all relevant board getters) +- `get board.bat` - Current battery type (liion1s, lifepo1s, lto2s, naion1s, none). +- `get board.fmax` - Current frost charge behavior (0%/20%/40%/100%; N/A for LTO/Na-Ion). +- `get board.imax` - Maximum charge current in mA. +- `get board.mppt` - MPPT status (0/1). +- `get board.leds` - LED status (Heartbeat + BQ Stat). +- `get board.batcap` - Battery capacity in mAh (set/default). +- `get board.telem` - Real-time telemetry (Battery/Solar incl. SOC, V/I/T). See [TELEMETRY.md](TELEMETRY.md) for what the app displays. +- `get board.stats` - Energy balance (24h/3d/7d), charge/discharge breakdown and MPPT ratio. +- `get board.cinfo` - Charger status (Charger State + Flags). +- `get board.selftest` - I²C hardware probe (`INA:OK BQ:OK RTC:OK BME:OK`). RTC includes a write/readback verify (state `WR_FAIL` on mismatch). +- `get board.conf` - Summary of all configs (B, F, M, I, Vco, V0). +- `get board.tccal` - NTC temperature calibration offset in °C (0.00 = default). + - → [FAQ #12 — When should I run tccal?](FAQ.md#12-when-should-i-run-set-boardtccal) + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/README.md b/variants/inhero_mr2/docs/README.md new file mode 100644 index 0000000000..00dd8707b0 --- /dev/null +++ b/variants/inhero_mr2/docs/README.md @@ -0,0 +1,382 @@ +# Inhero MR-2 + +Inhero MR2 + +## Table of Contents + +- [Overview](#overview) +- [Current Feature Matrix](#current-feature-matrix) +- [Power Management Features](#power-management-features) +- [Firmware Build](#firmware-build) +- [CLI Commands](#cli-commands) +- [Diagnostics & Troubleshooting](#diagnostics--troubleshooting) +- [Regulatory Notes & CE Compliance](#regulatory-notes--ce-compliance-red-201453eu) +- [See Also](#see-also) + +## Overview + +The Inhero MR-2 is an application-specific hardware platform designed for autonomous, long-term operation of mesh infrastructure. Unlike conventional general-purpose solutions, it is optimized for maximum reliability at hard-to-reach locations. With an active idle consumption of only 6.0 mA at 4.2 V and 7.7 mA at 3.3 V (USB off, no radio TX), the board is exceptionally efficient for a full-featured repeater — enabling long runtimes even with compact batteries and small solar panels. A universal solar input with active MPPT maximizes energy harvesting, enabling compact, low-profile installations while avoiding costly over-dimensioning of peripherals. With native support for Li-Ion, LiFePO4, LTO and Na-Ion batteries, combined with autonomous recovery logic via RTC wakeup, a consistent "install & forget" approach is achieved even under extreme environmental conditions. The design minimizes long-term operating costs at sites where manual maintenance visits would be disproportionately expensive due to difficult accessibility. + +**Hardware Version:** Rev 1.1 +**Key Features:** +- **Core:** Based on RAK4630 (nRF52840 + SX1262). +- **Power Path:** BQ25798 Buck/Boost Charger. Enables energy harvesting even when solar voltage is below battery voltage (critical for low-light conditions). +- **High-Efficiency Rail:** 3.3V rail via TPS62840 for maximum efficiency. +- **Robust Monitoring:** INA228 Coulomb Counter for precise SOC tracking (essential for LiFePO4 chemistry) and long-term energy statistics. +- **Universal Solar Input:** 3.6V – 24V with autonomous MPPT tracking and integrated protection against "stuck states" (hardware watchdog logic). +- **Environmental Sensing & Timekeeping:** Integrated BME280 and RV-3028 RTC for autonomous wake-up management and precise time base. See [FAQ #23](FAQ.md#23-why-does-the-repeater-board-need-a-correct-time) for why a correct clock matters. +- **Form Factor:** Only 45 × 40 mm – optimized for low-profile enclosures and minimal mechanical stress. + +> **⚠ WARNING — No Reverse Polarity Protection:** The board has no hardware reverse polarity protection on the battery or solar input. Connecting with reversed polarity will cause immediate, irreversible damage. Always verify correct polarity before connecting any power source. + +## Current Feature Matrix + +| Feature | Status | Notes | +|---------|--------|-------| +| INA228 ALERT → Low-Voltage System Sleep | Active | ISR on P1.02 → volatile flag → tickPeriodic() → System Sleep with GPIO latch + RTC Wake | +| RTC Wakeup (Low-Voltage Recovery) | Active | 60 min (periodic) | +| BQ CE Pin Safety (FET-inverted) | Active | GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low), Dual-Layer: GPIO + I2C | +| System Sleep with latched CE | Active | < 500µA, GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging possible | +| SOC 0% after Low-Voltage Recovery | Active | SOC initialized to 0% on recovery, auto-sync on "Charging Done" | +| SOC via INA228 + manual battery capacity | Active | `set board.batcap` available | +| SOC→Li-Ion mV Mapping (workaround) | Active | Will be removed when MeshCore transmits SOC% natively | +| MPPT Recovery + Stuck-PGOOD Handling | Active | Cooldown logic active | +| PFM Forward Mode | Active (chip default) | Enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it. Improves efficiency at low solar currents | + +## Power Management Features + +### Low-Voltage Handling (Flag/Tick Architecture) + +1. **INA228 ALERT** fires at `lowv_sleep_mv` (hardware interrupt on P1.02) +2. **ISR** sets `lowVoltageAlertFired = true` (volatile flag only, no FreeRTOS call) +3. **`tickPeriodic()`** (main loop, next `tick()`) checks flag → shutdown: + - CE pin → HIGH (FET ON → CE LOW → charging active) + - P0.04 excluded from `disconnectLeakyPullups()` → GPIO latch preserved in sleep + - RTC wake configured (`LOW_VOLTAGE_SLEEP_MINUTES` = 60 min) + - `sd_power_system_off()` → **System Sleep with GPIO latch** (< 500µA) +4. **RTC Wake** (hourly) → system boots, early-boot checks VBAT: + - Below `lowv_wake_mv` → immediately back to System Sleep (CE remains latched LOW) + - Above `lowv_wake_mv` → normal boot, SOC starts at 0% + +> **Note**: All I2C operations (MPPT, SOC, Hourly Stats) run in main loop context +> via `tickPeriodic()` — no FreeRTOS tasks for I2C, no mutex needed. + +### BQ CE Pin (Rev 1.1 — FET-inverted) +- **DMN2004TK-7 N-FET**: Gate ← GPIO4 (ext. pull-down), Drain → CE, Source → GND +- **GPIO HIGH** → FET ON → CE LOW → **charging ON** (BQ25798 CE active-low) +- **GPIO LOW / High-Z** → ext. pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** +- **System Sleep**: GPIO4 latch preserved HIGH (excluded from `disconnectLeakyPullups()`) → FET ON → CE LOW → **solar charging active** +- **Safety default**: RAK unpowered/unflashed → pull-down on gate → FET OFF → CE HIGH → **charging disabled** +- **Dual-Layer**: CE pin (hardware FET) + `setChargeEnable()` (I2C register) + +### Voltage Thresholds (all chemistries) + +| Chemistry | lowv_sleep_mv | lowv_wake_mv | Hysteresis | +|-----------|--------------|-------------|------------| +| Li-Ion 1S | 3100 | 3300 | 200mV | +| LiFePO4 1S | 2700 | 2900 | 200mV | +| LTO 2S | 3900 | 4100 | 200mV | +| Na-Ion 1S | 2500 | 2700 | 200mV | + +- **lowv_sleep_mv**: INA228 ALERT threshold → triggers System Sleep with GPIO latch +- **lowv_wake_mv**: RTC wake threshold → boot only when VBAT is above, also 0% SOC marker + +### System Sleep Power Consumption +- **< 500µA** total consumption (nRF52840 System-Off + RTC + quiescent currents of all components) +- GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging active + +### Active Idle Power Consumption +- **6.0 mA** @ VBAT 4.2 V (USB off, no radio TX) +- **7.7 mA** @ VBAT 3.3 V (USB off, no radio TX) +- **+0.8–1.0 mA** with USB peripheral enabled +- USB is auto-managed: enabled on VBUS detect, disabled on removal + +### Power Saving Measures +- **WFE Idle** (`board.sleep(0)`): CPU enters Wait-For-Event between loop iterations. Wakes on any interrupt (radio, SysTick, USB, I2C) — typically within 1 ms. Reduces nRF52840 CPU current from ~3 mA (busy-loop) to ~0.5–0.8 mA. +- **USB Auto-Disable**: nRF52840 USB peripheral is automatically disabled when no VBUS is detected, saving ~0.8–1.0 mA. Re-enabled automatically when USB cable is connected. + +### Coulomb Counter & SOC Tracking +- **Real-time SOC tracking** via INA228 (±0.1% accuracy) +- **100mΩ shunt resistor** (1.6A max current) +- **200mV uniform hysteresis** for all chemistries (lowv_sleep_mv → lowv_wake_mv) +- **Manual capacity:** `set board.batcap` for fixed capacity + +### SOC→Li-Ion mV Mapping (Workaround) +- **Problem**: MeshCore only transmits `getBattMilliVolts()`, not SOC%. The Companion App uses a Li-Ion curve for SOC calculation — incorrect display for LiFePO4/LTO. +- **Solution**: When valid coulomb-counting SOC is available, an equivalent Li-Ion 1S OCV (3000–4200 mV) is returned, so the app displays the correct SOC%. See [TELEMETRY.md](TELEMETRY.md) for details on how this affects the app display. +- This workaround will be removed once MeshCore supports native SOC% transmission. +- → [FAQ #11 — SOC shows 0% or N/A?](FAQ.md#11-why-does-the-soc-show-0-or-na) + +### Time-To-Live (TTL) Prediction +- **Time base:** 7-day moving average (`avg_7day_daily_net_mah`) of daily net energy consumption +- **Data source:** 168-hour ring buffer (7 days) with hourly INA228 coulomb counter samples (charged/discharged/solar mAh) +- **Formula:** `TTL_hours = max(0, SOC% × capacity_mah / 100 − capacity_mah × (1 − f(T))) / |avg_7day_daily_net_mah| × 24` +- **f(T):** Cold-temperature derating factor (trapped-charge model) — at low temperatures part of the stored charge is unusable and is subtracted first; f(T) = 1 at warm temperatures +- **Prerequisites:** `living_on_battery == true` (24h deficit), min. 24h data, capacity known +- **TTL = 0:** Solar surplus, no 24h data available, or capacity unknown +- **CLI:** TTL is shown in `get board.stats` (BAT mode only, e.g. `T:12d0h`) +- **Telemetry:** Transmitted as days via CayenneLPP Distance field (max. 990 days for "infinite"). See [TELEMETRY.md](TELEMETRY.md) for channel details. + +### Solar Power Management + +- **Solar current display:** The BQ25798 IBUS ADC is inaccurate at low currents (~±30mA error). Therefore solar current is displayed in steps: + - `0mA` — ADC reports exactly 0 (no solar current) + - `<50mA` — 1–49mA (ADC unreliable in this range) + - `~72mA` — 50–100mA with rounding symbol `~` (limited accuracy) + - `385mA` — >100mA without rounding symbol (sufficiently accurate) + - Always integer without decimal places (no pseudo-precision) +- **PFM Forward Mode:** PFM forward mode is enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it. Improves efficiency at low currents. +- **MPPT VOC_PCT 81.25%:** The BQ25798 MPPT is configured to VOC_PCT=81.25% (instead of the chip default 87.5%). This value matches the typical Vmp/Voc ratio of crystalline silicon solar cells (~80-83%). +- **MPPT Recovery:** Re-enables MPPT on PowerGood=1 (readback check: only on actual change) +- **BQ INT pin not used:** No interrupt — pure polling every 60s in `runMpptCycle()` +- **Error monitoring:** Diagnostic commands show FAULT_STATUS registers (0x20, 0x21) for detailed analysis incl. VBAT_OVP, VBUS_OVP and temperature conditions +- **VREG display:** Shows the actually configured battery regulation voltage in diagnostics for threshold verification + +### JEITA Temperature Zone Configuration + +The BQ25798 uses the TS pin (NTC thermistor) for JEITA-compliant temperature-dependent charge control. The Inhero MR2 uses a voltage divider (RT1=5.6 kΩ pullup to REGN, RT2=27 kΩ parallel to GND) that shifts TS thresholds lower than the TI reference design (5.24 kΩ / 30.31 kΩ). The shift is **temperature-dependent**: ~5–6 °C in the cold range, ~2–3 °C in the warm/hot range (because at low temperatures the NTC resistance is large relative to RT2, amplifying the divider mismatch). + +| JEITA Zone | BQ25798 Threshold | TI Reference | Inhero MR2 (actual) | Shift | Firmware Config | +|------------|-------------------|--------------|----------------------|-------|-----------------| +| T-Cold (charge suspend) | VT1 = 72.0% REGN | +3.7 °C | −2.0 °C | −5.7 °C | — (not configurable) | +| T-Cool (reduced current) | VT2 = 69.8% REGN | +7.9 °C | +2.8 °C | −5.1 °C | `set board.fmax` | +| T-Warm start | VT3 = 37.7% REGN | +54.5 °C | +52.2 °C | −2.3 °C | `TS_WARM = 55°C` register setting | +| T-Hot (charge suspend) | VT5 = 34.2% REGN | +59.9 °C | +57.7 °C | −2.2 °C | — (not configurable) | + +> Calculation based on: NTC 103AT (B25/50=3435) for TI reference, NCP15XH103F03RC (B25/85=3380) for Inhero. Typical %REGN values from BQ25798 datasheet. + +**Key firmware settings in `configureBaseBQ()`:** + +- **`TS_WARM = 55°C`** (BQ register value): Moves the WARM zone threshold from the default 45 °C setting (44.8% REGN, ~41.8 °C with Inhero divider) up to 37.7% REGN (~52.2 °C with Inhero divider). This prevents premature WARM zone entry at moderate temperatures. +- **`JEITA_VSET = UNCHANGED`**: No battery regulation voltage reduction in the WARM zone. The POR default (VREG−400 mV) would reduce VREG to 3.1 V for LiFePO4, causing VBAT_OVP at normal battery voltages (3.3–3.5 V). +- **`JEITA_ISETH = ICHG unchanged`** (POR default, retained): No charge current reduction in the WARM zone. Combined with JEITA_VSET=UNCHANGED, the WARM zone is effectively neutralized — charging continues at full voltage and full current. +- **`AUTO_IBATDIS = disabled`**: Disables the BQ25798's automatic 30 mA battery discharge during VBAT_OVP. The POR default actively drains the battery at ~30 mA (IBAT_LOAD) when OVP is triggered, which is counterproductive for solar-powered systems. + +> **Background:** With default BQ25798 settings, the combination of the Inhero divider offset and LiFePO4 chemistry caused a failure chain at ~42 °C: WARM zone entry → VREG reduced to 3.1 V → VBAT_OVP (battery at 3.47 V > 104% × 3.1 V) → active 30 mA discharge → net −45 mA drain despite solar input. The settings above prevent this entirely. The WARM zone (52–58 °C with Inhero divider) now has no effect on charging behavior. + +## Firmware Build + +```bash +# Repeater (default) +platformio run -e Inhero_MR2_repeater + +# Repeater with RS232 bridge (Serial2 on P0.19/P0.20) +platformio run -e Inhero_MR2_repeater_bridge_rs232 + +# Sensor +platformio run -e Inhero_MR2_sensor +``` + +## CLI Commands + +### Get Commands +```bash +get board.bat # Query current battery type + # Output: liion1s | lifepo1s | lto2s | naion1s | none + +get board.fmax # Query frost charge behavior + # Output: 0% | 20% | 40% | 100% + # Value = max charge current in T-Cool range + # (approx. -2 °C to +3 °C, see JEITA table in README), + # relative to board.imax + # 40% at imax=500mA → max. 200mA charge current in T-Cool range + # 0% = charging blocked in T-Cool range + # 100% = no reduction (full current even in cold) + # Below approx. -2 °C (T-Cold): charging always completely blocked (JEITA) + # Note: Only charging is restricted. With sufficient + # solar, the board continues to run on solar power — + # the battery is neither charged nor discharged. + # LTO / Na-Ion batteries: N/A (JEITA disabled, charges even in frost) + +get board.imax # Query maximum charge current + # Output: mA (e.g. 200mA) + +get board.mppt # Query MPPT status + # Output: MPPT=1 (enabled) | MPPT=0 (disabled) + +get board.telem # Query real-time telemetry with SOC + # Output: B:V/mA/C SOC:% S:V/ + # Examples: + # B:3.85V/125.4mA/22C SOC:68.5% S:5.12V/385mA (>100mA: accurate) + # B:3.85V/-8.2mA/18C SOC:72.0% S:4.90V/~72mA (50-100mA: ~estimate) + # B:3.30V/-45.0mA/5C SOC:40.1% S:0.00V/<50mA (<50mA: ADC inaccurate) + # Output variants: + # - SOC:N/A — no valid coulomb-counting SOC available + # - SOC:68.5% (52%) — second value = cold-derated SOC, + # shown when the temperature derating factor is < 1 + # - C becomes N/A when the NTC temperature is unavailable + # Components: + # - B: Battery (Voltage/Current/Temperature/SOC) + # - S: Solar (Voltage/Current — accuracy depends on BQ25798 IBUS ADC) + +get board.stats # Query energy statistics (balance + MPPT) + # Output: <24h>/<3d>/<7d>mAh C:<24h> D:<24h> 3C:<3d> 3D:<3d> 7C:<7d> 7D:<7d> M:% T: + # Example: +125/+45/+38mAh C:200 D:75 3C:150 3D:105 7C:140 7D:102 SOL M:85% T:N/A + # Example: -30/-45/-40mAh C:10 D:40 3C:5 3D:50 7C:8 7D:48 BAT M:45% T:72h + # Components: + # - +125: Last 24h net balance (charge - discharge) in mAh + # - +45: 3-day average net balance in mAh + # - +38: 7-day average net balance in mAh + # - C/D: Charged/Discharged mAh (24h) + # - 3C/3D: 3-day average charged/discharged mAh + # - 7C/7D: 7-day average charged/discharged mAh + # - SOL: Running on solar (self-sufficient) + # - BAT: Living on battery (deficit mode) + # - M:85%: MPPT enabled percentage (7-day average) + # - T:72h: Time To Live (only shown if BAT mode, 7d-avg basis) + # Format: T:12d5h (≥24h) or T:72h (<24h) or T:N/A + +get board.cinfo # Charger info + last PG-stuck HIZ toggle + # Output: + flags + # States: !CHG, PRE, CC, CV, TRICKLE, TOP, DONE + +get board.selftest # I²C hardware probe (all on-board devices) + # Output: INA: BQ: RTC: BME: + # States: OK | NACK | WR_FAIL (RTC only, write-verify mismatch) + +get board.conf # Query all configuration values + # Output: B: F: M: I: Vco: V0:<0%SOC> + +get board.batcap # Query battery capacity + # Output: mAh (set) or mAh (default) + # Shows whether capacity was manually set or chemistry default + +get board.tccal # Query NTC temperature calibration offset + # Output: TC offset: <+/-offset> C (0.00=default) + +get board.leds # Query LED enable status + # Output: "LEDs: ON (Heartbeat + BQ Stat)" or "LEDs: OFF (Heartbeat + BQ Stat)" + # Shows whether heartbeat LED and BQ25798 stat LED are enabled +``` + +### Set Commands +```bash +set board.bat # Set battery type + # Options: lto2s | lifepo1s | liion1s | naion1s | none + # none = no battery / unknown (charging disabled) + +set board.fmax # Set frost charge behavior + # Options: 0% | 20% | 40% | 100% + # Limits charge current in T-Cool range + # (approx. -2 °C to +3 °C, see JEITA table in README) + # to X% of board.imax + # 0% = charging blocked in T-Cool range + # 20% = max. 20% of imax in T-Cool range + # 40% = max. 40% of imax in T-Cool range + # 100% = no reduction + # Below approx. -2 °C (T-Cold): charging always blocked (JEITA) + # Note: Only charging is restricted. With sufficient + # solar, the board continues to run on solar power — + # the battery is neither charged nor discharged. + # N/A for LTO / Na-Ion batteries (JEITA disabled) + +set board.imax # Set maximum charge current in mA + # Range: 50-1500mA (BQ25798 minimum: 50mA) + +set board.mppt <1|0> # Enable/disable MPPT + # 1 = enabled, 0 = disabled + +set board.batcap # Set battery capacity in mAh + # Range: 100-100000 mAh + # Used for accurate SOC calculation + +set board.tccal # Calibrate NTC temperature + # Two modes: + # 1) set board.tccal → auto-calibration via BME280 + # Output: TC auto-cal: BME= offset=<+/-offset> C + # 2) set board.tccal reset → reset offset to 0.00 + # Output: TC calibration reset to 0.00 (default) + +set board.leds # Enable/disable heartbeat + BQ stat LED + # on/1 = enable, off/0 = disable + # Boot LEDs follow this setting; only the + # low-voltage recovery flash (3 blue blinks) + # is always active + +set board.soc # Manually set SOC + # Range: 0-100 + # Note: INA228 must be initialized +``` + +## Diagnostics & Troubleshooting + +### I²C Hardware Self-Test + +```bash +get board.selftest +``` + +Probes all I²C devices on the board and reports their status in one line: + +``` +INA:OK BQ:OK RTC:OK BME:OK +``` + +| Device | Address | Test | +|---|---|---| +| `INA` | `0x40` | INA228 power monitor — address ACK | +| `BQ` | `0x6B` | BQ25798 charger — address ACK | +| `RTC` | `0x52` | RV-3028 RTC — address ACK **plus** user-RAM (`0x1F`) write/readback verification with two patterns (`0xA5`, `0x5A`); original byte is restored | +| `BME` | `0x76` | BME280 environment sensor — address ACK | + +Possible per-device states: + +- **`OK`** — device responds (and, for the RTC, persists writes correctly) +- **`NACK`** — device does not acknowledge on the I²C bus +- **`WR_FAIL`** — *RTC only* — chip ACKs but write/readback mismatched. The same write-verify runs in `BoardConfigContainer::begin()` and triggers the slow red error LED on failure, so the board is flagged as faulty before deployment. + +### BQ25798 Register Verification +The diagnostic functions enable precise verification of BQ25798 registers against the datasheet: + +**Key Registers:** +- **0x0F (CHARGER_CONTROL_0)**: EN_CHG (Bit 5) +- **0x15 (MPPT_CONTROL)**: EN_MPPT (Bit 0), VOC_PCT (Bits 7-5), VOC_DLY (Bits 4-3), VOC_RATE (Bits 2-1) +- **0x1B (CHARGER_STATUS_0)**: PG_STAT (Bit 3), VINDPM (Bit 6), IINDPM (Bit 7) +- **0x1C (CHARGER_STATUS_1)**: CHG_STAT (Bits 7-5), VBUS_STAT (Bits 4-1) +- **0x1F (CHARGER_STATUS_4)**: Temperature status (Bits 3-0) + +**Known Issues:** +1. **MPPT disabled**: BQ25798 automatically sets MPPT=0 when PG=0 + - Solution: `checkAndFixSolarLogic()` re-enables MPPT on PG=1 +2. **PG stuck at sunrise**: VBUS rises slowly, BQ fails to qualify the source + - Solution: `checkAndFixSolarLogic()` toggles HIZ when VBUS ≥ 4.5V + PG=0 (5min cooldown) + +→ [FAQ #9 — Red LED blinks / battery not charging](FAQ.md#9-the-red-led-bq-status-led-blinks-slowly-and-the-battery-is-not-charging) + +## Regulatory Notes & CE Compliance (RED 2014/53/EU) + +The Inhero MR-2 is shipped as a hardware platform (development module) with a pre-installed bootloader. The hardware is **CE-marked and conforms to the European Radio Equipment Directive (RED 2014/53/EU)**; the corresponding tests were carried out by an accredited test laboratory and an EU Declaration of Conformity is on file. Radiated power certification was performed using the designated reference antennas (RAK FPCB antenna 863–870 MHz, MHF1 connector, antenna gain: 0.7 dBi). + +**Requirements for legally compliant operation of radio firmware:** +Since the final transmission characteristics (TX power, frequency, duty cycle) are largely determined by the software installed by the user (e.g. MeshCore) and the chosen antenna, the following European limits (per EN 300 220 and EN 300 328, ERC/REC 70-03 Annex 1) must be strictly observed: + +1. **Standard LoRa (868 MHz band):** + * Frequency range: 865.0 – 868.6 MHz + * Max. radiated TX power (ERP): 25 mW (14 dBm) + * Max. duty cycle: 1% (or LBT+AFA per EN 300 220) + * *Note: Different duty cycle requirements per ERC/REC 70-03 apply in the 863.0 – 865.0 MHz sub-band.* + +2. **High-Power LoRa (special band 869.5 MHz):** + * Frequency range: 869.40 – 869.65 MHz (MeshCore default channel) + * Max. radiated TX power (ERP): **500 mW (27 dBm)** + * Max. duty cycle: 10% + * *Hardware note:* The onboard LoRa transceiver (SX1262) provides a maximum conducted TX power of 22 dBm. To fully utilize the legal limit of 500 mW ERP (equivalent to 29.15 dBm EIRP), an antenna with a gain of approx. +7 dBi is required (minus any cable losses). With the included FPCB antenna (0.7 dBi), max. approx. 114 mW ERP is achieved. + +3. **Bluetooth Low Energy (2.4 GHz):** + * Max. radiated TX power (EIRP): 100 mW (20 dBm) + +**Antennas & operator responsibility (EIRP/ERP limit):** +The user is obligated to match the configured TX power in the chip with the antenna gain. If an antenna is used whose gain, in combination with the configured TX power, exceeds the legal EIRP/ERP limits stated above, the TX power must be reduced in software. + +**Disclaimer:** +The Inhero MR-2 is a module intended for professional developers and qualified users. If the legal parameters are operated outside EU norms due to the choice of firmware, antenna, or manual configuration, the CE compliance of the device is void. In this case, all legal responsibility for operation transfers to the integrator or end user. + +## See Also + +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/TELEMETRY.md b/variants/inhero_mr2/docs/TELEMETRY.md new file mode 100644 index 0000000000..988cae697b --- /dev/null +++ b/variants/inhero_mr2/docs/TELEMETRY.md @@ -0,0 +1,139 @@ +# Telemetry Channels + +The Inhero MR-2 transmits telemetry data in [CayenneLPP](https://docs.mydevices.com/docs/lorawan/cayenne-lpp) format across four channels. The companion app displays these as **Channel 1–4**. + +--- + +## Channel 1 — Device Status + +Base data from the node. + +| Field | Unit | Source | Description | +|-------|------|--------|-------------| +| Battery Level | % / V | INA228 | See note below on SOC workaround | +| Temperature | °C / °F | nRF52840 | MCU die temperature | + +### Battery Level & SOC Workaround + +MeshCore currently transmits only battery **voltage** on Channel 1 — there is no native SOC% field. The companion app converts this voltage back to a percentage using a hardcoded **Li-Ion discharge curve**. This works well for Li-Ion cells but produces wrong readings for LiFePO₄, LTO, or Na-Ion chemistries (which have a much flatter voltage curve). + +The MR-2 works around this limitation: + +| SOC State | What `getBattMilliVolts()` returns | App displays | +|-----------|------------------------------------|--------------| +| **SOC not yet valid** | Real battery voltage from INA228 | Percentage based on Li-Ion curve (may be inaccurate for non-Li-Ion) | +| **SOC valid** (coulomb counter calibrated) | Fake Li-Ion OCV reverse-mapped from true SOC% (`socToLiIonMilliVolts()`) | Correct percentage — the app's Li-Ion curve decodes back to the original SOC% | + +> **OCV** = Open Circuit Voltage — the battery's resting voltage without load. The OCV curve (voltage vs. SOC%) is characteristic for each battery chemistry and is used here as a lookup table to reverse-map SOC% back to a voltage the app can interpret. + +The SOC becomes valid as soon as a **reference point** exists — either manually via `set board.soc ` or automatically on a "Charging Done" event (which sets SOC to 100%). + +Without `set board.batcap ` a chemistry-typical default capacity (1500–2000 mAh) is assumed. Setting the real capacity is therefore required for the displayed percentage and the TTL to be accurate — not for the SOC to become valid. + +The reverse mapping uses a piecewise-linear Li-Ion OCV table (3000 mV at 0% → 4200 mV at 100%). This ensures the app displays the correct coulomb-counted SOC regardless of the actual battery chemistry. + +--- + +## Channel 2 — Environment (BME280) + +Data from the BME280 environment sensor (always present on the MR-2). + +| Field | Unit | Source | Description | +|-------|------|--------|-------------| +| Temperature | °C / °F | BME280 | Ambient temperature | +| Relative Humidity | % | BME280 | Relative humidity | +| Barometric Pressure | hPa | BME280 | Barometric pressure | +| Altitude | m / ft | BME280 | Altitude derived from barometric pressure (reference: sea level) | + +> **Note:** The altitude calculation is based on standard sea level pressure (1013.25 hPa) and may deviate depending on weather conditions. + +--- + +## Channel 3 — Battery (INA228 / BQ25798) + +High-precision battery data from the INA228 coulomb counter and BQ25798 charge controller. + +| Field | LPP Type | Unit | Source | Description | +|-------|----------|------|--------|-------------| +| Voltage | Voltage | V | INA228 | Battery voltage (20-bit ADC, ±0.1% accuracy) | +| SOC | Percentage | % | INA228 | State of charge via coulomb counting — *optional, only when calibrated* | +| Current | Current | A | INA228 | Battery current. Negative = discharging, positive = charging | +| Temperature | Temperature | °C / °F | BQ25798 NTC | Battery temperature from NTC thermistor | +| TTL | Distance | days | calculated | Estimated time-to-live (remaining runtime) — *optional, only with valid SOC* | + +### SOC & TTL + +SOC and TTL only appear when the coulomb counter has a valid reference point — either a manual SOC set (`set board.soc`) or a "Charging Done" event. The percentage is based on the configured battery capacity (`set board.batcap`; a chemistry-typical default of 1500–2000 mAh is assumed otherwise). Until the SOC is valid, these fields are omitted. + +### TTL Encoding + +The TTL (Time-To-Live) is transmitted as a **CayenneLPP Distance value** in days, since CayenneLPP has no native "duration" type. The companion app displays it as a distance (e.g. "42 m"), but the value represents **days of remaining runtime**. + +| Condition | Transmitted Value | Meaning | +|-----------|-------------------|---------| +| Finite TTL | `ttlHours / 24.0` | Estimated remaining days on battery | +| Surplus (charging > consumption) | `990.0` (sentinel value) | Effectively infinite — device is gaining charge | +| Unknown (SOC not yet valid) | *not sent* | TTL cannot be calculated yet | + +### Temperature Sentinel Values + +Invalid temperature readings are indicated by sentinel values and not transmitted to the app: + +| Value | Meaning | +|-------|---------| +| −999 °C | I²C communication error | +| −888 °C | ADC not ready | +| −99 °C | NTC open (not connected) | +| +99 °C | NTC shorted | + +--- + +## Channel 4 — Solar (BQ25798) + +Solar input data from the BQ25798 charge controller. + +| Field | LPP Type | Unit | Source | Description | +|-------|----------|------|--------|-------------| +| Voltage | Voltage | V | BQ25798 | Solar input voltage (VBUS) | +| Current | Current | A | BQ25798 | Solar input current (IBUS) | +| MPPT 7-Day | Percentage | % | Firmware | MPPT activation over the last 7 days. Shows what percentage of time the MPPT regulator was actively harvesting solar energy. | + +> **Note — Solar current accuracy:** The BQ25798 IBUS ADC has a resolution of 1 mA (15-bit mode) but exhibits significant measurement error at low currents (~±30 mA). Values below approximately 150 mA should be treated as rough estimates. For precise current measurement, the battery side uses the INA228 instead. + +> **Note:** The MPPT percentage is a rolling 7-day average. A low value (e.g. 1%) means the panel rarely delivers enough power to activate the MPPT regulator — e.g. during overcast conditions or suboptimal panel angle. + +--- + +## Channel Assignment in Code + +Channels are assigned dynamically: + +1. **Channel 1** (`TELEM_CHANNEL_SELF`) is statically defined and contains the MeshCore base data (battery voltage and MCU die temperature). +2. `querySensors()` assigns each active sensor its own channel starting right after Channel 1 — the BME280 therefore lands on **Channel 2**. +3. The **battery channel** is determined by `queryBoardTelemetry()` as the next free channel (`findNextFreeLppChannel`). +4. The **solar channel** = battery channel + 1. + +`querySensors()` assigns the BME280 to Channel 2 before `queryBoardTelemetry()` runs, so battery data lands on Channel 3 and solar on Channel 4 in practice. + +``` +Order in CayenneLPP packet: +┌───────────────────────────────────────────────┐ +│ Channel 1: Voltage (INA228 / SOC fake) │ ← MyMesh.cpp (getBattMilliVolts) +│ Channel 2: Temp, Humidity, Pressure, Alt. │ ← BME280 (querySensors) +│ Channel 3: VBAT, [SOC], IBAT, TBAT, [TTL] │ ← queryBoardTelemetry() +│ Channel 4: VSOL, ISOL, MPPT% │ ← queryBoardTelemetry() +│ Channel 1: MCU die temperature │ ← MyMesh.cpp (getMCUTemperature) +└───────────────────────────────────────────────┘ +``` + +> **Permissions:** Channels 2–4 are only sent if the requesting client has the `TELEM_PERM_ENVIRONMENT` permission. Guests (Guest role) receive only Channel 1 with base voltage and MCU temperature. + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/img/back-annotated_.png b/variants/inhero_mr2/docs/img/back-annotated_.png new file mode 100644 index 0000000000..2979eed2e4 Binary files /dev/null and b/variants/inhero_mr2/docs/img/back-annotated_.png differ diff --git a/variants/inhero_mr2/docs/img/back.jpg b/variants/inhero_mr2/docs/img/back.jpg new file mode 100644 index 0000000000..395b0f36f9 Binary files /dev/null and b/variants/inhero_mr2/docs/img/back.jpg differ diff --git a/variants/inhero_mr2/docs/img/front-annotated_.png b/variants/inhero_mr2/docs/img/front-annotated_.png new file mode 100644 index 0000000000..3f70e9fb3d Binary files /dev/null and b/variants/inhero_mr2/docs/img/front-annotated_.png differ diff --git a/variants/inhero_mr2/docs/img/front.jpg b/variants/inhero_mr2/docs/img/front.jpg new file mode 100644 index 0000000000..8de2c028ce Binary files /dev/null and b/variants/inhero_mr2/docs/img/front.jpg differ 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..80b8a6247f --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -0,0 +1,413 @@ +/* + * 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) { + const auto* props = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (props && props->ts_ignore) { + 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%% T:%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 = (confProps && confProps->ts_ignore) + ? "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(); + snprintf(reply, maxlen, "B:%s F:%s M:%s I:%s Vco:%.2f V0:%.2f", batType, frostBehaviour, + mpptEnabled ? "1" : "0", imax, chargeVoltage, voltage0Soc); + } + return true; + } else if (strcmp(cmd, "tccal") == 0) { + 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; + } + + snprintf(reply, maxlen, + "Err: bat|fmax|imax|mppt|telem|stats|cinfo|conf|tccal|leds|batcap"); + 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) { + const auto* fmaxProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (fmaxProps && fmaxProps->ts_ignore) { + snprintf(ret, sizeof(ret), "Err: Fmax setting N/A for this chemistry (JEITA disabled)"); + return ret; + } + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + BoardConfigContainer::FrostChargeBehaviour fcb = + BoardConfigContainer::getFrostChargeBehaviourFromCommandString(value); + if (fcb != BoardConfigContainer::FrostChargeBehaviour::REDUCE_UNKNOWN) { + 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) { + cfg.setMaxChargeCurrent_mA(ma); + 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); + if (cfg.setBatteryCapacity(capacity_mah)) { + snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh", capacity_mah); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid capacity (100-100000 mAh)"); + } + return ret; + } else if (strncmp(setCommand, "tccal", 5) == 0) { + // `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; + } + + snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|batcap|tccal|leds|soc"); + 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..fd2fd573d5 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -0,0 +1,563 @@ +/* + * 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); + Adafruit_BusIO_RegisterBits chrg_stat_1_bits = Adafruit_BusIO_RegisterBits(&chrg_stat_1_reg, 3, 5); + + uint8_t reg_value = chrg_stat_1_bits.read(); + + return (bq25798_charging_status)reg_value; +} + +// 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 }; + + // Determine if TS channel can be enabled based on VBAT + // See datasheet quote above: TS enabled requires VBAT >= 3.2V (battery-only) + bool ts_enabled = true; + if (vbat_mv > 0 && vbat_mv < 3200) { + ts_enabled = false; // Disable TS → ADC threshold drops to 2.9V + } + + bool success = this->startADCOneShot(ts_enabled); + + if (!success) { + return &telemetryData; + } + + // Poll ADC_EN bit until it auto-clears (conversion complete) or timeout. + // Channels: IBUS + VBUS (+ TS if enabled) → ~48-72ms typical. + const uint32_t ADC_TIMEOUT_MS = 250; + uint32_t start = millis(); + bool conversion_done = false; + while ((millis() - start) < ADC_TIMEOUT_MS) { + if (!this->getADCEnabled()) { + conversion_done = true; + break; + } + delay(10); + } + + if (!conversion_done) { + this->setADCEnabled(false); + } + + if (conversion_done) { + telemetryData.solar.voltage = getVBUS(); + telemetryData.solar.current = getIBUS(); + if (telemetryData.solar.current < 0) { + telemetryData.solar.current = 0; + } + telemetryData.solar.power = ((int32_t)telemetryData.solar.voltage * telemetryData.solar.current) / 1000; + + if (ts_enabled) { + telemetryData.battery.temperature = this->calculateBatteryTemp(getTS()); + } else { + // TS disabled due to low VBAT — cannot read NTC + 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 = 0x5A; // Enable IBUS(7), VBUS(5), TS(2) — disable rest + if (!ts_enabled) { + disable0 |= 0x04; // Also disable TS(2) → 0x5E + } + if (!disable_reg_0.write(disable0)) { return false; } + + // Reg 0x30: Disable all — D+(7), D-(6), VAC2(5), VAC1(4) not connected on MR2 + if (!disable_reg_1.write(0xF0)) { return false; } + + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + bool ok = adc_ctrl_reg.write(0xC0); + return ok; +} + +// ADC Control register (0x2E) implementations +bool BqDriver::getADCEnabled() { + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + Adafruit_BusIO_RegisterBits adc_en_bits = Adafruit_BusIO_RegisterBits(&adc_ctrl_reg, 1, 7); + bool result = (bool)adc_en_bits.read(); + return result; +} + +bool BqDriver::setADCEnabled(bool enabled) { + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + Adafruit_BusIO_RegisterBits adc_en_bits = Adafruit_BusIO_RegisterBits(&adc_ctrl_reg, 1, 7); + bool ok = adc_en_bits.write((uint8_t)enabled); + return ok; +} + +// ADC Reading implementations +int16_t BqDriver::getIBUS() { + Adafruit_BusIO_Register ibus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_IBUS_ADC, 2, MSBFIRST); + uint16_t raw; + if (!ibus_reg.read(&raw)) { // MSB first + return 0; + } + int16_t val = (int16_t)raw; // 2's complement for signed + return val; // in mA +} + +uint16_t BqDriver::getVBUS() { + Adafruit_BusIO_Register vbus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_VBUS_ADC, 2, MSBFIRST); + uint16_t val; + if (!vbus_reg.read(&val)) { + return 0; + } + return val; // in mV +} + +float BqDriver::getTS() { + Adafruit_BusIO_Register ts_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_TS_ADC, 2, MSBFIRST); + uint16_t val; + + // Try up to 3 times with small delays if we get invalid values + for (int retry = 0; retry < 3; retry++) { + if (!ts_reg.read(&val)) { + delay(20); + continue; // I2C read error, retry + } + // Check for invalid/uninitialized ADC value (0 or 0xFFFF) + if (val == 0 || val == 0xFFFF) { + if (retry < 2) { + delay(50); // Wait a bit longer for ADC to settle + continue; + } + return -2.0f; // ADC not ready / invalid value after retries + } + // Valid value + return val * 0.09765625f; // 0.09765625 %/LSB (exact: 1/1024) + } + + return -1.0f; // I2C read error after all retries +} + +bool BqDriver::setVOCpercent(bq25798_voc_pct_t pct) { + uint8_t reg15 = readReg(0x15); + reg15 = (reg15 & 0x1F) | ((uint8_t)pct << 5); // Bits [7:5] = VOC_PCT + return writeReg(0x15, reg15); +} + +bq25798_voc_pct_t BqDriver::getVOCpercent() { + uint8_t reg15 = readReg(0x15); + return (bq25798_voc_pct_t)((reg15 >> 5) & 0x07); +} + + +// 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..5830704892 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver for Inhero MR-2 + * Extends Adafruit_BQ25798 library (BSD License). + */ + +#pragma once + +#include +#include +#include + +#define R_PULLUP 5600.0f // Upper resistor RT1 in Ohms +#define R_PARALLEL 27000.0f // Lower parallel resistor RT2 in Ohms + +// Steinhart-Hart coefficients for NCP15XH103F03RC NTC (10kΩ, B=3380) +// Fitted from Murata datasheet R-T table at -20°C, 25°C, 85°C +// Max error vs. datasheet: ±0.36°C over -40..+125°C range +#define SH_A 8.7248136876e-04f // Steinhart-Hart coefficient A +#define SH_B 2.5405556775e-04f // Steinhart-Hart coefficient B +#define SH_C 1.8122847672e-07f // Steinhart-Hart coefficient C + +// Solar input telemetry data +// 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_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); + + // 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..532ef99b83 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -0,0 +1,542 @@ +/* + * 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(10.0f), _current_lsb(0.0f), _base_shunt_cal(0), _calibration_factor(1.0f) {} + +bool Ina228Driver::begin(float shunt_resistor_mohm) { + _shunt_mohm = shunt_resistor_mohm; + + // Check if device is present + if (!isConnected()) { + MESH_DEBUG_PRINTLN("INA228 begin() FAILED: isConnected() = false"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): Device connected"); + // Do NOT reset device - Early Boot voltage check may have configured it + // Resetting causes timing issues where subsequent writes fail + // Just reconfigure registers directly + + // Configure ADC: Continuous mode, all channels, long conversion times, 256 samples averaging + // - Long conversion times (VSHCT=4120µs, VBUSCT=2074µs) reduce noise for accurate SOC tracking + // - AVG_256 filters TX voltage peaks (prevents false UVLO triggers during transmit) + // - Trade-off: ~1s per measurement (excellent accuracy, acceptable for 1h SOC updates) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + // Expected 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) + + // Apply calibration factor to SHUNT_CAL (if set) + uint16_t calibrated_shunt_cal = (uint16_t)(_base_shunt_cal * _calibration_factor); + writeRegister16(INA228_REG_SHUNT_CAL, calibrated_shunt_cal); + delay(5); + + // Configure INA228: ADCRANGE = 0 (±163.84mV, default) for 100mΩ shunt + // At 1A: V_shunt = 100mV (61% of full-scale) — sufficient headroom + // At 10mA: V_shunt = 1mV — 5× better SNR than 20mΩ (was 200µV) + uint16_t config = 0; // ADCRANGE=0 (±163.84mV range, bit 4 = 0) + writeRegister16(INA228_REG_CONFIG, config); + delay(5); + + return true; +} + +bool Ina228Driver::isConnected() { + // First check if device responds at all + Wire.beginTransmission(_i2c_addr); + uint8_t i2c_result = Wire.endTransmission(); + + if (i2c_result != 0) { + return false; // No ACK on bus + } + + // Read Manufacturer ID (should be 0x5449 = "TI") + uint16_t mfg_id = readRegister16(INA228_REG_MANUFACTURER); + + if (mfg_id == 0x0000 || mfg_id == 0xFFFF) { + return false; // Invalid MFG_ID (bus error) + } + + // Some INA228 clones may have different MFG_ID, skip strict check + // Just verify it's not a bus error value + + // Read Device ID (should be 0x228 in lower 12 bits) + uint16_t dev_id = readRegister16(INA228_REG_DEVICE_ID); + + if (dev_id == 0x0000 || dev_id == 0xFFFF) { + return false; // Invalid DEV_ID (bus error) + } + + // 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 = (uint16_t)(_base_shunt_cal * _calibration_factor); + if (expected == 0) return false; // Not initialized + + uint16_t actual = readShuntCalRegister(); + if (actual == expected) return true; // OK + + // SHUNT_CAL is wrong — repair it! + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL corrupted! Expected=%u, Got=%u - repairing", expected, actual); + writeRegister16(INA228_REG_SHUNT_CAL, expected); + delay(2); + + uint16_t verify = readShuntCalRegister(); + if (verify != expected) { + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repair FAILED! Wrote=%u, Read=%u", expected, verify); + return false; + } + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repaired successfully"); + return true; +} + +bool Ina228Driver::setUnderVoltageAlert(uint16_t voltage_mv) { + // BUVL register: 3.125 mV/LSB (per datasheet Table 7-20) + // Non-zero BUVL enables bus under-voltage comparison → BUSUL flag + ALERT pin + // BUVL = 0 disables comparison (datasheet default) + uint16_t buvl_value = (uint16_t)(voltage_mv / 3.125f); + + // Write with retry and readback verification (I2C writes can fail silently) + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_BUVL, buvl_value)) { + delay(10); + continue; + } + delay(5); + uint16_t readback = readRegister16(INA228_REG_BUVL); + if (readback == buvl_value) { + return true; + } + MESH_DEBUG_PRINTLN("INA228: BUVL write mismatch (wrote=0x%04X, read=0x%04X), retry %d", + buvl_value, readback, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: BUVL write FAILED after 3 retries!"); + return false; +} + +void Ina228Driver::enableAlert(bool enable_uvlo, bool active_high, bool latch_alert) { + // DIAG_ALRT register: Only bits [15:12] are R/W (config), bits [11:0] are read-only flags. + // Writing to this register clears all flag bits [11:0]. + // Note: BUSUL/BUSOL flags (bits 3-4) are READ-ONLY status flags, NOT enable bits. + // Bus under-voltage comparison is enabled by setting BUVL register to non-zero. + uint16_t diag_alrt = 0; + + if (latch_alert) { + diag_alrt |= INA228_DIAG_ALRT_ALATCH; // Latch mode: Alert stays active until DIAG_ALRT is read + } + + if (active_high) { + diag_alrt |= INA228_DIAG_ALRT_APOL; // Active-high polarity (default: active-low) + } + + // Write with retry and readback verification + // Only bits [15:12] are readable as config; bits [11:0] are flags (may change between write and read) + uint16_t expected_config = diag_alrt & 0xF000; // Only check config bits + for (int retry = 0; retry < 3; retry++) { + writeRegister16(INA228_REG_DIAG_ALRT, diag_alrt); + delay(5); + uint16_t readback = readRegister16(INA228_REG_DIAG_ALRT); + uint16_t readback_config = readback & 0xF000; + if (readback_config == expected_config) { + return; + } + MESH_DEBUG_PRINTLN("INA228: DIAG_ALRT config mismatch (wrote=0x%04X, read=0x%04X, config=0x%04X vs 0x%04X), retry %d", + diag_alrt, readback, expected_config, readback_config, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: DIAG_ALRT write FAILED after 3 retries!"); +} + +bool Ina228Driver::isAlertActive() { + uint16_t diag_flags = getDiagnosticFlags(); + return (diag_flags & (INA228_DIAG_ALRT_BUSUL | INA228_DIAG_ALRT_BUSOL)) != 0; +} + +void Ina228Driver::clearAlert() { + // Read diagnostic register to clear latched alerts + getDiagnosticFlags(); +} + +uint16_t Ina228Driver::getDiagnosticFlags() { + return readRegister16(INA228_REG_DIAG_ALRT); +} + +uint16_t Ina228Driver::readBuvlRegister() { + return readRegister16(INA228_REG_BUVL); +} + +// ===== Private Methods ===== + +bool Ina228Driver::writeRegister16(uint8_t reg, uint16_t value) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.write((value >> 8) & 0xFF); // MSB + Wire.write(value & 0xFF); // LSB + bool ok = (Wire.endTransmission() == 0); + return ok; +} + +uint16_t Ina228Driver::readRegister16(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); // Repeated start + + Wire.requestFrom(_i2c_addr, (uint8_t)2); + if (Wire.available() < 2) { + return 0; + } + + uint16_t value = Wire.read() << 8; // MSB + value |= Wire.read(); // LSB + return value; +} + +int32_t Ina228Driver::readRegister24(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)3); + if (Wire.available() < 3) { + return 0; + } + + int32_t value = Wire.read() << 16; // MSB + value |= Wire.read() << 8; // Mid + value |= Wire.read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (value & 0x800000) { + value |= 0xFF000000; + } + + return value; +} + +int64_t Ina228Driver::readRegister40(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)5); + if (Wire.available() < 5) { + return 0; + } + + int64_t value = (int64_t)Wire.read() << 32; // MSB + value |= (int64_t)Wire.read() << 24; + value |= (int64_t)Wire.read() << 16; + value |= (int64_t)Wire.read() << 8; + value |= (int64_t)Wire.read(); // LSB + + // Sign-extend 40-bit to 64-bit + if (value & 0x8000000000LL) { + value |= 0xFFFFFF0000000000LL; + } + + return value; +} + +// ===== Calibration Methods ===== + +float Ina228Driver::calibrateCurrent(float actual_current_ma) { + // Step 1: Reset calibration to 1.0 for accurate measurement + setCalibrationFactor(1.0f); + + // Wait for ADC to settle + delay(10); + + // Step 2: Read current measured value (uncalibrated) + int16_t measured_current_ma = readCurrent_mA(); + + // Avoid division by zero + if (measured_current_ma == 0) { + return 1.0f; // No correction possible + } + + // Step 3: Calculate correction factor (INVERSE ratio) + // If INA shows -9.4mA but actual is -10.4mA, we need SHUNT_CAL to be SMALLER + // so INA writes a LARGER value. Factor = measured/actual = 9.4/10.4 = 0.904 + float new_factor = (float)measured_current_ma / actual_current_ma; + + // Step 4: Apply new calibration factor to INA228 hardware + setCalibrationFactor(new_factor); + + return new_factor; +} + +void Ina228Driver::setCalibrationFactor(float factor) { + // Clamp to reasonable range (0.5x to 2.0x) + if (factor < 0.5f) factor = 0.5f; + if (factor > 2.0f) factor = 2.0f; + + _calibration_factor = factor; + + // Apply calibration factor to SHUNT_CAL register + // Lower SHUNT_CAL → INA writes larger values → higher current reading + // Higher SHUNT_CAL → INA writes smaller values → lower current reading + // CURRENT_LSB stays constant (per datasheet design) + if (_base_shunt_cal > 0) { + uint16_t calibrated_shunt_cal = (uint16_t)(_base_shunt_cal * factor); + writeRegister16(INA228_REG_SHUNT_CAL, calibrated_shunt_cal); + } +} + +float Ina228Driver::getCalibrationFactor() const { + return _calibration_factor; +} + + diff --git a/variants/inhero_mr2/lib/Ina228Driver.h b/variants/inhero_mr2/lib/Ina228Driver.h new file mode 100644 index 0000000000..414de3a953 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.h @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver for Inhero MR-2 + * + * Features: + * - Voltage, current, power monitoring + * - Coulomb counter (accumulated charge) + * - Alert pin for firmware-triggered low-voltage sleep (INA228 BUVL → ISR → System Sleep) + * - Chemistry-specific thresholds (Li-Ion, LiFePO4, LTO) + */ + +#pragma once + +#include +#include + +// INA228 I2C Address +#define INA228_I2C_ADDR_DEFAULT 0x40 // A0=GND, A1=GND + +// INA228 Register Map +#define INA228_REG_CONFIG 0x00 // Configuration +#define INA228_REG_ADC_CONFIG 0x01 // ADC Configuration +#define INA228_REG_SHUNT_CAL 0x02 // Shunt Calibration +#define INA228_REG_SHUNT_TEMP 0x03 // Shunt Temperature Coefficient +#define INA228_REG_VSHUNT 0x04 // Shunt Voltage +#define INA228_REG_VBUS 0x05 // Bus Voltage +#define INA228_REG_DIETEMP 0x06 // Die Temperature +#define INA228_REG_CURRENT 0x07 // Current +#define INA228_REG_POWER 0x08 // Power +#define INA228_REG_ENERGY 0x09 // Energy (Coulomb Counter) +#define INA228_REG_CHARGE 0x0A // Charge (Coulomb Counter) +#define INA228_REG_DIAG_ALRT 0x0B // Diagnostic and Alert +#define INA228_REG_SOVL 0x0C // Shunt Over-Voltage Limit +#define INA228_REG_SUVL 0x0D // Shunt Under-Voltage Limit +#define INA228_REG_BOVL 0x0E // Bus Over-Voltage Limit +#define INA228_REG_BUVL 0x0F // Bus Under-Voltage Limit +#define INA228_REG_TEMP_LIMIT 0x10 // Temperature Limit +#define INA228_REG_PWR_LIMIT 0x11 // Power Limit +#define INA228_REG_MANUFACTURER 0x3E // Manufacturer ID (should be 0x5449 = "TI") +#define INA228_REG_DEVICE_ID 0x3F // Device ID (should be 0x228) + +// INA228 Configuration bits +#define INA228_CONFIG_RST (1 << 15) // Reset bit +#define INA228_CONFIG_ADCRANGE (1 << 4) // ADC Range (0=±163.84mV, 1=±40.96mV) + +// ADC Configuration - Mode +#define INA228_ADC_MODE_CONT_ALL 0x0F // Continuous conversion, all channels + +// ADC Configuration - Averaging +#define INA228_ADC_AVG_1 0x00 // No averaging +#define INA228_ADC_AVG_4 0x01 // 4 samples average +#define INA228_ADC_AVG_16 0x02 // 16 samples average +#define INA228_ADC_AVG_64 0x03 // 64 samples average +#define INA228_ADC_AVG_128 0x04 // 128 samples average +#define INA228_ADC_AVG_256 0x05 // 256 samples average +#define INA228_ADC_AVG_512 0x06 // 512 samples average +#define INA228_ADC_AVG_1024 0x07 // 1024 samples average + +// ADC Configuration - Conversion Time (VBUSCT, VSHCT, VTCT) +#define INA228_ADC_CT_50us 0x00 // 50 µs +#define INA228_ADC_CT_84us 0x01 // 84 µs +#define INA228_ADC_CT_150us 0x02 // 150 µs +#define INA228_ADC_CT_280us 0x03 // 280 µs +#define INA228_ADC_CT_540us 0x04 // 540 µs +#define INA228_ADC_CT_1052us 0x05 // 1052 µs (default) +#define INA228_ADC_CT_2074us 0x06 // 2074 µs +#define INA228_ADC_CT_4120us 0x07 // 4120 µs (maximum accuracy) + +// Alert Configuration +#define INA228_DIAG_ALRT_ALATCH (1 << 15) // Alert Latch Enable +#define INA228_DIAG_ALRT_CNVR (1 << 14) // Conversion Ready +#define INA228_DIAG_ALRT_SLOWALERT (1 << 13) // Slow Alert (for averaging) +#define INA228_DIAG_ALRT_APOL (1 << 12) // Alert Polarity (1=active high) +#define INA228_DIAG_ALRT_ENERGYOF (1 << 11) // Energy Overflow +#define INA228_DIAG_ALRT_CHARGEOF (1 << 10) // Charge Overflow +#define INA228_DIAG_ALRT_MATHOF (1 << 9) // Math Overflow +#define INA228_DIAG_ALRT_TMPOL (1 << 7) // Temperature Over-Limit +#define INA228_DIAG_ALRT_SHNTOL (1 << 6) // Shunt Over-Voltage +#define INA228_DIAG_ALRT_SHNTUL (1 << 5) // Shunt Under-Voltage +#define INA228_DIAG_ALRT_BUSOL (1 << 4) // Bus Over-Voltage +#define INA228_DIAG_ALRT_BUSUL (1 << 3) // Bus Under-Voltage (UVLO) +#define INA228_DIAG_ALRT_POL (1 << 2) // Power Over-Limit +#define INA228_DIAG_ALRT_CNVRF (1 << 1) // Conversion Ready Flag +#define INA228_DIAG_ALRT_MEMSTAT (1 << 0) // Memory Status + +// Battery telemetry from INA228 +typedef struct { + uint16_t voltage_mv; // Battery voltage in mV + int16_t current_ma; // Battery current in mA (+ = charging, - = discharging) + int32_t power_mw; // Battery power in mW + int32_t energy_mwh; // Accumulated energy in mWh (since last reset) + float charge_mah; // Accumulated charge in mAh (since last reset) + float die_temp_c; // Die temperature in °C +} Ina228BatteryData; + +class Ina228Driver { +public: + // 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 (e.g., 100 for 0.1Ω). + bool begin(float shunt_resistor_mohm = 10.0f); + + // 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(); + + // Calibrate current measurement against the actual measured battery current (in mA). + // Returns the calculated calibration factor (multiplier for future readings) — store it + // persistently and apply it via setCalibrationFactor(). + float calibrateCurrent(float actual_current_ma); + + // Set persistent current calibration factor (1.0 = no correction, >1.0 = increase readings, + // <1.0 = decrease). Written directly to the INA228 SHUNT_CAL register (hardware calibration), + // so all measurements (current, power, energy, charge) are corrected automatically. + // Call this at startup with the value loaded from persistent storage. + void setCalibrationFactor(float factor); + + // Get current calibration factor (1.0 = no calibration) + float getCalibrationFactor() const; + + + + // 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 24-bit ADC (±0.1% accuracy). + static uint16_t readVBATDirect(TwoWire* wire = &Wire, uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + +private: + uint8_t _i2c_addr; + float _shunt_mohm; + float _current_lsb; // Current LSB in A (constant per datasheet) + uint16_t _base_shunt_cal; // Original SHUNT_CAL value (before calibration) + float _calibration_factor; // Current calibration factor (1.0 = no correction) + + 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..2e4ea252d1 --- /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 MR-2 GPIO definitions + */ +static const uint8_t WB_IO1 = 17; // SLOT_A SLOT_B +static const uint8_t WB_IO2 = 34; // SLOT_A SLOT_B +static const uint8_t WB_IO3 = 21; // SLOT_C +static const uint8_t WB_IO4 = 4; // SLOT_C +static const uint8_t WB_IO5 = 9; // SLOT_D +static const uint8_t WB_IO6 = 10; // SLOT_D +static const uint8_t WB_SW1 = 33; // IO_SLOT +static const uint8_t WB_A0 = 5; // IO_SLOT +static const uint8_t WB_A1 = 31; // IO_SLOT +static const uint8_t WB_I2C1_SDA = 13; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C1_SCL = 14; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C2_SDA = 24; // IO_SLOT +static const uint8_t WB_I2C2_SCL = 25; // IO_SLOT +static const uint8_t WB_SPI_CS = 26; // IO_SLOT +static const uint8_t WB_SPI_CLK = 3; // IO_SLOT +static const uint8_t WB_SPI_MISO = 29; // IO_SLOT +static const uint8_t WB_SPI_MOSI = 30; // IO_SLOT + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (35) +#define PIN_LED2 (36) +#define BQ_INT_PIN (21) +// 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 MR-2 + +/* + * Analog pins + */ +#define PIN_A0 (5) //(3) +#define PIN_A1 (31) //(4) +#define PIN_A2 (28) +#define PIN_A3 (29) +#define PIN_A4 (30) +#define PIN_A5 (31) +#define PIN_A6 (0xff) +#define PIN_A7 (0xff) + +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; +static const uint8_t A6 = PIN_A6; +static const uint8_t A7 = PIN_A7; +#define ADC_RESOLUTION 14 + +// Other pins +#define PIN_AREF (2) +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +static const uint8_t AREF = PIN_AREF; + +/* + * Serial interfaces + */ +// TXD1 RXD1 on Base Board +#define PIN_SERIAL1_RX (15) +#define PIN_SERIAL1_TX (16) + +// TXD0 RXD0 on Base Board +#define PIN_SERIAL2_RX (19) +#define PIN_SERIAL2_TX (20) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 1 + +#define PIN_SPI_MISO (29) +#define PIN_SPI_MOSI (30) +#define PIN_SPI_SCK (3) + +static const uint8_t SS = 26; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +/* + * Wire Interfaces + */ +#define WIRE_INTERFACES_COUNT 2 + +#define PIN_WIRE_SDA (13) +#define PIN_WIRE_SCL (14) + +#define PIN_WIRE1_SDA (24) +#define PIN_WIRE1_SCL (25) + +// QSPI Pins +// QSPI occupied by GPIO's +#define PIN_QSPI_SCK 3 // 19 +#define PIN_QSPI_CS 26 // 17 +#define PIN_QSPI_IO0 30 // 20 +#define PIN_QSPI_IO1 29 // 21 +#define PIN_QSPI_IO2 28 // 22 +#define PIN_QSPI_IO3 2 // 23 + +// On-board QSPI Flash +// No onboard flash +#define EXTERNAL_FLASH_DEVICES IS25LP080D +#define EXTERNAL_FLASH_USE_QSPI + +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif