donutsorelse
Published © GPL3+

A Smart Irrigation System

A Smart Irrigation System that automatically waters based on the weather.

IntermediateFull instructions provided10 hours35
A Smart Irrigation System

Things used in this project

Hardware components

ESP32
Espressif ESP32
×1
Blues Notecarrier F
Blues Notecarrier F
×1
Motorized Ball Valve
×1
SparkFun Full-Bridge Motor Driver Breakout - L298N
SparkFun Full-Bridge Motor Driver Breakout - L298N
×1

Software apps and online services

Arduino IDE
Arduino IDE

Story

Read more

Custom parts and enclosures

Smart Irrigation App

This is an android app that can open and close your smart irrigation valve

Code

smart_irrigation_generified.ino

Arduino
This is the full code that makes your arduino setup automatically fetch weather data and water your garden based on the rain
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <Notecard.h>
#include <Wire.h>
#include <time.h>
#include "esp_sleep.h"
#include <WebServer.h>
#include <ESPmDNS.h>

void setupMdnsAndHttp();  // (re)start mDNS + HTTP after IP
void stopMdnsAndHttp();   // stop on disconnect
void startHttpServer();   // existing, we'll call it from setupMdnsAndHttp()

bool g_httpStarted = false;

// ===================== TEST FLAGS ======================
const bool FORCE_NOTECARD = false;                // true = use Notecard for time+weather; Wi-Fi still connects for LAN control
const bool IGNORE_ALREADY_WATERED_TODAY = false;  // pretend first run of the day

// ===================== USER CONFIG =====================
const char* WIFI_SSID = "<put your wifi here>";
const char* WIFI_PASS = "<wifi pw here>";

const double LAT = 1; //Your latitude here
const double LON = 1; //Your long here
const char* TIMEZONE = "America/New_York";

const int MORNING_CHECK_HOUR = 6;
const int IDEAL_WATER_HOUR = 7;
const int WATER_DURATION_SEC = 60;

const float YDAY_RAIN_MM_THRESHOLD = 1.0;  // mm
const int TODAY_RAIN_PROB_SKIP = 60;       // %
const int HOT_EVERY_DAY_F = 95;            // sunny-ish
const int HOT_ALWAYS_F = 98;               // any weather

// ===================== L298N (Channel B) ===============
const int IN3_PIN = 18;  // GPIO18 -> IN3 (OPEN direction)
const int IN4_PIN = 19;  // GPIO19 -> IN4 (CLOSE direction)
// Optional: ENB (enable) for Channel B. Set to GPIO or leave -1 if the ENB jumper is installed on the L298N.
const int ENB_PIN = -1;  // e.g. 23 if you wired it; -1 means unused/jumpered

const uint16_t MOVE_MS_OPEN = 7000;   // ms to fully OPEN (tune for your valve)
const uint16_t MOVE_MS_CLOSE = 7000;  // ms to fully CLOSE (tune for your valve)

// ===================== Wi-Fi & Retry ===================
const int WIFI_RETRY_ATTEMPTS = 5;
const int WIFI_RETRY_DELAY_MS = 1500;
const int DECISION_RETRY_INTERVAL_MIN = 30;
const int DECISION_RETRY_WINDOW_END_HOUR = 11;

// ===================== Notecard ========================
#define PRODUCT_UID "your blues product uid here"
Notecard notecard;

// ===================== HTTP CONTROL ====================
WebServer* http = nullptr;
unsigned long g_lastHttpMillis = 0;
const uint32_t HTTP_KEEPALIVE_MS = 120000;  // keep awake this long after last HTTP activity

// ===================== NVS / SCHEMA ====================
Preferences prefs;
const char* NVS_NAMESPACE = "irrigation";
const char* KEY_SCHEMA_VER = "schema";
const int SCHEMA_VER = 3;

const char* KEY_LAST_RUN_DAY = "lastRunDay";
const char* KEY_LAST_DECIDE_DAY = "lastDecideDay";
const char* KEY_PLAN_DAY = "planDay";
const char* KEY_PLAN_WATER = "planWater";

const char* KEY_D_SRC = "d_src";  // 0=wifi, 1=notecard
const char* KEY_D_RS = "d_reason";
const char* KEY_D_EPOCH = "d_epoch";
const char* KEY_D_YMM = "d_y_mm";
const char* KEY_D_PR = "d_prob";
const char* KEY_D_TF = "d_tmaxf";
const char* KEY_D_WC = "d_wcode";

// ===================== TYPES ===========================
struct WeatherDaily {
  float yday_precip_mm = 0.0f;
  int today_rain_prob = 0;
  float today_temp_max_f = 0.0f;
  int today_weathercode = 0;
};

