// SPDX-FileCopyrightText: 2026 Open Agriculture Technology Collective
// SPDX-License-Identifier: Apache-2.0
/* =============================================================================
   OAT Day/Night Cycle Timer  —  a recycling pump timer with a day pattern and a
   night pattern, set from your phone
   OpenAgricultureTechnology.com  ·  the Sketch Library
   -----------------------------------------------------------------------------
   One relay, one light sensor. While the grow lights are on it runs the day
   pattern ("water 4:00, rest 30:00"); while they are off it runs the night
   pattern ("water 2:00, rest 58:00"). Either pattern can also be Always on or
   Off. Set to the second: waterings 1 second (misting) to 12 hours, rests 5
   seconds to 12 hours.

   STANDALONE. The board raises its own Wi-Fi network (OAT-Timer-XXXXXX, password
   oatsetup until you change it). Join it and the page opens at
   http://192.168.4.1. It joins no home network and sends nothing anywhere. If
   the Wi-Fi, the phone or the page go away, the timer keeps timing.

   FAILURE WIRING. The relay's coil is OFF whenever the board is unpowered,
   booting or dead. So how the pump is wired to the relay decides what a dead
   board does to it:
     NC (common + normally closed): the pump RUNS when the coil is off. A dead
        board leaves the pump running. Towers, NFT, aeroponics: roots dry out
        fast, an overflow is unlikely.
     NO (common + normally open):   the pump STOPS when the coil is off. A dead
        board leaves the pump off. Anything that can overfill.
   The setup page asks which one you wired, and the timer drives the coil the
   right way round. "Check the wiring" runs the pump for 5 seconds so you can
   see that the setting matches the wires.

   WALL POWER. The ESP32 never touches mains. The relay module's screw terminals
   can: electricity bites, so unplug the pump and the board every time you work on
   that wiring. The page explains the ways to switch a pump, safest first.

   WIRING (classic ESP32; the IBC bucket controller board already has these)
     GPIO 16 ─────────── relay module IN (the IBC board's relay 1). Most modules
                          switch on with a LOW; that is a setting.
     GPIO 34 ─────────── light sensor: 10 kΩ from 3V3 to GPIO 34, photoresistor
                          from GPIO 34 to GND (bright reads LOW). ADC1 only (32-39),
                          because ADC2 cannot be read while Wi-Fi is on.

   NO CLOCK. Day and night come from the light sensor, so there is nothing to set
   and nothing to drift. Times on the page are shown in the phone's own clock,
   worked out from "this many seconds ago".
   ============================================================================= */

#include <oat_node_core.h>
#include <esp_task_wdt.h>
#include "timer_page.h"       // the control page (HTML + JS)

// ---------------------------------------------------------------------------
// 1. Identity
// ---------------------------------------------------------------------------
#define TIER        "oat-cycle-timer"
#define FW_SEMVER   "1.0.0"
#define FW_VERSION  "OAT-Cycle-Timer/1.0.0"
#define NVS_NS      "oattimer"

// Types the functions below take or return. Declared up here because the Arduino
// builder writes prototypes above the first function, and they name these types.
enum Phase : uint8_t { PH_RUN, PH_REST, PH_ALWAYS, PH_OFF, PH_PAUSE };

// ---------------------------------------------------------------------------
// 2. The plan (the control page). One blob in NVS, saved a few seconds after
//    the last change.
// ---------------------------------------------------------------------------
enum PatMode : uint8_t { PAT_CYCLE, PAT_ALWAYS, PAT_OFF };
enum LightChg : uint8_t { CHG_NOW, CHG_FINISH };       // at a light change: switch now, or finish the step
enum PowerUp  : uint8_t { PWR_RUN, PWR_REST };         // after power returns: water first, or rest first

static const uint32_t MIN_RUN_S  = 1;
static const uint32_t MIN_REST_S = 5;                  // never chatter a pump on and off
static const uint32_t MAX_STEP_S = 12UL * 3600UL;      // 12 hours

struct Pattern { uint8_t mode; uint32_t runS, restS; };

struct Plan {
  uint16_t magic;
  Pattern  day, night;
  uint8_t  dusk;          // lights count as OFF below this light level, %
  uint8_t  change;        // LightChg
  uint8_t  power;         // PowerUp
};
static const uint16_t PLAN_MAGIC = 0xC7C1;
static Plan plan;

static void planDefaults() {
  plan = Plan();
  plan.magic = PLAN_MAGIC;
  plan.day   = { PAT_CYCLE, 4 * 60, 30 * 60 };         // Frank's towers: 4 on / 30 off with the lights on
  plan.night = { PAT_CYCLE, 2 * 60, 58 * 60 };         //                 2 on / 58 off with them off
  plan.dusk = 30; plan.change = CHG_NOW; plan.power = PWR_RUN;
}

