Wattplot Live ↗ GitHub ↗

Wattplot Smart Planter — Watering + Monitoring Spec (v2.4)

STALE — written for the v2.4 mini on ESP32-C3 (GPIO5 relay, GPIO4 soil, GPIO10 1-Wire, GPIO8/9 I²C, GPIO20/21 UART). The current hardware is ESP32-S3, schematic rev B (2026-08-03), with GPIO6 soil, GPIO16 1-Wire, GPIO8/18 I²C, and a DRV8871 H-bridge (U5b) on GPIO10 driving the solenoid (no relay). The watering policy (when to water, safety guards, auto-mode logic) is still mostly valid; the pin numbers and entity IDs (switch.watering_solenoid etc.) are wrong. Cross-check against:

  • Pin map: docs/pinmap.html
  • Schematic: docs/schematic.html (rev B, 2026-08-03)
  • Firmware config: firmware/wattplot.yaml
  • Live entities: docs/control.html

A future pass will regenerate this doc. Tracked in ROADMAP.md.

Overview

The mini v2.4 turns the planter into a fully-instrumented smart garden:

No internet required — all decisions are local, using thresholds in wattplot_params.py (MINI dict).


v2.3 → v2.4 changes

What v2.3 (pump + bucket) v2.4 (solenoid + tap)
Water source 5-gallon bucket, refill weekly Tap water (already pressurized)
Pump 12V peristaltic, ~0.5 L/min, $15-20 none
Reservoir Bucket, 18.9 L, $5 none
Solenoid none 12V NC, 1/4”, $10-12
Tee on cold line none 1/4” brass tee, $3
Pressure regulator none optional 5-30 PSI, $10
Total watering ~$45 ~$46 (similar)
Refill cadence Weekly in summer Never (tap water is unlimited)
Failure mode Pump run-dry → overheat Solenoid stuck closed → no water (safer)
Energy monitoring none panel V×I → kWh, battery SOC, POA

The big win: no refilling, fail-safe, and we get full energy telemetry from the panel + battery for free.


Hardware

Watering (solenoid on tap)

Component Spec Notes
Solenoid 12V DC normally-closed, 1/4” barb, ~2 GPM Held open by ~4W, fail-safe closed
Relay 1-channel 5V low-level trigger Switches 12V to solenoid, driven by ESP32 GPIO 5
Tee 1/4” brass cold-water tee Taps into existing supply line (e.g. hose bib)
Pressure regulator 5-30 PSI adjustable, 1/4” NPT Optional, recommended for drip emitter
Tubing 1/4” vinyl, food-safe Solenoid → drip emitter
Drip emitter Pressure-compensating, 2 GPH Buried 1” deep in bed soil
Power 12V from main battery Solenoid draws ~0.33A when held open

Where to tee in: the easiest is a hose bib (outdoor faucet) — the existing 3/4” thread can take a 1/4” barb adapter. Or under the kitchen sink on the cold water line. Total run is typically 5-20 ft of 1/4” tubing.

Sensors (same as v2.3)

Sensor Type Pin Use
Panel temp DS18B20 GPIO 10 (1-Wire) Telemetry only (Sunapex does its own temp derating)
Soil temp DS18B20 GPIO 10 (1-Wire) Plant health
Battery temp DS18B20 GPIO 10 (1-Wire) Safety cutoff
Soil moisture V1.2 capacitive GPIO 4 (ADC) Watering trigger
Panel V/I INA219 (I2C 0x41) GPIO 8/9 (I2C) Energy monitoring
Battery V 10k/10k divider GPIO 33 (ADC) SOC calculation
Panel tilt BMI160 (I2C 0x68) GPIO 8/9 (I2C) Tilt → POA calculation

ESP32-C3 Pin Assignments

GPIO 5  →  Solenoid relay (low-side switch)
GPIO 4  →  Soil moisture sensor (V1.2 analog out, ADC)
GPIO 10 →  1-Wire data (3× DS18B20 sensors on shared bus, 4.7k pullup)
GPIO 8  →  I2C SDA (BMI160 + INA219, motor + panel)
GPIO 9  →  I2C SCL
GPIO 20 →  (reserved) full-size MPPT UART TX — DNP on mini
GPIO 21 →  (reserved) full-size MPPT UART RX — DNP on mini
GPIO 33 →  Battery voltage ADC (10k/10k divider on PCB)
GPIO 6  →  Limit switch 0° (digital input, pullup)
GPIO 7  →  Limit switch 35° (digital input, pullup)

