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 61f4659462
18 changed files with 2417 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
}
}

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);
}