static bool patValid(const Pattern& p) {
  return p.mode <= PAT_OFF && p.runS >= MIN_RUN_S && p.runS <= MAX_STEP_S && p.restS >= MIN_REST_S && p.restS <= MAX_STEP_S;
}
static bool planValid(const Plan& p) {
  return patValid(p.day) && patValid(p.night) && p.dusk >= 2 && p.dusk <= 90 && p.change <= CHG_FINISH && p.power <= PWR_REST;
}

static unsigned long g_saveAt = 0;
static void planChanged() { g_saveAt = millis() + 3000; if (!g_saveAt) g_saveAt = 1; }

// ---------------------------------------------------------------------------
// 3. Hardware settings (the setup page). Stored as fields; applied in hwApply().
//    set() only validates and stores: the core loads fields BEFORE begin().
// ---------------------------------------------------------------------------
static int  g_relayPin  = 16;
static bool g_coilLow   = true;    // the module switches its coil on with a LOW (most modules, the IBC board)
static bool g_wiredNC   = true;    // pump on the NC contact: a dead board leaves it running
static int  g_ldrPin    = 34;      // -1 = no light sensor: the day pattern always
static bool g_brightLow = true;    // IBC board: bright light reads LOW on the ADC

static int  g_relayLive = -1;
static String g_hwNote;

// Classic ESP32. A relay pin must be a plain output that does not move while the
// board boots: a strapping pin clicks the relay on every power-up.
static String pinWhyNot(int p, bool needOutput) {
  if (p >= 6 && p <= 11) return "is wired to the flash chip the firmware runs from";
  if (p == 1 || p == 3)  return "is the USB console (UART0)";
  if (p == 0)            return "is the BOOT button (hold it 5 s to reset the Wi-Fi password)";
  if (p == 20 || p == 24 || (p >= 28 && p <= 31) || p > 39 || p < 0) return "does not exist on a classic ESP32";
  if (needOutput && p >= 34) return "is input-only and cannot drive a relay";
  if (needOutput && (p == 2 || p == 5 || p == 12 || p == 15))
    return "is a strapping pin: it changes level while the board boots, so the relay would click at every power-up";
  if (p == 12) return "is a strapping pin that sets flash voltage at boot";
  return "";
}

static bool parsePinOrOff(const String& v, int& out, bool allowOff, bool needOutput, bool adc1Only, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (allowOff && (s == "off" || s == "none" || s == "-1" || s == "")) { out = -1; return true; }
  if (!s.length() || !isDigit(s[0])) { why = "'" + v + "' is not a pin number" + String(allowOff ? " (or off)" : ""); return false; }
  int p = s.toInt();
  String no = pinWhyNot(p, needOutput);
  if (no.length()) { why = "GPIO " + String(p) + " " + no + "."; return false; }
  if (adc1Only && !(p == 32 || p == 33 || (p >= 34 && p <= 39))) {
    why = "GPIO " + String(p) + " is not an ADC1 pin. Use 32-39: ADC2 cannot be read while Wi-Fi is on."; return false;
  }
  out = p; return true;
}

static String pinStr(int p) { return p < 0 ? String("off") : String(p); }

static String getRelay() { return String(g_relayPin); }
static bool   setRelay(const String& v, String& why) { int p; if (!parsePinOrOff(v, p, false, true, false, why)) { why = "Relay pin: " + why; return false; } g_relayPin = p; return true; }
static String getCoil()  { return g_coilLow ? "low" : "high"; }
static bool   setCoil(const String& v, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (s == "low") g_coilLow = true; else if (s == "high") g_coilLow = false;
  else { why = "The relay switches on with must be low or high."; return false; }
  return true;
}
static String getWired() { return g_wiredNC ? "nc" : "no"; }
static bool   setWired(const String& v, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (s == "nc") g_wiredNC = true; else if (s == "no") g_wiredNC = false;
  else { why = "The pump is wired to must be nc or no."; return false; }
  return true;
}
static String getLdr()   { return pinStr(g_ldrPin); }
static bool   setLdr(const String& v, String& why) { int p; if (!parsePinOrOff(v, p, true, false, true, why)) { why = "Light pin: " + why; return false; } g_ldrPin = p; return true; }
static String getLdrDir(){ return g_brightLow ? "low" : "high"; }
static bool   setLdrDir(const String& v, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (s == "low") g_brightLow = true; else if (s == "high") g_brightLow = false;
  else { why = "Bright light reads must be low or high."; return false; }
  return true;
}