Firmware entities (ESPHome)

Sensors (sensing + monitoring)

Entity Type Source Notes
sensor.panel_temp_c sensor DS18B20 #1 Back of panel
sensor.soil_temp_c sensor DS18B20 #2 Buried 2” in soil
sensor.battery_temp_c sensor DS18B20 #3 On battery case
sensor.soil_moisture_pct sensor V1.2 capacitive (calibrated) 0-100%
sensor.battery_v sensor 10k/10k divider on GPIO 33 12V LiFePO4
sensor.battery_soc_pct sensor voltage_to_soc(battery_v) Lookup table
sensor.panel_voltage_v sensor INA219 bus voltage 17.3V Vmp
sensor.panel_current_a sensor INA219 current 0.58A Imp
sensor.panel_power_w sensor V × I (computed) ~10W peak
sensor.energy_today_kwh sensor Integrator, reset at midnight Daily kWh
sensor.energy_total_kwh sensor Integrator, persists across reboots Lifetime kWh
sensor.poa_irradiance_w_m2 sensor Solar position + tilt + clear-sky W/m²
sensor.panel_efficiency_pct sensor (V×I) / (POA × area) × 100 %
sensor.water_ml_today sensor Counter, reset at midnight mL
sensor.watering_events_today sensor Counter, reset at midnight count
sensor.last_watering sensor Timestamp datetime
sensor.current_tilt_deg sensor BMI160 fused with motor count degrees

Binary sensors + switches (control)

Entity Type Source
binary_sensor.solenoid_state binary_sensor GPIO 5 high = solenoid energized
binary_sensor.is_night binary_sensor panel_power_w < 0.5
switch.watering_solenoid switch GPIO 5 manual override
switch.watering_automation switch Enable/disable auto-watering

POA irradiance calculation

POA = Plane of Array irradiance — the solar power per unit area hitting the tilted panel surface. This is the true input to the panel (and the same value the plant soil sees, roughly).

The ESPHome firmware computes POA from first principles every minute:

# Pseudo-code (firmware/wattplot.yaml lambda)
def compute_poa(lat, lon, tilt, azimuth, dt):
    # 1. Solar position (NOAA simplified algorithm)
    day_of_year = dt.timetuple().tm_yday
    declination = 23.45 * sin(360/365 * (day_of_year - 81))  # degrees
    hour_angle = 15 * (dt.hour + dt.minute/60 - 12)          # degrees
    # (with longitude correction for solar time)
    altitude = asin(sin(lat)*sin(declination) +
                    cos(lat)*cos(declination)*cos(hour_angle))
    azimuth_sun = atan2(-sin(hour_angle),
                        tan(declination)*cos(lat) - sin(lat)*cos(hour_angle))
    # 2. Air mass (Kasten & Young)
    air_mass = 1 / (sin(altitude) + 0.50572*(6.07995 + altitude)**-1.6364)
    # 3. Clear-sky direct normal irradiance (Ineichen model, simplified)
    dni = 1361 * 0.7 ** (air_mass ** 0.678)  # W/m²
    # 4. Angle of incidence on tilted panel
    aoi = acos(sin(altitude)*cos(tilt) +
               cos(altitude)*sin(tilt)*cos(azimuth_sun - azimuth))
    # 5. POA = direct × cos(aoi) + diffuse × (1 + cos(tilt))/2
    dhi = 0.1 * dni  # diffuse horizontal (rough estimate)
    poa_direct = max(0, dni * cos(aoi))
    poa_diffuse = dhi * (1 + cos(tilt)) / 2
    return poa_direct + poa_diffuse  # W/m²

For Phoenix (33.45°N) at noon on the summer solstice, POA on a 0° flat panel is ~1000 W/m²; on a 35° south-tilted panel is ~920 W/m². The difference is small because the panel is nearly facing the sun at noon.

Why this matters for the plant: DLI (Daily Light Integral) is the total moles of photons the plant receives per day. The Wattplot can integrate POA over the day → DLI → plant growth metric. This becomes the foundation of the long-term “design tool” vision: input your location, get a recommendation for bed size + panel size to hit a target DLI for tomatoes (25 mol/m²/day) or herbs (12 mol/m²/day).


Battery SOC calculation

LiFePO4 voltage-to-SOC is highly nonlinear at the top and bottom of the curve, but flat in the middle. The lookup table in wattplot_params.py is the simplest accurate-enough approach:

