diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9e7d6b8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +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 + - name: Install project dependencies + run: | + python -m pip install -e . + - name: Run logic tests + run: | + if [ -d tests ]; then python -m pytest tests/ -v; fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 496ee2c..4785022 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ -.DS_Store \ No newline at end of file +.DS_Store +# Auto-added by Marisol pipeline +node_modules/ +__pycache__/ +*.pyc +.pytest_cache/ +*.o +*.so +.env +debug_*.py +.cache/ +dist/ +build/ +*.egg-info/ diff --git a/MARISOL.md b/MARISOL.md new file mode 100644 index 0000000..29422d8 --- /dev/null +++ b/MARISOL.md @@ -0,0 +1,18 @@ +# MARISOL.md — Pipeline Context for in-plants + +## Project Overview +IoT device for monitoring houseplant humidity using Particle Mesh hardware. + +## Build & Run +- **Language**: c +- **Framework**: none +- **Docker image**: solarbotics/arduino-cli-arduino-avr +- **Install deps**: `pip install --no-cache-dir --break-system-packages pytest 2>&1 | tail -3; arduino-cli core update-index 2>&1 | tail -3 || true` +- **Run**: (see source code) + +## Testing +- **Test framework**: none +- **Test command**: `none` +- **Hardware mocks needed**: yes (arduino) +- **Last result**: 23/23 passed + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..53aba3b --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..b3d25ea --- /dev/null +++ b/tests/test_example.py @@ -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 diff --git a/tests/test_inplants.py b/tests/test_inplants.py new file mode 100644 index 0000000..4441ee3 --- /dev/null +++ b/tests/test_inplants.py @@ -0,0 +1,241 @@ +"""Tests for in-plants Arduino project. + +This project monitors houseplant humidity using Particle Mesh hardware. +Since Arduino .ino files cannot be imported in Python, we re-implement +the core algorithms and test them. +""" + +import pytest + + +# Re-implement the moisture percentage calculation from Arduino +# Formula: moisture_percentage = 100 - ((moisture_analog / 4095.00) * 100) +def calculate_moisture_percentage(moisture_analog: int) -> float: + """Calculate moisture percentage from analog sensor reading. + + The capacitive moisture sensor returns values 0-4095. + Higher analog values mean drier soil (less moisture). + Formula: 100 - ((analog / 4095.00) * 100) + + Args: + moisture_analog: Analog reading from sensor (0-4095) + + Returns: + Moisture percentage (0-100) + """ + return 100 - ((moisture_analog / 4095.00) * 100) + + +# Re-implement the battery voltage calculation +def calculate_battery_voltage(batt_analog: int) -> float: + """Calculate battery voltage from analog reading. + + Args: + batt_analog: Analog reading from battery sensor + + Returns: + Battery voltage in volts + """ + return batt_analog * 0.0011224 + + +# Re-implement the sleep logic decision from Xenon firmware +def should_use_sleep_mode(battery_level: int) -> bool: + """Determine if device should use sleep mode. + + Based on Xenon firmware: if BATT > 1, use sleep mode. + + Args: + battery_level: Battery sensor reading + + Returns: + True if sleep mode should be used + """ + return battery_level > 1 + + +class TestMoisturePercentage: + """Tests for moisture percentage calculation.""" + + def test_max_analog_returns_zero_percentage(self): + """When analog is max (4095), moisture should be 0%.""" + result = calculate_moisture_percentage(4095) + assert result == 0.0 + + def test_min_analog_returns_max_percentage(self): + """When analog is min (0), moisture should be 100%.""" + result = calculate_moisture_percentage(0) + assert result == 100.0 + + def test_half_analog_returns_half_percentage(self): + """When analog is half (2047.5), moisture should be 50%.""" + result = calculate_moisture_percentage(2047) + # 100 - (2047/4095 * 100) = 100 - 49.987... ≈ 50.01 + assert 49.9 < result < 50.1 + + def test_quarter_analog_returns_high_percentage(self): + """When analog is low (1024), moisture should be high (~75%).""" + result = calculate_moisture_percentage(1024) + expected = 100 - ((1024 / 4095.00) * 100) + assert abs(result - expected) < 0.01 + + def test_three_quarter_analog_returns_low_percentage(self): + """When analog is high (3072), moisture should be low (~25%).""" + result = calculate_moisture_percentage(3072) + expected = 100 - ((3072 / 4095.00) * 100) + assert abs(result - expected) < 0.01 + + def test_boundary_values(self): + """Test edge cases at boundaries.""" + assert calculate_moisture_percentage(0) == 100.0 + assert calculate_moisture_percentage(4095) == 0.0 + + def test_negative_analog_handling(self): + """Test behavior with negative analog (shouldn't happen but test anyway).""" + result = calculate_moisture_percentage(-1) + # Formula would give > 100, which indicates dry sensor error + assert result > 100.0 + + def test_analog_over_4095_handling(self): + """Test behavior with analog over 4095 (sensor error).""" + result = calculate_moisture_percentage(5000) + # Formula would give negative, which indicates wet sensor error + assert result < 0.0 + + +class TestBatteryVoltage: + """Tests for battery voltage calculation.""" + + def test_zero_analog_returns_zero_voltage(self): + """When analog is 0, voltage should be 0.""" + result = calculate_battery_voltage(0) + assert result == 0.0 + + def test_max_analog_returns_max_voltage(self): + """When analog is 4095, voltage should be ~4.6V.""" + result = calculate_battery_voltage(4095) + expected = 4095 * 0.0011224 + assert abs(result - expected) < 0.001 + + def test_half_analog_returns_half_voltage(self): + """When analog is 2047, voltage should be ~2.3V.""" + result = calculate_battery_voltage(2047) + expected = 2047 * 0.0011224 + assert abs(result - expected) < 0.001 + + def test_voltage_calculation_accuracy(self): + """Verify the multiplier is applied correctly.""" + test_cases = [ + (100, 0.11224), + (500, 0.5612), + (1000, 1.1224), + (2000, 2.2448), + ] + for analog, expected in test_cases: + result = calculate_battery_voltage(analog) + assert abs(result - expected) < 0.0001 + + +class TestSleepModeLogic: + """Tests for sleep mode decision logic (Xenon firmware).""" + + def test_battery_above_one_enables_sleep(self): + """When battery > 1, sleep mode should be enabled.""" + assert should_use_sleep_mode(2) is True + assert should_use_sleep_mode(10) is True + assert should_use_sleep_mode(100) is True + + def test_battery_at_one_disables_sleep(self): + """When battery == 1, sleep mode should be disabled.""" + assert should_use_sleep_mode(1) is False + + def test_battery_below_one_disables_sleep(self): + """When battery < 1, sleep mode should be disabled.""" + assert should_use_sleep_mode(0) is False + assert should_use_sleep_mode(-1) is False + + def test_boundary_case(self): + """Test exact boundary at battery = 1.""" + assert should_use_sleep_mode(1) is False + assert should_use_sleep_mode(1.0001) is True # Would be 2 in integer + + +class TestIntegration: + """Integration tests combining multiple calculations.""" + + def test_complete_sensor_reading_workflow(self): + """Test a complete workflow: read sensor, calculate percentage.""" + # Simulate reading from sensor + analog_reading = 2048 + + # Calculate moisture percentage + moisture = calculate_moisture_percentage(analog_reading) + + # Verify it's approximately 50% + assert 49.9 < moisture < 50.1 + + def test_dry_soil_scenario(self): + """Test scenario with dry soil (high analog reading).""" + analog_reading = 3500 + moisture = calculate_moisture_percentage(analog_reading) + + # Should be low moisture percentage + assert moisture < 20.0 + + def test_wet_soil_scenario(self): + """Test scenario with wet soil (low analog reading).""" + analog_reading = 500 + moisture = calculate_moisture_percentage(analog_reading) + + # Should be high moisture percentage + assert moisture > 80.0 + + def test_battery_voltage_with_sleep_decision(self): + """Test battery reading and sleep mode decision together.""" + battery_analog = 2000 + + voltage = calculate_battery_voltage(battery_analog) + use_sleep = should_use_sleep_mode(battery_analog) + + # Voltage should be ~2.24V + assert 2.2 < voltage < 2.3 + # Sleep should be enabled (2000 > 1) + assert use_sleep is True + + def test_charging_scenario(self): + """Test scenario when device is charging (low battery reading).""" + battery_analog = 0 + + voltage = calculate_battery_voltage(battery_analog) + use_sleep = should_use_sleep_mode(battery_analog) + + # Voltage should be 0 + assert voltage == 0.0 + # Sleep should be disabled (0 is not > 1) + assert use_sleep is False + + +class TestFormulaVerification: + """Verify the formulas match the Arduino implementation.""" + + def test_moisture_formula_matches_arduino(self): + """Verify moisture calculation matches Arduino formula.""" + # Arduino: float moisture_percentage = (100 - ( (moisture_analog/4095.00) * 100 ) ); + test_values = [0, 1024, 2047, 2048, 3072, 4095] + for analog in test_values: + python_result = calculate_moisture_percentage(analog) + arduino_formula = 100 - ((analog / 4095.00) * 100) + assert abs(python_result - arduino_formula) < 0.0001 + + def test_voltage_formula_matches_arduino(self): + """Verify voltage calculation matches Arduino formula.""" + # Arduino: float voltage = analogRead(BATT) * 0.0011224; + test_values = [0, 100, 500, 1000, 2000, 4095] + for analog in test_values: + python_result = calculate_battery_voltage(analog) + arduino_formula = analog * 0.0011224 + assert abs(python_result - arduino_formula) < 0.0001 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])