static const oatcore::Field FIELDS[] = {
  { "relay_pin", "Relay pin", "GPIO 16 on the IBC board (its relay 1). A plain output: strapping pins are refused because they click the relay at boot.", getRelay, setRelay },
  { "relay_on",  "The relay switches on with (low or high)", "low for most relay modules and the IBC board. If the relay clicks the wrong way round, swap it.", getCoil, setCoil },
  { "wired",     "The pump is wired to (nc or no)", "nc = common + normally closed: a dead board leaves the pump RUNNING (towers, NFT). no = common + normally open: a dead board leaves it OFF (anything that can overfill).", getWired, setWired },
  { "ldr_pin",   "Light sensor pin (or off)", "GPIO 34 on the IBC board. ADC1 pins only (32-39). Off = the day pattern all the time.", getLdr, setLdr },
  { "ldr_dir",   "Bright light reads (low or high)", "low on the IBC board. If the page shows Light now going UP when the lights go off, swap it.", getLdrDir, setLdrDir },
};

// ---------------------------------------------------------------------------
// 4. Runtime state
// ---------------------------------------------------------------------------
static Phase         g_phase = PH_REST;
static unsigned long g_stepStart = 0, g_stepLen = 0;  // ms; length 0 = no end (Always on / Off)
static bool          g_stepNight = false;             // the pattern this step was started from
static bool          g_pumpOn = false;                // what we are asking the pump to do right now
static unsigned long g_lastSwitch = 0;
enum Manual : uint8_t { MAN_NONE, MAN_RUN, MAN_CHECK };
static uint8_t       g_manualRun = MAN_NONE;          // this run came from Water now / Check the wiring

static unsigned long g_pauseUntil = 0;

// light sensor
static float         g_light = -1;        // 0..100 % brightness, smoothed; -1 = no sensor
static unsigned long g_lastLight = 0;
static bool          g_dark = false;
static unsigned long g_darkCandidateSince = 0;
static unsigned long g_lightChangedAt = 0;            // millis() of the last confirmed change (0 = none since power-up)
static float         g_lightLo = 101, g_lightHi = -1; // range seen since the last change, for the stuck warning
static const unsigned long DARK_CONFIRM_MS = 30000UL; // a flashlight or an opened door doesn't flip it
static const float         DARK_HYST = 8.0f;          // % above the switch point before it counts as lights on again
static const unsigned long STUCK_MS = 26UL * 3600000UL;

// counts and the log
static uint32_t g_runs = 0, g_runSecs = 0;
struct Ev { unsigned long t; uint8_t kind; uint32_t a; };
enum EvKind : uint8_t { EV_BOOT, EV_RUN, EV_LIGHTS_ON, EV_LIGHTS_OFF, EV_PAUSE, EV_RESUME, EV_GUARD, EV_CHANGED };
static const int LOG_N = 40;
static Ev   g_log[LOG_N];
static int  g_logHead = 0, g_logN = 0;

static String g_why;

static void logEv(uint8_t kind, uint32_t a = 0) {
  // A burst of setting changes is one "settings changed" line, not forty.
  int last = (g_logHead - 1 + LOG_N) % LOG_N;
  if (kind == EV_CHANGED && g_logN && g_log[last].kind == EV_CHANGED && millis() - g_log[last].t < 60000UL) {
    g_log[last].t = millis(); return;
  }
  g_log[g_logHead] = { millis(), kind, a };
  g_logHead = (g_logHead + 1) % LOG_N;
  if (g_logN < LOG_N) g_logN++;
}

// ---------------------------------------------------------------------------
// 5. Small helpers
// ---------------------------------------------------------------------------
static inline bool due(unsigned long now, unsigned long at) { return (long)(now - at) >= 0; }
static bool night() { return g_ldrPin >= 0 && g_light >= 0 && g_dark; }
static const Pattern& pat(bool isNight) { return isNight ? plan.night : plan.day; }

static String hms(uint32_t s) {
  char b[16];
  if (s >= 3600) snprintf(b, sizeof(b), "%lu:%02lu:%02lu", (unsigned long)(s / 3600), (unsigned long)(s / 60 % 60), (unsigned long)(s % 60));
  else snprintf(b, sizeof(b), "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60));
  return String(b);
}

static uint32_t clampRun(uint32_t s)  { return s < MIN_RUN_S ? MIN_RUN_S : (s > MAX_STEP_S ? MAX_STEP_S : s); }
static uint32_t clampRest(uint32_t s) { return s < MIN_REST_S ? MIN_REST_S : (s > MAX_STEP_S ? MAX_STEP_S : s); }

// ---------------------------------------------------------------------------
// 6. The relay. "Pump on" becomes a coil level through the wiring settings.
//    Write the level BEFORE making the pin an output, so the pin never spends a
//    moment at the wrong level (the IBC v3 boot blip).
// ---------------------------------------------------------------------------
static int coilLevel(bool coilOn) { return coilOn == g_coilLow ? LOW : HIGH; }
static bool coilFor(bool pumpOn) { return g_wiredNC ? !pumpOn : pumpOn; }

static void drive() {
  if (g_relayPin < 0) return;
  digitalWrite(g_relayPin, coilLevel(coilFor(g_pumpOn)));
}

static void setPump(bool on) {
  if (on != g_pumpOn) g_lastSwitch = millis();
  g_pumpOn = on;
  drive();
}

static void hwApply() {
  g_hwNote = "";
  if (g_ldrPin >= 0 && g_ldrPin == g_relayPin) { g_hwNote += "Light pin is the relay pin: light sensor off. "; g_ldrPin = -1; }
  if (g_relayLive >= 0 && g_relayLive != g_relayPin) {
    // Moving the relay: let the old pin go back to an input, where the module's own
    // pull sits the coil off (the same as a dead board).
    pinMode(g_relayLive, INPUT);
  }
  // gpio_set_level writes the output latch even before the pin is an output
  // (Arduino-ESP32 3.x refuses digitalWrite on a pin it has not claimed yet).
  gpio_set_level((gpio_num_t)g_relayPin, coilLevel(coilFor(g_pumpOn)));
  pinMode(g_relayPin, OUTPUT);
  digitalWrite(g_relayPin, coilLevel(coilFor(g_pumpOn)));
  g_relayLive = g_relayPin;
  if (g_ldrPin >= 0) { pinMode(g_ldrPin, INPUT); analogSetPinAttenuation(g_ldrPin, ADC_11db); }
  g_light = -1; g_lastLight = 0;
  Serial.printf("[timer] relay GPIO %d (on with %s, pump on %s), light %s\n", g_relayPin, getCoil().c_str(),
                g_wiredNC ? "NC" : "NO", pinStr(g_ldrPin).c_str());
}

// ---------------------------------------------------------------------------
// 7. The cycle. A step is a run or a rest; the pattern is chosen when a step
//    starts. Always on and Off are steps with no end.
// ---------------------------------------------------------------------------
static void startStep(Phase ph, bool isNight, unsigned long now) {
  const Pattern& p = pat(isNight);
  g_stepNight = isNight;
  g_stepStart = now;
  g_manualRun = MAN_NONE;
  if (p.mode == PAT_ALWAYS) { g_phase = PH_ALWAYS; g_stepLen = 0; setPump(true);  return; }
  if (p.mode == PAT_OFF)    { g_phase = PH_OFF;    g_stepLen = 0; setPump(false); return; }
  g_phase = ph;
  g_stepLen = (ph == PH_RUN ? clampRun(p.runS) : clampRest(p.restS)) * 1000UL;
  setPump(ph == PH_RUN);
}

static void endRun(unsigned long now) {
  if (g_manualRun == MAN_CHECK) return;               // a wiring check is not a watering
  uint32_t s = (now - g_stepStart + 500) / 1000;
  g_runs++; g_runSecs += s;
  logEv(EV_RUN, s);
}

// The light changed (confirmed). Switch now, or let the step finish.
static void lightChanged(unsigned long now) {
  logEv(night() ? EV_LIGHTS_OFF : EV_LIGHTS_ON);
  if (g_phase == PH_PAUSE) return;                    // the pause keeps its promise; it picks the new pattern when it ends
  const Pattern& p = pat(night());
  bool endless = g_phase == PH_ALWAYS || g_phase == PH_OFF;
  if (plan.change == CHG_NOW || endless || p.mode != PAT_CYCLE) {
    if (g_phase == PH_RUN) endRun(now);
    startStep(PH_RUN, night(), now);
  }
}

static void tick(unsigned long now) {
  if (g_phase == PH_PAUSE) {
    if (due(now, g_pauseUntil)) { logEv(EV_RESUME); startStep(PH_RUN, night(), now); }
    return;
  }
  if (g_phase == PH_ALWAYS || g_phase == PH_OFF) {
    // An endless step follows the pattern of the light it is in.
    if (pat(night()).mode != (g_phase == PH_ALWAYS ? PAT_ALWAYS : PAT_OFF)) startStep(PH_RUN, night(), now);
    return;
  }
  if (g_stepLen && now - g_stepStart >= g_stepLen) {
    if (g_phase == PH_RUN) { endRun(now); startStep(PH_REST, night(), now); }
    else startStep(PH_RUN, night(), now);
    return;
  }
  // A step can never outlast 12 hours and a minute. If one has, the timing went
  // wrong somewhere: end it, say so, and carry on with the pattern.
  if (now - g_stepStart > (MAX_STEP_S + 60) * 1000UL) {
    logEv(EV_GUARD, g_phase);
    if (g_phase == PH_RUN) endRun(now);
    startStep(g_phase == PH_RUN ? PH_REST : PH_RUN, night(), now);
  }
}

// ---------------------------------------------------------------------------
// 8. The light sensor (the Night Eyes logic: smoothed, a switch point, a gap
//    above it, and 30 seconds of agreement before it believes a change)
// ---------------------------------------------------------------------------
static void readLight(unsigned long now) {
  if (g_ldrPin < 0) { g_light = -1; g_dark = false; return; }
  if (g_lastLight && now - g_lastLight < 500) return;
  g_lastLight = now;
  int raw = analogRead(g_ldrPin);
  float pct = raw * 100.0f / 4095.0f;
  if (g_brightLow) pct = 100.0f - pct;
  bool first = g_light < 0;
  g_light = first ? pct : g_light * 0.8f + pct * 0.2f;
  if (g_light < g_lightLo) g_lightLo = g_light;
  if (g_light > g_lightHi) g_lightHi = g_light;

  bool looksDark  = g_light < plan.dusk;
  bool looksLight = g_light > fminf(plan.dusk + DARK_HYST, 99.0f);
  if (first) { g_dark = looksDark; g_darkCandidateSince = 0; return; }   // power-up: believe the first reading
  bool wantFlip = g_dark ? looksLight : looksDark;
  if (!wantFlip) { g_darkCandidateSince = 0; return; }
  if (!g_darkCandidateSince) { g_darkCandidateSince = now; return; }
  if (now - g_darkCandidateSince < DARK_CONFIRM_MS) return;
  g_dark = !g_dark; g_darkCandidateSince = 0;
  g_lightChangedAt = now; g_lightLo = 101; g_lightHi = -1;
  Serial.println(g_dark ? "[timer] lights off" : "[timer] lights on");
  lightChanged(now);
}

// No confirmed change for 26 hours: a cut wire, a sensor that can't see the lights,
// or a switch point the light never crosses. The timer carries on; the page says
// so, with the range it did see. g_lightChangedAt starts at power-up.
static bool lightStuck(unsigned long now) {
  if (g_ldrPin < 0 || g_light < 0) return false;
  return now - g_lightChangedAt >= STUCK_MS;
}

// ---------------------------------------------------------------------------
// 9. What it is doing, in words (the page shows this line)
// ---------------------------------------------------------------------------
static void describe(unsigned long now) {
  String lights = g_ldrPin < 0 ? String("No light sensor, day pattern") : (g_light < 0 ? String("Reading the light") : (night() ? String("Lights off") : String("Lights on")));
  switch (g_phase) {
    case PH_PAUSE:  g_why = "Paused · pump off · " + hms(due(now, g_pauseUntil) ? 0 : (g_pauseUntil - now + 999) / 1000) + " left"; return;
    case PH_ALWAYS: g_why = lights + " · pump on (always on)"; return;
    case PH_OFF:    g_why = lights + " · pump off (off for this pattern)"; return;
    default: break;
  }
  uint32_t left = g_stepLen > now - g_stepStart ? (g_stepLen - (now - g_stepStart) + 999) / 1000 : 0;
  if (g_phase == PH_RUN) g_why = lights + " · watering, " + hms(left) + " left of " + hms(g_stepLen / 1000) + (g_manualRun == MAN_RUN ? " (water now)" : (g_manualRun == MAN_CHECK ? " (wiring check)" : ""));
  else                   g_why = lights + " · resting, next watering in " + hms(left);
  if (g_stepNight != night() && plan.change == CHG_FINISH) g_why += " · then the " + String(night() ? "night" : "day") + " pattern";
}

// ---------------------------------------------------------------------------
// 10. Actions (the page's buttons and the Console)
// ---------------------------------------------------------------------------
static String doAction(const String& a, uint32_t arg) {
  unsigned long now = millis();
  if (a == "run" || a == "check") {
    if (g_phase == PH_RUN) endRun(now);
    if (g_phase == PH_PAUSE) logEv(EV_RESUME);
    const Pattern& p = pat(night());
    uint32_t s = a == "check" ? 5 : (p.mode == PAT_CYCLE ? p.runS : (plan.day.mode == PAT_CYCLE ? plan.day.runS : 60));
    g_phase = PH_RUN; g_stepNight = night(); g_stepStart = now; g_stepLen = clampRun(s) * 1000UL; g_manualRun = a == "check" ? MAN_CHECK : MAN_RUN;
    setPump(true);
    bool always = p.mode == PAT_ALWAYS;
    if (a == "check") return always ? "The pump runs for 5 seconds now. This pattern is Always on, so it stays on afterwards: use Pause to see it stop."
                                    : "The pump should run for 5 seconds now, then stop.";
    return "Watering now for " + hms(clampRun(s)) + ".";
  }
  if (a == "stop") {
    if (g_phase == PH_ALWAYS) return "This pattern is Always on, so the pump stays on. Use Pause to stop it for a while.";
    if (g_phase != PH_RUN) return "Not watering right now.";
    endRun(now); startStep(PH_REST, night(), now);
    if (g_phase == PH_ALWAYS) return "Stopped the watering, but this pattern is Always on, so the pump stays on. Use Pause to stop it.";
    return "Stopped. Resting until the next watering.";
  }
  if (a == "pause") {
    uint32_t m = arg < 5 ? 30 : (arg > 240 ? 240 : arg);
    if (g_phase == PH_RUN) endRun(now);
    g_phase = PH_PAUSE; g_pauseUntil = now + m * 60000UL; g_stepStart = now; g_stepLen = 0;
    setPump(false);
    logEv(EV_PAUSE, m);
    return "Paused for " + String(m) + " minutes. It resumes by itself, starting with a watering.";
  }
  if (a == "resume") {
    if (g_phase != PH_PAUSE) return "Not paused.";
    logEv(EV_RESUME); startStep(PH_RUN, night(), now);
    return "Resumed, starting with a watering.";
  }
  return "unknown action";
}

// ---------------------------------------------------------------------------
// 11. Web: the control page and its small JSON API
// ---------------------------------------------------------------------------
static String jsonEsc(const String& s) {
  String o; o.reserve(s.length() + 4);
  for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (c == '"' || c == '\\') o += '\\'; if ((uint8_t)c >= 0x20) o += c; }
  return o;
}