# Pseudo-code (firmware/wattplot.yaml lambda)
def voltage_to_soc(v):
    lut = [
        (13.6, 100), (13.4, 95), (13.3, 90), (13.2, 80),
        (13.0, 60), (12.8, 40), (12.5, 20), (12.0, 10), (10.5, 0)
    ]
    if v >= lut[0][0]: return 100
    if v <= lut[-1][0]: return 0
    # Linear interpolation between table points
    for i in range(len(lut) - 1):
        v_hi, soc_hi = lut[i]
        v_lo, soc_lo = lut[i+1]
        if v_lo <= v <= v_hi:
            return soc_lo + (soc_hi - soc_lo) * (v - v_lo) / (v_hi - v_lo)
    return 0

The ESPHome version uses a select or lambda returning the interpolated value. For higher accuracy, a Coulomb counter (integrate current in/out) can be added, but for a 7Ah battery the voltage curve is good enough.


Energy integration

# Pseudo-code (1-Hz loop in firmware)
def integrate_energy():
    p = id(panel_power_w).state  # W (V × I from INA219)
    dt = 1.0  # sec
    delta_kwh = p * dt / 3600 / 1000
    today = id(energy_today_kwh).state + delta_kwh
    total = id(energy_total_kwh).state + delta_kwh
    # cap at 10 MWh to prevent float drift over years
    total = min(total, 10000)
    id(energy_today_kwh).publish(today)
    id(energy_total_kwh).publish(total)

energy_today_kwh resets at midnight (cron trigger in ESPHome). energy_total_kwh persists across reboots (stored in preferences).


Panel efficiency calculation

# Pseudo-code
def compute_efficiency():
    p = id(panel_power_w).state         # W
    poa = id(poa_irradiance_w_m2).state # W/m²
    area_m2 = 0.0697  # 13.3" × 8.1" = 0.088 m², but ~80% of cells
    if poa < 50: return 0  # noise floor at night
    return 100 * p / (poa * area_m2)

Expected efficiency for the ECO-WORTHY 10W panel: ~15-18%. If you see consistently <12%, something’s wrong (shading, soiling, bad MPPT, etc.).


Watering automation (1-Hz control loop)

# Pseudo-code (firmware/wattplot.yaml)
def watering_control():
    if not id(watering_automation).state: return  # manual mode
    if id(controller_state).state == "Folding": return  # never water while folding
    if id(solenoid_state).state: return  # already watering

    # Read state
    moisture = id(soil_moisture_pct).state
    panel_t = id(panel_temp_c).state
    bat_v = id(battery_v).state
    bat_soc = id(battery_soc_pct).state
    is_dark = id(is_night).state
    events_today = id(watering_events_today).state

    # Safety blocks
    if panel_t > 45: return  # heat stress
    if bat_v < 11.5: return  # battery too low
    if bat_soc < 20: return  # <20% SOC
    if is_dark: return  # nighttime
    if events_today >= 3: return  # daily limit

    # Decision
    if moisture < 30:  # below dry threshold
        duration_sec = 50  # 100 mL at 2 mL/sec
        run_solenoid(duration_sec)
        id(watering_events_today).publish(events_today + 1)
        id(water_ml_today).publish(water_ml_today + 100)
        log(f"Watered: moisture={moisture:.0f}%, "
            f"events_today={events_today + 1}")

Solenoid behavior:


Daily water budget

Plant needs (1 herb in 0.48 cu ft bed):

System delivery (solenoid at 2 mL/sec, 100 mL per event, max 3 events/day):

(For a larger bed or more plants, scale up: 200 mL events, 5/day, with the option to use the full solenoid_max_runtime_sec=30 per event.)


Safety logic

Check Threshold Action
Panel temp > 45°C watering_block_panel_temp_c Block (water evaporates too fast)
Battery voltage < 11.5V watering_block_battery_v Block (conserve battery)
Battery SOC < 20% watering_block_battery_soc_pct Block (low energy)
Nighttime (panel power < 0.5W) watering_block_night Block (no charging)
Solenoid runtime > 30 sec solenoid_max_runtime_sec Force off (clogged line protection)
Daily events >= 3 solenoid_max_events_per_day Block (over-watering protection)
Controller state == “Folding” (always) Block (motor current spike protection)

Hard limit: the solenoid is hard-capped at 30 seconds per event via a hardware watchdog (ESP32 timer). Even if the firmware hangs, the solenoid can’t run forever.