enum DecisionReason : int {
  DR_HOT_ALWAYS = 1,
  DR_HOT_SUNNY,
  DR_RAINED_YDAY_SKIP,
  DR_HIGH_PROB_SKIP,
  DR_EVERY_OTHER_WATER,  // odd-day water
  DR_EVERY_OTHER_SKIP,   // even-day skip
  DR_NO_TIME_SKIP,
  DR_WEATHER_FAIL_SKIP
};

// ===================== TIME HELPERS ====================
static inline bool systemTimeValid() {
  return time(nullptr) > 1700000000;
}
String isoDate(struct tm* t) {
  char b[16];
  strftime(b, sizeof(b), "%Y-%m-%d", t);
  return String(b);
}
int todayYMD() {
  time_t n = time(nullptr);
  struct tm t;
  localtime_r(&n, &t);
  return (t.tm_year + 1900) * 10000 + (t.tm_mon + 1) * 100 + t.tm_mday;
}
String todayISO() {
  time_t n = time(nullptr);
  struct tm t;
  localtime_r(&n, &t);
  return isoDate(&t);
}
String yesterdayISO() {
  time_t n = time(nullptr) - 24 * 3600;
  struct tm t;
  localtime_r(&n, &t);
  return isoDate(&t);
}
static inline bool isTodayAtOrAfterHour(int h) {
  time_t n = time(nullptr);
  struct tm lt;
  localtime_r(&n, &lt);
  return lt.tm_hour >= h;
}
static inline bool isTodayBeforeHour(int h) {
  time_t n = time(nullptr);
  struct tm lt;
  localtime_r(&n, &lt);
  return lt.tm_hour < h;
}
uint64_t minutesToUs(int m) {
  return (uint64_t)m * 60ULL * 1000000ULL;
}
uint64_t usUntilHourToday(int h) {
  time_t n = time(nullptr);
  struct tm t;
  localtime_r(&n, &t);
  struct tm tgt = t;
  tgt.tm_hour = h;
  tgt.tm_min = 0;
  tgt.tm_sec = 0;
  time_t ts = mktime(&tgt);
  if (ts <= n) return 0;
  return (uint64_t)(ts - n) * 1000000ULL;
}
uint64_t usUntilNextOccurrence(int h) {
  time_t n = time(nullptr);
  struct tm t;
  localtime_r(&n, &t);
  struct tm tgt = t;
  if (t.tm_hour >= h) tgt.tm_mday += 1;
  tgt.tm_hour = h;
  tgt.tm_min = 0;
  tgt.tm_sec = 0;
  time_t ts = mktime(&tgt);
  return (uint64_t)(ts - n) * 1000000ULL;
}
void deepSleepForUs(uint64_t us) {
  if (us == 0) return;
  esp_sleep_enable_timer_wakeup(us);
  Serial.flush();
  esp_deep_sleep_start();
}

// ===================== CONNECTIVITY ====================
void connectWiFiWithRetries() {
  WiFi.mode(WIFI_STA);
  WiFi.setHostname("irrigator");
  Serial.println("[wifi] MAC: " + WiFi.macAddress());

  for (int i = 0; i < WIFI_RETRY_ATTEMPTS; i++) {
    WiFi.disconnect(true, true);
    delay(50);
    WiFi.begin(WIFI_SSID, WIFI_PASS);
    uint32_t s = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - s < 8000) delay(250);
    if (WiFi.status() == WL_CONNECTED) {
      Serial.print("[wifi] connected: ");
      Serial.println(WiFi.localIP());
      Serial.println("[wifi] control URL base: http://" + WiFi.localIP().toString() + "/");
      return;
    }
    Serial.println("[wifi] retry...");
    delay(WIFI_RETRY_DELAY_MS);
  }
  Serial.println("[wifi] FAILED");
}


bool httpGetWiFi(const String& url, String& out) {
  if (WiFi.status() != WL_CONNECTED) return false;
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(15000);
  if (!http.begin(client, url)) return false;
  Serial.print("[http] GET (wifi) ");
  Serial.println(url);
  int code = http.GET();
  Serial.print("[http] status: ");
  Serial.println(code);
  if (code == 200) {
    out = http.getString();
    http.end();
    return true;
  }
  http.end();
  return false;
}