static String patJson(const Pattern& p) {
  return "{\"m\":" + String(p.mode) + ",\"run\":" + String(p.runS) + ",\"rest\":" + String(p.restS) + "}";
}

static String stateJson() {
  unsigned long now = millis();
  describe(now);
  String j; j.reserve(1800);
  j += "{\"fw\":\"" FW_SEMVER "\"";
  j += ",\"day\":" + patJson(plan.day) + ",\"night\":" + patJson(plan.night);
  j += ",\"dusk\":" + String(plan.dusk) + ",\"change\":" + String(plan.change) + ",\"power\":" + String(plan.power);
  j += ",\"live\":{\"why\":\"" + jsonEsc(g_why) + "\",\"pump\":" + String(g_pumpOn ? "true" : "false");
  j += ",\"phase\":" + String(g_phase) + ",\"night\":" + String(night() ? "true" : "false");
  j += ",\"light\":" + String(g_light < 0 ? -1 : (int)(g_light + 0.5f)) + ",\"ldr\":" + String(g_ldrPin >= 0 ? "true" : "false");
  j += ",\"stuck\":" + String(lightStuck(now) ? "true" : "false");
  j += ",\"lo\":" + String(g_lightHi < 0 ? -1 : (int)(g_lightLo + 0.5f)) + ",\"hi\":" + String(g_lightHi < 0 ? -1 : (int)(g_lightHi + 0.5f));
  j += ",\"nc\":" + String(g_wiredNC ? "true" : "false") + ",\"relay\":" + String(g_relayPin);
  j += ",\"runs\":" + String(g_runs) + ",\"runsecs\":" + String(g_runSecs) + ",\"up\":" + String(now);
  j += ",\"note\":\"" + jsonEsc(g_hwNote) + "\",\"log\":[";
  for (int k = 0; k < g_logN; k++) {
    const Ev& e = g_log[(g_logHead - 1 - k + LOG_N) % LOG_N];
    if (k) j += ",";
    j += "[" + String(e.t) + "," + String(e.kind) + "," + String(e.a) + "]";
  }
  j += "]}}";
  return j;
}

static uint32_t argU(const char* k, uint32_t lo, uint32_t hi, uint32_t cur) {
  if (!oatcore::web().hasArg(k)) return cur;
  long v = oatcore::web().arg(k).toInt();
  return v < (long)lo ? lo : (v > (long)hi ? hi : (uint32_t)v);
}

static void apiSet() {
  auto& w = oatcore::web();
  Plan before = plan;
  plan.day.mode    = argU("dm", 0, 2, plan.day.mode);
  plan.day.runS    = argU("drun", MIN_RUN_S, MAX_STEP_S, plan.day.runS);
  plan.day.restS   = argU("drest", MIN_REST_S, MAX_STEP_S, plan.day.restS);
  plan.night.mode  = argU("nm", 0, 2, plan.night.mode);
  plan.night.runS  = argU("nrun", MIN_RUN_S, MAX_STEP_S, plan.night.runS);
  plan.night.restS = argU("nrest", MIN_REST_S, MAX_STEP_S, plan.night.restS);
  plan.dusk        = argU("dusk", 2, 90, plan.dusk);
  plan.change      = argU("change", 0, 1, plan.change);
  plan.power       = argU("power", 0, 1, plan.power);
  if (memcmp(&plan, &before, sizeof(Plan)) != 0) {
    planChanged();
    unsigned long now = millis();
    // A pattern's mode changed under the running step: start that pattern afresh.
    // A changed time applies to the step under way, like turning the dial on a box
    // timer: shorten a rest below what has passed and the watering starts now.
    const Pattern& b = g_stepNight ? before.night : before.day;
    const Pattern& a = pat(g_stepNight);
    const Pattern& bNow = night() ? before.night : before.day;
    const Pattern& aNow = pat(night());
    bool nowMode = g_stepNight != night() && bNow.mode != aNow.mode && aNow.mode != PAT_CYCLE;
    if (g_phase != PH_PAUSE && (b.mode != a.mode || nowMode)) { if (g_phase == PH_RUN) endRun(now); startStep(PH_RUN, night(), now); }
    else if (g_phase == PH_RUN && g_manualRun == MAN_NONE && a.runS != b.runS) g_stepLen = clampRun(a.runS) * 1000UL;
    else if (g_phase == PH_REST && a.restS != b.restS) g_stepLen = clampRest(a.restS) * 1000UL;
    if (plan.dusk != before.dusk) g_darkCandidateSince = 0;
    logEv(EV_CHANGED);
  }
  w.send(200, "application/json", stateJson());
}

static void apiDo() {
  auto& w = oatcore::web();
  String r = doAction(w.arg("a"), (uint32_t)w.arg("m").toInt());
  w.send(200, "application/json", "{\"result\":\"" + jsonEsc(r) + "\"}");
}

static void apiState() { oatcore::web().send(200, "application/json", stateJson()); }

static void pageRoot() {
  String p = oatcore::pageHead();
  p += FPSTR(TIMER_PAGE);
  p += oatcore::pageFoot();
  oatcore::web().send(200, "text/html", p);
}

static void timerRoutes() {
  auto& w = oatcore::web();      // the core's own server: a sketch never starts a second one
  w.on("/",          HTTP_GET,  pageRoot);
  w.on("/api/state", HTTP_GET,  apiState);
  w.on("/api/set",   HTTP_POST, apiSet);
  w.on("/api/do",    HTTP_POST, apiDo);
}

// ---------------------------------------------------------------------------
// 12. Driver hooks
// ---------------------------------------------------------------------------
static void timerBegin() {
  planDefaults();
  Plan saved;
  if (oatcore::blobLoad("plan", &saved, sizeof(saved)) == sizeof(saved) && saved.magic == PLAN_MAGIC && planValid(saved)) plan = saved;
  // Hold the pump in its power-up state from the first moment the pin is ours.
  g_pumpOn = plan.power == PWR_RUN;
  hwApply();
  readLight(millis());                                // day or night before the first step
  logEv(EV_BOOT);
  startStep(plan.power == PWR_RUN ? PH_RUN : PH_REST, night(), millis());
  g_lightChangedAt = millis();                        // the stuck-sensor clock starts at power-up
}

// A stuck loop must not hold the pump: the loop task joins the watchdog, and a
// board that stops looping for 30 s restarts (the relay drops to its wired state
// while it does, then the timer starts again). 30 s, not the stock 5 s: a phone
// that walks out of range mid page-load can hold the web server for about 10 s,
// and that must not reboot the timer. Armed on the first loop, after networking.
static void armWatchdog() {
  esp_task_wdt_config_t c = {};
  c.timeout_ms = 30000; c.idle_core_mask = 0; c.trigger_panic = true;
  if (esp_task_wdt_reconfigure(&c) != ESP_OK) esp_task_wdt_init(&c);
  enableLoopWDT();
}

