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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ jobs:

- name: Coverage report
run: |
output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --print-summary --fail-under-line 90)
output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --print-summary --fail-under-line 90)
status=$?
{
echo '### Coverage (src/, excluding SnipsController.ino)'
echo '### Coverage (src/, excluding SnipsController.ino and hardware adapters)'
echo '```'
echo "$output"
echo '```'
Expand Down
35 changes: 35 additions & 0 deletions include/oled.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#pragma once

#include <Adafruit_SSD1306.h>

#include "screen.h"

// Thin hardware adapter over Adafruit_SSD1306/GFX — owns the real display
// object and draws whatever a ScreenBuffer currently holds. No content
// decisions happen here; that's ScreenBuffer's job (screen.h), which stays
// hardware-agnostic and unit tested. This file touches real I2C hardware
// and is excluded from native build/coverage (see platformio.ini's
// [env:native] build_src_filter).
namespace Oled {

constexpr int kWidth = 128;
constexpr int kHeight = 64;
// Common default for SSD1306 breakout modules — PCB/README.md doesn't
// pin down the exact module's I2C address, so this is an assumption.
// Confirm against the real hardware during this PR's bring-up milestone
// (0x3D is the other common alternative if 0x3C comes back empty).
constexpr uint8_t kI2cAddress = 0x3C;

} // namespace Oled

class OledDisplay {
public:
// Initializes I2C and the display. Returns false if the display wasn't
// found/didn't initialize (mirrors Adafruit_SSD1306::begin()).
bool begin();

void render(const ScreenBuffer &content);

private:
Adafruit_SSD1306 display_{Oled::kWidth, Oled::kHeight, &Wire, -1};
};
28 changes: 28 additions & 0 deletions include/screen.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#pragma once

#include <cstddef>
#include <cstring>

// Pure content model for the OLED — decides *what* text should be on
// screen. Hardware-agnostic: the thin `oled.cpp` adapter just draws
// whatever a ScreenBuffer currently holds. Sized for a 128x64 SSD1306 at
// Adafruit_GFX's default 6x8px font (128/6 = 21 usable columns,
// 64/8 = 8 usable rows).
class ScreenBuffer {
public:
static constexpr size_t kMaxLines = 8;
static constexpr size_t kMaxLineLength = 21;

// Sets one line's text, truncating if it's too long for the display.
// Out-of-range indices are ignored.
void setLine(size_t index, const char *text);

void clear();

const char *line(size_t index) const;

static size_t lineCount() { return kMaxLines; }

private:
char lines_[kMaxLines][kMaxLineLength + 1] = {};
};
14 changes: 9 additions & 5 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,21 @@ platform = espressif32@7.0.1
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
lib_deps =
adafruit/Adafruit SSD1306@^2.5.13
adafruit/Adafruit GFX Library@^1.11.11

; Native (host) environment for unit tests — no hardware required.
; SnipsController.ino pulls in Arduino.h, so it's excluded here; everything
; else in src/ is hardware-agnostic and gets linked into the test binary.
; As new subsystems land, genuinely hardware-touching files (thin adapters
; over I2C/SPI/ADC/RMT/NVS) get added to this exclusion list alongside it —
; see CLAUDE.md's testing-approach note.
; else in src/ is hardware-agnostic and gets linked into the test binary,
; 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).
[env:native]
platform = native
test_build_src = yes
build_src_filter = +<*> -<*.ino>
build_src_filter = +<*> -<*.ino> -<oled.cpp>
test_framework = unity
build_flags = --coverage
extra_scripts = pre:scripts/native_coverage_linkflags.py
14 changes: 14 additions & 0 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#include <Arduino.h>

#include "buttons.h"
#include "oled.h"
#include "pin_assignment.h"
#include "power_latch.h"
#include "screen.h"