// ===================== HTTP CONTROL ====================
inline void serviceHttp() {
  if (http) http->handleClient();
  // MDNS.update();
}
inline void markHttp() {
  g_lastHttpMillis = millis();
}
void maybeStayAwakeForHttp() {
  if (!http) return;
  while (millis() - g_lastHttpMillis < HTTP_KEEPALIVE_MS) {
    serviceHttp();
    delay(10);
  }
}
void startHttpServer() {
  if (WiFi.status() != WL_CONNECTED) return;

  static WebServer server(80);
  http = &server;

  server.on("/", []() {
    markHttp();
    String ip = WiFi.localIP().toString();
    String html;
    html += "<h2>Irrigation Control</h2>";
    html += "<p>Device IP: <b>" + ip + "</b></p>";
    html += "<p>Endpoints:</p><ul>";
    html += "<li><a href=\"/status\">/status</a></li>";
    html += "<li><a href=\"/valve/open?ms=" + String(MOVE_MS_OPEN) + "\">/valve/open?ms=" + String(MOVE_MS_OPEN) + "</a></li>";
    html += "<li><a href=\"/valve/close?ms=" + String(MOVE_MS_CLOSE) + "\">/valve/close?ms=" + String(MOVE_MS_CLOSE) + "</a></li>";
    html += "<li><a href=\"/valve/cycle?open_ms=" + String(MOVE_MS_OPEN) + "&water_s=" + String(WATER_DURATION_SEC) + "&close_ms=" + String(MOVE_MS_CLOSE) + "\">";
    html += "/valve/cycle?open_ms=" + String(MOVE_MS_OPEN) + "&water_s=" + String(WATER_DURATION_SEC) + "&close_ms=" + String(MOVE_MS_CLOSE) + "</a></li>";
    html += "</ul>";
    server.send(200, "text/html", html);
  });

  server.on("/status", []() {
    markHttp();
    DynamicJsonDocument doc(1024);
    doc["ip"] = WiFi.localIP().toString();
    doc["lastRunDay"] = prefs.getInt(KEY_LAST_RUN_DAY, 0);
    doc["planDay"] = prefs.getInt(KEY_PLAN_DAY, 0);
    doc["planWater"] = prefs.getBool(KEY_PLAN_WATER, false);
    doc["forceNotecard"] = FORCE_NOTECARD;
    String s;
    serializeJson(doc, s);
    server.send(200, "application/json", s);
  });

  server.on("/valve/open", []() {
    markHttp();
    uint16_t ms = (http->hasArg("ms") ? http->arg("ms").toInt() : MOVE_MS_OPEN);
    http->send(200, "text/plain", "Opening valve for " + String(ms) + " ms");
    delay(10);
    // OPEN direction
    digitalWrite(IN4_PIN, LOW);
    digitalWrite(IN3_PIN, HIGH);
    delay(5);  // H-bridge settle
    uint32_t endt = millis() + ms;
    while ((int32_t)(millis() - endt) < 0) {
      serviceHttp();
      delay(5);
    }
    // idle
    digitalWrite(IN3_PIN, LOW);
    digitalWrite(IN4_PIN, LOW);
  });
  server.on("/ping", []() {
    markHttp();
    server.send(200, "text/plain", "pong");
  });


  server.on("/valve/close", []() {
    markHttp();
    uint16_t ms = (http->hasArg("ms") ? http->arg("ms").toInt() : MOVE_MS_CLOSE);
    http->send(200, "text/plain", "Closing valve for " + String(ms) + " ms");
    delay(10);
    // CLOSE direction
    digitalWrite(IN3_PIN, LOW);
    digitalWrite(IN4_PIN, HIGH);
    delay(5);  // H-bridge settle
    uint32_t endt = millis() + ms;
    while ((int32_t)(millis() - endt) < 0) {
      serviceHttp();
      delay(5);
    }
    // idle
    digitalWrite(IN3_PIN, LOW);
    digitalWrite(IN4_PIN, LOW);
  });

  server.on("/valve/cycle", []() {
    markHttp();
    uint16_t open_ms = http->hasArg("open_ms") ? http->arg("open_ms").toInt() : MOVE_MS_OPEN;
    uint16_t close_ms = http->hasArg("close_ms") ? http->arg("close_ms").toInt() : MOVE_MS_CLOSE;
    uint32_t water_s = http->hasArg("water_s") ? http->arg("water_s").toInt() : WATER_DURATION_SEC;

    DynamicJsonDocument doc(256);
    doc["action"] = "cycle";
    doc["open_ms"] = open_ms;
    doc["water_s"] = water_s;
    doc["close_ms"] = close_ms;
    String s;
    serializeJson(doc, s);
    http->send(200, "application/json", s);

    // OPEN
    delay(10);
    digitalWrite(IN4_PIN, LOW);
    digitalWrite(IN3_PIN, HIGH);
    delay(5);
    uint32_t t1 = millis() + open_ms;
    while ((int32_t)(millis() - t1) < 0) {
      serviceHttp();
      delay(5);
    }
    digitalWrite(IN3_PIN, LOW);
    digitalWrite(IN4_PIN, LOW);

    // HOLD (watering)
    uint32_t t2 = millis() + water_s * 1000UL;
    while ((int32_t)(millis() - t2) < 0) {
      serviceHttp();
      delay(10);
    }

    // CLOSE
    digitalWrite(IN3_PIN, LOW);
    digitalWrite(IN4_PIN, HIGH);
    delay(5);
    uint32_t t3 = millis() + close_ms;
    while ((int32_t)(millis() - t3) < 0) {
      serviceHttp();
      delay(5);
    }
    digitalWrite(IN3_PIN, LOW);
    digitalWrite(IN4_PIN, LOW);
  });

  server.begin();
  Serial.println("[http] server started: http://" + WiFi.localIP().toString() + "/");
}