Manual override

User can always:


Wiring diagram (text)

                    12V LiFePO4 Battery
                          │
                          │ (12V+)
                          ├──── Solenoid + (red wire)
                          │      │
                       [Relay] ← Solenoid - (black wire)
                          │
                          │ (control coil 5V)
                          │
                       ESP32 GPIO 5
                          │
                       3.3V ── 4.7k ──┬─ 1-Wire bus
                          │           ├─ DS18B20 #1 (panel)
                          │           ├─ DS18B20 #2 (soil)
                          │           └─ DS18B20 #3 (battery)
                          │
                       GND ───────────┴─ (all sensor grounds)

                       INA219 (I2C 0x40)
                          │
                       GPIO 8/9 (I2C) ──  ESP32

                       BMI160 (I2C 0x68)
                          │
                       GPIO 8/9 (I2C) ──  ESP32 (shared bus)

                       Sunapex 10A MPPT (standalone MPPT, no host connection)
                          │
                       GPIO 20/21 (UART) ── ESP32

Solenoid power wiring:

Sensor wiring:

Plumbing (solenoid on tap water):


Build sequence (mini v2.4)

  1. Phase 1-9: build bed, frame, panel, kickstand, wire electronics (existing)
  2. Phase 10: Watering system (solenoid on tap)
    • Locate a cold water tap accessible to the bed (hose bib on patio, or 1/4” tee on under-sink cold water line)
    • Cut into the cold water line with the 1/4” tee (shut off water first, drain, install tee, restore pressure, test for leaks)
    • Run 1/4” tubing from the tee to where the solenoid will mount (typically on the bed’s east short wall, near the electronics)
    • (Optional) Install pressure regulator in the tubing run, set to ~15 PSI for the drip emitter
    • Mount solenoid on bed’s east wall at ~6” height (above any potential water splash)
    • Connect solenoid INLET to the supply tubing
    • Run 1/4” tubing from solenoid OUTLET to the bed soil, attach drip emitter, insert into soil at 1” depth
    • Wire solenoid through relay to 12V battery and ESP32 GPIO 5
    • Test: toggle switch.watering_solenoid from HA for 10 sec, verify water flows and stops
  3. Phase 11: Energy + SOC + POA monitoring
    • Already wired (INA219 on I2C, 10k/10k divider for battery V on GPIO 33)
    • Verify in Home Assistant: sensor.battery_v, sensor.battery_soc_pct, sensor.energy_today_kwh, sensor.poa_irradiance_w_m2, sensor.panel_efficiency_pct
  4. Phase 12: Test & validate (1 week)
    • Monitor soil moisture trends
    • Verify auto-watering fires when soil drops below threshold
    • Verify energy total matches expected (10W × 5 peak sun hours = ~50 Wh/day)
    • Check SOC follows the expected LiFePO4 discharge curve
    • Check POA irradiance peaks at ~1000 W/m² around solar noon
    • Adjust thresholds in wattplot_params.py if needed

Adjustable parameters (wattplot_params.py)

MINI = dict(
    # ... existing fields ...

    # Watering thresholds (solenoid on tap water)
    soil_moisture_dry_pct=30,        # below this, trigger watering
    soil_moisture_wet_pct=60,        # above this, skip watering
    solenoid_water_volume_ml_default=100,  # per event (~50 sec at 2 mL/sec)
    solenoid_max_events_per_day=3,
    solenoid_max_runtime_sec=30,     # safety watchdog
    solenoid_flow_rate_ml_per_sec=2,  # at ~15 PSI after regulator

    # Safety blocks
    watering_block_panel_temp_c=45,
    watering_block_battery_v=11.5,
    watering_block_battery_soc_pct=20,
    watering_block_night=True,

    # Battery SOC (LiFePO4 4S, voltage → %)
    battery_ah=7,
    battery_soc_lut=[(13.6, 100), (13.4, 95), (13.3, 90), (13.2, 80),
                       (13.0, 60), (12.8, 40), (12.5, 20), (12.0, 10),
                       (10.5, 0)],

    # Energy integration
    energy_integration_interval_s=1,
    energy_total_max_kwh=10000,

    # POA + efficiency
    panel_rated_efficiency_pct=18,
    # panel_area_m2 is computed in firmware from panel_L_in × panel_W_in
)

Edit these values, reflash the firmware, and the new thresholds take effect immediately. No hardware changes needed.


What’s next (v2.5+)