static void timerRescan() {
  bool was = night();
  hwApply();
  unsigned long now = millis();
  readLight(now);
  if (night() != was) { g_lightChangedAt = now; g_lightLo = 101; g_lightHi = -1; lightChanged(now); }
}

static void timerCollect() {
  static bool armed = false;
  if (!armed) { armed = true; armWatchdog(); }
  unsigned long now = millis();
  readLight(now);
  tick(now);
  if (g_saveAt && due(now, g_saveAt)) { g_saveAt = 0; oatcore::blobSave("plan", &plan, sizeof(plan)); }
}

static String patText(const Pattern& p) {
  if (p.mode == PAT_ALWAYS) return "always on";
  if (p.mode == PAT_OFF) return "off";
  return "water " + hms(p.runS) + " every " + hms(p.restS);
}

static String timerStatusHtml() {
  describe(millis());
  String h;
  h += "<div class='muted'>" + g_why + "</div>";
  h += "<div class='muted'>Day: " + patText(plan.day) + " &middot; Night: " + patText(plan.night) + "</div>";
  h += "<div class='muted'>Relay GPIO " + String(g_relayPin) + " (on with " + getCoil() + ") &middot; pump on " + String(g_wiredNC ? "NC: a dead board leaves it running" : "NO: a dead board leaves it off") + "</div>";
  h += "<div class='muted'>Light: " + (g_ldrPin < 0 ? String("no sensor") : "GPIO " + String(g_ldrPin) + " &middot; " + String((int)g_light) + "% &middot; " + (night() ? "lights off" : "lights on")) + "</div>";
  h += "<div class='muted'>Since power-up: " + String(g_runs) + " waterings, " + hms(g_runSecs) + " of pump time</div>";
  if (g_hwNote.length()) h += "<div class='bad'>" + g_hwNote + "</div>";
  return h;
}

static String timerStatusText() {
  describe(millis());
  String s = "timer: " + g_why + "\n";
  s += "day: " + patText(plan.day) + "  night: " + patText(plan.night) + "\n";
  s += "relay: GPIO " + String(g_relayPin) + " on=" + getCoil() + " wired=" + getWired() + " pump=" + String(g_pumpOn ? "on" : "off") + "\n";
  s += "light: " + (g_ldrPin < 0 ? String("off") : "GPIO " + String(g_ldrPin) + " " + String((int)g_light) + "% " + (night() ? "lights off" : "lights on")) +
       " switch<" + String(plan.dusk) + "%\n";
  s += "runs since power-up: " + String(g_runs) + " (" + hms(g_runSecs) + ")\n";
  if (g_hwNote.length()) s += "note: " + g_hwNote + "\n";
  return s;
}

// Console: the same actions as the page's buttons.
static void cmdRun(const String&)   { Serial.println("[timer] " + doAction("run", 0)); }
static void cmdStop(const String&)  { Serial.println("[timer] " + doAction("stop", 0)); }
static void cmdCheck(const String&) { Serial.println("[timer] " + doAction("check", 0)); }
static void cmdPause(const String& r) { Serial.println("[timer] " + doAction("pause", (uint32_t)r.toInt())); }
static void cmdResume(const String&) { Serial.println("[timer] " + doAction("resume", 0)); }

static const oatcore::Command COMMANDS[] = {
  { "run",    "water now, for the current pattern's run time",   cmdRun },
  { "stop",   "end this watering and rest",                      cmdStop },
  { "check",  "check the wiring: run the pump for 5 seconds",    cmdCheck },
  { "pause",  "pause <minutes>: pump off, resumes by itself",    cmdPause },
  { "resume", "end a pause now",                                  cmdResume },
};

static const oatcore::Driver DRIVER = {
  TIER, "The timer", FW_VERSION, FW_SEMVER, NVS_NS,
  timerBegin,
  nullptr,                           // startSample: nothing to sample on a timer
  timerCollect,                      // every loop: the light sensor and the cycle
  nullptr,                           // sampleBlocking
  timerRescan,                       // a hardware field changed: re-open the pins
  timerStatusHtml, timerStatusText,
  nullptr, nullptr,                  // diagFull, diag
  FIELDS,   (int)(sizeof(FIELDS)   / sizeof(FIELDS[0])),
  COMMANDS, (int)(sizeof(COMMANDS) / sizeof(COMMANDS[0])),
  true,                              // standalone: its own network, sends nothing
  "OAT-Timer",
  timerRoutes,
};

void setup() { oatcore::begin(DRIVER); }
void loop()  { oatcore::loop(); }