// ===================== WEATHER =========================
bool parseYesterday(const String& json, float& precip_mm) {
  StaticJsonDocument<8192> doc;
  if (deserializeJson(doc, json)) return false;
  if (!doc["daily"]["precipitation_sum"].is<JsonArray>()) return false;
  precip_mm = doc["daily"]["precipitation_sum"][0] | 0.0;
  if (isnan(precip_mm)) precip_mm = 0.0;
  return true;
}
bool parseToday(const String& json, int& rainProb, float& tempMaxF, int& wcode) {
  StaticJsonDocument<16384> doc;
  if (deserializeJson(doc, json)) return false;
  if (!doc["daily"].is<JsonObject>()) return false;
  rainProb = doc["daily"]["precipitation_probability_max"][0] | 0;
  float tempC = doc["daily"]["temperature_2m_max"][0] | 0.0;
  tempMaxF = tempC * 9.0 / 5.0 + 32.0;
  wcode = doc["daily"]["weathercode"][0] | 0;
  if (isnan(tempMaxF)) tempMaxF = 0.0;
  rainProb = constrain(rainProb, 0, 100);
  return true;
}

// Notehub route alias call to Open-Meteo
bool httpGetNotecardRouteMeteo(const char* alias,
                               double lat, double lon,
                               const char* tz,
                               const String& startISO, const String& endISO,
                               String& out) {
  J* req = notecard.newRequest("web.get");
  if (!req) return false;

  JAddStringToObject(req, "route", alias);
  JAddStringToObject(req, "content", "application/json");
  // If your routes are environment-scoped, uncomment:
  // JAddStringToObject(req, "environment", "development");

  // Execute now and wait for response
  JAddBoolToObject(req, "sync", true);
  JAddNumberToObject(req, "timeout", 45);

  J* b = JAddObjectToObject(req, "body");
  if (b) {
    JAddNumberToObject(b, "lat", lat);
    JAddNumberToObject(b, "lon", lon);
    JAddStringToObject(b, "tz", tz);
    JAddStringToObject(b, "start", startISO.c_str());
    JAddStringToObject(b, "end", endISO.c_str());
  }

  J* rsp = notecard.requestAndResponse(req);
  if (!rsp) return false;

  const char* err = JGetString(rsp, "err");
  if (err && *err) {
    Serial.print("[route] err: ");
    Serial.println(err);
    JDelete(rsp);
    return false;
  }

  J* bodyObj = JGetObject(rsp, "body");
  if (!bodyObj) {
    const char* raw = JConvertToJSONString(rsp);
    Serial.print("[route] unexpected rsp: ");
    Serial.println(raw ? raw : "(null)");
    JDelete(rsp);
    return false;
  }

  const char* rawBody = JConvertToJSONString(bodyObj);
  if (!rawBody) {
    Serial.println("[route] body JSON conversion failed");
    JDelete(rsp);
    return false;
  }

  out = String(rawBody);
  JDelete(rsp);
  return true;
}

