A Smart Irrigation System


Intro

 

My wife loves plants and food, and gardens tend to be her happy place. The thing is, it's also the happy place for all sorts of animals (especially deer) who want to gobble it first. So, over the course of an extremely crazy year and a half, I eventually managed to find the time to build an epic garden. But, I can't just have a project go without some sort of techy addition, so I built an automated irrigation system to go along with it.

 

The goal of the smart irrigation is to connect to a rain barrel that I've attached to the gutters. Based on the weather, we automatically open a valve that is connected to a pvc irrigation system, that is then connected to a drip irrigation hose.

 

image.png

 

 

The Hardware

 

 

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
    

 

20251017_145751.jpg

 

I've grown increasingly fond of the ESP32 as my go-to arduino. Even though whoever decided the order of the pins (eg. D5, D18, D19, D21...???) had to have been on some sort of substance, it just works reliably and lets me put a whole lot of code on it.

 

But, even though one of the upsides is that it's wifi enabled, projects that go in the yard tend to have a tough time with getting wifi signal. Where I'm putting mine in particular is on the opposite end of the house from where the wifi is, so getting any signal pretty much just doesn't happen. So to keep this all working reliably, I'm using a Notecarrier-F from Blues Wireless to make calls anytime wifi fails (which is realistically going to be most times).

 

The other key hardware is the valve. I tried some other valves and they didn't work, and for no apparent reason at that. I should probably add an affiliate link here since I can attest that the valve I did get does just work, but instead I'll be a goofball and miss out on those several pennies and just provide a picture of what I got:

 

image.png

 

We do still need to connect the valve to the arduino, so for that we need one last component. I'm using a L298N Motor Drive Controller Board. This was a rare wonderful moment where I've been doing these nerdy projects long enough that I happen to have some on hand, but also managed to find them (that's the miraculous part).

 

20250921_093818.jpg

 

For those following along, here is the pinout:

ESP32 D18 → L298N IN3

ESP32 D19 → L298N IN4

12V power - L298N

GND of the 12V power and the ESP32 GND - L298N GND

Ball Valve Yellow → L298N GND

Ball Valve Red → OUT3

Ball Valve Blue → OUT4

ESP32 D21 (SDA) → SDA on Notecarrier-F

ESP32 D22 (SCL) → SCL on Notecarrier-F

ESP32 GND → GND Notecarrier-F

and I have the ESP32 and Notecarrier-F each powered separately via usb.

 

 

Code!

 

There is a lot of code here, but it can be summed up relatively concisely. The main goal of all of this is to fetch weather data and decide whether to water accordingly.

For the most part, we aim to water every other day. If it rained a decent amount yesterday or there is a good chance that it rained today, skip watering today. If it is extra hot, water every day. For weather data, I tried something different than in previous projects and it seemed to work well, which is Open Meteo.

 

We let the device sleep when not in use, and it wakes up in the morning to fetch the weather data. We try to use wifi first, and when that fails we use Blues Wireless.

 

I added a couple of test fields that are pretty handy for ensuring everything works, which are the IGNORE_ALREADY_WATERED_TODAY and FORCE_NOTECARD flags. They're pretty straightforward based on the names, but if we're testing everything with weather fetches to ensure the valve opens and closes properly more than ones, we can ignore that we've already watered today. In configuring blues, we can force the code to use the notecard. This is particularly helpful, since the ESP32 will realistically be able to successfully make the wifi calls while inside, while we should expect the calls to fail outside - so we need to ensure we aren't using wifi while testing once we know that part works.

 

image.png

 

 

Blues Wireless

 

For anyone less familiar with Blues, this is a good way to send or receive data when we might not have other connectivity. I tend to use it as a go-to for my projects that will be too far away from wifi.

 

The step one if you're new to Blues is to follow through their Quickstart (found here) guide, which walks you through configuring everything and getting up and running. Once your project is setup, just follow the below parts and you'll be good to go.

For the Routes in this project, we're using the Proxy for Notecard Web Requests option, seen below:

image.png

 

For my setup what I found worked well was to setup 2 routes in the Blues project. One fetches the data for yesterday and one fetches todays data. I find what works well for me to be including the fields in the Blues route URLs, so they're a bit long, but both the ones you'll need are below. I labeled my routes meteoY and meteoT (for Yesterday and Today, using the meteo api).

 

the meteoY URL:

https://api.open-meteo.com/v1/forecast?latitude=[.body.lat]&longitude=[.body.lon]&daily=precipitation_sum&timezone=[.body.tz]&start_date=[.body.start]&end_date=[.body.end]

 

The meteoT URL:

https://api.open-meteo.com/v1/forecast?latitude=[.body.lat]&longitude=[.body.lon]&daily=precipitation_probability_max,temperature_2m_max,weathercode&timezone=[.body.tz]&start_date=[.body.start]&end_date=[.body.end]

image.png

 

With that, we have everything we need setup for the code we already discussed to work in full.

 

 

Extra Credit - an Android App

image.png

 

Every now and then we get an inkling that we should just do "one more thing real quick". The thinking was that making a call to the arduino via a url in something like a web browser is easy, so I'd just basically be making it look nicer.

 

The app's purpose is to give the ability to control the valve directly, where we can open, close, or open then close (as in one button press opens, waits, then closes it) the valve.

It's not the world's fanciest app by any stretch, but it does work in full and it's included in the project. The easiest way to make this work consistently was to just use the ESP32's ip address, which you can lock in to keep it consistent with your wifi network. For example, I'm using a Google Wifi setup, and to keep the ip address consistent I was able to just do that in the Google Home app.

 

In retrospect, the issue with this idea is that, as discussed, the setup is a bit too far for the wifi to reach. Since this app is setup up to work over wifi it does work in theory but in practice this was a bit of a silly add-on, at least for my setup. If you're following along and your setup is within range of your wifi, you're welcome! Thankfully, I don't see much of a need to open and close the valve manually. If we feel the need to water more than the rain barrel is doing automatically, we'll just use the hose.

 

 

Done!

 

 

image.png

This project overall (including the garden build) took quite a bit of work to make happen so it's very satisfying to see it all done and working. The electronics work in full and are a set it and forget it type solution that should help keep the garden alive while our attention is focused on things like keeping our kids alive instead, so hopefully all the upfront effort pays off.

 

Hope you enjoyed and have a good one.

CODE
#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 */
}
icon smart-irrigation_GgazQEPmid.zip 4.07MB Download(0)
License
All Rights
Reserved
licensBg
0