#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, <);
return lt.tm_hour >= h;
}
static inline bool isTodayBeforeHour(int h) {
time_t n = time(nullptr);
struct tm lt;
localtime_r(&n, <);
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 */
}
Comments