diff --git a/boards/inhero_mr2.json b/boards/inhero_mr2.json new file mode 100644 index 0000000000..ed8414ce48 --- /dev/null +++ b/boards/inhero_mr2.json @@ -0,0 +1,74 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_NRF52840_FEATHER -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + [ + "0x239A", + "0x8029" + ], + [ + "0x239A", + "0x0029" + ], + [ + "0x239A", + "0x002A" + ], + [ + "0x239A", + "0x802A" + ] + ], + "usb_product": "Inhero MR2", + "mcu": "nrf52840", + "variant": "Inhero_MR2_Board", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": [ + "bluetooth" + ], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52.cfg" + }, + "frameworks": [ + "arduino" + ], + "name": "Inhero MR2", + "upload": { + "maximum_ram_size": 235520, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://inhero.de", + "vendor": "Inhero GmbH" +} diff --git a/src/helpers/AutoDiscoverRTCClock.cpp b/src/helpers/AutoDiscoverRTCClock.cpp index af08fddb03..eb9093ed46 100644 --- a/src/helpers/AutoDiscoverRTCClock.cpp +++ b/src/helpers/AutoDiscoverRTCClock.cpp @@ -20,6 +20,69 @@ static bool rtc_8130_success = false; #define PCF8563_ADDRESS 0x51 #define RX8130CE_ADDRESS 0x32 +#if defined(INHERO_MR2) +static bool configureMr2RtcBackup(TwoWire& wire) { + // The library's EEPROM helpers omit the command delays and I2C error checks. + auto read = [&wire](uint8_t reg, uint8_t& value) { + wire.beginTransmission(RV3028_ADDRESS); + wire.write(reg); + if (wire.endTransmission(false) != 0 || + wire.requestFrom((uint8_t)RV3028_ADDRESS, (uint8_t)1) != 1) return false; + value = wire.read(); + return true; + }; + auto write = [&wire](uint8_t reg, uint8_t value) { + wire.beginTransmission(RV3028_ADDRESS); + wire.write(reg); + wire.write(value); + return wire.endTransmission() == 0; + }; + auto waitReady = [&read]() { + const uint32_t start = millis(); + do { + uint8_t status; + if (!read(0x0E, status)) return false; + if ((status & 0x80) == 0) return true; // EEbusy + delay(1); + } while ((uint32_t)(millis() - start) < 500); + return false; + }; + auto startBackupRead = [&]() { + return write(0x25, 0x37) && write(0x27, 0x00) && write(0x27, 0x22); + }; + + uint8_t control1; + if (!read(0x0F, control1)) return false; + const bool ok = [&]() { + if (!write(0x0F, control1 | 0x08) || !waitReady()) return false; // EERD + if (!startBackupRead()) return false; + delay(1); // RV-3028 manual 4.6.7: wait before checking EEbusy after a read. + uint8_t stored; + if (!waitReady() || !read(0x26, stored)) return false; + // No backup battery: BSM=00, TCE=0, BSIE=0, FEDE=1. Keep EEOffset[0]/TCR. + const uint8_t desired = (stored & 0x83) | 0x10; + if (stored != desired) { + // Write only EEPROM byte 0x37, never issue an Update All command. + if (!write(0x25, 0x37) || !write(0x26, desired) || + !write(0x27, 0x00) || !write(0x27, 0x21)) return false; + delay(10); // RV-3028 manual 4.6.7: wait before checking EEbusy after a write. + if (!waitReady() || !startBackupRead()) return false; + delay(1); + uint8_t verified; + if (!waitReady() || !read(0x26, verified) || verified != desired) return false; + } + // A single-byte EEPROM write does not update the active RAM mirror. + uint8_t active; + return write(0x37, desired) && read(0x37, active) && active == desired; + }(); + + // Re-enable automatic refresh on both success and failure, without recovery. + const bool released = write(0x0F, control1 & ~0x08); + uint8_t finalControl1; + return ok && released && read(0x0F, finalControl1) && (finalControl1 & 0x08) == 0; +} +#endif + bool AutoDiscoverRTCClock::i2c_probe(TwoWire& wire, uint8_t addr) { wire.beginTransmission(addr); uint8_t error = wire.endTransmission(); @@ -36,9 +99,14 @@ void AutoDiscoverRTCClock::begin(TwoWire& wire) { if (i2c_probe(wire, RV3028_ADDRESS)) { rtc_rv3028.initI2C(wire); rtc_rv3028.writeToRegister(0x35, 0x00); +#if defined(INHERO_MR2) + // MR2 has no backup battery: VDD and VBACKUP share the 3.3 V supply. + rv3028_success = configureMr2RtcBackup(wire); +#else rtc_rv3028.writeToRegister(0x37, 0xB4); // Direct Switching Mode (DSM): when VDD < VBACKUP, switchover occurs from VDD to VBACKUP - rtc_rv3028.set24HourMode(); // Set the device to use the 24hour format (default) instead of the 12 hour format rv3028_success = true; +#endif + rtc_rv3028.set24HourMode(); // Set the device to use the 24hour format (default) instead of the 12 hour format } if (i2c_probe(wire, PCF8563_ADDRESS)) { diff --git a/test/mr2_ina_alert/fixture.inc b/test/mr2_ina_alert/fixture.inc new file mode 100644 index 0000000000..50028dd45f --- /dev/null +++ b/test/mr2_ina_alert/fixture.inc @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include + +// PRODUCTION_DEFINES +#define MESH_DEBUG_PRINTLN(...) ((void)0) +void delay(int) {} +void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +class Ina228Driver { +public: + uint16_t control = 0; + uint16_t flags = 0; + unsigned writes = 0; + unsigned staleReads = 0; + void enableAlert(bool enable_uvlo, bool active_high, bool latch_alert); + bool writeRegister16(uint8_t reg, uint16_t value) { + check(reg == INA228_REG_DIAG_ALRT, "unexpected register write"); + ++writes; + control = value; + return true; + } + uint16_t readRegister16(uint8_t reg) { + check(reg == INA228_REG_DIAG_ALRT, "unexpected register read"); + if (staleReads) { + --staleReads; + return (control & ~INA228_DIAG_ALRT_SLOWALERT) | flags; + } + return control | flags; + } +}; + +// PRODUCTION_METHOD + +// INA228 datasheet Table 7-16: SLOWALERT selects raw or averaged comparison. +bool underVoltage(uint16_t diag, unsigned rawMv, unsigned averagedMv) { + return ((diag & 0x2000) ? averagedMv : rawMv) < 2500; +} + +int main() { + const std::vector>> tests = { + {"UVLO uses averaging with latch and active-low polarity", [] { + Ina228Driver ina; + ina.enableAlert(true, false, true); + check(ina.control == 0xa000, "expected SLOWALERT + ALATCH (0xa000)"); + check(ina.writes == 1, "successful configuration unexpectedly retried"); + }}, + {"latch and polarity remain independent of averaging", [] { + Ina228Driver ina; + ina.enableAlert(true, false, false); + check(ina.control == 0x2000, "transparent active-low should retain averaging"); + ina.enableAlert(true, true, true); + check(ina.control == 0xb000, "active-high should retain latch and averaging"); + }}, + {"disarming clears averaging and latch", [] { + Ina228Driver ina; + ina.enableAlert(true, false, true); + ina.enableAlert(false, false, false); + check(ina.control == 0, "disarm retained configuration bits"); + ina.enableAlert(false, true, true); + check(ina.control == 0x9000, "disabled UVLO changed requested latch or polarity"); + }}, + {"readback retries if SLOWALERT did not stick", [] { + Ina228Driver ina; + ina.staleReads = 1; + ina.enableAlert(true, false, true); + check(ina.writes == 2, "missing SLOWALERT was accepted during readback"); + check(ina.control == 0xa000, "retry lost averaging"); + }}, + {"changing status flags do not trigger configuration retries", [] { + Ina228Driver ina; + ina.flags = 0x0fff; + ina.enableAlert(true, false, true); + check(ina.writes == 1, "status flags affected configuration verification"); + }}, + {"persistent configuration mismatch retains bounded retries", [] { + Ina228Driver ina; + ina.staleReads = 10; + ina.enableAlert(true, false, true); + check(ina.writes == 3, "configuration retry limit changed"); + }}, + {"raw voltage dip is filtered; sustained undervoltage still alerts", [] { + Ina228Driver ina; + ina.enableAlert(true, false, true); + check(!underVoltage(ina.control, 2400, 3250), "brief raw dip triggered UVLO"); + check(underVoltage(ina.control, 2400, 2440), "real averaged undervoltage missed"); + check(!underVoltage(ina.control, 2400, 2500), "threshold equality triggered UVLO"); + check(underVoltage(0x8000, 2400, 3250), "unfiltered negative control did not alert"); + }} + }; + unsigned failures = 0; + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS " << test.first << '\n'; + } catch (const std::exception& error) { + ++failures; + std::cerr << "FAIL " << test.first << ": " << error.what() << '\n'; + } + } + std::cout << tests.size() - failures << '/' << tests.size() << " passed\n"; + return failures ? 1 : 0; +} diff --git a/test/mr2_ina_alert/run.py b/test/mr2_ina_alert/run.py new file mode 100644 index 0000000000..5b92658b50 --- /dev/null +++ b/test/mr2_ina_alert/run.py @@ -0,0 +1,41 @@ +"""Host regression for the production INA228 alert configuration function. + +Run with Python 3; requires g++ on PATH or CXX pointing to a compiler. +Only register I/O is mocked. The final comparison check models datasheet +Table 7-16, not a physical ADC or a board timing measurement. +""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +DRIVER = ROOT / "variants/inhero_mr2/lib/Ina228Driver.cpp" +HEADER = DRIVER.with_suffix(".h") +source = DRIVER.read_text(encoding="utf-8") +start = source.index("void Ina228Driver::enableAlert(") +brace = source.index("{", start) +depth = 0 +for end in range(brace, len(source)): + depth += (source[end] == "{") - (source[end] == "}") + if depth == 0: + method = source[start:end + 1] + break +else: + raise ValueError("Unbalanced enableAlert function") +defines = "\n".join(re.findall(r"^#define INA228_(?:DIAG_ALRT_|REG_).*$", HEADER.read_text(encoding="utf-8"), re.M)) +fixture = Path(__file__).with_name("fixture.inc").read_text(encoding="utf-8") +code = fixture.replace("// PRODUCTION_DEFINES", defines).replace("// PRODUCTION_METHOD", method) +compiler = os.environ.get("CXX") or shutil.which("g++") +if not compiler and Path("C:/Tools/mingw64/bin/g++.exe").is_file(): + compiler = "C:/Tools/mingw64/bin/g++.exe" +if not compiler: + raise SystemExit("Set CXX to a C++17 compiler or add g++ to PATH") +with tempfile.TemporaryDirectory(prefix="mr2-ina-alert-") as temp: + cpp = Path(temp) / "test.cpp" + exe = Path(temp) / ("test.exe" if os.name == "nt" else "test") + cpp.write_text(code, encoding="utf-8") + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", str(cpp), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) diff --git a/test/mr2_mpptdiag/run_test.py b/test/mr2_mpptdiag/run_test.py new file mode 100644 index 0000000000..af20dbb4a0 --- /dev/null +++ b/test/mr2_mpptdiag/run_test.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Compile the real MR2 MPPT diagnostic method against a checked I2C mock. + +Run with: python test/mr2_mpptdiag/run_test.py [--cxx path/to/g++] +The temporary C++ source stays outside PlatformIO's test discovery. +""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include + +struct BqMock { + uint8_t registers[256] = {}; + int fail = -1; + std::vector reads; + bool readReg(uint8_t reg, uint8_t& value) { + reads.push_back(reg); + if (reg == fail) return false; // Leave value untouched on failure. + value = registers[reg]; + return true; + } + // Intentionally no unchecked read, writes, or ADC-start methods. +} bq; + +struct BoardConfigContainer { + bool bqInitialized = true; + bool wish = true; + bool getMPPTEnabled() const { return wish; } + void getMpptDiagnostics(char* buffer, uint32_t bufferSize); +}; + +// The runner inserts the production method here, without rewriting it. +@METHOD@ + +static void set16(uint8_t reg, unsigned value) { + bq.registers[reg] = value >> 8; + bq.registers[reg + 1] = value & 0xFF; +} + +static std::string snapshot(BoardConfigContainer& cfg) { + char buffer[100]; + memset(buffer, '!', sizeof(buffer)); + cfg.getMpptDiagnostics(buffer, sizeof(buffer)); + assert(memchr(buffer, '\0', sizeof(buffer)) != nullptr); + return buffer; +} + +int main() { + BoardConfigContainer cfg; + + // Na-ion before the change: configured MPPT on, hardware off, VSYS minimum active. + bq.registers[0x00] = 1; + set16(0x01, 390); + set16(0x03, 20); + bq.registers[0x05] = 37; + bq.registers[0x1B] = 0x08; + bq.registers[0x1E] = 0x10; + assert(snapshot(cfg) == + "MPPT:cfg=1/hw=0 VSYSMIN:2750mV VINDPM:3700mV VSYS_MIN:1 PG:1 CELL:1S ICHG:200mA VREG:3900mV"); + + // Expected RC state with the same cell and an enabled hardware MPPT loop. + bq.registers[0x00] = 0; + bq.registers[0x05] = 60; + bq.registers[0x15] = 1; + bq.registers[0x1E] = 0; + assert(snapshot(cfg) == + "MPPT:cfg=1/hw=1 VSYSMIN:2500mV VINDPM:6000mV VSYS_MIN:0 PG:1 CELL:1S ICHG:200mA VREG:3900mV"); + + // LTO2S: register byte order and masks, with unrelated/reserved bits set. + // Config and hardware can disagree in either direction. + cfg.wish = false; + bq.registers[0x00] = 0xC0; + set16(0x01, 0xF800 | 540); + set16(0x03, 0xFE00 | 40); + bq.registers[0x0A] = 0x7F; + bq.registers[0x15] = 0xFF; + bq.registers[0x1B] = 0xF7; + bq.registers[0x1E] = 0xEF; + assert(snapshot(cfg) == + "MPPT:cfg=0/hw=1 VSYSMIN:2500mV VINDPM:6000mV VSYS_MIN:0 PG:0 CELL:2S ICHG:400mA VREG:5400mV"); + + // Even out-of-range/reserved encodings fit in a CLI reply without truncation. + cfg.wish = true; + memset(bq.registers, 0xFF, sizeof(bq.registers)); + const auto maximum = snapshot(cfg); + assert(maximum == + "MPPT:cfg=1/hw=1 VSYSMIN:18250mV VINDPM:25500mV VSYS_MIN:1 PG:1 CELL:4S ICHG:5110mA VREG:20470mV"); + assert(maximum.size() == 95); + + // Check every register read failure, including both bytes of each 16-bit field. + // No partial or zero-valued success response may escape. + const uint8_t expected_reads[] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x0A, 0x15, 0x1B, 0x1E}; + for (unsigned i = 0; i < sizeof(expected_reads); ++i) { + bq.reads.clear(); + bq.fail = expected_reads[i]; + char expected[32]; + snprintf(expected, sizeof(expected), "Err: BQ read %02X", static_cast(expected_reads[i])); + assert(snapshot(cfg) == expected); + assert(bq.reads == std::vector(expected_reads, expected_reads + i + 1)); + } + bq.fail = -1; + + // Truncated callers still receive a terminated string and never lose canaries. + for (unsigned capacity = 1; capacity <= 100; ++capacity) { + char guarded[102]; + memset(guarded, '#', sizeof(guarded)); + cfg.getMpptDiagnostics(guarded + 1, capacity); + assert(guarded[0] == '#'); + assert(guarded[capacity + 1] == '#'); + assert(memchr(guarded + 1, '\0', capacity) != nullptr); + assert(std::string(guarded + 1) == maximum.substr(0, capacity - 1)); + } + + bq.reads.clear(); + char untouched = '#'; + cfg.getMpptDiagnostics(&untouched, 0); + cfg.getMpptDiagnostics(nullptr, 100); + assert(untouched == '#'); + assert(bq.reads.empty()); + + cfg.bqInitialized = false; + assert(snapshot(cfg) == "BQ not init"); + assert(bq.reads.empty()); + puts("PASS: MR2 mpptdiag decode, masks, 95-character bound, all I2C failures, buffer guards"); +} +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=os.environ.get("CXX") or shutil.which("g++")) + args = parser.parse_args() + if not args.cxx: + parser.error("g++ unavailable; pass --cxx with a C++17 compiler") + + root = Path(__file__).resolve().parents[2] + source = (root / "variants/inhero_mr2/BoardConfigContainer.cpp").read_text(encoding="utf-8") + signature = "void BoardConfigContainer::getMpptDiagnostics(" + start = source.index(signature) + body = source.index("{", start) + depth = 1 + end = body + 1 + while depth: + if source[end] == "{": + depth += 1 + elif source[end] == "}": + depth -= 1 + end += 1 + method = source[start:end] + + cli = (root / "variants/inhero_mr2/helpers/CliCommands.cpp").read_text(encoding="utf-8") + branch_start = cli.index('strcmp(cmd, "mpptdiag") == 0') + branch_end = cli.index("} else", branch_start) + branch = cli[branch_start:branch_end] + assert "cfg.getMpptDiagnostics(diagBuffer, sizeof(diagBuffer));" in branch + assert "char diagBuffer[100];" in branch + assert 'snprintf(reply, maxlen, "%s", diagBuffer);' in branch + + with tempfile.TemporaryDirectory(prefix="mr2-mpptdiag-") as directory: + tmp = Path(directory) + cpp = tmp / "mpptdiag.cpp" + exe = tmp / ("mpptdiag.exe" if os.name == "nt" else "mpptdiag") + cpp.write_text(HARNESS.replace("@METHOD@", method), encoding="utf-8") + subprocess.run([args.cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", + str(cpp), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == "__main__": + main() diff --git a/test/mr2_rtc_wake/README.md b/test/mr2_rtc_wake/README.md new file mode 100644 index 0000000000..a36a8edb71 --- /dev/null +++ b/test/mr2_rtc_wake/README.md @@ -0,0 +1,25 @@ +# MR2 RTC wake verification + +Run with Python 3 and a C++17 compiler: + +```powershell +python test/mr2_rtc_wake/run_test.py --cxx C:/Tools/mingw64/bin/g++.exe +``` + +The runner compiles the complete production `Rv3028Wake.cpp` and its header, +copied verbatim into a temporary directory, against a small Wire register model. +It takes RTC register addresses from the real board header. Generated files and +the executable are temporary; the `.inc` fixture is excluded from PlatformIO's +normal C++ test discovery. + +Tests cover valid and clamped durations; restarting an active timer; NACKs on +every write and register selection; failure to queue any address or data byte; +acknowledged but ignored writes; short and inconsistent reads; incorrect +readback, including a timer flag that did not clear; and an absent RTC. Unrelated +registers must remain unchanged. Reserved register bits are ignored correctly. + +The helper makes one attempt and returns false on failure. Bus recovery, retry, +the GPIO wake input and deciding whether to enter System OFF belong to the board +caller, outside this test. This model does not prove electrical I2C behavior, +oscillator operation or a physical wake from System OFF. Those require a board +test with low voltage and RTC/I2C fault injection. diff --git a/test/mr2_rtc_wake/fixture.inc b/test/mr2_rtc_wake/fixture.inc new file mode 100644 index 0000000000..82194147f1 --- /dev/null +++ b/test/mr2_rtc_wake/fixture.inc @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include "helpers/Rv3028Wake.h" + +FakeWire Wire; +unsigned cases = 0; + +void check(bool condition, const char* name, unsigned index = 0) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s [%u]\n", name, index); + std::exit(1); + } +} + +void reset() { + Wire = {}; + // Previously armed/expired timer makes ignored stop, interrupt-disable and + // flag-clear writes observable, even if the final armed values would match. + Wire.registers[0x0F] = 0x87; + Wire.registers[0x10] = 0x10; + Wire.registers[0x0E] = 0x08; + Wire.registers[0x0A] = 0x33; + Wire.registers[0x0B] = 0x03; + Wire.registers[0x00] = 0x42; // Time/calendar must not be changed. + Wire.registers[0x35] = 0x48; // CLKOUT and backup configuration stay untouched. + Wire.registers[0x37] = 0x93; +} + +void expectFailure(const char* name, unsigned index = 0) { + ++cases; + check(!inhero::configurePeriodicWake(60), name, index); +} + +int main() { + const std::array, 7> durations{{ + {{0, 1}}, {{1, 1}}, {{60, 60}}, {{256, 256}}, + {{4095, 4095}}, {{4096, 4095}}, {{65535, 4095}} + }}; + for (const auto& duration : durations) { + reset(); + const auto before = Wire.registers; + ++cases; + check(inhero::configurePeriodicWake(duration[0]), "valid duration", duration[0]); + check(Wire.registers[0x0A] == (duration[1] & 0xFF) && + Wire.registers[0x0B] == (duration[1] >> 8), "timer preset", duration[0]); + check(Wire.registers[0x0F] == 0x07 && Wire.registers[0x10] == 0x10 && + (Wire.registers[0x0E] & 0x08) == 0 && Wire.starts == 1, + "fresh single-shot timer armed", duration[0]); + for (unsigned reg = 0; reg < before.size(); ++reg) { + if (reg == 0x0A || reg == 0x0B || reg == 0x0E || reg == 0x0F || reg == 0x10) continue; + check(Wire.registers[reg] == before[reg], "unrelated register changed", reg); + } + } + + reset(); + check(inhero::configurePeriodicWake(60), "baseline"); + const unsigned transactions = Wire.transfers; + const unsigned bytes = Wire.queuedBytes; + const unsigned writes = Wire.dataWrites; + const auto readRegisters = Wire.readRegisters; + check(transactions > writes && !readRegisters.empty(), "verification reads present"); + + for (unsigned i = 1; i <= transactions; ++i) { + reset(); + Wire.nackAt = i; + expectFailure("NACK on write or read selection", i); + } + for (unsigned i = 1; i <= bytes; ++i) { + reset(); + Wire.queueFailureAt = i; + expectFailure("queue failure on any address/data byte", i); + } + for (unsigned i = 1; i <= writes; ++i) { + reset(); + Wire.dropWriteAt = i; + expectFailure("ACK but write ignored", i); + } + for (unsigned i = 1; i <= readRegisters.size(); ++i) { + for (const auto& counts : std::array, 4>{{ + {{0, 0}}, {{1, 0}}, {{0, 1}}, {{2, 2}}}}) { + reset(); + Wire.badReadAt = i; + Wire.readReported = counts[0]; + Wire.readDelivered = counts[1]; + expectFailure("short or inconsistent verification read", i); + check(Wire.available() == 0, "failed read buffer drained", i); + } + reset(); + Wire.corruptReadAt = i; + expectFailure("incorrect register readback / TF still set", i); + } + + reset(); + Wire.absent = true; + expectFailure("RTC absent"); + reset(); + Wire.reservedReadBits = true; + ++cases; + check(inhero::configurePeriodicWake(60), "reserved bits do not reject valid timer"); + + // Two successful calls must start two fresh countdowns, including when the + // first timer is still active. This also guards stop-before-rearm behavior. + ++cases; + check(inhero::configurePeriodicWake(60) && Wire.starts == 2, "rearm running timer"); + std::printf("PASS: %u RTC wake cases (durations, all %u transfers, %u queued bytes, " + "%u writes, %zu verification reads, absent RTC and rearm)\n", + cases, transactions, bytes, writes, readRegisters.size()); + return 0; +} diff --git a/test/mr2_rtc_wake/run_test.py b/test/mr2_rtc_wake/run_test.py new file mode 100644 index 0000000000..268e71e0f4 --- /dev/null +++ b/test/mr2_rtc_wake/run_test.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compile the complete production RTC wake helper with a host Wire model.""" + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=shutil.which("g++")) + args = parser.parse_args() + if not args.cxx: + parser.error("g++ not found; supply --cxx /path/to/g++") + + with tempfile.TemporaryDirectory(prefix="mr2-rtc-wake-") as temporary: + directory = Path(temporary) + helper = directory / "helpers" + helper.mkdir() + source = ROOT / "variants/inhero_mr2" + # Copy both files verbatim. Only hardware/framework dependencies are + # replaced; no production function extraction or rewriting is needed. + for name in ("Rv3028Wake.cpp", "Rv3028Wake.h"): + shutil.copyfile(source / "helpers" / name, helper / name) + board_header = (source / "InheroMr2Board.h").read_text(encoding="utf-8") + constants = re.findall( + r"^#define\s+(?:RTC_I2C_ADDR|RV3028_REG_\w+)\s+[^\n]+", + board_header, re.MULTILINE) + if len(constants) != 6: + raise ValueError("Unexpected RTC register declarations") + (directory / "InheroMr2Board.h").write_text( + "\n".join(constants) + "\n", encoding="utf-8") + executable = directory / "rtc_wake_test.exe" + subprocess.run([ + args.cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-I", str(HERE / "stubs"), "-I", str(directory), + "-x", "c++", str(HERE / "fixture.inc"), + str(helper / "Rv3028Wake.cpp"), "-o", str(executable), + ], check=True) + return subprocess.run([str(executable)]).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/mr2_rtc_wake/stubs/Arduino.h b/test/mr2_rtc_wake/stubs/Arduino.h new file mode 100644 index 0000000000..8b14fbed74 --- /dev/null +++ b/test/mr2_rtc_wake/stubs/Arduino.h @@ -0,0 +1,2 @@ +#pragma once +#include diff --git a/test/mr2_rtc_wake/stubs/MeshCore.h b/test/mr2_rtc_wake/stubs/MeshCore.h new file mode 100644 index 0000000000..7d2ad0911c --- /dev/null +++ b/test/mr2_rtc_wake/stubs/MeshCore.h @@ -0,0 +1,2 @@ +#pragma once +#define MESH_DEBUG_PRINTLN(...) diff --git a/test/mr2_rtc_wake/stubs/Wire.h b/test/mr2_rtc_wake/stubs/Wire.h new file mode 100644 index 0000000000..dc0c980d65 --- /dev/null +++ b/test/mr2_rtc_wake/stubs/Wire.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +struct FakeWire { + std::array registers{}; + std::vector tx, rx, readRegisters; + uint8_t pointer = 0; + unsigned transfers = 0, queuedBytes = 0, dataWrites = 0, reads = 0, starts = 0; + unsigned nackAt = 0, queueFailureAt = 0, dropWriteAt = 0, badReadAt = 0; + unsigned corruptReadAt = 0; + uint8_t readReported = 0, readDelivered = 0; + bool absent = false, reservedReadBits = false; + + void beginTransmission(uint8_t) { tx.clear(); } + size_t write(uint8_t value) { + if (++queuedBytes == queueFailureAt) return 0; + tx.push_back(value); + return 1; + } + uint8_t endTransmission(bool = true) { + ++transfers; + if (absent || transfers == nackAt) return 2; + if (tx.empty()) return 0; + pointer = tx[0]; + if (tx.size() > 1 && ++dataWrites == dropWriteAt) return 0; + for (size_t i = 1; i < tx.size(); ++i) { + if (pointer == 0x0F && !(registers[pointer] & 0x04) && (tx[i] & 0x04)) ++starts; + // RV-3028 status flags clear on zero writes; EEbusy is read-only. + if (pointer == 0x0E) registers[pointer] &= tx[i] | 0x80; + else registers[pointer] = tx[i]; + ++pointer; + } + return 0; + } + uint8_t requestFrom(uint8_t, uint8_t count) { + ++reads; + readRegisters.push_back(pointer); + rx.clear(); + if (absent) return 0; + if (reads == badReadAt) { + rx.assign(readDelivered, registers[pointer]); + return readReported; + } + for (uint8_t i = 0; i < count; ++i) { + const uint8_t address = pointer++; + uint8_t value = registers[address]; + if (reads == corruptReadAt) value ^= address == 0x0E ? 0x08 : 0x01; + if (reservedReadBits) { + if (address == 0x0B) value |= 0xF0; + if (address == 0x0F) value |= 0x40; + if (address == 0x0E) value |= 0x80; // EEbusy is unrelated to TF. + } + rx.push_back(value); + } + return count; + } + int available() const { return static_cast(rx.size()); } + int read() { + if (rx.empty()) return -1; + const uint8_t value = rx.front(); + rx.erase(rx.begin()); + return value; + } +}; + +extern FakeWire Wire; diff --git a/test/mr2_uvlo_flow/README.md b/test/mr2_uvlo_flow/README.md new file mode 100644 index 0000000000..096409b151 --- /dev/null +++ b/test/mr2_uvlo_flow/README.md @@ -0,0 +1,19 @@ +# MR2 UVLO control-flow regression + +Run with Python 3 and a C++17 compiler: + +```powershell +python test/mr2_uvlo_flow/run.py --cxx C:/Tools/mingw64/bin/g++.exe +``` + +The runner extracts the production board boot, shutdown, RTC wake wrapper, board +loop and periodic dispatch functions, then compiles them against host hardware +fakes. It exercises validation failure, an asserted RTC INT pin, bounded recovery, +successful sleep, both early boot paths, continued watchdog/periodic work after a +failed shutdown, retry throttling, recovered or invalid voltage, sticky INA ALERT +and the 32-bit millisecond rollover. No production decision logic is copied into +the fixture. Temporary generated sources/binaries live outside the repository. + +These checks cover board sequencing and decisions; the RTC configuration helper +is a fake at this layer. They do not verify register writes, electrical wake-up, +I2C timing, ADC averaging or battery behavior on physical hardware. diff --git a/test/mr2_uvlo_flow/fixture.inc b/test/mr2_uvlo_flow/fixture.inc new file mode 100644 index 0000000000..b1937ef40a --- /dev/null +++ b/test/mr2_uvlo_flow/fixture.inc @@ -0,0 +1,302 @@ +// Production function bodies are extracted by run.py. This fixture supplies +// only hardware/dependency fakes; the sleep and retry decisions are production. +#include +#include +#include +#include +#include +#include +#include +#include + +struct EnteredSystemOff {}; +struct Model { + uint32_t now = 0; + uint16_t voltage = 2400; + std::vector rtc_results{false, false}; + std::vector rtc_levels{1, 1}; + int rtc_calls = 0, recoveries = 0, rtc_level = 1, ina_level = 1; + int initialized = 0, wdt_started = 0, wdt_fed = 0; + int stopped = 0, radio_off = 0, ics_off = 0, ina_off = 0; + int mppt = 0, soc = 0, hourly = 0, wire_ends = 0, wire_begins = 0; + int flag_clears = 0, voltage_reads = 0; + bool sticky_ina = false; + std::vector ticks; + std::vector events; +} model; + +enum { LOW = 0, HIGH = 1, INPUT = 0, OUTPUT = 1, INPUT_PULLUP = 2, FALLING = 3 }; +#define RTC_INT_PIN 17 +#define INA_ALERT_PIN 34 +#define BQ_CE_PIN 4 +#define PIN_BOARD_SDA 13 +#define PIN_BOARD_SCL 14 +#define SX126X_POWER_EN 37 +#define PIN_VBAT_READ 5 +#define LED_BLUE 11 +#define PIN_LED1 11 +#define PIN_LED2 12 +#define RTC_I2C_ADDR 0x52 +#define INA228_I2C_ADDR 0x40 +#define BQ25798_I2C_ADDR 0x6B +#define BME280_I2C_ADDR 0x76 +#define RV3028_REG_STATUS 0x0E +#define LOW_VOLTAGE_SLEEP_MINUTES 60 +#define SHUTDOWN_REASON_NONE 0 +#define SHUTDOWN_REASON_LOW_VOLTAGE 1 +#define GPREGRET2_LOW_VOLTAGE_SLEEP 4 +#define SOLAR_MPPT_INTERVAL_MS 60000UL +enum { GPIO_PIN_CNF_DIR_Input = 0, GPIO_PIN_CNF_DIR_Pos = 0, + GPIO_PIN_CNF_INPUT_Connect = 0, GPIO_PIN_CNF_INPUT_Pos = 0, + GPIO_PIN_CNF_PULL_Pullup = 0, GPIO_PIN_CNF_PULL_Pos = 0, + GPIO_PIN_CNF_DRIVE_S0S1 = 0, GPIO_PIN_CNF_DRIVE_Pos = 0, + GPIO_PIN_CNF_SENSE_Low = 0, GPIO_PIN_CNF_SENSE_Pos = 0 }; +template void debug(const char*, Args...) {} +#define MESH_DEBUG_PRINTLN(...) debug(__VA_ARGS__) + +struct Power { uint8_t GPREGRET2 = 0; uint32_t SYSTEMOFF = 0; } power; +struct Gpio { uint32_t PIN_CNF[64]{}; uint32_t LATCH = 0; } gpio; +auto* NRF_POWER = &power; +auto* NRF_GPIO = &gpio; +auto* NRF_P0 = &gpio; +uint32_t millis() { return model.now; } +void delay(uint32_t ms) { model.now += ms; } +void pinMode(int, int) {} +void digitalWrite(int, int) {} +int digitalRead(int pin) { return pin == RTC_INT_PIN ? model.rtc_level : model.ina_level; } +int digitalPinToInterrupt(int pin) { return pin; } +void attachInterrupt(int, void(*)(), int) {} +void blinkRed(int, int, int, bool) {} +void sd_power_system_off() { model.events.push_back("systemoff"); throw EnteredSystemOff{}; } +void __WFE() { throw std::runtime_error("unexpected fallback WFE"); } + +struct FakeWire { + int available_bytes = 0; + void setPins(int, int) {} + void begin() { ++model.wire_begins; model.events.push_back("wire.begin"); } + void end() { ++model.wire_ends; model.events.push_back("wire.end"); } + void beginTransmission(int) {} + void write(int) {} + int endTransmission(bool = true) { return 0; } + int requestFrom(int, int count) { available_bytes = count; return count; } + int available() { return available_bytes; } + int read() { --available_bytes; return 0; } +} Wire; + +struct Ina228Driver { + static uint16_t readVBATDirect(FakeWire*, uint8_t) { return model.voltage; } + uint16_t readVoltage_mV() { ++model.voltage_reads; return model.voltage; } + void enableAlert(bool, bool, bool, bool = false) {} + bool setUnderVoltageAlert(int) { return true; } + void shutdown() { ++model.ina_off; model.events.push_back("ina.off"); } + void clearAlert() { + ++model.flag_clears; + if (!model.sticky_ina) model.ina_level = HIGH; + } +} ina; + +struct BoardConfigContainer { + enum BatteryType { BAT_UNKNOWN, BAT_NA_ION_1S }; + struct BatteryProperties { bool charge_enable; }; + static const BatteryProperties* getBatteryProperties(BatteryType) { + static BatteryProperties props{true}; return &props; + } + static inline Ina228Driver* ina228DriverInstance = &ina; + static inline bool lowVoltageAlertFired = false; + static inline uint16_t lowVoltageSleepMv = 2500; + static inline bool leds_enabled = false; + static inline struct { int unused; } mpptStats{}; + uint32_t lastMpptMs = 0, lastSocMs = 0, lastLowVoltageMs = 0, lastHourlyMs = 0; + bool tickInitialized = false, lowVoltageSleepRetryPending = false; + uint32_t lastLowVoltageSleepAttemptMs = 0; + BatteryType getBatteryType() { return BAT_NA_ION_1S; } + bool getMPPTEnabled() { return true; } + Ina228Driver* getIna228Driver() { return &ina; } + void begin() { ++model.initialized; model.events.push_back("board.begin"); } + bool getStationAltitude(float&) { return false; } + void setLowVoltageRecovery() {} + static bool setSOCManually(float) { return true; } + static void setupWatchdog() { ++model.wdt_started; } + static void feedWatchdog() { ++model.wdt_fed; } + static void setUsbConnected(bool) {} + static void stopBackgroundTasks() { ++model.stopped; model.events.push_back("tasks.stop"); } + void tickPeriodic(); + void runMpptCycle() { ++model.mppt; } + void updateBatterySOC() { ++model.soc; } + void updateHourlyStats() { ++model.hourly; } +}; +struct NRF52BoardDCDC { void begin() {} }; +struct InheroMr2Board : NRF52BoardDCDC { + static inline volatile bool rtc_irq_pending = false; + static inline volatile uint32_t ota_dfu_reset_at = 0; + void begin(); + void loop(); + void initiateShutdown(uint8_t); + bool configureRTCWake(uint32_t); + uint16_t getLowVoltageWakeThreshold() { return 2700; } + uint16_t getLowVoltageSleepThreshold() { return 2500; } + static void rtcInterruptHandler() { rtc_irq_pending = true; } + void enterOTADfu() { throw std::runtime_error("unexpected DFU"); } + void sleep(uint32_t) {} +} board; +static BoardConfigContainer boardConfig; +struct Sensors { + void setBme280StationAltitude(float) {} + void setBoardTelemetrySource(BoardConfigContainer&) {} +} sensors; + +namespace inhero { +void clearTimerFlag() {} +void recoverI2cBus(int, int) { ++model.recoveries; model.events.push_back("recover"); } +bool configurePeriodicWake(uint16_t ticks) { + auto index = static_cast(model.rtc_calls++); + model.ticks.push_back(ticks); + model.events.push_back("rtc.configure"); + model.rtc_level = model.rtc_levels.at(std::min(index, model.rtc_levels.size() - 1)); + return model.rtc_results.at(std::min(index, model.rtc_results.size() - 1)); +} +void maintainSolarDuringLowVoltageWake(bool) {} +void prepareIcsForSystemOff() { ++model.ics_off; model.events.push_back("ics.off"); } +void prepareRadioForSystemOff(bool = true) { ++model.radio_off; model.events.push_back("radio.off"); } +void disconnectLeakyPullups() {} +bool isUsbPowered() { return false; } +void serviceUsbAutoManagement() {} +} + +#include "production.inc" + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +void reset() { + model = Model{}; + power = Power{}; + gpio = Gpio{}; + boardConfig = BoardConfigContainer{}; + BoardConfigContainer::lowVoltageAlertFired = false; + BoardConfigContainer::lowVoltageSleepMv = 2500; + InheroMr2Board::rtc_irq_pending = false; + InheroMr2Board::ota_dfu_reset_at = 0; +} +template bool slept(Operation operation) { + try { operation(); } catch (const EnteredSystemOff&) { return true; } + return false; +} +void no_shutdown_effects() { + require(model.stopped == 0 && model.radio_off == 0 && model.ics_off == 0 && model.ina_off == 0, + "failed wake validation must not shut down tasks, radio or INA"); + require(power.GPREGRET2 == 0, "failed wake validation must not leave a shutdown marker"); +} + +int main() { + try { + reset(); + require(!slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "failed RTC must not sleep"); + no_shutdown_effects(); + require(model.rtc_calls == 2 && model.recoveries == 1, "RTC setup must have one bounded recovery retry"); + + reset(); model.rtc_results = {true, true}; model.rtc_levels = {LOW, LOW}; + require(!slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "asserted RTC INT must not sleep"); + no_shutdown_effects(); + require(model.rtc_calls == 2 && model.recoveries == 1, "stuck INT should get one bounded recovery retry"); + + reset(); model.rtc_results = {true, true}; model.rtc_levels = {LOW, HIGH}; + require(slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "released RTC INT on retry must allow sleep"); + require(model.rtc_calls == 2 && model.recoveries == 1, "recovered INT needs exactly one recovery retry"); + + reset(); model.rtc_results = {false, true}; + require(slept([] { board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); }), "recovered RTC must allow sleep"); + require(model.rtc_calls == 2 && model.recoveries == 1 && model.stopped == 1, + "validated retry must proceed to one shutdown"); + require(power.GPREGRET2 == (GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE), + "successful sleep must retain UVLO reason"); + auto validated = std::find(model.events.begin(), model.events.end(), "tasks.stop"); + require(std::count(model.events.begin(), validated, "rtc.configure") == 2, + "RTC validation must precede destructive shutdown work"); + + reset(); model.rtc_results = {true}; + require(board.configureRTCWake(0), "default interval must validate"); + require(model.ticks.back() == LOW_VOLTAGE_SLEEP_MINUTES, "zero uses configured sleep interval"); + require(board.configureRTCWake(9000) && model.ticks.back() == 4095, "interval must clamp to RTC width"); + require(model.recoveries == 0, "healthy wake configuration needs no bus recovery"); + + for (bool wake : {false, true}) { + reset(); + if (wake) power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(!slept([] { board.begin(); }), "failed early RTC must continue normal boot"); + no_shutdown_effects(); + require(model.initialized == 1 && model.wdt_started == 1, "failed early sleep must reach board init and watchdog"); + require(model.rtc_calls == 2, "early wake failure must not retry through a second boot path"); + + reset(); model.rtc_results = {true}; + if (wake) power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(slept([] { board.begin(); }), "validated early low voltage must sleep"); + require(model.initialized == 0 && model.wdt_started == 0, "successful early sleep must keep fast path"); + require(model.rtc_calls == 1, "healthy early path needs exactly one RTC validation"); + } + + reset(); model.voltage = 0; + power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(!slept([] { board.begin(); }), "unreadable VBAT plus RTC failure must not trap fast wake in sleep"); + no_shutdown_effects(); + require(model.initialized == 1 && model.wdt_started == 1 && model.rtc_calls == 2, + "unreadable fast-wake voltage must reach board init and WDT after bounded RTC failure"); + + reset(); model.voltage = 3250; + power.GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + require(!slept([] { board.begin(); }), "recovered voltage must boot normally"); + require(model.initialized == 1 && model.wdt_started == 1 && model.rtc_calls == 0, + "voltage recovery must not depend on RTC wake availability"); + + reset(); model.now = 3600000; + BoardConfigContainer::lowVoltageAlertFired = true; + board.loop(); + no_shutdown_effects(); + require(boardConfig.lowVoltageSleepRetryPending, "failed runtime sleep must enter retry state"); + require(model.wdt_fed == 1 && model.mppt == 1 && model.soc == 1 && model.hourly == 1, + "failed shutdown must preserve periodic work and watchdog feeds"); + auto retry_at = boardConfig.lastLowVoltageSleepAttemptMs; + model.now = retry_at + 59999; + BoardConfigContainer::lowVoltageAlertFired = true; + model.ina_level = LOW; + board.loop(); + require(model.rtc_calls == 2 && model.wdt_fed == 2, "ISR and asserted ALERT must respect 60s cooldown"); + model.now = retry_at + 60000; + model.rtc_results = {true}; + require(slept([] { board.loop(); }), "valid persistent low voltage retries after cooldown"); + + for (uint16_t voltage : {uint16_t(0), uint16_t(3250)}) { + reset(); BoardConfigContainer::lowVoltageAlertFired = true; board.loop(); + model.voltage = voltage; + model.rtc_results = {true}; + model.ina_level = LOW; + BoardConfigContainer::lowVoltageAlertFired = true; + model.now = boardConfig.lastLowVoltageSleepAttemptMs + 60000; + require(!slept([] { board.loop(); }), "invalid/healed voltage must not retry stale alert"); + no_shutdown_effects(); + require(model.rtc_calls == 2, "invalid/healed voltage must not reconfigure RTC"); + require(boardConfig.lowVoltageSleepRetryPending == (voltage == 0), "valid healed voltage releases retry state"); + } + + reset(); BoardConfigContainer::lowVoltageAlertFired = true; board.loop(); + model.voltage = 3250; model.sticky_ina = true; model.ina_level = LOW; + model.rtc_results = {true}; + model.now = boardConfig.lastLowVoltageSleepAttemptMs + 60000; + require(!slept([] { board.loop(); }), "healthy voltage with stuck ALERT must remain awake"); + require(boardConfig.lowVoltageSleepRetryPending, "stuck ALERT must remain guarded against stale retries"); + model.now += 1; board.loop(); + require(model.rtc_calls == 2, "stuck pin must not retrigger shutdown on next loop"); + + reset(); model.now = UINT32_MAX - 30000; + BoardConfigContainer::lowVoltageAlertFired = true; board.loop(); + model.now = boardConfig.lastLowVoltageSleepAttemptMs + uint32_t(60000); + model.rtc_results = {true}; + require(slept([] { board.loop(); }), "retry cooldown must survive millis wraparound"); + + std::cout << "PASS MR2 UVLO flow: RTC/INT failures, bounded recovery, boot/runtime gates, cooldown, voltage recovery, wraparound\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } +} diff --git a/test/mr2_uvlo_flow/run.py b/test/mr2_uvlo_flow/run.py new file mode 100644 index 0000000000..424ed0d031 --- /dev/null +++ b/test/mr2_uvlo_flow/run.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Exercise the production MR2 UVLO control flow with host hardware fakes.""" + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def function(text, signature): + start = text.index(signature) + opening = text.index("{", start) + depth = 0 + for token in re.finditer( + r'//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', + text[opening:], re.DOTALL, + ): + if token.group() == "{": + depth += 1 + elif token.group() == "}": + depth -= 1 + if depth == 0: + return text[start:opening + token.end()] + raise ValueError(f"Unbalanced function: {signature}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=shutil.which("g++")) + args = parser.parse_args() + if not args.cxx: + parser.error("g++ not found; supply --cxx /path/to/g++") + board = (ROOT / "variants/inhero_mr2/InheroMr2Board.cpp").read_text(encoding="utf-8") + config = (ROOT / "variants/inhero_mr2/BoardConfigContainer.cpp").read_text(encoding="utf-8") + production = "\n\n".join([ + *(function(board, signature) for signature in ( + "static bool restoreConfiguredChargeEnable()", + "void InheroMr2Board::begin()", + "void InheroMr2Board::loop()", + "void InheroMr2Board::initiateShutdown(uint8_t reason)", + "bool InheroMr2Board::configureRTCWake(uint32_t minutes)", + )), + function(config, "void BoardConfigContainer::tickPeriodic()"), + ]) + with tempfile.TemporaryDirectory(prefix="mr2-uvlo-flow-") as temporary: + directory = Path(temporary) + (directory / "production.inc").write_text(production, encoding="utf-8") + executable = directory / "flow.exe" + subprocess.run([ + args.cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-x", "c++", str(HERE / "fixture.inc"), "-I", str(directory), + "-o", str(executable), + ], check=True) + return subprocess.run([str(executable)]).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/mr2_vsysmin/README.md b/test/mr2_vsysmin/README.md new file mode 100644 index 0000000000..f3c96c60f7 --- /dev/null +++ b/test/mr2_vsysmin/README.md @@ -0,0 +1,29 @@ +# MR2 minimum-system-voltage regression + +Run with Python 3 and a C++17 compiler: + +```powershell +.venv/Scripts/python.exe test/mr2_vsysmin/run.py --cxx C:/Tools/mingw64/bin/g++.exe --check-mutations +``` + +The runner extracts the production `configureBaseBQ()`, `configureChemistry()` and +`getBatteryProperties()` implementations and the real battery property table and +constants. It compiles them against a BQ register-state model and executes ten +scenarios. Generated sources and binaries use a temporary directory. The fixture +has an `.inc` extension so PlatformIO does not pick it up as an Arduino/native test +translation unit. + +The model implements successful CELL writes resetting VSYSMIN, VREG and ICHG; +MPPT rejection below VSYSMIN or without a source; and the final BATFET linear-mode +condition. Checks cover Na-ion at 2.63 V, LTO 2S at 4.9 V, repeated chemistry changes, +MPPT disabled, unknown chemistry, voltage below 2.5 V, an absent source, an +uninitialized driver, LiFePO4 and Li-ion. They also check CE, register restoration +order, preservation of the MPPT preference and the existing 60 C thermal setting. + +`--check-mutations` proves the suite rejects the old 2.75 V setting, removal of the +post-CELL VSYSMIN restoration, and removal of the final MPPT enable request. + +These are host sequencing tests, not electrical measurements. The model does not +exercise the I2C bus or driver register encoding, simulate I2C write failures, prove +transient thermal behavior, or simulate MPPT tracking under clouds. Flashing and +reading hardware telemetry is still necessary to confirm the observed behavior. diff --git a/test/mr2_vsysmin/fixture.inc b/test/mr2_vsysmin/fixture.inc new file mode 100644 index 0000000000..58c98647ae --- /dev/null +++ b/test/mr2_vsysmin/fixture.inc @@ -0,0 +1,243 @@ +// Host-only state model. Production configuration functions/declarations are +// extracted at test runtime, not copied into this fixture. This models successful +// register writes and documented CELL resets; it does not simulate I2C failures, +// real MPPT tracking, ADC measurements, temperature or electrical transients. +#include +#include +#include +#include +#include +#include + +enum bq25798_cell_count_t { BQ25798_CELL_COUNT_1S, BQ25798_CELL_COUNT_2S }; +enum { BQ25798_TREG_60C = 60, BQ25798_WDT_DISABLE, BQ25798_VOC_DLY_2S, + BQ25798_VOC_RATE_2MIN, BQ25798_VOC_PCT_81_25, BQ25798_TS_COOL_5C, + BQ25798_TS_WARM_55C, BQ25798_JEITA_VSET_UNCHANGED }; +#define MESH_DEBUG_PRINTLN(...) do {} while (false) +#define BQ_CE_PIN 42 +enum { OUTPUT, LOW, HIGH }; +static int ce_level = LOW; +static void pinMode(int, int) {} +static void digitalWrite(int, int level) { ce_level = level; } + +struct FakeBQ { + struct Event { + std::string name; + double value; + }; + std::vector events; + double battery = 2.63; + bool source_present = true; + double sysmin = 3.5; + double vreg = 4.2; + double ichg = 1.0; + int precharge_ma = 120; + int cells = 1; + int treg = 120; + bool mppt = false; + bool charging = false; + + void record(const char* name, double value) { events.push_back({name, value}); } + bool linear() const { return source_present && charging && battery < sysmin; } + void setCellCount(bq25798_cell_count_t count) { + cells = count == BQ25798_CELL_COUNT_2S ? 2 : 1; + // CELL writes restore defaults even when the cell count is unchanged. + sysmin = cells == 2 ? 7.0 : 3.5; + vreg = cells == 2 ? 8.4 : 4.2; + ichg = 1.0; + if (battery < sysmin) mppt = false; + record("CELL", cells); + } + void setMinSystemV(float voltage) { + // REG00 starts at 2.5 V in 250 mV steps. + if (voltage < 2.5 || std::abs((voltage - 2.5) / 0.25 - + std::round((voltage - 2.5) / 0.25)) > 0.001) + throw std::runtime_error("Invalid VSYSMIN register value"); + sysmin = voltage; + if (battery < sysmin) mppt = false; + record("VSYSMIN", voltage); + } + void setChargeLimitV(float voltage) { vreg = voltage; record("VREG", voltage); } + void setChargeLimitA(float amps) { ichg = amps; record("ICHG", amps); } + bool setPrechargeLimitmA(uint16_t ma) { + precharge_ma = static_cast((ma / 40) * 40); + record("IPRECHG", precharge_ma); + return true; + } + void setChargeEnable(bool enabled) { charging = enabled; record("CHARGE", enabled); } + void setMPPTenable(bool enabled) { + mppt = enabled && source_present && battery >= sysmin; + record("MPPT", enabled); + } + void setThermRegulationThresh(int threshold) { treg = threshold; } + void setRechargeThreshOffsetV(float) {} + void setPrechargeTimerEnable(bool) {} + void setFastChargeTimerEnable(bool) {} + void setTsIgnore(bool) {} + void setWDT(int) {} + void setExtILIMpin(bool) {} + void setInputLimitA(float) {} + void setICOEnable(bool) {} + void setVOCdelay(int) {} + void setVOCrate(int) {} + void setVOCpercent(int) {} + void setAutoDPinsDetection(bool) {} + void setStatPinEnable(bool) {} + void setTsCool(int) {} + void setTsWarm(int) {} + void setJeitaVSet(int) {} + void setAutoIBATDIS(bool) {} + void getTelemetryData(int) {} +}; + +class BoardConfigContainer { + public: +#include "production_declarations.inc" + FakeBQ bq; + bool bqInitialized = true; + bool leds_enabled = false; + bool mppt_wish = true; + uint16_t imax_mA = 200; + bool configureBaseBQ(); + bool configureChemistry(BatteryType type); + static const BatteryProperties* getBatteryProperties(BatteryType type); + bool getMPPTEnabled() const { return mppt_wish; } + uint16_t getMaxChargeCurrent_mA() const { return imax_mA; } + void applyJeitaIgnore(const BatteryProperties*) { bq.record("JEITA", bq.ichg); } +}; + +#include "production_functions.inc" + +static void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +static void near(double actual, double expected, const char* message) { + require(std::abs(actual - expected) < 0.0001, message); +} +static void configure(BoardConfigContainer& board, BoardConfigContainer::BatteryType type) { + // begin() and setBatteryType() both use this production sequence. + require(board.configureBaseBQ(), "base configuration failed"); + require(!board.bq.mppt, "base must leave MPPT disabled before CELL programming"); + require(board.configureChemistry(type), "chemistry configuration failed"); +} +static void check_final(const BoardConfigContainer& board, int cells, double vreg, double ichg) { + const auto& bq = board.bq; + near(bq.sysmin, 2.5, "VSYSMIN must be 2.5 V after CELL reset"); + near(bq.vreg, vreg, "chemistry VREG must be restored after CELL reset"); + near(bq.ichg, ichg, "configured ICHG must be restored after CELL reset"); + require(bq.precharge_ma == static_cast(ichg * 1000 + 0.5) / 40 * 40, + "IPRECHG must follow ICHG in 40 mA steps"); + require(bq.cells == cells, "incorrect cell count"); + require(bq.treg == 60, "60 C thermal regulation must remain configured"); + require(bq.charging && ce_level == HIGH, "known chemistry should allow charging"); + require(!bq.linear(), "final configuration must leave BATFET outside linear region"); + int cell = -1, minimum = -1, current = -1, precharge = -1, jeita = -1, mppt = -1; + for (size_t i = 0; i < bq.events.size(); ++i) { + const auto& event = bq.events[i]; + if (event.name == "CELL") cell = static_cast(i); + if (event.name == "VSYSMIN") minimum = static_cast(i); + if (event.name == "ICHG") current = static_cast(i); + if (event.name == "IPRECHG") precharge = static_cast(i); + if (event.name == "JEITA") jeita = static_cast(i); + if (event.name == "MPPT" && event.value) mppt = static_cast(i); + } + require(cell >= 0 && minimum > cell, "VSYSMIN must be written after last CELL write"); + require(current > cell && jeita > current, "ICHG must be restored before JEITA override"); + require(precharge > current && jeita > precharge, + "IPRECHG must be restored after ICHG and before JEITA override"); + if (board.mppt_wish) { + require(mppt > minimum && mppt > current, "MPPT must be requested after final register restoration"); + require(bq.mppt, "MPPT should be accepted with source and VBAT above VSYSMIN"); + } else { + require(mppt == -1 && !bq.mppt, "disabled MPPT preference must never be enabled"); + } +} + +int main() { + unsigned passed = 0; + auto test = [&passed](const char* name, auto run) { + run(); + ++passed; + std::cout << "PASS " << name << '\n'; + }; + try { + test("Na-ion at 2.63 V restores 2.50 V and immediately enables requested MPPT", [] { + BoardConfigContainer board; + configure(board, BoardConfigContainer::NAION_1S); + check_final(board, 1, 3.9, 0.2); + }); + test("LTO 2S at 4.90 V restores 2.50 V / 5.40 V / configured 1.50 A", [] { + BoardConfigContainer board; + board.bq.battery = 4.9; + board.imax_mA = 1500; + configure(board, BoardConfigContainer::LTO_2S); + check_final(board, 2, 5.4, 1.5); + }); + test("repeated chemistry changes restore all settings after every CELL write", [] { + BoardConfigContainer board; + for (int cycle = 0; cycle < 3; ++cycle) { + board.bq.battery = 4.9; + board.imax_mA = 1500; + configure(board, BoardConfigContainer::LTO_2S); + check_final(board, 2, 5.4, 1.5); + board.bq.battery = 2.63; + board.imax_mA = 200; + configure(board, BoardConfigContainer::NAION_1S); + check_final(board, 1, 3.9, 0.2); + } + }); + test("MPPT off preference never requests enable", [] { + BoardConfigContainer board; + board.mppt_wish = false; + configure(board, BoardConfigContainer::NAION_1S); + check_final(board, 1, 3.9, 0.2); + }); + test("unknown battery disables charging, CE and previously active MPPT", [] { + BoardConfigContainer board; + configure(board, BoardConfigContainer::NAION_1S); + require(board.bq.mppt, "precondition: MPPT active"); + configure(board, BoardConfigContainer::BAT_UNKNOWN); + require(!board.bq.charging && ce_level == LOW, "unknown battery must prohibit charging"); + require(!board.bq.mppt, "unknown chemistry must not re-enable MPPT"); + require(board.mppt_wish, "configuration must preserve the stored MPPT preference"); + }); + test("below 2.50 V hardware rejects MPPT without erasing saved preference", [] { + BoardConfigContainer board; + board.bq.battery = 2.4; + configure(board, BoardConfigContainer::NAION_1S); + near(board.bq.sysmin, 2.5, "minimum register value must remain 2.5 V"); + require(!board.bq.mppt && board.mppt_wish, "MPPT must be rejected but its preference retained"); + require(board.bq.linear(), "below 2.5 V the model must still report linear operation"); + }); + test("absent source rejects MPPT while preserving saved preference", [] { + BoardConfigContainer board; + board.bq.source_present = false; + configure(board, BoardConfigContainer::NAION_1S); + require(!board.bq.mppt && board.mppt_wish, "no source: hardware MPPT off, preference on"); + }); + test("uninitialized BQ is not written", [] { + BoardConfigContainer board; + board.bqInitialized = false; + require(!board.configureBaseBQ(), "uninitialized base should fail"); + require(!board.configureChemistry(BoardConfigContainer::NAION_1S), "uninitialized chemistry should fail"); + require(board.bq.events.empty(), "uninitialized BQ must not receive writes"); + }); + test("LiFePO4 chemistry retains 3.50 V charge voltage", [] { + BoardConfigContainer board; + board.bq.battery = 3.0; + configure(board, BoardConfigContainer::LIFEPO4_1S); + check_final(board, 1, 3.5, 0.2); + }); + test("Li-ion chemistry retains 4.10 V charge voltage", [] { + BoardConfigContainer board; + board.bq.battery = 3.7; + configure(board, BoardConfigContainer::LIION_1S); + check_final(board, 1, 4.1, 0.2); + }); + } catch (const std::exception& error) { + std::cerr << "FAIL after " << passed << " passing cases: " << error.what() << '\n'; + return 1; + } + std::cout << passed << " cases passed (production configuration, simulated BQ register behavior)\n"; + return 0; +} diff --git a/test/mr2_vsysmin/run.py b/test/mr2_vsysmin/run.py new file mode 100644 index 0000000000..fd2cecdbed --- /dev/null +++ b/test/mr2_vsysmin/run.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Compile production MR2 charger configuration against a host BQ state model. + +No PlatformIO/Arduino dependencies. Generated files live in a temporary directory; +the .inc fixture is deliberately excluded from PlatformIO's normal C++ test scan. +""" + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def match_one(pattern, text): + matches = re.findall(pattern, text, re.DOTALL) + if len(matches) != 1: + raise ValueError(f"Expected one production declaration: {pattern}") + return matches[0] + + +def function_body(text, signature): + """Extract a complete function, ignoring braces in comments and literals.""" + start = text.index(signature) + opening = text.index("{", start) + # Tokenization makes the bracket counter insensitive to comment/string edits. + tokens = re.finditer( + r'//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', + text[opening:], re.DOTALL, + ) + depth = 0 + for token in tokens: + if token.group() == "{": + depth += 1 + elif token.group() == "}": + depth -= 1 + if depth == 0: + return text[start:opening + token.end()] + raise ValueError(f"Unbalanced production function: {signature}") + + +def extract_production(): + header = (ROOT / "variants/inhero_mr2/BoardConfigContainer.h").read_text(encoding="utf-8") + source = (ROOT / "variants/inhero_mr2/BoardConfigContainer.cpp").read_text(encoding="utf-8") + declarations = [ + match_one(r"enum BatteryType\s*:\s*uint8_t\s*\{[^}]*\};", header), + match_one(r"// Battery type properties\s*(typedef struct\s*\{.*?\}\s*BatteryProperties;)", header), + match_one(r"static inline constexpr BatteryProperties battery_properties\[\]\s*=\s*\{.*?\n\s*\};", header), + match_one(r"static constexpr float BQ_MIN_SYSTEM_V\s*=\s*[^;]+;", header), + match_one(r"static constexpr float IINDPM_MAX_A\s*=\s*[^;]+;", header), + ] + functions = [function_body(source, signature) for signature in ( + "bool BoardConfigContainer::configureBaseBQ()", + "bool BoardConfigContainer::configureChemistry(BatteryType type)", + "const BoardConfigContainer::BatteryProperties* BoardConfigContainer::getBatteryProperties(BatteryType type)", + )] + return "\n".join(declarations), "\n\n".join(functions) + + +def compile_and_run(compiler, directory, declarations, functions, name): + (directory / "production_declarations.inc").write_text(declarations, encoding="utf-8") + (directory / "production_functions.inc").write_text(functions, encoding="utf-8") + executable = directory / (name + ".exe") + subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-x", "c++", str(HERE / "fixture.inc"), "-I", str(directory), "-o", str(executable), + ], check=True) + return subprocess.run([str(executable)], capture_output=True, text=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default=shutil.which("g++")) + parser.add_argument("--check-mutations", action="store_true", + help="Also prove tests reject three original/regression failure modes") + args = parser.parse_args() + if not args.cxx: + parser.error("g++ not found; supply --cxx /path/to/g++") + declarations, functions = extract_production() + with tempfile.TemporaryDirectory(prefix="mr2-vsysmin-") as temporary: + directory = Path(temporary) + result = compile_and_run(args.cxx, directory, declarations, functions, "regression") + print(result.stdout, end="") + print(result.stderr, end="") + if result.returncode: + return result.returncode + if args.check_mutations: + mutations = ( + ("old-2.75V", declarations.replace("BQ_MIN_SYSTEM_V = 2.50f", "BQ_MIN_SYSTEM_V = 2.75f"), functions), + ("missing-post-CELL-restore", declarations, + functions.replace("bq.setMinSystemV(BQ_MIN_SYSTEM_V);\n bq.setChargeLimitA", + "bq.setChargeLimitA")), + ("missing-final-MPPT-enable", declarations, + functions.replace("bq.setMPPTenable(getMPPTEnabled());", "")), + ) + for name, mutant_declarations, mutant_functions in mutations: + if (mutant_declarations, mutant_functions) == (declarations, functions): + raise ValueError(f"Mutation {name} no longer changes production code") + mutant = compile_and_run(args.cxx, directory, mutant_declarations, mutant_functions, name) + if mutant.returncode == 0: + raise RuntimeError(f"Regression tests failed to detect {name}") + print(f"PASS mutation rejected: {name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp new file mode 100644 index 0000000000..6b28da2d23 --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -0,0 +1,2500 @@ +/* + * 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) + +#include + +// 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; +uint16_t BoardConfigContainer::lowVoltageSleepMv = 0; +MpptStatistics BoardConfigContainer::mpptStats = {}; +BatterySOCStats BoardConfigContainer::socStats = {}; +BoardConfigContainer::BatteryType BoardConfigContainer::cachedBatteryType = BAT_UNKNOWN; +bool BoardConfigContainer::leds_enabled = true; // Default: enabled +bool BoardConfigContainer::usbInputActive = false; // Default: no USB connected +float BoardConfigContainer::tcCalOffset = 0.0f; // Default: no temperature calibration offset +bool BoardConfigContainer::jeitaIgnoreActive = false; // Derived on chemistry apply, never persisted +float BoardConfigContainer::lastValidBatteryTemp = 25.0f; // 25°C = no derating until first valid reading +uint32_t BoardConfigContainer::lastTempUpdateMs = 0; // 0 = never updated + +// Battery voltage thresholds live in the BatteryProperties table (see .h file) +// Rev 1.1: INA228 ALERT pin (P1.02) triggers low-voltage sleep via ISR → volatile flag → tickPeriodic(). +// No hardware UVLO (TPS EN tied to VDD). Low-voltage handling is always active when battery configured. + +// PG-Stuck recovery: timestamp of last HIZ toggle (0 = never) +static uint32_t lastPgStuckToggleTime = 0; +static uint32_t lastPgStuckAttemptTime = 0; +static bool pgStuckRecoveryAttempted = false; +#define PG_STUCK_COOLDOWN_MS (5 * 60 * 1000) // 5 minutes between toggles + +void BoardConfigContainer::setupWatchdog() { inhero::setupWatchdog(leds_enabled); } +void BoardConfigContainer::feedWatchdog() { inhero::feedWatchdog(); } +void BoardConfigContainer::disableWatchdog() { inhero::disableWatchdog(); } + +// Retry source qualification at !PG without relying on ADC availability. +// With PG set, restore configured MPPT if the charger disabled it. +void BoardConfigContainer::checkAndFixSolarLogic() { + if (!bqDriverInstance) return; + + // 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; + if (bqDriverInstance->readReg(0x15, mpptVal) && (mpptVal & 0x01) && + bqDriverInstance->writeReg(0x15, mpptVal & ~0x01)) { + MESH_DEBUG_PRINTLN("MPPT disabled via config"); + } + return; + } + + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + if (!props || !props->charge_enable) return; + + // A missing/unpowered BQ must not be mistaken for PG=0. + uint8_t status; + if (!bqDriverInstance->readReg(0x1B, status)) return; + bool powerGood = (status & 0x08) != 0; + + if (!powerGood) { + // PG-Stuck recovery: Panel may be connected but BQ didn't qualify it. + // 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 (pgStuckRecoveryAttempted && (now - lastPgStuckAttemptTime) < PG_STUCK_COOLDOWN_MS) { + return; + } + lastPgStuckAttemptTime = now; + pgStuckRecoveryAttempted = true; + + // Low VBAT + HIZ can leave ADC unavailable. Let the BQ qualify the + // source itself, including at night or with a weak panel. + uint8_t control; + if (!bqDriverInstance->readReg(BQ25798_REG_CHARGER_CONTROL_0, control)) return; + if (!bqDriverInstance->writeReg(BQ25798_REG_CHARGER_CONTROL_0, control | 0x04)) return; + delay(50); + for (int retry = 0; retry < 3; ++retry) { + // Re-read to preserve any control bits changed by the charger. + if (bqDriverInstance->readReg(BQ25798_REG_CHARGER_CONTROL_0, control) && + bqDriverInstance->writeReg(BQ25798_REG_CHARGER_CONTROL_0, control & ~0x04)) { + lastPgStuckToggleTime = now; + MESH_DEBUG_PRINTLN("PG recovery: PG=0, toggled HIZ"); + break; + } + delay(10); + } + return; + } + + // Re-enable MPPT when PGOOD=1 + uint8_t mpptVal; + if (bqDriverInstance->readReg(0x15, mpptVal) && !(mpptVal & 0x01) && + bqDriverInstance->writeReg(0x15, mpptVal | 0x01) && + bqDriverInstance->readReg(0x15, mpptVal) && (mpptVal & 0x01)) { + MESH_DEBUG_PRINTLN("MPPT re-enabled via register"); + } +} + +// Single MPPT cycle — called from tickPeriodic() every 60s +// Checks solar logic and updates MPPT stats. +void BoardConfigContainer::runMpptCycle() { + // Clear any pending BQ25798 flags so the INT line stays de-asserted + // (we don't wire INT to an MCU IRQ, but leaving flags latched costs current). + if (bqDriverInstance) { + BqDriver::clearInterruptFlags(); + } + + checkAndFixSolarLogic(); + bool mpptEnabled; + BoardConfigContainer::loadMpptEnabled(mpptEnabled); + if (mpptEnabled && bqDriverInstance) { + updateMpptStats(); + } +} + +// Stops heartbeat task and disarms alerts before OTA. +// MPPT and SOC work are tick-based (no tasks to stop); +// only the heartbeat LED task and INA228 alert need cleanup. +void BoardConfigContainer::stopBackgroundTasks() { + MESH_DEBUG_PRINTLN("Stopping background tasks for OTA..."); + + // Delete heartbeat task if running + if (heartbeatTaskHandle != NULL) { + vTaskDelete(heartbeatTaskHandle); + heartbeatTaskHandle = NULL; + MESH_DEBUG_PRINTLN("Heartbeat task stopped"); + } + + // Disarm INA228 low-voltage alert (Rev 1.1) + disarmLowVoltageAlert(); + + delay(200); + MESH_DEBUG_PRINTLN("Background cleanup complete"); +} + +void BoardConfigContainer::heartbeatTask(void* pvParameters) { + (void)pvParameters; + + pinMode(LED_BLUE, OUTPUT); + + while (true) { + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + } + vTaskDelay(pdMS_TO_TICKS(10)); // 10ms flash - well visible, minimal power + if (leds_enabled) { + digitalWrite(LED_BLUE, LOW); + } + vTaskDelay(pdMS_TO_TICKS(5000)); // 5s interval - lower power consumption + } +} + +// Enable or disable heartbeat LED and BQ25798 stat LED +bool BoardConfigContainer::setLEDsEnabled(bool enabled) { + leds_enabled = enabled; + + // Save to filesystem + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(LEDSKEY, enabled ? "1" : "0"); + prefs.end(); + + // Control heartbeat task + if (enabled) { + // Start heartbeat if not running + if (heartbeatTaskHandle == NULL) { + xTaskCreate(heartbeatTask, "Heartbeat", 512, NULL, 1, &heartbeatTaskHandle); + } + } else { + // Stop heartbeat task + if (heartbeatTaskHandle != NULL) { + vTaskDelete(heartbeatTaskHandle); + heartbeatTaskHandle = NULL; + // Turn off LED + pinMode(LED_BLUE, OUTPUT); + digitalWrite(LED_BLUE, LOW); + } + } + + // Control BQ25798 STAT LED (only if BQ is initialized) + if (bqInitialized && bqDriverInstance) { + bqDriverInstance->setStatPinEnable(enabled); + } + + return true; +} + +// Get current LED enable state +bool BoardConfigContainer::getLEDsEnabled() const { + return leds_enabled; +} + +// Updates MPPT statistics based on elapsed time and current status +// Should be called when MPPT status changes or periodically for time accounting +void BoardConfigContainer::updateMpptStats() { + if (!bqDriverInstance) return; + + static bool lastMpptStatus = false; + static bool initialized = false; + + // Get current time - prefer RTC, fallback to millis() + uint32_t currentTime; + uint32_t rtcTime = getRTCTime(); + + // Check if RTC is initialized (returns > 0 if time was set) + // AutoDiscoverRTCClock returns 0 if no RTC found and time not set + if (rtcTime > 1000000000) { // Sanity check: After year 2001 + currentTime = rtcTime; + if (!mpptStats.usingRTC) { + // Switch from millis to RTC + mpptStats.usingRTC = true; + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = bqDriverInstance->getMPPTenable(); + initialized = true; + return; // Reset timing on switch + } + } else { + // RTC not available or not set - use millis() in seconds + currentTime = millis() / 1000; + } + + bool currentMpptStatus = bqDriverInstance->getMPPTenable(); + + // Initialize on first run + if (!initialized) { + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + initialized = true; + return; + } + + // Calculate elapsed time since last update + uint32_t elapsedSeconds = currentTime - mpptStats.lastUpdateTime; + + // Sanity check: If more than 48 hours passed, reset + const uint32_t MAX_INTERVAL_SEC = 48UL * 60UL * 60UL; + if (elapsedSeconds > MAX_INTERVAL_SEC) { + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + return; + } + + uint32_t elapsedMinutes = elapsedSeconds / 60; + + if (elapsedMinutes == 0 && lastMpptStatus == currentMpptStatus) { + return; // No time passed and no status change + } + + // Add time to current hour accumulator if MPPT was enabled + if (lastMpptStatus && elapsedMinutes > 0) { + mpptStats.currentHourMinutes += elapsedMinutes; + if (mpptStats.currentHourMinutes > 60) { + mpptStats.currentHourMinutes = 60; // Cap at 60 minutes per hour + } + } + + // Calculate energy harvested since last update if MPPT was enabled + if (lastMpptStatus && elapsedSeconds > 0) { + // Use last measured power and integrate over time: E = P × t + // Energy in mWh = Power in mW × Time in hours + float hours = elapsedSeconds / 3600.0f; + uint32_t energy_mWh = (uint32_t)(mpptStats.lastPower_mW * hours); + mpptStats.currentHourEnergy_mWh += energy_mWh; + } + + // Sample current solar power for next integration period + if (currentMpptStatus) { + uint16_t vbat_mppt = ina228DriverInstance ? ina228DriverInstance->readVoltage_mV() : 0; + const Telemetry* telem = bqDriverInstance->getTelemetryData(vbat_mppt); + if (telem) { + // Calculate power: P = U * I (both in mV and mA, result in mW) + mpptStats.lastPower_mW = (int32_t)telem->solar.voltage * telem->solar.current / 1000; + } + } else { + mpptStats.lastPower_mW = 0; // No power when MPPT disabled + } + + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + + // Check if we need to move to the next hour + static uint32_t lastHourCheck = 0; + uint32_t currentHour = currentTime / 3600; + uint32_t lastHour = lastHourCheck / 3600; + + if (currentHour > lastHour) { + // Store the completed hour's data + mpptStats.hours[mpptStats.currentIndex].mpptEnabledMinutes = mpptStats.currentHourMinutes; + mpptStats.hours[mpptStats.currentIndex].timestamp = currentTime; + mpptStats.hours[mpptStats.currentIndex].harvestedEnergy_mWh = mpptStats.currentHourEnergy_mWh; + + // Move to next index (circular buffer) + mpptStats.currentIndex = (mpptStats.currentIndex + 1) % MPPT_STATS_HOURS; + + // Reset for new hour + mpptStats.currentHourMinutes = 0; + mpptStats.currentHourEnergy_mWh = 0; + lastHourCheck = currentTime; + } +} + +// Returns current max charge current as string +const char* BoardConfigContainer::getChargeCurrentAsStr() { + static char buffer[16]; + snprintf(buffer, sizeof(buffer), "%dmA", this->getMaxChargeCurrent_mA()); + return buffer; +} + +// Writes charger status information into provided buffer +void BoardConfigContainer::getChargerInfo(char* buffer, uint32_t bufferSize) { + // Check if buffer is valid + if (!buffer || bufferSize == 0) { + return; + } + + // Clear buffer to prevent garbage data + memset(buffer, 0, bufferSize); + + // Check if BQ25798 is initialized and responsive + if (!bqInitialized) { + snprintf(buffer, bufferSize, "BQ25798 not initialized"); + return; + } + + const char* powerGood = bq.getChargerStatusPowerGood() ? "PG" : "!PG"; + const char* statusString = "Unknown"; // Initialize with default value + bq25798_charging_status status = bq.getChargingStatus(); + + switch (status) { + case bq25798_charging_status::BQ25798_CHARGER_STATE_NOT_CHARGING: { + statusString = "!CHG"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_PRE_CHARGING: { + statusString = "PRE"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_CC_CHARGING: { + statusString = "CC"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_CV_CHARGING: { + statusString = "CV"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_TRICKLE_CHARGING: { + statusString = "TRICKLE"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_TOP_OF_TIMER_ACTIVE_CHARGING: { + statusString = "TOP"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_DONE_CHARGING: { + statusString = "DONE"; + break; + } + default: + statusString = "Unknown"; + break; + } + + if (lastPgStuckToggleTime == 0) { + snprintf(buffer, bufferSize, "%s / %s HIZ:never", powerGood, statusString); + } else { + uint32_t agoSec = (millis() - lastPgStuckToggleTime) / 1000; + if (agoSec < 60) { + snprintf(buffer, bufferSize, "%s / %s HIZ:%ds ago", powerGood, statusString, agoSec); + } else if (agoSec < 3600) { + snprintf(buffer, bufferSize, "%s / %s HIZ:%dm ago", powerGood, statusString, agoSec / 60); + } else { + snprintf(buffer, bufferSize, "%s / %s HIZ:%dh ago", powerGood, statusString, agoSec / 3600); + } + } + + // BQ die temperature (from the last ADC one-shot; up to one telemetry period old) + float tdie = bq.getDieTemperature_C(); + if (tdie > -100.0f && tdie < 200.0f) { + size_t len = strlen(buffer); + snprintf(buffer + len, bufferSize - len, " TDIE:%dC", (int)lroundf(tdie)); + } +} + +// RV-3028 self-test: address ACK plus user-RAM write/readback (see getSelfTest()). +bool BoardConfigContainer::probeRtc() { + // Address ACK + Wire.beginTransmission(0x52); + if (Wire.endTransmission() != 0) return false; + + // User-RAM 0x1F write/readback (two patterns, save/restore original). + Wire.beginTransmission(0x52); + Wire.write(0x1F); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)0x52, (uint8_t)1) != 1) return false; + uint8_t saved = Wire.read(); + + for (uint8_t pat : {0xA5, 0x5A}) { + Wire.beginTransmission(0x52); + Wire.write(0x1F); + Wire.write(pat); + if (Wire.endTransmission() != 0) return false; + + Wire.beginTransmission(0x52); + Wire.write(0x1F); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)0x52, (uint8_t)1) != 1) return false; + if (Wire.read() != pat) return false; + } + + // Restore original byte + Wire.beginTransmission(0x52); + Wire.write(0x1F); + Wire.write(saved); + Wire.endTransmission(); + return true; +} + +namespace { + bool probeI2CAddr(uint8_t addr) { + Wire.beginTransmission(addr); + return Wire.endTransmission() == 0; + } +} + +void BoardConfigContainer::getSelfTest(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + const char* ina = probeI2CAddr(0x40) ? "OK" : "NACK"; + const char* bq = probeI2CAddr(BQ25798_I2C_ADDR) ? "OK" : "NACK"; + const char* bme = probeI2CAddr(0x76) ? "OK" : "NACK"; + + // RTC: distinguish bus-NACK from write-failure + const char* rtc; + if (!probeI2CAddr(0x52)) { + rtc = "NACK"; + } else if (!probeRtc()) { + rtc = "WR_FAIL"; + } else { + rtc = "OK"; + } + + snprintf(buffer, bufferSize, "INA:%s BQ:%s RTC:%s BME:%s", ina, bq, rtc, bme); +} + +// Reads BQ25798 status/fault registers and produces a compact diagnostic string. +// Register layout (BQ25798 datasheet SLUSDV2B): +// 0x1B STATUS_0: IINDPM[7] VINDPM[6] WD[5] rsvd[4] PG[3] AC2[2] AC1[1] VBUS[0] +// 0x1C STATUS_1: CHG_STAT[7:5] VBUS_STAT[4:1] BC12[0] +// 0x1D STATUS_2: ICO[7:6] rsvd[5:3] TREG[2] DPDM[1] VBAT_PRESENT[0] +// 0x1E STATUS_3: ACRB2[7] ACRB1[6] ADC_DONE[5] VSYS[4] CHG_TMR[3] TRICHG_TMR[2] PRECHG_TMR[1] rsvd[0] +// 0x1F STATUS_4: rsvd[7:5] VBATOTG_LOW[4] TS_COLD[3] TS_COOL[2] TS_WARM[1] TS_HOT[0] +// 0x20 FAULT_0: IBAT_REG[7] VBUS_OVP[6] VBAT_OVP[5] IBUS_OCP[4] IBAT_OCP[3] CONV_OCP[2] VAC2_OVP[1] VAC1_OVP[0] +// 0x21 FAULT_1: rsvd[7] OTG_UVP[6] OTG_OVP[5] rsvd[4] VSYS_SHORT[3] VSYS_OVP[2] rsvd[1:0] +// 0x0F CTRL_0: AUTO_IBATDIS[7] FORCE_IBATDIS[6] EN_CHG[5] EN_ICO[4] FORCE_ICO[3] EN_HIZ[2] EN_TERM[1] EN_BACKUP[0] +// 0x18 NTC_1: TS_COOL[7:6] TS_WARM[5:4] BHOT[3:2] BCOLD[1] TS_IGNORE[0] +void BoardConfigContainer::getBqDiagnostics(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + memset(buffer, 0, bufferSize); + + if (!bqInitialized) { + snprintf(buffer, bufferSize, "BQ not init"); + return; + } + + // Read status registers (read-only, safe to read) + uint8_t s0 = bq.readReg(0x1B); // CHARGER_STATUS_0 + uint8_t s1 = bq.readReg(0x1C); // CHARGER_STATUS_1 + uint8_t s2 = bq.readReg(0x1D); // CHARGER_STATUS_2 + uint8_t s3 = bq.readReg(0x1E); // CHARGER_STATUS_3 + uint8_t s4 = bq.readReg(0x1F); // CHARGER_STATUS_4 + uint8_t f0 = bq.readReg(0x20); // FAULT_STATUS_0 + uint8_t f1 = bq.readReg(0x21); // FAULT_STATUS_1 + + // Read control registers + uint8_t ctrl0 = bq.readReg(0x0F); // CHARGER_CONTROL_0: EN_CHG[5], EN_HIZ[2] + uint8_t ntc1 = bq.readReg(0x18); // NTC_CONTROL_1 + + // Decode TS region from STATUS_4 (0x1F): TS_COLD[3] TS_COOL[2] TS_WARM[1] TS_HOT[0] + const char* ts_str = "OK"; + if (s4 & 0x01) ts_str = "HOT"; + else if (s4 & 0x02) ts_str = "WARM"; + else if (s4 & 0x04) ts_str = "COOL"; + else if (s4 & 0x08) ts_str = "COLD"; + + // Build active-flags substring (only show abnormal conditions) + char flags[50] = ""; + int pos = 0; + if (s0 & 0x80) pos += snprintf(flags + pos, sizeof(flags) - pos, " IINDPM"); + if (s0 & 0x40) pos += snprintf(flags + pos, sizeof(flags) - pos, " VINDPM"); + if (s0 & 0x20) pos += snprintf(flags + pos, sizeof(flags) - pos, " WD!"); + if (s2 & 0x04) pos += snprintf(flags + pos, sizeof(flags) - pos, " TREG"); + if (s3 & 0x08) pos += snprintf(flags + pos, sizeof(flags) - pos, " CHG_TMR"); + if (s3 & 0x04) pos += snprintf(flags + pos, sizeof(flags) - pos, " TCTMR"); + if (s3 & 0x02) pos += snprintf(flags + pos, sizeof(flags) - pos, " PCTMR"); + if (f0 & 0x40) pos += snprintf(flags + pos, sizeof(flags) - pos, " VBUS_OVP"); + if (f0 & 0x20) pos += snprintf(flags + pos, sizeof(flags) - pos, " VBAT_OVP"); + + bool en_chg = (ctrl0 >> 5) & 1; // EN_CHG: bit 5 of CHARGER_CONTROL_0 + bool en_hiz = (ctrl0 >> 2) & 1; // EN_HIZ: bit 2 of CHARGER_CONTROL_0 + + // Read actual IINDPM from REG06 for verification + uint16_t iindpm_mA = (uint16_t)(bq.getInputLimitA() * 1000); + + // Compact output: TS region, active flags, control bits, IINDPM readback, faults, raw status hex, NTC config + snprintf(buffer, bufferSize, + "TS:%s%s CE:%d HIZ:%d IINDPM:%umA F:%02X/%02X S:%02X.%02X.%02X.%02X.%02X N:%02X", + ts_str, flags, en_chg, en_hiz, iindpm_mA, f0, f1, s0, s1, s2, s3, s4, ntc1); +} + +// Read-only MPPT acceptance snapshot. cfg is the stored wish, hw is EN_MPPT. +// VSYS_MIN reports VSYS_STAT; it does not by itself prove active LDO charging. +void BoardConfigContainer::getMpptDiagnostics(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + if (!bqInitialized) { + snprintf(buffer, bufferSize, "BQ not init"); + return; + } + + // VSYSMIN, VREG hi/lo, ICHG hi/lo, VINDPM, CELL, MPPT, STATUS_0/3. + const uint8_t regs[] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x0A, 0x15, 0x1B, 0x1E}; + uint8_t raw[sizeof(regs)]; + for (unsigned i = 0; i < sizeof(regs); ++i) { + if (!bq.readReg(regs[i], raw[i])) { + snprintf(buffer, bufferSize, "Err: BQ read %02X", static_cast(regs[i])); + return; + } + } + + const unsigned vsysmin_mV = 2500U + (raw[0] & 0x3F) * 250U; + const unsigned vreg_mV = (((raw[1] << 8) | raw[2]) & 0x07FF) * 10U; + const unsigned ichg_mA = (((raw[3] << 8) | raw[4]) & 0x01FF) * 10U; + const unsigned vindpm_mV = raw[5] * 100U; + const unsigned cells = ((raw[6] >> 6) & 0x03) + 1U; + const unsigned mppt = raw[7] & 0x01; + const unsigned pg = (raw[8] >> 3) & 0x01; + const unsigned vsys_min = (raw[9] >> 4) & 0x01; + + // At most 95 characters, including maximum encodings, for the 100-byte CLI buffer. + snprintf(buffer, bufferSize, + "MPPT:cfg=%u/hw=%u VSYSMIN:%umV VINDPM:%umV VSYS_MIN:%u PG:%u CELL:%uS ICHG:%umA VREG:%umV", + static_cast(getMPPTEnabled()), mppt, vsysmin_mV, vindpm_mV, + vsys_min, pg, cells, ichg_mA, vreg_mV); +} + +// Initializes battery manager, preferences, and background tasks +bool BoardConfigContainer::begin() { + // Initialize LEDs early for boot sequence visualization + 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(getBatteryType()); + + // 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(getFrostChargeBehaviour()); + 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::loop() — 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; +} + +bool BoardConfigContainer::loadStationAltitude(float& altitude_m) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[24]; + if (prefs.getString(ALTITUDEKEY, buffer, sizeof(buffer), "") == 0) { + return false; + } + + char* end = nullptr; + const float value = strtof(buffer, &end); + if (end == buffer || *end != '\0' || !isfinite(value) || + value < MIN_STATION_ALTITUDE_M || value > MAX_STATION_ALTITUDE_M) { + return false; + } + + altitude_m = value; + return true; +} + +bool BoardConfigContainer::getStationAltitude(float& altitude_m) const { + return loadStationAltitude(altitude_m); +} + +bool BoardConfigContainer::setStationAltitude(float altitude_m) { + if (!isfinite(altitude_m) || altitude_m < MIN_STATION_ALTITUDE_M || + altitude_m > MAX_STATION_ALTITUDE_M) { + return false; + } + + char buffer[24]; + snprintf(buffer, sizeof(buffer), "%.1f", altitude_m); + + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + return prefs.putString(ALTITUDEKEY, buffer) > 0; +} + +bool BoardConfigContainer::clearStationAltitude() { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + return prefs.remove(ALTITUDEKEY); +} + +// Returns combined telemetry from INA228 (battery) and BQ25798 (solar + temperature). +// Battery voltage/current come from the INA228 (20-bit ADC, ±0.1% accuracy); +// solar data and battery temperature from the BQ25798 ADC. +// +// Temperature availability depends on power conditions: +// VBUS > 3.4V → BQ25798 ADC runs → temperature available +// VBAT >= 3.2V → BQ25798 ADC runs → temperature available +// VBAT < 3.2V → TS channel disabled (datasheet 9.3.16) → temperature = N/A +// VBAT < 2.9V → ADC cannot operate at all → temperature = N/A, solar = 0 +// +// Temperature sentinel values (propagated from BqDriver::calculateBatteryTemp): +// -999.0f = I2C communication error or NTC unavailable +// -888.0f = ADC not ready / TS disabled due to low VBAT +// -99.0f = NTC open circuit (disconnected) +// 99.0f = NTC short circuit +// Values outside -50..+90°C are treated as invalid → displayed as "N/A" +const Telemetry* BoardConfigContainer::getTelemetryData() { + static Telemetry telemetry; + + // Battery voltage/current ALWAYS from INA228 (no fallback to BQ25798) + // INA228 for precise battery monitoring + uint16_t batt_voltage = 0; + float batt_current = 0.0f; + int32_t batt_power = 0; + if (ina228DriverInstance != nullptr) { + batt_voltage = ina228DriverInstance->readVoltage_mV(); + batt_current = ina228DriverInstance->readCurrent_mA_precise(); + batt_power = (int32_t)((batt_voltage * batt_current) / 1000.0f); + } + + // Get base telemetry from BQ25798 (solar data + temperature) + // Pass VBAT so BqDriver can disable TS channel when VBAT < 3.2V + // (BQ25798 ADC requires VBAT >= 3.2V with TS enabled, else ADC won't start) + const Telemetry* bqData = bq.getTelemetryData(batt_voltage); + if (!bqData) { + memset(&telemetry, 0, sizeof(Telemetry)); + return &telemetry; + } + + // Copy BQ25798 data (solar, system) + telemetry.solar = bqData->solar; + telemetry.system = bqData->system; + + // Temperature: BQ25798 TS ADC reads NTC via REGN-biased divider. + // Error codes from calculateBatteryTemp: -999 (I2C), -888 (ADC not ready), -99 (open), 99 (short). + // Valid NTC range: approx -40..+85 °C. Anything outside -50..+90 is treated as unavailable. + float bqTemp = bqData->battery.temperature; + if (bqTemp >= -50.0f && bqTemp <= 90.0f) { + float ntcTemp = bqTemp + tcCalOffset; + // Plausibility against the BME280: an open/missing NTC decodes through the + // RT2-only pole to ≈-46°C regardless of ambient, which this window check + // cannot catch. A reading further than NTC_BME_MAX_DIFF_C from the board + // temperature is not the battery → discard. Without a BME reading the + // check stands down and the value passes as before. + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f && + fabsf(ntcTemp - bmeTemp) > NTC_BME_MAX_DIFF_C) { + telemetry.battery.temperature = -999.0f; // implausible → N/A + } else { + telemetry.battery.temperature = ntcTemp; + // Cache for temperature derating in updateBatterySOC() (static context) + lastValidBatteryTemp = ntcTemp; + lastTempUpdateMs = millis(); + } + } else { + // NTC unavailable (no solar / I2C error / ADC not ready) → propagate sentinel + telemetry.battery.temperature = -999.0f; + } + + telemetry.battery.voltage = batt_voltage; + telemetry.battery.current = batt_current; + telemetry.battery.power = batt_power; + + return &telemetry; +} + +// Configures base BQ25798 settings (timers, watchdog, input limits, MPPT) +bool BoardConfigContainer::configureBaseBQ() { + if (!bqInitialized) { + return false; + } + + bq.setRechargeThreshOffsetV(.2); + bq.setPrechargeTimerEnable(false); + + // Thermal regulation at 60°C instead of the 120°C POR default. In the + // buck-boost transition region (LTO 2S at ~5V input) the converter otherwise + // rides the 120°C ceiling at high charge currents. Harmless for 1S + // chemistries — clean buck operation never gets near 60°C. + bq.setThermRegulationThresh(BQ25798_TREG_60C); + bq.setFastChargeTimerEnable(false); + bq.setTsIgnore(false); + bq.setWDT(BQ25798_WDT_DISABLE); + bq.setExtILIMpin(false); // Disable ILIM_HIZ pin clamp — IINDPM managed by software + bq.setInputLimitA(IINDPM_MAX_A); // Safe default before chemistry is known; updateSolarIINDPM() refines later + bq.setICOEnable(false); // Disable ICO — IINDPM is explicitly managed, ICO must not overwrite it + + bq.setVOCdelay(BQ25798_VOC_DLY_2S); + bq.setVOCrate(BQ25798_VOC_RATE_2MIN); + bq.setVOCpercent(BQ25798_VOC_PCT_81_25); // 81.25% matches Vmp/Voc of typical crystalline Si panels (~80-83%) + bq.setAutoDPinsDetection(false); + // CELL programming below resets VSYSMIN. Enable MPPT only after the final + // chemistry settings are restored, and only if requested in preferences. + bq.setMPPTenable(false); + + bq.setMinSystemV(BQ_MIN_SYSTEM_V); + bq.setStatPinEnable(leds_enabled); // Configure STAT LED based on user preference + bq.setTsCool(BQ25798_TS_COOL_5C); + bq.setTsWarm(BQ25798_TS_WARM_55C); // 37.7% REGN → ~52°C with Inhero divider (default 45°C was ~42°C) + + // JEITA WARM: keep VREG unchanged. Default -400mV triggers VBAT_OVP on LiFePO4 + // and is unnecessarily conservative for Li-Ion (4.1V / 3.5V are already safe). + bq.setJeitaVSet(BQ25798_JEITA_VSET_UNCHANGED); + + // Disable auto battery discharge during VBAT_OVP (EN_AUTO_IBATDIS). + // POR default = enabled → BQ actively sinks 30mA from battery during OVP to lower VBAT. + // With JEITA_VSET fixed, VBAT_OVP should no longer trigger. Belt-and-suspenders safety. + bq.setAutoIBATDIS(false); + + // Flush stale ADC registers by running one discard conversion. + // After reboot (e.g. low-voltage recovery), BQ25798 retains old ADC values + // from before shutdown. A fresh one-shot ensures registers reflect actual state. + bq.getTelemetryData(0); // VBAT unknown at this point, assume sufficient + + return true; +} + +// Configures battery chemistry-specific parameters (cell count, charge voltage) +bool BoardConfigContainer::configureChemistry(BatteryType type) { + if (!bqInitialized) { + return false; + } + + // Get battery properties from lookup table + const BatteryProperties* props = getBatteryProperties(type); + if (!props) { + MESH_DEBUG_PRINTLN("ERROR: Invalid battery type"); + return false; + } + + // Apply charge enable/disable based on battery type + bq.setChargeEnable(props->charge_enable); + + // CE-Pin hardware safety: Only pull CE HIGH (enable charging via FET) when chemistry is known + // Rev 1.1: DMN2004TK-7 N-FET inverts CE logic — HIGH=enable, LOW=disable + // External pull-down ensures CE stays LOW (charging disabled) when RAK is off or unbooted +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, props->charge_enable ? HIGH : LOW); + MESH_DEBUG_PRINTLN("BQ CE pin %s (charge_enable=%d)", + props->charge_enable ? "HIGH (enabled via FET)" : "LOW (disabled via FET)", + props->charge_enable); +#endif + + if (!props->charge_enable) { + // No charging at all for this type, so the temperature guard has nothing to + // guard; derive the override anyway to keep the reported state truthful. + applyJeitaIgnore(props); + MESH_DEBUG_PRINTLN("WARNING: Battery type UNKNOWN - Charging DISABLED for safety!"); + return true; // No further configuration needed for unknown battery + } + + // Configure chemistry-specific parameters, starting with the cell count + bq25798_cell_count_t cellCount = (type == BoardConfigContainer::BatteryType::LTO_2S) + ? BQ25798_CELL_COUNT_2S : BQ25798_CELL_COUNT_1S; + bq.setCellCount(cellCount); + + bq.setChargeLimitV(props->charge_voltage); + + // Writing the CELL bits resets ICHG, VSYSMIN and VREG to the per-cell-count + // POR defaults (datasheet 9.3.1.2 — 2S: 1A / 7V / 8.4V). VREG is re-applied + // above; restore the other two, otherwise a 2S chemistry runs with + // VSYSMIN=7V and the BATFET burns (VSYS - VBAT) × ICHG linearly during + // charging (LTO at 4.9V/0.93A: ~2W → BQ rides its thermal limit), and the + // configured imax silently falls back to the 1A default on every boot. + bq.setMinSystemV(BQ_MIN_SYSTEM_V); + const uint16_t chargeCurrent_mA = getMaxChargeCurrent_mA(); + bq.setChargeLimitA(chargeCurrent_mA / 1000.0f); + const bool prechargeConfigured = bq.setPrechargeLimitmA(chargeCurrent_mA); + + // Derive the JEITA override once ICHG holds the configured imax again. + // Deriving it earlier leaves a window in which the temperature guard is off + // while setCellCount() has just reset ICHG to the 1A POR default — and an I2C + // failure inside that window would freeze the board in exactly that state. + // configureBaseBQ() clears TS_IGNORE, so the hardware guard rules until here. + applyJeitaIgnore(props); + + // Below VSYSMIN the BQ rejects EN_MPPT; above it, apply the configured wish + // now instead of waiting for the next 60-second solar-maintenance cycle. + bq.setMPPTenable(getMPPTEnabled()); + + return prechargeConfigured; +} + +// Gets current battery type from preferences +BoardConfigContainer::BatteryType BoardConfigContainer::getBatteryType() const { + BatteryType bat; + if (loadBatType(bat)) { + return bat; + } else { + return DEFAULT_BATTERY_TYPE; + } +} + +// Gets current frost charge behavior from preferences +BoardConfigContainer::FrostChargeBehaviour BoardConfigContainer::getFrostChargeBehaviour() const { + FrostChargeBehaviour frost; + if (loadFrost(frost)) { + return frost; + } else { + return NO_CHARGE; + } +} + +// Gets maximum charge current from preferences +uint16_t BoardConfigContainer::getMaxChargeCurrent_mA() const { + uint16_t maxI = 100; + loadMaxChrgI(maxI); + return maxI; +} + +// Gets current MPPT enable status from preferences +bool BoardConfigContainer::getMPPTEnabled() const { + bool enabled; + loadMpptEnabled(enabled); + return enabled; +} + +// === JEITA override (board.jeitaignore) === + +// Loads the accepted user override. Only meaningful for needs_jeita chemistries. +bool BoardConfigContainer::loadJeitaIgnoreEnabled(bool& on) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[4]; + if (prefs.getString(JEITAIGNKEY, buffer, sizeof(buffer), "") > 0) { + on = (buffer[0] == '1'); + return true; + } + on = false; + return false; +} + +bool BoardConfigContainer::getJeitaIgnoreEnabled() const { + bool on = false; + loadJeitaIgnoreEnabled(on); + return on; +} + +// The gate: batcap must be user-set and imax must be strictly below 0.05C. +// The safety is this static bound, not a firmware control loop — in SYSTEMOFF +// sleep the charger stays enabled and no loop runs, so an unattended frozen +// cell must never see more than that rate. +bool BoardConfigContainer::jeitaIgnoreGateOk() const { + if (!isBatteryCapacitySet()) { + return false; + } + // Read the persisted capacity, not getBatteryCapacity(): that returns the + // socStats RAM cache, which begin() fills only AFTER configureChemistry() — + // the boot derivation would gate against 0 mAh and always fail. + float cap_mah = 0.0f; + loadBatteryCapacity(cap_mah); + return isJeitaIgnoreCurrentAllowed(getMaxChargeCurrent_mA(), cap_mah); +} + +bool BoardConfigContainer::setJeitaIgnore(bool on) { + const auto* props = getBatteryProperties(getBatteryType()); + if (!props || !props->needs_jeita || (on && !jeitaIgnoreGateOk())) return false; + const bool wasEnabled = getJeitaIgnoreEnabled(); + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + // No hidden frost value: enabling and a real 1->0 transition reset it. + if ((on || wasEnabled) && + !prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(DEFAULT_FROST_BEHAVIOUR))) return false; + if (!prefs.putString(JEITAIGNKEY, on ? "1" : "0")) { + return false; + } + if (!applyJeitaIgnore(props)) { + prefs.putString(JEITAIGNKEY, wasEnabled ? "1" : "0"); + return false; + } + return true; +} + +// Applies the accepted setting for the current chemistry. +bool BoardConfigContainer::applyJeitaIgnore() { + return applyJeitaIgnore(getBatteryProperties(getBatteryType())); +} + +// Derives the effective JEITA override and programs the BQ: +// chemistry runs without JEITA (LTO, Na-ion, UNKNOWN) → forced on; for +// Na-ion the cell datasheet sets the charge window, the board does not +// otherwise → accepted user override +// TS_IGNORE stops the BQ's temperature regulation permanently — deliberately +// including SYSTEMOFF sleep. Turning the override off restores default fmax. +bool BoardConfigContainer::applyJeitaIgnore(const BatteryProperties* props) { + if (!bqInitialized || !props) { + jeitaIgnoreActive = false; + return false; + } + + bool enabled = getJeitaIgnoreEnabled(); + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + // Remove legacy deferred requests, and discard any frost value they hid. + if (enabled || !props->needs_jeita) { + if (getFrostChargeBehaviour() != DEFAULT_FROST_BEHAVIOUR && + !prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(DEFAULT_FROST_BEHAVIOUR))) return false; + } + if (enabled && (!props->needs_jeita || !jeitaIgnoreGateOk())) { + if (!prefs.putString(JEITAIGNKEY, "0")) return false; + enabled = false; + } + bool ignore = !props->needs_jeita || enabled; + bool was_active = jeitaIgnoreActive; + + bool ok = bq.setTsIgnore(ignore); + if (ignore) { + ok = bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED) && ok; + ok = bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED) && ok; + } else if (was_active) { + ok = setFrostChargeBehaviour(getFrostChargeBehaviour()) && ok; + } + jeitaIgnoreActive = ok && ignore; + return ok; +} + +// 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) { + if (!getBatteryProperties(type)) return false; + if (type == getBatteryType()) return true; + + // An actual chemistry change starts with battery/charging defaults. + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + if (!prefs.putInt(MAXCHARGECURRENTKEY, DEFAULT_MAX_CHARGE_CURRENT_MA) || + !prefs.putString(MPPTENABLEKEY, "0") || + !prefs.putString(JEITAIGNKEY, "0") || + !prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(DEFAULT_FROST_BEHAVIOUR)) || + !prefs.remove(BATTERY_CAPACITY_KEY) || + !prefs.putString(BATTKEY, getBatteryTypeCommandString(type))) return false; + loadBatteryCapacity(socStats.capacity_mah); + + bool bqBaseConfigured = this->configureBaseBQ(); + bool bqConfigured = this->configureChemistry(type); + cachedBatteryType = type; // Update cache for static methods (updateBatterySOC, calculateTTL) + + // 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(type); + delay(10); + } + + // 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) { + const auto* props = getBatteryProperties(getBatteryType()); + if (!props || !props->needs_jeita || getJeitaIgnoreEnabled()) return false; + switch (behaviour) { + case BoardConfigContainer::FrostChargeBehaviour::NO_CHARGE: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_SUSPEND); + 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) { + if (getJeitaIgnoreEnabled()) { + float capacity = 0.0f; + if (!loadBatteryCapacity(capacity) || !isJeitaIgnoreCurrentAllowed(maxChrgI, capacity)) return false; + } + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putInt(MAXCHARGECURRENTKEY, maxChrgI); + + bool ok = bq.setChargeLimitA(maxChrgI / 1000.0f); + const bool prechargeOk = bq.setPrechargeLimitmA(maxChrgI); + + // Readback verification — detect silent I2C failures + float readback = bq.getChargeLimitA(); + 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 && prechargeOk; +} + +// 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 + } + // Check the same one-decimal value the existing storage format will retain. + char buffer[20]; + snprintf(buffer, sizeof(buffer), "%.1f", capacity_mah); + capacity_mah = atof(buffer); + if (getJeitaIgnoreEnabled() && + !isJeitaIgnoreCurrentAllowed(getMaxChargeCurrent_mA(), capacity_mah)) return false; + + // Store user-configured capacity in mAh + socStats.capacity_mah = capacity_mah; + + // 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); + + 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() { + 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; +} + +// Arm INA228 BUVL alert at the chemistry's lowv_sleep_mv threshold. +// Fires → ISR → flag → tickPeriodic() → System Sleep. BAT_UNKNOWN = disabled. +void BoardConfigContainer::armLowVoltageAlert(BatteryType bat_type) { + disarmLowVoltageAlert(); + lowVoltageSleepRetryPending = false; + if (!ina228DriverInstance) { + return; + } + + const BatteryProperties* props = getBatteryProperties(bat_type); + uint16_t sleep_mv = props ? props->lowv_sleep_mv : 0; + + if (bat_type == BAT_UNKNOWN || sleep_mv == 0) { + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: DISABLED (BAT_UNKNOWN)"); + return; + } + + // Keep the software check active even if configuring the hardware alert fails. + lowVoltageSleepMv = sleep_mv; + lastLowVoltageMs = millis(); + bool buvl_ok = ina228DriverInstance->setUnderVoltageAlert(sleep_mv); + ina228DriverInstance->enableAlert(true, false, true); // active-LOW, LATCHED + + // Attach ISR on ALERT pin (active-LOW, falling edge) + pinMode(INA_ALERT_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN), lowVoltageAlertISR, FALLING); + // A latched alert may already be LOW before the falling-edge ISR is attached. + if (digitalRead(INA_ALERT_PIN) == LOW) { + lowVoltageAlertFired = true; + } + + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: ARMED @ %dmV (BUVL write %s)", sleep_mv, buvl_ok ? "OK" : "FAILED"); +} + +void BoardConfigContainer::disarmLowVoltageAlert() { + detachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN)); + lowVoltageAlertFired = false; + lowVoltageSleepMv = 0; + if (!ina228DriverInstance) { + return; + } + + ina228DriverInstance->setUnderVoltageAlert(0); + ina228DriverInstance->enableAlert(false, false, false); + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: DISARMED"); +} + +// ISR: falling edge on INA228 ALERT (active-LOW, latched). Just sets the flag; +// tickPeriodic() consumes it and initiates shutdown. +void BoardConfigContainer::lowVoltageAlertISR() { + lowVoltageAlertFired = true; +} + +// Get low-voltage sleep threshold (INA228 ALERT fires at this level) +uint16_t BoardConfigContainer::getLowVoltageSleepThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_sleep_mv : 2000; +} + +// Get low-voltage wake threshold (RTC wake boots if VBAT >= this, 0% SOC marker) +uint16_t BoardConfigContainer::getLowVoltageWakeThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_wake_mv : 2200; +} + +// Update battery SOC from INA228 Hardware Coulomb Counter +// Uses INA228 CHARGE register (mAh) for accurate charge tracking +void BoardConfigContainer::updateBatterySOC() { + if (!ina228DriverInstance) { + return; + } + + // Periodic SHUNT_CAL self-heal (~every 5 min via static counter) + // If SHUNT_CAL got wiped (clone chip glitch, I2C error, etc.), + // CURRENT and CHARGE registers read 0 forever → stats stay at 0. + static uint8_t scal_check_counter = 0; + if (++scal_check_counter >= 5) { // Every 5th call = ~5 minutes (called every 60s) + scal_check_counter = 0; + ina228DriverInstance->validateAndRepairShuntCal(); + } + + // Read INA228 Hardware Coulomb Counter (mAh) - TWO'S COMPLEMENT, has correct sign! + // Positive = charging (into battery), Negative = discharging (from battery) + float charge_mah = ina228DriverInstance->readCharge_mAh(); + + uint32_t now_ms = millis(); + socStats.last_soc_update_ms = now_ms; + socStats.soc_update_count++; + + // Update current hour statistics (track charged/discharged charge in mAh) + // This runs ALWAYS, independent of SOC validity + static float last_charge_mah = 0.0f; + static bool first_read = true; + + if (first_read) { + // Initialize baseline on first read, don't count initial value as delta + last_charge_mah = charge_mah; + first_read = false; + } else { + float delta_mah = charge_mah - last_charge_mah; + last_charge_mah = charge_mah; + + // Handle potential counter wrap or reset (ignore huge jumps > 10Ah) + if (delta_mah > 10000.0f || delta_mah < -10000.0f) { + MESH_DEBUG_PRINTLN("SOC: Large charge delta %.0fmAh - ignoring (counter reset?)", delta_mah); + } else { + // CHARGE register inverted in driver: positive delta = charging, negative delta = discharging + if (delta_mah > 0.0f) { + // Charging (positive delta) + socStats.current_hour_charged_mah += delta_mah; + socStats.current_hour_solar_mah += delta_mah; // Assume solar (BQ tracks this) + } else if (delta_mah < 0.0f) { + // Discharging (negative delta) + socStats.current_hour_discharged_mah += (-delta_mah); + } + } + } + socStats.last_charge_reading_mah = charge_mah; // Always update for diagnostics + + // "Charging Done" → sync to 100%. Edge-triggered: DONE must be stable for two + // consecutive reads (a single misread never syncs; UNKNOWN = failed I2C read + // neither counts as DONE nor resets the streak), and the sync fires once per + // DONE episode. Syncing on the edge (not the persisting DONE state) keeps + // discharge during supplement mode on the ledger instead of erasing it + // every minute. + if (bqDriverInstance) { + static uint8_t done_streak = 0; + bq25798_charging_status status = bqDriverInstance->getChargingStatus(); + if (status == BQ25798_CHARGER_STATE_DONE_CHARGING) { + if (done_streak < 3) done_streak++; + } else if (status != BQ25798_CHARGER_STATE_UNKNOWN) { + done_streak = 0; + } + + if (done_streak == 2) { + done_streak = 3; // fire once per DONE episode + MESH_DEBUG_PRINTLN("SOC: \"Charging Done\" edge - syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after the counter reset — and refresh the LOCAL reading + // too: the ledger clamp below would otherwise capture the stale pre-reset + // value into the baseline, wrecking the SOC one tick later. + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + charge_mah = last_charge_mah; + } + } + + // SOC calculation is only valid after first "Charging Done" sync via syncSOCToFull() + if (!socStats.soc_valid) { + return; // Wait for first sync + } + + // Net charge since last baseline reset (using CHARGE register in mAh) + // Driver inverted: positive = charged into battery, negative = discharged from battery + float net_charge_mah = charge_mah - socStats.ina228_baseline_mah; + + // Remaining capacity = Initial capacity + net charge (positive=charged adds, negative=discharged subtracts) + float remaining_mah = socStats.capacity_mah + net_charge_mah; + + // Ledger clamp: the battery cannot hold more than its capacity. Counted charge + // beyond "full" (charge losses, measurement drift, a sync at not-actually-full) + // would otherwise pile up as an invisible surplus that later discharge must burn + // off before the displayed SOC moves below 100%. Pull the baseline forward so + // the ledger itself — not just the displayed percentage — is capped at capacity. + if (remaining_mah > socStats.capacity_mah) { + socStats.ina228_baseline_mah = charge_mah; + net_charge_mah = 0.0f; + remaining_mah = socStats.capacity_mah; + } + + // Temperature derating: calculate factor for TTL and display purposes. + // The derating factor is NOT applied to SOC% — SOC% is purely Coulomb-based + // (remaining_mah / capacity_mah) and represents the actual stored charge. + // Derating only affects TTL calculation (extractable capacity) and is shown + // separately in CLI output as "derated SOC%". + + // Temperature source priority: 1) NTC via BQ25798 TS ADC (cached), + // 2) BME280 fallback after >5min of no NTC (no NTC fitted, or filtered out). + refreshTempDerating(); + + // Calculate SOC percentage — purely Coulomb-based, NO temperature derating + if (socStats.capacity_mah > 0) { + socStats.current_soc_percent = (remaining_mah / socStats.capacity_mah) * 100.0f; + + // Clamp to 0-100% + if (socStats.current_soc_percent > 100.0f) socStats.current_soc_percent = 100.0f; + if (socStats.current_soc_percent < 0.0f) socStats.current_soc_percent = 0.0f; + } +} + +uint32_t BoardConfigContainer::getRTCTimestamp() { + return getRTCTime(); +} + +// Update hourly battery statistics and advance rolling window +void BoardConfigContainer::updateHourlyStats() { + uint32_t currentTime = getRTCTime(); + + // Calculate hour boundary (align to full hours) + uint32_t currentHour = (currentTime / 3600) * 3600; // Truncate to hour boundary + + // Check if hour has changed + if (socStats.lastHourUpdateTime == 0) { + // First run - initialize + socStats.lastHourUpdateTime = currentHour; + MESH_DEBUG_PRINTLN("SOC: Hourly stats initialized at timestamp %u", currentHour); + return; + } + + uint32_t lastHour = (socStats.lastHourUpdateTime / 3600) * 3600; + + if (currentHour > lastHour) { + // Hour boundary crossed - save current hour stats + MESH_DEBUG_PRINTLN("SOC: Hour changed (%u -> %u) - saving stats: C:%.1f D:%.1f S:%.1f mAh", + lastHour, currentHour, + socStats.current_hour_charged_mah, + socStats.current_hour_discharged_mah, + socStats.current_hour_solar_mah); + + // Move to next hour slot in circular buffer + uint8_t nextIndex = (socStats.currentIndex + 1) % HOURLY_STATS_HOURS; + + // Save completed hour's stats + HourlyBatteryStats& completedHour = socStats.hours[socStats.currentIndex]; + completedHour.timestamp = lastHour; + completedHour.charged_mah = socStats.current_hour_charged_mah; + completedHour.discharged_mah = socStats.current_hour_discharged_mah; + completedHour.solar_mah = socStats.current_hour_solar_mah; + + // Reset accumulators for new hour + socStats.currentIndex = nextIndex; + socStats.current_hour_charged_mah = 0.0f; + socStats.current_hour_discharged_mah = 0.0f; + socStats.current_hour_solar_mah = 0.0f; + socStats.lastHourUpdateTime = currentHour; + + // Recalculate rolling window statistics (24h and 3-day averages) + calculateRollingStats(); + } +} + +// Calculate 24h and 3-day rolling averages from hourly buffer +void BoardConfigContainer::calculateRollingStats() { + // Calculate last 24 hours net balance + float sum_24h_charged = 0.0f; + float sum_24h_discharged = 0.0f; + float sum_24h_solar = 0.0f; + int valid_hours_24h = 0; + + // Sum up last 24 hours (most recent 24 entries) + for (int i = 0; i < 24 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_24h_charged += socStats.hours[idx].charged_mah; + sum_24h_discharged += socStats.hours[idx].discharged_mah; + sum_24h_solar += socStats.hours[idx].solar_mah; + valid_hours_24h++; + } + } + + // Last 24h net: solar - discharged (positive = surplus, negative = deficit) + socStats.last_24h_net_mah = sum_24h_solar - sum_24h_discharged; + socStats.last_24h_charged_mah = sum_24h_charged; + socStats.last_24h_discharged_mah = sum_24h_discharged; + socStats.living_on_battery = (socStats.last_24h_net_mah < 0.0f); + + // Calculate 3-day average daily net (72 hours) + float sum_72h_charged = 0.0f; + float sum_72h_discharged = 0.0f; + float sum_72h_solar = 0.0f; + int valid_hours_72h = 0; + + for (int i = 0; i < 72 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_72h_charged += socStats.hours[idx].charged_mah; + sum_72h_discharged += socStats.hours[idx].discharged_mah; + sum_72h_solar += socStats.hours[idx].solar_mah; + valid_hours_72h++; + } + } + + // Average daily net over 3 days (divide 72h sum by 3) + if (valid_hours_72h >= 24) { // Need at least 24h of data + float net_72h = sum_72h_solar - sum_72h_discharged; + socStats.avg_3day_daily_net_mah = net_72h / 3.0f; // Divide by 3 days + socStats.avg_3day_daily_charged_mah = sum_72h_charged / 3.0f; + socStats.avg_3day_daily_discharged_mah = sum_72h_discharged / 3.0f; + } else { + socStats.avg_3day_daily_net_mah = 0.0f; + socStats.avg_3day_daily_charged_mah = 0.0f; + socStats.avg_3day_daily_discharged_mah = 0.0f; + } + + // Calculate 7-day average daily net (168 hours) + float sum_168h_charged = 0.0f; + float sum_168h_discharged = 0.0f; + float sum_168h_solar = 0.0f; + int valid_hours_168h = 0; + + for (int i = 0; i < 168 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_168h_charged += socStats.hours[idx].charged_mah; + sum_168h_discharged += socStats.hours[idx].discharged_mah; + sum_168h_solar += socStats.hours[idx].solar_mah; + valid_hours_168h++; + } + } + + // Average daily net over 7 days (divide 168h sum by 7) + if (valid_hours_168h >= 24) { // Need at least 24h of data + float net_168h = sum_168h_solar - sum_168h_discharged; + socStats.avg_7day_daily_net_mah = net_168h / 7.0f; // Divide by 7 days + socStats.avg_7day_daily_charged_mah = sum_168h_charged / 7.0f; + socStats.avg_7day_daily_discharged_mah = sum_168h_discharged / 7.0f; + } else { + socStats.avg_7day_daily_net_mah = 0.0f; + socStats.avg_7day_daily_charged_mah = 0.0f; + socStats.avg_7day_daily_discharged_mah = 0.0f; + } + + MESH_DEBUG_PRINTLN("SOC: Rolling stats - 24h net: %+.1fmAh, 3d avg: %+.1fmAh/day, 7d avg: %+.1fmAh/day", + socStats.last_24h_net_mah, socStats.avg_3day_daily_net_mah, socStats.avg_7day_daily_net_mah); + + // Calculate TTL + calculateTTL(); +} + +// Calculate Time To Live (hours until battery empty). +// TTL is based on the 7-day rolling average of daily net energy consumption +// (avg_7day_daily_net_mah), computed from a 168-hour ring buffer of hourly +// INA228 Coulomb-counter measurements (charged/discharged/solar mAh). +// +// Data flow: +// 1. INA228 hardware Coulomb counter measures charge flow continuously (20-bit ADC) +// 2. updateHourlyStats() samples the counter every hour, storing per-hour deltas +// (charged_mah, discharged_mah, solar_mah) in the hours[168] ring buffer +// 3. calculateRollingStats() sums the last 168 hours and divides by 7 to get +// avg_7day_daily_net_mah (= solar - discharged per day) +// 4. This method extrapolates: remaining_mah / deficit_per_day * 24 = TTL hours +// +// Preconditions for TTL > 0: +// - living_on_battery == true (24h net is negative, i.e. energy deficit) +// - avg_7day_daily_net_mah < 0 (7-day average shows net discharge) +// - capacity_mah > 0 (battery capacity is known) +// - at least 24 hours of valid hourly data exist in the ring buffer +// +// When the device is solar-powered with energy surplus (net >= 0), TTL is 0 +// and callers interpret this as "infinite" via the living_on_battery flag. +void BoardConfigContainer::calculateTTL() { + if (!socStats.living_on_battery || socStats.avg_7day_daily_net_mah >= 0) { + socStats.ttl_hours = 0; // Not draining or charging + return; + } + + if (socStats.capacity_mah <= 0) { + socStats.ttl_hours = 0; // Capacity unknown + return; + } + + // Trapped Charge model: cold temperatures "lock" the bottom of the discharge + // curve — the cell shuts down (OCV near cutoff + TX-peak IR-drop) while charge + // is still physically stored. trapped_mah is the unusable floor. + // trapped_mah = capacity × (1 − f(T)) e.g. 8000 × 0.17 = 1360 mAh at −10 °C + // extractable = max(0, remaining − trapped) + // This is more realistic than proportional scaling (remaining × f) because + // capacity loss at cold is not uniform — it steals from the bottom. + float remaining_mah = (socStats.current_soc_percent / 100.0f) * socStats.capacity_mah; + float trapped_mah = socStats.capacity_mah * (1.0f - socStats.temp_derating_factor); + float extractable_mah = remaining_mah - trapped_mah; + if (extractable_mah < 0.0f) extractable_mah = 0.0f; + + // Daily deficit (negative value) + float deficit_per_day = -socStats.avg_7day_daily_net_mah; + + if (deficit_per_day <= 0) { + socStats.ttl_hours = 0; + return; + } + + // Days until empty (based on extractable capacity) + float days_remaining = extractable_mah / deficit_per_day; + + // Convert to hours + socStats.ttl_hours = (uint16_t)(days_remaining * 24.0f); + + MESH_DEBUG_PRINTLN("TTL: %.1f days (%.0f mAh stored, %.0f trapped, %.0f extractable @d=%.2f, -%.0f mAh/day)", + days_remaining, remaining_mah, trapped_mah, extractable_mah, + socStats.temp_derating_factor, deficit_per_day); +} + +// ===== Tick-based Periodic Dispatch ===== + +// Called from InheroMr2Board::loop() — dispatches all periodic I2C work with +// millis()-based scheduling in the main loop context. +// Checks the INA228 alert and polls voltage as a fallback if the interrupt is missed. +void BoardConfigContainer::tickPeriodic() { + // First-call init: clear MPPT stats + if (!tickInitialized) { + memset(&mpptStats, 0, sizeof(mpptStats)); + tickInitialized = true; + } + + uint32_t now = millis(); + + if (lowVoltageSleepMv != 0 && ina228DriverInstance && lowVoltageSleepRetryPending) { + // An aborted sleep leaves all hardware running. Bound RTC retries and do + // not act later on an old latched alert after the voltage has recovered. + if (now - lastLowVoltageSleepAttemptMs >= 60000UL) { + lastLowVoltageSleepAttemptMs = now; + lowVoltageAlertFired = false; + ina228DriverInstance->clearAlert(); + uint16_t vbat_mv = ina228DriverInstance->readVoltage_mV(); + if (vbat_mv > 0 && vbat_mv < lowVoltageSleepMv) { + lowVoltageSleepRetryPending = false; + lowVoltageAlertFired = true; + } else if (vbat_mv >= lowVoltageSleepMv && digitalRead(INA_ALERT_PIN) == HIGH) { + lowVoltageSleepRetryPending = false; + lowVoltageAlertFired = false; + } + // An unreadable voltage or uncleared latch keeps the bounded retry mode. + } + } else if (lowVoltageSleepMv != 0 && ina228DriverInstance) { + if (digitalRead(INA_ALERT_PIN) == LOW) { + lowVoltageAlertFired = true; + } + + // Use the INA228's averaged voltage, independent of SOC and CLI requests. + if (!lowVoltageAlertFired && now - lastLowVoltageMs >= 1000UL) { + lastLowVoltageMs = now; + uint16_t vbat_mv = ina228DriverInstance->readVoltage_mV(); + // A failed I2C read returns 0; it is not a valid battery voltage sample. + if (vbat_mv > 0 && vbat_mv < lowVoltageSleepMv) { + MESH_DEBUG_PRINTLN("PWRMGT: VBAT %dmV below %dmV (voltage fallback)", vbat_mv, lowVoltageSleepMv); + lowVoltageAlertFired = true; + } + } + } + + // Check low-voltage alert flag (set by ISR, pin level, or voltage fallback) + if (lowVoltageAlertFired && !lowVoltageSleepRetryPending) { + MESH_DEBUG_PRINTLN("PWRMGT: Low-voltage alert fired - initiating System Sleep"); + blinkRed(1, 100, 100, leds_enabled); + blinkRed(3, 300, 300, leds_enabled); + + board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); + // Returns only when RTC wake could not be verified, before any shutdown. + lastLowVoltageSleepAttemptMs = millis(); + lowVoltageSleepRetryPending = true; + lowVoltageAlertFired = false; + if (ina228DriverInstance) ina228DriverInstance->clearAlert(); + } + + // Every ~60s: MPPT cycle (solar charging control) + 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..b87ed0d1da --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" + +#include + +#define SOLAR_MPPT_INTERVAL_MS (1 * 60 * 1000) // 1 minute + +#define MPPT_STATS_HOURS 168 // 7 days + +typedef struct { + uint8_t mpptEnabledMinutes; // 0–60 + uint32_t timestamp; // unix seconds + uint32_t harvestedEnergy_mWh; +} MpptHourlyStats; + +typedef struct { + MpptHourlyStats hours[MPPT_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastUpdateTime; // unix seconds, or millis if RTC unavailable + uint16_t currentHourMinutes; + bool usingRTC; + uint32_t currentHourEnergy_mWh; + int32_t lastPower_mW; +} MpptStatistics; + +// Battery SOC: mAh-based, uses INA228 hardware coulomb counter (CHARGE register) +#define HOURLY_STATS_HOURS 168 // 7 days + +typedef struct { + uint32_t timestamp; // start of hour, unix seconds + float charged_mah; + float discharged_mah; + float solar_mah; +} HourlyBatteryStats; + +typedef struct { + // Battery configuration + float capacity_mah; + float nominal_voltage; + + // SOC tracking via INA228 CHARGE register + float current_soc_percent; // 0–100 + bool soc_valid; // true after first "Charging Done" sync + float ina228_baseline_mah; // INA228 CHARGE at last 100% sync + + uint32_t last_soc_update_ms; + + // 168-hour rolling buffer + HourlyBatteryStats hours[HOURLY_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastHourUpdateTime; + + // Current hour accumulators + float current_hour_charged_mah; + float current_hour_discharged_mah; + float current_hour_solar_mah; + float last_charge_reading_mah; // last INA228 CHARGE for delta calc + + // Rolling window stats (computed from hourly buffer) + float last_24h_net_mah; + float last_24h_charged_mah; + float last_24h_discharged_mah; + float avg_3day_daily_net_mah; + float avg_3day_daily_charged_mah; + float avg_3day_daily_discharged_mah; + float avg_7day_daily_net_mah; + float avg_7day_daily_charged_mah; + float avg_7day_daily_discharged_mah; + // TTL: hours until battery empty (0 = not calculated). Based on 7-day rolling + // avg of daily net deficit from INA228 coulomb-counter samples. + uint16_t ttl_hours; + bool living_on_battery; // net deficit over last 24h + uint16_t soc_update_count; + float temp_derating_factor; // 0.0–1.0 + float last_battery_temp_c; +} BatterySOCStats; + +class BoardConfigContainer { + +public: + enum BatteryType : uint8_t { BAT_UNKNOWN = 0, LTO_2S = 1, LIFEPO4_1S = 2, LIION_1S = 3, NAION_1S = 4 }; + typedef struct { + const char* command_string; + BatteryType type; + } BatteryMapping; + + // Battery type properties + typedef struct { + BatteryType type; + float charge_voltage; + float nominal_voltage; + uint16_t lowv_sleep_mv; // INA228 ALERT → System Sleep + uint16_t lowv_wake_mv; // 0% SOC marker, RTC wake decision + bool charge_enable; + // Whether the board supervises the charge temperature for this chemistry. + // false forces the JEITA override on: LTO because its anode sits far above + // the plating potential, Na-ion by product decision — the permissible charge + // window differs per cell (0 to -20 C by manufacturer) and is left to the + // cell datasheet, not enforced here. true (Li-ion, LiFePO4) leaves the + // override off unless the user sets board.jeitaignore and passes the 0.05C + // gate. + bool needs_jeita; + // Capacity derating at cold temps. Calibrated for ~0.01C avg / ~0.05C TX + // peak loads on 2–8 Ah cells (much milder than datasheet 0.2C–0.5C values). + // f(T) = 1.0 for T >= temp_ref_c + // f(T) = max(temp_derating_min, 1 - k*(Tref - T)) for T < temp_ref_c + float temp_derating_k; + float temp_derating_min; + float temp_ref_c; + } BatteryProperties; + + static inline constexpr BatteryProperties battery_properties[] = { + // Type ChgV NomV SleepMv WakeMv ChgEn NeedsJ k min Tref + { BAT_UNKNOWN, 0.0f, 0.0f, 2000, 2200, false, false, 0.000f, 1.00f, 25.0f }, + { LTO_2S, 5.4f, 4.6f, 3900, 4100, true, false, 0.002f, 0.88f, 25.0f }, + { LIFEPO4_1S, 3.5f, 3.2f, 2700, 2900, true, true, 0.006f, 0.70f, 25.0f }, + { LIION_1S, 4.1f, 3.7f, 3100, 3300, true, true, 0.005f, 0.75f, 25.0f }, + { NAION_1S, 3.9f, 3.1f, 2500, 2700, true, false, 0.003f, 0.85f, 25.0f } + }; + + static inline constexpr BatteryMapping bat_map[] = { { "lto2s", LTO_2S }, + { "lifepo1s", LIFEPO4_1S }, + { "liion1s", LIION_1S }, + { "naion1s", NAION_1S }, + { "none", BAT_UNKNOWN }, + { nullptr, BAT_UNKNOWN } }; + + enum FrostChargeBehaviour : uint8_t { + NO_CHARGE = 4, + I_REDUCE_TO_20 = 3, + I_REDUCE_TO_40 = 2, + NO_REDUCE = 1, + REDUCE_UNKNOWN = 0 + }; + typedef struct { + const char* command_string; + FrostChargeBehaviour type; + } FrostChargeBehaviourMapping; + + static inline constexpr FrostChargeBehaviourMapping frostchargebehaviour_map[] = { + { "0%", NO_CHARGE }, + { "20%", I_REDUCE_TO_20 }, + { "40%", I_REDUCE_TO_40 }, + { "100%", NO_REDUCE }, + { nullptr, REDUCE_UNKNOWN } + }; + + // Defaults for newly flashed boards + static constexpr BatteryType DEFAULT_BATTERY_TYPE = BAT_UNKNOWN; + static constexpr FrostChargeBehaviour DEFAULT_FROST_BEHAVIOUR = NO_CHARGE; + static constexpr uint16_t DEFAULT_MAX_CHARGE_CURRENT_MA = 200; + static constexpr bool DEFAULT_MPPT_ENABLED = false; + // REG00[5:0] = 0: allow MPPT above the Na-ion 2.50V sleep threshold. + // Keep below VBAT to avoid minimum-system regulation and linear BATFET loss. + static constexpr float BQ_MIN_SYSTEM_V = 2.50f; + static constexpr float MIN_STATION_ALTITUDE_M = -500.0f; + static constexpr float MAX_STATION_ALTITUDE_M = 9000.0f; + + // 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; + + 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); + + // Installation altitude used to reduce BME280 station pressure to QNH. + // Returns false when the operator has not configured an altitude yet. + bool getStationAltitude(float& altitude_m) const; + bool setStationAltitude(float altitude_m); + bool clearStationAltitude(); + + float getMaxChargeVoltage() const; + + bool begin(); + + // 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); + void getMpptDiagnostics(char* buffer, uint32_t bufferSize); + + // "INA:OK BQ:OK RTC:OK BME:OK". RTC probe writes/reads user-RAM to catch + // zombie chips that ACK but don't persist. + 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(); + + // Accepted user override; rejected requests are never stored or reactivated. + bool setJeitaIgnore(bool on); + bool getJeitaIgnoreEnabled() const; + bool isJeitaIgnoreActive() const { return jeitaIgnoreActive; } + bool jeitaIgnoreGateOk() const; // batcap user-set AND imax < 0.05C + static bool isJeitaIgnoreCurrentAllowed(uint16_t imax_mA, float capacity_mah) { + return capacity_mah >= 100.0f && capacity_mah <= 100000.0f && + static_cast(imax_mA) * 20.0 < static_cast(capacity_mah); + } + bool applyJeitaIgnore(); // re-derive for the current chemistry + + // INA228 ALERT on P1.02 (Rev 1.1) + void armLowVoltageAlert(BatteryType type); + 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 + static uint16_t lowVoltageSleepMv; // Active threshold; 0 when disarmed + + // Tick scheduling (millis-based, overflow-safe) + uint32_t lastMpptMs = 0; + uint32_t lastSocMs = 0; + uint32_t lastLowVoltageMs = 0; + uint32_t lastLowVoltageSleepAttemptMs = 0; + bool lowVoltageSleepRetryPending = false; // RTC failure: retry at most once/minute + uint32_t lastHourlyMs = 0; // Last updateHourlyStats() execution + bool tickInitialized = false; // First-call init flag for MPPT stats + + void runMpptCycle(); // Single MPPT cycle + static MpptStatistics mpptStats; // MPPT statistics data + static BatterySOCStats socStats; // Battery SOC statistics + // Cached battery type for static methods (set by begin()/setBatteryType()) + static BatteryType cachedBatteryType; + + bool bqInitialized = false; + bool ina228Initialized = false; + bool lowVoltageRecovery = false; // Set in begin() if booting from low-voltage sleep (GPREGRET2) + static bool leds_enabled; // Heartbeat and BQ stat LED control (static for ISR access) + static bool usbInputActive; // True when USB VBUS detected — caps IINDPM to 500mA + static float tcCalOffset; // NTC temperature calibration offset in °C (0.0 = no calibration) + static bool jeitaIgnoreActive; // effective JEITA override state (derived, never persisted) + // NTC plausibility: an open/missing NTC decodes through the RT2-only pole to + // ≈-46°C regardless of ambient — in DC indistinguishable from a real NTC at + // that temperature. The BME280 is the reference; readings further apart than + // this are discarded as "not the battery". + static constexpr float NTC_BME_MAX_DIFF_C = 15.0f; + // Last valid battery temperature in °C, updated by getTelemetryData() or + // BME280 fallback (default 25.0 = no derating) + static float lastValidBatteryTemp; + static uint32_t lastTempUpdateMs; // millis() of last valid temperature update (0 = never updated) + + // Refresh socStats.temp_derating_factor and last_battery_temp_c. + // Falls back to BME280 if NTC has not updated for >5 min. + static void refreshTempDerating(); + + bool configureBaseBQ(); + bool configureChemistry(BatteryType type); + float performTcCalibration(float actual_temp_c); // Internal: calibrate NTC given reference temp (called by BME auto-cal) + static constexpr const char* PREFS_NAMESPACE = "inheromr2"; + static constexpr const char* BATTKEY = "batType"; + static constexpr const char* FROSTKEY = "frost"; + static constexpr const char* MAXCHARGECURRENTKEY = "maxChrg"; + static constexpr const char* MPPTENABLEKEY = "mpptEn"; + static constexpr const char* LEDSKEY = "leds_en"; + static constexpr const char* BATTERY_CAPACITY_KEY = "batCap"; + static constexpr const char* TCCAL_KEY = "tcCal"; // NTC temperature calibration offset + static constexpr const char* JEITAIGNKEY = "jeitaIgn"; // accepted JEITA user override + static constexpr const char* ALTITUDEKEY = "altitude"; // BME280 installation altitude (m) + + bool applyJeitaIgnore(const BatteryProperties* props); // derive + program TS_IGNORE/ISETC/ISETH + bool loadJeitaIgnoreEnabled(bool& on) const; + bool loadBatType(BatteryType& type) const; + bool loadFrost(FrostChargeBehaviour& behaviour) const; + bool loadMaxChrgI(uint16_t& maxCharge_mA) const; + bool loadBatteryCapacity(float& capacity_mah) const; + bool loadTcCalOffset(float& offset) const; // NTC temperature calibration + bool loadStationAltitude(float& altitude_m) const; + + // 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 +}; diff --git a/variants/inhero_mr2/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp new file mode 100644 index 0000000000..f3ad20dc4b --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -0,0 +1,597 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Inhero MR2 Board Implementation + */ + +#include "InheroMr2Board.h" + +#include "BoardConfigContainer.h" +#include "helpers/BatteryOcvMapping.h" +#include "helpers/BqLowPowerSetup.h" +#include "helpers/CliCommands.h" +#include "helpers/I2cBusRecovery.h" +#include "helpers/Rv3028Wake.h" +#include "helpers/SystemSleepGpio.h" +#include "helpers/UsbAutoManagement.h" +#include "target.h" + +#include +#include +#include + +static BoardConfigContainer boardConfig; +volatile bool InheroMr2Board::rtc_irq_pending = false; +volatile uint32_t InheroMr2Board::ota_dfu_reset_at = 0; + +// CE output is retained during SYSTEMOFF, but GPIO configuration resets on wake. +// Restore only the charge permission from the stored chemistry (unknown = off). +static bool restoreConfiguredChargeEnable() { + const auto* props = BoardConfigContainer::getBatteryProperties(boardConfig.getBatteryType()); + bool enabled = props && props->charge_enable; +#ifdef BQ_CE_PIN + digitalWrite(BQ_CE_PIN, enabled ? HIGH : LOW); + pinMode(BQ_CE_PIN, OUTPUT); +#endif + return enabled; +} + +// ===== Public Methods ===== + +void InheroMr2Board::begin() { + // === 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) && + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)) { + // Only resleep after verifying the next wake, before shutting down any IC. + // INA228 ADC needs shutdown (readVBATDirect left it in one-shot mode). + + bool chargeEnabled = restoreConfiguredChargeEnable(); + if (chargeEnabled) { + inhero::maintainSolarDuringLowVoltageWake(boardConfig.getMPPTEnabled()); + } + + // 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); + + 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, or wake setup failed: complete boot so the board remains + // serviceable and the main loop can retry low-voltage sleep with its WDT active. + Wire.end(); + + // Normal boot 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: Normal boot (VBAT=%dmV, wake=%dmV)", 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 && + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)) { + MESH_DEBUG_PRINTLN("ColdBoot below sleep threshold (%dmV < %dmV)", vbat_mv, sleep_threshold); + MESH_DEBUG_PRINTLN("Going to sleep for %d min to avoid motorboating", LOW_VOLTAGE_SLEEP_MINUTES); + + 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(); + + // Hold CE according to the configured chemistry during sleep + restoreConfiguredChargeEnable(); + + // 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(); + + 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(); + } + else if (vbat_mv < sleep_threshold) { + MESH_DEBUG_PRINTLN("ColdBoot: RTC wake unavailable - continuing normal boot"); + } + // Normal ColdBoot — voltage OK + else { + MESH_DEBUG_PRINTLN("Normal ColdBoot - voltage OK (%dmV >= %dmV)", vbat_mv, sleep_threshold); + } + } + } + + // === Normal boot path: Initialize board hardware === + // Also reached if RTC wake verification failed; keep normal operation available. + // boardConfig.begin() initializes BQ25798, INA228, CE pin, alerts, LEDs, etc. + MESH_DEBUG_PRINTLN("Initializing Rev 1.1 features (BQ25798, INA228, RTC, CE-FET)"); + boardConfig.begin(); + sensors.setBoardTelemetrySource(boardConfig); + + float station_altitude_m = NAN; + if (boardConfig.getStationAltitude(station_altitude_m)) { + sensors.setBme280StationAltitude(station_altitude_m); + MESH_DEBUG_PRINTLN("BME280 pressure correction: QNH at %.1f m", station_altitude_m); + } else { + sensors.setBme280StationAltitude(NAN); + MESH_DEBUG_PRINTLN("BME280 pressure correction: disabled (station pressure)"); + } + + // Handle low-voltage recovery (deferred until after boardConfig.begin()) + if (isLowVoltageRecovery) { + boardConfig.setLowVoltageRecovery(); + 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::loop() { + 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. + // loop() will handle cleanup (stop tasks, radio off) and reset after the delay. + ota_dfu_reset_at = millis() + 3000; // 3s delay to ensure reply is transmitted + + return true; +} + +// Handles this board's own 'get board.*' / 'set board.*' CLI commands. +// CommonCLI::handleCommand() offers every command to the board before its own +// dispatch, so returning false just falls through to the core CLI. +// +// The reply buffer carries no length. The smallest one a caller on this board's +// builds provides is 160 bytes (simple_repeater/main.cpp, simple_sensor/main.cpp); +// the DM path gives 161. +bool InheroMr2Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + const uint32_t maxlen = 160; + + if (strncmp(command, "get board.", 10) == 0) { + return inhero::handleGet(boardConfig, &command[10], reply, maxlen); + } + if (strncmp(command, "set board.", 10) == 0) { + const char* result = inhero::handleSet(boardConfig, &command[10]); + if (result == NULL) return false; + + float station_altitude_m = NAN; + sensors.setBme280StationAltitude( + boardConfig.getStationAltitude(station_altitude_m) ? station_altitude_m : NAN); + + strncpy(reply, result, maxlen - 1); + reply[maxlen - 1] = 0; + return true; + } + return false; +} + +// ===== Power Management Methods (Rev 1.1) ===== + +// Get low-voltage sleep threshold (chemistry-specific) +uint16_t InheroMr2Board::getLowVoltageSleepThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageSleepThreshold(chemType); +} + +// Get low-voltage wake threshold (chemistry-specific) +uint16_t InheroMr2Board::getLowVoltageWakeThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageWakeThreshold(chemType); +} + +// Initiate controlled shutdown with filesystem protection (Rev 1.1) + +// Rev 1.1 low-voltage shutdown uses System Sleep with GPIO latch (< 500µA total): +// - INA228 enters shutdown mode (~3.5µA) +// - BQ CE pin latched HIGH via FET (solar charging continues autonomously) +// - RTC countdown timer configured for periodic wake +// - nRF52 enters SYSTEMOFF (~1.5µA) — GPIO latches preserved +// - RTC wake triggers reboot; Early Boot checks voltage for boot vs sleep-again +void InheroMr2Board::initiateShutdown(uint8_t reason) { + MESH_DEBUG_PRINTLN("PWRMGT: Initiating shutdown (reason=0x%02X)", reason); + + // A failed wake setup must leave normal operation intact. Check before + // stopping tasks, putting the INA/radio to sleep, or storing a sleep marker. + if (reason == SHUTDOWN_REASON_LOW_VOLTAGE && + !configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)) { + MESH_DEBUG_PRINTLN("PWRMGT: RTC wake unavailable - low-voltage sleep aborted"); + return; + } + + // 1. Stop background tasks to prevent filesystem corruption + BoardConfigContainer::stopBackgroundTasks(); + + // 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. Hold the configured CE level (HIGH enables charging via FET) + // GPIO output latch survives System Sleep as long as VDD is present + restoreConfiguredChargeEnable(); + + // 5b. INA228 + BQ25798 -> minimum sleep current. Must be AFTER CE restore + // (charge enable may re-enable BQ ADC). Repeats INA228 shutdown via raw I2C + // with readback as a safety net if the driver call in step 2 silently failed. + inhero::prepareIcsForSystemOff(); + + // 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"); + + // 7. Clear GPIO LATCH for RTC INT pin (timer was verified before shutdown). + // If a previous RTC wake cycle set the LATCH (retained across System Sleep), + // DETECT would fire immediately → instant wake → boot loop. + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + // 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(); +} + +bool InheroMr2Board::configureRTCWake(uint32_t minutes) { + uint16_t ticks = static_cast( + minutes == 0 ? LOW_VOLTAGE_SLEEP_MINUTES + : (minutes > 4095 ? 4095 : minutes)); + for (uint8_t attempt = 0; attempt < 2; ++attempt) { + if (inhero::configurePeriodicWake(ticks) && digitalRead(RTC_INT_PIN) == HIGH) { + return true; + } + if (attempt == 0) { + // One bounded recovery/retry, using the existing board bus recovery. + Wire.end(); + inhero::recoverI2cBus(PIN_BOARD_SDA, PIN_BOARD_SCL); + Wire.begin(); + delay(10); + } + } + MESH_DEBUG_PRINTLN("PWRMGT: RTC wake verification failed"); + return false; +} + +void InheroMr2Board::rtcInterruptHandler() { + // 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..3af8747176 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#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 + +// Hardware test builds may override the interval; normal firmware wakes hourly. +#ifndef LOW_VOLTAGE_SLEEP_MINUTES +#define LOW_VOLTAGE_SLEEP_MINUTES (60) +#endif + +class InheroMr2Board : public NRF52BoardDCDC { +public: + InheroMr2Board() : NRF52Board("InheroMR2_OTA") {} + void begin(); + void loop() override; + + uint16_t getBattMilliVolts() override; + + void initiateShutdown(uint8_t reason); + // Returns false unless the timer registers and released INT pin are verified. + bool configureRTCWake(uint32_t minutes); + uint16_t getLowVoltageSleepThreshold(); + uint16_t getLowVoltageWakeThreshold(); + + static void rtcInterruptHandler(); + + const char *getManufacturerName() const override { return "Inhero MR2"; } + void reboot() override { NVIC_SystemReset(); } + + bool startOTAUpdate(const char *id, char reply[]) override; + bool handleCommand(const char *command, uint32_t sender_timestamp, char *reply) override; + +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/InheroMr2SensorManager.cpp b/variants/inhero_mr2/InheroMr2SensorManager.cpp new file mode 100644 index 0000000000..be8f4f5f3b --- /dev/null +++ b/variants/inhero_mr2/InheroMr2SensorManager.cpp @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "InheroMr2SensorManager.h" +#include "helpers/BoardTelemetry.h" + +#include +#include +#include + +#ifndef TELEM_BME280_ADDRESS +#define TELEM_BME280_ADDRESS 0x76 +#endif + +namespace { +Adafruit_BME280 bme280; +float station_altitude_m = NAN; + +void queryBme280(uint8_t channel, uint8_t, CayenneLPP& telemetry) { + if (!bme280.takeForcedMeasurement()) return; + + telemetry.addTemperature(channel, bme280.readTemperature()); + telemetry.addRelativeHumidity(channel, bme280.readHumidity()); + const float pressure_hpa = bme280.readPressure() / 100.0f; + if (isnan(station_altitude_m)) { + telemetry.addBarometricPressure(channel, pressure_hpa); + telemetry.addAltitude(channel, bme280.readAltitude(1013.25f)); + } else { + telemetry.addBarometricPressure( + channel, InheroMr2SensorManager::pressureToQnh(pressure_hpa, station_altitude_m)); + telemetry.addAltitude(channel, station_altitude_m); + } +} +} + +bool InheroMr2SensorManager::begin() { + // The base resets the registry and retains optional GPS and other sensors. + if (!EnvironmentSensorManager::begin()) return false; + if (_active_sensor_count >= MAX_ACTIVE_SENSORS) return true; + +#if ENV_PIN_SDA && ENV_PIN_SCL + TwoWire* wire = &Wire1; +#else + TwoWire* wire = &Wire; +#endif + const uint8_t addresses[] = { + TELEM_BME280_ADDRESS, TELEM_BME280_ADDRESS == 0x77 ? 0x76 : 0x77 + }; + for (uint8_t address : addresses) { + wire->beginTransmission(address); + if (wire->endTransmission() != 0) continue; + if (!bme280.begin(address, wire)) continue; + + bme280.setSampling(Adafruit_BME280::MODE_FORCED, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::FILTER_OFF, + Adafruit_BME280::STANDBY_MS_1000); + _active_sensors[_active_sensor_count++] = { queryBme280, 0 }; + MESH_DEBUG_PRINTLN("Found MR2 BME280 at address: %02X", address); + break; // One physical BME280; the second address is only a fallback. + } + return true; +} + +bool InheroMr2SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + const bool result = EnvironmentSensorManager::querySensors(requester_permissions, telemetry); + if (_board_config && (requester_permissions & TELEM_PERM_ENVIRONMENT)) { + inhero::appendBoardTelemetry(*_board_config, telemetry); + } + return result; +} + +void InheroMr2SensorManager::setBme280StationAltitude(float altitude_m) { + station_altitude_m = altitude_m; +} + +float InheroMr2SensorManager::getBme280StationAltitude() { + return station_altitude_m; +} + +float InheroMr2SensorManager::pressureToQnh(float pressure_hpa, float altitude_m) { + // ICAO standard atmosphere, matching Adafruit's seaLevelForAltitude(). + return pressure_hpa / powf(1.0f - altitude_m / 44330.0f, 5.255f); +} diff --git a/variants/inhero_mr2/InheroMr2SensorManager.h b/variants/inhero_mr2/InheroMr2SensorManager.h new file mode 100644 index 0000000000..3306dac52b --- /dev/null +++ b/variants/inhero_mr2/InheroMr2SensorManager.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +class BoardConfigContainer; + +class InheroMr2SensorManager : public EnvironmentSensorManager { + BoardConfigContainer* _board_config = nullptr; + +public: + using EnvironmentSensorManager::EnvironmentSensorManager; + + bool begin() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + + // The board owns and initializes the telemetry source before sensor setup. + void setBoardTelemetrySource(BoardConfigContainer& config) { _board_config = &config; } + + // NAN keeps station pressure and the pressure-derived altitude. + static void setBme280StationAltitude(float altitude_m); + static float getBme280StationAltitude(); + static float pressureToQnh(float pressure_hpa, float altitude_m); +}; diff --git a/variants/inhero_mr2/README.md b/variants/inhero_mr2/README.md new file mode 100644 index 0000000000..93112b4b6a --- /dev/null +++ b/variants/inhero_mr2/README.md @@ -0,0 +1,39 @@ +# Inhero MR2 + +Purpose-built solar repeater board: RAK4630 (nRF52840 + SX1262), BQ25798 +buck/boost charger with universal 3.6–24 V solar input and MPPT, INA228 +coulomb counter, RV-3028 RTC, BME280 environment sensor, 45 × 40 mm, +CE-certified (RED 2014/53/EU). + +Supports Li-ion, LiFePO4, LTO and Na-ion battery profiles, solar-input recovery +and RTC wakeup after low-voltage sleep. An optional installation altitude +(`set board.altitude `) enables QNH pressure telemetry from the BME280. + +The charger uses a 2.50 V minimum system voltage and reapplies the MPPT preference +after restoring the battery profile. `get board.mpptdiag` reports the requested +and actual MPPT state, voltage/current settings and minimum-system status. + +Power-management optimizations combine averaged voltage monitoring, checked +RTC wake scheduling and a one-hour recovery interval. RTC configuration is +persisted for the MR2 supply arrangement without a separate backup battery. + +Changing the battery chemistry resets charging settings to 200 mA, MPPT off, +frost charging off, no user-set capacity and no user JEITA override. Reapplying +the same chemistry preserves settings. Enabling `board.jeitaignore` for a +JEITA-controlled chemistry requires an explicit `board.batcap` and charge +current strictly below 0.05C; rejected requests are not saved for later. + +Build environments: + +```bash +pio run -e Inhero_MR2_repeater +pio run -e Inhero_MR2_repeater_bridge_rs232 +pio run -e Inhero_MR2_sensor +``` + +Full documentation — quick start, datasheet, battery chemistry guide, power +management, telemetry, CLI reference and FAQ — is maintained by the manufacturer: + +- [English documentation](https://docs.inhero.de/en/mr2/) +- [Deutsche Dokumentation](https://docs.inhero.de/mr2/) +- [Documentation sources in the vendor fork](https://github.com/liekmarflow/MeshCore/tree/main/variants/inhero_mr2/docs) diff --git a/variants/inhero_mr2/helpers/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/BoardTelemetry.cpp b/variants/inhero_mr2/helpers/BoardTelemetry.cpp new file mode 100644 index 0000000000..667aed8878 --- /dev/null +++ b/variants/inhero_mr2/helpers/BoardTelemetry.cpp @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BoardTelemetry.h" + +#include "../BoardConfigContainer.h" +#include +#include + +namespace inhero { + +namespace { + +uint8_t getLPPDataLength(uint8_t type) { + switch (type) { + case LPP_DIGITAL_INPUT: + case LPP_DIGITAL_OUTPUT: + case LPP_PRESENCE: + case LPP_RELATIVE_HUMIDITY: + case LPP_PERCENTAGE: + case LPP_SWITCH: + return 1; + case LPP_ANALOG_INPUT: + case LPP_ANALOG_OUTPUT: + case LPP_LUMINOSITY: + case LPP_TEMPERATURE: + case LPP_BAROMETRIC_PRESSURE: + case LPP_VOLTAGE: + case LPP_CURRENT: + case LPP_ALTITUDE: + case LPP_POWER: + case LPP_DIRECTION: + case LPP_CONCENTRATION: + return 2; + case LPP_COLOUR: + return 3; + case LPP_GENERIC_SENSOR: + case LPP_FREQUENCY: + case LPP_DISTANCE: + case LPP_ENERGY: + case LPP_UNIXTIME: + return 4; + case LPP_ACCELEROMETER: + case LPP_GYROMETER: + return 6; + case LPP_GPS: + return 9; + case LPP_POLYLINE: + return 8; // minimum size + default: + return 0; + } +} + +uint8_t findNextFreeLppChannel(CayenneLPP& lpp) { + uint8_t max_channel = 0; + uint8_t cursor = 0; + uint8_t* buffer = lpp.getBuffer(); + uint8_t size = lpp.getSize(); + + while (cursor < size) { + if (cursor + 1 >= size) break; + uint8_t channel = buffer[cursor]; + uint8_t type = buffer[cursor + 1]; + uint8_t data_len = getLPPDataLength(type); + if (data_len == 0) break; // unknown type, can't continue + if (channel > max_channel) max_channel = channel; + cursor += 2 + data_len; + } + return max_channel + 1; +} + +} // namespace + +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry) { + const Telemetry* telemetryData = cfg.getTelemetryData(); + if (!telemetryData) return false; + + uint8_t batteryChannel = findNextFreeLppChannel(telemetry); + uint8_t solarChannel = batteryChannel + 1; + + const BatterySOCStats* socStats = cfg.getSOCStats(); + bool hasValidSoc = (socStats && socStats->soc_valid); + float socPercent = roundf(cfg.getStateOfCharge() * 10.0f) / 10.0f; + + uint16_t ttlHours = cfg.getTTL_Hours(); + bool isInfiniteTtl = (socStats && socStats->soc_valid && !socStats->living_on_battery); + constexpr float MAX_TTL_DAYS = 990.0f; // sentinel reported when TTL is effectively infinite + + // Battery: VBAT[V], SOC[%] (opt), IBAT[A], TBAT[°C], TTL[d] (opt) + telemetry.addVoltage(batteryChannel, telemetryData->battery.voltage / 1000.0f); + if (hasValidSoc) telemetry.addPercentage(batteryChannel, socPercent); + telemetry.addCurrent(batteryChannel, telemetryData->battery.current / 1000.0f); + if (telemetryData->battery.temperature > -100.0f) { + telemetry.addTemperature(batteryChannel, telemetryData->battery.temperature); + } + if (ttlHours > 0) { + telemetry.addDistance(batteryChannel, ttlHours / 24.0f); + } else if (isInfiniteTtl) { + telemetry.addDistance(batteryChannel, MAX_TTL_DAYS); + } + + // Solar: VSOL[V], ISOL[A], MPPT_7D[%] + if (telemetryData->solar.valid) { + telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); + telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + } + telemetry.addPercentage(solarChannel, cfg.getMpptEnabledPercentage7Day()); + + return true; +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BoardTelemetry.h b/variants/inhero_mr2/helpers/BoardTelemetry.h new file mode 100644 index 0000000000..c688de5a1f --- /dev/null +++ b/variants/inhero_mr2/helpers/BoardTelemetry.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +class BoardConfigContainer; +class CayenneLPP; + +namespace inhero { + +// Appends the MR2 battery and solar values after the existing sensor channels. +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp new file mode 100644 index 0000000000..cfe8049f8a --- /dev/null +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BqLowPowerSetup.h" + +#include +#include +#include + +#include "../InheroMr2Board.h" +#include "../lib/BqDriver.h" + +namespace inhero { + +static constexpr uint8_t INA228_ADDR = 0x40; + +namespace { +bool readBq(uint8_t reg, uint8_t* data, uint8_t count = 1) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)BQ25798_I2C_ADDR, count) != count) { + while (Wire.available()) Wire.read(); + return false; + } + for (uint8_t i = 0; i < count; ++i) data[i] = Wire.read(); + return true; +} + +bool writeBq(uint8_t reg, uint8_t value) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(reg); + Wire.write(value); + return Wire.endTransmission() == 0; +} +} + +void maintainSolarDuringLowVoltageWake(bool mpptEnabled) { + // The caller already checked charge_enable and restored the CE output. + if (!mpptEnabled) return; + + // At deep discharge the charger may be unpowered. Never use failed reads + // as status or ADC data, and never initialise/reset its retained settings. + Wire.beginTransmission(BQ25798_I2C_ADDR); + if (Wire.endTransmission() != 0) { + MESH_DEBUG_PRINTLN("LV-Wake: BQ unavailable, skipping solar maintenance"); + return; + } + + // Do not require an ADC conversion: low VBAT + HIZ can prevent it from + // starting. Source qualification itself rejects missing/weak input power. + uint8_t status; + if (!readBq(0x1B, &status)) return; + if (!(status & 0x08)) { + uint8_t control; + if (!readBq(BQ25798_REG_CHARGER_CONTROL_0, &control)) return; + if (!writeBq(BQ25798_REG_CHARGER_CONTROL_0, control | 0x04)) return; + delay(50); + // Retry clearing HIZ on transient bus errors so charging can resume. + bool cleared = false; + for (int retry = 0; retry < 3; ++retry) { + if (readBq(BQ25798_REG_CHARGER_CONTROL_0, &control) && + writeBq(BQ25798_REG_CHARGER_CONTROL_0, control & ~0x04)) { + cleared = true; + break; + } + delay(10); + } + if (!cleared) return; + MESH_DEBUG_PRINTLN("LV-Wake: PG=0, toggled HIZ"); + uint32_t start = millis(); + do { + delay(20); + if (!readBq(0x1B, &status)) return; + if (status & 0x08) break; + } while (millis() - start < 1000); + } + if (!(status & 0x08)) return; + uint8_t mppt; + if (readBq(0x15, &mppt) && !(mppt & 0x01)) { + // Below VSYSMIN the BQ may immediately reset EN_MPPT. Verify before logging. + if (writeBq(0x15, mppt | 0x01) && readBq(0x15, &mppt) && (mppt & 0x01)) + MESH_DEBUG_PRINTLN("LV-Wake: MPPT re-enabled"); + } +} + +void prepareIcsForSystemOff() { + // INA228 -> shutdown mode (~3.5uA vs ~350uA continuous). + // I2C writes can fail silently -> retry with readback verification. + 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..84dd2576ed --- /dev/null +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.h @@ -0,0 +1,20 @@ +/* + * 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(); + +// Bounded solar recovery before returning to UV sleep; no driver init required. +// Caller must check configured charge_enable. !PG retries do not require ADC/VBUS. +void maintainSolarDuringLowVoltageWake(bool mpptEnabled); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp new file mode 100644 index 0000000000..5d1dd090bd --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -0,0 +1,415 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "CliCommands.h" + +#include "../BoardConfigContainer.h" + +#include +#include +#include +#include + +namespace inhero { + +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->needs_jeita || cfg.getJeitaIgnoreEnabled()) { + 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, "altitude") == 0) { + float altitude_m = 0.0f; + if (cfg.getStationAltitude(altitude_m)) { + snprintf(reply, maxlen, "%.1f m", altitude_m); + } else { + snprintf(reply, maxlen, "N/A (station pressure)"); + } + return true; + } else if (strcmp(cmd, "stats") == 0) { + const BatterySOCStats* socStats = cfg.getSOCStats(); + if (!socStats) { + snprintf(reply, maxlen, "N/A M:%.0f%%", cfg.getMpptEnabledPercentage7Day()); + return true; + } + + // Rolling windows incl. current-hour accumulators (visible before first hour boundary) + float last_24h_net = socStats->last_24h_net_mah + + socStats->current_hour_solar_mah + - socStats->current_hour_discharged_mah; + float last_24h_charged = socStats->last_24h_charged_mah + socStats->current_hour_charged_mah; + float last_24h_discharged = socStats->last_24h_discharged_mah + socStats->current_hour_discharged_mah; + const char* status = socStats->living_on_battery ? "BAT" : "SOL"; + uint16_t ttl = cfg.getTTL_Hours(); + float mppt_pct = cfg.getMpptEnabledPercentage7Day(); + + char ttlBuf[16]; + if (ttl >= 24) snprintf(ttlBuf, sizeof(ttlBuf), "%dd%dh", ttl / 24, ttl % 24); + else if (ttl > 0) snprintf(ttlBuf, sizeof(ttlBuf), "%dh", ttl); + else snprintf(ttlBuf, sizeof(ttlBuf), "N/A"); + + snprintf(reply, maxlen, + "%+.0f/%+.0f/%+.0fmAh C:%.0f D:%.0f 3C:%.0f 3D:%.0f 7C:%.0f 7D:%.0f %s M:%.0f%% BT:%s", + last_24h_net, socStats->avg_3day_daily_net_mah, socStats->avg_7day_daily_net_mah, + last_24h_charged, last_24h_discharged, + socStats->avg_3day_daily_charged_mah, socStats->avg_3day_daily_discharged_mah, + socStats->avg_7day_daily_charged_mah, socStats->avg_7day_daily_discharged_mah, + status, mppt_pct, ttlBuf); + return true; + } else if (strcmp(cmd, "cinfo") == 0) { + char infoBuffer[100]; + cfg.getChargerInfo(infoBuffer, sizeof(infoBuffer)); + snprintf(reply, maxlen, "%s", infoBuffer); + return true; + } else if (strcmp(cmd, "bqdiag") == 0) { + char diagBuffer[100]; + cfg.getBqDiagnostics(diagBuffer, sizeof(diagBuffer)); + snprintf(reply, maxlen, "%s", diagBuffer); + return true; + } else if (strcmp(cmd, "mpptdiag") == 0) { + char diagBuffer[100]; + cfg.getMpptDiagnostics(diagBuffer, sizeof(diagBuffer)); + snprintf(reply, maxlen, "%s", diagBuffer); + return true; + } else if (strcmp(cmd, "selftest") == 0) { + char stBuffer[64]; + cfg.getSelfTest(stBuffer, sizeof(stBuffer)); + 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 solar_str[32]; + if (telemetry->solar.valid) { + snprintf(solar_str, sizeof(solar_str), "%.2fV/%s", telemetry->solar.voltage / 1000.0f, sol_current_str); + } else { + snprintf(solar_str, sizeof(solar_str), "N/A"); + } + + char temp_str[8]; + if (telemetry->battery.temperature <= -100.0f) { + snprintf(temp_str, sizeof(temp_str), "N/A"); + } 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:%s", + telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, + soc, derated_soc, solar_str); + } else { + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% S:%s", + telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, + soc, solar_str); + } + } else { + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:N/A S:%s", + telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, + solar_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->needs_jeita || cfg.getJeitaIgnoreEnabled()) + ? "N/A" + : BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour()); + + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + snprintf(reply, maxlen, "B:%s (no battery, charging disabled)", batType); + } else { + float chargeVoltage = cfg.getMaxChargeVoltage(); + float voltage0Soc = + BoardConfigContainer::getLowVoltageWakeThreshold(cfg.getBatteryType()) / 1000.0f; + const char* imax = cfg.getChargeCurrentAsStr(); + bool mpptEnabled = cfg.getMPPTEnabled(); + // J:1 appears only while the user override is on — for chemistries that + // run without JEITA anyway the line is unchanged. + const char* jeitaMark = + (confProps && confProps->needs_jeita && cfg.getJeitaIgnoreEnabled()) ? " J:1" : ""; + snprintf(reply, maxlen, "B:%s F:%s M:%s I:%s Vco:%.2f V0:%.2f%s", batType, frostBehaviour, + mpptEnabled ? "1" : "0", imax, chargeVoltage, voltage0Soc, jeitaMark); + } + return true; + } else if (strcmp(cmd, "tccal") == 0) { + snprintf(reply, maxlen, "TC offset: %+.2f C (0.00=default)", cfg.getTcCalOffset()); + return true; + } else if (strcmp(cmd, "leds") == 0) { + snprintf(reply, maxlen, "LEDs: %s (Heartbeat + BQ Stat)", + cfg.getLEDsEnabled() ? "ON" : "OFF"); + return true; + } else if (strcmp(cmd, "batcap") == 0) { + float capacity_mah = cfg.getBatteryCapacity(); + bool explicitly_set = cfg.isBatteryCapacitySet(); + snprintf(reply, maxlen, "%.0f mAh (%s)", capacity_mah, explicitly_set ? "set" : "default"); + return true; + } else if (strcmp(cmd, "jeitaignore") == 0) { + const auto* jiProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + // No chemistry set means no charging at all, so the question has no answer yet. + snprintf(reply, maxlen, "N/A"); + } else if (jiProps && !jiProps->needs_jeita) { + snprintf(reply, maxlen, "jeitaignore 1 (chemistry)"); + } else if (cfg.getJeitaIgnoreEnabled()) { + snprintf(reply, maxlen, "jeitaignore 1"); + } else { + snprintf(reply, maxlen, "jeitaignore 0"); + } + return true; + } + + snprintf(reply, maxlen, + "Err: bat|fmax|imax|mppt|altitude|telem|stats|cinfo|conf|tccal|leds|batcap|jeitaignore"); + return true; +} + +const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { + static char ret[100]; + memset(ret, 0, sizeof(ret)); + + if (strncmp(setCommand, "bat ", 4) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[4])); + BoardConfigContainer::BatteryType bt = BoardConfigContainer::getBatteryTypeFromCommandString(value); + if (bt != BoardConfigContainer::BatteryType::BAT_UNKNOWN || strcmp(value, "none") == 0) { + if (!cfg.setBatteryType(bt)) return "Err: Battery setup failed"; + snprintf(ret, sizeof(ret), "Bat set to %s", + BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType())); + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", + BoardConfigContainer::getAvailableBatOptions()); + } + return ret; + } else if (strncmp(setCommand, "fmax ", 5) == 0) { + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + return "Err: Set board.bat first"; + } + const auto* fmaxProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (fmaxProps && !fmaxProps->needs_jeita) { + snprintf(ret, sizeof(ret), "Err: Fmax setting N/A for this chemistry (JEITA disabled)"); + return ret; + } + if (cfg.getJeitaIgnoreEnabled()) { + snprintf(ret, sizeof(ret), "Err: Fmax N/A while jeitaignore is on"); + return ret; + } + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + BoardConfigContainer::FrostChargeBehaviour fcb = + BoardConfigContainer::getFrostChargeBehaviourFromCommandString(value); + if (fcb != BoardConfigContainer::FrostChargeBehaviour::REDUCE_UNKNOWN) { + cfg.setFrostChargeBehaviour(fcb); + snprintf(ret, sizeof(ret), "Fmax charge current set to %s of imax", + BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour())); + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", + BoardConfigContainer::getAvailableFrostChargeBehaviourOptions()); + } + return ret; + } else if (strncmp(setCommand, "imax ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + int ma = atoi(value); + if (ma >= 50 && ma <= 1500) { + if (cfg.getJeitaIgnoreEnabled() && + (!cfg.isBatteryCapacitySet() || + !BoardConfigContainer::isJeitaIgnoreCurrentAllowed(ma, cfg.getBatteryCapacity()))) { + return "N/A, jeitaignore=1"; + } + if (!cfg.setMaxChargeCurrent_mA(ma)) { + return "Err: Charge current setup failed"; + } + 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, "altitude ", 9) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[9])); + if (strcmp(value, "clear") == 0) { + if (!cfg.clearStationAltitude()) { + return "Err: Failed to clear altitude"; + } + return "Altitude cleared (BME280 reports station pressure)"; + } + char* end = nullptr; + const float altitude_m = strtof(value, &end); + if (end == value || *end != '\0' || !isfinite(altitude_m) || + altitude_m < BoardConfigContainer::MIN_STATION_ALTITUDE_M || + altitude_m > BoardConfigContainer::MAX_STATION_ALTITUDE_M) { + return "Err: Try -500 to 9000 m"; + } + if (!cfg.setStationAltitude(altitude_m)) { + return "Err: Failed to store altitude"; + } + snprintf(ret, sizeof(ret), "Altitude set to %.1f m (BME280 pressure is now QNH)", altitude_m); + return ret; + } else if (strncmp(setCommand, "batcap ", 7) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[7])); + float capacity_mah = atof(value); + if (!(capacity_mah >= 100.0f && capacity_mah <= 100000.0f)) { + return "Err: Invalid capacity (100-100000 mAh)"; + } + char storedCapacity[20]; + snprintf(storedCapacity, sizeof(storedCapacity), "%.1f", capacity_mah); + capacity_mah = atof(storedCapacity); + if (cfg.getJeitaIgnoreEnabled() && + !BoardConfigContainer::isJeitaIgnoreCurrentAllowed(cfg.getMaxChargeCurrent_mA(), capacity_mah)) { + return "N/A, jeitaignore=1"; + } + if (cfg.setBatteryCapacity(capacity_mah)) { + snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh", capacity_mah); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid capacity (100-100000 mAh)"); + } + return ret; + } else if (strncmp(setCommand, "tccal", 5) == 0) { + // `set board.tccal` -> auto-read BME280 as reference + // `set board.tccal reset` -> reset to 0.00 + const char* rest = &setCommand[5]; + if (*rest == ' ') rest++; + const char* value = BoardConfigContainer::trim(const_cast(rest)); + + if (strcmp(value, "reset") == 0 || strcmp(value, "RESET") == 0) { + if (cfg.setTcCalOffset(0.0f)) { + snprintf(ret, sizeof(ret), "TC calibration reset to 0.00 (default)"); + } else { + snprintf(ret, sizeof(ret), "Err: Failed to reset TC calibration"); + } + return ret; + } + + float bme_avg = 0.0f; + float new_offset = cfg.performTcCalibration(&bme_avg); + if (new_offset > -900.0f) { + snprintf(ret, sizeof(ret), "TC auto-cal: BME=%.1f offset=%+.2f C", bme_avg, new_offset); + } else { + snprintf(ret, sizeof(ret), "Err: Auto-cal failed (BME280/NTC error?)"); + } + return ret; + } else if (strncmp(setCommand, "leds ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[5])); + bool enabled = (strcmp(value, "1") == 0 || strcmp(value, "on") == 0 || strcmp(value, "ON") == 0); + bool disabled = (strcmp(value, "0") == 0 || strcmp(value, "off") == 0 || strcmp(value, "OFF") == 0); + if (enabled || disabled) { + cfg.setLEDsEnabled(enabled); + snprintf(ret, sizeof(ret), "LEDs %s (Heartbeat + BQ Stat)", + enabled ? "enabled" : "disabled"); + } else { + snprintf(ret, sizeof(ret), "Err: Use 'on/1' or 'off/0'"); + } + return ret; + } else if (strncmp(setCommand, "soc ", 4) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[4])); + float soc_percent = atof(value); + if (BoardConfigContainer::setSOCManually(soc_percent)) { + snprintf(ret, sizeof(ret), "SOC set to %.1f%%", soc_percent); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid SOC (0-100) or INA228 not ready"); + } + return ret; + } else if (strncmp(setCommand, "jeitaignore ", 12) == 0) { + const char* value = BoardConfigContainer::trim(const_cast(&setCommand[12])); + bool on = (strcmp(value, "1") == 0 || strcmp(value, "true") == 0); + bool off = (strcmp(value, "0") == 0 || strcmp(value, "false") == 0); + if (!on && !off) { + return "Err: Use 1|0"; + } + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + return "Err: Set board.bat first"; + } + const auto* jiProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (jiProps && !jiProps->needs_jeita) { + return "Err: This chemistry runs without JEITA (always 1)"; + } + if (on && !cfg.isBatteryCapacitySet()) return "N/A, batcap not set"; + if (on && !cfg.jeitaIgnoreGateOk()) return "N/A, imax >=0,05C"; + if (!cfg.setJeitaIgnore(on)) { + return "Err: Failed to store setting"; + } + snprintf(ret, sizeof(ret), "jeitaignore set to %d", on ? 1 : 0); + return ret; + } + + snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|altitude|batcap|tccal|leds|soc|jeitaignore"); + return ret; +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.h b/variants/inhero_mr2/helpers/CliCommands.h new file mode 100644 index 0000000000..03609a6a30 --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#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); + +} // 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..132f94811f --- /dev/null +++ b/variants/inhero_mr2/helpers/Rv3028Wake.cpp @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "Rv3028Wake.h" + +#include "../InheroMr2Board.h" // RTC_I2C_ADDR + RV3028_REG_* + +#include +#include + +namespace inhero { +namespace { + +bool writeRegister(uint8_t reg, uint8_t value) { + Wire.beginTransmission(RTC_I2C_ADDR); + bool queued = Wire.write(reg) == 1; + queued = (Wire.write(value) == 1) && queued; + return Wire.endTransmission() == 0 && queued; +} + +bool readRegister(uint8_t reg, uint8_t& value) { + Wire.beginTransmission(RTC_I2C_ADDR); + bool queued = Wire.write(reg) == 1; + // STOP also terminates the transfer if queuing the register address failed. + if (Wire.endTransmission() != 0 || !queued) return false; + if (Wire.requestFrom((uint8_t)RTC_I2C_ADDR, (uint8_t)1) != 1 || Wire.available() != 1) { + while (Wire.available()) Wire.read(); + return false; + } + value = Wire.read(); + return true; +} + +bool verifyRegister(uint8_t reg, uint8_t expected, uint8_t mask = 0xFF) { + uint8_t value; + return readRegister(reg, value) && (value & mask) == (expected & mask); +} + +bool writeVerified(uint8_t reg, uint8_t value, uint8_t mask = 0xFF) { + return writeRegister(reg, value) && verifyRegister(reg, value, mask); +} + +} // namespace + +bool configurePeriodicWake(uint16_t minutes) { + uint16_t ticks = (minutes == 0) ? 1 : minutes; + if (ticks > 4095) ticks = 4095; // 12-bit register + + 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. Verify the stopped state: if TE never + // goes LOW, writing TE=1 later would not reliably start a fresh countdown. + // Control 1 bit 6 is reserved; only TF is relevant in the status register. + if (!writeVerified(RV3028_REG_CTRL1, 0x00, 0xBF) || + !writeVerified(RV3028_REG_CTRL2, 0x00) || + !writeVerified(RV3028_REG_STATUS, 0x00, 0x08)) return false; + + // Step 2: Set Timer Value (ticks at 1/60 Hz) + Wire.beginTransmission(RTC_I2C_ADDR); + bool queued = Wire.write(RV3028_REG_TIMER_VALUE_0) == 1; + queued = (Wire.write(ticks & 0xFF) == 1) && queued; + queued = (Wire.write((ticks >> 8) & 0x0F) == 1) && queued; + if (Wire.endTransmission() != 0 || !queued || + !verifyRegister(RV3028_REG_TIMER_VALUE_0, ticks & 0xFF) || + !verifyRegister(RV3028_REG_TIMER_VALUE_1, (ticks >> 8) & 0x0F, 0x0F)) return false; + + // Step 3: Enable timer (1/60 Hz, single shot) + if (!writeRegister(RV3028_REG_CTRL1, 0x07)) return false; + + // Step 4: Enable timer interrupt + if (!writeRegister(RV3028_REG_CTRL2, 0x10)) return false; + + // Verify the final state before allowing System OFF. Read the preset (0A/0B), + // not the live countdown (0C/0D), and reject a timer that already expired. + if (!verifyRegister(RV3028_REG_TIMER_VALUE_0, ticks & 0xFF) || + !verifyRegister(RV3028_REG_TIMER_VALUE_1, (ticks >> 8) & 0x0F, 0x0F) || + !verifyRegister(RV3028_REG_CTRL1, 0x07, 0xBF) || + !verifyRegister(RV3028_REG_CTRL2, 0x10) || + !verifyRegister(RV3028_REG_STATUS, 0x00, 0x08)) return false; + + MESH_DEBUG_PRINTLN("PWRMGT: RTC countdown configured (%u ticks at 1/60 Hz)", ticks); + return true; +} + +void clearTimerFlag() { + 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..3bdbbcd4aa --- /dev/null +++ b/variants/inhero_mr2/helpers/Rv3028Wake.h @@ -0,0 +1,23 @@ +/* + * 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). +// Returns true only after the stopped state, preset and armed state are verified. +// Performs one attempt; the caller must not enter System OFF on failure. +bool configurePeriodicWake(uint16_t minutes); + +// Clears the RV-3028 Timer Flag (TF, status bit 3) without touching other bits. +// Read-modify-write: required because System Sleep wake is a reset, so the +// 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..00641a56be --- /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 loop(); 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..57a3df313e --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -0,0 +1,645 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver Implementation + */ +#include "BqDriver.h" + +#include + +BqDriver::BqDriver() {} + +BqDriver::~BqDriver() { + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } +} + +// Initializes BQ25798 charger and creates dedicated I2C device for NTC access +bool BqDriver::begin(uint8_t i2c_addr, TwoWire* wire) { + if (!Adafruit_BQ25798::begin(i2c_addr, wire)) { + // Cleanup any existing device before returning + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } + return false; + } + if (ih_i2c_dev) { + delete ih_i2c_dev; + } + ih_i2c_dev = new Adafruit_I2CDevice(i2c_addr, wire); + if (!ih_i2c_dev->begin()) { + // Cleanup on failure + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + return false; + } + return true; +} + +// Reads Power Good status from charger — true if input power is sufficient for charging +bool BqDriver::getChargerStatusPowerGood() { + Adafruit_BusIO_Register chrg_stat_0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_0); + Adafruit_BusIO_RegisterBits chrg_stat_0_bits = Adafruit_BusIO_RegisterBits(&chrg_stat_0_reg, 1, 3); + + uint8_t reg_value = chrg_stat_0_bits.read(); + + return (bool)reg_value; +} + +// Reads current charging state from charger +bq25798_charging_status BqDriver::getChargingStatus() { + Adafruit_BusIO_Register chrg_stat_1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_1); + + // Read with explicit error check. RegisterBits::read() would return bits of -1 + // on a failed I2C read — CHG_STAT[7:5] of 0xFF decodes as 0x07 = DONE_CHARGING, + // which the SOC logic treats as "battery full". + uint8_t reg_value = 0; + if (!chrg_stat_1_reg.read(®_value, 1)) { + return BQ25798_CHARGER_STATE_UNKNOWN; + } + + return (bq25798_charging_status)((reg_value >> 5) & 0x07); +} + +// Reads solar and temperature telemetry via BQ25798 ADC one-shot +// +// BQ25798 ADC Operating Conditions (SLUSDV2B 9.3.10 / SLUSDV2C 7.3.10): +// "The ADC is allowed to operate if either VBUS > 3.4V or VBAT > 2.9V is valid. +// At battery only condition, if the TS_ADC channel is enabled, the ADC only +// works when battery voltage is higher than 3.2V, otherwise, the ADC works +// 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 used on MR2 are enabled (IBUS, VBUS, TDIE, optional TS). +// Other channels are disabled to avoid unnecessary conversion time. +// +// ADC_EN auto-clear behavior: +// In one-shot mode, ADC_EN resets to 0 only when ALL enabled channels +// have completed conversion. However, a rejected or interrupted conversion +// can also leave ADC_EN=0 without fresh data. Require ADC_DONE_FLAG as well. +// +// vbat_mv: battery voltage in mV from INA228 (0 = unknown, assume sufficient). +// Returns pointer to internal Telemetry struct (valid until next call). +const Telemetry* BqDriver::getTelemetryData(uint16_t vbat_mv) { + telemetryData = { 0 }; + telemetryData.battery.temperature = -888.0f; + if (!ih_i2c_dev) return &telemetryData; + + Adafruit_BusIO_Register inputStatus(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_0); + uint8_t input; + if (!inputStatus.read(&input, 1)) return &telemetryData; + + // The TS channel runs regardless of chemistry. A missing NTC then decodes + // through the RT2-only pole to a bogus ≈-46°C that slips past the open-pin + // check; the BME280 plausibility filter in + // BoardConfigContainer::getTelemetryData() discards such readings. + // + // The VBAT >= 3.2V requirement applies to battery-only operation (datasheet + // quote above). With an input source qualified the ADC runs off VBUS, so the + // channel stays on — that is exactly the case worth measuring: a cold, nearly + // empty cell being charged. Below the threshold and without an input the TS + // channel would stall the whole conversion, costing the solar readings too, + // so it is switched off there. + bool ts_enabled = true; + if (vbat_mv > 0 && vbat_mv < 3200 && !(input & 0x08)) { + ts_enabled = false; // Disable TS → ADC threshold drops to 2.9V + } + + bool success = this->startADCOneShot(ts_enabled); + + if (!success) { + setADCEnabled(false); + return &telemetryData; + } + + // ADC_EN=0 alone does not prove that a conversion happened (e.g. low supply). + // Require a fresh ADC_DONE_FLAG, cleared before this one-shot was started. + // Channels: IBUS + VBUS + TDIE (+ TS if enabled) → ~72-96ms typical. + const uint32_t ADC_TIMEOUT_MS = 250; + uint32_t start = millis(); + bool conversion_done = false; + bool doneSeen = false; + Adafruit_BusIO_Register adc_flags(ih_i2c_dev, 0x24); + Adafruit_BusIO_Register adc_control(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + while ((millis() - start) < ADC_TIMEOUT_MS) { + uint8_t control = 0, flags = 0; + if (!adc_control.read(&control, 1) || !adc_flags.read(&flags, 1)) { + break; + } + doneSeen = doneSeen || (flags & 0x20); + if (!(control & 0x80) && doneSeen) { + conversion_done = true; + break; + } + delay(10); + } + + if (!conversion_done) { + this->setADCEnabled(false); + } + + if (conversion_done) { + Adafruit_BusIO_Register vbus(ih_i2c_dev, BQ25798_REG_VBUS_ADC, 2, MSBFIRST); + Adafruit_BusIO_Register ibus(ih_i2c_dev, BQ25798_REG_IBUS_ADC, 2, MSBFIRST); + uint16_t voltage = 0, current = 0; + if (vbus.read(&voltage) && ibus.read(¤t)) { + telemetryData.solar.voltage = voltage; + telemetryData.solar.current = (int16_t)current; + telemetryData.solar.valid = true; + } + if (telemetryData.solar.current < 0) { + telemetryData.solar.current = 0; + } + telemetryData.solar.power = ((int32_t)telemetryData.solar.voltage * telemetryData.solar.current) / 1000; + + if (ts_enabled) { + telemetryData.battery.temperature = this->calculateBatteryTemp(getTS()); + } else { + // TS channel off — battery-only below 3.2V. No reading. + telemetryData.battery.temperature = -888.0f; + } + } else { + // ADC didn't complete — VBAT < 2.9V and no VBUS, or I2C issue + telemetryData.battery.temperature = -888.0f; + } + + telemetryData.solar.mppt = getMPPTenable(); + + return &telemetryData; +} + +// Calculates battery temperature in °C using Steinhart-Hart equation. +// Uses coefficients derived from Murata NCP15XH103F03RC datasheet R-T table. +// Max error vs. datasheet: ±0.36°C over -40..+125°C range. +// +// Per BQ25798 datasheet Figure 9-12: REGN → RT1 → TS → (RT2||NTC) → GND +// ts_pct: voltage at TS pin in percentage of REGN (e.g., 70.5 for 70.5%). +// Special input values: -1.0 = I2C error, -2.0 = ADC not ready/invalid. +// Returns temperature in °C, or error codes: +// -999.0 = I2C communication error +// -888.0 = ADC not ready (read 0 or 0xFFFF) +// -99.0 = NTC open/disconnected (k > 0.99) +// 99.0 = NTC short circuit (k < 0.01) +float BqDriver::calculateBatteryTemp(float ts_pct) { + // Check for I2C read error + if (ts_pct == -1.0f) return -999.0f; // I2C error + if (ts_pct == -2.0f) return -888.0f; // ADC not ready or invalid value + + // Convert TS percentage to ratio (0.0 to 1.0) + // TS% = 100 × R_bottom / (R_top + R_bottom) + // where R_bottom = RT2 || NTC + float k = ts_pct / 100.0f; + + // Plausibility check + if (k > 0.99f) return -99.0f; // NTC open/disconnected + if (k < 0.01f) return 99.0f; // NTC short circuit + + // Calculate total resistance of bottom network (RT2 || NTC) + // From: k = R_bottom / (RT1 + R_bottom) + // Rearranged: R_bottom = RT1 × k / (1 - k) + float r_bottom_total = R_PULLUP * (k / (1.0f - k)); + + // Extract NTC resistance from parallel combination with RT2 + // For parallel resistors: 1/R_total = 1/R_NTC + 1/RT2 + // Therefore: 1/R_NTC = 1/R_total - 1/RT2 + float g_total = 1.0f / r_bottom_total; + float g_rt2 = 1.0f / R_PARALLEL; + + if (g_total <= g_rt2) { + return -99.0f; // Invalid measurement + } + + float r_ntc = 1.0f / (g_total - g_rt2); + + // Apply Steinhart-Hart equation: 1/T = A + B·ln(R) + C·(ln(R))³ + float ln_r = logf(r_ntc); + float inv_T = SH_A + SH_B * ln_r + SH_C * ln_r * ln_r * ln_r; + + // Convert Kelvin to Celsius + return (1.0f / inv_T) - 273.15f; +} + +// Getter/Setter for NTC Control 0 (0x17) +// Gets JEITA voltage setting for warm/cool regions +bq25798_jeita_vset_t BqDriver::getJeitaVSet() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_vset_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 3, 5); + + uint8_t reg_value = jeita_vset_bits.read(); + + return (bq25798_jeita_vset_t)reg_value; +} + +// Sets JEITA voltage setting for warm/cool temperature regions +bool BqDriver::setJeitaVSet(bq25798_jeita_vset_t setting) { + if (setting > BQ25798_JEITA_VSET_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_vset_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 3, 5); + + jeita_vset_bits.write((uint8_t)setting); + + return true; +} + +// Gets JEITA current setting for hot region +bq25798_jeita_iseth_t BqDriver::getJeitaISetH() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_iseth_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 3); + + uint8_t reg_value = jeita_iseth_bits.read(); + + return (bq25798_jeita_iseth_t)reg_value; +} + +// Sets JEITA current setting for hot temperature region +bool BqDriver::setJeitaISetH(bq25798_jeita_iseth_t setting) { + if (setting > BQ25798_JEITA_ISETH_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_iseth_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 3); + + jeita_iseth_bits.write((uint8_t)setting); + + return true; +} + +// Gets JEITA current setting for cold region +bq25798_jeita_isetc_t BqDriver::getJeitaISetC() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_isetc_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 1); + + uint8_t reg_value = jeita_isetc_bits.read(); + + return (bq25798_jeita_isetc_t)reg_value; +} + +// Sets JEITA current setting for cold temperature region +bool BqDriver::setJeitaISetC(bq25798_jeita_isetc_t setting) { + if (setting > BQ25798_JEITA_ISETC_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_isetc_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 1); + + jeita_isetc_bits.write((uint8_t)setting); + + return true; +} + +// Gets TS Cool threshold (lower boundary of COOL region) +bq25798_ts_cool_t BqDriver::getTsCool() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_cool_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 6); + + uint8_t reg_value = ts_cool_bits.read(); + + return (bq25798_ts_cool_t)reg_value; +} + +// Sets TS Cool threshold (lower boundary of COOL region) +bool BqDriver::setTsCool(bq25798_ts_cool_t threshold) { + if (threshold > BQ25798_TS_COOL_20C) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_cool_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 6); + + ts_cool_bits.write((uint8_t)threshold); + + return true; +} + +// Gets TS Warm threshold (upper boundary of WARM region) +bq25798_ts_warm_t BqDriver::getTsWarm() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_warm_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 4); + + uint8_t reg_value = ts_warm_bits.read(); + + return (bq25798_ts_warm_t)reg_value; +} + +// Sets TS Warm threshold (upper boundary of WARM region) +bool BqDriver::setTsWarm(bq25798_ts_warm_t threshold) { + if (threshold > BQ25798_TS_WARM_55C) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_warm_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 4); + + ts_warm_bits.write((uint8_t)threshold); + + return true; +} + +// Gets BHOT threshold (upper limit for charging) +bq25798_bhot_t BqDriver::getBHot() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bhot_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 2); + + uint8_t reg_value = bhot_bits.read(); + + return (bq25798_bhot_t)reg_value; +} + +// Sets BHOT threshold (upper limit for charging) +bool BqDriver::setBHot(bq25798_bhot_t threshold) { + if (threshold > BQ25798_BHOT_DISABLE) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bhot_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 2); + + bhot_bits.write((uint8_t)threshold); + + return true; +} + +// Gets BCOLD threshold (lower limit for charging) +bq25798_bcold_t BqDriver::getBCold() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bcold_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 1); + + uint8_t reg_value = bcold_bits.read(); + + return (bq25798_bcold_t)reg_value; +} + +// Sets BCOLD threshold (lower limit for charging) +bool BqDriver::setBCold(bq25798_bcold_t threshold) { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bcold_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 1); + + bcold_bits.write((uint8_t)threshold); + + return true; +} + +// Gets TS ignore status (disables all temperature monitoring) +bool BqDriver::getTsIgnore() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_ignore_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 0); + + return (bool)ts_ignore_bits.read(); +} + +// Sets TS ignore status (disables all temperature monitoring) +bool BqDriver::setTsIgnore(bool ignore) { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_ignore_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 0); + + ts_ignore_bits.write((uint8_t)ignore); + + return true; +} + +// Starts ADC one-shot conversion for selected channels +// +// MR2 ADC Channel Map: +// Reg 0x2F (ADC_FUNCTION_DISABLE_0): bit=1 means DISABLED +// Bit 7: IBUS → ENABLED (solar current) +// Bit 6: IBAT → disabled (INA228 measures battery current) +// Bit 5: VBUS → ENABLED (solar voltage) +// Bit 4: VBAT → disabled (INA228 measures battery voltage) +// Bit 3: VSYS → disabled (not used) +// Bit 2: TS → ENABLED or disabled depending on VBAT level +// Bit 1: TDIE → ENABLED (charger die temperature) +// 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) +// +// Unused channels stay disabled to reduce conversion time. Supply availability +// still limits ADC operation; channel masks cannot compensate for an invalid supply. +// +// Release the input before a one-shot, without changing charge enable. +bool BqDriver::prepareADCInput() { + if (!ih_i2c_dev) return false; + Adafruit_BusIO_Register charger(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + uint8_t control; + if (!charger.read(&control, 1)) return false; + if (control & 0x04) { + // Preserve EN_CHG and all other settings; CE is controlled by board config. + if (!charger.write(control & ~0x04)) return false; + } + return true; +} + +// ts_enabled: true = enable TS (requires VBAT > 3.2V in battery-only operation). +// Success confirms setup/start register accesses, not conversion completion. +bool BqDriver::startADCOneShot(bool ts_enabled) { + // Stop any previous conversion, then discard its read-to-clear done flag. + if (!setADCEnabled(false)) return false; + Adafruit_BusIO_Register adc_flags(ih_i2c_dev, 0x24); + uint8_t flags; + if (!adc_flags.read(&flags, 1)) return false; + Adafruit_BusIO_Register disable_reg_0 = Adafruit_BusIO_Register(ih_i2c_dev, 0x2F); + Adafruit_BusIO_Register disable_reg_1 = Adafruit_BusIO_Register(ih_i2c_dev, 0x30); + + // Reg 0x2F bit map: IBUS(7) IBAT(6) VBUS(5) VBAT(4) VSYS(3) TS(2) TDIE(1) reserved(0) + // 1 = disabled, 0 = enabled + uint8_t disable0 = 0x58; // Enable IBUS(7), VBUS(5), TS(2), TDIE(1) — disable rest + if (!ts_enabled) { + disable0 |= 0x04; // Also disable TS(2) → 0x5C + } + if (!disable_reg_0.write(disable0)) { return false; } + + // Reg 0x30: Disable all — D+(7), D-(6), VAC2(5), VAC1(4) not connected on MR2 + if (!disable_reg_1.write(0xF0)) { return false; } + + uint8_t mask0, mask1; + if (!disable_reg_0.read(&mask0, 1) || !disable_reg_1.read(&mask1, 1)) return false; + if (mask0 != disable0 || mask1 != 0xF0) return false; + + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + // Release HIZ only after setup, immediately before ADC_EN. Waiting here can + // let source qualification reassert HIZ before our conversion even starts. + if (!prepareADCInput()) return false; + uint8_t control; + if (!adc_ctrl_reg.write(0xC0) || !adc_ctrl_reg.read(&control, 1)) return false; + return true; +} + +// ADC Control register (0x2E) implementations +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); + uint8_t control; + if (!adc_ctrl_reg.read(&control, 1)) return false; + return adc_ctrl_reg.write(enabled ? (control | 0x80) : (control & ~0x80)); +} + +// ADC Reading implementations +int16_t BqDriver::getIBUS() { + Adafruit_BusIO_Register ibus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_IBUS_ADC, 2, MSBFIRST); + uint16_t raw; + if (!ibus_reg.read(&raw)) { // MSB first + return 0; + } + int16_t val = (int16_t)raw; // 2's complement for signed + return val; // in mA +} + +uint16_t BqDriver::getVBUS() { + Adafruit_BusIO_Register vbus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_VBUS_ADC, 2, MSBFIRST); + uint16_t val; + if (!vbus_reg.read(&val)) { + return 0; + } + return val; // in mV +} + +float BqDriver::getDieTemperature_C() { + Adafruit_BusIO_Register tdie_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_TDIE_ADC, 2, MSBFIRST); + uint16_t raw; + if (!tdie_reg.read(&raw)) { + return -999.0f; + } + return (int16_t)raw * 0.5f; // 2's complement, 0.5°C/LSB +} + +float BqDriver::getTS() { + Adafruit_BusIO_Register ts_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_TS_ADC, 2, MSBFIRST); + uint16_t val; + + // Try up to 3 times with small delays if we get invalid values + for (int retry = 0; retry < 3; retry++) { + if (!ts_reg.read(&val)) { + delay(20); + continue; // I2C read error, retry + } + // Check for invalid/uninitialized ADC value (0 or 0xFFFF) + if (val == 0 || val == 0xFFFF) { + if (retry < 2) { + delay(50); // Wait a bit longer for ADC to settle + continue; + } + return -2.0f; // ADC not ready / invalid value after retries + } + // Valid value + return val * 0.09765625f; // 0.09765625 %/LSB (exact: 1/1024) + } + + return -1.0f; // I2C read error after all retries +} + +bool BqDriver::setVOCpercent(bq25798_voc_pct_t pct) { + uint8_t reg15 = readReg(0x15); + reg15 = (reg15 & 0x1F) | ((uint8_t)pct << 5); // Bits [7:5] = VOC_PCT + return writeReg(0x15, reg15); +} + +bq25798_voc_pct_t BqDriver::getVOCpercent() { + uint8_t reg15 = readReg(0x15); + return (bq25798_voc_pct_t)((reg15 >> 5) & 0x07); +} + + +// Gets EN_AUTO_IBATDIS state (auto battery discharge during VBAT_OVP; POR default = enabled) +bool BqDriver::getAutoIBATDIS() { + Adafruit_BusIO_Register ctrl0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + Adafruit_BusIO_RegisterBits auto_ibatdis_bit = Adafruit_BusIO_RegisterBits(&ctrl0_reg, 1, 7); + return (bool)auto_ibatdis_bit.read(); +} + +// Sets EN_AUTO_IBATDIS (auto battery discharge during VBAT_OVP). +// enable: true = BQ sinks 30mA from BAT during OVP, false = no active discharge. +bool BqDriver::setAutoIBATDIS(bool enable) { + Adafruit_BusIO_Register ctrl0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + Adafruit_BusIO_RegisterBits auto_ibatdis_bit = Adafruit_BusIO_RegisterBits(&ctrl0_reg, 1, 7); + return auto_ibatdis_bit.write(enable ? 1 : 0); +} + +bool BqDriver::setPrechargeLimitmA(uint16_t current_mA) { + if (current_mA < 40 || current_mA > 2000) return false; + + // IPRECHG is REG08[5:0] in 40 mA steps. Preserve VBAT_LOWV in [7:6] + // and round down so precharge never exceeds the configured ICHG limit. + const uint8_t steps = static_cast(current_mA / 40); + uint8_t reg; + if (!readReg(BQ25798_REG_PRECHARGE_CONTROL, reg)) return false; + const uint8_t desired = (reg & 0xC0) | steps; + if (!writeReg(BQ25798_REG_PRECHARGE_CONTROL, desired)) return false; + return readReg(BQ25798_REG_PRECHARGE_CONTROL, reg) && + (reg & 0x3F) == steps; +} + +// Non-static register access methods (use instance I2C config) +bool BqDriver::writeReg(uint8_t reg, uint8_t val) { + if (!ih_i2c_dev) return false; + + uint8_t buffer[2] = {reg, val}; + bool ok = ih_i2c_dev->write(buffer, 2); + return ok; +} + +uint8_t BqDriver::readReg(uint8_t reg) { + uint8_t value = 0; + return readReg(reg, value) ? value : 0; +} + +bool BqDriver::readReg(uint8_t reg, uint8_t& val) { + if (!ih_i2c_dev) return false; + return ih_i2c_dev->write_then_read(®, 1, &val, 1); +} + +// Static, raw-Wire helpers — safe pre-begin(). +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..8f6a03f419 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver for Inhero MR2 + * Extends Adafruit_BQ25798 library (BSD License). + */ + +#pragma once + +#include +#include +#include + +#define R_PULLUP 5600.0f // Upper resistor RT1 in Ohms +#define R_PARALLEL 27000.0f // Lower parallel resistor RT2 in Ohms + +// Steinhart-Hart coefficients for NCP15XH103F03RC NTC (10kΩ, B=3380) +// Fitted from Murata datasheet R-T table at -20°C, 25°C, 85°C +// Max error vs. datasheet: ±0.36°C over -40..+125°C range +#define SH_A 8.7248136876e-04f // Steinhart-Hart coefficient A +#define SH_B 2.5405556775e-04f // Steinhart-Hart coefficient B +#define SH_C 1.8122847672e-07f // Steinhart-Hart coefficient C + +// Solar input telemetry data +// Solar current from BQ25798 IBUS ADC has significant error at low currents (~±30mA). +// Values are approximate - treat as estimates, not precise measurements. +typedef struct { + uint16_t voltage; // Solar voltage in mV + int16_t current; // Solar current in mA (approximate, see note above) + int32_t power; // Solar power in mW + bool valid; // Fresh ADC conversion and successful U/I reads + bool mppt; // MPPT enabled status +} SolarData; + +// Battery telemetry data +typedef struct { + uint16_t voltage; // Battery voltage in mV + float current; // Battery current in mA (positive = charging, negative = discharging) + int32_t power; // Battery power in mW + float temperature; // Battery temperature in °C +} BattData; + +// System voltage telemetry +typedef struct { + uint16_t voltage; // System voltage in mV +} SysData; + +// Main telemetry container aggregating all data sources +typedef struct { + SysData system; // System voltage data + SolarData solar; // Solar input data + BattData battery; // Battery data +} Telemetry; + +// JEITA voltage setting for warm/cool regions (NTC Control 0 Register 0x17) +typedef enum { + BQ25798_JEITA_VSET_SUSPEND = 0x00, // Charge Suspend + BQ25798_JEITA_VSET_MINUS_800MV = 0x01, // Set VREG to VREG-800mV + BQ25798_JEITA_VSET_MINUS_600MV = 0x02, // Set VREG to VREG-600mV + BQ25798_JEITA_VSET_MINUS_400MV = 0x03, // Set VREG to VREG-400mV (default) + BQ25798_JEITA_VSET_MINUS_300MV = 0x04, // Set VREG to VREG-300mV + BQ25798_JEITA_VSET_MINUS_200MV = 0x05, // Set VREG to VREG-200mV + BQ25798_JEITA_VSET_MINUS_100MV = 0x06, // Set VREG to VREG-100mV + BQ25798_JEITA_VSET_UNCHANGED = 0x07 // VREG unchanged +} bq25798_jeita_vset_t; + +typedef enum { + BQ25798_JEITA_ISETH_SUSPEND = 0x00, // Charge Suspend + BQ25798_JEITA_ISETH_20_PERCENT = 0x01, // Set ICHG to 20% * ICHG + BQ25798_JEITA_ISETH_40_PERCENT = 0x02, // Set ICHG to 40% * ICHG + BQ25798_JEITA_ISETH_UNCHANGED = 0x03 // ICHG unchanged (default) +} bq25798_jeita_iseth_t; + +typedef enum { + BQ25798_JEITA_ISETC_SUSPEND = 0x00, // Charge Suspend + BQ25798_JEITA_ISETC_20_PERCENT = 0x01, // Set ICHG to 20% * ICHG (default) + BQ25798_JEITA_ISETC_40_PERCENT = 0x02, // Set ICHG to 40% * ICHG + BQ25798_JEITA_ISETC_UNCHANGED = 0x03 // ICHG unchanged +} bq25798_jeita_isetc_t; + +typedef enum { + BQ25798_CHARGER_STATE_NOT_CHARGING = 0x00, + BQ25798_CHARGER_STATE_TRICKLE_CHARGING = 0x01, + BQ25798_CHARGER_STATE_PRE_CHARGING = 0x02, + BQ25798_CHARGER_STATE_CC_CHARGING = 0x03, + BQ25798_CHARGER_STATE_CV_CHARGING = 0x04, + BQ25798_CHARGER_STATE_TOP_OF_TIMER_ACTIVE_CHARGING = 0x06, + BQ25798_CHARGER_STATE_DONE_CHARGING = 0x07, + BQ25798_CHARGER_STATE_UNKNOWN = 0xFF // I2C read failed — status unavailable + +} bq25798_charging_status; + +// New enums for NTC Control 1 (Register 0x18) +typedef enum { + BQ25798_TS_COOL_5C = 0x00, // 71.1% of REGN (5°C) + BQ25798_TS_COOL_10C = 0x01, // 68.4% of REGN (10°C, default) + BQ25798_TS_COOL_15C = 0x02, // 65.5% of REGN (15°C) + BQ25798_TS_COOL_20C = 0x03 // 62.4% of REGN (20°C) +} bq25798_ts_cool_t; + +typedef enum { + BQ25798_TS_WARM_40C = 0x00, // 48.4% of REGN (40°C) + BQ25798_TS_WARM_45C = 0x01, // 44.8% of REGN (45°C, default) + BQ25798_TS_WARM_50C = 0x02, // 41.2% of REGN (50°C) + BQ25798_TS_WARM_55C = 0x03 // 37.7% of REGN (55°C) +} bq25798_ts_warm_t; + +// BHOT threshold - upper temperature limit for charging +typedef enum { + BQ25798_BHOT_55C = 0x00, // 55°C + BQ25798_BHOT_60C = 0x01, // 60°C (default) + BQ25798_BHOT_65C = 0x02, // 65°C + BQ25798_BHOT_DISABLE = 0x03 // Disable BHOT protection +} bq25798_bhot_t; + +// BCOLD threshold - lower temperature limit for charging +typedef enum { + BQ25798_BCOLD_MINUS_10C = 0x00, // -10°C (default) + BQ25798_BCOLD_MINUS_20C = 0x01 // -20°C +} bq25798_bcold_t; + +// ADC resolution setting +typedef enum { + BQ25798_ADC_SAMPLE_15BIT = 0b00, // 15-bit resolution (default, ~24ms conversion) + BQ25798_ADC_SAMPLE_14BIT = 0b01, // 14-bit resolution + BQ25798_ADC_SAMPLE_13BIT = 0b10, // 13-bit resolution + BQ25798_ADC_SAMPLE_12BIT = 0b11 // 12-bit resolution (not recommended) +} bq25798_adc_sample_t; + +// Extended BQ25798 driver with NTC support and comprehensive telemetry. +// Extends Adafruit_BQ25798 with: +// - JEITA temperature control (VSET, ISETH, ISETC) +// - NTC thermistor temperature calculation +// - Complete ADC telemetry (solar, battery, system) +// - One-shot ADC conversion management +class BqDriver : public Adafruit_BQ25798 { +public: + BqDriver(); + ~BqDriver(); + + bool begin(uint8_t i2c_addr = BQ25798_DEFAULT_ADDR, TwoWire* wire = &Wire); + + // Direct pass-through to parent — all I2C runs in loop() context (no concurrent access) + bool setHIZMode(bool enable) { return Adafruit_BQ25798::setHIZMode(enable); } + bool setChargeEnable(bool enable) { return Adafruit_BQ25798::setChargeEnable(enable); } + bool getChargeEnable() { return Adafruit_BQ25798::getChargeEnable(); } + bool getMPPTenable() { return Adafruit_BQ25798::getMPPTenable(); } + bool setMPPTenable(bool enable) { return Adafruit_BQ25798::setMPPTenable(enable); } + bool setStatPinEnable(bool enable) { return Adafruit_BQ25798::setStatPinEnable(enable); } + bool getStatPinEnable() { return Adafruit_BQ25798::getStatPinEnable(); } + + bq25798_jeita_vset_t getJeitaVSet(); + bool setJeitaVSet(bq25798_jeita_vset_t setting); + + bq25798_jeita_iseth_t getJeitaISetH(); + bool setJeitaISetH(bq25798_jeita_iseth_t setting); + + bq25798_jeita_isetc_t getJeitaISetC(); + bool setJeitaISetC(bq25798_jeita_isetc_t setting); + + bq25798_ts_cool_t getTsCool(); + bool setTsCool(bq25798_ts_cool_t threshold); + + bq25798_ts_warm_t getTsWarm(); + bool setTsWarm(bq25798_ts_warm_t threshold); + + bq25798_bhot_t getBHot(); + bool setBHot(bq25798_bhot_t threshold); + + bq25798_bcold_t getBCold(); + bool setBCold(bq25798_bcold_t threshold); + + bool getTsIgnore(); + bool setTsIgnore(bool ignore); + + // Die temperature from the TDIE ADC (0.5°C/LSB, two's complement). The value + // stems from the last completed ADC one-shot (getTelemetryData), so it may be + // up to one telemetry period old. Returns -999.0 on I2C error. + float getDieTemperature_C(); + + // Read solar + temperature telemetry via BQ25798 ADC. + // vbat_mv: battery voltage from INA228 in mV, used to decide if the TS channel + // can be enabled (requires VBAT > 3.2V without VBUS, per SLUSDV2B 9.3.10). + // 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); + + // Match precharge to the configured charge current (40 mA register steps). + // Returns false if the register write or readback fails. + bool setPrechargeLimitmA(uint16_t current_mA); + + // Non-static register access methods (use instance I2C config) + bool writeReg(uint8_t reg, uint8_t val); + uint8_t readReg(uint8_t reg); + bool readReg(uint8_t reg, uint8_t& val); + + // Low-level BQ25798 housekeeping via raw TwoWire. These are safe to call + // before begin() — used on the low-voltage wake path where the driver + // 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 prepareADCInput(); + 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 }; +}; diff --git a/variants/inhero_mr2/lib/Ina228Driver.cpp b/variants/inhero_mr2/lib/Ina228Driver.cpp new file mode 100644 index 0000000000..9544d343e9 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -0,0 +1,499 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver Implementation + */ + +#include "Ina228Driver.h" +#include // for MESH_DEBUG_PRINTLN + +Ina228Driver::Ina228Driver(uint8_t i2c_addr) + : _i2c_addr(i2c_addr), _shunt_mohm(0.0f), _current_lsb(0.0f), _base_shunt_cal(0) {} + +bool Ina228Driver::begin(float shunt_resistor_mohm) { + _shunt_mohm = shunt_resistor_mohm; + + // Check if device is present + if (!isConnected()) { + MESH_DEBUG_PRINTLN("INA228 begin() FAILED: isConnected() = false"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): Device connected"); + // Do NOT reset device - Early Boot voltage check may have configured it + // Resetting causes timing issues where subsequent writes fail + // Just reconfigure registers directly + + // Configure ADC: Continuous mode, all channels, long conversion times, 256 samples averaging + // - Long conversion times (VSHCT=4120µs, VBUSCT=2074µs) reduce noise for accurate SOC tracking + // - AVG_256 filters TX voltage peaks; enableAlert() also selects averaged alerts + // - One averaged update takes (2074 + 4120 + 540)us * 256 = ~1.72s + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + // Expected value: 0xFDE5 + + // Write ADC_CONFIG with retry and verify + // Sometimes the first write after readVBATDirect() fails + bool adc_config_ok = false; + for (int retry = 0; retry < 5; retry++) { + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); + delay(10); + uint16_t readback = readRegister16(INA228_REG_ADC_CONFIG); + if (readback == adc_config) { + adc_config_ok = true; + break; + } + delay(20); // Wait longer before retry + } + + if (!adc_config_ok) { + MESH_DEBUG_PRINTLN("INA228 begin() ERROR: Failed to set ADC_CONFIG after 5 retries!"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): ADC_CONFIG set to 0x%04X", adc_config); + + // Calculate current LSB: Max expected current / 2^19 (20-bit ADC) + // With 100mΩ shunt and ±163.84mV ADC range (ADCRANGE=0): Max = 163.84mV / 0.1Ω = 1.6384A + // Using 1.6384A, LSB = 1.6384A / 524288 ≈ 3.125 µA + // At 10mA standby: V_shunt = 1mV → SNR greatly improved vs. 20mΩ (200µV) + _current_lsb = 1.6384f / 524288.0f; // in Amperes (max ±1.6384A) + + // Calculate shunt calibration value + // SHUNT_CAL = 13107.2 × 10^6 × CURRENT_LSB × R_SHUNT + // R_SHUNT in Ohms, CURRENT_LSB in A + float shunt_ohm = _shunt_mohm / 1000.0f; + _base_shunt_cal = (uint16_t)(13107.2e6 * _current_lsb * shunt_ohm); + + // ADCRANGE = 0 (±163.84mV): No multiplier needed (×4 only required for ADCRANGE=1) + + writeRegister16(INA228_REG_SHUNT_CAL, _base_shunt_cal); + delay(5); + + // Configure INA228: ADCRANGE = 0 (±163.84mV, default) for 100mΩ shunt + // At 1A: V_shunt = 100mV (61% of full-scale) — sufficient headroom + // At 10mA: V_shunt = 1mV — 5× better SNR than 20mΩ (was 200µV) + uint16_t config = 0; // ADCRANGE=0 (±163.84mV range, bit 4 = 0) + writeRegister16(INA228_REG_CONFIG, config); + delay(5); + + return true; +} + +bool Ina228Driver::isConnected() { + // First check if device responds at all + Wire.beginTransmission(_i2c_addr); + uint8_t i2c_result = Wire.endTransmission(); + + if (i2c_result != 0) { + return false; // No ACK on bus + } + + // Read Manufacturer ID (should be 0x5449 = "TI") + uint16_t mfg_id = readRegister16(INA228_REG_MANUFACTURER); + + if (mfg_id == 0x0000 || mfg_id == 0xFFFF) { + return false; // Invalid MFG_ID (bus error) + } + + // Some INA228 clones may have different MFG_ID, skip strict check + // Just verify it's not a bus error value + + // Read Device ID (should be 0x228 in lower 12 bits) + uint16_t dev_id = readRegister16(INA228_REG_DEVICE_ID); + + if (dev_id == 0x0000 || dev_id == 0xFFFF) { + return false; // Invalid DEV_ID (bus error) + } + + // Accept any non-error DEV_ID — some INA228 clones report 0x2281 instead of 0x228. + + return true; // Accept device if MFG_ID was valid +} + +void Ina228Driver::reset() { + writeRegister16(INA228_REG_CONFIG, INA228_CONFIG_RST); +} + +uint16_t Ina228Driver::readVoltage_mV() { + int32_t vbus_raw = readRegister24(INA228_REG_VBUS); + // INA228 VBUS: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + vbus_raw >>= 4; + // VBUS LSB = 195.3125 µV + float vbus_v = vbus_raw * 195.3125e-6; + return (uint16_t)(vbus_v * 1000.0f); // Convert to mV +} + +int16_t Ina228Driver::readCurrent_mA() { + int32_t current_raw = readRegister24(INA228_REG_CURRENT); + // INA228 CURRENT: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + current_raw >>= 4; + // Current = raw × CURRENT_LSB + // Calibration is applied via SHUNT_CAL register (hardware calibration) + // Sign convention: INVERT because shunt is oriented for battery perspective + // Positive = charging (current into battery), Negative = discharging (current from battery) + float current_a = current_raw * _current_lsb; + float current_mA = -current_a * 1000.0f; // Convert to mA, inverted sign + return (int16_t)(current_mA); +} + +float Ina228Driver::readCurrent_mA_precise() { + int32_t current_raw = readRegister24(INA228_REG_CURRENT); + // INA228 CURRENT: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + current_raw >>= 4; + // Current = raw × CURRENT_LSB + // Calibration is applied via SHUNT_CAL register (hardware calibration) + // Sign convention: INVERT because shunt is oriented for battery perspective + // Positive = charging (current into battery), Negative = discharging (current from battery) + float current_a = current_raw * _current_lsb; + float current_mA = -current_a * 1000.0f; // Convert to mA with full precision, inverted sign + return current_mA; +} + +bool Ina228Driver::shutdown() { + // Set operating mode to Shutdown (MODE = 0x0) + // This disables all conversions and Coulomb Counter. + // Use retry+readback — I2C writes can fail silently (see setUnderVoltageAlert). + // If this fails, INA228 stays in continuous mode (~350µA wasted in System Sleep!). + uint16_t adc_config = 0x0000; // MODE = 0x0 (Shutdown) + + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_ADC_CONFIG, adc_config)) { + delay(10); + continue; + } + delay(2); + uint16_t readback = readRegister16(INA228_REG_ADC_CONFIG); + if ((readback & 0xF000) == 0x0000) { // Check MODE bits [15:12] + return true; + } + delay(10); + } + return false; +} + +void Ina228Driver::wakeup() { + // Re-enable continuous measurement mode with full ADC configuration + // Must restore conversion times from begin() - defaults are much shorter (50µs) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); +} + +uint16_t Ina228Driver::readVBATDirect(TwoWire* wire, uint8_t i2c_addr) { + // === Important: This is called BEFORE begin() in Early Boot Check === + // The INA228 may be in power-on reset state, so we need to be careful + + // First check if device responds + wire->beginTransmission(i2c_addr); + if (wire->endTransmission() != 0) { + return 0; // Device not present + } + + // === One-Shot ADC Trigger === + // Configure ADC for single-shot bus voltage measurement + // MODE = 0x1 (Single-shot bus voltage only) + uint16_t adc_config = (0x1 << 12); // MODE = 0x1, no averaging for speed + + wire->beginTransmission(i2c_addr); + wire->write(INA228_REG_ADC_CONFIG); + wire->write((adc_config >> 8) & 0xFF); + wire->write(adc_config & 0xFF); + if (wire->endTransmission() != 0) { + return 0; // I2C communication failed + } + + // Wait for conversion to complete (~200µs typical, use 2ms to be safe) + delay(2); + + // Read VBUS register (24-bit) + wire->beginTransmission(i2c_addr); + wire->write(INA228_REG_VBUS); + if (wire->endTransmission(false) != 0) { + return 0; + } + + wire->requestFrom(i2c_addr, (uint8_t)3); + if (wire->available() < 3) { + return 0; + } + + int32_t vbus_raw = wire->read() << 16; // MSB + vbus_raw |= wire->read() << 8; // Mid + vbus_raw |= wire->read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (vbus_raw & 0x800000) { + vbus_raw |= 0xFF000000; + } + + // INA228 VBUS: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + vbus_raw >>= 4; + + // VBUS LSB = 195.3125 µV + float vbus_v = vbus_raw * 195.3125e-6; + uint16_t vbus_mv = (uint16_t)(vbus_v * 1000.0f); + + return vbus_mv; +} + +int32_t Ina228Driver::readPower_mW() { + int32_t power_raw = readRegister24(INA228_REG_POWER); + // Power LSB = 3.2 × CURRENT_LSB + // Sign convention: INVERT to match current sign (positive = charging) + float power_w = power_raw * (3.2f * _current_lsb); + return (int32_t)(-power_w * 1000.0f); // Convert to mW, inverted sign +} + +int32_t Ina228Driver::readEnergy_mWh() { + int64_t energy_raw = readRegister40(INA228_REG_ENERGY); + // Energy LSB = 16 × 3.2 × CURRENT_LSB (in J) + // Convert to Wh: / 3600 + // NO inversion - shunt orientation gives correct battery perspective + // Positive = discharging (energy from battery), Negative = charging (energy into battery) + float energy_j = energy_raw * (16.0f * 3.2f * _current_lsb); + float energy_wh = energy_j / 3600.0f; + return (int32_t)(energy_wh * 1000.0f); // Convert to mWh, NO inversion +} + +float Ina228Driver::readCharge_mAh() { + int64_t charge_raw = readRegister40(INA228_REG_CHARGE); + // Charge LSB = CURRENT_LSB (in C = A·s) + // Convert to Ah: / 3600 + // INVERT: Hardware negative = charging, we want positive = charging (battery perspective) + float charge_c = charge_raw * _current_lsb; + float charge_ah = charge_c / 3600.0f; + return -charge_ah * 1000.0f; // Convert to mAh, INVERTED for battery perspective +} + +float Ina228Driver::readDieTemperature_C() { + // DIETEMP is a 16-bit register (not 24-bit like others!) + int16_t temp_raw = (int16_t)readRegister16(INA228_REG_DIETEMP); + // Temperature LSB = 7.8125 m°C + float temp_c = temp_raw * 7.8125e-3; + return temp_c; +} + +bool Ina228Driver::readAll(Ina228BatteryData* data) { + if (!isConnected()) { + return false; + } + + data->voltage_mv = readVoltage_mV(); + data->current_ma = readCurrent_mA(); + data->power_mw = readPower_mW(); + data->energy_mwh = readEnergy_mWh(); + data->charge_mah = readCharge_mAh(); + data->die_temp_c = readDieTemperature_C(); + + return true; +} + +void Ina228Driver::resetCoulombCounter() { + // Write RSTACC (bit 14) directly — no read-modify-write! + // CONFIG is always 0x0000 (set in begin()), so we can safely write 0x4000. + // RMW is dangerous: if readRegister16() returns garbage on I2C glitch, + // we could accidentally set RST (bit 15) and wipe SHUNT_CAL. + writeRegister16(INA228_REG_CONFIG, (1 << 14)); // RSTACC only +} + +uint16_t Ina228Driver::readShuntCalRegister() { + return readRegister16(INA228_REG_SHUNT_CAL); +} + +uint16_t Ina228Driver::readAdcConfigRegister() { + return readRegister16(INA228_REG_ADC_CONFIG); +} + +uint16_t Ina228Driver::readConfigRegister() { + return readRegister16(INA228_REG_CONFIG); +} + +bool Ina228Driver::validateAndRepairShuntCal() { + uint16_t expected = _base_shunt_cal; + if (expected == 0) return false; // Not initialized + + uint16_t actual = readShuntCalRegister(); + if (actual == expected) return true; // OK + + // SHUNT_CAL is wrong — repair it! + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL corrupted! Expected=%u, Got=%u - repairing", expected, actual); + writeRegister16(INA228_REG_SHUNT_CAL, expected); + delay(2); + + uint16_t verify = readShuntCalRegister(); + if (verify != expected) { + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repair FAILED! Wrote=%u, Read=%u", expected, verify); + return false; + } + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repaired successfully"); + return true; +} + +bool Ina228Driver::setUnderVoltageAlert(uint16_t voltage_mv) { + // BUVL register: 3.125 mV/LSB (per datasheet Table 7-20) + // Non-zero BUVL enables bus under-voltage comparison → BUSUL flag + ALERT pin + // BUVL = 0 disables comparison (datasheet default) + uint16_t buvl_value = (uint16_t)(voltage_mv / 3.125f); + + // Write with retry and readback verification (I2C writes can fail silently) + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_BUVL, buvl_value)) { + delay(10); + continue; + } + delay(5); + uint16_t readback = readRegister16(INA228_REG_BUVL); + if (readback == buvl_value) { + return true; + } + MESH_DEBUG_PRINTLN("INA228: BUVL write mismatch (wrote=0x%04X, read=0x%04X), retry %d", + buvl_value, readback, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: BUVL write FAILED after 3 retries!"); + return false; +} + +void Ina228Driver::enableAlert(bool enable_uvlo, bool active_high, bool latch_alert) { + // DIAG_ALRT register: Only bits [15:12] are R/W (config), bits [11:0] are read-only flags. + // Writing to this register clears all flag bits [11:0]. + // Note: BUSUL/BUSOL flags (bits 3-4) are READ-ONLY status flags, NOT enable bits. + // Bus under-voltage comparison is enabled by setting BUVL register to non-zero. + uint16_t diag_alrt = 0; + + if (enable_uvlo) { + // AVG in ADC_CONFIG alone does not filter alerts. Compare the completed + // average to attenuate brief TX voltage dips before evaluating undervoltage. + diag_alrt |= INA228_DIAG_ALRT_SLOWALERT; + } + + if (latch_alert) { + diag_alrt |= INA228_DIAG_ALRT_ALATCH; // Latch mode: Alert stays active until DIAG_ALRT is read + } + + 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); + if (Wire.endTransmission(false) != 0) { + return 0; // Register selection failed; do not read from a stale register pointer. + } + + Wire.requestFrom(_i2c_addr, (uint8_t)3); + if (Wire.available() < 3) { + return 0; + } + + int32_t value = Wire.read() << 16; // MSB + value |= Wire.read() << 8; // Mid + value |= Wire.read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (value & 0x800000) { + value |= 0xFF000000; + } + + return value; +} + +int64_t Ina228Driver::readRegister40(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)5); + if (Wire.available() < 5) { + return 0; + } + + int64_t value = (int64_t)Wire.read() << 32; // MSB + value |= (int64_t)Wire.read() << 24; + value |= (int64_t)Wire.read() << 16; + value |= (int64_t)Wire.read() << 8; + value |= (int64_t)Wire.read(); // LSB + + // Sign-extend 40-bit to 64-bit + if (value & 0x8000000000LL) { + value |= 0xFFFFFF0000000000LL; + } + + return value; +} + + diff --git a/variants/inhero_mr2/lib/Ina228Driver.h b/variants/inhero_mr2/lib/Ina228Driver.h new file mode 100644 index 0000000000..ea3f6a3578 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.h @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver for Inhero MR2 + * + * Features: + * - Voltage, current, power monitoring + * - Coulomb counter (accumulated charge) + * - Alert pin for firmware-triggered low-voltage sleep (INA228 BUVL → ISR → System Sleep) + * - Chemistry-specific thresholds (Li-Ion, LiFePO4, LTO) + */ + +#pragma once + +#include +#include + +// INA228 I2C Address +#define INA228_I2C_ADDR_DEFAULT 0x40 // A0=GND, A1=GND + +// INA228 Register Map +#define INA228_REG_CONFIG 0x00 // Configuration +#define INA228_REG_ADC_CONFIG 0x01 // ADC Configuration +#define INA228_REG_SHUNT_CAL 0x02 // Shunt Calibration +#define INA228_REG_SHUNT_TEMP 0x03 // Shunt Temperature Coefficient +#define INA228_REG_VSHUNT 0x04 // Shunt Voltage +#define INA228_REG_VBUS 0x05 // Bus Voltage +#define INA228_REG_DIETEMP 0x06 // Die Temperature +#define INA228_REG_CURRENT 0x07 // Current +#define INA228_REG_POWER 0x08 // Power +#define INA228_REG_ENERGY 0x09 // Energy (Coulomb Counter) +#define INA228_REG_CHARGE 0x0A // Charge (Coulomb Counter) +#define INA228_REG_DIAG_ALRT 0x0B // Diagnostic and Alert +#define INA228_REG_SOVL 0x0C // Shunt Over-Voltage Limit +#define INA228_REG_SUVL 0x0D // Shunt Under-Voltage Limit +#define INA228_REG_BOVL 0x0E // Bus Over-Voltage Limit +#define INA228_REG_BUVL 0x0F // Bus Under-Voltage Limit +#define INA228_REG_TEMP_LIMIT 0x10 // Temperature Limit +#define INA228_REG_PWR_LIMIT 0x11 // Power Limit +#define INA228_REG_MANUFACTURER 0x3E // Manufacturer ID (should be 0x5449 = "TI") +#define INA228_REG_DEVICE_ID 0x3F // Device ID (should be 0x228) + +// INA228 Configuration bits +#define INA228_CONFIG_RST (1 << 15) // Reset bit +#define INA228_CONFIG_ADCRANGE (1 << 4) // ADC Range (0=±163.84mV, 1=±40.96mV) + +// ADC Configuration - Mode +#define INA228_ADC_MODE_CONT_ALL 0x0F // Continuous conversion, all channels + +// ADC Configuration - Averaging +#define INA228_ADC_AVG_1 0x00 // No averaging +#define INA228_ADC_AVG_4 0x01 // 4 samples average +#define INA228_ADC_AVG_16 0x02 // 16 samples average +#define INA228_ADC_AVG_64 0x03 // 64 samples average +#define INA228_ADC_AVG_128 0x04 // 128 samples average +#define INA228_ADC_AVG_256 0x05 // 256 samples average +#define INA228_ADC_AVG_512 0x06 // 512 samples average +#define INA228_ADC_AVG_1024 0x07 // 1024 samples average + +// ADC Configuration - Conversion Time (VBUSCT, VSHCT, VTCT) +#define INA228_ADC_CT_50us 0x00 // 50 µs +#define INA228_ADC_CT_84us 0x01 // 84 µs +#define INA228_ADC_CT_150us 0x02 // 150 µs +#define INA228_ADC_CT_280us 0x03 // 280 µs +#define INA228_ADC_CT_540us 0x04 // 540 µs +#define INA228_ADC_CT_1052us 0x05 // 1052 µs (default) +#define INA228_ADC_CT_2074us 0x06 // 2074 µs +#define INA228_ADC_CT_4120us 0x07 // 4120 µs (maximum accuracy) + +// Alert Configuration +#define INA228_DIAG_ALRT_ALATCH (1 << 15) // Alert Latch Enable +#define INA228_DIAG_ALRT_CNVR (1 << 14) // Conversion Ready +#define INA228_DIAG_ALRT_SLOWALERT (1 << 13) // Slow Alert (for averaging) +#define INA228_DIAG_ALRT_APOL (1 << 12) // Alert Polarity (1=active high) +#define INA228_DIAG_ALRT_ENERGYOF (1 << 11) // Energy Overflow +#define INA228_DIAG_ALRT_CHARGEOF (1 << 10) // Charge Overflow +#define INA228_DIAG_ALRT_MATHOF (1 << 9) // Math Overflow +#define INA228_DIAG_ALRT_TMPOL (1 << 7) // Temperature Over-Limit +#define INA228_DIAG_ALRT_SHNTOL (1 << 6) // Shunt Over-Voltage +#define INA228_DIAG_ALRT_SHNTUL (1 << 5) // Shunt Under-Voltage +#define INA228_DIAG_ALRT_BUSOL (1 << 4) // Bus Over-Voltage +#define INA228_DIAG_ALRT_BUSUL (1 << 3) // Bus Under-Voltage (UVLO) +#define INA228_DIAG_ALRT_POL (1 << 2) // Power Over-Limit +#define INA228_DIAG_ALRT_CNVRF (1 << 1) // Conversion Ready Flag +#define INA228_DIAG_ALRT_MEMSTAT (1 << 0) // Memory Status + +// Battery telemetry from INA228 +typedef struct { + uint16_t voltage_mv; // Battery voltage in mV + int16_t current_ma; // Battery current in mA (+ = charging, - = discharging) + int32_t power_mw; // Battery power in mW + int32_t energy_mwh; // Accumulated energy in mWh (since last reset) + float charge_mah; // Accumulated charge in mAh (since last reset) + float die_temp_c; // Die temperature in °C +} Ina228BatteryData; + +class Ina228Driver { +public: + // i2c_addr default is for A0=A1=GND + Ina228Driver(uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + + // Initialize INA228 with default configuration. + // shunt_resistor_mohm is in milliohms; the MR2 board fits a 100mΩ shunt. + bool begin(float shunt_resistor_mohm); + + // Check if INA228 is present and responsive + bool isConnected(); + + // Reset INA228 to default values + void reset(); + + // Read battery voltage in mV + uint16_t readVoltage_mV(); + + // Read battery current in mA (+ = charging, - = discharging) + int16_t readCurrent_mA(); + + // Read battery current in mA with full float precision (+ = charging, - = discharging). + // ±1 LSB ≈ 3.125 µA. + float readCurrent_mA_precise(); + + // Read battery power in mW + int32_t readPower_mW(); + + // Read accumulated energy in mWh (Coulomb Counter) + int32_t readEnergy_mWh(); + + // Read accumulated charge in mAh (Coulomb Counter) + float readCharge_mAh(); + + // Read die temperature in °C + float readDieTemperature_C(); + + // Get all battery data in one call + bool readAll(Ina228BatteryData* data); + + // Reset Coulomb Counter (energy and charge accumulators) + void resetCoulombCounter(); + + // Read back SHUNT_CAL register value (diagnostic) + uint16_t readShuntCalRegister(); + + // Read back ADC_CONFIG register value (diagnostic) + uint16_t readAdcConfigRegister(); + + // Read back CONFIG register value (diagnostic) + uint16_t readConfigRegister(); + + // Validate SHUNT_CAL and repair if corrupted. + // Returns true if SHUNT_CAL is correct (or was repaired), false if repair failed. + bool validateAndRepairShuntCal(); + + // Set bus under-voltage alert threshold in mV (for UVLO; e.g., 3200 for Li-Ion) + bool setUnderVoltageAlert(uint16_t voltage_mv); + + // Set bus over-voltage alert threshold in mV + bool setOverVoltageAlert(uint16_t voltage_mv); + + // Configure ALERT output. enable_uvlo selects averaged comparisons (SLOWALERT). + // BUVL must be set separately; setUnderVoltageAlert(0) disables UVLO comparison. + // latch_alert: true to latch the alert until DIAG_ALRT is read. + void enableAlert(bool enable_uvlo = true, bool active_high = false, bool latch_alert = false); + + // Check if alert condition is active + bool isAlertActive(); + + // Clear alert flags + void clearAlert(); + + // Get diagnostic and alert register value. + // WARNING: In LATCH mode, reading DIAG_ALRT clears latched alert flags and de-asserts ALERT pin! + uint16_t getDiagnosticFlags(); + + // Read back raw BUVL threshold register value (multiply by 3.125 for mV) + uint16_t readBuvlRegister(); + + // Put INA228 into shutdown mode — disables all measurements and Coulomb Counter to save power. + // Returns true if shutdown confirmed via readback, false if write failed. + bool shutdown(); + + // Wake INA228 from shutdown mode (re-enables continuous measurement mode) + void wakeup(); + + // Read battery voltage in mV directly via I2C, without requiring driver initialization — + // for early boot use before the INA228 is initialized. Returns 0 if the read fails. + // Triggers a One-Shot ADC conversion; uses the high-precision 20-bit ADC (±0.1% accuracy). + static uint16_t readVBATDirect(TwoWire* wire = &Wire, uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + +private: + uint8_t _i2c_addr; + float _shunt_mohm; + float _current_lsb; // Current LSB in A (constant per datasheet) + uint16_t _base_shunt_cal; // SHUNT_CAL value, computed from fixed constants in begin() + + bool writeRegister16(uint8_t reg, uint16_t value); + uint16_t readRegister16(uint8_t reg); + + // Read 24-bit register (sign-extended to 32-bit) + int32_t readRegister24(uint8_t reg); + + // Read 40-bit register (for energy/charge) + int64_t readRegister40(uint8_t reg); +}; diff --git a/variants/inhero_mr2/lib/SimplePreferences.h b/variants/inhero_mr2/lib/SimplePreferences.h new file mode 100644 index 0000000000..e5dadfbe64 --- /dev/null +++ b/variants/inhero_mr2/lib/SimplePreferences.h @@ -0,0 +1,116 @@ +/* + * 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()); + } + + bool remove(const char* key) { + if (!_started) return false; + String path = getFilePath(key); + if (!InternalFS.exists(path.c_str())) return true; + return InternalFS.remove(path.c_str()); + } +}; diff --git a/variants/inhero_mr2/platformio.ini b/variants/inhero_mr2/platformio.ini new file mode 100644 index 0000000000..b254cc7028 --- /dev/null +++ b/variants/inhero_mr2/platformio.ini @@ -0,0 +1,72 @@ +[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} + ; The nRF52 framework uses -Ofast. Keep NaN/Inf checks for unset altitude + ; and invalid sensor/configuration values instead of optimizing them away. + -fno-finite-math-only + -I variants/inhero_mr2 + -D INHERO_MR2 + -D PIN_BOARD_SCL=14 + -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..229f2f3351 --- /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); + InheroMr2SensorManager sensors(nmea); +#else + InheroMr2SensorManager 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..ef3e3f40cc --- /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 InheroMr2SensorManager sensors; + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/inhero_mr2/variant.cpp b/variants/inhero_mr2/variant.cpp new file mode 100644 index 0000000000..fed9cc7c6a --- /dev/null +++ b/variants/inhero_mr2/variant.cpp @@ -0,0 +1,48 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + Modified (c) 2026, Inhero GmbH + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include "nrf.h" + +const uint32_t g_ADigitalPinMap[] = +{ + // P0 + 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , + 8 , 9 , 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47 +}; + +void initVariant() +{ + // LED1 & LED2 + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + + pinMode(PIN_LED2, OUTPUT); + ledOff(PIN_LED2); +} diff --git a/variants/inhero_mr2/variant.h b/variants/inhero_mr2/variant.h new file mode 100644 index 0000000000..0772894889 --- /dev/null +++ b/variants/inhero_mr2/variant.h @@ -0,0 +1,177 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + Modified (c) 2026, Inhero GmbH + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_INHERO_MR2_ +#define _VARIANT_INHERO_MR2_ + +#define INHERO_MR2 + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF +// define USE_LFRC // Board uses RC for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/* + * Inhero MR2 GPIO definitions + */ +static const uint8_t WB_IO1 = 17; // SLOT_A SLOT_B +static const uint8_t WB_IO2 = 34; // SLOT_A SLOT_B +static const uint8_t WB_IO3 = 21; // SLOT_C +static const uint8_t WB_IO4 = 4; // SLOT_C +static const uint8_t WB_IO5 = 9; // SLOT_D +static const uint8_t WB_IO6 = 10; // SLOT_D +static const uint8_t WB_SW1 = 33; // IO_SLOT +static const uint8_t WB_A0 = 5; // IO_SLOT +static const uint8_t WB_A1 = 31; // IO_SLOT +static const uint8_t WB_I2C1_SDA = 13; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C1_SCL = 14; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C2_SDA = 24; // IO_SLOT +static const uint8_t WB_I2C2_SCL = 25; // IO_SLOT +static const uint8_t WB_SPI_CS = 26; // IO_SLOT +static const uint8_t WB_SPI_CLK = 3; // IO_SLOT +static const uint8_t WB_SPI_MISO = 29; // IO_SLOT +static const uint8_t WB_SPI_MOSI = 30; // IO_SLOT + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (35) +#define PIN_LED2 (36) +#define BQ_INT_PIN (21) +// BQ25798 CE via DMN2004TK-7 FET — inverted: HIGH = charge enable, LOW = charge disable +#define BQ_CE_PIN (4) // P0.04 (WB_IO4) +// INA228 ALERT (active-low, open-drain) for low-voltage sleep trigger +#define INA_ALERT_PIN (34) // P1.02 (WB_IO2) + +#define LED_BUILTIN PIN_LED1 +#define LED_CONN PIN_LED2 + +#define LED_BLUE PIN_LED1 // P1.03 +#define LED_RED PIN_LED2 // P1.04 + +#define LED_STATE_ON 1 // State when LED is litted + +/* + * Buttons + */ +// No user buttons on Inhero MR2 + +/* + * Analog pins + */ +#define PIN_A0 (5) //(3) +#define PIN_A1 (31) //(4) +#define PIN_A2 (28) +#define PIN_A3 (29) +#define PIN_A4 (30) +#define PIN_A5 (31) +#define PIN_A6 (0xff) +#define PIN_A7 (0xff) + +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; +static const uint8_t A6 = PIN_A6; +static const uint8_t A7 = PIN_A7; +#define ADC_RESOLUTION 14 + +// Other pins +#define PIN_AREF (2) +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +static const uint8_t AREF = PIN_AREF; + +/* + * Serial interfaces + */ +// TXD1 RXD1 on Base Board +#define PIN_SERIAL1_RX (15) +#define PIN_SERIAL1_TX (16) + +// TXD0 RXD0 on Base Board +#define PIN_SERIAL2_RX (19) +#define PIN_SERIAL2_TX (20) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 1 + +#define PIN_SPI_MISO (29) +#define PIN_SPI_MOSI (30) +#define PIN_SPI_SCK (3) + +static const uint8_t SS = 26; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +/* + * Wire Interfaces + */ +#define WIRE_INTERFACES_COUNT 2 + +#define PIN_WIRE_SDA (13) +#define PIN_WIRE_SCL (14) + +#define PIN_WIRE1_SDA (24) +#define PIN_WIRE1_SCL (25) + +// QSPI Pins +// QSPI occupied by GPIO's +#define PIN_QSPI_SCK 3 // 19 +#define PIN_QSPI_CS 26 // 17 +#define PIN_QSPI_IO0 30 // 20 +#define PIN_QSPI_IO1 29 // 21 +#define PIN_QSPI_IO2 28 // 22 +#define PIN_QSPI_IO3 2 // 23 + +// On-board QSPI Flash +// No onboard flash +#define EXTERNAL_FLASH_DEVICES IS25LP080D +#define EXTERNAL_FLASH_USE_QSPI + +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif