Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: arduino/compile-sketches@v1
with:
sketch-paths: |
- .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install -r requirements.txt 2>/dev/null || true
- name: Run logic tests
run: |
if [ -d tests ]; then python -m pytest tests/ -v --tb=short; fi
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
.DS_Store
.DS_Store
# Auto-added by Marisol pipeline
node_modules/
__pycache__/
*.pyc
.pytest_cache/
*.o
*.so
.env
debug_*.py
.cache/
dist/
build/
*.egg-info/
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Auto-generated conftest.py -- stubs for Arduino/embedded C testing.

Strategy: Arduino .ino/.cpp files can't be imported in Python, so we test by
re-implementing the core algorithms (state machines, math, protocol encoding,
parsers) in Python and verifying them with pytest. The conftest provides mock
serial and common helper dependencies.
"""
import sys
import os
import pytest
from unittest.mock import MagicMock, patch

# Add repo root to path so test files can import source modules
sys.path.insert(0, '')

# Mock common Arduino Python helper dependencies
for mod_name in ['serial', 'serial.tools', 'serial.tools.list_ports',
'pyserial', 'pyfirmata', 'pyfirmata2']:
sys.modules[mod_name] = MagicMock()


class ArduinoConstants:
"""Provides Arduino-style constants for Python re-implementations."""
HIGH = 1
LOW = 0
INPUT = 0
OUTPUT = 1
INPUT_PULLUP = 2
A0, A1, A2, A3, A4, A5 = range(14, 20)

@staticmethod
def map_value(x, in_min, in_max, out_min, out_max):
"""Arduino map() function."""
return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)

@staticmethod
def constrain(x, a, b):
"""Arduino constrain() function."""
return max(a, min(x, b))


@pytest.fixture
def arduino():
"""Provides Arduino-like constants and helpers for testing re-implemented logic."""
return ArduinoConstants()
55 changes: 55 additions & 0 deletions tests/test_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""test_example.py — Starter template for Arduino project tests.

Arduino .ino/.cpp files CANNOT be imported in Python. Instead:
1. READ the source files to understand the algorithms
2. RE-IMPLEMENT the logic in Python in this test file
3. Test the Python re-implementation with pytest

The `arduino` fixture provides constants (HIGH, LOW, A0-A5) and helpers
(map_value, constrain). Also see embedded_mocks.py for I2C/SPI/UART mocks.
DO NOT modify conftest.py.
"""
import pytest
from embedded_mocks import (
MockI2C, MockSPI, MockUART, MockGPIO, MockNeoPixel,
MockWiFi, MockBLE, MockPreferences,
arduino_map, arduino_constrain, analog_to_voltage,
)


def test_arduino_map():
"""Test Arduino map() function re-implementation."""
assert arduino_map(0, 0, 1023, 0, 255) == 0
assert arduino_map(512, 0, 1023, 0, 255) == 127
assert arduino_map(1023, 0, 1023, 0, 255) == 255


def test_arduino_constrain():
"""Test Arduino constrain() function."""
assert arduino_constrain(150, 0, 100) == 100
assert arduino_constrain(-10, 0, 100) == 0
assert arduino_constrain(50, 0, 100) == 50


def test_analog_voltage_conversion():
"""Test ADC raw value to voltage conversion."""
assert abs(analog_to_voltage(512, bits=10, ref=3.3) - 1.65) < 0.01
assert abs(analog_to_voltage(0, bits=10) - 0.0) < 0.01
assert abs(analog_to_voltage(1023, bits=10, ref=3.3) - 3.3) < 0.01


def test_serial_protocol_example():
"""Example: test a serial command protocol."""
uart = MockUART(baudrate=115200)
# Simulate sending a command
uart.write(b"AT+CMD\r\n")
assert uart._tx_log == b"AT+CMD\r\n"
# Simulate receiving a response
uart.inject_rx(b"OK\r\n")
assert uart.readline() == b"OK\r\n"


# TODO: Read the .ino source files and re-implement key algorithms below:
# def test_state_machine():
# """Re-implement the state machine from the Arduino source."""
# pass
219 changes: 219 additions & 0 deletions tests/test_inplants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Tests for in-plants Arduino firmware.

Re-implements the core moisture sensing algorithms from inplants-argon.ino
and inplants-xenon.ino for Python testing.
"""
import pytest


class MockParticle:
"""Mock Particle.publish function."""
published_events = []

@classmethod
def reset(cls):
cls.published_events = []

@classmethod
def publish(cls, name, data, ttl=60, is_private=False):
cls.published_events.append({
'name': name,
'data': data,
'ttl': ttl,
'is_private': is_private
})


def calculate_moisture_percentage(analog_value, min_moisture=300, max_moisture=900):
"""Calculate moisture percentage from analog reading.

This re-implements the logic from inplants-argon.ino and inplants-xenon.ino:
- moisture_analog = analogRead(moisturePin)
- moisture_percentage = map(moisture_analog, min_moisture, max_moisture, 100, 0)
- moisture_percentage = constrain(moisture_percentage, 0, 100)

Args:
analog_value: Raw ADC value from moisture sensor (0-1023)
min_moisture: Analog value representing 100% moisture (wet)
max_moisture: Analog value representing 0% moisture (dry)

Returns:
Moisture percentage (0-100)
"""
# Map from sensor range to percentage (inverted: wet = 100%, dry = 0%)
percentage = int((analog_value - min_moisture) * (0 - 100) / (max_moisture - min_moisture) + 100)
# Constrain to valid percentage range
percentage = max(0, min(percentage, 100))
return percentage


class MockGPIO:
"""Mock GPIO operations."""
pins = {}

@classmethod
def reset(cls):
cls.pins = {}

@classmethod
def digitalWrite(cls, pin, value):
cls.pins[pin] = value

@classmethod
def digitalRead(cls, pin):
return cls.pins.get(pin, 0)


class MockADC:
"""Mock ADC operations."""
analog_values = {}

@classmethod
def reset(cls):
cls.analog_values = {}

@classmethod
def analogRead(cls, pin):
return cls.analog_values.get(pin, 0)

@classmethod
def set_analog_value(cls, pin, value):
cls.analog_values[pin] = value


def test_moisture_percentage_wet_sensor(arduino):
"""Test moisture percentage calculation for wet sensor (min_moisture = 100%)."""
# When sensor reads min_moisture value, it should be 100% moist
assert calculate_moisture_percentage(300, 300, 900) == 100


def test_moisture_percentage_dry_sensor(arduino):
"""Test moisture percentage calculation for dry sensor (0% moisture)."""
# When sensor reads max_moisture value, it should be 0% moist
assert calculate_moisture_percentage(900, 300, 900) == 0


def test_moisture_percentage_mid_range(arduino):
"""Test moisture percentage calculation for mid-range values."""
# When sensor reads midpoint, it should be ~50% moist
# map(600, 300, 900, 100, 0) = (600-300)*(0-100)/(900-300) + 100 = 300*(-100)/600 + 100 = -50 + 100 = 50
assert calculate_moisture_percentage(600, 300, 900) == 50


def test_moisture_percentage_clamping_below_range(arduino):
"""Test that values below min_moisture are clamped to 100%."""
# Values below min_moisture should be clamped to 100%
assert calculate_moisture_percentage(200, 300, 900) == 100


def test_moisture_percentage_clamping_above_range(arduino):
"""Test that values above max_moisture are clamped to 0%."""
# Values above max_moisture should be clamped to 0%
assert calculate_moisture_percentage(1000, 300, 900) == 0


def test_moisture_percentage_extreme_values(arduino):
"""Test moisture percentage with extreme ADC values."""
# ADC value 0 (0V) should give >100%, clamped to 100%
assert calculate_moisture_percentage(0, 300, 900) == 100

# ADC value 1023 (max) should give <0%, clamped to 0%
assert calculate_moisture_percentage(1023, 300, 900) == 0


def test_moisture_percentage_custom_calibration(arduino):
"""Test moisture percentage with custom calibration values."""
# Test with different calibration values
assert calculate_moisture_percentage(500, 400, 800) == 75
assert calculate_moisture_percentage(400, 400, 800) == 100
assert calculate_moisture_percentage(800, 400, 800) == 0


def test_led_control(arduino):
"""Test LED control logic from the Arduino code."""
MockGPIO.reset()

# Turn LED on (HIGH)
MockGPIO.digitalWrite(13, 1) # boardLed = 13, HIGH = 1
assert MockGPIO.digitalRead(13) == 1

# Turn LED off (LOW)
MockGPIO.digitalWrite(13, 0) # LOW = 0
assert MockGPIO.digitalRead(13) == 0


def test_adc_reading(arduino):
"""Test ADC reading functionality."""
MockADC.reset()

# Set a specific analog value
MockADC.set_analog_value(0, 512) # A0 = 512 (mid-range)
assert MockADC.analogRead(0) == 512

# Set another value
MockADC.set_analog_value(0, 768)
assert MockADC.analogRead(0) == 768


def test_full_moisture_reading_workflow(arduino):
"""Test the complete moisture reading workflow from the Arduino code."""
MockParticle.reset()
MockGPIO.reset()
MockADC.reset()

# Simulate sensor reading (A0 for moisture sensor)
moisture_pin = 0
MockADC.set_analog_value(moisture_pin, 600)

# Read analog value
moisture_analog = MockADC.analogRead(moisture_pin)
assert moisture_analog == 600

# Calculate percentage
moisture_percentage = calculate_moisture_percentage(moisture_analog, 300, 900)
assert moisture_percentage == 50

# Simulate publishing (like Particle.publish)
MockParticle.publish("plantStatus_analog", str(moisture_analog), 60, False)
MockParticle.publish("plantStatus_percentage", str(moisture_percentage), 60, False)

# Verify events were published
assert len(MockParticle.published_events) == 2
assert MockParticle.published_events[0]['name'] == 'plantStatus_analog'
assert MockParticle.published_events[0]['data'] == '600'
assert MockParticle.published_events[1]['name'] == 'plantStatus_percentage'
assert MockParticle.published_events[1]['data'] == '50'

# Simulate LED blink
MockGPIO.digitalWrite(13, 1) # LED on
assert MockGPIO.digitalRead(13) == 1
MockGPIO.digitalWrite(13, 0) # LED off
assert MockGPIO.digitalRead(13) == 0


def test_multiple_sensor_readings(arduino):
"""Test multiple sensor readings over time."""
MockParticle.reset()
MockADC.reset()

# Simulate readings at different times
readings = [
(300, 100), # Wet (100%)
(450, 75), # Wet (75%)
(600, 50), # Medium (50%)
(750, 25), # Dry (25%)
(900, 0), # Dry (0%)
]

for analog_val, expected_pct in readings:
MockADC.set_analog_value(0, analog_val)
moisture_analog = MockADC.analogRead(0)
moisture_percentage = calculate_moisture_percentage(moisture_analog, 300, 900)

# Publish
MockParticle.publish("plantStatus_percentage", str(moisture_percentage), 60, False)

assert moisture_percentage == expected_pct

# Verify all events were published
assert len(MockParticle.published_events) == len(readings)
Loading