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
This commit is contained in:
Eric Ratliff
2026-02-14 09:25:49 -06:00
committed by Eric Ratliff
commit 991b9a8ee9
19 changed files with 2438 additions and 0 deletions

14
.clang-format Normal file
View File

@@ -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
---

24
.editorconfig Normal file
View File

@@ -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

29
.gitattributes vendored Normal file
View File

@@ -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

28
.gitignore vendored Normal file
View File

@@ -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

36
.vscode/settings.json vendored Normal file
View File

@@ -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
}
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nexus Workshops LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

79
README.md Normal file
View File

@@ -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
```

999
arduino-build.sh Executable file
View File

@@ -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 <<EOF
${BLD}NAME${RST}
arduino-build.sh - Build, upload, and monitor Arduino sketches
${BLD}VERSION${RST}
${VERSION}
${BLD}SYNOPSIS${RST}
./arduino-build.sh ${BLD}--setup${RST}
./arduino-build.sh ${BLD}--devices${RST}
./arduino-build.sh [OPTIONS] <sketch_dir>
${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 <<EOF
${BLD}STANDALONE COMMANDS${RST}
These run independently. No sketch directory needed.
${BLD}-h, --help${RST}
Show this help and exit. You are reading it now.
${BLD}--version${RST}
Print version string and exit.
${BLD}--setup${RST}
First-time setup wizard. Installs the arduino:avr core, checks
that your user is in the dialout group (serial port access), and
verifies the toolchain. Run once after installing arduino-cli.
${BLD}--devices${RST}
Scan for connected boards. For each /dev/ttyUSB* and /dev/ttyACM*
port found, shows:
- USB vendor and product strings
- USB vendor:product ID (hex)
- Kernel driver in use (ch341-uart, cdc_acm, etc.)
- Best guess at what board it is
- Whether you have write permission to the port
Also runs 'arduino-cli board list' for cross-reference.
${BLD}--watch${RST}
Open a persistent serial monitor that automatically reconnects
when the port disappears and reappears (e.g., during upload from
another terminal). Run in one terminal, build/upload in another.
Survives: upload resets, board power cycles, USB re-plugs.
Exit with Ctrl+C.
Combine with -p and -b:
./arduino-build.sh --watch -p /dev/ttyUSB0 -b 9600
EOF
}
_help_section_build_options() {
cat <<EOF
${BLD}BUILD & UPLOAD OPTIONS${RST}
${BLD}--verify${RST}
Compile only -- do not upload. Good for syntax checking, CI
pipelines, or when the board is not plugged in. Still reports
binary size so you can track flash usage.
${BLD}--upload${RST}
Compile and upload. This is the default when you pass a sketch
directory, so you almost never need to type it explicitly.
${BLD}--upload-only${RST}
Upload previously compiled artifacts without recompiling. Requires
a prior --verify or build to have cached the .hex file. Useful for
separating build and flash steps:
Terminal 1: ./arduino-build.sh --verify ./blink (compile)
Terminal 2: ./arduino-build.sh --upload-only ./blink (flash)
Fails if no cached build exists for the sketch.
${BLD}--monitor${RST}
After a successful upload, automatically open the serial monitor
at the configured baud rate. Ctrl+C exits the monitor. Combines
the build-upload-monitor cycle into a single command.
${BLD}--clean${RST}
Delete cached build artifacts for this sketch before compiling.
Forces a full recompile from scratch. Use when you suspect stale
object files or after changing compiler flags.
${BLD}--verbose${RST}
Show full compiler and avrdude output. Helpful for debugging
linker errors, upload failures, or library conflicts.
EOF
}
_help_section_config_options() {
cat <<EOF
${BLD}CONFIGURATION OPTIONS${RST}
${BLD}-p, --port PORT${RST}
Use this serial port instead of auto-detecting.
Example: -p /dev/ttyUSB0
${BLD}-b, --baud RATE${RST}
Serial monitor baud rate (default: 115200). Only affects the
--monitor feature; upload speed is set by the bootloader.
Common values: 9600, 19200, 38400, 57600, 115200
${BLD}--fqbn FQBN${RST}
Override the Fully Qualified Board Name. Default: arduino:avr:uno
Common FQBNs:
arduino:avr:uno Uno / RedBoard
arduino:avr:mega:cpu=atmega2560 Mega 2560
arduino:avr:nano:cpu=atmega328 Nano (old bootloader)
arduino:avr:nano:cpu=atmega328old Nano (new bootloader)
esp32:esp32:esp32 ESP32 DevKit
SparkFun:avr:RedBoard SparkFun board package
To list all installed FQBNs:
arduino-cli board listall
EOF
}
_help_section_env_vars() {
cat <<EOF
${BLD}ENVIRONMENT VARIABLES${RST}
Override defaults without editing the script or passing flags.
Variable Default What it does
-------- ------- ------------
ARDUINO_FQBN arduino:avr:uno Board identifier
ARDUINO_BAUD 115200 Serial monitor baud rate
ARDUINO_BUILD_DIR /tmp/arduino-build Where .o/.elf/.hex go
ARDUINO_CLI_BIN arduino-cli Path to arduino-cli binary
EOF
}
_help_section_examples() {
cat <<EOF
${BLD}EXAMPLES${RST}
${DIM}# --- Getting started (do these once) ---${RST}
./arduino-build.sh --setup
Install the AVR toolchain and verify permissions.
./arduino-build.sh --devices
See what boards are plugged in and which port to use.
${DIM}# --- Day-to-day usage ---${RST}
./arduino-build.sh ./blink
Compile and upload blink sketch. Port auto-detected.
./arduino-build.sh --verify ./blink
Compile only. Good for checking code on a laptop with no board.
./arduino-build.sh -p /dev/ttyUSB0 ./blink
Upload to a specific port (skip auto-detection).
./arduino-build.sh --monitor ./blink
Build, upload, and open serial monitor in one shot.
./arduino-build.sh --clean --verbose ./blink
Nuke the cache and rebuild with full compiler output.
${DIM}# --- Two-terminal workflow (monitor + build separately) ---${RST}
${DIM}# Terminal 1: persistent monitor that reconnects after uploads${RST}
./arduino-build.sh --watch
${DIM}# Terminal 2: build and upload (monitor in T1 reconnects)${RST}
./arduino-build.sh ./blink
${DIM}# --- Split compile and upload (CI / unit test workflow) ---${RST}
./arduino-build.sh --verify ./blink
Compile and cache artifacts. No board needed.
./arduino-build.sh --upload-only ./blink
Flash cached artifacts. No recompile.
${DIM}# --- Different boards ---${RST}
./arduino-build.sh --fqbn arduino:avr:mega:cpu=atmega2560 ./my_sketch
Upload to an Arduino Mega instead of a RedBoard.
ARDUINO_FQBN=esp32:esp32:esp32 ./arduino-build.sh ./my_esp_sketch
Upload to an ESP32 board via environment variable.
EOF
}
_help_section_cheatsheet() {
cat <<EOF
${BLD}QUICK REFERENCE (tape this to your monitor)${RST}
Command What it does
------- ------------
--help You are here
--setup One-time toolchain install
--devices What's plugged in?
--verify ./sketch Does it compile?
./sketch Build + upload (auto port)
--upload-only ./sketch Flash cached build (no compile)
-p /dev/ttyUSB0 ./sketch Build + upload (manual port)
--monitor ./sketch Build + upload + serial console
--watch Persistent monitor (reconnects)
--clean ./sketch Full rebuild (no cache)
--verbose ./sketch Show all compiler output
${BLD}Two-terminal workflow:${RST}
Terminal 1: ./arduino-build.sh --watch
Terminal 2: ./arduino-build.sh ./blink (watch reconnects)
EOF
}
_help_section_sketch_layout() {
cat <<EOF
${BLD}SKETCH DIRECTORY LAYOUT${RST}
Arduino requires the .ino filename to match the directory name:
my_sketch/ <-- pass this directory to the script
my_sketch.ino <-- main sketch file (name must match dir)
config.h <-- optional extra files
utils.cpp <-- optional extra files
Quick way to create a new sketch:
mkdir blink
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 <<EOF
${BLD}SPARKFUN REDBOARD SPECS${RST}
MCU: ATmega328P at 16 MHz
Flash: 32 KB total (31.5 KB usable; 512 B bootloader)
SRAM: 2 KB (runtime variables and stack)
EEPROM: 1 KB (persistent storage)
Digital I/O: 14 pins (6 with PWM on pins 3, 5, 6, 9, 10, 11)
Analog inputs: 6 pins (A0-A5, 10-bit ADC)
USB chip: CH340G (shows up as /dev/ttyUSB*)
Voltage: 5V logic, 7-15V barrel jack input
FQBN: arduino:avr:uno (same as Arduino Uno)
The RedBoard is electrically identical to an Arduino Uno. The only
real difference is the CH340 USB-to-serial chip (Uno uses ATmega16U2).
The CH340 shows up as /dev/ttyUSB* while the 16U2 shows as /dev/ttyACM*.
EOF
}
_help_section_port_permissions() {
cat <<EOF
${BLD}SERIAL PORT PERMISSIONS${RST}
If you get "Permission denied" on /dev/ttyUSB0 or /dev/ttyACM0:
sudo usermod -aG dialout \$USER
Then ${BLD}log out and back in${RST} (or reboot). Verify with:
groups | grep dialout
Alternatively, for a quick one-off test:
sudo chmod 666 /dev/ttyUSB0 (resets on reboot/replug)
EOF
}
_help_section_first_time() {
cat <<EOF
${BLD}FIRST-TIME INSTALL (step by step)${RST}
1. Install arduino-cli:
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
sudo mv bin/arduino-cli /usr/local/bin/
2. Run first-time setup:
./arduino-build.sh --setup
3. Plug in your RedBoard and verify detection:
./arduino-build.sh --devices
4. Create a test sketch:
mkdir blink && 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 <<EOF
${BLD}TROUBLESHOOTING${RST}
${BLD}Board not detected (--devices shows nothing)${RST}
- Try a different USB cable. Many cables are charge-only with no
data lines. If your phone does not offer file transfer with the
cable, it is probably charge-only.
- Check the USB bus directly:
lsusb | grep -i -E 'ch34|arduino|1a86|2341'
- Check kernel log for attach/detach events:
dmesg | tail -20
- Try a different USB port (front panel ports can be flaky).
${BLD}Upload fails: "avrdude: stk500_getsync(): not in sync"${RST}
- Most common cause: wrong port. Run --devices to double-check.
- Close any other program using the port (serial monitors, other
IDE instances, screen sessions, etc.)
- Try pressing the board reset button just before upload starts.
- Bad or loose USB cable.
${BLD}"Sketch uses N bytes ... maximum is 32256 bytes"${RST}
- The ATmega328P has 32,256 bytes for program storage.
- Use --verify to check size without uploading.
- Tips to reduce size: use F() macro for string literals,
avoid the String class (use char arrays), remove unused libraries.
- If you genuinely need more space, use a Mega 2560 (256 KB).
${BLD}Compilation errors about missing libraries${RST}
- Search: arduino-cli lib search "Servo"
- Install: arduino-cli lib install "Servo"
- List: arduino-cli lib list
${BLD}Serial monitor shows garbage characters${RST}
- Baud rate mismatch. Make sure -b/--baud matches the
Serial.begin() value in your sketch. Default here is 115200.
${BLD}CH340 driver not loading (Linux)${RST}
- Most kernels since 4.x include ch341 out of the box.
- Verify: lsmod | grep ch341
- If missing: sudo modprobe ch341
- Still gone: sudo apt install linux-modules-extra-\$(uname -r)
EOF
}
_help_section_usb_ids() {
cat <<EOF
${BLD}KNOWN USB VENDOR:PRODUCT IDS${RST}
Used by --devices to identify boards.
ID Board / Chip
---- ------------
1a86:7523 CH340 (SparkFun RedBoard, most Arduino clones)
1a86:55d4 CH340C (RedBoard Qwiic, newer clones)
2341:0043 Arduino Uno R3 (ATmega16U2)
2341:0001 Arduino Uno (older revision)
2341:0010 Arduino Mega 2560
2341:0042 Arduino Mega 2560 R3
2341:003d Arduino Due (Programming Port)
2341:003e Arduino Due (Native USB)
2341:8036 Arduino Leonardo
2341:8037 Arduino Micro
0403:6001 FTDI FT232R (older RedBoard, FTDI clones)
0403:6015 FTDI FT231X (SparkFun FTDI breakout)
10c4:ea60 CP2102 (NodeMCU, some ESP32 boards)
EOF
}
# ============================================================================
# DEVICE SCANNER
# ============================================================================
scan_devices() {
echo ""
echo -e "${BLD}=== Connected Serial Devices ===${RST}"
echo ""
local found=0
for port in /dev/ttyUSB* /dev/ttyACM*; do
[[ -e "$port" ]] || continue
found=1
echo -e " ${BLD}${GRN}$port${RST}"
local sysfs_path="" usb_dev_path=""
# Find the kernel driver for this tty
local driver=""
local tty_device="/sys/class/tty/$(basename "$port")/device/driver"
if [[ -L "$tty_device" ]]; then
driver="$(basename "$(readlink -f "$tty_device")")"
fi
# Walk up from the tty device node to find the USB device node.
# The tty sits under a USB interface node; idVendor/idProduct
# live on the USB device node which is one or more levels above.
if [[ -e "/sys/class/tty/$(basename "$port")/device" ]]; then
sysfs_path="$(readlink -f "/sys/class/tty/$(basename "$port")/device")"
usb_dev_path="$sysfs_path"
# Walk up until we find idVendor (max 5 levels to avoid runaway)
local walk=0
while [[ $walk -lt 5 ]] && [[ "$usb_dev_path" != "/" ]]; do
if [[ -f "$usb_dev_path/idVendor" ]]; then
break
fi
usb_dev_path="$(dirname "$usb_dev_path")"
walk=$((walk + 1))
done
# If we didn't find idVendor, clear the path
if [[ ! -f "$usb_dev_path/idVendor" ]]; then
usb_dev_path=""
fi
fi
if [[ -n "$usb_dev_path" ]]; then
local vendor="" product="" vid="" pid="" serial=""
[[ -f "$usb_dev_path/manufacturer" ]] && vendor="$(cat "$usb_dev_path/manufacturer" 2>/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] <sketch_dir>"
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

32
blink/blink.ino Normal file
View File

@@ -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 <hal_arduino.h>
#include <blink_app.h>
static ArduinoHal hw;
static BlinkApp app(&hw);
void setup() {
app.begin();
}
void loop() {
app.update();
}

88
lib/app/blink_app.h Normal file
View File

@@ -0,0 +1,88 @@
#ifndef BLINK_APP_H
#define BLINK_APP_H
#include <hal.h>
/*
* 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

67
lib/hal/hal.h Normal file
View File

@@ -0,0 +1,67 @@
#ifndef HAL_H
#define HAL_H
#include <stdint.h>
// 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

93
lib/hal/hal_arduino.h Normal file
View File

@@ -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 <Arduino.h>
#include <Wire.h>
#include <hal.h>
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

70
test/CMakeLists.txt Normal file
View File

@@ -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)

45
test/mocks/mock_hal.h Normal file
View File

@@ -0,0 +1,45 @@
#ifndef MOCK_HAL_H
#define MOCK_HAL_H
#include <gmock/gmock.h>
#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

256
test/mocks/sim_hal.h Normal file
View File

@@ -0,0 +1,256 @@
#ifndef SIM_HAL_H
#define SIM_HAL_H
#include "hal.h"
#include <cstdio>
#include <cstring>
#include <functional>
#include <map>
#include <queue>
#include <string>
#include <vector>
/*
* 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<int>(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<uint8_t>(n);
}
int i2cAvailable() override {
return static_cast<int>(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<GpioEvent>& 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<uint8_t>(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<uint8_t, int> analog_values_;
std::vector<GpioEvent> gpio_log_;
// Timing
unsigned long clock_ms_;
unsigned long clock_us_;
// Serial
std::string serial_output_;
std::vector<uint8_t> serial_input_;
// I2C
uint8_t i2c_addr_ = 0;
std::vector<uint8_t> i2c_tx_buf_;
std::vector<uint8_t> i2c_rx_buf_;
std::map<uint8_t, I2cDeviceSim*> i2c_devices_;
};
#endif // SIM_HAL_H

84
test/run_tests.sh Executable file
View File

@@ -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

201
test/test_blink_system.cpp Normal file
View File

@@ -0,0 +1,201 @@
#include <gtest/gtest.h>
#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);
}

132
test/test_blink_unit.cpp Normal file
View File

@@ -0,0 +1,132 @@
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#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<MockHal> 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
}

140
test/test_i2c_example.cpp Normal file
View File

@@ -0,0 +1,140 @@
#include <gtest/gtest.h>
#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<int16_t>(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<uint8_t>((raw >> 8) & 0xFF);
buf[1] = static_cast<uint8_t>(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<uint8_t>(hal->i2cRead());
uint8_t lsb = static_cast<uint8_t>(hal->i2cRead());
// Convert 12-bit left-aligned to temperature
int16_t raw = (static_cast<int16_t>(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);
}