From 4a16bf151cd7f08b60dd16d9b261e5cfba64d8bc Mon Sep 17 00:00:00 2001 From: Martin Dobrev Date: Thu, 17 Sep 2026 17:48:00 +0100 Subject: [PATCH] fix: STATUS no longer starts an I2C bus on the Wireless Bridge's radio and PSRAM pins The first STATUS after boot scans the general I2C bus. Boards that name no I2C pins inherited Config.h's OLED default of 18/17, which on the Wireless Bridge is the LoRa chip select and the D0WDQ6's PSRAM clock: one STATUS put the node into a restart loop it left with no PSRAM. The Wireless Paper inherited the same pair. The panel pair now has no default (-1, no bus), mainBus() refuses to start a bus on -1 instead of letting the core fall back to the variant's 21/22, and busFor() reports whether begin() succeeded. The build refuses a general pair on a radio pin or, on classic ESP32 with PSRAM, the sdkconfig's PSRAM CS/CLK. --- docs/hardware.md | 13 +++++++++++++ docs/troubleshooting.md | 2 +- src/Config.h | 37 ++++++++++++++++++++++++++++++++++--- src/sys/I2cReg.h | 20 +++++++++++++++++--- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/hardware.md b/docs/hardware.md index eb079f5..96d757a 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -926,6 +926,19 @@ performs; and BOOT + RST recovers any of them. Everything in `Config.h` is `#ifndef`-guarded, so the board header wins and anything it omits falls back to a sensible default. Host connectivity is **not** declared here — it comes from `boards.json` (next step). + I2C pins are the exception to "a sensible default": there is none. A panel + on I2C names its pair (`PIN_OLED_SDA`/`PIN_OLED_SCL`), a board with parts + on a general-purpose bus of its own names that pair (`PIN_I2C_SDA`/ + `PIN_I2C_SCL`), and a board with neither says nothing, which reads as -1: + no bus. Pins matter even where nothing sits on them, because the console's + `STATUS` and `I2C` scan the general bus and starting it takes those pins + over — the default used to be the T3-S3's 18/17, and on the Wireless Bridge + that is the radio's chip select and the PSRAM clock, which the first + `STATUS` after boot turned into a restart loop. The build now refuses a + general I2C pair (`PIN_I2C_SDA`/`PIN_I2C_SCL`) that lands on one of the + radio's pins, or on the PSRAM's chip select or clock on a classic ESP32 + with PSRAM; and with no pair named the bus is never started, so a driver + that asks for it is told it is down. 2. `src/Config.h`: one line in the board-selection block mapping `-DBOARD_` to the header. 3. `platformio.ini`: a new `[env:]` (board, partitions, `-DBOARD_`, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 15b2378..b1a8772 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -39,7 +39,7 @@ ROM, and a power cycle boots whatever is in flash. | A node panics repeatedly and every reading taken afterwards looks healthy | the fault counters describe the *current* run. `diag.faults` / the `alloc_failures=` line are zeroed by the restart you are trying to explain, so a node that died of memory exhaustion answers `0` a minute later, every time — which is how one board panicked seven times in a week while every check said it was fine | read the **previous** run instead: `/api/status` gives `diag.boot.prev_alloc_failures` and `prev_contained`, and `STATUS` prints a `prev_uptime_s= prev_alloc_failures= prev_contained=` line on every node, always. Non-zero next to `clean=false` means it ran out of byte-addressable DRAM; zero means the panic was something else and the coredump is next. The word **`unknown`** rather than a number means nothing survived to report — a power cut, not a crash — or, where `prev_uptime_s` is a real figure beside it, the first boot after upgrading from a firmware that did not keep the counts. `prev_contained` counts every exception the run caught, not only allocation failures, so on its own it is not evidence of memory pressure. `tools/soak.py` records both and names the dead run in its summary | | A task's stack needs sizing, or a node restarts with `reason="panic or unhandled exception"` under load | stacks are guessed at when a task is written and only measurement settles them | `STACKS` reports bytes never used, per task, and names the tightest. The figures are high-water marks, so they only fall — read them after days of real traffic, not off the bench, and leave margin: they are the worst seen, not the worst possible | | A device answers the I2C scan but you do not know what it is, or a driver written from a vendor page finds the wrong chip id | the page describes a different revision of the board, or the part is not the one named | `I2C 0x7c` dumps that device's first 16 registers, which is where these parts keep their chip id and revision. Compare against the datasheet before writing a driver: a driver built on a page that is wrong fails in a way that looks like wiring. Sixteen copies of one byte mean the part does not auto-increment its register pointer, not that the bus is broken | -| A part on I2C reads as absent — touch layer, keyboard, charger — and the wiring looks right | its driver probed once at boot, before the part had finished coming up; or the board header names the wrong pins, which is indistinguishable from a part that is not fitted | `I2C` scans every bus now and says what answered: `RM I2C i2c main sda=7 scl=6 parts=3 acked=0x51 0x6b 0x7c`. An address that answers here but not to its own driver is a timing or address fault, not wiring; `parts=0` on a bus with parts on it is the pins. `STATUS` carries the same lines from the last scan | +| A part on I2C reads as absent — touch layer, keyboard, charger — and the wiring looks right | its driver probed once at boot, before the part had finished coming up; or the board header names the wrong pins, which is indistinguishable from a part that is not fitted | `I2C` scans every bus now and says what answered: `RM I2C i2c main sda=7 scl=6 parts=3 acked=0x51 0x6b 0x7c`. An address that answers here but not to its own driver is a timing or address fault, not wiring; `parts=0` on a bus with parts on it is the pins. `STATUS` carries the same lines from the last scan. A board with no general I2C bus — the Wireless Bridge, the Wireless Paper — prints no `i2c main` line at all, rather than a scan of pins it uses for something else | | Web flasher needs BOOT held | previous firmware's USB stack ignored the reset sequence | one-time; RetiMesh firmware resets hands-free | | `pio run -t upload` says `several ESP32 ports` | more than one board attached and no port chosen | `--upload-port /dev/serial/by-id/…` (by-path for CP2102 boards, which all report serial `0001`) | | `pio run -t upload` says the port `did not reappear` | the node accepted the bootloader request but its USB device did not come back within 8 s — a hub or host that re-enumerates slowly | hold BOOT, press RST, run the upload again; `RETIMESH_NO_AUTO_BOOTLOADER=1` leaves the reset to esptool | diff --git a/src/Config.h b/src/Config.h index 9b306c2..66559b1 100644 --- a/src/Config.h +++ b/src/Config.h @@ -644,7 +644,7 @@ #define SD_LOG_MAX_BYTES (1024UL * 1024UL) // --------------------------------------------------------------------------- -// Display — SSD1306 128x64 on I2C by default (T3-S3: SDA 18 / SCL 17, 0x3C) +// Display — SSD1306 128x64 on I2C by default (0x3C; the board names the pins) // --------------------------------------------------------------------------- #ifndef HAS_DISPLAY #define HAS_DISPLAY 1 @@ -681,11 +681,23 @@ #ifndef OLED_CONTROLLER #define OLED_CONTROLLER OLED_CONTROLLER_SSD1306 #endif +// The panel's pair, which a board with an OLED names in its own header. There +// is no default pair, and -1 is what "no such bus" reads as — I2cReg::mainBus() +// does not start a bus on pin -1 and hosts() does not list one. The default used to be 18/17, the T3-S3's +// pair, and the two boards that name none inherited it: the Wireless Bridge, +// which has no panel, and the Wireless Paper, whose panel is on SPI. Through +// PIN_I2C_* below that handed each a general bus on pins it uses for something +// else, and the first STATUS after boot scans that bus — on the Bridge, GPIO 18 +// is the radio's chip select and GPIO 17 the D0WDQ6's PSRAM clock, and the scan +// panicked the node into a restart loop that came out of it with no PSRAM. #ifndef PIN_OLED_SDA - #define PIN_OLED_SDA 18 + #define PIN_OLED_SDA -1 #endif #ifndef PIN_OLED_SCL - #define PIN_OLED_SCL 17 + #define PIN_OLED_SCL -1 +#endif +#if HAS_DISPLAY && DISPLAY_KIND == DISPLAY_KIND_OLED && (PIN_OLED_SDA < 0 || PIN_OLED_SCL < 0) + #error "an OLED board must name its panel's pins (PIN_OLED_SDA/PIN_OLED_SCL) in its header; a board with no panel sets HAS_DISPLAY 0" #endif // The board's general-purpose I2C — the bus that carries whatever is not the // panel: a charger, an accelerometer, a touch controller, a keyboard. Most @@ -702,6 +714,25 @@ #ifndef PIN_I2C_SCL #define PIN_I2C_SCL PIN_OLED_SCL #endif +// Nothing may start this bus on a pin the board has already given to something +// that dies when it is clocked: the radio's SPI and control lines, and on a +// classic ESP32 with PSRAM the PSRAM's chip select and clock — GPIO 16 and 17 +// on the D0WD parts, read from the core's sdkconfig rather than restated here. +// Wire.begin() takes the pins over from whatever held them, so a clash is not +// a bus with nothing on it; it is the radio or the heap failing underneath a +// console command. Refused here, so a header that clashes does not compile. +#define RM_PIN_IS_RADIO(p) ((p) >= 0 && ((p) == PIN_LORA_SCK || (p) == PIN_LORA_MISO || \ + (p) == PIN_LORA_MOSI || (p) == PIN_LORA_CS || (p) == PIN_LORA_RST || \ + (p) == PIN_LORA_DIO0 || (p) == PIN_LORA_DIO1 || (p) == PIN_LORA_BUSY)) +#if RM_PIN_IS_RADIO(PIN_I2C_SDA) || RM_PIN_IS_RADIO(PIN_I2C_SCL) + #error "PIN_I2C_SDA/PIN_I2C_SCL name one of the radio's pins; a board with no general I2C bus leaves them -1" +#endif +#if defined(CONFIG_IDF_TARGET_ESP32) && defined(BOARD_HAS_PSRAM) && defined(CONFIG_D0WD_PSRAM_CLK_IO) && \ + (PIN_I2C_SDA == CONFIG_D0WD_PSRAM_CS_IO || PIN_I2C_SDA == CONFIG_D0WD_PSRAM_CLK_IO || \ + PIN_I2C_SCL == CONFIG_D0WD_PSRAM_CS_IO || PIN_I2C_SCL == CONFIG_D0WD_PSRAM_CLK_IO) + #error "PIN_I2C_SDA/PIN_I2C_SCL name the PSRAM's chip select or clock (CONFIG_D0WD_PSRAM_CS_IO/CLK_IO)" +#endif +#undef RM_PIN_IS_RADIO // Panels on a switched rail need it brought up before they are probed, and it // is active low on every board that has one so far. #ifndef HAS_DISPLAY_VEXT diff --git a/src/sys/I2cReg.h b/src/sys/I2cReg.h index 4835c91..3e2f62d 100644 --- a/src/sys/I2cReg.h +++ b/src/sys/I2cReg.h @@ -23,6 +23,7 @@ namespace I2cReg { // and none would agree about the pins. namespace detail { inline bool& mainStarted() { static bool v = false; return v; } +inline bool& mainUp() { static bool v = false; return v; } inline bool& h1Started() { static bool v = false; return v; } inline int& h1Sda() { static int v = -1; return v; } inline int& h1Scl() { static int v = -1; return v; } @@ -37,9 +38,21 @@ inline int& h1Scl() { static int v = -1; return v; } // and its keyboard on this one: two drivers, neither of which owns the bus, // both of which need it up, and each initialised at a different point in // setup(). Whoever asks first brings it up; everyone else gets the same bus. +// +// A board that names no general pair has no such bus, and this is the one +// place that holds to it. Wire.begin(-1, -1) does not mean "no pins" to the +// core: TwoWire::initPins() swaps a negative pin for the variant's SDA/SCL, +// which on the Wireless Bridge is 21/22 — its Vext and its LoRa LED. So the +// call is not made at all, and the Wire object left unstarted refuses every +// transaction rather than clocking someone else's pins. inline TwoWire& mainBus() { if (!detail::mainStarted()) { - Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, I2C_HZ); +#if PIN_I2C_SDA >= 0 && PIN_I2C_SCL >= 0 + detail::mainUp() = Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, I2C_HZ); +#else + log_e("I2C: this board names no general pair (PIN_I2C_SDA/PIN_I2C_SCL), so that bus is not started"); + detail::mainUp() = false; +#endif detail::mainStarted() = true; } return Wire; @@ -59,8 +72,9 @@ inline TwoWire& mainBus() { // before it decides its part is missing. inline TwoWire& busFor(int sda, int scl, uint32_t hz, bool* up = nullptr) { if (sda == PIN_I2C_SDA && scl == PIN_I2C_SCL) { - if (up) *up = true; - return mainBus(); + TwoWire& bus = mainBus(); + if (up) *up = detail::mainUp(); + return bus; } if (!detail::h1Started()) { detail::h1Started() = Wire1.begin(sda, scl, hz);