bool getWeather(struct WeatherDaily& w, bool& viaNotecard) {
  const String yday = yesterdayISO();
  const String today = todayISO();

  // Wi-Fi URLs (direct Open-Meteo)
  const String urlY = "https://api.open-meteo.com/v1/forecast?latitude=" + String(LAT, 8) + "&longitude=" + String(LON, 11) + "&daily=precipitation_sum&timezone=" + String(TIMEZONE) + "&start_date=" + yday + "&end_date=" + yday;

  const String urlT = "https://api.open-meteo.com/v1/forecast?latitude=" + String(LAT, 8) + "&longitude=" + String(LON, 11) + "&daily=precipitation_probability_max,temperature_2m_max,weathercode&timezone=" + String(TIMEZONE) + "&start_date=" + today + "&end_date=" + today;

  String body;
  viaNotecard = false;

  if (FORCE_NOTECARD) {
    if (!httpGetNotecardRouteMeteo("meteoY", LAT, LON, TIMEZONE, yday, yday, body)) return false;
    if (!parseYesterday(body, w.yday_precip_mm)) return false;

    body = "";
    if (!httpGetNotecardRouteMeteo("meteoT", LAT, LON, TIMEZONE, today, today, body)) return false;
    if (!parseToday(body, w.today_rain_prob, w.today_temp_max_f, w.today_weathercode)) return false;

    viaNotecard = true;

  } else {
    if (!httpGetWiFi(urlY, body)) {
      Serial.println("[http] wifi failed; trying notecard route (yesterday)...");
      if (!httpGetNotecardRouteMeteo("meteoY", LAT, LON, TIMEZONE, yday, yday, body)) return false;
      viaNotecard = true;
    }
    if (!parseYesterday(body, w.yday_precip_mm)) return false;

    body = "";
    if (!httpGetWiFi(urlT, body)) {
      Serial.println("[http] wifi failed; trying notecard route (today)...");
      if (!httpGetNotecardRouteMeteo("meteoT", LAT, LON, TIMEZONE, today, today, body)) return false;
      viaNotecard = true;
    }
    if (!parseToday(body, w.today_rain_prob, w.today_temp_max_f, w.today_weathercode)) return false;
  }

  Serial.printf("[weather] via=%s  yday=%.1fmm  prob=%d%%  tMax=%.1fF  code=%d\n",
                viaNotecard ? "notecard" : "wifi",
                w.yday_precip_mm, w.today_rain_prob, w.today_temp_max_f, w.today_weathercode);
  return true;
}

// ===================== RULES ===========================
bool isSunnyish(int wcode) {
  return (wcode >= 0 && wcode <= 3);
}
bool isOddDay() {
  time_t n = time(nullptr);
  struct tm t;
  localtime_r(&n, &t);
  int ymd = (t.tm_year + 1900) * 10000 + (t.tm_mon + 1) * 100 + t.tm_mday;
  return (ymd % 2) == 1;  // odd days water when allowed
}
bool shouldWaterToday(const WeatherDaily& w, bool oddDay) {
  if (w.today_temp_max_f >= HOT_ALWAYS_F) return true;
  if (w.today_temp_max_f >= HOT_EVERY_DAY_F && isSunnyish(w.today_weathercode)) return true;
  if (w.yday_precip_mm >= YDAY_RAIN_MM_THRESHOLD) return false;
  if (w.today_rain_prob >= TODAY_RAIN_PROB_SKIP) return false;
  return oddDay;
}
enum DecisionReason decideReason(const WeatherDaily& w, bool oddDay, bool willWater) {
  if (w.today_temp_max_f >= HOT_ALWAYS_F) return DR_HOT_ALWAYS;
  if (w.today_temp_max_f >= HOT_EVERY_DAY_F && isSunnyish(w.today_weathercode)) return DR_HOT_SUNNY;
  if (w.yday_precip_mm >= YDAY_RAIN_MM_THRESHOLD) return DR_RAINED_YDAY_SKIP;
  if (w.today_rain_prob >= TODAY_RAIN_PROB_SKIP) return DR_HIGH_PROB_SKIP;
  return willWater ? DR_EVERY_OTHER_WATER : DR_EVERY_OTHER_SKIP;
}
const char* reasonToStr(DecisionReason r) {
  switch (r) {
    case DR_HOT_ALWAYS: return "WATER: temp >= 98F";
    case DR_HOT_SUNNY: return "WATER: hot & sunny-ish";
    case DR_RAINED_YDAY_SKIP: return "SKIP: it rained yesterday";
    case DR_HIGH_PROB_SKIP: return "SKIP: high chance of rain today";
    case DR_EVERY_OTHER_WATER: return "WATER: every-other-day (odd)";
    case DR_EVERY_OTHER_SKIP: return "SKIP: every-other-day (even)";
    case DR_NO_TIME_SKIP: return "SKIP: no valid time";
    case DR_WEATHER_FAIL_SKIP: return "SKIP: weather fetch failed";
    default: return "SKIP: unknown";
  }
}