// Pure entry point — wiring only. All real logic lives in dedicated
// subsystem files under src/ + include/; this file just owns real
Expand All @@ -12,6 +14,7 @@ namespace {

PowerOffDetector powerOffDetector;
ButtonPanel buttonPanel;
OledDisplay oledDisplay;
bool lastReportedPressed[Buttons::kCount] = {};

const char *buttonName(size_t index) {
Expand Down Expand Up @@ -60,6 +63,17 @@ void setup() {
for (size_t i = 0; i < Buttons::kCount; ++i) {
pinMode(Buttons::kPins[i], INPUT_PULLUP);
}

// Real screen content (menus, complications, gesture feedback) lands in
// later PRs. For now this just proves the display works end to end.
if (oledDisplay.begin()) {
ScreenBuffer bootScreen;
bootScreen.setLine(0, "Snips Controller");
bootScreen.setLine(1, "OLED OK");
oledDisplay.render(bootScreen);
} else {
Serial.println("OLED not found at boot.");
}
}

void loop() {
Expand Down
19 changes: 19 additions & 0 deletions src/oled.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#include "oled.h"

#include "pin_assignment.h"

bool OledDisplay::begin() {
Wire.begin(PinAssignment::kI2cSda, PinAssignment::kI2cScl);
return display_.begin(SSD1306_SWITCHCAPVCC, Oled::kI2cAddress);
}

void OledDisplay::render(const ScreenBuffer &content) {
display_.clearDisplay();
display_.setTextSize(1);
display_.setTextColor(SSD1306_WHITE);
for (size_t i = 0; i < ScreenBuffer::lineCount(); ++i) {
display_.setCursor(0, static_cast<int16_t>(i * 8));
display_.print(content.line(i));
}
display_.display();
}
19 changes: 19 additions & 0 deletions src/screen.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#include "screen.h"

void ScreenBuffer::setLine(size_t index, const char *text) {
if (index >= kMaxLines || text == nullptr) {
return;
}
std::strncpy(lines_[index], text, kMaxLineLength);
lines_[index][kMaxLineLength] = '\0';
}

void ScreenBuffer::clear() {
for (size_t i = 0; i < kMaxLines; ++i) {
lines_[i][0] = '\0';
}
}

const char *ScreenBuffer::line(size_t index) const {
return index < kMaxLines ? lines_[index] : "";
}
71 changes: 71 additions & 0 deletions test/test_screen/test_screen.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#include <unity.h>
#include <cstring>

#include "screen.h"

void setUp(void) {}
void tearDown(void) {}

void test_new_buffer_lines_are_empty() {
ScreenBuffer buffer;
for (size_t i = 0; i < ScreenBuffer::lineCount(); ++i) {
TEST_ASSERT_EQUAL_STRING("", buffer.line(i));
}
}

void test_set_line_is_retrievable() {
ScreenBuffer buffer;
buffer.setLine(0, "Snips Controller");
TEST_ASSERT_EQUAL_STRING("Snips Controller", buffer.line(0));
}

void test_set_line_truncates_text_too_long_for_display() {
ScreenBuffer buffer;
// 25 'x's — longer than kMaxLineLength (21).
buffer.setLine(0, "xxxxxxxxxxxxxxxxxxxxxxxxx");
TEST_ASSERT_EQUAL_INT(ScreenBuffer::kMaxLineLength,
std::strlen(buffer.line(0)));
}

void test_set_line_ignores_out_of_range_index() {
ScreenBuffer buffer;
buffer.setLine(ScreenBuffer::kMaxLines + 5, "should be ignored");
// Nothing should have been written anywhere retrievable.
for (size_t i = 0; i < ScreenBuffer::lineCount(); ++i) {
TEST_ASSERT_EQUAL_STRING("", buffer.line(i));
}
}

void test_set_line_ignores_null_text() {
ScreenBuffer buffer;
buffer.setLine(0, "kept");
buffer.setLine(0, nullptr);
TEST_ASSERT_EQUAL_STRING("kept", buffer.line(0));
}

void test_clear_resets_all_lines() {
ScreenBuffer buffer;
buffer.setLine(0, "line 0");
buffer.setLine(3, "line 3");
buffer.clear();
for (size_t i = 0; i < ScreenBuffer::lineCount(); ++i) {
TEST_ASSERT_EQUAL_STRING("", buffer.line(i));
}
}

void test_line_out_of_range_returns_empty_string() {
ScreenBuffer buffer;
TEST_ASSERT_EQUAL_STRING("", buffer.line(ScreenBuffer::kMaxLines + 5));
}

int main(int argc, char **argv) {
UNITY_BEGIN();
RUN_TEST(test_new_buffer_lines_are_empty);
RUN_TEST(test_set_line_is_retrievable);
RUN_TEST(test_set_line_truncates_text_too_long_for_display);
RUN_TEST(test_set_line_ignores_out_of_range_index);
RUN_TEST(test_set_line_ignores_null_text);
RUN_TEST(test_clear_resets_all_lines);
RUN_TEST(test_line_out_of_range_returns_empty_string);
return UNITY_END();
}
Loading