From 61f465946266838c4df3249c19719484387307b2 Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Sat, 14 Feb 2026 09:25:49 -0600 Subject: [PATCH] Arduino CLI build system with HAL-based test architecture Build and upload tool (arduino-build.sh): - Compile, upload, and monitor via arduino-cli - Device discovery with USB ID identification (--devices) - Persistent reconnecting serial monitor (--watch) - Split compile/upload workflow (--verify, --upload-only) - First-time setup wizard (--setup) - Comprehensive --help with troubleshooting and RedBoard specs Testable application architecture: - Hardware abstraction layer (lib/hal/) decouples logic from Arduino API - Google Mock HAL for unit tests (exact call verification) - Simulated HAL for system tests (GPIO state, virtual clock, I2C devices) - Example I2C temperature sensor simulator (TMP102) - Host-side test suite via CMake + Google Test (21 tests) Example sketch: - blink/ -- LED blink with button-controlled speed, wired through HAL --- .clang-format | 14 + .editorconfig | 24 + .gitattributes | 29 ++ .gitignore | 28 ++ .vscode/settings.json | 36 ++ README.md | 79 +++ arduino-build.sh | 999 +++++++++++++++++++++++++++++++++++++ blink/blink.ino | 32 ++ lib/app/blink_app.h | 88 ++++ lib/hal/hal.h | 67 +++ lib/hal/hal_arduino.h | 93 ++++ test/CMakeLists.txt | 70 +++ test/mocks/mock_hal.h | 45 ++ test/mocks/sim_hal.h | 256 ++++++++++ test/run_tests.sh | 84 ++++ test/test_blink_system.cpp | 201 ++++++++ test/test_blink_unit.cpp | 132 +++++ test/test_i2c_example.cpp | 140 ++++++ 18 files changed, 2417 insertions(+) create mode 100644 .clang-format create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100755 arduino-build.sh create mode 100644 blink/blink.ino create mode 100644 lib/app/blink_app.h create mode 100644 lib/hal/hal.h create mode 100644 lib/hal/hal_arduino.h create mode 100644 test/CMakeLists.txt create mode 100644 test/mocks/mock_hal.h create mode 100644 test/mocks/sim_hal.h create mode 100755 test/run_tests.sh create mode 100644 test/test_blink_system.cpp create mode 100644 test/test_blink_unit.cpp create mode 100644 test/test_i2c_example.cpp diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..62a0bc9 --- /dev/null +++ b/.clang-format @@ -0,0 +1,14 @@ +--- +Language: Cpp +BasedOnStyle: Google +IndentWidth: 4 +ColumnLimit: 100 +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +BreakBeforeBraces: Attach +PointerAlignment: Left +SpaceAfterCStyleCast: false +IncludeBlocks: Preserve +SortIncludes: false +--- diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..85c1388 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.ino] +indent_size = 4 + +[*.{c,cpp,h,hpp}] +indent_size = 4 + +[*.sh] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..909f1c7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Auto-detect text files and normalize line endings +* text=auto + +# Source code +*.ino text +*.cpp text +*.c text +*.h text +*.hpp text +*.S text + +# Scripts +*.sh text eol=lf + +# Config +*.json text +*.yaml text +*.yml text +*.md text +*.txt text + +# Binary / generated -- do not diff +*.hex binary +*.elf binary +*.bin binary +*.eep binary +*.png binary +*.jpg binary +*.pdf binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ccda2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Build artifacts (arduino-cli output) +/tmp/arduino-build/ +*.elf +*.hex +*.bin +*.map +*.eep + +# Compiled object files +*.o +*.d + +# Arduino IDE (if someone opens it there) +build/ +*.ino.cpp + +# Editor files +.vscode/c_cpp_properties.json +.vscode/ipch/ +*.swp +*.swo +*~ +.idea/ +*.sublime-workspace + +# OS junk +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8a972b8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,36 @@ +{ + "files.associations": { + "*.ino": "cpp" + }, + "C_Cpp.default.compilerPath": "", + "C_Cpp.default.defines": [ + "ARDUINO=10816", + "ARDUINO_AVR_UNO", + "ARDUINO_ARCH_AVR", + "__AVR_ATmega328P__", + "F_CPU=16000000L" + ], + "C_Cpp.default.includePath": [ + "${workspaceFolder}/**", + "~/.arduino15/packages/arduino/hardware/avr/*/cores/arduino", + "~/.arduino15/packages/arduino/hardware/avr/*/variants/standard", + "~/.arduino15/packages/arduino/hardware/avr/*/libraries/**", + "~/Arduino/libraries/**" + ], + "C_Cpp.errorSquiggles": "enabled", + "C_Cpp.default.cStandard": "c11", + "C_Cpp.default.cppStandard": "c++11", + "editor.formatOnSave": false, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.exclude": { + "**/*.o": true, + "**/*.d": true, + "**/*.elf": true, + "**/*.hex": true + }, + "search.exclude": { + "**/build": true, + "/tmp/arduino-build": true + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc9bd33 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# SparkFun RedBoard Projects + +Arduino sketches for SparkFun RedBoard, built and uploaded via `arduino-build.sh`. + +## Prerequisites + +- [arduino-cli](https://arduino.github.io/arduino-cli/) +- Run `./arduino-build.sh --setup` once after installing + +## Quick Start + +```bash +./arduino-build.sh --devices # find your board +./arduino-build.sh --monitor ./blink # build, upload, serial console +``` + +## Project Layout + +``` +sparkfun/ + arduino-build.sh Build/upload/monitor tool (run --help) + blink/ + blink.ino LED blink + serial hello world +``` + +## Two-Terminal Workflow + +```bash +# Terminal 1: persistent serial monitor +./arduino-build.sh --watch + +# Terminal 2: edit, build, upload (monitor reconnects automatically) +./arduino-build.sh ./blink +``` + +## Adding a New Sketch + +```bash +mkdir my_sketch +# Create my_sketch/my_sketch.ino (filename must match directory) +./arduino-build.sh --verify ./my_sketch # compile check +./arduino-build.sh ./my_sketch # build + upload +``` + +## Troubleshooting + +### Ubuntu: Board not detected (CH340 / RedBoard) + +On Ubuntu, `brltty` (a braille display daemon) may claim the CH340 USB-serial +interface before the `ch341` kernel driver can bind to it. The board will appear +in `lsusb` but no `/dev/ttyUSB*` device is created. + +Symptoms in `dmesg`: + +``` +usb 1-3.1.3: usbfs: interface 0 claimed by ch341 while 'brltty' sets config #1 +ch341-uart ttyUSB0: ch341-uart converter now disconnected from ttyUSB0 +``` + +If you are not using a braille display, remove `brltty` entirely: + +```bash +sudo systemctl stop brltty-udev +sudo systemctl disable brltty-udev +sudo systemctl stop brltty +sudo systemctl disable brltty +sudo apt remove brltty +``` + +Alternatively, to keep `brltty` installed but block it from claiming CH340 +devices, add a udev rule: + +```bash +sudo tee /etc/udev/rules.d/99-ch340-no-brltty.rules << 'EOF' +SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", ENV{BRLTTY_BRAILLE_DRIVER}="none" +EOF +sudo udevadm control --reload-rules +sudo udevadm trigger +``` \ No newline at end of file diff --git a/arduino-build.sh b/arduino-build.sh new file mode 100755 index 0000000..4cebd1e --- /dev/null +++ b/arduino-build.sh @@ -0,0 +1,999 @@ +#!/usr/bin/env bash +# +# arduino-build.sh -- Build, upload, and monitor Arduino sketches +# for SparkFun RedBoard (and other Uno-compatible boards) +# +# Run with --help for full usage information. +# Run with --setup for first-time toolchain installation. +# Run with --devices to find connected boards. + +set -euo pipefail + +# -- Configuration (override via env vars if needed) ------------------------- +FQBN="${ARDUINO_FQBN:-arduino:avr:uno}" +BAUD="${ARDUINO_BAUD:-115200}" +BUILD_DIR="${ARDUINO_BUILD_DIR:-/tmp/arduino-build}" +ARDUINO_CLI="${ARDUINO_CLI_BIN:-arduino-cli}" +VERSION="1.1.0" + +# -- Color output (disabled if stdout is not a terminal) --------------------- +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GRN=$'\033[0;32m'; YEL=$'\033[0;33m' + CYN=$'\033[0;36m'; BLD=$'\033[1m'; DIM=$'\033[2m'; RST=$'\033[0m' +else + RED=''; GRN=''; YEL=''; CYN=''; BLD=''; DIM=''; RST='' +fi + +info() { echo -e "${CYN}[INFO]${RST} $*"; } +ok() { echo -e "${GRN}[ OK ]${RST} $*"; } +warn() { echo -e "${YEL}[WARN]${RST} $*"; } +die() { echo -e "${RED}[ERR]${RST} $*" >&2; exit 1; } + +# ============================================================================ +# HELP +# ============================================================================ +show_help() { + cat < + +${BLD}DESCRIPTION${RST} + A self-contained script that replaces the Arduino IDE for command-line + workflows. Compiles sketches with arduino-cli, uploads to the board, + and optionally opens a serial monitor -- all in one command. + + Designed for the SparkFun RedBoard but works with any Uno-compatible + board. Just plug in, run --devices to find your port, and go. + +$(_help_section_commands) +$(_help_section_build_options) +$(_help_section_config_options) +$(_help_section_env_vars) +$(_help_section_examples) +$(_help_section_cheatsheet) +$(_help_section_sketch_layout) +$(_help_section_redboard) +$(_help_section_port_permissions) +$(_help_section_first_time) +$(_help_section_troubleshooting) +$(_help_section_usb_ids) +EOF +} + +_help_section_commands() { + cat < blink/blink.ino << 'SKETCH' + void setup() { + Serial.begin(115200); + pinMode(LED_BUILTIN, OUTPUT); + } + void loop() { + digitalWrite(LED_BUILTIN, HIGH); + Serial.println("ON"); + delay(500); + digitalWrite(LED_BUILTIN, LOW); + Serial.println("OFF"); + delay(500); + } + SKETCH +EOF +} + +_help_section_redboard() { + cat < blink/blink.ino << 'SKETCH' + void setup() { pinMode(LED_BUILTIN, OUTPUT); } + void loop() { digitalWrite(LED_BUILTIN, HIGH); delay(500); + digitalWrite(LED_BUILTIN, LOW); delay(500); } + SKETCH + + 5. Build and upload: + ./arduino-build.sh ./blink + + 6. Verify the LED on the board is blinking. Done! +EOF +} + +_help_section_troubleshooting() { + cat </dev/null)" + [[ -f "$usb_dev_path/product" ]] && product="$(cat "$usb_dev_path/product" 2>/dev/null)" + [[ -f "$usb_dev_path/idVendor" ]] && vid="$(cat "$usb_dev_path/idVendor" 2>/dev/null)" + [[ -f "$usb_dev_path/idProduct" ]] && pid="$(cat "$usb_dev_path/idProduct" 2>/dev/null)" + [[ -f "$usb_dev_path/serial" ]] && serial="$(cat "$usb_dev_path/serial" 2>/dev/null)" + + [[ -n "$vendor" ]] && echo " Manufacturer: $vendor" + [[ -n "$product" ]] && echo " Product: $product" + [[ -n "$vid" ]] && [[ -n "$pid" ]] && echo " USB ID: ${vid}:${pid}" + [[ -n "$serial" ]] && echo " Serial: $serial" + [[ -n "$driver" ]] && echo " Driver: $driver" + + echo -n " Likely board: " + case "${vid}:${pid}" in + 1a86:7523) echo "SparkFun RedBoard / CH340 clone" ;; + 1a86:55d4) echo "SparkFun RedBoard Qwiic / CH340C" ;; + 2341:0043) echo "Arduino Uno R3 (ATmega16U2)" ;; + 2341:0001) echo "Arduino Uno (older revision)" ;; + 2341:0010) echo "Arduino Mega 2560" ;; + 2341:0042) echo "Arduino Mega 2560 R3" ;; + 2341:003d) echo "Arduino Due (Programming Port)" ;; + 2341:003e) echo "Arduino Due (Native USB)" ;; + 2341:8036) echo "Arduino Leonardo" ;; + 2341:8037) echo "Arduino Micro" ;; + 0403:6001) echo "FTDI FT232R (older RedBoard / clone)" ;; + 0403:6015) echo "FTDI FT231X (SparkFun breakout)" ;; + 10c4:ea60) echo "CP2102 (NodeMCU / some ESP32 boards)" ;; + 1a86:*) echo "CH340 variant (likely Arduino clone)" ;; + 2341:*) echo "Arduino (unknown model -- USB ID ${vid}:${pid})" ;; + *) echo "Unknown device -- USB ID ${vid}:${pid}" ;; + esac + else + # Could not find USB device node, but we may still have the driver + [[ -n "$driver" ]] && echo " Driver: $driver" + echo " USB info: Could not read (sysfs walk failed)" + fi + + if [[ -w "$port" ]]; then + echo -e " Permissions: ${GRN}OK (writable)${RST}" + else + echo -e " Permissions: ${RED}NOT writable${RST} -- run: sudo usermod -aG dialout \$USER" + fi + + echo "" + done + + if [[ $found -eq 0 ]]; then + echo -e " ${YEL}No serial devices found.${RST}" + echo "" + echo " Checklist:" + echo " 1. Is the board plugged in via USB?" + echo " 2. Is the USB cable a data cable (not charge-only)?" + echo " 3. Check kernel log: dmesg | tail -20" + echo " 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341|0403'" + echo " 5. Try a different USB port or cable" + echo "" + fi + + if command -v "$ARDUINO_CLI" &>/dev/null; then + echo -e "${BLD}=== arduino-cli Board Detection ===${RST}" + echo "" + $ARDUINO_CLI board list 2>/dev/null || warn "arduino-cli board list failed (is the core installed?)" + echo "" + else + warn "arduino-cli not found -- run --setup first" + echo "" + fi +} + +# ============================================================================ +# FIRST-TIME SETUP +# ============================================================================ +run_setup() { + echo "" + echo -e "${BLD}=== Arduino CLI First-Time Setup ===${RST}" + echo "" + + if ! command -v "$ARDUINO_CLI" &>/dev/null; then + die "arduino-cli not found in PATH. + + Install it: + curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh + sudo mv bin/arduino-cli /usr/local/bin/ + + Or via package manager: + sudo apt install arduino-cli (Debian/Ubuntu) + brew install arduino-cli (macOS) + yay -S arduino-cli (Arch) + + Then re-run: ./arduino-build.sh --setup" + fi + ok "arduino-cli found: $($ARDUINO_CLI version 2>/dev/null | head -1)" + + info "Updating board index..." + $ARDUINO_CLI core update-index + ok "Board index updated." + + if $ARDUINO_CLI core list 2>/dev/null | grep -q "arduino:avr"; then + ok "arduino:avr core already installed." + else + info "Installing arduino:avr core (this may take a minute)..." + $ARDUINO_CLI core install arduino:avr + ok "arduino:avr core installed." + fi + + if command -v avr-size &>/dev/null; then + ok "avr-size found (binary size reporting enabled)." + else + warn "avr-size not found. Install for binary size details:" + warn " sudo apt install gcc-avr" + fi + + echo "" + if groups 2>/dev/null | grep -q dialout; then + ok "User '$(whoami)' is in the 'dialout' group." + else + warn "User '$(whoami)' is NOT in the 'dialout' group." + warn "Fix with: sudo usermod -aG dialout \$USER" + warn "Then log out and back in (or reboot)." + fi + + echo "" + scan_devices + + echo -e "${BLD}Setup complete!${RST}" + echo "" + echo " Next steps:" + echo " 1. Plug in your RedBoard" + echo " 2. ./arduino-build.sh --devices" + echo " 3. ./arduino-build.sh ./your_sketch" + echo "" +} + +# ============================================================================ +# AUTO-DETECT SERIAL PORT +# ============================================================================ +auto_detect_port() { + local candidates=() + for p in /dev/ttyUSB* /dev/ttyACM*; do + [[ -e "$p" ]] && candidates+=("$p") + done + + if [[ ${#candidates[@]} -eq 0 ]]; then + die "No serial ports found. Is the board plugged in? + Run: ./arduino-build.sh --devices" + fi + + if [[ ${#candidates[@]} -eq 1 ]]; then + echo "${candidates[0]}" + return + fi + + warn "Multiple serial ports detected:" + for p in "${candidates[@]}"; do + warn " $p" + done + + # Prefer /dev/ttyUSB* (CH340 on RedBoard) over /dev/ttyACM* + for p in "${candidates[@]}"; do + if [[ "$p" == /dev/ttyUSB* ]]; then + warn "Auto-selected $p (CH340 -- likely RedBoard)" + warn "Use -p to override if this is wrong." + echo "$p" + return + fi + done + + warn "Auto-selected ${candidates[0]}. Use -p to override." + echo "${candidates[0]}" +} + +# ============================================================================ +# PERSISTENT SERIAL MONITOR (--watch) +# ============================================================================ +run_watch_monitor() { + local port="$1" + local baud="$2" + + # If no port specified, auto-detect once to get a starting point + if [[ -z "$port" ]]; then + # Don't die if no port yet -- we'll wait for it + for p in /dev/ttyUSB* /dev/ttyACM*; do + if [[ -e "$p" ]]; then + port="$p" + break + fi + done + if [[ -z "$port" ]]; then + # Default to the most common RedBoard port + port="/dev/ttyUSB0" + info "No port detected yet. Waiting for $port to appear..." + fi + fi + + info "Persistent monitor on ${BLD}$port${RST} at ${baud} baud" + info "Reconnects automatically after upload / reset / replug." + info "Press Ctrl+C to exit." + echo "" + + trap 'echo ""; info "Monitor stopped."; exit 0' INT TERM + + while true; do + # Wait for port to exist + if [[ ! -e "$port" ]]; then + echo -e "${DIM}--- Waiting for $port ...${RST}" + while [[ ! -e "$port" ]]; do + sleep 0.5 + done + # Brief settle time after port appears (CH340 needs a moment) + sleep 1.0 + echo -e "${GRN}--- $port connected ---${RST}" + fi + + # Open the monitor (will exit when port disappears) + $ARDUINO_CLI monitor -p "$port" -c baudrate="$baud" 2>/dev/null || true + + # If we get here, the monitor exited (port vanished during upload, + # board reset, USB replug, etc.). Brief pause then retry. + echo -e "${YEL}--- $port disconnected ---${RST}" + sleep 0.5 + done +} + +# ============================================================================ +# ARGUMENT PARSING +# ============================================================================ +SKETCH_DIR="" +PORT="" +VERIFY_ONLY=0 +UPLOAD_ONLY=0 +DO_MONITOR=0 +DO_CLEAN=0 +DO_VERBOSE=0 +ACTION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + exit 0 + ;; + --version) + echo "arduino-build.sh v${VERSION}" + exit 0 + ;; + --setup) + ACTION="setup" + shift + ;; + --devices) + ACTION="devices" + shift + ;; + --watch) + ACTION="watch" + shift + ;; + --verify) + VERIFY_ONLY=1 + shift + ;; + --upload) + shift # default behavior, accepted for explicitness + ;; + --upload-only) + UPLOAD_ONLY=1 + shift + ;; + --monitor) + DO_MONITOR=1 + shift + ;; + --clean) + DO_CLEAN=1 + shift + ;; + --verbose) + DO_VERBOSE=1 + shift + ;; + -p|--port) + [[ -n "${2:-}" ]] || die "--port requires a value (e.g., -p /dev/ttyUSB0)" + PORT="$2" + shift 2 + ;; + -b|--baud) + [[ -n "${2:-}" ]] || die "--baud requires a value (e.g., -b 115200)" + BAUD="$2" + shift 2 + ;; + --fqbn) + [[ -n "${2:-}" ]] || die "--fqbn requires a value (e.g., --fqbn arduino:avr:uno)" + FQBN="$2" + shift 2 + ;; + -*) + die "Unknown option: $1 + Run with --help for usage." + ;; + *) + if [[ -z "$SKETCH_DIR" ]]; then + SKETCH_DIR="$1" + else + die "Unexpected argument: $1 + Sketch directory already set to: $SKETCH_DIR" + fi + shift + ;; + esac +done + +# ============================================================================ +# DISPATCH STANDALONE COMMANDS +# ============================================================================ +case "${ACTION:-}" in + setup) run_setup; exit 0 ;; + devices) scan_devices; exit 0 ;; + watch) run_watch_monitor "$PORT" "$BAUD" ;; +esac + +# ============================================================================ +# VALIDATE SKETCH +# ============================================================================ +if [[ $UPLOAD_ONLY -eq 1 ]] && [[ $VERIFY_ONLY -eq 1 ]]; then + die "--upload-only and --verify are contradictory. + --verify means compile only, --upload-only means upload only." +fi + +if [[ $UPLOAD_ONLY -eq 1 ]] && [[ $DO_CLEAN -eq 1 ]]; then + die "--upload-only and --clean are contradictory. + --clean would delete the cached build that --upload-only needs." +fi + +if [[ -z "$SKETCH_DIR" ]]; then + echo "" + echo -e "${RED}Error: No sketch directory specified.${RST}" + echo "" + echo " Usage: ./arduino-build.sh [OPTIONS] " + echo "" + echo " Quick start:" + echo " --help Full help with examples and troubleshooting" + echo " --setup First-time toolchain installation" + echo " --devices Find connected boards" + echo " ./my_sketch Build and upload a sketch" + echo "" + exit 1 +fi + +SKETCH_DIR="$(realpath "$SKETCH_DIR")" +[[ -d "$SKETCH_DIR" ]] || die "Not a directory: $SKETCH_DIR" + +SKETCH_NAME="$(basename "$SKETCH_DIR")" +INO_FILE="$SKETCH_DIR/$SKETCH_NAME.ino" + +if [[ ! -f "$INO_FILE" ]]; then + ALT_INO="$(find "$SKETCH_DIR" -maxdepth 1 -name '*.ino' | head -1)" + if [[ -n "$ALT_INO" ]]; then + warn "Expected ${SKETCH_NAME}.ino but found $(basename "$ALT_INO")" + warn "Arduino convention: .ino filename must match directory name." + INO_FILE="$ALT_INO" + SKETCH_NAME="$(basename "$INO_FILE" .ino)" + else + die "No .ino file found in $SKETCH_DIR + Expected: $SKETCH_DIR/$SKETCH_NAME.ino + Create one: touch $SKETCH_DIR/$SKETCH_NAME.ino" + fi +fi + +info "Sketch: ${BLD}$SKETCH_NAME${RST}" +info "Board: $FQBN" + +# ============================================================================ +# PRE-FLIGHT CHECKS +# ============================================================================ +command -v "$ARDUINO_CLI" &>/dev/null \ + || die "arduino-cli not found. Run: ./arduino-build.sh --setup" + +if ! $ARDUINO_CLI core list 2>/dev/null | grep -q "arduino:avr"; then + die "arduino:avr core not installed. Run: ./arduino-build.sh --setup" +fi + +# ============================================================================ +# CLEAN (if requested) +# ============================================================================ +SKETCH_BUILD_DIR="$BUILD_DIR/$SKETCH_NAME" + +if [[ $DO_CLEAN -eq 1 ]] && [[ -d "$SKETCH_BUILD_DIR" ]]; then + info "Cleaning build cache..." + rm -rf "$SKETCH_BUILD_DIR" + ok "Cache cleared." +fi + +# ============================================================================ +# COMPILE +# ============================================================================ +# ============================================================================ +# COMPILE (skipped with --upload-only) +# ============================================================================ +if [[ $UPLOAD_ONLY -eq 1 ]]; then + # Verify cached build artifacts exist + if [[ ! -d "$SKETCH_BUILD_DIR" ]]; then + die "No cached build found for '$SKETCH_NAME'. + Run a compile first: ./arduino-build.sh --verify ./$SKETCH_NAME + Build cache expected at: $SKETCH_BUILD_DIR" + fi + + HEX_FILE="$SKETCH_BUILD_DIR/$SKETCH_NAME.ino.hex" + if [[ ! -f "$HEX_FILE" ]]; then + die "Build cache exists but no .hex file found. + Try a clean rebuild: ./arduino-build.sh --clean ./$SKETCH_NAME" + fi + + ok "Using cached build." + info "Hex: $HEX_FILE" + + # Report binary size if possible + ELF_FILE="$SKETCH_BUILD_DIR/$SKETCH_NAME.ino.elf" + if command -v avr-size &>/dev/null && [[ -f "$ELF_FILE" ]]; then + echo "" + avr-size --mcu=atmega328p -C "$ELF_FILE" 2>/dev/null || avr-size "$ELF_FILE" + echo "" + fi +else + info "Compiling..." + mkdir -p "$SKETCH_BUILD_DIR" + + COMPILE_ARGS=( + --fqbn "$FQBN" + --build-path "$SKETCH_BUILD_DIR" + --warnings all + ) + [[ $DO_VERBOSE -eq 1 ]] && COMPILE_ARGS+=(--verbose) + + if ! $ARDUINO_CLI compile "${COMPILE_ARGS[@]}" "$SKETCH_DIR"; then + die "Compilation failed." + fi + + ok "Compile succeeded." + + # Report binary size + ELF_FILE="$SKETCH_BUILD_DIR/$SKETCH_NAME.ino.elf" + if command -v avr-size &>/dev/null && [[ -f "$ELF_FILE" ]]; then + echo "" + avr-size --mcu=atmega328p -C "$ELF_FILE" 2>/dev/null || avr-size "$ELF_FILE" + echo "" + fi +fi + +# ============================================================================ +# VERIFY-ONLY EXIT +# ============================================================================ +if [[ $VERIFY_ONLY -eq 1 ]]; then + ok "Verify-only mode. Done." + exit 0 +fi + +# ============================================================================ +# UPLOAD +# ============================================================================ +if [[ -z "$PORT" ]]; then + PORT="$(auto_detect_port)" +fi + +info "Uploading to ${BLD}$PORT${RST} ..." + +if [[ ! -e "$PORT" ]]; then + die "Port $PORT does not exist. Run --devices to check." +fi + +if [[ ! -w "$PORT" ]]; then + warn "No write access to $PORT" + warn "Fix: sudo usermod -aG dialout \$USER (then log out/in)" +fi + +UPLOAD_ARGS=( + --fqbn "$FQBN" + --port "$PORT" + --input-dir "$SKETCH_BUILD_DIR" +) +[[ $DO_VERBOSE -eq 1 ]] && UPLOAD_ARGS+=(--verbose) + +if ! $ARDUINO_CLI upload "${UPLOAD_ARGS[@]}"; then + die "Upload failed. Run with --verbose for details. + Also try: ./arduino-build.sh --devices" +fi + +ok "Upload complete!" + +# ============================================================================ +# SERIAL MONITOR +# ============================================================================ +if [[ $DO_MONITOR -eq 1 ]]; then + echo "" + info "Opening serial monitor on $PORT at ${BAUD} baud..." + info "Press Ctrl+C to exit." + echo "" + $ARDUINO_CLI monitor -p "$PORT" -c baudrate="$BAUD" +else + echo "" + info "To open serial monitor:" + echo " ./arduino-build.sh --monitor -p $PORT ./your_sketch" + echo " arduino-cli monitor -p $PORT -c baudrate=$BAUD" + echo " screen $PORT $BAUD" +fi \ No newline at end of file diff --git a/blink/blink.ino b/blink/blink.ino new file mode 100644 index 0000000..ea639c6 --- /dev/null +++ b/blink/blink.ino @@ -0,0 +1,32 @@ +/* + * blink.ino -- LED blink with button-controlled speed + * + * This .ino file is just the entry point. All logic lives in + * BlinkApp (lib/app/blink_app.h) which depends on the HAL + * interface (lib/hal/hal.h), making it testable on the host. + * + * The build script auto-discovers lib/ subdirectories and passes + * them as --library flags to arduino-cli, so angle-bracket includes + * work for project-local libraries. + * + * Wiring: + * Pin 13 (LED_BUILTIN) -- onboard LED (no wiring needed) + * Pin 2 -- momentary button to GND (uses INPUT_PULLUP) + * + * Serial: 115200 baud + * Prints "FAST" or "SLOW" on button press. + */ + +#include +#include + +static ArduinoHal hw; +static BlinkApp app(&hw); + +void setup() { + app.begin(); +} + +void loop() { + app.update(); +} diff --git a/lib/app/blink_app.h b/lib/app/blink_app.h new file mode 100644 index 0000000..87ce664 --- /dev/null +++ b/lib/app/blink_app.h @@ -0,0 +1,88 @@ +#ifndef BLINK_APP_H +#define BLINK_APP_H + +#include + +/* + * BlinkApp -- Testable blink logic, decoupled from hardware. + * + * Blinks an LED and reads a button. When the button is pressed, + * the blink rate doubles (toggles between normal and fast mode). + * + * All hardware access goes through the injected Hal pointer. This + * class has no dependency on Arduino.h and compiles on any host. + */ +class BlinkApp { +public: + static constexpr uint8_t DEFAULT_LED_PIN = LED_BUILTIN; // pin 13 + static constexpr uint8_t DEFAULT_BUTTON_PIN = 2; + static constexpr unsigned long SLOW_INTERVAL_MS = 500; + static constexpr unsigned long FAST_INTERVAL_MS = 125; + + BlinkApp(Hal* hal, + uint8_t led_pin = DEFAULT_LED_PIN, + uint8_t button_pin = DEFAULT_BUTTON_PIN) + : hal_(hal) + , led_pin_(led_pin) + , button_pin_(button_pin) + , led_state_(LOW) + , fast_mode_(false) + , last_toggle_ms_(0) + , last_button_state_(HIGH) // pulled up, so HIGH = not pressed + {} + + // Call once from setup() + void begin() { + hal_->pinMode(led_pin_, OUTPUT); + hal_->pinMode(button_pin_, INPUT_PULLUP); + hal_->serialBegin(115200); + hal_->serialPrintln("BlinkApp started"); + last_toggle_ms_ = hal_->millis(); + } + + // Call repeatedly from loop() + void update() { + handleButton(); + handleBlink(); + } + + // -- Accessors for testing ---------------------------------------------- + bool ledState() const { return led_state_ == HIGH; } + bool fastMode() const { return fast_mode_; } + unsigned long interval() const { + return fast_mode_ ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS; + } + +private: + void handleButton() { + uint8_t reading = hal_->digitalRead(button_pin_); + + // Detect falling edge (HIGH -> LOW = button press with INPUT_PULLUP) + if (last_button_state_ == HIGH && reading == LOW) { + fast_mode_ = !fast_mode_; + hal_->serialPrintln(fast_mode_ ? "FAST" : "SLOW"); + } + last_button_state_ = reading; + } + + void handleBlink() { + unsigned long now = hal_->millis(); + unsigned long target = fast_mode_ ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS; + + if (now - last_toggle_ms_ >= target) { + led_state_ = (led_state_ == HIGH) ? LOW : HIGH; + hal_->digitalWrite(led_pin_, led_state_); + last_toggle_ms_ = now; + } + } + + Hal* hal_; + uint8_t led_pin_; + uint8_t button_pin_; + uint8_t led_state_; + bool fast_mode_; + unsigned long last_toggle_ms_; + uint8_t last_button_state_; +}; + +#endif // BLINK_APP_H diff --git a/lib/hal/hal.h b/lib/hal/hal.h new file mode 100644 index 0000000..45313a1 --- /dev/null +++ b/lib/hal/hal.h @@ -0,0 +1,67 @@ +#ifndef HAL_H +#define HAL_H + +#include + +// Pin modes (match Arduino constants) +#ifndef INPUT +#define INPUT 0x0 +#define OUTPUT 0x1 +#define INPUT_PULLUP 0x2 +#endif + +#ifndef LOW +#define LOW 0x0 +#define HIGH 0x1 +#endif + +// LED_BUILTIN for host builds +#ifndef LED_BUILTIN +#define LED_BUILTIN 13 +#endif + +/* + * Hardware Abstraction Layer + * + * Abstract interface over GPIO, timing, serial, and I2C. Sketch logic + * depends on this interface only -- never on Arduino.h directly. + * + * Two implementations: + * hal_arduino.h -- real hardware (included by .ino files) + * mock_hal.h -- Google Mock (included by test files) + */ +class Hal { +public: + virtual ~Hal() = default; + + // -- GPIO --------------------------------------------------------------- + virtual void pinMode(uint8_t pin, uint8_t mode) = 0; + virtual void digitalWrite(uint8_t pin, uint8_t value) = 0; + virtual uint8_t digitalRead(uint8_t pin) = 0; + virtual int analogRead(uint8_t pin) = 0; + virtual void analogWrite(uint8_t pin, int value) = 0; + + // -- Timing ------------------------------------------------------------- + virtual unsigned long millis() = 0; + virtual unsigned long micros() = 0; + virtual void delay(unsigned long ms) = 0; + virtual void delayMicroseconds(unsigned long us) = 0; + + // -- Serial ------------------------------------------------------------- + virtual void serialBegin(unsigned long baud) = 0; + virtual void serialPrint(const char* msg) = 0; + virtual void serialPrintln(const char* msg) = 0; + virtual int serialAvailable() = 0; + virtual int serialRead() = 0; + + // -- I2C ---------------------------------------------------------------- + virtual void i2cBegin() = 0; + virtual void i2cBeginTransmission(uint8_t addr) = 0; + virtual size_t i2cWrite(uint8_t data) = 0; + virtual uint8_t i2cEndTransmission() = 0; + virtual uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) = 0; + virtual int i2cAvailable() = 0; + virtual int i2cRead() = 0; +}; + +#endif // HAL_H diff --git a/lib/hal/hal_arduino.h b/lib/hal/hal_arduino.h new file mode 100644 index 0000000..05dc49d --- /dev/null +++ b/lib/hal/hal_arduino.h @@ -0,0 +1,93 @@ +#ifndef HAL_ARDUINO_H +#define HAL_ARDUINO_H + +/* + * Real hardware implementation of the HAL. + * + * This file includes Arduino.h and Wire.h, so it can only be compiled + * by avr-gcc (via arduino-cli). It is included by .ino files only. + * + * Every method is a trivial passthrough to the real Arduino function. + * The point is not to add logic here -- it is to keep Arduino.h out + * of your application code so that code can compile on the host. + */ + +#include +#include +#include + +class ArduinoHal : public Hal { +public: + // -- GPIO --------------------------------------------------------------- + void pinMode(uint8_t pin, uint8_t mode) override { + ::pinMode(pin, mode); + } + void digitalWrite(uint8_t pin, uint8_t value) override { + ::digitalWrite(pin, value); + } + uint8_t digitalRead(uint8_t pin) override { + return ::digitalRead(pin); + } + int analogRead(uint8_t pin) override { + return ::analogRead(pin); + } + void analogWrite(uint8_t pin, int value) override { + ::analogWrite(pin, value); + } + + // -- Timing ------------------------------------------------------------- + unsigned long millis() override { + return ::millis(); + } + unsigned long micros() override { + return ::micros(); + } + void delay(unsigned long ms) override { + ::delay(ms); + } + void delayMicroseconds(unsigned long us) override { + ::delayMicroseconds(us); + } + + // -- Serial ------------------------------------------------------------- + void serialBegin(unsigned long baud) override { + Serial.begin(baud); + } + void serialPrint(const char* msg) override { + Serial.print(msg); + } + void serialPrintln(const char* msg) override { + Serial.println(msg); + } + int serialAvailable() override { + return Serial.available(); + } + int serialRead() override { + return Serial.read(); + } + + // -- I2C ---------------------------------------------------------------- + void i2cBegin() override { + Wire.begin(); + } + void i2cBeginTransmission(uint8_t addr) override { + Wire.beginTransmission(addr); + } + size_t i2cWrite(uint8_t data) override { + return Wire.write(data); + } + uint8_t i2cEndTransmission() override { + return Wire.endTransmission(); + } + uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) override { + return Wire.requestFrom(addr, count); + } + int i2cAvailable() override { + return Wire.available(); + } + int i2cRead() override { + return Wire.read(); + } +}; + +#endif // HAL_ARDUINO_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..191fca4 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.14) +project(sparkfun_tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# -------------------------------------------------------------------------- +# Google Test (fetched automatically) +# -------------------------------------------------------------------------- +include(FetchContent) +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) +# Prevent gtest from overriding compiler/linker options on Windows +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +enable_testing() + +# -------------------------------------------------------------------------- +# Include paths -- same headers used by Arduino, but compiled on host +# -------------------------------------------------------------------------- +set(LIB_DIR ${CMAKE_SOURCE_DIR}/../lib) + +include_directories( + ${LIB_DIR}/hal # hal.h + ${LIB_DIR}/app # blink_app.h + ${CMAKE_SOURCE_DIR}/mocks # mock_hal.h, sim_hal.h +) + +# -------------------------------------------------------------------------- +# Unit tests (Google Mock) +# -------------------------------------------------------------------------- +add_executable(test_blink_unit + test_blink_unit.cpp +) +target_link_libraries(test_blink_unit + GTest::gtest_main + GTest::gmock +) + +# -------------------------------------------------------------------------- +# System tests (SimHal) +# -------------------------------------------------------------------------- +add_executable(test_blink_system + test_blink_system.cpp +) +target_link_libraries(test_blink_system + GTest::gtest_main +) + +# -------------------------------------------------------------------------- +# I2C example tests +# -------------------------------------------------------------------------- +add_executable(test_i2c_example + test_i2c_example.cpp +) +target_link_libraries(test_i2c_example + GTest::gtest_main +) + +# -------------------------------------------------------------------------- +# Register with CTest +# -------------------------------------------------------------------------- +include(GoogleTest) +gtest_discover_tests(test_blink_unit) +gtest_discover_tests(test_blink_system) +gtest_discover_tests(test_i2c_example) diff --git a/test/mocks/mock_hal.h b/test/mocks/mock_hal.h new file mode 100644 index 0000000..2d3015f --- /dev/null +++ b/test/mocks/mock_hal.h @@ -0,0 +1,45 @@ +#ifndef MOCK_HAL_H +#define MOCK_HAL_H + +#include +#include "hal.h" + +/* + * StrictMock-friendly HAL mock for unit tests. + * + * Use this when you want to verify exact call sequences: + * EXPECT_CALL(mock, digitalWrite(13, HIGH)).Times(1); + */ +class MockHal : public Hal { +public: + // GPIO + MOCK_METHOD(void, pinMode, (uint8_t pin, uint8_t mode), (override)); + MOCK_METHOD(void, digitalWrite, (uint8_t pin, uint8_t value), (override)); + MOCK_METHOD(uint8_t, digitalRead, (uint8_t pin), (override)); + MOCK_METHOD(int, analogRead, (uint8_t pin), (override)); + MOCK_METHOD(void, analogWrite, (uint8_t pin, int value), (override)); + + // Timing + MOCK_METHOD(unsigned long, millis, (), (override)); + MOCK_METHOD(unsigned long, micros, (), (override)); + MOCK_METHOD(void, delay, (unsigned long ms), (override)); + MOCK_METHOD(void, delayMicroseconds, (unsigned long us), (override)); + + // Serial + MOCK_METHOD(void, serialBegin, (unsigned long baud), (override)); + MOCK_METHOD(void, serialPrint, (const char* msg), (override)); + MOCK_METHOD(void, serialPrintln, (const char* msg), (override)); + MOCK_METHOD(int, serialAvailable, (), (override)); + MOCK_METHOD(int, serialRead, (), (override)); + + // I2C + MOCK_METHOD(void, i2cBegin, (), (override)); + MOCK_METHOD(void, i2cBeginTransmission, (uint8_t addr), (override)); + MOCK_METHOD(size_t, i2cWrite, (uint8_t data), (override)); + MOCK_METHOD(uint8_t, i2cEndTransmission, (), (override)); + MOCK_METHOD(uint8_t, i2cRequestFrom, (uint8_t addr, uint8_t count), (override)); + MOCK_METHOD(int, i2cAvailable, (), (override)); + MOCK_METHOD(int, i2cRead, (), (override)); +}; + +#endif // MOCK_HAL_H diff --git a/test/mocks/sim_hal.h b/test/mocks/sim_hal.h new file mode 100644 index 0000000..b899f2a --- /dev/null +++ b/test/mocks/sim_hal.h @@ -0,0 +1,256 @@ +#ifndef SIM_HAL_H +#define SIM_HAL_H + +#include "hal.h" + +#include +#include +#include +#include +#include +#include +#include + +/* + * Simulated HAL for system tests. + * + * Unlike MockHal (which verifies call expectations), SimHal actually + * maintains state: pin values, a virtual clock, serial output capture, + * and pluggable I2C device simulators. + * + * This lets you write system tests that exercise full application logic + * against simulated hardware: + * + * SimHal sim; + * BlinkApp app(&sim); + * app.begin(); + * + * sim.setPin(2, LOW); // simulate button press + * sim.advanceMillis(600); // advance clock + * app.update(); + * + * EXPECT_EQ(sim.getPin(13), HIGH); // check LED state + */ + +// -------------------------------------------------------------------- +// I2C device simulator interface +// -------------------------------------------------------------------- +class I2cDeviceSim { +public: + virtual ~I2cDeviceSim() = default; + + // Called when master writes bytes to this device + virtual void onReceive(const uint8_t* data, size_t len) = 0; + + // Called when master requests bytes; fill response buffer + virtual size_t onRequest(uint8_t* buf, size_t max_len) = 0; +}; + +// -------------------------------------------------------------------- +// Simulated HAL +// -------------------------------------------------------------------- +class SimHal : public Hal { +public: + static const int NUM_PINS = 20; // D0-D13 + A0-A5 + + SimHal() : clock_ms_(0), clock_us_(0) { + memset(pin_modes_, 0, sizeof(pin_modes_)); + memset(pin_values_, 0, sizeof(pin_values_)); + } + + // -- GPIO --------------------------------------------------------------- + void pinMode(uint8_t pin, uint8_t mode) override { + if (pin < NUM_PINS) { + pin_modes_[pin] = mode; + // INPUT_PULLUP defaults to HIGH + if (mode == INPUT_PULLUP) { + pin_values_[pin] = HIGH; + } + } + } + + void digitalWrite(uint8_t pin, uint8_t value) override { + if (pin < NUM_PINS) { + pin_values_[pin] = value; + gpio_log_.push_back({clock_ms_, pin, value}); + } + } + + uint8_t digitalRead(uint8_t pin) override { + if (pin < NUM_PINS) return pin_values_[pin]; + return LOW; + } + + int analogRead(uint8_t pin) override { + if (pin < NUM_PINS) return analog_values_[pin]; + return 0; + } + + void analogWrite(uint8_t pin, int value) override { + if (pin < NUM_PINS) pin_values_[pin] = (value > 0) ? HIGH : LOW; + } + + // -- Timing ------------------------------------------------------------- + unsigned long millis() override { return clock_ms_; } + unsigned long micros() override { return clock_us_; } + void delay(unsigned long ms) override { advanceMillis(ms); } + void delayMicroseconds(unsigned long us) override { clock_us_ += us; } + + // -- Serial ------------------------------------------------------------- + void serialBegin(unsigned long baud) override { (void)baud; } + void serialPrint(const char* msg) override { + serial_output_ += msg; + } + void serialPrintln(const char* msg) override { + serial_output_ += msg; + serial_output_ += "\n"; + } + int serialAvailable() override { + return static_cast(serial_input_.size()); + } + int serialRead() override { + if (serial_input_.empty()) return -1; + int c = serial_input_.front(); + serial_input_.erase(serial_input_.begin()); + return c; + } + + // -- I2C ---------------------------------------------------------------- + void i2cBegin() override {} + + void i2cBeginTransmission(uint8_t addr) override { + i2c_addr_ = addr; + i2c_tx_buf_.clear(); + } + + size_t i2cWrite(uint8_t data) override { + i2c_tx_buf_.push_back(data); + return 1; + } + + uint8_t i2cEndTransmission() override { + auto it = i2c_devices_.find(i2c_addr_); + if (it == i2c_devices_.end()) return 2; // NACK on address + it->second->onReceive(i2c_tx_buf_.data(), i2c_tx_buf_.size()); + return 0; // success + } + + uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) override { + i2c_rx_buf_.clear(); + auto it = i2c_devices_.find(addr); + if (it == i2c_devices_.end()) return 0; + uint8_t tmp[256]; + size_t n = it->second->onRequest(tmp, count); + for (size_t i = 0; i < n; ++i) { + i2c_rx_buf_.push_back(tmp[i]); + } + return static_cast(n); + } + + int i2cAvailable() override { + return static_cast(i2c_rx_buf_.size()); + } + + int i2cRead() override { + if (i2c_rx_buf_.empty()) return -1; + int val = i2c_rx_buf_.front(); + i2c_rx_buf_.erase(i2c_rx_buf_.begin()); + return val; + } + + // ==================================================================== + // Test control API (not part of Hal interface) + // ==================================================================== + + // -- Clock control ------------------------------------------------------ + void advanceMillis(unsigned long ms) { + clock_ms_ += ms; + clock_us_ += ms * 1000; + } + + void setMillis(unsigned long ms) { + clock_ms_ = ms; + clock_us_ = ms * 1000; + } + + // -- GPIO control ------------------------------------------------------- + void setPin(uint8_t pin, uint8_t value) { + if (pin < NUM_PINS) pin_values_[pin] = value; + } + + uint8_t getPin(uint8_t pin) const { + if (pin < NUM_PINS) return pin_values_[pin]; + return LOW; + } + + uint8_t getPinMode(uint8_t pin) const { + if (pin < NUM_PINS) return pin_modes_[pin]; + return 0; + } + + void setAnalog(uint8_t pin, int value) { + analog_values_[pin] = value; + } + + // -- GPIO history ------------------------------------------------------- + struct GpioEvent { + unsigned long timestamp_ms; + uint8_t pin; + uint8_t value; + }; + + const std::vector& gpioLog() const { return gpio_log_; } + + void clearGpioLog() { gpio_log_.clear(); } + + // Count how many times a pin was set to a specific value + int countWrites(uint8_t pin, uint8_t value) const { + int count = 0; + for (const auto& e : gpio_log_) { + if (e.pin == pin && e.value == value) ++count; + } + return count; + } + + // -- Serial control ----------------------------------------------------- + const std::string& serialOutput() const { return serial_output_; } + void clearSerialOutput() { serial_output_.clear(); } + + void injectSerialInput(const std::string& data) { + for (char c : data) { + serial_input_.push_back(static_cast(c)); + } + } + + // -- I2C device registration -------------------------------------------- + void attachI2cDevice(uint8_t addr, I2cDeviceSim* device) { + i2c_devices_[addr] = device; + } + + void detachI2cDevice(uint8_t addr) { + i2c_devices_.erase(addr); + } + +private: + // GPIO + uint8_t pin_modes_[NUM_PINS]; + uint8_t pin_values_[NUM_PINS]; + std::map analog_values_; + std::vector gpio_log_; + + // Timing + unsigned long clock_ms_; + unsigned long clock_us_; + + // Serial + std::string serial_output_; + std::vector serial_input_; + + // I2C + uint8_t i2c_addr_ = 0; + std::vector i2c_tx_buf_; + std::vector i2c_rx_buf_; + std::map i2c_devices_; +}; + +#endif // SIM_HAL_H diff --git a/test/run_tests.sh b/test/run_tests.sh new file mode 100755 index 0000000..a064b64 --- /dev/null +++ b/test/run_tests.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# run_tests.sh -- Build and run host-side unit and system tests +# +# Usage: +# ./test/run_tests.sh Build and run all tests +# ./test/run_tests.sh --clean Clean rebuild +# ./test/run_tests.sh --verbose Verbose test output +# ./test/run_tests.sh --filter X Run only tests matching X +# +# Prerequisites: +# cmake >= 3.14, g++ or clang++, git (for fetching gtest) +# +# First run will download Google Test (~30 seconds). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" + +# Color output +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m' + BLD=$'\033[1m'; RST=$'\033[0m' +else + RED=''; GRN=''; CYN=''; BLD=''; RST='' +fi + +info() { echo -e "${CYN}[TEST]${RST} $*"; } +ok() { echo -e "${GRN}[PASS]${RST} $*"; } +die() { echo -e "${RED}[FAIL]${RST} $*" >&2; exit 1; } + +# Parse args +DO_CLEAN=0 +VERBOSE="" +FILTER="" + +for arg in "$@"; do + case "$arg" in + --clean) DO_CLEAN=1 ;; + --verbose) VERBOSE="--verbose" ;; + --filter) : ;; # next arg is the pattern + -*) die "Unknown option: $arg" ;; + *) FILTER="$arg" ;; + esac +done + +# Check prerequisites +command -v cmake &>/dev/null || die "cmake not found. Install: sudo apt install cmake" +command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found" +command -v git &>/dev/null || die "git not found (needed to fetch Google Test)" + +# Clean if requested +if [[ $DO_CLEAN -eq 1 ]] && [[ -d "$BUILD_DIR" ]]; then + info "Cleaning build directory..." + rm -rf "$BUILD_DIR" +fi + +# Configure +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + info "Configuring (first run will fetch Google Test)..." + cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug +fi + +# Build +info "Building tests..." +cmake --build "$BUILD_DIR" --parallel + +# Run +echo "" +info "${BLD}Running tests...${RST}" +echo "" + +CTEST_ARGS=("--test-dir" "$BUILD_DIR" "--output-on-failure") +[[ -n "$VERBOSE" ]] && CTEST_ARGS+=("--verbose") +[[ -n "$FILTER" ]] && CTEST_ARGS+=("-R" "$FILTER") + +if ctest "${CTEST_ARGS[@]}"; then + echo "" + ok "${BLD}All tests passed.${RST}" +else + echo "" + die "Some tests failed." +fi diff --git a/test/test_blink_system.cpp b/test/test_blink_system.cpp new file mode 100644 index 0000000..4882118 --- /dev/null +++ b/test/test_blink_system.cpp @@ -0,0 +1,201 @@ +#include + +#include "hal.h" +#include "sim_hal.h" +#include "blink_app.h" + +// ============================================================================ +// System Tests -- exercise full app behavior against simulated hardware +// ============================================================================ + +class BlinkAppSystemTest : public ::testing::Test { +protected: + SimHal sim_; +}; + +// ----------------------------------------------------------------------- +// Basic blink behavior +// ----------------------------------------------------------------------- + +TEST_F(BlinkAppSystemTest, LedBlinksAtSlowRate) { + BlinkApp app(&sim_); + app.begin(); + + // Run for 2 seconds, calling update every 10ms (like a real loop) + for (int i = 0; i < 200; ++i) { + sim_.advanceMillis(10); + app.update(); + } + + // At 500ms intervals over 2000ms, we expect ~4 toggles + // (0->500: toggle, 500->1000: toggle, 1000->1500: toggle, 1500->2000: toggle) + int highs = sim_.countWrites(LED_BUILTIN, HIGH); + int lows = sim_.countWrites(LED_BUILTIN, LOW); + EXPECT_GE(highs, 2); + EXPECT_GE(lows, 2); + EXPECT_LE(highs + lows, 6); // should not over-toggle +} + +TEST_F(BlinkAppSystemTest, LedBlinksAtFastRateAfterButtonPress) { + BlinkApp app(&sim_, LED_BUILTIN, 2); + app.begin(); + + // Button starts HIGH (INPUT_PULLUP) + EXPECT_EQ(sim_.getPin(2), HIGH); + + // Run 1 second in slow mode + for (int i = 0; i < 100; ++i) { + sim_.advanceMillis(10); + app.update(); + } + + int slow_toggles = sim_.gpioLog().size(); + + // Press button + sim_.setPin(2, LOW); + sim_.advanceMillis(10); + app.update(); + EXPECT_TRUE(app.fastMode()); + + // Release button + sim_.setPin(2, HIGH); + sim_.advanceMillis(10); + app.update(); + + // Clear log and run another second in fast mode + sim_.clearGpioLog(); + for (int i = 0; i < 100; ++i) { + sim_.advanceMillis(10); + app.update(); + } + + int fast_toggles = sim_.gpioLog().size(); + + // Fast mode (125ms) should produce roughly 4x more toggles than slow (500ms) + EXPECT_GT(fast_toggles, slow_toggles * 2); +} + +// ----------------------------------------------------------------------- +// Button edge detection +// ----------------------------------------------------------------------- + +TEST_F(BlinkAppSystemTest, ButtonDebounceOnlyTriggersOnFallingEdge) { + BlinkApp app(&sim_, LED_BUILTIN, 2); + app.begin(); + + // Rapid button noise: HIGH-LOW-HIGH-LOW-HIGH + uint8_t sequence[] = {HIGH, LOW, HIGH, LOW, HIGH}; + int mode_changes = 0; + bool was_fast = false; + + for (uint8_t val : sequence) { + sim_.setPin(2, val); + sim_.advanceMillis(10); + app.update(); + if (app.fastMode() != was_fast) { + mode_changes++; + was_fast = app.fastMode(); + } + } + + // Each HIGH->LOW transition is a falling edge, so 2 edges + // (positions 0->1 and 2->3), toggling fast->slow->fast or slow->fast->slow + EXPECT_EQ(mode_changes, 2); +} + +TEST_F(BlinkAppSystemTest, ButtonHeldDoesNotRepeatToggle) { + BlinkApp app(&sim_, LED_BUILTIN, 2); + app.begin(); + + // Press and hold for 50 update cycles + sim_.setPin(2, LOW); + for (int i = 0; i < 50; ++i) { + sim_.advanceMillis(10); + app.update(); + } + + // Should have toggled exactly once (to fast mode) + EXPECT_TRUE(app.fastMode()); + + // Release and hold released for 50 cycles + sim_.setPin(2, HIGH); + for (int i = 0; i < 50; ++i) { + sim_.advanceMillis(10); + app.update(); + } + + // Still fast -- release alone should not toggle + EXPECT_TRUE(app.fastMode()); +} + +// ----------------------------------------------------------------------- +// Serial output verification +// ----------------------------------------------------------------------- + +TEST_F(BlinkAppSystemTest, PrintsStartupMessage) { + BlinkApp app(&sim_); + app.begin(); + + EXPECT_NE(sim_.serialOutput().find("BlinkApp started"), std::string::npos); +} + +TEST_F(BlinkAppSystemTest, PrintsModeChangeMessages) { + BlinkApp app(&sim_, LED_BUILTIN, 2); + app.begin(); + sim_.clearSerialOutput(); + + // Press button + sim_.setPin(2, LOW); + sim_.advanceMillis(10); + app.update(); + + EXPECT_NE(sim_.serialOutput().find("FAST"), std::string::npos); + + sim_.setPin(2, HIGH); + sim_.advanceMillis(10); + app.update(); + sim_.clearSerialOutput(); + + // Press again + sim_.setPin(2, LOW); + sim_.advanceMillis(10); + app.update(); + + EXPECT_NE(sim_.serialOutput().find("SLOW"), std::string::npos); +} + +// ----------------------------------------------------------------------- +// GPIO log / timing verification +// ----------------------------------------------------------------------- + +TEST_F(BlinkAppSystemTest, LedToggleTimingIsCorrect) { + BlinkApp app(&sim_); + app.begin(); + + // Run for 3 seconds at 1ms resolution + for (int i = 0; i < 3000; ++i) { + sim_.advanceMillis(1); + app.update(); + } + + const auto& log = sim_.gpioLog(); + ASSERT_GE(log.size(), 4u); + + // Check intervals between consecutive toggles on pin 13 + for (size_t i = 1; i < log.size(); ++i) { + if (log[i].pin == LED_BUILTIN && log[i - 1].pin == LED_BUILTIN) { + unsigned long delta = log[i].timestamp_ms - log[i - 1].timestamp_ms; + // Should be approximately 500ms (+/- 10ms for loop granularity) + EXPECT_NEAR(delta, 500, 10) + << "Toggle " << i << " at t=" << log[i].timestamp_ms; + } + } +} + +TEST_F(BlinkAppSystemTest, PinModesAreConfiguredCorrectly) { + BlinkApp app(&sim_, LED_BUILTIN, 2); + app.begin(); + + EXPECT_EQ(sim_.getPinMode(LED_BUILTIN), OUTPUT); + EXPECT_EQ(sim_.getPinMode(2), INPUT_PULLUP); +} diff --git a/test/test_blink_unit.cpp b/test/test_blink_unit.cpp new file mode 100644 index 0000000..341dade --- /dev/null +++ b/test/test_blink_unit.cpp @@ -0,0 +1,132 @@ +#include +#include + +#include "hal.h" +#include "mock_hal.h" +#include "blink_app.h" + +using ::testing::_; +using ::testing::AnyNumber; +using ::testing::Return; +using ::testing::InSequence; +using ::testing::HasSubstr; + +// ============================================================================ +// Unit Tests -- verify exact HAL interactions +// ============================================================================ + +class BlinkAppUnitTest : public ::testing::Test { +protected: + void SetUp() override { + // Allow timing and serial calls by default so tests can focus + // on the specific interactions they care about. + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(_)).WillByDefault(Return(HIGH)); + EXPECT_CALL(mock_, serialBegin(_)).Times(AnyNumber()); + EXPECT_CALL(mock_, serialPrintln(_)).Times(AnyNumber()); + EXPECT_CALL(mock_, millis()).Times(AnyNumber()); + } + + ::testing::NiceMock mock_; +}; + +TEST_F(BlinkAppUnitTest, BeginConfiguresPins) { + BlinkApp app(&mock_, 13, 2); + + EXPECT_CALL(mock_, pinMode(13, OUTPUT)).Times(1); + EXPECT_CALL(mock_, pinMode(2, INPUT_PULLUP)).Times(1); + EXPECT_CALL(mock_, serialBegin(115200)).Times(1); + + app.begin(); +} + +TEST_F(BlinkAppUnitTest, StartsInSlowMode) { + BlinkApp app(&mock_); + app.begin(); + + EXPECT_FALSE(app.fastMode()); + EXPECT_EQ(app.interval(), BlinkApp::SLOW_INTERVAL_MS); +} + +TEST_F(BlinkAppUnitTest, TogglesLedAfterInterval) { + BlinkApp app(&mock_); + + // begin() at t=0 + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + app.begin(); + + // update() at t=500 should toggle LED + ON_CALL(mock_, millis()).WillByDefault(Return(500)); + EXPECT_CALL(mock_, digitalWrite(13, _)).Times(1); + app.update(); +} + +TEST_F(BlinkAppUnitTest, DoesNotToggleBeforeInterval) { + BlinkApp app(&mock_); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + app.begin(); + + // update() at t=499 -- not enough time, no toggle + ON_CALL(mock_, millis()).WillByDefault(Return(499)); + EXPECT_CALL(mock_, digitalWrite(_, _)).Times(0); + app.update(); +} + +TEST_F(BlinkAppUnitTest, ButtonPressSwitchesToFastMode) { + BlinkApp app(&mock_, 13, 2); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.begin(); + + // Simulate button press (falling edge: HIGH -> LOW) + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + EXPECT_CALL(mock_, serialPrintln(HasSubstr("FAST"))).Times(1); + app.update(); + + EXPECT_TRUE(app.fastMode()); + EXPECT_EQ(app.interval(), BlinkApp::FAST_INTERVAL_MS); +} + +TEST_F(BlinkAppUnitTest, SecondButtonPressReturnsToSlowMode) { + BlinkApp app(&mock_, 13, 2); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.begin(); + + // First press: fast mode + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + app.update(); + EXPECT_TRUE(app.fastMode()); + + // Release + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.update(); + + // Second press: back to slow + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + EXPECT_CALL(mock_, serialPrintln(HasSubstr("SLOW"))).Times(1); + app.update(); + + EXPECT_FALSE(app.fastMode()); +} + +TEST_F(BlinkAppUnitTest, HoldingButtonDoesNotRepeatToggle) { + BlinkApp app(&mock_, 13, 2); + + ON_CALL(mock_, millis()).WillByDefault(Return(0)); + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH)); + app.begin(); + + // Press and hold + ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW)); + app.update(); + EXPECT_TRUE(app.fastMode()); + + // Still held -- should NOT toggle again + app.update(); + app.update(); + EXPECT_TRUE(app.fastMode()); // still fast, not toggled back +} diff --git a/test/test_i2c_example.cpp b/test/test_i2c_example.cpp new file mode 100644 index 0000000..562302a --- /dev/null +++ b/test/test_i2c_example.cpp @@ -0,0 +1,140 @@ +#include + +#include "hal.h" +#include "sim_hal.h" + +// ============================================================================ +// Example: I2C Temperature Sensor Simulator +// +// Demonstrates how to mock an I2C device. This simulates a simple +// temperature sensor at address 0x48 (like a TMP102 or LM75). +// +// Protocol (simplified): +// Write register pointer (1 byte), then read 2 bytes back. +// Register 0x00 = temperature (MSB:LSB, 12-bit, 0.0625 deg/LSB) +// Register 0x01 = config register +// ============================================================================ + +class TempSensorSim : public I2cDeviceSim { +public: + static const uint8_t ADDR = 0x48; + static const uint8_t REG_TEMP = 0x00; + static const uint8_t REG_CONFIG = 0x01; + + TempSensorSim() : reg_pointer_(0), temp_raw_(0) {} + + // Set temperature in degrees C (converted to 12-bit raw) + void setTemperature(float deg_c) { + temp_raw_ = static_cast(deg_c / 0.0625f); + } + + // I2cDeviceSim interface + void onReceive(const uint8_t* data, size_t len) override { + if (len >= 1) { + reg_pointer_ = data[0]; + } + } + + size_t onRequest(uint8_t* buf, size_t max_len) override { + if (max_len < 2) return 0; + + if (reg_pointer_ == REG_TEMP) { + // 12-bit left-aligned in 16 bits + int16_t raw = temp_raw_ << 4; + buf[0] = static_cast((raw >> 8) & 0xFF); + buf[1] = static_cast(raw & 0xFF); + return 2; + } + if (reg_pointer_ == REG_CONFIG) { + buf[0] = 0x60; // default config + buf[1] = 0xA0; + return 2; + } + return 0; + } + +private: + uint8_t reg_pointer_; + int16_t temp_raw_; +}; + +// ----------------------------------------------------------------------- +// Helper: read temperature the way real Arduino code would +// ----------------------------------------------------------------------- +static float readTemperature(Hal* hal, uint8_t addr) { + // Set register pointer to 0x00 (temperature) + hal->i2cBeginTransmission(addr); + hal->i2cWrite(TempSensorSim::REG_TEMP); + hal->i2cEndTransmission(); + + // Read 2 bytes + uint8_t count = hal->i2cRequestFrom(addr, 2); + if (count < 2) return -999.0f; + + uint8_t msb = static_cast(hal->i2cRead()); + uint8_t lsb = static_cast(hal->i2cRead()); + + // Convert 12-bit left-aligned to temperature + int16_t raw = (static_cast(msb) << 8) | lsb; + raw >>= 4; // right-align the 12 bits + return raw * 0.0625f; +} + +// ============================================================================ +// Tests +// ============================================================================ + +class I2cTempSensorTest : public ::testing::Test { +protected: + void SetUp() override { + sensor_.setTemperature(25.0f); + sim_.attachI2cDevice(TempSensorSim::ADDR, &sensor_); + sim_.i2cBegin(); + } + + SimHal sim_; + TempSensorSim sensor_; +}; + +TEST_F(I2cTempSensorTest, ReadsRoomTemperature) { + sensor_.setTemperature(25.0f); + float temp = readTemperature(&sim_, TempSensorSim::ADDR); + EXPECT_NEAR(temp, 25.0f, 0.1f); +} + +TEST_F(I2cTempSensorTest, ReadsFreezing) { + sensor_.setTemperature(0.0f); + float temp = readTemperature(&sim_, TempSensorSim::ADDR); + EXPECT_NEAR(temp, 0.0f, 0.1f); +} + +TEST_F(I2cTempSensorTest, ReadsNegativeTemperature) { + sensor_.setTemperature(-10.5f); + float temp = readTemperature(&sim_, TempSensorSim::ADDR); + EXPECT_NEAR(temp, -10.5f, 0.1f); +} + +TEST_F(I2cTempSensorTest, ReadsHighTemperature) { + sensor_.setTemperature(85.0f); + float temp = readTemperature(&sim_, TempSensorSim::ADDR); + EXPECT_NEAR(temp, 85.0f, 0.1f); +} + +TEST_F(I2cTempSensorTest, UnregisteredDeviceReturnsNack) { + // Try to talk to a device that does not exist + sim_.i2cBeginTransmission(0x50); + sim_.i2cWrite(0x00); + uint8_t result = sim_.i2cEndTransmission(); + EXPECT_NE(result, 0); // non-zero = error +} + +TEST_F(I2cTempSensorTest, TemperatureUpdatesLive) { + sensor_.setTemperature(20.0f); + float t1 = readTemperature(&sim_, TempSensorSim::ADDR); + + sensor_.setTemperature(30.0f); + float t2 = readTemperature(&sim_, TempSensorSim::ADDR); + + EXPECT_NEAR(t1, 20.0f, 0.1f); + EXPECT_NEAR(t2, 30.0f, 0.1f); +}