diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 7e5be76..1e63efd 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -169,6 +169,7 @@ arduino-cli upload --fqbn esp32:esp32:adafruit_feather_esp32s3 --port F | `--before usb-reset` from a running app | does not enter download mode | | bulk `read-flash` over native USB | fails repeatedly; small transactions like `chip-id` work | | holding a serial handle across a reset | port re-enumerates, handle dies | +| `pio-flash confirm` with its output redirected to a file or pipe on Windows, default code page | looks like a hang at `Connecting...` with no `Writing at` line for ten minutes. Not the board: PlatformIO's echo thread dies with `UnicodeEncodeError` (cp1252) on esptool's Unicode progress bar, esptool blocks on the full pipe **mid-write**, and the chip is left in the stub. Set `PYTHONUTF8=1 PYTHONIOENCODING=utf-8` for the wrapper call. Seen twice on 2026-09-08 (#289), clean flash on the third run with the variables set | A full flash image backup needs a UART adapter wired to RX/TX — it is not achievable over native USB. diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 9f9ac2a..7dd3449 100644 --- a/docs/HARDWARE.md +++ b/docs/HARDWARE.md @@ -378,6 +378,20 @@ Source for all of these: UM's FeatherS3D pinout card, needs an external boost from `BAT`. IO34 "5V SENSE" reads the USB net through a 2 kΩ / 3.3 kΩ divider. STEMMA1 (LDO1) = IO9 SCL / IO8 SDA; STEMMA2 (LDO2) = IO15 SCL / IO16 SDA, as the wiring page says. +- **STEMMA QT chain order on the bench** (owner, 2026-09-08 18:5x, during #289): Feather → + BME688 → SHT41 → LSM6DSOX/LIS3MDL (one 4517 board) → TSL2591 (unused). The IMU board was + moved up the chain to this position. The bus failure it was moved for did not change + with the move: one failed transaction to the IMU board per loop pass, 22.4/s, steady for + 75 minutes either side of it, and it moved from the LIS3MDL read (garbage magnetometer + data until 17:49) to the other read on the same board with no reboot. The scan's 0x29 is + that TSL2591, not a VEML7700 (label fixed in #289). + The 4517 itself (adafruit.com/product/4517 and learn.adafruit.com/st-9-dof-combo/pinouts, + read 2026-09-08): one on-board 3.3 V regulator for both chips (AP2112K, or a ≥150 mA + substitute since the 2022-06 parts shortage), FET level shifters on SDA/SCL with 10 kΩ + pull-ups, address jumpers on the back — `ADM` bridges the LIS3MDL 0x1C → 0x1E, `ADAG` + bridges the LSM6DSOX 0x6A → 0x6B, both open on the bench — and INT1/INT2/INTM/DRDY pins, + none wired. Regulator and level shifters are shared by both chips, so a fault in either + would hit both reads at once; the bench fault takes one chip at a time. - **First run results** (image 8af7ce5): TFT init and LVGL completed and the backlight lit, but the panel stayed blank, the I2C1 scan found zero devices (the stacked OLED wing included), and the first SD mount never returned. The SD hang was two SPI drivers on one diff --git a/src/battery.cpp b/src/battery.cpp index e485ecb..b3c1d41 100644 --- a/src/battery.cpp +++ b/src/battery.cpp @@ -2,10 +2,18 @@ #include "battery.h" #include "fram.h" #include "logging.h" +#include "i2c_health.h" Adafruit_MAX17048 battery; bool batteryAvailable = false; +// Dropout tracking (#289). The gauge is read on a 10 s timer and by the +// settings screen and /diags, never in a tight loop, so a health check every +// BATT_CHECK_MS is enough. isDeviceReady() reads the version register and is +// false on a NACK; every cellVoltage()/cellPercent() call already gates on it. +static I2CDevice battDev = {"MAX17048", 0x36, &batteryAvailable}; +#define BATT_CHECK_MS 2000 + void initBattery() { logPrint("Initializing MAX17048... "); @@ -15,9 +23,22 @@ void initBattery() { } batteryAvailable = true; + i2cNoteReady(battDev); logPrintln("OK"); } +// Called every loop pass (#289). +void serviceBattery() { + if (!batteryAvailable) { + if (i2cReprobeDue(battDev)) initBattery(); + return; + } + static unsigned long lastCheck = 0; + if (millis() - lastCheck < BATT_CHECK_MS) return; + lastCheck = millis(); + if (battery.isDeviceReady()) i2cNoteOk(battDev); else i2cNoteFail(battDev); +} + // Log battery data to SD card for analysis void logBatteryToSD() { if (!sdHealth.available || !batteryAvailable) return; diff --git a/src/battery.h b/src/battery.h index c376fb8..acb255a 100644 --- a/src/battery.h +++ b/src/battery.h @@ -9,6 +9,7 @@ extern Adafruit_MAX17048 battery; extern bool batteryAvailable; void initBattery(); +void serviceBattery(); // health check; re-probe a dropped gauge and re-init it (#289) void logBatteryToSD(); void logBatteryToFRAM(); bool isBatteryConnected(); diff --git a/src/env.cpp b/src/env.cpp index a5b741c..e0c0a1c 100644 --- a/src/env.cpp +++ b/src/env.cpp @@ -2,6 +2,7 @@ #include "env.h" #include "fram.h" #include "logging.h" +#include "i2c_health.h" // BSEC2 IAQ config for BME680/688 at 3.3V, 3-second sample rate, 4-day calibration const uint8_t bsec2_config[] = { @@ -12,6 +13,18 @@ Bsec2 envSensor; Adafruit_SHT4x sht4 = Adafruit_SHT4x(); // SHT41 temp/humidity (#48) bool bmeAvailable = false; bool shtAvailable = false; // SHT41 temp/humidity (#48) + +// Dropout tracking (#289). The BME688 address is whichever answered in +// initBME688(), the other is the alternate the re-probe also tries, and its +// chip-ID register is the re-probe check. The SHT41 has one address and no +// ID register, so its re-probe is an address probe; initSHT41() (a serial +// number read) is the real test when it answers. +static I2CDevice shtDev = {"SHT41", 0x44, &shtAvailable}; +static I2CDevice bmeDev = {"BME688", 0x77, &bmeAvailable, BME68X_REG_CHIP_ID, BME68X_CHIP_ID, 0x76}; +// The BME688 is touched once per BSEC sample (LP mode = 3 s) and its status +// persists until the next access, so its health is sampled on that cadence; +// sampling every loop pass would count one failed read five times over. +#define BME_HEALTH_MS 3000 static uint8_t bsecState[BSEC_MAX_STATE_BLOB_SIZE]; static unsigned long lastBsecStateSave = 0; bool bsecStateLoaded = false; @@ -88,14 +101,19 @@ void initBME688() { }; // Try primary address (0x77), then secondary (0x76) - if (!envSensor.begin(0x77, Wire)) { - if (!envSensor.begin(0x76, Wire)) { + uint8_t addr = 0x77; + if (!envSensor.begin(addr, Wire)) { + addr = 0x76; + if (!envSensor.begin(addr, Wire)) { logPrintln("NOT FOUND"); logPrintf(" BSEC status: %d\n", envSensor.status); logPrintf(" Sensor status: %d\n", envSensor.sensor.status); return; } } + bmeDev.addr = addr; + bmeDev.altAddr = (addr == 0x77) ? 0x76 : 0x77; + i2cNoteReady(bmeDev); // Load BSEC2 IAQ config if (!envSensor.setConfig(bsec2_config)) { @@ -137,6 +155,7 @@ void initSHT41() { sht4.setHeater(SHT4X_NO_HEATER); shtAvailable = true; + i2cNoteReady(shtDev); logPrintln("OK (0x44)"); // Read initial values immediately @@ -261,6 +280,9 @@ void readSHT41() { if (sht4.getEvent(&humEv, &tempEv)) { shtData.temperature = tempEv.temperature; shtData.humidity = humEv.relative_humidity; + i2cNoteOk(shtDev); + } else { + i2cNoteFail(shtDev); // false on a NACK (before the measurement delay) or a bad CRC } } @@ -272,6 +294,32 @@ void readBME688() { LOG_ERROR("BSEC error: %d", envSensor.status); } } + + // Bus health (#289): the BME68x driver checks endTransmission() and leaves + // BME68X_E_COM_FAIL in sensor.status when the chip stops answering. Only + // that code counts: the other negative codes (null pointer, self-test, + // invalid length) are not bus faults and a re-probe would not clear them + // (review finding on #289). BSEC touches the chip in one burst per sample + // (setTPH, heater profile, forced mode, then fetchData), so a status + // sampled on the same 3 s cadence sees at most one or two samples per + // burst: one transient failure cannot reach I2C_FAIL_LIMIT. + static unsigned long lastHealth = 0; + if (millis() - lastHealth >= BME_HEALTH_MS) { + lastHealth = millis(); + if (envSensor.sensor.status == BME68X_E_COM_FAIL) i2cNoteFail(bmeDev); else i2cNoteOk(bmeDev); + } +} + +// Called every loop pass (#289): re-probe a dropped sensor on the slow timer +// and run its normal init when it answers. +void serviceSHT41() { + if (!shtAvailable && i2cReprobeDue(shtDev)) initSHT41(); +} + +void serviceBME688() { + if (bmeAvailable || !i2cReprobeDue(bmeDev)) return; + initBME688(); // fresh BSEC instance: restore its state the way setup() does + if (bmeAvailable && !loadBsecFromFRAM() && sdHealth.available) loadBsecState(); } // Helper function to get IAQ accuracy as short text diff --git a/src/env.h b/src/env.h index fd9ad36..bac9f73 100644 --- a/src/env.h +++ b/src/env.h @@ -41,6 +41,8 @@ bool saveBsecToFRAM(); bool loadBsecFromFRAM(); void readSHT41(); void readBME688(); +void serviceSHT41(); // re-probe a dropped sensor and re-init it (#289) +void serviceBME688(); const char* getIaqAccuracyText(uint8_t accuracy); const char* getIaqQualityText(float iaq); float hPaToInHg(float hPa); diff --git a/src/i2c_health.cpp b/src/i2c_health.cpp new file mode 100644 index 0000000..84fba41 --- /dev/null +++ b/src/i2c_health.cpp @@ -0,0 +1,147 @@ +// i2c_health.cpp -- per-device I2C failure tracking, back-off and re-probe (#289). +#include "i2c_health.h" +#include "logging.h" +#include +#include + +void i2cNoteOk(I2CDevice& d) { + d.fails = 0; + d.recent <<= 1; + // Probation ends with good behaviour, not only at the next drop: a device + // that has answered for I2C_FLAP_MS since it came back is off the back-off + // ladder (review finding on #289: otherwise only the timing of the next + // failure could clear it). + if (d.backoff && millis() - d.lastReturn >= I2C_FLAP_MS) d.backoff = 0; +} + +void i2cNoteReady(I2CDevice& d) { + d.fails = 0; + d.recent = 0; +} + +bool i2cNoteFail(I2CDevice& d) { + d.totalFails++; + if (d.fails < 255) d.fails++; + d.recent = (d.recent << 1) | 1; + if (!*d.available) return false; // already dropped + if (d.fails < I2C_FAIL_LIMIT && __builtin_popcount(d.recent) < I2C_FAIL_WINDOW) return false; + *d.available = false; + d.drops++; + d.droppedAt = millis(); + d.lastProbe = d.droppedAt; // first re-probe one interval from now + // Flapping: dropped again soon after it came back, so wait longer each time. + if (d.lastReturn && d.droppedAt - d.lastReturn < I2C_FLAP_MS) { + if (d.backoff < I2C_BACKOFF_MAX) d.backoff++; + } else { + d.backoff = 0; + } + logPrintf("[I2C] %s (0x%02X) stopped answering: %u consecutive failures, %d of the last 16 checks; dropped, re-probing in %lu s\n", + d.name, d.addr, (unsigned)d.fails, __builtin_popcount(d.recent), + (unsigned long)(I2C_REPROBE_MS << d.backoff) / 1000); + return true; +} + +bool i2cProbe(uint8_t addr) { + // Zero-length write: the core turns it into i2c_master_probe (address and + // ACK only), the same transaction scanI2CBus() uses. + Wire.beginTransmission(addr); + return Wire.endTransmission() == 0; +} + +bool i2cReadReg(uint8_t addr, uint8_t reg, uint8_t* val) { + // The same write-then-read every Adafruit driver uses. endTransmission(false) + // only queues the register byte on this core; the combined transaction runs + // in requestFrom(), which returns 0 bytes when any part of it is NACKed. + Wire.beginTransmission(addr); + Wire.write(reg); + Wire.endTransmission(false); + if (Wire.requestFrom((uint8_t)addr, (uint8_t)1) != 1) return false; + *val = Wire.read(); + return true; +} + +static bool checkAt(uint8_t addr, const I2CDevice& d) { + if (d.idReg == I2C_NO_REG) return i2cProbe(addr); + uint8_t v; + return i2cReadReg(addr, d.idReg, &v) && v == d.idVal; +} + +bool i2cCheck(const I2CDevice& d) { + return checkAt(d.addr, d); +} + +// A slave left mid-transaction by a glitch can hold SDA low until it sees +// enough clocks to finish its byte and then a STOP. Clock it out with the +// pins as plain GPIO, then hand the pins back to the I2C peripheral. Same +// end()/begin() pair the #283 bus diagnostic uses, verified on both boards. +void i2cBusRecover() { + Wire.end(); + // Pins back to GPIO as inputs first: the core refuses digitalWrite() on a + // pin still routed to the I2C peripheral (seen in the first recovery on the + // bench, 2026-09-08 20:44). Then set the latch high while nothing drives, + // then switch SCL to open-drain output so it does not dip on the change. + pinMode(SDA, INPUT_PULLUP); + pinMode(SCL, INPUT_PULLUP); + digitalWrite(SDA, HIGH); + digitalWrite(SCL, HIGH); + pinMode(SCL, OUTPUT_OPEN_DRAIN); + delayMicroseconds(5); + for (int i = 0; i < 9; i++) { + digitalWrite(SCL, LOW); + delayMicroseconds(5); + digitalWrite(SCL, HIGH); + delayMicroseconds(5); + if (digitalRead(SDA) == HIGH) break; // slave released the line + } + // START then STOP with no address: every slave's state machine resets on + // the STOP. SDA falling while SCL is high is the START. + pinMode(SDA, OUTPUT_OPEN_DRAIN); + digitalWrite(SDA, LOW); + delayMicroseconds(5); + digitalWrite(SDA, HIGH); + delayMicroseconds(5); + Wire.begin(); // variant SDA/SCL at the default clock, as in setup() +} + +bool i2cReprobeDue(I2CDevice& d) { + if (*d.available || d.drops == 0) return false; + unsigned long now = millis(); + if (now - d.lastProbe < (unsigned long)(I2C_REPROBE_MS << d.backoff)) return false; + d.lastProbe = now; + if (!checkAt(d.addr, d)) { + i2cBusRecover(); + if (!checkAt(d.addr, d)) { + if (!d.altAddr || !checkAt(d.altAddr, d)) return false; + logPrintf("[I2C] %s answers at 0x%02X now, not 0x%02X\n", d.name, d.altAddr, d.addr); + uint8_t was = d.addr; + d.addr = d.altAddr; + d.altAddr = was; + } + } + logPrintf("[I2C] %s (0x%02X) answers again after %lu s (drop #%lu, %lu failed checks so far); re-initialising\n", + d.name, d.addr, (now - d.droppedAt) / 1000, (unsigned long)d.drops, (unsigned long)d.totalFails); + d.lastReturn = now; // a drop within I2C_FLAP_MS of this raises the back-off + return true; // the caller's init clears the history via i2cNoteReady() if it succeeds +} + +void logResetReason() { + esp_reset_reason_t r = esp_reset_reason(); + const char* s; + switch (r) { + case ESP_RST_POWERON: s = "power-on"; break; + case ESP_RST_EXT: s = "external pin"; break; + case ESP_RST_SW: s = "software (esp_restart)"; break; + case ESP_RST_PANIC: s = "PANIC -- the previous run crashed"; break; + case ESP_RST_INT_WDT: s = "INTERRUPT WATCHDOG"; break; + case ESP_RST_TASK_WDT: s = "TASK WATCHDOG -- loop() stalled"; break; + case ESP_RST_WDT: s = "other watchdog"; break; + case ESP_RST_DEEPSLEEP: s = "deep-sleep wake"; break; + case ESP_RST_BROWNOUT: s = "BROWNOUT -- supply dipped"; break; + case ESP_RST_SDIO: s = "SDIO"; break; + case ESP_RST_USB: s = "USB peripheral"; break; + case ESP_RST_JTAG: s = "JTAG"; break; + case ESP_RST_EFUSE: s = "efuse error"; break; + default: s = "unknown"; break; + } + logPrintf("[BOOT] Reset reason: %s (%d)\n", s, (int)r); +} diff --git a/src/i2c_health.h b/src/i2c_health.h new file mode 100644 index 0000000..42bdf8b --- /dev/null +++ b/src/i2c_health.h @@ -0,0 +1,77 @@ +#pragma once +// i2c_health.h -- per-device I2C failure tracking, back-off and re-probe (#289). +// +// Why: on 2026-09-08 the touch controller, and later the LIS3MDL, went silent +// on the bus for hours. Every reader retried its device on every loop pass, +// each retry costing two error lines to serial and SD, so the loop fell from +// 44 Hz to 12-25 Hz and the log filled with a million identical errors. +// A device that stops answering must cost nothing per pass and come back on +// its own when it answers again. +// +// Pattern for a unit that owns an I2C device: +// static I2CDevice dev = {"LSM6DSOX", 0x6A, &imuAvailable, LSM6DS_WHOAMI, LSM6DSOX_CHIP_ID, 0x6B}; +// health path: i2cCheck(dev) ? i2cNoteOk(dev) : i2cNoteFail(dev); (on a slow timer) +// read path: a library call that reports failure feeds the same two +// service path: if (!imuAvailable && i2cReprobeDue(dev)) initIMU(); +// +// What counts as "answering": a chip-ID register read that returns the +// expected value. Not an address probe. The bench case that shaped this: +// the LIS3MDL kept ACKing its address (the boot scan listed 0x1C every time) +// while NACKing nearly every data byte, so an address-only probe would have +// called it healthy all night. Devices with no ID register (SHT41, MAX17048) +// fall back to the probe; their own init is the real test when they return. +// +// i2cNoteFail() clears *available after I2C_FAIL_LIMIT consecutive failures +// and logs once. i2cReprobeDue() runs at most every I2C_REPROBE_MS for a +// dropped device: check at its address; if that fails, clear the bus (nine +// SCL clocks with SDA released, then START+STOP), re-open Wire and check +// again, at the alternate address too when the chip has one (a floating +// address-select pad moves a chip between its two addresses). It returns +// true only when the chip answers, so the caller runs its normal init. Only +// devices present at boot are re-probed: dropout recovery, not hot-plug. +#include + +#define I2C_FAIL_LIMIT 5 // consecutive failures before a device is dropped... +#define I2C_FAIL_WINDOW 8 // ...or this many of the last 16 checks: an intermittent + // chip (the bench LIS3MDL answered ~1 read in 100) must + // not keep resetting the count with the odd success +#define I2C_REPROBE_MS 10000 // how often a dropped device is probed again... +#define I2C_FLAP_MS 30000 // ...doubling each time it drops again within this long of a +#define I2C_BACKOFF_MAX 5 // return, up to I2C_REPROBE_MS << 5 = 320 s. The bench + // LIS3MDL with its floating address pad came back and + // dropped again every 11 s, a re-init and a burst of failed + // reads each time (2026-09-08 20:44). The first good spell + // of I2C_FLAP_MS resets it. +#define I2C_NO_REG 0xFF // idReg value for a chip without an ID register + +struct I2CDevice { + const char* name; + uint8_t addr; + bool* available; + uint8_t idReg = I2C_NO_REG; // chip-ID register, or I2C_NO_REG for an address probe + uint8_t idVal = 0; // the value that register must return + uint8_t altAddr = 0; // the chip's other address, 0 if it has one only + uint8_t fails = 0; // consecutive; reset by i2cNoteOk + uint16_t recent = 0; // last 16 results, 1 = failed, newest in bit 0 + uint32_t totalFails = 0; + uint32_t drops = 0; + uint8_t backoff = 0; // re-probe interval is I2C_REPROBE_MS << backoff + unsigned long droppedAt = 0; + unsigned long lastProbe = 0; + unsigned long lastReturn = 0; // when it last answered a re-probe; 0 = never dropped +}; + +void i2cNoteOk(I2CDevice& d); +bool i2cNoteFail(I2CDevice& d); // true on the call that drops the device +void i2cNoteReady(I2CDevice& d); // a unit's init calls this on success: clears the count and the window. + // Nothing else clears the window, so a chip that passes the re-probe + // but fails its init keeps its history (review finding on #289) +bool i2cReprobeDue(I2CDevice& d); // true when a dropped device answers again (d.addr updated if it moved) +bool i2cCheck(const I2CDevice& d); // one real transaction at d.addr: ID register read, or probe +bool i2cReadReg(uint8_t addr, uint8_t reg, uint8_t* val); // write reg, repeated start, read one byte +bool i2cProbe(uint8_t addr); // address ACK only, one transaction +void i2cBusRecover(); // clock out a stuck slave, re-open Wire + +// Boot: print why the chip reset (power-on, panic, watchdog, brownout...). +// Without it a crash boot is unattributable from the next boot's log. +void logResetReason(); diff --git a/src/imu.cpp b/src/imu.cpp index 7aa03fb..3d94524 100644 --- a/src/imu.cpp +++ b/src/imu.cpp @@ -1,11 +1,27 @@ // imu.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). #include "imu.h" #include "logging.h" +#include "i2c_health.h" Adafruit_LSM6DSOX lsm; Adafruit_LIS3MDL lis; bool imuAvailable = false; bool magAvailable = false; + +// Dropout tracking (#289). Both chips sit on one breakout (Adafruit 4517) but +// are separate I2C devices, and on the bench they failed separately: the +// LIS3MDL NACKed nearly every data byte for hours while the LSM6DSOX beside +// it read fine. Each has two possible addresses; the one that answered in +// initIMU() is recorded, the other is the alternate the re-probe also tries. +// The health check is the WHO_AM_I read: the LIS3MDL kept ACKing its address +// (the boot scan listed 0x1C throughout) while failing every data read, so +// an address probe is not a signal for these chips. +static I2CDevice imuDev = {"LSM6DSOX", 0x6A, &imuAvailable, LSM6DS_WHOAMI, LSM6DSOX_CHIP_ID, 0x6B}; +static I2CDevice magDev = {"LIS3MDL", 0x1C, &magAvailable, LIS3MDL_REG_WHO_AM_I, 0x3D, 0x1E}; +// getEvent() on both chips returns true unconditionally, so the read path +// carries no failure signal; the checks run on this timer instead. Worst +// case a dead chip is read for IMU_CHECK_MS x I2C_FAIL_LIMIT = 1 s. +#define IMU_CHECK_MS 200 float magOffsetX = 0, magOffsetY = 0, magOffsetZ = 0; bool magCalibrated = false; bool magCalibrating = false; @@ -22,41 +38,94 @@ void magLogPrintln(const char* msg) { } void initIMU() { + // Re-runnable (#289): a re-init that fails must not leave the flag from + // the previous success standing. + imuAvailable = false; + magAvailable = false; + logPrint("Initializing LSM6DSOX... "); - if (!lsm.begin_I2C(0x6A)) { - if (!lsm.begin_I2C(0x6B)) { + uint8_t addr = 0x6A; + if (!lsm.begin_I2C(addr)) { + addr = 0x6B; + if (!lsm.begin_I2C(addr)) { logPrintln("NOT FOUND"); return; } } + imuDev.addr = addr; + imuDev.altAddr = (addr == 0x6A) ? 0x6B : 0x6A; lsm.setAccelRange(LSM6DS_ACCEL_RANGE_4_G); lsm.setGyroRange(LSM6DS_GYRO_RANGE_500_DPS); lsm.setAccelDataRate(LSM6DS_RATE_104_HZ); lsm.setGyroDataRate(LSM6DS_RATE_104_HZ); + // Read one setting back. begin_I2C() checks only WHO_AM_I and the setters + // are void, so a chip that NACKs its configuration writes would be declared + // available while misconfigured or powered down. The 17:38 boot on + // 2026-09-08 did exactly that on the LIS3MDL: WHO_AM_I read, every write + // after it NACKed, "OK". A failed read-back returns all-ones, never 104 Hz. + if (lsm.getAccelDataRate() != LSM6DS_RATE_104_HZ) { + logPrintln("CONFIG NOT ACCEPTED (read-back mismatch)"); + return; + } + imuAvailable = true; + i2cNoteReady(imuDev); logPrintln("OK"); logPrint("Initializing LIS3MDL... "); - if (!lis.begin_I2C(0x1C)) { - if (!lis.begin_I2C(0x1E)) { + addr = 0x1C; + if (!lis.begin_I2C(addr)) { + addr = 0x1E; + if (!lis.begin_I2C(addr)) { logPrintln("NOT FOUND"); return; } } + magDev.addr = addr; + magDev.altAddr = (addr == 0x1C) ? 0x1E : 0x1C; lis.setPerformanceMode(LIS3MDL_MEDIUMMODE); lis.setOperationMode(LIS3MDL_CONTINUOUSMODE); lis.setDataRate(LIS3MDL_DATARATE_155_HZ); lis.setRange(LIS3MDL_RANGE_4_GAUSS); + // Same read-back as the LSM6DSOX above, on the setting that matters: a + // chip left in power-down (the register default, and what a failed read + // returns as all-ones) reports zeros for every axis. + if (lis.getOperationMode() != LIS3MDL_CONTINUOUSMODE) { + logPrintln("CONFIG NOT ACCEPTED (read-back mismatch)"); + return; + } + magAvailable = true; + i2cNoteReady(magDev); logPrintln("OK"); } +// Called every loop pass (#289). While both chips are up, read each WHO_AM_I +// every IMU_CHECK_MS and count the result. Once either has been dropped, +// re-probe it on the slow timer and run the normal init when it answers; +// initIMU() re-initialises both chips, which a breakout that lost power or +// its bus needs anyway, and tries both addresses of each. +void serviceIMU() { + if (!imuAvailable || !magAvailable) { + if ((!imuAvailable && i2cReprobeDue(imuDev)) || + (!magAvailable && i2cReprobeDue(magDev))) { + initIMU(); + } + return; + } + static unsigned long lastCheck = 0; + if (millis() - lastCheck < IMU_CHECK_MS) return; + lastCheck = millis(); + if (i2cCheck(imuDev)) i2cNoteOk(imuDev); else i2cNoteFail(imuDev); + if (i2cCheck(magDev)) i2cNoteOk(magDev); else i2cNoteFail(magDev); +} + // Load magnetometer calibration from SD card void loadMagCal() { if (!sdHealth.available) return; @@ -114,6 +183,10 @@ void saveMagCal() { void readIMU() { sensors_event_t accel, gyro, temp, mag; + // Neither getEvent() reports a failed read (they return true and leave the + // buffer uninitialised on a NACK); serviceIMU() watches the chips instead + // and the loop gate (imuAvailable && magAvailable) stops this once one is + // dropped (#289). lsm.getEvent(&accel, &gyro, &temp); lis.getEvent(&mag); diff --git a/src/imu.h b/src/imu.h index e2df607..966f4b6 100644 --- a/src/imu.h +++ b/src/imu.h @@ -33,3 +33,4 @@ void initIMU(); void loadMagCal(); void saveMagCal(); void readIMU(); +void serviceIMU(); // re-probe a dropped chip and re-init it (#289) diff --git a/src/src.ino b/src/src.ino index ba7907b..87c974f 100644 --- a/src/src.ino +++ b/src/src.ino @@ -69,6 +69,7 @@ #include "display.h" #include "ui_state.h" #include "touch.h" +#include "i2c_health.h" // reset reason at boot; the units own their device records (#289) #include "web.h" #include "lvgl_port.h" #include "oled_screens.h" @@ -134,6 +135,7 @@ void setup() { char banner[128]; snprintf(banner, sizeof(banner), "=================================\nField Compass Dual %s\n=================================\n\n", FW_VERSION); logPrintf("%s", banner); + logResetReason(); // panic / watchdog / brownout / power-on -- so a crash boot is attributable (#289) #ifdef FC_LDO2_PIN // UM FeatherS3 only (#283): the second LDO feeds the vertical STEMMA QT @@ -310,6 +312,15 @@ void loop() { if (shtAvailable) readSHT41(); // SHT41 temp/humidity (#48) if (imuAvailable && magAvailable) readIMU(); + // I2C health (#289): a device that stops answering is dropped after + // I2C_FAIL_LIMIT consecutive failures (its reads above stop), re-probed + // every I2C_REPROBE_MS, and re-initialised when it answers again. + serviceIMU(); + serviceSHT41(); + serviceBME688(); + serviceBattery(); + serviceTouch(); + // Weather logging (every 5 minutes) if (sdAvailable && bmeAvailable && (millis() - lastWeatherLog > WEATHER_LOG_INTERVAL)) { logWeatherReading(); @@ -481,7 +492,7 @@ static int scanI2CBus(TwoWire &bus, const char *label) { } else if (address == 0x44) { desc = " (SHT41 - Temp/Humidity)"; } else if (address == 0x29) { - desc = " (VEML7700 - Light Sensor)"; + desc = " (TSL2591 - Light Sensor, unused)"; // the bench device at 0x29 (#289); a VEML7700 lives at 0x10 } else if (address == 0x68) { desc = " (PCF8523 - RTC)"; } else if (address == 0x7E) { diff --git a/src/touch.cpp b/src/touch.cpp index f58e34e..90b7a3d 100644 --- a/src/touch.cpp +++ b/src/touch.cpp @@ -2,10 +2,34 @@ #include "touch.h" #include "fc_config.h" #include "logging.h" +#include "i2c_health.h" Adafruit_FT6206 ctp = Adafruit_FT6206(); bool touchAvailable = false; // FT6336U capacitive touch +// Dropout tracking (#289). The LVGL indev reads the chip at 30 Hz through +// touched(), which has no error path (a NACK reads as "not touched"), so the +// health check is a vendor-ID register read on its own timer (the same +// register begin() verifies). TOUCH_CHECK_MS x I2C_FAIL_LIMIT is how long a +// dead chip keeps being read: 5 s, ~150 failed reads, against the 250k of +// 2026-09-08. Dropping touchAvailable stops both the indev callback and the +// wake poll. +static I2CDevice touchDev = {"FT6336U", 0x38, &touchAvailable, FT62XX_REG_VENDID, FT62XX_VENDID}; +#define TOUCH_CHECK_MS 1000 + +// Called every loop pass (#289). initTouch() re-runs begin() and re-attaches +// the CTP_INT interrupt (the core replaces the handler on re-attach). +void serviceTouch() { + if (!touchAvailable) { + if (i2cReprobeDue(touchDev)) initTouch(); + return; + } + static unsigned long lastCheck = 0; + if (millis() - lastCheck < TOUCH_CHECK_MS) return; + lastCheck = millis(); + if (i2cCheck(touchDev)) i2cNoteOk(touchDev); else i2cNoteFail(touchDev); +} + // CTP_INT is active-low: the FT6336U pulls it low for the duration of a // touch. The interrupt is attached FALLING (initTouch), so the ISR runs once // per finger-down and does nothing but set the flag -- no pin read, no call @@ -29,6 +53,7 @@ void initTouch() { } touchAvailable = true; + i2cNoteReady(touchDev); // Configure interrupt pin (CTP_INT is active-low, open-drain). FALLING: // one interrupt per finger-down, nothing on release (#290). diff --git a/src/touch.h b/src/touch.h index 54293d2..c187198 100644 --- a/src/touch.h +++ b/src/touch.h @@ -7,6 +7,7 @@ extern bool touchAvailable; void IRAM_ATTR touchISR(); void initTouch(); +void serviceTouch(); // health probe; re-probe a dropped chip and re-init it (#289) // Wake-from-sleep path (#290). While the TFT sleeps loop() skips // lv_timer_handler(), so the LVGL indev callback -- the only reader of the