From d0fb42efa3f8e328d3575fff629b269c3a0e3441 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 7 Sep 2026 18:44:48 -0500 Subject: [PATCH] feat: drive RGB status LED from system state (PR 4/9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SK6812 status LED per PCB/GPIO_table.md (pin 37, RMT-driven). Same split as the OLED work in PR 3: StatusLedController (status_led.h/.cpp) is pure logic mapping SystemState (booting/connected/disconnected/charging/error) to an RGB color, fully unit tested; RgbLed (rgb_led.cpp) is a thin Adafruit_NeoPixel adapter that just shows whatever color it's given. rgb_led.cpp joins the native build/coverage exclusion list alongside oled.cpp. SnipsController.ino cycles through every status color once at boot — the bring-up check PCB/README.md's recommended order calls for ("verify RMT output on pin 37, cycle colors") — then settles on "disconnected," which is accurate until the XBee link exists (PR 8). Real state (connected/charging/error) gets wired up by later PRs once there's something to base it on. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 2 +- include/rgb_led.h | 20 +++++++ include/status_led.h | 25 +++++++++ platformio.ini | 6 ++- src/SnipsController.ino | 21 ++++++++ src/rgb_led.cpp | 11 ++++ src/status_led.cpp | 17 ++++++ test/test_status_led/test_status_led.cpp | 67 ++++++++++++++++++++++++ 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 include/rgb_led.h create mode 100644 include/status_led.h create mode 100644 src/rgb_led.cpp create mode 100644 src/status_led.cpp create mode 100644 test/test_status_led/test_status_led.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d4c46..eb39a8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Coverage report run: | - output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --print-summary --fail-under-line 90) + output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --exclude 'src/rgb_led\.cpp' --print-summary --fail-under-line 90) status=$? { echo '### Coverage (src/, excluding SnipsController.ino and hardware adapters)' diff --git a/include/rgb_led.h b/include/rgb_led.h new file mode 100644 index 0000000..73935d0 --- /dev/null +++ b/include/rgb_led.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include "pin_assignment.h" +#include "status_led.h" + +// Thin hardware adapter over Adafruit_NeoPixel — drives the single +// SK6812 status LED (RMT-driven). No state->color decisions happen here; +// that's StatusLedController's job (status_led.h), which stays +// hardware-agnostic and unit tested. This file is excluded from native +// build/coverage (see platformio.ini's [env:native] build_src_filter). +class RgbLed { + public: + void begin(); + void show(const RgbColor &color); + + private: + Adafruit_NeoPixel pixel_{1, PinAssignment::kRgbLedData, NEO_GRB + NEO_KHZ800}; +}; diff --git a/include/status_led.h b/include/status_led.h new file mode 100644 index 0000000..f175945 --- /dev/null +++ b/include/status_led.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +// Pure logic mapping overall system state to an RGB status color. Knows +// nothing about the real LED hardware — src/rgb_led.cpp is the thin +// adapter that actually drives it. +enum class SystemState { + kBooting, + kConnected, + kDisconnected, + kCharging, + kError, +}; + +struct RgbColor { + uint8_t r; + uint8_t g; + uint8_t b; +}; + +class StatusLedController { + public: + RgbColor colorFor(SystemState state) const; +}; diff --git a/platformio.ini b/platformio.ini index b56d638..cd45e67 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,7 @@ monitor_speed = 115200 lib_deps = adafruit/Adafruit SSD1306@^2.5.13 adafruit/Adafruit GFX Library@^1.11.11 + adafruit/Adafruit NeoPixel@^1.15.4 ; Native (host) environment for unit tests — no hardware required. ; SnipsController.ino pulls in Arduino.h, so it's excluded here; everything @@ -36,11 +37,12 @@ lib_deps = ; EXCEPT a small, explicitly-named list of genuinely hardware-touching ; adapter files (thin wrappers over I2C/SPI/ADC/RMT/NVS), added here ; alongside as new subsystems land — see CLAUDE.md's testing-approach note. -; Currently: oled.cpp (Adafruit_SSD1306/Wire). +; Currently: oled.cpp (Adafruit_SSD1306/Wire), rgb_led.cpp +; (Adafruit_NeoPixel/RMT). [env:native] platform = native test_build_src = yes -build_src_filter = +<*> -<*.ino> - +build_src_filter = +<*> -<*.ino> - - test_framework = unity build_flags = --coverage extra_scripts = pre:scripts/native_coverage_linkflags.py diff --git a/src/SnipsController.ino b/src/SnipsController.ino index aa51da9..ed3733b 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -4,7 +4,9 @@ #include "oled.h" #include "pin_assignment.h" #include "power_latch.h" +#include "rgb_led.h" #include "screen.h" +#include "status_led.h" // Pure entry point — wiring only. All real logic lives in dedicated // subsystem files under src/ + include/; this file just owns real @@ -15,6 +17,8 @@ namespace { PowerOffDetector powerOffDetector; ButtonPanel buttonPanel; OledDisplay oledDisplay; +RgbLed rgbLed; +StatusLedController statusLedController; bool lastReportedPressed[Buttons::kCount] = {}; const char *buttonName(size_t index) { @@ -74,6 +78,23 @@ void setup() { } else { Serial.println("OLED not found at boot."); } + + // Bring-up check per PCB/README.md's recommended order: cycle through + // every status color once to prove RMT output on the real LED. Real + // state (connected/charging/error) gets driven by later PRs once there's + // an XBee link and charge-status reading to base it on; for now this + // just settles on "disconnected," which is accurate today. + rgbLed.begin(); + const SystemState bringUpSequence[] = { + SystemState::kBooting, SystemState::kConnected, + SystemState::kDisconnected, SystemState::kCharging, + SystemState::kError, + }; + for (SystemState state : bringUpSequence) { + rgbLed.show(statusLedController.colorFor(state)); + delay(300); + } + rgbLed.show(statusLedController.colorFor(SystemState::kDisconnected)); } void loop() { diff --git a/src/rgb_led.cpp b/src/rgb_led.cpp new file mode 100644 index 0000000..d18b3f9 --- /dev/null +++ b/src/rgb_led.cpp @@ -0,0 +1,11 @@ +#include "rgb_led.h" + +void RgbLed::begin() { + pixel_.begin(); + pixel_.show(); // off until told otherwise +} + +void RgbLed::show(const RgbColor &color) { + pixel_.setPixelColor(0, pixel_.Color(color.r, color.g, color.b)); + pixel_.show(); +} diff --git a/src/status_led.cpp b/src/status_led.cpp new file mode 100644 index 0000000..ca4b820 --- /dev/null +++ b/src/status_led.cpp @@ -0,0 +1,17 @@ +#include "status_led.h" + +RgbColor StatusLedController::colorFor(SystemState state) const { + switch (state) { + case SystemState::kBooting: + return {0, 0, 255}; // blue + case SystemState::kConnected: + return {0, 255, 0}; // green + case SystemState::kDisconnected: + return {255, 255, 0}; // yellow + case SystemState::kCharging: + return {255, 165, 0}; // orange + case SystemState::kError: + return {255, 0, 0}; // red + } + return {0, 0, 0}; +} diff --git a/test/test_status_led/test_status_led.cpp b/test/test_status_led/test_status_led.cpp new file mode 100644 index 0000000..83c86d8 --- /dev/null +++ b/test/test_status_led/test_status_led.cpp @@ -0,0 +1,67 @@ +#include + +#include "status_led.h" + +void setUp(void) {} +void tearDown(void) {} + +void test_booting_is_blue() { + StatusLedController controller; + RgbColor color = controller.colorFor(SystemState::kBooting); + TEST_ASSERT_EQUAL_UINT8(0, color.r); + TEST_ASSERT_EQUAL_UINT8(0, color.g); + TEST_ASSERT_EQUAL_UINT8(255, color.b); +} + +void test_connected_is_green() { + StatusLedController controller; + RgbColor color = controller.colorFor(SystemState::kConnected); + TEST_ASSERT_EQUAL_UINT8(0, color.r); + TEST_ASSERT_EQUAL_UINT8(255, color.g); + TEST_ASSERT_EQUAL_UINT8(0, color.b); +} + +void test_disconnected_is_yellow() { + StatusLedController controller; + RgbColor color = controller.colorFor(SystemState::kDisconnected); + TEST_ASSERT_EQUAL_UINT8(255, color.r); + TEST_ASSERT_EQUAL_UINT8(255, color.g); + TEST_ASSERT_EQUAL_UINT8(0, color.b); +} + +void test_charging_is_orange() { + StatusLedController controller; + RgbColor color = controller.colorFor(SystemState::kCharging); + TEST_ASSERT_EQUAL_UINT8(255, color.r); + TEST_ASSERT_EQUAL_UINT8(165, color.g); + TEST_ASSERT_EQUAL_UINT8(0, color.b); +} + +void test_error_is_red() { + StatusLedController controller; + RgbColor color = controller.colorFor(SystemState::kError); + TEST_ASSERT_EQUAL_UINT8(255, color.r); + TEST_ASSERT_EQUAL_UINT8(0, color.g); + TEST_ASSERT_EQUAL_UINT8(0, color.b); +} + +void test_unknown_state_is_off() { + StatusLedController controller; + // A value outside the enum's defined range — the defensive fallback for + // whatever calls colorFor() with something invalid. + RgbColor color = controller.colorFor(static_cast(99)); + TEST_ASSERT_EQUAL_UINT8(0, color.r); + TEST_ASSERT_EQUAL_UINT8(0, color.g); + TEST_ASSERT_EQUAL_UINT8(0, color.b); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_booting_is_blue); + RUN_TEST(test_connected_is_green); + RUN_TEST(test_disconnected_is_yellow); + RUN_TEST(test_charging_is_orange); + RUN_TEST(test_error_is_red); + RUN_TEST(test_unknown_state_is_off); + return UNITY_END(); +}