From 7b802f7bb960bcfd905eaafd9d747452ee963ed3 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Tue, 22 Sep 2026 19:39:24 -0700 Subject: [PATCH] feat: add boot logo splash, on-device Button Test screen, and firmware version - Show the Snips logo on the OLED for 1.2s at boot, before the existing text confirmation, so it's clear the device is powering up. - Add a "Button Test" menu screen for hardware bring-up: shows the name of the last button pressed (all 12, including nav-stolen ones), with a persistent "Bumper = exit" line. - Show firmware version (git describe, injected at build time) on the Device Info screen. Co-Authored-By: Claude Sonnet 5 --- include/buttons.h | 5 ++ include/firmware_version.h | 12 ++++ include/menu.h | 17 +++++ include/oled.h | 6 ++ include/snips_logo_bitmap.h | 54 +++++++++++++++ platformio.ini | 1 + scripts/firmware_version.py | 26 ++++++++ src/SnipsController.ino | 49 +++++++------- src/buttons.cpp | 18 +++++ src/menu.cpp | 42 +++++++++++- src/oled.cpp | 9 +++ test/test_buttons/test_buttons.cpp | 19 ++++++ test/test_menu/test_menu.cpp | 103 +++++++++++++++++++++++++++++ 13 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 include/firmware_version.h create mode 100644 include/snips_logo_bitmap.h create mode 100644 scripts/firmware_version.py diff --git a/include/buttons.h b/include/buttons.h index 9c29a6d..0a8cfae 100644 --- a/include/buttons.h +++ b/include/buttons.h @@ -42,6 +42,11 @@ constexpr int kPins[kCount] = { PinAssignment::kTriggerUp, PinAssignment::kTriggerDown, }; +// Human-readable name for a button index, e.g. for the on-device Button +// Test screen (see menu.h) and Serial press/release logging. Out-of-range +// indices return "Unknown" rather than being undefined. +const char *name(size_t index); + } // namespace Buttons // Debounces a single button's raw (noisy) pin reading over time. A state diff --git a/include/firmware_version.h b/include/firmware_version.h new file mode 100644 index 0000000..63e9ec8 --- /dev/null +++ b/include/firmware_version.h @@ -0,0 +1,12 @@ +#pragma once + +// Injected by scripts/firmware_version.py as a `git describe` string for +// the esp32s3 build; the native test build never defines it, so it falls +// back to a fixed placeholder there. +#ifndef FIRMWARE_VERSION +#define FIRMWARE_VERSION "dev" +#endif + +namespace Firmware { +constexpr const char *kVersion = FIRMWARE_VERSION; +} // namespace Firmware diff --git a/include/menu.h b/include/menu.h index 98b33ca..93c5985 100644 --- a/include/menu.h +++ b/include/menu.h @@ -27,6 +27,7 @@ enum class MenuScreen { kDisplayConfig, kPowerConfig, kDeviceInfo, + kButtonTest, kFactoryResetConfirm, }; @@ -38,6 +39,7 @@ enum class MainMenuItem { kDisplayConfig, kPowerConfig, kDeviceInfo, + kButtonTest, kFactoryReset, kCount, }; @@ -101,6 +103,19 @@ class MenuController { int *outMaxY); bool consumeFactoryResetConfirmed(); + // Button Test screen (bring-up/support tool — see PCB/README.md's + // Bringup Sequence, step 4): records the most recent raw button press + // for display, by index into Buttons::Index. While this screen is + // active, SnipsController.ino routes every button's press edge here + // instead of through the usual nav calls (onUp()/onDown()/onEnter()), + // including the ones normally "stolen" for nav everywhere else — + // except Bumper, which still exits the screen via onBack() same as any + // other screen; that exit is itself the test for Bumper. No-op unless + // this screen is active. -1 means "nothing pressed yet since the + // screen was opened". + void onButtonTestPress(size_t buttonIndex); + int lastTestedButtonIndex() const { return lastTestedButtonIndex_; } + // Droid management — the store is owned here so rendering/navigation // can see it directly; SnipsController.ino restores it from // DroidPersistence once at boot and re-persists it whenever @@ -186,6 +201,8 @@ class MenuController { bool factoryResetConfirmed_ = false; + int lastTestedButtonIndex_ = -1; + DroidStore droidStore_; int droidListIndex_ = 0; bool droidStoreChanged_ = false; diff --git a/include/oled.h b/include/oled.h index 321e72e..189288a 100644 --- a/include/oled.h +++ b/include/oled.h @@ -30,6 +30,12 @@ class OledDisplay { void render(const ScreenBuffer &content); + // Draws a 1-bit bitmap (row-major, MSB-first-per-byte, e.g. + // snips_logo_bitmap.h) centered on the display and pushes it immediately. + // Used for the boot splash; not part of the ScreenBuffer content model + // since nothing else on the device draws bitmaps. + void renderBitmapCentered(const uint8_t *bitmap, int width, int height); + // Low-power mode support (see power_management.h). Dimming/undimming // and powering the panel back on take effect immediately; they don't // need a render() call to show existing content again since the diff --git a/include/snips_logo_bitmap.h b/include/snips_logo_bitmap.h new file mode 100644 index 0000000..a8ad01b --- /dev/null +++ b/include/snips_logo_bitmap.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +// 1-bit Snips logo bitmap for the boot splash, generated from +// graphics/snips-logo.png (itself a 1:1 raster of graphics/snips-logo.svg) +// by thresholding its alpha channel at 48x64 — the largest size that fits +// the 128x64 OLED (Oled::kWidth/kHeight) while preserving the source +// canvas's aspect ratio. Regenerate by re-running that conversion if the +// source art changes; this file has no logic of its own, just packed +// pixel data in the row-major, MSB-first-per-byte format +// Adafruit_GFX::drawBitmap() expects. +namespace SnipsLogo { + +constexpr int kWidth = 48; +constexpr int kHeight = 64; +constexpr int kByteWidth = (kWidth + 7) / 8; + +constexpr uint8_t kBitmap[kByteWidth * kHeight] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x40, 0x08, 0x00, + 0x00, 0x00, 0x00, 0xc0, 0x08, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x10, 0x00, + 0x00, 0x00, 0x00, 0xc0, 0x30, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x20, 0x00, + 0x00, 0x00, 0x00, 0xc0, 0x60, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xc0, 0x00, + 0x00, 0x00, 0x00, 0xc0, 0xc0, 0x00, 0x00, 0x00, 0x00, 0xc1, 0x80, 0x00, + 0x00, 0x00, 0x00, 0xc3, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xc6, 0x00, 0x00, 0x00, 0x00, 0x01, 0xce, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x03, 0x5c, 0x00, 0x00, + 0x00, 0x00, 0x02, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x05, 0xb8, 0x00, 0x00, + 0x00, 0x00, 0x0b, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x12, 0x1c, 0x00, 0x00, + 0x00, 0x00, 0x24, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x68, 0x1c, 0x00, 0x00, + 0x00, 0x00, 0x28, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x34, 0x1c, 0x00, 0x00, + 0x00, 0x00, 0x1e, 0x7c, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xfc, 0x00, 0x00, + 0x00, 0x00, 0x0f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xc0, 0x00, 0x00, + 0x00, 0x00, 0x39, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x31, 0xc0, 0x00, 0x00, + 0x00, 0x00, 0x71, 0xc0, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xc0, 0x00, 0x00, + 0x00, 0x00, 0xc0, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x80, 0xc0, 0x00, 0x00, + 0x00, 0x1f, 0x00, 0xc0, 0x00, 0x00, 0x01, 0xff, 0x00, 0xc0, 0x00, 0x00, + 0x07, 0xff, 0x00, 0xc0, 0x00, 0x00, 0x07, 0xff, 0x01, 0xe0, 0x00, 0x00, + 0x07, 0xff, 0x01, 0x30, 0x00, 0x00, 0x07, 0xff, 0x02, 0x10, 0x00, 0x00, + 0x07, 0xff, 0x86, 0x08, 0x00, 0x00, 0x07, 0xff, 0x84, 0x04, 0x00, 0x00, + 0x03, 0xff, 0x88, 0x02, 0x00, 0x00, 0x03, 0xff, 0x90, 0x01, 0x00, 0x00, + 0x03, 0xff, 0x30, 0x01, 0x00, 0x00, 0x03, 0xf0, 0x10, 0x02, 0x00, 0x00, + 0x03, 0x00, 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x00, 0x00, + 0x00, 0x00, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +} // namespace SnipsLogo diff --git a/platformio.ini b/platformio.ini index 6fb68c1..a3ea215 100644 --- a/platformio.ini +++ b/platformio.ini @@ -30,6 +30,7 @@ lib_deps = adafruit/Adafruit SSD1306@^2.5.13 adafruit/Adafruit GFX Library@^1.11.11 adafruit/Adafruit NeoPixel@^1.15.4 +extra_scripts = pre:scripts/firmware_version.py ; Native (host) environment for unit tests — no hardware required. ; SnipsController.ino pulls in Arduino.h, so it's excluded here; everything diff --git a/scripts/firmware_version.py b/scripts/firmware_version.py new file mode 100644 index 0000000..9733d2c --- /dev/null +++ b/scripts/firmware_version.py @@ -0,0 +1,26 @@ +import subprocess + +Import("env") + +# Firmware version shown on the on-device Device Info screen (see +# menu.cpp's kDeviceInfo render case) — the repo has no tag-based release +# process yet, so this is just the current commit, which is still exactly +# what you want on a bring-up device: proof of what's actually flashed. +# Once real version tags exist, `git describe` picks them up automatically +# with no code changes needed here. +def firmware_version(): + try: + return ( + subprocess.check_output( + ["git", "describe", "--tags", "--always", "--dirty"], + cwd=env["PROJECT_DIR"], + stderr=subprocess.DEVNULL, + ) + .decode() + .strip() + ) + except Exception: + return "unknown" + + +env.Append(CPPDEFINES=[("FIRMWARE_VERSION", '\\"%s\\"' % firmware_version())]) diff --git a/src/SnipsController.ino b/src/SnipsController.ino index 133f120..33941cb 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -19,6 +19,7 @@ #include "power_management.h" #include "rgb_led.h" #include "screen.h" +#include "snips_logo_bitmap.h" #include "status_led.h" #include "xbee_control.h" @@ -140,24 +141,6 @@ void performGracefulShutdown() { } } -const char *buttonName(size_t index) { - switch (index) { - case Buttons::kMacro1: return "Macro1"; - case Buttons::kMacro2: return "Macro2"; - case Buttons::kMacro3: return "Macro3"; - case Buttons::kMacro4: return "Macro4"; - case Buttons::kMacro5: return "Macro5"; - case Buttons::kMacro6: return "Macro6"; - case Buttons::kBumper: return "Bumper"; - case Buttons::kStickClick: return "StickClick"; - case Buttons::kLeftUp: return "LeftUp"; - case Buttons::kLeftDown: return "LeftDown"; - case Buttons::kRightUp: return "RightUp"; - case Buttons::kRightDown: return "RightDown"; - default: return "Unknown"; - } -} - const char *chargeStateName(ChargeState state) { switch (state) { case ChargeState::kDone: return "done"; @@ -308,9 +291,15 @@ void setup() { Serial.println("XBee SL query failed at boot."); } - // Brief boot confirmation — loop() takes over with the real complications + // Power-on splash, so it's obvious the device is booting rather than + // just dark/unresponsive, followed by the existing brief boot + // confirmation text — loop() takes over with the real complications // screen once real sensor data starts flowing. if (oledDisplay.begin()) { + oledDisplay.renderBitmapCentered(SnipsLogo::kBitmap, SnipsLogo::kWidth, + SnipsLogo::kHeight); + constexpr unsigned long kBootSplashMs = 1200; + delay(kBootSplashMs); showBootScreen(); } else { Serial.println("OLED not found at boot."); @@ -360,7 +349,7 @@ void loop() { justPressed[i] = pressed && !lastReportedPressed[i]; if (pressed != lastReportedPressed[i]) { lastReportedPressed[i] = pressed; - Serial.print(buttonName(i)); + Serial.print(Buttons::name(i)); Serial.println(pressed ? " pressed" : " released"); } } @@ -375,10 +364,22 @@ void loop() { buttonPanel.isPressed(Buttons::kLeftDown), now); menuController.tick(rawStickX, rawStickY); - if (justPressed[Buttons::kLeftUp]) menuController.onUp(); - if (justPressed[Buttons::kLeftDown]) menuController.onDown(); - if (justPressed[Buttons::kStickClick]) { - menuController.onEnter(rawTrigger, rawStickX, rawStickY); + // Button Test (see menu.h's onButtonTestPress()) wants to see every raw + // button press, including the four normally "stolen" for menu nav + // below — so while it's active, route all of them there instead of + // through the usual nav calls. Bumper is the one exception: it still + // exits via onBack() same as every other screen, which doubles as the + // test for Bumper itself. + if (menuController.currentScreen() == MenuScreen::kButtonTest) { + for (size_t i = 0; i < Buttons::kCount; ++i) { + if (justPressed[i]) menuController.onButtonTestPress(i); + } + } else { + if (justPressed[Buttons::kLeftUp]) menuController.onUp(); + if (justPressed[Buttons::kLeftDown]) menuController.onDown(); + if (justPressed[Buttons::kStickClick]) { + menuController.onEnter(rawTrigger, rawStickX, rawStickY); + } } if (justPressed[Buttons::kBumper]) menuController.onBack(); diff --git a/src/buttons.cpp b/src/buttons.cpp index e06fe24..3b64513 100644 --- a/src/buttons.cpp +++ b/src/buttons.cpp @@ -1,5 +1,23 @@ #include "buttons.h" +const char *Buttons::name(size_t index) { + switch (index) { + case Buttons::kMacro1: return "Macro1"; + case Buttons::kMacro2: return "Macro2"; + case Buttons::kMacro3: return "Macro3"; + case Buttons::kMacro4: return "Macro4"; + case Buttons::kMacro5: return "Macro5"; + case Buttons::kMacro6: return "Macro6"; + case Buttons::kBumper: return "Bumper"; + case Buttons::kStickClick: return "StickClick"; + case Buttons::kLeftUp: return "LeftUp"; + case Buttons::kLeftDown: return "LeftDown"; + case Buttons::kRightUp: return "RightUp"; + case Buttons::kRightDown: return "RightDown"; + default: return "Unknown"; + } +} + ButtonDebouncer::ButtonDebouncer(unsigned long debounceMs) : debounceMs_(debounceMs) {} diff --git a/src/menu.cpp b/src/menu.cpp index 65f6be7..92709dd 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -3,6 +3,9 @@ #include #include +#include "buttons.h" +#include "firmware_version.h" + namespace { // A 64-bit all-zero PAN ID — Digi's convention for "unconfigured," used // by Factory Reset to make sure the radio doesn't quietly stay @@ -20,6 +23,7 @@ const char *mainMenuItemLabel(MainMenuItem item) { case MainMenuItem::kDisplayConfig: return "Display Config"; case MainMenuItem::kPowerConfig: return "Power Management"; case MainMenuItem::kDeviceInfo: return "Device Info"; + case MainMenuItem::kButtonTest: return "Button Test"; case MainMenuItem::kFactoryReset: return "Factory Reset"; default: return "Unknown"; } @@ -169,6 +173,7 @@ void MenuController::onBack() { case MenuScreen::kDisplayConfig: case MenuScreen::kPowerConfig: case MenuScreen::kDeviceInfo: + case MenuScreen::kButtonTest: case MenuScreen::kFactoryResetConfirm: screen_ = MenuScreen::kMainMenu; break; @@ -206,6 +211,10 @@ void MenuController::enterMainMenuItem(MainMenuItem item) { case MainMenuItem::kDeviceInfo: screen_ = MenuScreen::kDeviceInfo; break; + case MainMenuItem::kButtonTest: + lastTestedButtonIndex_ = -1; + screen_ = MenuScreen::kButtonTest; + break; case MainMenuItem::kFactoryReset: screen_ = MenuScreen::kFactoryResetConfirm; break; @@ -349,6 +358,13 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { screen_ = MenuScreen::kMainMenu; break; + // Not reached in normal operation — SnipsController.ino routes button + // presses to onButtonTestPress() instead of onEnter() while this + // screen is active (see its declaration in menu.h). Kept as an + // explicit no-op case for switch exhaustiveness. + case MenuScreen::kButtonTest: + break; + case MenuScreen::kFactoryResetConfirm: factoryResetConfirmed_ = true; droidStore_ = DroidStore(); @@ -374,6 +390,12 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { } } +void MenuController::onButtonTestPress(size_t buttonIndex) { + if (screen_ != MenuScreen::kButtonTest) return; + if (buttonIndex >= Buttons::kCount) return; + lastTestedButtonIndex_ = static_cast(buttonIndex); +} + void MenuController::tick(int rawStickX, int rawStickY) { if (screen_ == MenuScreen::kCalibrateStick && stickFlow_.currentStep() == StickCalibrationFlow::Step::kRolling) { @@ -678,10 +700,28 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { break; } - case MenuScreen::kDeviceInfo: + case MenuScreen::kDeviceInfo: { screen->setLine(0, "Device Info"); screen->setLine(1, "XBee SL:"); screen->setLine(2, menu.deviceSerialLow()); + char line[ScreenBuffer::kMaxLineLength + 1]; + std::snprintf(line, sizeof(line), "FW: %s", Firmware::kVersion); + screen->setLine(3, line); + break; + } + + case MenuScreen::kButtonTest: + screen->setLine(0, "Button Test"); + if (menu.lastTestedButtonIndex() < 0) { + screen->setLine(1, "Press any button"); + } else { + char line[ScreenBuffer::kMaxLineLength + 1]; + std::snprintf( + line, sizeof(line), "Pressed: %s", + Buttons::name(static_cast(menu.lastTestedButtonIndex()))); + screen->setLine(1, line); + } + screen->setLine(3, "Bumper = exit"); break; case MenuScreen::kFactoryResetConfirm: diff --git a/src/oled.cpp b/src/oled.cpp index cce86fb..9b9bdce 100644 --- a/src/oled.cpp +++ b/src/oled.cpp @@ -18,6 +18,15 @@ void OledDisplay::render(const ScreenBuffer &content) { display_.display(); } +void OledDisplay::renderBitmapCentered(const uint8_t *bitmap, int width, + int height) { + display_.clearDisplay(); + const int16_t x = static_cast((Oled::kWidth - width) / 2); + const int16_t y = static_cast((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::setPowerOn(bool on) { diff --git a/test/test_buttons/test_buttons.cpp b/test/test_buttons/test_buttons.cpp index b6ec4db..358c031 100644 --- a/test/test_buttons/test_buttons.cpp +++ b/test/test_buttons/test_buttons.cpp @@ -1,4 +1,5 @@ #include +#include #include "buttons.h" @@ -67,6 +68,22 @@ void test_button_pins_within_gpio_range() { } } +// ---- Buttons::name --------------------------------------------------------- + +void test_name_covers_every_button_uniquely() { + for (size_t i = 0; i < Buttons::kCount; ++i) { + TEST_ASSERT_NOT_EQUAL(0, strcmp(Buttons::name(i), "Unknown")); + for (size_t j = i + 1; j < Buttons::kCount; ++j) { + TEST_ASSERT_NOT_EQUAL(0, strcmp(Buttons::name(i), Buttons::name(j))); + } + } +} + +void test_name_out_of_range_is_unknown() { + TEST_ASSERT_EQUAL_STRING("Unknown", Buttons::name(Buttons::kCount)); + TEST_ASSERT_EQUAL_STRING("Unknown", Buttons::name(Buttons::kCount + 100)); +} + // ---- ButtonPanel --------------------------------------------------------- void test_panel_defaults_to_not_pressed() { @@ -101,6 +118,8 @@ int main(int argc, char **argv) { RUN_TEST(test_debouncer_registers_press_held_past_threshold); RUN_TEST(test_debouncer_release_is_also_debounced); RUN_TEST(test_debouncer_ignores_bouncing_signal); + RUN_TEST(test_name_covers_every_button_uniquely); + RUN_TEST(test_name_out_of_range_is_unknown); RUN_TEST(test_button_pins_are_unique); RUN_TEST(test_button_pins_within_gpio_range); RUN_TEST(test_panel_defaults_to_not_pressed); diff --git a/test/test_menu/test_menu.cpp b/test/test_menu/test_menu.cpp index 4f25f55..93a1fa9 100644 --- a/test/test_menu/test_menu.cpp +++ b/test/test_menu/test_menu.cpp @@ -2,6 +2,7 @@ #include #include +#include "buttons.h" #include "menu.h" void setUp(void) {} @@ -116,6 +117,8 @@ void test_down_cycles_through_every_main_menu_item_in_order() { menu.onDown(); TEST_ASSERT_TRUE(MainMenuItem::kDeviceInfo == menu.selectedMainMenuItem()); menu.onDown(); + TEST_ASSERT_TRUE(MainMenuItem::kButtonTest == menu.selectedMainMenuItem()); + menu.onDown(); TEST_ASSERT_TRUE(MainMenuItem::kFactoryReset == menu.selectedMainMenuItem()); menu.onDown(); // wraps back to the first item @@ -207,6 +210,92 @@ void test_factory_reset_disconnects_the_radio() { TEST_ASSERT_FALSE(transport.rejoinCalled); } +// ---- button test ------------------------------------------------------------ + +void test_enter_button_test_from_main_menu() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kButtonTest == menu.currentScreen()); + // Nothing pressed yet since the screen was opened. + TEST_ASSERT_EQUAL_INT(-1, menu.lastTestedButtonIndex()); +} + +void test_button_test_press_is_recorded() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + + menu.onButtonTestPress(Buttons::kMacro3); + TEST_ASSERT_EQUAL_INT(static_cast(Buttons::kMacro3), + menu.lastTestedButtonIndex()); + + // A later press overwrites the previous one rather than accumulating. + menu.onButtonTestPress(Buttons::kRightUp); + TEST_ASSERT_EQUAL_INT(static_cast(Buttons::kRightUp), + menu.lastTestedButtonIndex()); +} + +void test_button_test_press_is_noop_outside_the_screen() { + MenuController menu; // still kInactive + menu.onButtonTestPress(Buttons::kMacro1); + TEST_ASSERT_EQUAL_INT(-1, menu.lastTestedButtonIndex()); +} + +void test_button_test_press_ignores_out_of_range_index() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + menu.onButtonTestPress(Buttons::kCount + 5); + TEST_ASSERT_EQUAL_INT(-1, menu.lastTestedButtonIndex()); +} + +void test_button_test_back_returns_to_main_menu() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + menu.onButtonTestPress(Buttons::kMacro1); + + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); +} + +void test_reentering_button_test_clears_previous_press() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + menu.onButtonTestPress(Buttons::kMacro1); + menu.onBack(); // -> kMainMenu + menu.onBack(); // -> kInactive, so selectMainMenuItem below can reopen + + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + TEST_ASSERT_EQUAL_INT(-1, menu.lastTestedButtonIndex()); +} + +void test_render_button_test_before_any_press() { + MenuController menu; + ScreenBuffer screen; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Button Test", screen.line(0)); + TEST_ASSERT_EQUAL_STRING("Press any button", screen.line(1)); + TEST_ASSERT_EQUAL_STRING("Bumper = exit", screen.line(3)); +} + +void test_render_button_test_shows_pressed_button_name() { + MenuController menu; + ScreenBuffer screen; + selectMainMenuItem(&menu, MainMenuItem::kButtonTest); + menu.onEnter(0, 0, 0); + menu.onButtonTestPress(Buttons::kRightDown); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Pressed: RightDown", screen.line(1)); + // The exit instruction stays visible regardless of what was pressed. + TEST_ASSERT_EQUAL_STRING("Bumper = exit", screen.line(3)); +} + // ---- trigger calibration --------------------------------------------------- void test_trigger_calibration_full_flow() { @@ -720,6 +809,8 @@ void test_main_menu_item_labels() { mainMenuItemLabel(MainMenuItem::kPowerConfig)); TEST_ASSERT_EQUAL_STRING("Device Info", mainMenuItemLabel(MainMenuItem::kDeviceInfo)); + TEST_ASSERT_EQUAL_STRING("Button Test", + mainMenuItemLabel(MainMenuItem::kButtonTest)); TEST_ASSERT_EQUAL_STRING("Factory Reset", mainMenuItemLabel(MainMenuItem::kFactoryReset)); TEST_ASSERT_EQUAL_STRING("Unknown", @@ -815,6 +906,10 @@ void test_render_device_info() { TEST_ASSERT_EQUAL_STRING("Device Info", screen.line(0)); // Default before SnipsController.ino ever calls setDeviceSerialLow(). TEST_ASSERT_EQUAL_STRING("(unknown)", screen.line(2)); + // The native test build has no FIRMWARE_VERSION define (that's injected + // by scripts/firmware_version.py for the real esp32s3 build only), so + // this is firmware_version.h's fallback. + TEST_ASSERT_EQUAL_STRING("FW: dev", screen.line(3)); } void test_device_serial_low_defaults_then_reflects_what_was_set() { @@ -1015,6 +1110,14 @@ int main(int argc, char **argv) { RUN_TEST(test_enter_factory_reset_confirm_then_back_cancels); RUN_TEST(test_factory_reset_confirmed_via_enter); RUN_TEST(test_factory_reset_disconnects_the_radio); + RUN_TEST(test_enter_button_test_from_main_menu); + RUN_TEST(test_button_test_press_is_recorded); + RUN_TEST(test_button_test_press_is_noop_outside_the_screen); + RUN_TEST(test_button_test_press_ignores_out_of_range_index); + RUN_TEST(test_button_test_back_returns_to_main_menu); + RUN_TEST(test_reentering_button_test_clears_previous_press); + RUN_TEST(test_render_button_test_before_any_press); + RUN_TEST(test_render_button_test_shows_pressed_button_name); RUN_TEST(test_trigger_calibration_full_flow); RUN_TEST(test_back_during_trigger_calibration_cancels_and_resets); RUN_TEST(test_stick_calibration_full_flow);