diff --git a/PCB/libraries/SnipsControllers.kicad_sym b/PCB/libraries/SnipsControllers.kicad_sym index 8728a1b..3a0bd62 100644 --- a/PCB/libraries/SnipsControllers.kicad_sym +++ b/PCB/libraries/SnipsControllers.kicad_sym @@ -33,19 +33,19 @@ (symbol "SSD1306_OLED_4pin_1_1" (pin power_in line (at -16.51 3.81 0) (length 3.81) (name "VCC" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) ) (pin power_in line (at -16.51 1.27 0) (length 3.81) (name "GND" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) ) (pin input line (at -16.51 -1.27 0) (length 3.81) (name "SCL" (effects (font (size 1.27 1.27)))) - (number "3" (effects (font (size 1.27 1.27)))) + (number "4" (effects (font (size 1.27 1.27)))) ) (pin bidirectional line (at -16.51 -3.81 0) (length 3.81) (name "SDA" (effects (font (size 1.27 1.27)))) - (number "4" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) ) ) ) diff --git a/PCB/oled.kicad_sch b/PCB/oled.kicad_sch index 134fbe2..15b1518 100644 --- a/PCB/oled.kicad_sch +++ b/PCB/oled.kicad_sch @@ -383,7 +383,7 @@ ) ) ) - (number "1" + (number "2" (effects (font (size 1.27 1.27) @@ -401,7 +401,7 @@ ) ) ) - (number "2" + (number "1" (effects (font (size 1.27 1.27) @@ -419,7 +419,7 @@ ) ) ) - (number "3" + (number "4" (effects (font (size 1.27 1.27) @@ -437,7 +437,7 @@ ) ) ) - (number "4" + (number "3" (effects (font (size 1.27 1.27) diff --git a/include/buttons.h b/include/buttons.h index 0a8cfae..04bacf9 100644 --- a/include/buttons.h +++ b/include/buttons.h @@ -23,8 +23,10 @@ enum Index : size_t { // The two generic, symmetric up/down button pairs — see // PCB/GPIO_table.md. What each pair does is assigned by Amidala, not // fixed in firmware. "Left"/"Right" here is just a fixed, arbitrary - // application-level label: Left = the "Vol" pair, Right = the "Trigger" - // pair, matching PinAssignment's hardware-truth names. + // application-level label: Left = the "Trigger" pair, Right = the "Vol" + // pair, matching PinAssignment's hardware-truth names. (Originally + // assigned the other way around; confirmed backwards against the + // physical controller during bring-up and flipped here.) kLeftUp, kLeftDown, kRightUp, @@ -38,8 +40,8 @@ constexpr int kPins[kCount] = { PinAssignment::kMacro5, PinAssignment::kMacro6, PinAssignment::kDigitalTrigger, PinAssignment::kThumbstickClick, - PinAssignment::kVolUp, PinAssignment::kVolDown, PinAssignment::kTriggerUp, PinAssignment::kTriggerDown, + PinAssignment::kVolUp, PinAssignment::kVolDown, }; // Human-readable name for a button index, e.g. for the on-device Button diff --git a/include/oled.h b/include/oled.h index f3fdae9..32aab21 100644 --- a/include/oled.h +++ b/include/oled.h @@ -18,6 +18,7 @@ constexpr int kHeight = 64; // own silkscreen (also documented in PCB/GPIO_table.md's Accelerometer // section, which shares the bus at a different address). constexpr uint8_t kI2cAddress = 0x3C; +constexpr uint8_t kI2cAddressAlt = 0x3D; } // namespace Oled @@ -44,4 +45,13 @@ class OledDisplay { private: Adafruit_SSD1306 display_{Oled::kWidth, Oled::kHeight, &Wire, -1}; + // Adafruit_SSD1306 only allocates its internal frame buffer inside its + // own begin() — every other method (clearDisplay(), display(), etc.) + // writes into that buffer unconditionally, with no null check, and + // crashes (StoreProhibited) if begin() was never successfully called. + // Since begin() can legitimately fail (no display present) and callers + // throughout SnipsController.ino call render()/etc. unconditionally + // every tick regardless, this class has to remember its own readiness + // and no-op everything until begin() actually succeeds. + bool ready_ = false; }; diff --git a/platformio.ini b/platformio.ini index a3ea215..b348600 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,6 +26,14 @@ platform = espressif32@7.0.1 board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 115200 +; This board has no external USB-UART bridge chip (bare WROOM-1 module, +; see PCB/GPIO_table.md) — Serial has to go over the ESP32-S3's native USB +; peripheral, the same port used for flashing. The board def enables that +; peripheral (ARDUINO_USB_MODE=1) but doesn't turn Serial itself onto it by +; default, so without this flag Serial silently goes to UART0's physical +; pins instead (unconnected on this board) and the monitor shows nothing, +; even though the firmware is running fine. +build_flags = -D ARDUINO_USB_CDC_ON_BOOT=1 lib_deps = adafruit/Adafruit SSD1306@^2.5.13 adafruit/Adafruit GFX Library@^1.11.11 diff --git a/src/SnipsController.ino b/src/SnipsController.ino index 33941cb..9014462 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -56,6 +56,7 @@ unsigned long lastDownlinkMs = 0; // 0 = never received one constexpr unsigned long kConnectionTimeoutMs = 5000; MenuScreen previousMenuScreen = MenuScreen::kInactive; MainMenuItem previousMainMenuItem = MainMenuItem::kSwitchDroid; +int previousLastTestedButtonIndex = -1; void showBootScreen() { ScreenBuffer bootScreen; @@ -170,6 +171,24 @@ void setup() { Serial.begin(115200); + // Bring-up aid: this board has no external USB-UART bridge chip (bare + // WROOM-1 module, see PCB/GPIO_table.md) — Serial goes over the + // ESP32-S3's native USB-CDC peripheral, which has to fully + // re-enumerate with the host after every reset. That consistently + // takes longer than this firmware needs to reach its early boot + // prints, so they were getting lost even with a monitor already open + // and waiting. Give the host a couple seconds to finish opening the + // port before continuing — harmless in the field with no host + // attached (just a bounded worst-case boot delay), and makes early + // diagnostics reliably visible during bring-up. HWCDC's bool operator + // reflects the real host-connection state, not just whether begin() + // was called. + constexpr unsigned long kSerialWaitMs = 2000; + const unsigned long serialWaitStartMs = millis(); + while (!Serial && millis() - serialWaitStartMs < kSerialWaitMs) { + delay(10); + } + // If the battery is already at/below the critical threshold the // instant the device is turned on (e.g. it sat unused long enough to // self-discharge), don't run a full boot — show a brief message and @@ -195,11 +214,13 @@ void setup() { } } - // Polarity of the power-button sense pin isn't documented anywhere in the - // PCB docs (it's part of the soft-latch circuit, not a simple - // switch-to-GND button like the others) — assumed active-high (HIGH while - // held), no internal pull needed. Confirm against real hardware during - // this PR's bring-up milestone and flip here if wrong. + // Confirmed against real hardware during bring-up: this line idles HIGH + // (100kOhm R_PWR_SENSE pull-up to 3V3, per PCB/power_control.kicad_sch) + // and the switch pulls it to GND when held — active-low, same sense as + // every other button, just via an external pull-up instead of the + // internal one. The original active-high assumption had this backwards, + // which read the button as continuously held from boot and triggered an + // unwanted graceful-shutdown 3 seconds into every session. pinMode(PinAssignment::kPowerButtonSense, INPUT); // All buttons wire to GND with the internal pull-up enabled, so LOW = @@ -244,6 +265,18 @@ void setup() { xbeeControl.begin(); + // Bring-up diagnostic: with R_XBEE_ATTN_PU1 (10kOhm to 3V3) in place, + // ATTN should read HIGH here (idle, no frame queued) whenever the + // module is actually connected and powered — regardless of whether any + // AT command below succeeds. A LOW or erratic reading instead points at + // a physical connection problem (e.g. the module's THT socket, used + // for prototyping per PCB/GPIO_table.md) rather than at command + // sequencing/timing. + Serial.print("XBee ATTN pin at boot: "); + Serial.println(digitalRead(PinAssignment::kXbeeSpiAttn) == HIGH + ? "HIGH (expected idle)" + : "LOW (unexpected -- check module seating/wiring)"); + // A controller must join a droid's network, never form its own — CE=1 // (coordinator) would strand it on a PAN of its own. switch (ensureRouterRole(&xbeeControl)) { @@ -326,7 +359,7 @@ void loop() { const unsigned long now = millis(); const bool powerButtonHeld = - digitalRead(PinAssignment::kPowerButtonSense) == HIGH; + digitalRead(PinAssignment::kPowerButtonSense) == LOW; if (powerOffDetector.update(powerButtonHeld, now)) { performGracefulShutdown(); // never returns @@ -421,11 +454,18 @@ void loop() { // Only touch the display when something actually changed — a full // redraw every tick would be needless I2C traffic for static text. + // lastTestedButtonIndex() is included because it's the Button Test + // screen's entire displayed content, but changes independently of both + // currentScreen() and selectedMainMenuItem() (pressing a button while + // already on that screen changes neither) — without this, the screen + // would render once on entry and then never update again. const bool menuStateChanged = menuController.currentScreen() != previousMenuScreen || - menuController.selectedMainMenuItem() != previousMainMenuItem; + menuController.selectedMainMenuItem() != previousMainMenuItem || + menuController.lastTestedButtonIndex() != previousLastTestedButtonIndex; previousMenuScreen = menuController.currentScreen(); previousMainMenuItem = menuController.selectedMainMenuItem(); + previousLastTestedButtonIndex = menuController.lastTestedButtonIndex(); if (menuStateChanged) { if (menuController.currentScreen() != MenuScreen::kInactive) { diff --git a/src/oled.cpp b/src/oled.cpp index 9b9bdce..a35467f 100644 --- a/src/oled.cpp +++ b/src/oled.cpp @@ -2,12 +2,64 @@ #include "pin_assignment.h" +namespace { + +// An address-only write with no data is the standard I2C presence probe +// — endTransmission() returns 0 only if a device at that address +// actually ACKed it. Adafruit_SSD1306::begin() never does this check +// itself (see below), so this is the only real signal we have. +bool i2cAck(uint8_t addr) { + Wire.beginTransmission(addr); + return Wire.endTransmission() == 0; +} + +// Bring-up diagnostic: neither expected address ACKed, so scan the whole +// bus and report whatever's actually there (or confirm nothing is) — +// much faster to narrow down over Serial than guessing addresses one at +// a time by re-flashing. +void logI2cScan() { + Serial.println("OLED not found at 0x3C or 0x3D -- scanning I2C bus:"); + bool foundAny = false; + for (uint8_t addr = 0x03; addr <= 0x77; ++addr) { + if (i2cAck(addr)) { + foundAny = true; + Serial.print(" Found device at 0x"); + Serial.println(addr, HEX); + } + } + if (!foundAny) { + Serial.println( + " Nothing responded at any address -- bus is dead (check " + "SDA/SCL continuity and pull-ups, not just VCC/GND)."); + } +} + +} // namespace + bool OledDisplay::begin() { Wire.begin(PinAssignment::kI2cSda, PinAssignment::kI2cScl); - return display_.begin(SSD1306_SWITCHCAPVCC, Oled::kI2cAddress); + + // Adafruit_SSD1306::begin() never actually checks whether any of its + // I2C writes were ACKed — it always runs the full init sequence and + // returns true unless memory allocation fails (which never happens in + // practice). So it can't tell us whether the display is really there; + // do a real presence check ourselves first, trying both common + // addresses before giving up. + uint8_t addr = Oled::kI2cAddress; + if (!i2cAck(addr)) { + addr = Oled::kI2cAddressAlt; + if (!i2cAck(addr)) { + logI2cScan(); + return false; + } + } + + ready_ = display_.begin(SSD1306_SWITCHCAPVCC, addr); + return ready_; } void OledDisplay::render(const ScreenBuffer &content) { + if (!ready_) return; display_.clearDisplay(); display_.setTextSize(1); display_.setTextColor(SSD1306_WHITE); @@ -20,6 +72,7 @@ void OledDisplay::render(const ScreenBuffer &content) { void OledDisplay::renderBitmapCentered(const uint8_t *bitmap, int width, int height) { + if (!ready_) return; display_.clearDisplay(); const int16_t x = static_cast((Oled::kWidth - width) / 2); const int16_t y = static_cast((Oled::kHeight - height) / 2); @@ -27,8 +80,12 @@ void OledDisplay::renderBitmapCentered(const uint8_t *bitmap, int width, display_.display(); } -void OledDisplay::setDimmed(bool dimmed) { display_.dim(dimmed); } +void OledDisplay::setDimmed(bool dimmed) { + if (!ready_) return; + display_.dim(dimmed); +} void OledDisplay::setPowerOn(bool on) { + if (!ready_) return; display_.ssd1306_command(on ? SSD1306_DISPLAYON : SSD1306_DISPLAYOFF); }