// ===================== PERSIST & LOG ===================
bool reasonImpliesWater(int rs) {
  return rs == DR_HOT_ALWAYS || rs == DR_HOT_SUNNY || rs == DR_EVERY_OTHER_WATER;
}
void persistDecision(bool viaNotecard, const WeatherDaily& w, bool willWater, DecisionReason reason) {
  prefs.putInt(KEY_D_SRC, viaNotecard ? 1 : 0);
  prefs.putInt(KEY_D_RS, (int)reason);
  prefs.putInt(KEY_D_EPOCH, (int)time(nullptr));
  prefs.putFloat(KEY_D_YMM, w.yday_precip_mm);
  prefs.putInt(KEY_D_PR, w.today_rain_prob);
  prefs.putFloat(KEY_D_TF, w.today_temp_max_f);
  prefs.putInt(KEY_D_WC, w.today_weathercode);
}
bool snapshotLooksValid() {
  int src = prefs.getInt(KEY_D_SRC, -1);
  int rs = prefs.getInt(KEY_D_RS, -999);
  return (src == 0 || src == 1) && (rs >= 1 && rs <= DR_WEATHER_FAIL_SKIP);
}
void printPersistedDecisionSummary(const char* prefix) {
  int src = prefs.getInt(KEY_D_SRC, -1);
  int rs = prefs.getInt(KEY_D_RS, -999);
  float ymm = prefs.getFloat(KEY_D_YMM, -1.0f);
  int prob = prefs.getInt(KEY_D_PR, -1);
  float tF = prefs.getFloat(KEY_D_TF, -1000.0f);
  int wcode = prefs.getInt(KEY_D_WC, -1);
  const char* rstr = reasonToStr((DecisionReason)rs);
  Serial.printf("%s %s  (via=%s  yday=%.1fmm  prob=%d%%  tMax=%.1fF  code=%d)\n",
                prefix, rstr, (src == 1 ? "notecard" : (src == 0 ? "wifi" : "?")),
                ymm, prob, tF, wcode);
}
void reconcilePlanFromSnapshot(int today, int& planDay, bool& planWater) {
  int rs = prefs.getInt(KEY_D_RS, -999);
  if (rs < 1 || rs > DR_WEATHER_FAIL_SKIP) return;
  bool want = reasonImpliesWater(rs);
  if (planDay != today || planWater != want) {
    planDay = today;
    planWater = want;
    prefs.putInt(KEY_PLAN_DAY, planDay);
    prefs.putBool(KEY_PLAN_WATER, planWater);
    Serial.println("[plan] reconciled from snapshot");
  }
}

// ===================== VALVE (L298N) ===================
inline void valveIdle() {
  digitalWrite(IN3_PIN, LOW);
  digitalWrite(IN4_PIN, LOW);
}
void valveDriveOpen(uint16_t ms) {
  // Optional ENB (enable) HIGH
  if (ENB_PIN >= 0) digitalWrite(ENB_PIN, HIGH);
  // OPEN direction
  digitalWrite(IN4_PIN, LOW);
  digitalWrite(IN3_PIN, HIGH);
  delay(5);  // allow H-bridge to settle
  delay(ms);
  valveIdle();
}
void valveDriveClose(uint16_t ms) {
  if (ENB_PIN >= 0) digitalWrite(ENB_PIN, HIGH);
  // CLOSE direction
  digitalWrite(IN3_PIN, LOW);
  digitalWrite(IN4_PIN, HIGH);
  delay(5);
  delay(ms);
  valveIdle();
}

// ===================== NOTECARD ========================
void initNotecard() {
  Wire.begin();  // SDA=21, SCL=22
  notecard.setDebugOutputStream(Serial);
  notecard.begin(NOTE_I2C_ADDR_DEFAULT, NOTE_I2C_MAX_DEFAULT, Wire);
  J* req = notecard.newRequest("hub.set");
  if (req) {
    JAddStringToObject(req, "product", PRODUCT_UID);
    JAddStringToObject(req, "mode", "continuous");  // keep link ready for sync web.get
    // If your routes are environment-scoped, you can also:
    // JAddStringToObject(req,"environment","development");
    notecard.sendRequest(req);
  }
}
bool timeFromNotecard() {
  J* req = notecard.newRequest("card.time");
  if (!req) return false;
  J* rsp = notecard.requestAndResponse(req);
  if (!rsp) return false;
  long epoch = JGetInt(rsp, "time");
  JDelete(rsp);
  if (epoch <= 0) return false;
  struct timeval tv = { .tv_sec = epoch, .tv_usec = 0 };
  settimeofday(&tv, NULL);
  Serial.println("[time] synced via notecard");
  return true;
}

// ===================== SLEEP & FLOW ====================
void sleepUntilNextMorning() {
  maybeStayAwakeForHttp();
  deepSleepForUs(usUntilNextOccurrence(MORNING_CHECK_HOUR));
}
void sleepUntilIdealHour() {
  maybeStayAwakeForHttp();
  deepSleepForUs(usUntilHourToday(IDEAL_WATER_HOUR));
}
void sleepForRetryInterval() {
  maybeStayAwakeForHttp();
  deepSleepForUs(minutesToUs(DECISION_RETRY_INTERVAL_MIN));
}
bool withinDecisionRetryWindow() {
  return isTodayBeforeHour(DECISION_RETRY_WINDOW_END_HOUR);
}

