Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions PCB/libraries/SnipsControllers.kicad_sym
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
)
)
)
Expand Down
8 changes: 4 additions & 4 deletions PCB/oled.kicad_sch
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@
)
)
)
(number "1"
(number "2"
(effects
(font
(size 1.27 1.27)
Expand All @@ -401,7 +401,7 @@
)
)
)
(number "2"
(number "1"
(effects
(font
(size 1.27 1.27)
Expand All @@ -419,7 +419,7 @@
)
)
)
(number "3"
(number "4"
(effects
(font
(size 1.27 1.27)
Expand All @@ -437,7 +437,7 @@
)
)
)
(number "4"
(number "3"
(effects
(font
(size 1.27 1.27)
Expand Down
8 changes: 5 additions & 3 deletions include/buttons.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions include/oled.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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;
};
8 changes: 8 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 47 additions & 7 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
61 changes: 59 additions & 2 deletions src/oled.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -20,15 +72,20 @@ 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<int16_t>((Oled::kWidth - width) / 2);
const int16_t y = static_cast<int16_t>((Oled::kHeight - height) / 2);
display_.drawBitmap(x, y, bitmap, width, height, SSD1306_WHITE);
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);
}
Loading