bool ensureValidTime() {
  if (FORCE_NOTECARD) {
    Serial.println("[flag] FORCE_NOTECARD -> time via Notecard");
    return timeFromNotecard();
  }
  configTzTime(TIMEZONE, "pool.ntp.org", "time.nist.gov");
  for (int i = 0; i < 60; i++) {
    if (systemTimeValid()) {
      Serial.println("[time] synced via NTP");
      return true;
    }
    delay(250);
  }
  Serial.println("[time] NTP failed, trying notecard...");
  return timeFromNotecard();
}

void startOfDayResetForTest(int today) {
  if (!IGNORE_ALREADY_WATERED_TODAY) return;
  Serial.println("[flag] IGNORE_ALREADY_WATERED_TODAY -> clearing today's state");
  prefs.putInt(KEY_LAST_RUN_DAY, 0);
  prefs.putInt(KEY_LAST_DECIDE_DAY, 0);
  prefs.putInt(KEY_PLAN_DAY, 0);
  prefs.putBool(KEY_PLAN_WATER, false);
}

void doWaterNow() {
  Serial.println("[valve] OPEN");
  valveDriveOpen(MOVE_MS_OPEN);

  uint32_t ms = (uint32_t)WATER_DURATION_SEC * 1000UL;
  while (ms > 0) {
    serviceHttp();  // remain responsive
    delay(100);
    ms = (ms >= 100) ? (ms - 100) : 0;
  }

  Serial.println("[valve] CLOSE");
  valveDriveClose(MOVE_MS_CLOSE);

  prefs.putInt(KEY_LAST_RUN_DAY, todayYMD());
  prefs.putInt(KEY_PLAN_DAY, 0);
  prefs.putBool(KEY_PLAN_WATER, false);

  sleepUntilNextMorning();
}

void maybeMigratePrefs() {
  int ver = prefs.getInt(KEY_SCHEMA_VER, 0);
  if (ver != SCHEMA_VER) {
    Serial.println("[nvs] migrating/clearing old keys");
    prefs.clear();
    prefs.putInt(KEY_SCHEMA_VER, SCHEMA_VER);
  }
}

void setupMdnsAndHttp() {
  if (g_httpStarted) return;

  // (Re)start mDNS
  MDNS.end();  // safe even if not started
  if (MDNS.begin("irrigator")) {
    MDNS.setInstanceName("irrigator");
    MDNS.addService("http", "tcp", 80);
    MDNS.addServiceTxt("http", "tcp", "path", "/");
    Serial.println("[mdns] started: http://irrigator.local");
  } else {
    Serial.println("[mdns] FAILED to start");
  }

  startHttpServer();
  g_httpStarted = true;

  // Print absolute URLs you can click/bookmark
  String ip = WiFi.localIP().toString();
  Serial.println("[http] server:   http://" + ip + "/");
  Serial.println("[http] open:     http://" + ip + "/valve/open?ms=" + String(MOVE_MS_OPEN));
  Serial.println("[http] close:    http://" + ip + "/valve/close?ms=" + String(MOVE_MS_CLOSE));
  Serial.println("[http] cycle:    http://" + ip + "/valve/cycle?open_ms=" + String(MOVE_MS_OPEN) + "&water_s=" + String(WATER_DURATION_SEC) + "&close_ms=" + String(MOVE_MS_CLOSE));
  Serial.println("[http] mDNS:     http://irrigator.local/");
}

void stopMdnsAndHttp() {
  if (!g_httpStarted) return;
  MDNS.end();
  g_httpStarted = false;
}



void setup() {
  Serial.begin(115200);
  delay(120);

  if (FORCE_NOTECARD) Serial.println("[flag] FORCE_NOTECARD active");
  if (IGNORE_ALREADY_WATERED_TODAY) Serial.println("[flag] IGNORE_ALREADY_WATERED_TODAY active");
  WiFi.onEvent([](WiFiEvent_t e, WiFiEventInfo_t info) {
    if (e == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
      Serial.print("[wifi] got IP: ");
      Serial.println(IPAddress(info.got_ip.ip_info.ip.addr));
      setupMdnsAndHttp();  // advertise irrigator.local and (re)start HTTP
    } else if (e == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
      Serial.println("[wifi] disconnected");
      stopMdnsAndHttp();
    }
  });
  prefs.begin(NVS_NAMESPACE, false);
  maybeMigratePrefs();

  pinMode(IN3_PIN, OUTPUT);
  pinMode(IN4_PIN, OUTPUT);
  if (ENB_PIN >= 0) {
    pinMode(ENB_PIN, OUTPUT);
    digitalWrite(ENB_PIN, HIGH);
  }
  valveIdle();

  initNotecard();        // Notecard always initialized
  valveDriveClose(800);  // brief close nudge

  connectWiFiWithRetries();  // Always try Wi-Fi for local control
  // if (WiFi.status() == WL_CONNECTED) {
  //   Serial.println("[wifi] browse to: http://" + WiFi.localIP().toString() + "/");
  // }
  // startHttpServer();  // Start HTTP control if Wi-Fi is up

  if (!ensureValidTime()) {
    Serial.println("[time] no valid time; will retry in AM window or skip");
  }

  const int today = todayYMD();
  startOfDayResetForTest(today);

  int lastRun = prefs.getInt(KEY_LAST_RUN_DAY, 0);
  int lastDecide = prefs.getInt(KEY_LAST_DECIDE_DAY, 0);
  int planDay = prefs.getInt(KEY_PLAN_DAY, 0);
  bool planWater = prefs.getBool(KEY_PLAN_WATER, false);

  if (planDay == today && !snapshotLooksValid()) {
    Serial.println("[plan] stored plan had no details; recomputing now");
    planDay = 0;
    planWater = false;
    prefs.putInt(KEY_PLAN_DAY, 0);
    prefs.putBool(KEY_PLAN_WATER, false);
  }

  if (isTodayBeforeHour(MORNING_CHECK_HOUR) && lastDecide != today) {
    Serial.println("[sched] too early; sleeping to morning check");
    sleepUntilNextMorning();
  }

  if (lastDecide == today && snapshotLooksValid()) {
    reconcilePlanFromSnapshot(today, planDay, planWater);
    printPersistedDecisionSummary("[sched] already decided; summary:");
    if (planDay == today && planWater) {
      if (isTodayAtOrAfterHour(IDEAL_WATER_HOUR)) doWaterNow();
      Serial.println("[sched] sleeping to ideal hour");
      sleepUntilIdealHour();
    } else {
      Serial.println("[sched] decided skip; sleeping to next morning");
      sleepUntilNextMorning();
    }
  }

  // === Make today's decision (with retry window) ===
  while (true) {
    serviceHttp();  // keep server responsive

    if (WiFi.status() != WL_CONNECTED) {
      connectWiFiWithRetries();
      startHttpServer();
    }

    if (!systemTimeValid()) {
      Serial.println("[time] invalid; retrying");
      if (withinDecisionRetryWindow()) {
        sleepForRetryInterval();
        continue;
      }
      // conservative skip
      prefs.putInt(KEY_LAST_DECIDE_DAY, today);
      prefs.putInt(KEY_PLAN_DAY, today);
      prefs.putBool(KEY_PLAN_WATER, false);
      WeatherDaily empty{};
      persistDecision(false, empty, false, DR_NO_TIME_SKIP);
      printPersistedDecisionSummary("[decision] SKIP:");
      sleepUntilNextMorning();
    }

    WeatherDaily w;
    bool viaNotecard = false;
    if (!getWeather(w, viaNotecard)) {
      Serial.println("[weather] fetch failed");
      if (withinDecisionRetryWindow()) {
        sleepForRetryInterval();
        continue;
      }
      // conservative skip
      prefs.putInt(KEY_LAST_DECIDE_DAY, today);
      prefs.putInt(KEY_PLAN_DAY, today);
      prefs.putBool(KEY_PLAN_WATER, false);
      WeatherDaily empty{};
      persistDecision(false, empty, false, DR_WEATHER_FAIL_SKIP);
      printPersistedDecisionSummary("[decision] SKIP:");
      sleepUntilNextMorning();
    }

    const bool oddDay = isOddDay();
    const bool waterToday = shouldWaterToday(w, oddDay);
    const DecisionReason reason = decideReason(w, oddDay, waterToday);

    // persist & announce
    prefs.putInt(KEY_LAST_DECIDE_DAY, today);
    prefs.putInt(KEY_PLAN_DAY, today);
    prefs.putBool(KEY_PLAN_WATER, waterToday);
    persistDecision(viaNotecard, w, waterToday, reason);
    printPersistedDecisionSummary("[decision]");

    if (!waterToday) {
      sleepUntilNextMorning();
    }

    if (isTodayAtOrAfterHour(IDEAL_WATER_HOUR)) {
      doWaterNow();
    } else {
      Serial.println("[sched] watering planned; sleeping to ideal hour");
      sleepUntilIdealHour();
    }
  }
}

void loop() { /* unused; deep-sleep between events */
}

Credits

donutsorelse
23 projects • 24 followers
I make different stuff every week of all kinds. Usually I make funny yet useful inventions.

Comments